Kez/SPEC.md
Tudisco 6dfd5a6938 spec: v0.3 — restructure, add glossary, worked example, primitives,
versioning policy, changelog

Full sweep across the three buckets discussed:

Bucket A (quick wins — staleness/bugs):
- Fix §3 (was §2): drop wrong mastodon row with double-@; ActivityPub
  channel formalized as `ap:` with mastodon as alias; consolidated
  with current channel set.
- §7 channels table now matches the actually-shipped channel adapters
  in rust-channels and node-channels.
- Drop §12 Test Vectors (the directory never existed). Replaced with
  one paragraph in §15 pointing at the crosstest.sh harness, which
  is what we actually use for inter-implementation conformance.
- Replace §10 historical "MVP Scope (v0.2)" with §14 Changelog.
- §15 Implementation Layout now points at actual repos (rust/,
  nodejs/, rust-sig-server/) rather than the never-existed kez-web.

Bucket B (simplifications):
- Folded §9 Starting Points into §10.1 (one paragraph).
- Consolidated §1 Core Concepts and §13 One-Sentence Summary into
  the new opener (§1 Summary + §2 Glossary).
- §3.1 Canonicalization inlined into §4.2 (where it actually applies).
- §8 Verification trimmed from 9 conflated steps to 5 clean phases.
- §8.5 "MUST" softened to "expected" for libraries; complete verifiers
  do network, helpers don't.

Bucket C (real improvements + restructure):
- §2 Glossary added (primary key, claim, subject, proof, channel,
  sigchain, signature envelope, identity graph — all in one place).
- §11 Cryptographic Primitives table — every algo we use, its role.
- §12 Worked Example with REAL reproducible bytes: fixed Ed25519
  seed (4242... — clearly labeled TEST ONLY), specific subject and
  timestamp, the exact JCS bytes, the exact deterministic Ed25519
  signature, the exact compact form. Generated against the reference
  Rust implementation; any conforming implementation should produce
  identical bytes.
- §13 Versioning & Wire Compatibility policy — what bumps major,
  what bumps minor, how implementations handle unknown ops.
- §14 Changelog — v0.1 / v0.2 / v0.3 with notable changes.
- §8.4 Sigchain in pictures — ASCII diagram showing 5 events with
  hash chaining and rotation.

Structural reorganization:
- §1 summary → §2 glossary → §3 identifiers → §4 signature envelope
  → §5 payload shapes → §6 wire encodings → §7 channels → §8 sigchain
  → §9 storage → §10 verification → §11 crypto → §12 worked example
  → §13 versioning → §14 changelog → §15 implementation layout.
- The envelope (the unit of transport) is now described before the
  payloads it wraps, matching what's actually on the wire.

Also: added §6.5 documenting `kez:zc1:` (compact sigchain bundle)
that exists in the implementations but was missing from the spec.
2026-05-24 22:51:51 -06:00

24 KiB

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:

{
  "kez": "<envelope-tag>",
  "payload": { ... },
  "signature": {
    "alg": "<signature-suite>",
    "key": "<primary-identity>",
    "sig": "<hex-encoded-signature>"
  }
}

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.

{
  "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

{
  "type": "kez.sigchain.event",
  "version": 1,
  "primary": "ed25519:2152f8d19b791d24...",
  "seq": 7,
  "prev": "sha256:<hex of prior envelope's JCS bytes>",
  "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:<base64url-no-pad(zstd(envelope-as-compact-json))>
  • 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:

# 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.<domain>

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:<base64url-no-pad(zstd(jsonl))>

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 <user>/<user> profile repo Markdown (§6.3)
dns TXT record at _kez.<domain> Compact (§6.2)
web https://<domain>/.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": "<hex>" } 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.<domain>.
  • 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 (<key-family>-<hash>-<canonicalization>) 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:

{
  "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):

{
  "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.