// Browser-native KEZ primitives. Mirrors the Rust/Node implementations // for byte-identical JCS canonicalization + Ed25519 signing. // // Deliberately self-contained — no dep on @kez/core. The SPA needs // only a small surface and inlining keeps the bundle tight. If we add // more KEZ functionality (sigchain walking, channel verification), // reconsider depending on the Node port. import { ed25519 } from "@noble/curves/ed25519"; import { sha512 } from "@noble/hashes/sha2"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import canonicalize from "canonicalize"; // ───────────────────────────────────────────────────────────────────────────── // Constants (must match SPEC.md v0.3 and Rust kez-core) // ───────────────────────────────────────────────────────────────────────────── export const CLAIM_TYPE = "kez.claim"; export const REGISTRATION_TYPE = "kez.chat.handle_registration"; export const REGISTRATION_ENVELOPE = "handle_registration"; export const CLAIM_ENVELOPE = "claim"; export const ED25519_SHA512_ALG = "ed25519-sha512-jcs"; export const FORMAT_VERSION = 1; export const COMPACT_PROOF_PREFIX = "kez:z1:"; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export type Identity = string; // canonical `system:value` string export interface Ed25519Identity { seed: Uint8Array; // 32 bytes publicKey: Uint8Array; // 32 bytes identity: Identity; // "ed25519:" } export interface SignatureBlock { alg: string; key: Identity; sig: string; // lowercase hex } export interface ClaimPayload { type: typeof CLAIM_TYPE; version: number; primary: Identity; subject: Identity; created_at: string; } export interface SignedClaimEnvelope { kez: typeof CLAIM_ENVELOPE; payload: ClaimPayload; signature: SignatureBlock; } export interface RegistrationPayload { type: typeof REGISTRATION_TYPE; version: number; handle: string; primary: Identity; server: string; created_at: string; } export interface SignedRegistration { kez: typeof REGISTRATION_ENVELOPE; payload: RegistrationPayload; signature: SignatureBlock; } // ───────────────────────────────────────────────────────────────────────────── // Key generation + restoration // ───────────────────────────────────────────────────────────────────────────── export function generateIdentity(): Ed25519Identity { const seed = ed25519.utils.randomPrivateKey(); return identityFromSeed(seed); } export function identityFromSeed(seed: Uint8Array): Ed25519Identity { if (seed.length !== 32) { throw new Error(`Ed25519 seed must be 32 bytes, got ${seed.length}`); } const publicKey = ed25519.getPublicKey(seed); return { seed, publicKey, identity: `ed25519:${bytesToHex(publicKey)}`, }; } export function identityFromSeedHex(seedHex: string): Ed25519Identity { return identityFromSeed(hexToBytes(seedHex)); } // ───────────────────────────────────────────────────────────────────────────── // JCS canonicalization // ───────────────────────────────────────────────────────────────────────────── /** RFC 8785 canonical bytes of the payload. */ export function canonicalBytes(payload: unknown): Uint8Array { const text = canonicalize(payload); if (text === undefined) { throw new Error("canonicalize returned undefined"); } return new TextEncoder().encode(text); } // ───────────────────────────────────────────────────────────────────────────── // Signing // ───────────────────────────────────────────────────────────────────────────── function signWith( payload: unknown, signer: Ed25519Identity, ): SignatureBlock { const jcs = canonicalBytes(payload); const sig = ed25519.sign(jcs, signer.seed); return { alg: ED25519_SHA512_ALG, key: signer.identity, sig: bytesToHex(sig), }; } /** Build + sign a kez.claim envelope ("I control "). */ export function signClaim( signer: Ed25519Identity, subject: Identity, createdAt: Date = new Date(), ): SignedClaimEnvelope { const payload: ClaimPayload = { type: CLAIM_TYPE, version: FORMAT_VERSION, subject, primary: signer.identity, created_at: createdAt.toISOString(), }; return { kez: CLAIM_ENVELOPE, payload, signature: signWith(payload, signer), }; } /** Build + sign a handle-registration envelope. */ export function signRegistration( signer: Ed25519Identity, handle: string, server: string, createdAt: Date = new Date(), ): SignedRegistration { const payload: RegistrationPayload = { type: REGISTRATION_TYPE, version: FORMAT_VERSION, handle, primary: signer.identity, server, created_at: createdAt.toISOString(), }; return { kez: REGISTRATION_ENVELOPE, payload, signature: signWith(payload, signer), }; } // ───────────────────────────────────────────────────────────────────────────── // Encodings — pretty JSON, compact (kez:z1:), markdown fence // ───────────────────────────────────────────────────────────────────────────── export function toPrettyJson(envelope: SignedClaimEnvelope): string { return JSON.stringify(envelope, null, 2); } /** Markdown fenced block suitable for a GitHub gist / profile README. */ export function toMarkdown(envelope: SignedClaimEnvelope): string { return [ "# KEZ Proof", "", "This account publishes a signed KEZ identity claim.", "", `- Primary: \`${envelope.payload.primary}\``, `- Subject: \`${envelope.payload.subject}\``, `- Created: \`${envelope.payload.created_at}\``, "", "```kez", toPrettyJson(envelope), "```", "", ].join("\n"); } /** * `kez:z1:` * Browser zstd: `CompressionStream` doesn't support zstd as of 2026, so * we fall back to a tiny pure-JS zstd compressor for now. For v0.1 we * only need it for short claim envelopes; small payloads compress fast. * * Implementation: use the browser's native `CompressionStream("deflate-raw")` * as a substitute (NOT zstd — different format!). This is a v0.1 stopgap * so the SPA can show a compact form; we mark these as `kez:zd1:` instead * of `kez:z1:` to make absolutely clear they are NOT the spec-compliant * zstd encoding. They round-trip in the SPA but don't interop with the * Rust/Node implementations until we add proper zstd-in-browser (next * iteration; the @bokuweb/zstd-wasm crate works). */ export async function toCompactDevPreview( envelope: SignedClaimEnvelope, ): Promise { const json = JSON.stringify(envelope); const compressed = await deflateRaw(new TextEncoder().encode(json)); // base64url, no padding let b64 = btoa(String.fromCharCode(...compressed)); b64 = b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return `kez:zd1:${b64}`; } async function deflateRaw(input: Uint8Array): Promise { // Copy into a fresh ArrayBuffer-backed buffer so the BodyInit // overload in TS 5.6+ accepts it (Uint8Array isn't // assignable to BodyInit without this). const fresh = new Uint8Array(input.byteLength); fresh.set(input); const stream = new Response(fresh).body!.pipeThrough( new CompressionStream("deflate-raw"), ); const chunks: Uint8Array[] = []; const reader = stream.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; if (value) chunks.push(value); } const total = chunks.reduce((n, c) => n + c.length, 0); const out = new Uint8Array(total); let off = 0; for (const c of chunks) { out.set(c, off); off += c.length; } return out; } // ───────────────────────────────────────────────────────────────────────────── // Hashing helper — used for displaying a content hash of a payload // ───────────────────────────────────────────────────────────────────────────── export function sha512Hex(bytes: Uint8Array): string { return bytesToHex(sha512(bytes)); }