// Sigchain — append-only, validated chain of signed events for one primary. // Mirrors Rust's `Sigchain` exactly so the JCS bytes round-trip across impls. import { base64url } from "@scure/base"; import { sha256 } from "@noble/hashes/sha2"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { zstdCompressSync, zstdDecompressSync } from "node:zlib"; import { canonicalBytes } from "./jcs.js"; import { COMPACT_CHAIN_PREFIX, ED25519_SHA512_ALG, FORMAT_VERSION, NOSTR_SCHNORR_ALG, SIGCHAIN_EVENT_TYPE, type SigchainEventPayload, type SigchainOp, type SignedSigchainEvent, } from "./envelope.js"; import { Identity } from "./identity.js"; import { Ed25519Secret, verifyEd25519 } from "./ed25519.js"; import { NostrSecret, nostrPubkeyHex, verifySchnorr } from "./nostr.js"; import type { Signer } from "./claim.js"; export class SigchainError extends Error { readonly code: | "WrongPrimary" | "SeqMismatch" | "PrevMismatch" | "BadSignature" | "WrongEnvelopeTag" | "Empty" | "BadJsonl"; constructor(code: SigchainError["code"], message: string) { super(message); this.code = code; this.name = "SigchainError"; } } // ───────────────────────────────────────────────────────────────────────────── // Payload constructors — match Rust's SigchainEventPayload::new_add / new_revoke // ───────────────────────────────────────────────────────────────────────────── /** * Field insertion order matches Rust's struct field order so that raw * `JSON.stringify` produces byte-identical output across implementations. * Signature verification doesn't require this (JCS sorts keys), but * downstream consumers (`to_jsonl`, on-wire storage) do. */ function buildPayload( primary: Identity, seq: number, prev: string | undefined, createdAt: Date, op: SigchainOp, payload: Record, ): SigchainEventPayload { // Order matters: type, version, primary, seq, prev?, created_at, op, payload. const result = { type: SIGCHAIN_EVENT_TYPE, version: FORMAT_VERSION, primary: primary.toString(), seq, } as SigchainEventPayload; if (prev !== undefined) result.prev = prev; result.created_at = createdAt.toISOString(); result.op = op; result.payload = payload; return result; } export function newAddPayload( primary: Identity, seq: number, prev: string | undefined, subject: Identity, proofUrl: string | undefined, createdAt: Date, ): SigchainEventPayload { const payload: Record = { subject: subject.toString() }; if (proofUrl !== undefined) payload.proof_url = proofUrl; return buildPayload(primary, seq, prev, createdAt, "add", payload); } export function newRevokePayload( primary: Identity, seq: number, prev: string | undefined, subject: Identity, createdAt: Date, ): SigchainEventPayload { return buildPayload(primary, seq, prev, createdAt, "revoke", { subject: subject.toString(), }); } /** Extract the `subject` field for `add`/`revoke` payloads. */ export function eventSubject(event: SignedSigchainEvent): Identity | undefined { const s = event.payload.payload.subject; if (typeof s !== "string") return undefined; try { return Identity.parse(s); } catch { return undefined; } } // ───────────────────────────────────────────────────────────────────────────── // Sign / verify a single sigchain event // ───────────────────────────────────────────────────────────────────────────── /** * Sign a sigchain event payload. Does NOT check that `payload.primary` matches * the signer (matches Rust's `SignedSigchainEvent::sign_with`); cross-key * mismatches are caught later by `verifySigchainEvent` / `Sigchain.append`. */ export function signSigchainEvent( payload: SigchainEventPayload, signer: Signer, ): SignedSigchainEvent { if (signer instanceof Ed25519Secret) { const key = signer.identity().toString(); const jcs = canonicalBytes(payload); const sig = signer.sign(jcs); return { kez: "sigchain_event", payload, signature: { alg: ED25519_SHA512_ALG, key, sig: bytesToHex(sig) }, }; } // NostrSecret const key = `nostr:${signer.npub()}`; const digest = sha256(canonicalBytes(payload)); const sig = signer.signDigest(digest); return { kez: "sigchain_event", payload, signature: { alg: NOSTR_SCHNORR_ALG, key, sig: bytesToHex(sig) }, }; } export function verifySigchainEvent(event: SignedSigchainEvent): void { if (event.kez !== "sigchain_event") { throw new SigchainError("WrongEnvelopeTag", `expected sigchain_event, got: ${event.kez}`); } if (event.signature.key !== event.payload.primary) { throw new SigchainError( "BadSignature", `signature.key (${event.signature.key}) != payload.primary (${event.payload.primary})`, ); } const primary = Identity.parse(event.payload.primary); let sigBytes: Uint8Array; try { sigBytes = hexToBytes(event.signature.sig); } catch (e) { throw new SigchainError("BadSignature", `sig not valid hex: ${(e as Error).message}`); } if (sigBytes.length !== 64) { throw new SigchainError("BadSignature", `sig must be 64 bytes, got ${sigBytes.length}`); } switch (event.signature.alg) { case NOSTR_SCHNORR_ALG: { const pubkeyHex = nostrPubkeyHex(primary); const digest = sha256(canonicalBytes(event.payload)); if (!verifySchnorr(sigBytes, digest, pubkeyHex)) { throw new SigchainError("BadSignature", "schnorr verify failed"); } return; } case ED25519_SHA512_ALG: { if (primary.scheme !== "ed25519") { throw new SigchainError("BadSignature", `ed25519 alg requires ed25519: primary`); } const jcs = canonicalBytes(event.payload); if (!verifyEd25519(sigBytes, jcs, primary.id)) { throw new SigchainError("BadSignature", "ed25519 verify failed"); } return; } default: throw new SigchainError("BadSignature", `unsupported alg: ${event.signature.alg}`); } } /** `sha256:` of the JCS-canonicalized envelope. */ export function eventHash(event: SignedSigchainEvent): string { const bytes = canonicalBytes(event); return `sha256:${bytesToHex(sha256(bytes))}`; } // ───────────────────────────────────────────────────────────────────────────── // Sigchain — ordered, validated chain // ───────────────────────────────────────────────────────────────────────────── export class Sigchain { private readonly _primary: Identity; private readonly _events: SignedSigchainEvent[]; private constructor(primary: Identity, events: SignedSigchainEvent[]) { this._primary = primary; this._events = events; } static create(primary: Identity): Sigchain { return new Sigchain(primary, []); } get primary(): Identity { return this._primary; } get length(): number { return this._events.length; } get isEmpty(): boolean { return this._events.length === 0; } events(): readonly SignedSigchainEvent[] { return this._events; } head(): SignedSigchainEvent | undefined { return this._events[this._events.length - 1]; } headHash(): string | undefined { const h = this.head(); return h === undefined ? undefined : eventHash(h); } nextSeq(): number { const h = this.head(); return h === undefined ? 0 : h.payload.seq + 1; } /** Append a signed event after re-running the spec §6.2 integrity rules. */ append(event: SignedSigchainEvent): void { if (event.kez !== "sigchain_event") { throw new SigchainError("WrongEnvelopeTag", `expected sigchain_event, got: ${event.kez}`); } if (event.payload.primary !== this._primary.toString()) { throw new SigchainError( "WrongPrimary", `expected primary ${this._primary}, got ${event.payload.primary}`, ); } const expectedSeq = this.nextSeq(); if (event.payload.seq !== expectedSeq) { throw new SigchainError( "SeqMismatch", `expected seq ${expectedSeq}, got ${event.payload.seq}`, ); } const expectedPrev = this.headHash(); if (event.payload.prev !== expectedPrev) { throw new SigchainError( "PrevMismatch", `expected prev ${expectedPrev ?? ""}, got ${event.payload.prev ?? ""}`, ); } verifySigchainEvent(event); this._events.push(event); } /** Re-validate the entire chain from scratch. */ validate(): void { const rebuilt = Sigchain.create(this._primary); for (const e of this._events) rebuilt.append(e); } isRevoked(subject: Identity): boolean { for (let i = this._events.length - 1; i >= 0; i--) { const s = eventSubject(this._events[i]); if (s && s.equals(subject)) return this._events[i].payload.op === "revoke"; } return false; } isActive(subject: Identity): boolean { for (let i = this._events.length - 1; i >= 0; i--) { const s = eventSubject(this._events[i]); if (s && s.equals(subject)) return this._events[i].payload.op === "add"; } return false; } /** Convenience: build, sign, and append an `add` event. */ signAdd(subject: Identity, proofUrl: string | undefined, signer: Signer): SignedSigchainEvent { const payload = newAddPayload( this._primary, this.nextSeq(), this.headHash(), subject, proofUrl, new Date(), ); const signed = signSigchainEvent(payload, signer); this.append(signed); return signed; } /** Convenience: build, sign, and append a `revoke` event. */ signRevoke(subject: Identity, signer: Signer): SignedSigchainEvent { const payload = newRevokePayload( this._primary, this.nextSeq(), this.headHash(), subject, new Date(), ); const signed = signSigchainEvent(payload, signer); this.append(signed); return signed; } /** JSONL — one envelope per line. The portable bundle format. */ toJsonl(): string { return this._events.map((e) => JSON.stringify(e)).join("\n") + (this._events.length ? "\n" : ""); } /** `kez:zc1:` — single-string portable form. */ toCompactBundle(): string { const jsonl = this.toJsonl(); const compressed = zstdCompressSync(Buffer.from(jsonl, "utf8")); return ( COMPACT_CHAIN_PREFIX + base64url.encode(new Uint8Array(compressed)).replace(/=+$/, "") ); } static fromJsonl(text: string): Sigchain { const lines = text .split("\n") .map((l) => l.trim()) .filter((l) => l.length > 0); if (lines.length === 0) { throw new SigchainError("BadJsonl", "empty input"); } let first: SignedSigchainEvent; try { first = JSON.parse(lines[0]) as SignedSigchainEvent; } catch (e) { throw new SigchainError("BadJsonl", `line 0: ${(e as Error).message}`); } const chain = Sigchain.create(Identity.parse(first.payload.primary)); chain.append(first); for (let i = 1; i < lines.length; i++) { let ev: SignedSigchainEvent; try { ev = JSON.parse(lines[i]) as SignedSigchainEvent; } catch (e) { throw new SigchainError("BadJsonl", `line ${i}: ${(e as Error).message}`); } chain.append(ev); } return chain; } static fromCompactBundle(value: string): Sigchain { const trimmed = value.trim(); if (!trimmed.startsWith(COMPACT_CHAIN_PREFIX)) { throw new SigchainError( "BadJsonl", `missing ${COMPACT_CHAIN_PREFIX} prefix`, ); } const body = trimmed.slice(COMPACT_CHAIN_PREFIX.length); const padded = body + "=".repeat((4 - (body.length % 4)) % 4); let compressed: Uint8Array; try { compressed = base64url.decode(padded); } catch (e) { throw new SigchainError("BadJsonl", `base64url: ${(e as Error).message}`); } const jsonl = zstdDecompressSync(compressed); return Sigchain.fromJsonl(new TextDecoder().decode(jsonl)); } } // Lookups used by sign/verify here to keep one type the export surface. Avoids // circular imports: this module brings together Identity + crypto + envelope. export { NostrSecret, Ed25519Secret };