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.
+