# KEZ Specification **Status:** Draft v0.3 **Date:** 2026-05-25 **Conformance:** Implementations MUST conform to the formats and behaviors defined here. Two implementations conform iff a claim signed by one verifies in the other and vice versa (see §15). --- ## 1. Summary KEZ is a portable identity graph. Users sign claims connecting their many accounts, publish those claims in places they control, and anyone can verify the connections without trusting a central server. ``` "These accounts, keys, domains, and identities are all me." ``` No central authority blesses the claim. Verification is done by cryptographic signatures against keys the user already controls. --- ## 2. Glossary | Term | Meaning | |---|---| | **Primary key** | The key the user owns; the root of their identity. One per user. Supported algorithms in §11. | | **Identity** | A canonical `system:identifier` string (e.g. `github:jason`, `nostr:npub1abc...`). §3. | | **Subject** | The identity being claimed about, in a claim payload. | | **Claim payload** | The unsigned JSON object asserting that the primary key controls a subject. §5.1. | | **Signature envelope** | The wrapper that wraps any payload with `{kez, payload, signature}`. The unit of transport. §4. | | **Proof** | A signature envelope (containing a claim) that has been *published* in a location only the claimed subject controls. | | **Channel** | A protocol/system where a proof can be published and fetched. §7. | | **Sigchain event** | An entry in a user's sigchain — `add`, `revoke`, `rotate`, etc. §5.2. | | **Sigchain** | The append-only signed log of sigchain events for one primary key. §8. | | **Identity graph** | The set of currently-active subjects reachable from a primary key, walked through its sigchain. | --- ## 3. Identifiers A KEZ identifier is always `system:identifier` — the system prefix is required, never optional. A human glancing at the string can tell what namespace it lives in. Canonical examples: | System | Example | |---|---| | `nostr` | `nostr:npub1abc...` | | `ed25519` | `ed25519:2152f8d19b791d24...` (64 lowercase hex chars) | | `github` | `github:jason` | | `dns` | `dns:jason.example.com` | | `web` | `web:https://jason.example.com` | | `bluesky` | `bluesky:jason.bsky.social` | | `did` | `did:plc:abc...` | | `ens` | `ens:jason.eth` | | `ap` | `ap:@jason@mastodon.social` (alias: `mastodon:@...`) | | `farcaster` | `farcaster:fid:12345` | Bare `npub1...` (no `nostr:` prefix) is **not** a valid stored or signed identifier. CLI tools MAY accept it as input ergonomics, but MUST normalize to `nostr:npub1...` before signing, publishing, or storing. Identifier strings are case-sensitive except where the underlying system normalizes case (e.g. DNS — lowercased before comparison). KEZ is **not a phone book.** It cannot resolve "Jason" from a human name. A verifier always needs a concrete starting identifier (see §10). --- ## 4. The Signature Envelope Every signed thing in KEZ — claims, sigchain events, future payload types — is wrapped in the same three-field envelope: ```json { "kez": "", "payload": { ... }, "signature": { "alg": "", "key": "", "sig": "" } } ``` The envelope is the unit of transport. Everything on the wire is an envelope; payloads never appear bare. ### 4.1 Envelope fields | Field | Type | Meaning | |---|---|---| | `kez` | string | Envelope tag — identifies the payload kind without parsing the payload. Examples: `"claim"`, `"sigchain_event"`. | | `payload` | object | The payload — the actual data being signed. Shape depends on the envelope tag (see §5). | | `signature.alg` | string | Full signature suite, identifying key family + hash + canonicalization. §11. | | `signature.key` | string | The signing key as a KEZ identity. MUST equal `payload.primary` for claim and sigchain-event envelopes. | | `signature.sig` | string | The signature, **lowercase hex**, no `0x` prefix. The signature covers `JCS(payload)`, not the envelope. | The on-disk file extension for a signed claim envelope is `.kez`. The MIME type is `application/vnd.kez+json`. ### 4.2 Canonicalization for signing Signatures are computed over the **JCS-canonicalized bytes of the payload** (RFC 8785, JSON Canonicalization Scheme). Implementations MUST NOT rely on incidental key ordering — JCS sorts keys lexicographically, normalizes numbers, escapes strings deterministically. This is what enables interop: byte-identical canonical bytes across implementations means byte-identical signatures (for deterministic algorithms) or universally-verifiable signatures (for randomized ones). --- ## 5. Payload Shapes Two payload shapes are defined in v0.3. ### 5.1 Claim payload A claim asserts that a primary key controls a subject identity. ```json { "type": "kez.claim", "version": 1, "primary": "ed25519:2152f8d19b791d24...", "subject": "github:jason", "created_at": "2026-05-19T18:00:00Z" } ``` Required fields: `type`, `version`, `primary`, `subject`, `created_at`. Optional fields: | Field | Type | Meaning | |---|---|---| | `expires_at` | RFC 3339 timestamp | After this time the claim is treated as expired. | | `nonce` | opaque string | Prevents replay across publishing channels. | | `note` | string ≤256 chars | Human-readable note. | Type strings are dotted and namespaced (`kez.claim`, `kez.sigchain.event`, future `kez.device`, etc.). ### 5.2 Sigchain event payload ```json { "type": "kez.sigchain.event", "version": 1, "primary": "ed25519:2152f8d19b791d24...", "seq": 7, "prev": "sha256:", "created_at": "2026-05-20T12:00:00Z", "op": "add", "payload": { ... op-specific ... } } ``` `seq` is strictly monotonic starting from 0. `prev` is `sha256:` plus hex SHA-256 of the JCS bytes of the **prior signed envelope** (whole envelope, not just its payload). The first event (`seq: 0`) has no `prev` field. Per-op `payload` shapes are defined in §8. --- ## 6. Wire Encodings A signed envelope can be transported in four interchangeable forms. All carry the same logical content; choose the one that fits the channel. ### 6.1 JSON Standard JSON serialization of the envelope (pretty or compact). Native form. Used for HTTP fetches, `/.well-known/kez.json`, developer tooling. Different implementations MAY produce different whitespace and field order in raw JSON, but the **JCS-canonicalized payload** must match. Signatures verify regardless of envelope whitespace. ### 6.2 Compact (`kez:z1:`) URL-safe, copy-pasteable form designed for QR codes, DNS TXT records, chat messages, and other tight channels: ``` kez:z1: ``` - `z1` is the compact format version. Future versions (`z2`, …) may change compression or framing. - Compression: **zstd**, default level 3. - Encoding: **base64url** (RFC 4648 §5), no padding. - The decoded payload MUST parse as a valid signature envelope per §4. Why zstd over gzip: ~30% smaller for our JSON shapes, low CPU cost, modern decoder available everywhere (built into Node 22.15+ via `node:zlib`, the `zstd` crate in Rust, etc.). Implementations MUST support encoding and decoding `kez:z1:`. ### 6.3 Markdown Human-readable proof file suitable for a GitHub gist, profile README, or any Markdown-rendering surface. The machine-readable proof lives inside a fenced code block tagged ```` ```kez ````: ````markdown # KEZ Proof This account publishes a signed KEZ identity claim. - Primary: `ed25519:2152f8d19b...` - Subject: `github:jason` - Created: `2026-05-19T18:00:00Z` ```kez { "kez": "claim", "payload": { ... }, "signature": { ... } } ``` ```` Parsers MUST locate the first ```` ```kez ```` fence, take the contents up to the next ```` ``` ````, trim whitespace, parse as JSON. Surrounding prose is informational. ### 6.4 DNS TXT For `dns:` subjects, the proof is published as a TXT record at: ``` _kez. ``` The TXT record value is the **compact encoding** (§6.2): ``` kez:z1:KLUv_QBY... ``` Raw JSON is NOT a valid DNS TXT proof — JSON exceeds the 255-byte TXT segment limit too easily and complicates quoting. The compact form fits common claims in a single segment (chunked across segments if needed, per standard DNS TXT semantics). If a claim is too large even compressed, implementations SHOULD publish a `web:` proof instead and add a `dns:` claim pointing at the domain — chain, don't split. ### 6.5 Sigchain compact bundle (`kez:zc1:`) For transporting an entire sigchain as one string (nostr event content, DNS TXT pointer, ActivityPub profile field): ``` kez:zc1: ``` Where the inner content is JSONL — one signed sigchain envelope per line. Parsers MUST decompress, split on newlines, and parse each line as a sigchain event envelope. --- ## 7. Channels A channel is a system + the conventions for publishing a proof there so a verifier can fetch it. | System | Where to publish | Wire encoding | |---|---|---| | `github` | Public gist (filename should include `kez`), or `README` on the user's `/` profile repo | Markdown (§6.3) | | `dns` | TXT record at `_kez.` | Compact (§6.2) | | `web` | `https:///.well-known/kez.json` | JSON (§6.1) | | `nostr` | Nostr event of kind `30078` (parameterized replaceable), `d` tag `kez` | JSON in event content | | `bluesky` | Public post on the user's PDS | JSON or Markdown | | `ap` (alias `mastodon`) | Profile field (preferred) or bio of the user's actor object; resolved via WebFinger → actor JSON | Compact (§6.2) inline, or Markdown | Verifying a proof always means: **fetch from the channel the identity itself controls, then verify the signature against the primary key.** A claim that signs correctly but is not found at the identity's own channel is **not a valid proof.** Channel implementations are pluggable. Adding a new channel does not change the wire format or signatures — only adds a new resolver for a new `system:` prefix. --- ## 8. Sigchain Semantics The sigchain is the append-only, signed log of identity events belonging to one primary key. It's how a verifier knows: - What subjects the user has currently claimed - What subjects have been revoked - What devices / secondary keys the user controls - When (if ever) the primary key was rotated ### 8.1 Operations Each sigchain event has an `op` string and a payload tailored to it. | `op` | Payload | Effect | |---|---|---| | `add` | `{ "subject": "github:jason", "proof_url": "..." }` (proof_url optional) | Asserts the user controls `subject`. | | `revoke` | `{ "subject": "github:jason" }` | Withdraws a previously added subject. | | `rotate` | `{ "new_primary": "ed25519:...", "new_key_sig": "" }` | Rotates the primary key. The old key signs this event; `new_key_sig` is the new key's signature over the JCS of the same event with `new_key_sig` omitted, proving both keys consent. | | `add_device` | `{ "device_key": "ed25519:...", "label": "macbook" }` | Adds a secondary key authorized to sign on behalf of this primary. (Semantics for device-signed events TBD in a future minor version.) | ### 8.2 Integrity rules - `seq` MUST be strictly monotonic, starting at 0. - `prev` MUST equal `sha256:` + hex SHA-256 of the **JCS-canonicalized envelope** (not just payload) of the prior entry. The first event (`seq: 0`) has no `prev` field. - A verifier MUST reject any sigchain with a broken hash chain, missing `prev`, or non-monotonic `seq`. - Multiple sigchain copies MAY exist across stores; the **longest valid chain wins**. If two valid chains diverge at the same `seq` with different envelope hashes, a verifier SHOULD report a **fork** rather than silently picking one. ### 8.3 Key rotation After a `rotate`: - All subsequent entries MUST be signed by the new primary. - Each entry's `primary` field MUST reflect the new key. - Verifiers walk rotations by following the chain: the old key signed the rotation event, and the `new_key_sig` field within that event is the new key's signature over the same event (with `new_key_sig` omitted from the JCS input), closing the loop. ### 8.4 Sigchain in pictures ``` seq 0 seq 1 seq 2 seq 3 add github add dns:example.com revoke github rotate to new_pk ↓ ┌────────┐ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │type: │ │type: ...event │ │type: ...event │ │type: ...event │ │ ... │ │seq: 1 │ │seq: 2 │ │seq: 3 │ │seq: 0 │ │prev: sha256:H0 │ ────────►│prev: sha256:H1 │ ────────►│prev: sha256:H2 │ │prev: - │ │op: add │ │op: revoke │ │op: rotate │ │op: add │ │payload: ... │ │payload: ... │ │new_key_sig:... │ │sub:gh:.│ └────────────────┘ └────────────────┘ └────────────────┘ └────────┘ │ signed by old │ │ signed by old │ │ signed by old │ │signed by │ │ primary │ │ primary │ │ + new_key_sig│ │primary │ └────────────────┘ └────────────────┘ └────────────────┘ └──────────┘ H0 = After this event, sha256( subsequent events JCS(seq 0 are signed by envelope)) new_pk and have primary: new_pk ``` H₀, H₁, H₂ are the SHA-256s of the full JCS envelopes. Each event's `prev` points back to the prior envelope's hash, forming the chain. --- ## 9. Storage KEZ is store-agnostic. Verifiers MUST be able to fetch sigchains and proofs from any of: - nostr relays - GitHub (gists, repos) - HTTP(S) URLs (websites, `/.well-known/kez.json`) - DNS (TXT records) - IPFS - Bluesky PDS - ActivityPub (WebFinger + actor JSON) - Local filesystem (for development and testing) There is no required *central* KEZ server. Storage servers (e.g. a sigchain HTTP store) MAY exist as convenience tiers, but the signatures are the source of truth — a verifier MUST validate signatures locally, not trust a server's claim about validity. Implementations SHOULD support multiple stores in parallel and treat them as mirrors. --- ## 10. Verification ### 10.1 Starting point A verifier always starts from one identifier supplied externally — e.g. someone shared `@chris@kez.lat`, a QR code embedded `nostr:npub1abc...`, a profile page passed `dns:chris.com`. KEZ cannot resolve "Chris" from a human name; it always needs a concrete starting identifier. Same mental model as the web: you don't know every URL; you start at one and follow links. ### 10.2 Input and output Input: one identifier. Output: a graph of identities reachable from the input, each with a status. Example: ``` Primary: ed25519:2152f8d19b791d24... Verified identities: github:jason valid dns:jason.example valid bluesky:jason.bsky.social valid ens:jason.eth unreachable Status: valid Confidence: strong ``` `Confidence` is implementation-defined but is typically a function of: number of independently-verified subjects, channel diversity, sigchain length and age. ### 10.3 Algorithm (5 phases) 1. **Fetch.** Resolve the input identifier on its native channel; fetch the proof. (Real network fetch — see §10.5.) 2. **Verify signature.** Parse the envelope; verify the signature against `payload.primary` over JCS bytes of the payload. 3. **Confirm channel ownership.** The proof was found at a location only the input identity could publish to. 4. **Walk sigchain.** Locate and load the sigchain for the primary key. Validate it end-to-end (signatures, hash chain, monotonic seq, rotation closure). Reject the proof if the input subject's most recent op is `revoke`, or any required event fails validation. 5. **Walk graph.** For each other subject in the sigchain, attempt to re-verify its proof against its own channel (best-effort). ### 10.4 Failure modes A verifier MUST distinguish: | Status | Cause | |---|---| | `invalid` | A signature failed, or the chain is broken. | | `revoked` | The identity was explicitly removed via a sigchain `revoke`. | | `expired` | The claim's `expires_at` has passed. | | `unreachable` | The channel could not be fetched (network/transient). | | `fork` | The sigchain has diverging valid branches at the same `seq`. | These MUST NOT be conflated. A verifier MUST surface the distinction. ### 10.5 Network fetching is expected A complete verifier performs real network fetches for each channel: - `dns:` — real DNS resolution; reads the TXT record at `_kez.`. - `github:` — HTTPS fetch against `api.github.com` / `raw.githubusercontent.com`. - `web:` — HTTPS fetch of `/.well-known/kez.json`. - `nostr:` — connect to one or more relays; fetch the relevant event. - `bluesky:` — HTTPS fetch against the user's PDS. - `ap:` — HTTPS fetch of WebFinger followed by actor JSON. A library that only checks signatures over local data is a useful *helper* but not a complete verifier. Complete verifiers MAY expose a local-file mode for testing; it MUST be clearly labeled as such. --- ## 11. Cryptographic Primitives | Primitive | Role | |---|---| | **BIP-340 Schnorr** (over secp256k1) | nostr-key signatures. Suite: `nostr-secp256k1-schnorr-sha256-jcs`. Signatures are deterministic (zero aux randomness). | | **Ed25519** (RFC 8032) | ed25519-key signatures. Suite: `ed25519-sha512-jcs`. PureEdDSA — the signature is over the raw JCS bytes; SHA-512 is performed internally. | | **SHA-256** | Sigchain `prev` chaining; pre-hash for Schnorr signing (`sig = schnorr(SHA-256(JCS(payload)), key)`). | | **SHA-512** | Internal to Ed25519. | | **JCS** (RFC 8785) | Canonicalize the bytes to be signed. | | **zstd** (default level 3) | Compress the JSON envelope for the compact wire form. | | **base64url** (RFC 4648 §5, no padding) | Encode compressed bytes for URL-safe transport. | | **hex** (lowercase, no `0x`) | Encode signatures and hashes wherever they appear in JSON. | The suite string names every choice (`--`) so a verifier can dispatch without guessing. Future suites follow the same pattern. --- ## 12. Worked Example (reproducible) Anyone implementing this spec should produce **byte-identical** results for these inputs, modulo signature aux randomness (Ed25519 is deterministic so the signature is byte-identical too). **Inputs (TEST ONLY — do not use this key for anything real):** ``` Ed25519 seed (32 bytes, hex): 4242424242424242424242424242424242424242424242424242424242424242 ``` ``` Subject: github:jason created_at: 2026-01-01T00:00:00Z ``` **Derived public key:** ``` ed25519 pubkey (hex): 2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12 KEZ identity: ed25519:2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12 ``` **The claim payload:** ```json { "type": "kez.claim", "version": 1, "subject": "github:jason", "primary": "ed25519:2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12", "created_at": "2026-01-01T00:00:00Z" } ``` **JCS-canonicalized form (UTF-8 bytes, no whitespace, keys sorted):** ``` {"created_at":"2026-01-01T00:00:00Z","primary":"ed25519:2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12","subject":"github:jason","type":"kez.claim","version":1} ``` JCS bytes (hex, for byte-level comparison): ``` 7b22637265617465645f6174223a22323032362d30312d30315430303a30303a30 305a222c227072696d617279223a2265643235353139... ``` **Ed25519 signature** (over the JCS bytes above, using the seed above — PureEdDSA, no pre-hash): ``` bc338ba33c28aab2962041e115753865c37f0edca7bdc821ed4f5e8f45bf92e72fbce5623d6d977fa0f8d41b7fff9a47de9ac8123b4ab63429e08223f856540b ``` **The full signed envelope (pretty JSON for display):** ```json { "kez": "claim", "payload": { "type": "kez.claim", "version": 1, "subject": "github:jason", "primary": "ed25519:2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12", "created_at": "2026-01-01T00:00:00Z" }, "signature": { "alg": "ed25519-sha512-jcs", "key": "ed25519:2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12", "sig": "bc338ba33c28aab2962041e115753865c37f0edca7bdc821ed4f5e8f45bf92e72fbce5623d6d977fa0f8d41b7fff9a47de9ac8123b4ab63429e08223f856540b" } } ``` **Compact form (`kez:z1:`) of the same envelope:** ``` kez:z1:KLUv_QBY5QgAlpZCIFCH1gEAeFb7A6gC1YAb2oLOI6pa3SWmN_aYp6OqqqoBPwA2ADoAyrzw02VjaYSyLBLJhILpgnMGoVEG6bJ5X3AGk4i6TCgUZnkCgII3pMtfNJp0uIStODR6kUgilD68dFnwhnQEJ9GTqD2bLpsegN_0Ly-RXHWojDV1_qi22jkIPrfy1TG-5U95_0bW37SIU1qobXlTyOKyrmsBvvrBP3Ghx4ohXqz6ms6qtt7N9iHUzhB6ni4HCvOGRJJ5hWSVm8Akg0Onw-UvjKYTA4VJ5JEC7pwADY9H-kmsmEW0y863TUh5J0er7HV7uFK3GONyTZDF03GtBhVEq_ifx12_LTqnyJ5jgq_LHgwEADhkaDUYyCo6IR0I9QQ ``` (The compact bytes encode the **compact JSON** of the full envelope — no pretty-printing — compressed with zstd and base64url-encoded. Implementations that emit raw JSON in different field order or with different whitespace will produce different compact strings even though the signature still verifies; the test above produced exactly these bytes against the reference Rust implementation.) To reproduce: use the seed and inputs above with any conforming implementation; you should get the same JCS bytes and the same signature. --- ## 13. Versioning & Wire Compatibility Spec versions are `major.minor`. The current spec is **v0.3**. ### What's non-breaking (minor bump) - Adding **optional** payload fields - Adding new envelope tags (`kez: "..."`) - Adding new wire encodings (`kez:z2:`, future) - Adding new sigchain ops (existing verifiers MUST ignore unknown ops, preserving chain validity) - Adding new signature suites - Adding new channels (new `system:` prefixes) ### What's breaking (major bump) - Adding **required** fields - Renaming or removing fields - Changing the meaning of an existing field - Changing the canonicalization scheme - Changing the hashing or signature dispatch ### Implementation policy - An implementation declaring `v0.3` MUST accept all v0.3 features. - Implementations MAY accept newer minor versions if they ignore unknown additive content safely. - Verifiers that encounter an event with an unknown `op` MUST chain past it (validate `seq` and `prev`) but MUST NOT use it for graph-resolution decisions. --- ## 14. Changelog | Version | Notable | |---|---| | **v0.3** (current) | Restructured around the signature envelope; added glossary, cryptographic primitives table, reproducible worked example, versioning policy. Added `kez:zc1:` sigchain bundle encoding. ActivityPub channel formalized; `mastodon:` is an alias. | | v0.2 | Spec-aligned types across implementations. Added Ed25519 (`ed25519-sha512-jcs`). JCS canonicalization required. Five channels formalized (dns, github, nostr, bluesky, ap). | | v0.1 | Initial draft. Single signature suite (Schnorr/nostr). | --- ## 15. Implementation Layout Each language implementation lives in its own top-level directory. Current implementations: | Directory | Language | What it ships | |---|---|---| | `rust/` | Rust | `kez-core` (lib), `kez-channels` (lib), `kez-cli` (binary `kez`) | | `nodejs/` | TypeScript | `@kez/core`, `@kez/channels`, `@kez/cli` — npm workspaces | | `rust-sig-server/` | Rust | Optional HTTP server for sigchain storage | Conformance between implementations is verified by `crosstest.sh` at the repo root — a harness that generates artifacts with one implementation and verifies them with the other, in every direction and every wire encoding. A passing cross-test is the operational definition of "two implementations interoperate." A future `vectors/` directory may hold canonical test fixtures for third-party implementations to validate against. Not required while two interop-tested implementations exist.