Kez/SPEC.md
Jason Tudisco b237ac2d9e docs+fix: SPEC v0.4, Node nostr channel checks kinds 0/1/30078, NOSTR-CHAT.md current
SPEC.md → v0.4
- §3.1 BIP-39 recovery phrases (12-word hashed with "kez-bip39-12-v1",
  24-word = seed), parsing rules, test vectors.
- §7 nostr: kind 30078 canonical (MUST); verifiers SHOULD also check
  kind-1 notes and the kind-0 `about`.
- §9.1 sigchain HTTP store API; §11 BIP-39 primitive; §15 Python and
  kez-chat; §16 applications (kez-chat tags); changelog.

nodejs kez-channels
- NostrChannel now requests kinds 0, 1, 30078 and extracts the proof
  body per kind (kind-0 `about` JSON-decoded), mirroring Rust's
  extract_proof_body. Proofs posted as kind-1 notes (e.g. via NIP-07
  from kez-chat) previously failed to verify in Node.
- Tests for kind-1 Markdown notes, kind-0 about, and extractProofBody.

Docs
- kez-chat/web/NOSTR-CHAT.md rewritten for the current protocol: `q`
  routing tag, kinds 4242/4244/10142/0/10002, KEZ-signed heartbeats,
  v1–v4 envelopes, receipts, relay ordering, honest metadata model.
- Stale `#h` comments in nostr-id.ts / nostr-transport.ts.
- rust/README.md nostr channel rows list all three kinds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:00:51 -06:00

748 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# KEZ Specification
**Status:** Draft v0.4
**Date:** 2026-09-27
**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. |
| **Recovery phrase** | A BIP-39 English word list (12 or 24 words) that deterministically yields an Ed25519 primary key's 32-byte seed. §3.1. |
---
## 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).
### 3.1 Recovery phrases (Ed25519 seeds)
An Ed25519 primary key is a 32-byte seed (RFC 8032). Implementations
that offer a human-writable backup MUST encode it as a BIP-39 phrase
over the **BIP-39 English wordlist**, with these semantics:
| Words | Entropy | Seed |
|---|---|---|
| **24** | 32 bytes | `seed = entropy` — a bijection; the phrase can be recovered from any seed |
| **12** | 16 bytes | `seed = SHA-256("kez-bip39-12-v1" ‖ entropy)` — one-way; a seed has no 12-word form |
- The domain tag is the 15 ASCII bytes `kez-bip39-12-v1`. Changing it
would break every existing 12-word identity; it MUST NOT change.
- Entropy is the raw BIP-39 entropy (checksum-validated). BIP-39's
PBKDF2 `to_seed(passphrase)` and BIP-32 derivation are **not** used:
KEZ has one identity per phrase and no derivation tree. KEZ phrases
are therefore not interchangeable with wallet derivations of the
same words.
- Parsers MUST trim surrounding whitespace and collapse internal runs
of whitespace to single spaces before decoding. Any other word count
is an error.
- New identities SHOULD be generated as 12-word phrases. Restore flows
MUST accept both lengths. To display a backup for a seed that has no
known 12-word phrase, use its 24-word encoding.
Test vectors (all implementations MUST match byte-for-byte; more in
[`python/MNEMONIC-TEST-VECTORS.md`](python/MNEMONIC-TEST-VECTORS.md)):
```
24 words: abandon ×23, art
seed 0000000000000000000000000000000000000000000000000000000000000000
primary ed25519:3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
12 words: abandon ×11, about
seed 09451c0f06588db78205e32a793536e15ae263c8f9ee6d14f5c6fd82b8bd20da
primary ed25519:9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70
```
---
## 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": "<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 this version.
### 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:<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 ````:
````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.<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` | Canonical: event of kind `30078` (NIP-78 app data), `d` tag `kez`. Verifiers MUST check kind 30078 and SHOULD also check kind `1` text notes and the kind `0` profile `about` field, where clients like NIP-07 extensions commonly publish | JSON in kind-30078 content; Markdown fence (§6.3) or compact (§6.2) in kinds 1 and 0 |
| `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.
### 9.1 Sigchain HTTP store (informative)
The reference store ([`rust-sig-server/`](rust-sig-server/)) exposes:
| Method | Path | |
|---|---|---|
| GET | `/v1/sigchains/<scheme>/<id>` | Whole chain as JSONL (one signed envelope per line); 404 if empty |
| GET | `/v1/sigchains/<scheme>/<id>/head` | Latest `{seq, hash}` |
| POST | `/v1/sigchains/<scheme>/<id>/events` | Append one signed sigchain event; the store re-checks tag, primary, `seq`, `prev`, and signature before accepting |
`<scheme>/<id>` is the primary key split at the colon (e.g.
`ed25519/2152f8…`). A store MAY be fronted by another origin (kez-chat
proxies it at `https://<server>/v1/sigchains/…`); clients should use
whatever `sigchain_url` they are given rather than constructing one.
---
## 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. |
| **BIP-39** (English wordlist) | Recovery phrases for Ed25519 seeds (§3.1). Raw entropy only — no PBKDF2 seed, no BIP-32. |
| **SHA-256** | Sigchain `prev` chaining; pre-hash for Schnorr signing (`sig = schnorr(SHA-256(JCS(payload)), key)`); 12-word phrase → seed (§3.1). |
| **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:**
```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.4**.
### 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.4` MUST accept all v0.4 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.4** (current) | BIP-39 recovery phrases for Ed25519 seeds (§3.1), with test vectors. Nostr channel: verifiers SHOULD also accept kinds 1 and 0. Sigchain HTTP store API documented (§9.1). Python implementation added to the conformance set. kez-chat listed as an application (§16). |
| v0.3 | 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 |
| `python/` | Python | `kez` package + CLI (`python -m kez`) |
| `rust-sig-server/` | Rust | Optional HTTP server for sigchain storage (§9.1) |
| `kez-chat/` | Rust + TypeScript | Application built on KEZ (§16) — not part of the conformance set |
Conformance between implementations is verified by `crosstest.sh` at
the repo root — a harness that generates artifacts with one
implementation and verifies them with another, in every direction and
every wire encoding, including recovery phrases (§3.1). A passing
cross-test is the operational definition of "implementations
interoperate."
Canonical fixtures live next to the implementation that owns them
(currently [`python/MNEMONIC-TEST-VECTORS.md`](python/MNEMONIC-TEST-VECTORS.md)).
A shared `vectors/` directory may collect them for third-party
implementations.
---
## 16. Applications (informative)
Applications MAY define their own envelope tags and payload types on
top of §4; these are outside KEZ conformance but reuse its envelope,
canonicalization, and signature suites.
**[kez-chat](kez-chat/README.md)** — end-to-end encrypted chat over
nostr, live at `kez.lat`. A chat account *is* an Ed25519 KEZ primary
key (recovered from a §3.1 phrase), and profile proofs are ordinary
KEZ claims mirrored to the user's sigchain. It adds:
| Tag / type | Purpose |
|---|---|
| `handle_registration` / `kez.chat.handle_registration` | Signed claim of a `handle@server` name on a chat server |
| `kez-heartbeat-v1` (`t` field of a JCS payload signed with the primary key, `ed25519-sha512-jcs`) | Binds a published message-encryption key (X25519 + ML-KEM-768) to the primary key |
Its messaging protocol (envelope versions, heartbeat ratchet,
post-quantum hybrid, nostr event kinds) is specified in
[`kez-chat/README.md`](kez-chat/README.md) and
[`kez-chat/SECURITY.md`](kez-chat/SECURITY.md).