// KEZ identifiers: always `system:value`. Mirrors Rust's `Identity` type. import { bech32 } from "@scure/base"; export class IdentityError extends Error { constructor(message: string) { super(message); this.name = "IdentityError"; } } export class Identity { /** Internal canonical form (`system:value`). */ readonly value: string; private constructor(canonical: string) { this.value = canonical; } /** Parse a KEZ identifier. Bare `npub1...` is normalized to `nostr:npub1...`. */ static parse(raw: string): Identity { const trimmed = raw.trim(); if (trimmed.length === 0) { throw new IdentityError(`empty identity: "${raw}"`); } if (trimmed.startsWith("npub1")) { validateNpub(trimmed); return new Identity(`nostr:${trimmed}`); } const colon = trimmed.indexOf(":"); if (colon <= 0 || colon === trimmed.length - 1) { throw new IdentityError(`invalid identity (need scheme:value): "${raw}"`); } const scheme = trimmed.slice(0, colon); const rest = trimmed.slice(colon + 1); if (scheme === "nostr") { validateNpub(rest); } else if (scheme === "ed25519") { validateEd25519HexShape(rest); } return new Identity(`${scheme}:${rest}`); } get scheme(): string { const i = this.value.indexOf(":"); return i < 0 ? "" : this.value.slice(0, i); } get id(): string { const i = this.value.indexOf(":"); return i < 0 ? "" : this.value.slice(i + 1); } toString(): string { return this.value; } toJSON(): string { return this.value; } equals(other: Identity): boolean { return this.value === other.value; } } /** Validate the canonical ed25519 pubkey shape (64 lowercase hex chars). */ function validateEd25519HexShape(value: string): void { if (value.length !== 64) { throw new IdentityError(`ed25519 pubkey must be 64 hex chars, got ${value.length}`); } for (let i = 0; i < value.length; i++) { const c = value.charCodeAt(i); const ok = (c >= 0x30 && c <= 0x39) || // 0-9 (c >= 0x61 && c <= 0x66); // a-f if (!ok) { throw new IdentityError(`ed25519 pubkey must be lowercase hex: ${value}`); } } } /** Validate that `npub` is a well-formed bech32 npub1 string. */ export function validateNpub(npub: string): void { try { const decoded = bech32.decode(npub as `${string}1${string}`, 1023); if (decoded.prefix !== "npub") { throw new IdentityError(`expected npub bech32, got hrp=${decoded.prefix}`); } const bytes = bech32.fromWords(decoded.words); if (bytes.length !== 32) { throw new IdentityError(`npub must decode to 32 bytes, got ${bytes.length}`); } } catch (e) { if (e instanceof IdentityError) throw e; throw new IdentityError(`invalid npub: ${(e as Error).message}`); } }