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

247 lines
7.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
COMPACT_PROOF_PREFIX,
ED25519_SHA512_ALG,
Ed25519Secret,
Identity,
IdentityError,
NOSTR_SCHNORR_ALG,
NostrSecret,
VerificationError,
canonicalBytes,
dnsTxtName,
dnsTxtValue,
extractMarkdownProof,
fromCompact,
fromJson,
newClaimPayload,
nostrPubkeyHex,
parseDnsTxtValue,
signClaim,
toCompact,
toMarkdown,
toPrettyJson,
verifyClaim,
} from "../src/index.js";
function makeSigned(subjectStr: string) {
const secret = NostrSecret.generate();
const primary = secret.identity();
const subject = Identity.parse(subjectStr);
const payload = newClaimPayload(subject, primary, new Date());
return { secret, primary, subject, signed: signClaim(payload, secret) };
}
describe("Identity", () => {
it("parses bare npub as nostr identity", () => {
const secret = NostrSecret.generate();
const npub = secret.npub();
const id = Identity.parse(npub);
expect(id.toString()).toBe(`nostr:${npub}`);
expect(id.scheme).toBe("nostr");
});
it("rejects invalid inputs", () => {
expect(() => Identity.parse("")).toThrow(IdentityError);
expect(() => Identity.parse(" ")).toThrow(IdentityError);
expect(() => Identity.parse("no-colon")).toThrow(IdentityError);
expect(() => Identity.parse(":missing-scheme")).toThrow(IdentityError);
expect(() => Identity.parse("scheme:")).toThrow(IdentityError);
expect(() => Identity.parse("nostr:not-a-real-npub")).toThrow(IdentityError);
});
it("splits scheme and id", () => {
const id = Identity.parse("github:jason");
expect(id.scheme).toBe("github");
expect(id.id).toBe("jason");
expect(id.toString()).toBe("github:jason");
});
});
describe("NostrSecret", () => {
it("round-trips nsec", () => {
const secret = NostrSecret.generate();
const nsec = secret.nsec();
const restored = NostrSecret.fromNsec(nsec);
expect(restored.npub()).toBe(secret.npub());
expect(restored.pubkeyHex()).toBe(secret.pubkeyHex());
});
it("nostrPubkeyHex returns 32-byte lowercase", () => {
const secret = NostrSecret.generate();
const hex = nostrPubkeyHex(secret.identity());
expect(hex).toHaveLength(64);
expect(hex).toBe(hex.toLowerCase());
});
});
describe("signClaim / verifyClaim", () => {
it("signs and verifies", () => {
const { signed, primary } = makeSigned("github:jason");
const status = verifyClaim(signed);
expect(status.status).toBe("valid");
expect(status.primary.equals(primary)).toBe(true);
expect(status.verified[0].toString()).toBe("github:jason");
});
it("rejects tampered subject", () => {
const { signed } = makeSigned("github:jason");
signed.payload.subject = "github:mallory";
expect(() => verifyClaim(signed)).toThrow(VerificationError);
});
it("rejects unsupported algorithm", () => {
const { signed } = makeSigned("github:jason");
signed.signature.alg = "made-up-suite";
expect(() => verifyClaim(signed)).toThrow(/unsupported algorithm/);
});
it("rejects signature.key mismatched against payload.primary", () => {
const { signed } = makeSigned("github:jason");
const other = NostrSecret.generate();
signed.signature.key = `nostr:${other.npub()}`;
expect(() => verifyClaim(signed)).toThrow(/does not match payload.primary/);
});
it("uses the expected algorithm string", () => {
const { signed } = makeSigned("github:jason");
expect(signed.signature.alg).toBe(NOSTR_SCHNORR_ALG);
});
});
describe("encodings", () => {
it("round-trips JSON", () => {
const { signed } = makeSigned("github:jason");
const json = toPrettyJson(signed);
const back = fromJson(json);
expect(back).toEqual(signed);
verifyClaim(back);
});
it("round-trips compact", () => {
const { signed } = makeSigned("github:jason");
const compact = toCompact(signed);
expect(compact.startsWith(COMPACT_PROOF_PREFIX)).toBe(true);
const back = fromCompact(compact);
expect(back).toEqual(signed);
verifyClaim(back);
});
it("round-trips markdown", () => {
const { signed } = makeSigned("github:jason");
const md = toMarkdown(signed);
expect(md).toContain("```kez");
const back = extractMarkdownProof(md);
expect(back).toEqual(signed);
verifyClaim(back);
});
it("round-trips legacy DNS TXT", () => {
const { signed } = makeSigned("dns:jason.example.com");
const txt = dnsTxtValue(signed);
expect(txt.startsWith("kez1:")).toBe(true);
const back = parseDnsTxtValue(txt);
expect(back).toEqual(signed);
});
it("extractMarkdownProof rejects malformed input", () => {
expect(() => extractMarkdownProof("no fence here")).toThrow();
expect(() => extractMarkdownProof("```kez\n{ unterminated")).toThrow();
});
it("fromCompact rejects missing prefix", () => {
expect(() => fromCompact("hello")).toThrow();
expect(() => fromCompact("kez1:foo")).toThrow();
});
});
describe("dns helpers", () => {
it("dnsTxtName requires dns: scheme", () => {
expect(dnsTxtName(Identity.parse("dns:jason.example.com"))).toBe(
"_kez.jason.example.com",
);
expect(() => dnsTxtName(Identity.parse("github:jason"))).toThrow();
});
});
describe("JCS", () => {
it("produces stable bytes for object regardless of key ordering", () => {
const a = canonicalBytes({ b: 1, a: 2, c: [3, 4] });
const b = canonicalBytes({ c: [3, 4], a: 2, b: 1 });
expect(a).toEqual(b);
});
});
describe("Ed25519Secret", () => {
it("round-trips seed hex", () => {
const secret = Ed25519Secret.generate();
const seed = secret.seedHex();
const restored = Ed25519Secret.fromSeedHex(seed);
expect(restored.pubkeyHex()).toBe(secret.pubkeyHex());
expect(restored.seedHex()).toBe(seed);
});
it("identity is lowercase 64-hex with ed25519: scheme", () => {
const secret = Ed25519Secret.generate();
const id = secret.identity();
expect(id.scheme).toBe("ed25519");
expect(id.id).toHaveLength(64);
expect(id.id).toBe(id.id.toLowerCase());
});
it("rejects malformed seed", () => {
expect(() => Ed25519Secret.fromSeedHex("notHex")).toThrow();
expect(() => Ed25519Secret.fromSeedHex("ab".repeat(31))).toThrow(/32 bytes/);
});
});
describe("Identity ed25519 validation", () => {
it("accepts well-formed identifiers", () => {
expect(() =>
Identity.parse(`ed25519:${"ab".repeat(32)}`),
).not.toThrow();
});
it("rejects malformed identifiers", () => {
expect(() => Identity.parse("ed25519:tooshort")).toThrow();
expect(() => Identity.parse(`ed25519:${"AB".repeat(32)}`)).toThrow();
expect(() => Identity.parse(`ed25519:${"Z".repeat(64)}`)).toThrow();
});
});
describe("Ed25519 signClaim / verifyClaim", () => {
function signEd25519(subjectStr: string) {
const secret = Ed25519Secret.generate();
const primary = secret.identity();
const subject = Identity.parse(subjectStr);
const payload = newClaimPayload(subject, primary, new Date());
return { secret, primary, subject, signed: signClaim(payload, secret) };
}
it("uses the ed25519 alg string", () => {
const { signed } = signEd25519("github:jason");
expect(signed.signature.alg).toBe(ED25519_SHA512_ALG);
});
it("signs and verifies", () => {
const { signed, primary } = signEd25519("github:jason");
const status = verifyClaim(signed);
expect(status.status).toBe("valid");
expect(status.primary.equals(primary)).toBe(true);
});
it("rejects tampered subject", () => {
const { signed } = signEd25519("github:jason");
signed.payload.subject = "github:mallory";
expect(() => verifyClaim(signed)).toThrow(VerificationError);
});
it("rejects ed25519 sig over non-ed25519 primary", () => {
const { signed } = signEd25519("github:jason");
// Forge a nostr-shaped primary while keeping the ed25519 alg.
const other = NostrSecret.generate();
signed.payload.primary = `nostr:${other.npub()}`;
signed.signature.key = signed.payload.primary;
expect(() => verifyClaim(signed)).toThrow(/ed25519/);
});
});