Tudisco d0db6f00f1 Initial implementation of KEZ — protocol, two impls, and storage server
KEZ is a portable, decentralized identity graph: a person signs claims
linking their many accounts, publishes those claims in places only the
claimed account can publish to, and anyone can verify the connections
without trusting a central server.

Layout
------
- SPEC.md            Language-agnostic protocol spec (v0.2)
- rust/              Rust implementation: kez-core, kez-channels, kez-cli
- nodejs/            TypeScript port at full parity
- rust-sig-server/   Optional axum + SQLite storage server for sigchains
- crosstest.sh       Cross-implementation interop harness

Capabilities (both implementations, byte-compatible)
----------------------------------------------------
- Two primary-key algorithms: nostr/secp256k1 Schnorr (BIP-340) and
  Ed25519 (RFC 8032). Identifiers: nostr:npub1... and ed25519:<hex>.
- JCS (RFC 8785) canonicalization for everything signed.
- Four proof encodings: JSON envelope, compact (kez:z1:<base64url(zstd(json))>),
  Markdown fence, DNS TXT.
- Five channel plugins (no API keys, no auth needed for any of them):
    dns:        system resolver, _kez.<domain> TXT records
    github:     public gist scan + <user>/<user> profile README fallback
    nostr:      kind-30078 events from default relays
    bluesky:    public AppView author feed
    ap:         WebFinger + actor JSON (alias mastodon:)
- Identical CLI surface:
    kez identity new [--key-type nostr|ed25519]
    kez claim create <subject> (--nsec | --ed25519-seed) [--format ...] [--out ...]
    kez claim dns <domain>     (--nsec | --ed25519-seed)
    kez verify file <path>
    kez verify id <identifier>
    kez sigchain add|revoke|show|export|publish
- Sigchains: append-only signed log per primary, hash-chained per spec §6,
  stored locally at ~/.kez/sigchains/, exportable as JSONL or kez:zc1: bundle.
- Sigchain publish destinations: chain server, web (file dump), DNS (zone
  record print), nostr (kind-30078 wrapping event).

kez-sig-server
--------------
Optional storage tier. Axum + SQLite, single binary, no external deps.

- No auth — the cryptography is the access control. The server validates
  every signature, every seq, every prev hash before storing.
- REST API: POST /v1/sigchains/{scheme}/{id}/events (append signed event,
  201 with new head hash or 4xx); GET /{scheme}/{id} (full chain as JSONL);
  GET /head; GET /healthz.
- Designed for one central instance for now; the design doesn't preclude
  running more later (clients gain a configurable list, verifiers
  reconcile per spec §6.2).
- Channel-based publishing remains the always-available fallback if the
  server is unavailable.

Tests
-----
- rust/                 99 tests
- rust-sig-server/      10 integration tests (real HTTP, real SQLite)
- nodejs/               91 tests (vitest)
- crosstest.sh          19 cross-impl scenarios — proves JCS bytes,
                        Schnorr + Ed25519 sigs, all four claim encodings,
                        and the sigchain JSONL bundle are byte-compatible
                        between Rust and Node in both directions.

What's not done yet
-------------------
- verify id consulting the sigchain for revocations (data path exists,
  just not wired into the verifier output).
- rotate and add_device sigchain ops (types reserved).
- expires_at enforcement during claim verification.
- Typed VerificationStatus.status reflecting the five failure modes.
- Auth-required publishers (GitHub gist, Bluesky, ActivityPub).
2026-05-24 14:41:00 -06:00

388 lines
13 KiB
TypeScript

// 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<string, unknown>,
): 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<string, unknown> = { 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:<hex>` 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 ?? "<none>"}, got ${event.payload.prev ?? "<none>"}`,
);
}
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:<base64url-no-pad(zstd(jsonl))>` — 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 };