From 5ad47a917dbf035cefe3a7350639aa77cd653e9b Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Fri, 5 Jun 2026 22:43:16 -0600 Subject: [PATCH] feat(kez-chat/web): mirror local claims to chain-service sigchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user adds a claim, append an `add` event to their sigchain on the chain service (rust-sig-server); when they remove a claim, append a `revoke`. Implements SPEC.md §8 — the sigchain is now the canonical, verifiable record of what the user currently claims, not a per-claim field. • lib/sigchain-service.ts: new module that fetches the current chain to compute the next seq + prev hash, signs the event locally (the chain service never sees the seed), and POSTs. Returns a typed SigchainSyncResult so the caller can record the seq + status. • lib/kez.ts: sigchain event types + helpers (nextChainCursor, sigchainEventHash, signSigchainEvent, SignedSigchainEvent / SigchainOp types). Mirrors the Rust + Node + Python core surface. • lib/api.ts: getSigchain (GET full chain) + postSigchainEvent (POST one signed event) wrappers against the chain service. • lib/claims-store: StoredClaim gains chain_service, sigchain_seq, sigchain_status ("synced" | "error"), sigchain_error fields + setSigchainSync helper. • routes/AddClaim: on successful claim creation, fires an `add` to the chain service in the background; surfaces sync errors with a "Retry sync" button. • routes/Claims: a `revoke` is posted to the chain service first when the user removes a claim. Best-effort — if the service is unreachable, asks before dropping the local copy so the chain doesn't silently drift. Per-row "Sync to chain" button retries failed adds. Co-Authored-By: Claude Opus 4.7 --- kez-chat/web/src/lib/api.ts | 52 +++++++++- kez-chat/web/src/lib/claims-store.ts | 29 ++++++ kez-chat/web/src/lib/kez.ts | 104 +++++++++++++++++++- kez-chat/web/src/lib/sigchain-service.ts | 117 +++++++++++++++++++++++ kez-chat/web/src/routes/AddClaim.svelte | 74 +++++++++++++- kez-chat/web/src/routes/Claims.svelte | 98 ++++++++++++++++++- 6 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 kez-chat/web/src/lib/sigchain-service.ts diff --git a/kez-chat/web/src/lib/api.ts b/kez-chat/web/src/lib/api.ts index ff06f50..b73e5d2 100644 --- a/kez-chat/web/src/lib/api.ts +++ b/kez-chat/web/src/lib/api.ts @@ -3,7 +3,7 @@ import { ed25519 } from "@noble/curves/ed25519"; import { bytesToHex } from "@noble/hashes/utils"; -import type { SignedRegistration } from "./kez.js"; +import type { SignedRegistration, SignedSigchainEvent } from "./kez.js"; export interface HandleResponse { handle: string; @@ -118,3 +118,53 @@ export async function register( }); return unwrap(resp); } + +// ───────────────────────────────────────────────────────────────────────────── +// Chain service (sigchain storage server) +// +// `sigchainUrl` is the per-user base URL the chat server hands back in a +// handle lookup (`HandleResponse.sigchain_url`), e.g. +// `https://sig.kez.lat/v1/sigchains/ed25519/`. It's a different origin +// than the chat server, so these talk to it directly (it sends permissive +// CORS). The signatures are the source of truth — the chain service just +// stores them. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Fetch the full sigchain for a user as an ordered list of signed events. + * The chain service returns `application/jsonl` (one envelope per line); + * an unknown/empty chain yields an empty list. + */ +export async function getSigchain( + sigchainUrl: string, +): Promise { + const resp = await fetch(sigchainUrl); + if (resp.status === 404) return []; + if (!resp.ok) { + throw new ApiError(resp.status, `getSigchain → HTTP ${resp.status}`); + } + const text = await resp.text(); + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as SignedSigchainEvent); +} + +/** + * Append one signed event to the user's sigchain on the chain service. + * The server re-runs the full integrity check (tag, primary, seq, prev, + * signature) and returns the recorded `{ seq, hash }` on success. + */ +export async function postSigchainEvent( + sigchainUrl: string, + event: SignedSigchainEvent, +): Promise<{ seq: number; hash: string }> { + const base = sigchainUrl.replace(/\/$/, ""); + const resp = await fetch(`${base}/events`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(event), + }); + return unwrap(resp); +} diff --git a/kez-chat/web/src/lib/claims-store.ts b/kez-chat/web/src/lib/claims-store.ts index 333102c..40acdf4 100644 --- a/kez-chat/web/src/lib/claims-store.ts +++ b/kez-chat/web/src/lib/claims-store.ts @@ -17,8 +17,25 @@ export interface StoredClaim { notes?: string; /** Latest verification result, if we've checked. */ last_verify?: VerifyResult; + // ── Chain-service mirror (sigchain) ── + /** Chain-service base URL this claim was mirrored to (its sigchain URL). */ + chain_service?: string; + /** Sequence number of the sigchain `add` event for this claim. */ + sigchain_seq?: number; + /** Sync state of the sigchain mirror. */ + sigchain_status?: "synced" | "error"; + /** Error detail when sigchain_status === "error". */ + sigchain_error?: string; } +/** Fields the caller may patch after a chain-service sync attempt. */ +export type SigchainSyncPatch = Partial< + Pick< + StoredClaim, + "chain_service" | "sigchain_seq" | "sigchain_status" | "sigchain_error" + > +>; + export async function listClaims(): Promise { return (await get(KEY)) ?? []; } @@ -50,6 +67,18 @@ export async function setVerifyResult( } } +/** Record the outcome of a chain-service (sigchain) sync for a claim. */ +export async function setSigchainSync( + id: string, + patch: SigchainSyncPatch, +): Promise { + const existing = await listClaims(); + const target = existing.find((c) => c.id === id); + if (!target) return; + Object.assign(target, patch); + await set(KEY, existing); +} + export async function removeClaim(id: string): Promise { const existing = await listClaims(); await set( diff --git a/kez-chat/web/src/lib/kez.ts b/kez-chat/web/src/lib/kez.ts index ac21362..393cd59 100644 --- a/kez-chat/web/src/lib/kez.ts +++ b/kez-chat/web/src/lib/kez.ts @@ -7,7 +7,7 @@ // reconsider depending on the Node port. import { ed25519 } from "@noble/curves/ed25519"; -import { sha512 } from "@noble/hashes/sha2"; +import { sha256, sha512 } from "@noble/hashes/sha2"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import canonicalize from "canonicalize"; @@ -19,6 +19,8 @@ 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 SIGCHAIN_EVENT_TYPE = "kez.sigchain.event"; +export const SIGCHAIN_ENVELOPE = "sigchain_event"; export const ED25519_SHA512_ALG = "ed25519-sha512-jcs"; export const FORMAT_VERSION = 1; export const COMPACT_PROOF_PREFIX = "kez:z1:"; @@ -70,6 +72,33 @@ export interface SignedRegistration { signature: SignatureBlock; } +export type SigchainOp = "add" | "revoke"; + +export interface SigchainEventPayload { + type: typeof SIGCHAIN_EVENT_TYPE; + version: number; + primary: Identity; + seq: number; + /** `sha256:` of the prior envelope's JCS bytes. Omitted iff seq === 0. */ + prev?: string; + created_at: string; + op: SigchainOp; + /** Op-specific fields, e.g. `{ subject, proof_url? }`. */ + payload: Record; +} + +export interface SignedSigchainEvent { + kez: typeof SIGCHAIN_ENVELOPE; + payload: SigchainEventPayload; + signature: SignatureBlock; +} + +/** Where the next event sits in the chain: its `seq` and `prev` hash. */ +export interface ChainCursor { + seq: number; + prev?: string; +} + // ───────────────────────────────────────────────────────────────────────────── // Key generation + restoration // ───────────────────────────────────────────────────────────────────────────── @@ -137,6 +166,22 @@ function signWith( }; } +/** + * RFC 3339 UTC timestamp at SECOND precision (no fractional part), e.g. + * `2026-05-19T18:00:00Z` — matching the SPEC.md examples. + * + * Why drop the milliseconds `toISOString()` emits: the Rust kez-core + * verifier (used by the chat server and the chain service) deserializes + * `created_at` into a `chrono::DateTime` and re-serializes it to + * re-canonicalize for signature checking. Chrono's `AutoSi` seconds format + * drops a zero fractional part, so a browser-signed `…:00.000Z` would + * re-serialize as `…:00Z` and fail verification ~1 in 1000. Emitting whole + * seconds round-trips byte-stably through chrono for every timestamp. + */ +export function rfc3339Utc(date: Date = new Date()): string { + return date.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + // ───────────────────────────────────────────────────────────────────────────── // Verification // ───────────────────────────────────────────────────────────────────────────── @@ -211,7 +256,7 @@ export function signClaim( version: FORMAT_VERSION, subject, primary: signer.identity, - created_at: createdAt.toISOString(), + created_at: rfc3339Utc(createdAt), }; return { kez: CLAIM_ENVELOPE, @@ -233,7 +278,7 @@ export function signRegistration( handle, primary: signer.identity, server, - created_at: createdAt.toISOString(), + created_at: rfc3339Utc(createdAt), }; return { kez: REGISTRATION_ENVELOPE, @@ -242,6 +287,59 @@ export function signRegistration( }; } +// ───────────────────────────────────────────────────────────────────────────── +// Sigchain events (Spec §8) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * `sha256:` of the JCS-canonicalized bytes of the WHOLE signed envelope + * (not just the payload). This is what the next event's `prev` points at. + */ +export function sigchainEventHash(event: SignedSigchainEvent): string { + return `sha256:${bytesToHex(sha256(canonicalBytes(event)))}`; +} + +/** + * Given the current (validated, ordered) chain, return where the next event + * goes: `seq` one past the head, and `prev` = the head's envelope hash. + * An empty chain yields `{ seq: 0 }` with no `prev` (per spec, seq 0 has none). + */ +export function nextChainCursor(events: SignedSigchainEvent[]): ChainCursor { + if (events.length === 0) return { seq: 0 }; + const head = events[events.length - 1]; + return { seq: head.payload.seq + 1, prev: sigchainEventHash(head) }; +} + +/** + * Build + sign a sigchain event (add/revoke a subject) at the given cursor. + * Insertion order matches the Rust/Node/Python impls (type, version, primary, + * seq, prev?, created_at, op, payload) so the JSONL form lines up; JCS sorts + * keys for signing regardless. + */ +export function signSigchainEvent( + signer: Ed25519Identity, + op: SigchainOp, + opPayload: Record, + cursor: ChainCursor, + createdAt: Date = new Date(), +): SignedSigchainEvent { + const payload: SigchainEventPayload = { + type: SIGCHAIN_EVENT_TYPE, + version: FORMAT_VERSION, + primary: signer.identity, + seq: cursor.seq, + ...(cursor.prev !== undefined ? { prev: cursor.prev } : {}), + created_at: rfc3339Utc(createdAt), + op, + payload: opPayload, + }; + return { + kez: SIGCHAIN_ENVELOPE, + payload, + signature: signWith(payload, signer), + }; +} + // ───────────────────────────────────────────────────────────────────────────── // Encodings — pretty JSON, compact (kez:z1:), markdown fence // ───────────────────────────────────────────────────────────────────────────── diff --git a/kez-chat/web/src/lib/sigchain-service.ts b/kez-chat/web/src/lib/sigchain-service.ts new file mode 100644 index 0000000..e30935e --- /dev/null +++ b/kez-chat/web/src/lib/sigchain-service.ts @@ -0,0 +1,117 @@ +// Mirrors the user's claims into their sigchain on the chain service. +// +// The chain service (the rust-sig-server) is where a user's append-only, +// signed sigchain lives. When the user adds a claim we append an `add` +// event for that subject; when they remove a claim we append a `revoke`. +// This keeps the sigchain an accurate, verifiable record of what the user +// currently claims — the spec's mechanism (SPEC.md §8), not a per-claim +// field. +// +// We read the current chain to compute the next `seq` + `prev` hash so +// appends stay monotonic even across devices, then sign locally (the +// chain service never sees the seed) and POST the event. + +import { + identityFromSeed, + nextChainCursor, + sigchainEventHash, + signSigchainEvent, + type Identity, + type SignedSigchainEvent, + type SigchainOp, +} from "./kez.js"; +import { getSigchain, lookup, postSigchainEvent } from "./api.js"; + +export interface SigchainSyncResult { + /** The chain-service base URL the event was posted to. */ + chainService: string; + /** Sequence number of the event (or the existing add, when noop). */ + seq: number; + /** `sha256:` head hash of the chain. */ + hash: string; + /** + * True when the chain already reflected the desired state, so no new + * event was posted (e.g. re-syncing a claim already on the chain, or + * removing a claim that was never added). Makes sync idempotent — safe + * to run repeatedly and safe for claims created before sigchain support. + */ + noop: boolean; +} + +/** + * Walk the chain and return the currently-active subjects (added and not + * later revoked), mapped to the `seq` of the `add` that activated each. + * This is how we keep appends idempotent. + */ +function activeSubjects(events: SignedSigchainEvent[]): Map { + const active = new Map(); + for (const event of events) { + const subject = (event.payload.payload as { subject?: unknown })?.subject; + if (typeof subject !== "string") continue; + if (event.payload.op === "add") active.set(subject, event.payload.seq); + else if (event.payload.op === "revoke") active.delete(subject); + } + return active; +} + +/** + * Resolve the user's chain-service URL (their sigchain base URL) from their + * handle. The chat server constructs this from the configured sig-server and + * the user's primary key. + */ +export async function resolveChainService(handle: string): Promise { + const record = await lookup(handle); + if (!record.sigchain_url) { + throw new Error("server did not return a sigchain URL for this handle"); + } + return record.sigchain_url; +} + +/** + * Append an add/revoke event for `subject` to the user's sigchain on the + * chain service. Reads the current chain to compute the next cursor, signs + * the event with the user's seed, and POSTs it. + */ +export async function appendSubjectEvent(opts: { + seed: Uint8Array; + chainService: string; + subject: Identity; + op: SigchainOp; + /** Optional URL where the channel proof is published (spec: add.proof_url). */ + proofUrl?: string; +}): Promise { + const signer = identityFromSeed(opts.seed); + const events = await getSigchain(opts.chainService); + const active = activeSubjects(events); + const head = events.length > 0 ? events[events.length - 1] : null; + const headHash = head ? sigchainEventHash(head) : ""; + + // Idempotency: don't duplicate state the chain already has. Re-adding an + // already-active subject, or revoking one that isn't active, is a no-op. + if (opts.op === "add" && active.has(opts.subject)) { + return { + chainService: opts.chainService, + seq: active.get(opts.subject)!, + hash: headHash, + noop: true, + }; + } + if (opts.op === "revoke" && !active.has(opts.subject)) { + return { + chainService: opts.chainService, + seq: head ? head.payload.seq : -1, + hash: headHash, + noop: true, + }; + } + + const opPayload: Record = { subject: opts.subject }; + if (opts.op === "add" && opts.proofUrl) { + opPayload.proof_url = opts.proofUrl; + } + + const cursor = nextChainCursor(events); + const event = signSigchainEvent(signer, opts.op, opPayload, cursor); + const { seq, hash } = await postSigchainEvent(opts.chainService, event); + return { chainService: opts.chainService, seq, hash, noop: false }; +} diff --git a/kez-chat/web/src/routes/AddClaim.svelte b/kez-chat/web/src/routes/AddClaim.svelte index 30761e5..740b655 100644 --- a/kez-chat/web/src/routes/AddClaim.svelte +++ b/kez-chat/web/src/routes/AddClaim.svelte @@ -9,7 +9,11 @@ toCompact, type SignedClaimEnvelope, } from "../lib/kez.js"; - import { addClaim } from "../lib/claims-store.js"; + import { addClaim, setSigchainSync } from "../lib/claims-store.js"; + import { + appendSubjectEvent, + resolveChainService, + } from "../lib/sigchain-service.js"; import { session } from "../lib/store.svelte.js"; import { hasNip07, @@ -154,6 +158,14 @@ | { status: "error"; message: string } >({ status: "idle" }); + /** Outcome of mirroring this claim into the user's sigchain on the chain service. */ + let sigchainSync = $state< + | { status: "idle" } + | { status: "pending" } + | { status: "ok"; seq: number; noop: boolean } + | { status: "error"; message: string } + >({ status: "idle" }); + /** Re-evaluated each render; cheap (just a typeof check on window.nostr). */ const nip07Available = $derived(hasNip07()); @@ -226,13 +238,15 @@ } async function saveAndDone() { - if (!envelope || !selected) return; + if (!envelope || !selected || !session.unlocked) return; + const id = crypto.randomUUID(); + const subject = envelope.payload.subject; try { // $state wraps `envelope` in a deep Proxy; structuredClone (used // by idb-keyval) can't clone proxies and throws DataCloneError. // $state.snapshot returns a plain, cloneable object. await addClaim({ - id: crypto.randomUUID(), + id, envelope: $state.snapshot(envelope) as SignedClaimEnvelope, channel: selected.key, }); @@ -240,6 +254,36 @@ } catch (e) { console.error("saveAndDone failed", e); alert(`Failed to save claim: ${(e as Error).message}`); + return; + } + // Mirror the claim into the user's sigchain on the chain service: append + // a signed `add` event for this subject. Best-effort — the claim is + // already saved locally; if the chain service is unreachable we record + // the error and the user can retry from the Claims page later. + sigchainSync = { status: "pending" }; + try { + const chainService = await resolveChainService(session.unlocked.handle); + const proofUrl = + nostrPublish.status === "ok" ? nostrPublish.result.evidence_url : undefined; + const result = await appendSubjectEvent({ + seed: session.unlocked.seed, + chainService, + subject, + op: "add", + proofUrl, + }); + await setSigchainSync(id, { + chain_service: result.chainService, + sigchain_seq: result.seq, + sigchain_status: "synced", + }); + sigchainSync = { status: "ok", seq: result.seq, noop: result.noop }; + } catch (e) { + await setSigchainSync(id, { + sigchain_status: "error", + sigchain_error: (e as Error).message, + }); + sigchainSync = { status: "error", message: (e as Error).message }; } } @@ -452,6 +496,28 @@ Once you've published the proof on that channel, come back to the Claims page and mark it published.

