feat(kez-chat/web): mirror local claims to chain-service sigchain

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 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-06-05 22:43:16 -06:00
parent 3fdbdc9fcf
commit 5ad47a917d
6 changed files with 465 additions and 9 deletions

View File

@ -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/<hex>`. 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<SignedSigchainEvent[]> {
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);
}

View File

@ -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<StoredClaim[]> {
return (await get<StoredClaim[]>(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<void> {
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<void> {
const existing = await listClaims();
await set(

View File

@ -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:<hex>` 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<string, unknown>;
}
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<Utc>` 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:<hex>` 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<string, unknown>,
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
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -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:<hex>` 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<string, number> {
const active = new Map<string, number>();
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<string> {
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<SigchainSyncResult> {
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<string, unknown> = { 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 };
}

View File

@ -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 };
}
}
</script>
@ -452,6 +496,28 @@
Once you've published the proof on that channel, come back to the
Claims page and mark it published.
</p>
<!-- Chain-service (sigchain) mirror status -->
<div class="mt-3 text-sm">
{#if sigchainSync.status === "pending"}
<p class="text-text-secondary">⏳ Updating your sigchain on the chain service…</p>
{:else if sigchainSync.status === "ok"}
<p class="text-verified">
{#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}
</p>
{:else if sigchainSync.status === "error"}
<p class="text-warning">
⚠ Couldn't update your sigchain on the chain service:
{sigchainSync.message}. The claim is saved locally — retry the
sigchain sync from the Claims page.
</p>
{/if}
</div>
<div class="mt-4 flex gap-2">
<a
href="#/claims"
@ -466,6 +532,8 @@
selected = null;
identifierInput = "";
envelope = null;
nostrPublish = { status: "idle" };
sigchainSync = { status: "idle" };
}}
>
Add another

View File

@ -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<Set<string>>(new Set());
/** ids currently mid chain-service sync (add retry or revoke-on-delete). */
let syncing = $state<Set<string>>(new Set());
/** Which claims have their details panel expanded. */
let expanded = $state<Set<string>>(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: <span class="font-mono">{c.channel}</span> ·
Signed: <span class="font-mono">{c.envelope.payload.created_at}</span>
</p>
{#if syncing.has(c.id)}
<p class="mt-1 text-xs text-text-secondary">⏳ Syncing with chain service…</p>
{:else if c.sigchain_status === "synced"}
<p class="mt-1 text-xs text-verified">
⛓ On your sigchain{#if c.sigchain_seq !== undefined} · seq {c.sigchain_seq}{/if}
</p>
{:else if c.sigchain_status === "error"}
<p class="mt-1 text-xs text-warning">
⚠ Not on your sigchain{#if c.sigchain_error} ({c.sigchain_error}){/if}
</p>
{:else}
<p class="mt-1 text-xs text-text-muted">⛓ Not yet on your sigchain</p>
{/if}
{#if c.last_verify}
<p class="mt-1 text-xs text-text-secondary">
{c.last_verify.summary}
@ -208,9 +291,20 @@
Mark published
</button>
{/if}
{#if c.sigchain_status !== "synced"}
<button
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-elevated disabled:opacity-50"
onclick={() => syncToChain(c)}
disabled={syncing.has(c.id)}
title="Append an `add` event for this subject to your sigchain on the chain service."
>
{syncing.has(c.id) ? "Syncing…" : "Sync to chain"}
</button>
{/if}
<button
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-danger/10 hover:border-danger"
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-danger/10 hover:border-danger disabled:opacity-50"
onclick={() => deleteClaim(c)}
disabled={syncing.has(c.id)}
>
Remove
</button>