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).
247 lines
7.9 KiB
TypeScript
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/);
|
|
});
|
|
});
|