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).
388 lines
13 KiB
TypeScript
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 };
|