+ + +
+ {#if sigchainSync.status === "pending"} +

⏳ Updating your sigchain on the chain service…

+ {:else if sigchainSync.status === "ok"} +

+ {#if sigchainSync.noop} + ⛓ Already on your sigchain (seq {sigchainSync.seq}) — nothing to add. + {:else} + ⛓ Sigchain updated — added at seq {sigchainSync.seq} on the chain service. + {/if} +

+ {:else if sigchainSync.status === "error"} +

+ ⚠ Couldn't update your sigchain on the chain service: + {sigchainSync.message}. The claim is saved locally — retry the + sigchain sync from the Claims page. +

+ {/if} +
+
Add another diff --git a/kez-chat/web/src/routes/Claims.svelte b/kez-chat/web/src/routes/Claims.svelte index 7195cba..1c0f2e7 100644 --- a/kez-chat/web/src/routes/Claims.svelte +++ b/kez-chat/web/src/routes/Claims.svelte @@ -6,8 +6,13 @@ markPublished, removeClaim, setVerifyResult, + setSigchainSync, type StoredClaim, } from "../lib/claims-store.js"; + import { + appendSubjectEvent, + resolveChainService, + } from "../lib/sigchain-service.js"; import { verifyClaim } from "../lib/verify.js"; import { session } from "../lib/store.svelte.js"; @@ -15,6 +20,8 @@ let loading = $state(true); /** ids currently mid-verify, so we can disable the button + show a spinner. */ let verifying = $state>(new Set()); + /** ids currently mid chain-service sync (add retry or revoke-on-delete). */ + let syncing = $state>(new Set()); /** Which claims have their details panel expanded. */ let expanded = $state>(new Set()); @@ -33,11 +40,74 @@ } async function deleteClaim(c: StoredClaim) { - if (!confirm(`Remove the local copy of claim for ${c.envelope.payload.subject}?`)) return; + if (!confirm(`Remove the claim for ${c.envelope.payload.subject}?`)) return; + // Revoke on the chain service first so the user's sigchain reflects the + // removal (SPEC.md §8: a revoke event withdraws a previously-added + // subject). Best-effort — if the chain service is unreachable, ask + // before dropping the local copy so the sigchain doesn't silently drift. + if (session.unlocked) { + syncing = new Set(syncing).add(c.id); + try { + const chainService = + c.chain_service ?? (await resolveChainService(session.unlocked.handle)); + await appendSubjectEvent({ + seed: session.unlocked.seed, + chainService, + subject: c.envelope.payload.subject, + op: "revoke", + }); + } catch (e) { + const proceed = confirm( + `Couldn't post a revoke to the chain service (${(e as Error).message}). ` + + `Remove the local copy anyway? Your sigchain will still list this subject.`, + ); + if (!proceed) { + const next = new Set(syncing); + next.delete(c.id); + syncing = next; + return; + } + } finally { + const next = new Set(syncing); + next.delete(c.id); + syncing = next; + } + } await removeClaim(c.id); claims = await listClaims(); } + /** (Re)mirror a claim into the sigchain on the chain service as an `add`. */ + async function syncToChain(c: StoredClaim) { + if (!session.unlocked) return; + syncing = new Set(syncing).add(c.id); + try { + const chainService = await resolveChainService(session.unlocked.handle); + const result = await appendSubjectEvent({ + seed: session.unlocked.seed, + chainService, + subject: c.envelope.payload.subject, + op: "add", + }); + await setSigchainSync(c.id, { + chain_service: result.chainService, + sigchain_seq: result.seq, + sigchain_status: "synced", + sigchain_error: undefined, + }); + } catch (e) { + await setSigchainSync(c.id, { + sigchain_status: "error", + sigchain_error: (e as Error).message, + }); + } finally { + const next = new Set(syncing); + next.delete(c.id); + syncing = next; + claims = await listClaims(); + } + } + async function runVerify(c: StoredClaim) { verifying = new Set(verifying).add(c.id); try { @@ -149,6 +219,19 @@ Channel: {c.channel} · Signed: {c.envelope.payload.created_at}

+ {#if syncing.has(c.id)} +

⏳ Syncing with chain service…

+ {:else if c.sigchain_status === "synced"} +

+ ⛓ On your sigchain{#if c.sigchain_seq !== undefined} · seq {c.sigchain_seq}{/if} +

+ {:else if c.sigchain_status === "error"} +

+ ⚠ Not on your sigchain{#if c.sigchain_error} ({c.sigchain_error}){/if} +

+ {:else} +

⛓ Not yet on your sigchain

+ {/if} {#if c.last_verify}

{c.last_verify.summary} @@ -208,9 +291,20 @@ Mark published {/if} + {#if c.sigchain_status !== "synced"} + + {/if}