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.
This commit is contained in:
Tudisco 2026-05-24 22:51:51 -06:00
parent 7b8b136e92
commit 6dfd5a6938

704
SPEC.md
View File

@ -1,149 +1,228 @@
# KEZ Specification # KEZ Specification
**Status:** Draft v0.2 **Status:** Draft v0.3
**Date:** 2026-05-23 **Date:** 2026-05-25
**Conformance:** Implementations MUST conform to the formats and behaviors
KEZ is a portable, decentralized identity graph. It lets a person say: defined here. Two implementations conform iff a claim signed by one
verifies in the other and vice versa (see §15).
> "These accounts, keys, domains, and identities are all me."
…without depending on any central authority to bless the claim. Verification is done by cryptographic signatures against keys the user already controls.
This document is the language-agnostic specification. Implementations (Rust, and others) MUST conform to the formats and behaviors defined here.
--- ---
## 1. Core Concepts ## 1. Summary
### 1.1 Primary Key KEZ is a portable identity graph. Users sign claims connecting their many
The user chooses one key as their current "main identity key." KEZ does not invent a new key type — it reuses keys people already hold. Supported key types in v1: accounts, publish those claims in places they control, and anyone can
verify the connections without trusting a central server.
- `nostr` / `secp256k1` (Schnorr signatures, BIP-340) ```
- `ed25519` "These accounts, keys, domains, and identities are all me."
```
Future, non-normative: passkeys, Bluesky DID keys, Ethereum keys, GPG keys. No central authority blesses the claim. Verification is done by
cryptographic signatures against keys the user already controls.
### 1.2 Claim
A signed JSON object asserting that the primary key also controls some other identity (a GitHub account, a domain, a Bluesky handle, etc.).
### 1.3 Proof
A claim, plus evidence that it was published in a location only the claimed identity can publish to (e.g. the GitHub user's own gist, the domain's DNS).
### 1.4 Sigchain
A signed, append-only log of identity events (adds, revokes, key rotations) owned by a primary key.
### 1.5 Identity Graph
The set of all currently-active proofs reachable from a starting identifier, walked through the sigchain.
--- ---
## 2. Identifiers ## 2. Glossary
A KEZ identifier is always `system:identifier` — the system prefix is required, never optional. This lets a human glance at an identifier and know where the key/account lives. | 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: Canonical examples:
| System | Example | | System | Example |
|------------|--------------------------------------| |---|---|
| `nostr` | `nostr:npub1abc...` | | `nostr` | `nostr:npub1abc...` |
| `ed25519` | `ed25519:2152f8d19b791d24...` (64 lowercase hex chars) |
| `github` | `github:jason` | | `github` | `github:jason` |
| `dns` | `dns:jason.example.com` | | `dns` | `dns:jason.example.com` |
| `web` | `web:https://jason.example.com` |
| `bluesky` | `bluesky:jason.bsky.social` | | `bluesky` | `bluesky:jason.bsky.social` |
| `did` | `did:plc:abc...` | | `did` | `did:plc:abc...` |
| `ens` | `ens:jason.eth` | | `ens` | `ens:jason.eth` |
| `mastodon` | `mastodon:@jason@server.social` | | `ap` | `ap:@jason@mastodon.social` (alias: `mastodon:@...`) |
| `farcaster`| `farcaster:fid:12345` | | `farcaster` | `farcaster:fid:12345` |
| `web` | `web:https://jason.example.com` |
A bare `npub1...` (no `nostr:` prefix) is **not** a valid KEZ identifier. CLI tools MAY accept it as input ergonomics, but they MUST normalize it to `nostr:npub1...` before signing, publishing, or storing. 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). 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. 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. The Claim Format ## 4. The Signature Envelope
A claim is a UTF-8 JSON object with the following required fields: 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 v0.3.
### 5.1 Claim payload
A claim asserts that a primary key controls a subject identity.
```json ```json
{ {
"type": "kez.claim", "type": "kez.claim",
"version": 1, "version": 1,
"primary": "nostr:npub1abc...", "primary": "ed25519:2152f8d19b791d24...",
"subject": "github:jason", "subject": "github:jason",
"created_at": "2026-05-19T18:00:00Z" "created_at": "2026-05-19T18:00:00Z"
} }
``` ```
KEZ uses dotted, namespaced type strings throughout: `kez.claim`, `kez.sigchain.event`, `kez.device`, etc. Required fields: `type`, `version`, `primary`, `subject`, `created_at`.
Optional fields: Optional fields:
- `expires_at` — RFC 3339 timestamp; after this time the claim is treated as expired. | Field | Type | Meaning |
- `nonce` — opaque string to prevent replay across publishing channels. |---|---|---|
- `note` — short human-readable note (≤256 chars). | `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. |
### 3.1 Canonicalization Type strings are dotted and namespaced (`kez.claim`,
For signing and verification, the claim MUST be serialized as **JCS** (RFC 8785, JSON Canonicalization Scheme). Implementations MUST NOT rely on incidental key ordering. `kez.sigchain.event`, future `kez.device`, etc.).
### 3.2 Signature Envelope ### 5.2 Sigchain event payload
A signed claim is wrapped in:
```json ```json
{ {
"kez": "claim", "type": "kez.sigchain.event",
"payload": { ... the claim object ... }, "version": 1,
"signature": { "primary": "ed25519:2152f8d19b791d24...",
"alg": "nostr-secp256k1-schnorr-sha256-jcs", "seq": 7,
"key": "nostr:npub1abc...", "prev": "sha256:<hex of prior envelope's JCS bytes>",
"sig": "<hex signature over JCS(payload)>" "created_at": "2026-05-20T12:00:00Z",
} "op": "add",
"payload": { ... op-specific ... }
} }
``` ```
- `kez` (top-level) tags the envelope kind: `"claim"`, `"sigchain_event"`, etc. This lets generic tooling route envelopes without parsing the payload. `seq` is strictly monotonic starting from 0. `prev` is `sha256:` plus
- `alg` is the **full signature suite**, identifying key family + hash + canonicalization. Defined suites in v1: hex SHA-256 of the JCS bytes of the **prior signed envelope** (whole
- `nostr-secp256k1-schnorr-sha256-jcs` — BIP-340 Schnorr over secp256k1, SHA-256, JCS. envelope, not just its payload). The first event (`seq: 0`) has no
- `ed25519-sha512-jcs` — Ed25519 (RFC 8032), JCS-canonicalized payload. `prev` field.
- `key` MUST equal `payload.primary` (same key, expressed as a `system:identifier` string in the encoding native to its system).
- `sig` is the signature, **hex-encoded** (lowercase, 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`. Per-op `payload` shapes are defined in §8.
--- ---
## 4. Proof Encodings ## 6. Wire Encodings
A "proof" is a signed claim envelope, encoded for transport over a specific channel. KEZ defines four encodings; the same envelope round-trips losslessly between them. A signed envelope can be transported in four interchangeable forms.
All carry the same logical content; choose the one that fits the
channel.
### 4.1 JSON ### 6.1 JSON
The signature envelope serialized as standard JSON (pretty or compact). Native form. Used for HTTP fetches, `.well-known/kez.json`, and developer tooling.
### 4.2 Compact (`kez:z1:`) Standard JSON serialization of the envelope (pretty or compact). Native
A URL-safe, copy-pasteable form designed for QR codes, DNS TXT records, chat messages, and other tight channels: 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(json-envelope))> kez:z1:<base64url-no-pad(zstd(envelope-as-compact-json))>
``` ```
- `z1` is the compact format version. Future versions (`z2`, …) MAY change the compression or framing. - `z1` is the compact format version. Future versions (`z2`, …) may
- Compression: zstd, default level 3. change compression or framing.
- Encoding: base64url (RFC 4648 §5), **no padding**. - Compression: **zstd**, default level 3.
- The decoded payload MUST be a valid signature envelope per section 3.2. - 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:`. Implementations MUST support encoding and decoding `kez:z1:`.
### 4.3 Markdown ### 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 ````:
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 ````markdown
# KEZ Proof # KEZ Proof
This account publishes a signed KEZ identity claim. This account publishes a signed KEZ identity claim.
- Primary: `nostr:npub1abc...` - Primary: `ed25519:2152f8d19b...`
- Subject: `github:jason` - Subject: `github:jason`
- Created: `2026-05-19T18:00:00Z` - Created: `2026-05-19T18:00:00Z`
@ -156,84 +235,150 @@ This account publishes a signed KEZ identity claim.
``` ```
```` ````
Parsers MUST locate the first ```` ```kez ```` fence, take the contents up to the next ```` ``` ````, trim whitespace, and parse as JSON. Surrounding prose is informational only. 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
### 4.4 DNS TXT
For `dns:` subjects, the proof is published as a TXT record at: For `dns:` subjects, the proof is published as a TXT record at:
``` ```
_kez.<domain> _kez.<domain>
``` ```
The TXT record value is the **compact encoding** (section 4.2): The TXT record value is the **compact encoding** (§6.2):
``` ```
kez:z1:KLUv_QBY... 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. 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 for a single TXT record even compressed, implementations MUST publish a `web:` proof instead and add a `dns:` claim pointing at the domain — chaining, not splitting. 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:`)
## 5. Publishing Proofs For transporting an entire sigchain as one string (nostr event content,
DNS TXT pointer, ActivityPub profile field):
The claim must be published somewhere only the claimed identity can publish to. Each `system` defines its own channel(s) and default encoding: ```
kez:zc1:<base64url-no-pad(zstd(jsonl))>
| System | Channel | Encoding |
|------------|------------------------------------------------------------|------------------|
| `github` | Public gist owned by the user, or `README` on profile repo | Markdown (§4.3) |
| `dns` | TXT record at `_kez.<domain>` | Compact (§4.2) |
| `web` | `https://<domain>/.well-known/kez.json` | JSON (§4.1) |
| `nostr` | Nostr event of kind `30078` (parameterized replaceable) | JSON (§4.1) |
| `bluesky` | Public post or record on the user's PDS | JSON or Markdown |
| `mastodon` | Public profile post or pinned status | Markdown (§4.3) |
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.**
---
## 6. The Sigchain
The sigchain is an append-only, signed log of events for a single primary key. Each entry's payload is:
```json
{
"type": "kez.sigchain.event",
"version": 1,
"primary": "nostr:npub1abc...",
"seq": 7,
"prev": "sha256:<hex SHA-256 of the prior entry's JCS envelope>",
"created_at": "2026-05-20T12:00:00Z",
"op": "add" | "revoke" | "rotate" | "add_device",
"payload": { ... op-specific fields ... }
}
``` ```
Wrapped in the same signature envelope as a claim, with top-level tag `"kez": "sigchain_event"`. Where the inner content is JSONL — one signed sigchain envelope per
line. Parsers MUST decompress, split on newlines, and parse each line
### 6.1 Operations as a sigchain event envelope.
- **`add`** — payload: `{ "subject": "github:jason", "proof_url": "..." }`. Adds an identity to the graph.
- **`revoke`** — payload: `{ "subject": "github:jason" }`. Removes a previously-added identity.
- **`rotate`** — payload: `{ "new_primary": "nostr:npub1xyz...", "new_key_sig": "<hex>" }`. Rotates the primary key. `new_key_sig` is the new key signing over the JCS of the rotation event with `new_key_sig` omitted, proving the rotation is two-way authorized.
- **`add_device`** — payload: `{ "device_key": "...", "label": "..." }`. Adds a secondary key authorized to sign on behalf of this primary.
### 6.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 entry (`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, the verifier MUST report a **fork** rather than silently picking one.
### 6.3 Key Rotation
After a `rotate`, all subsequent entries MUST be signed by the new primary, and each entry's `primary` field MUST reflect the new key. A verifier walks rotations by following the chain: the old key's `rotate` event authorizes the new key, and the new key's `new_key_sig` over that same event closes the loop.
--- ---
## 7. Storage ## 7. Channels
KEZ is store-agnostic. The verifier MUST be able to fetch sigchains and proofs from any of: 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 - nostr relays
- GitHub (gists, repos) - GitHub (gists, repos)
@ -241,37 +386,41 @@ KEZ is store-agnostic. The verifier MUST be able to fetch sigchains and proofs f
- DNS (TXT records) - DNS (TXT records)
- IPFS - IPFS
- Bluesky PDS - Bluesky PDS
- Local filesystem (for development/testing) - ActivityPub (WebFinger + actor JSON)
- Local filesystem (for development and testing)
There is no required KEZ server. Implementations SHOULD support multiple stores in parallel and treat them as mirrors, not sources of truth — the signatures are the source of truth. 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.
--- ---
## 8. Verification ## 10. Verification
### 8.1 Verifier Input ### 10.1 Starting point
```
kez verify <identifier>
```
Example: `kez verify github:jason` 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.
### 8.2 Algorithm Same mental model as the web: you don't know every URL; you start at
one and follow links.
1. **Fetch the proof** at the channel native to the input identifier's system (real network fetch — see §8.5). ### 10.2 Input and output
2. **Decode** the proof from its on-the-wire encoding (JSON, compact, or Markdown) into a signature envelope.
3. **Verify signature** against the embedded primary key over `JCS(payload)`.
4. **Confirm channel ownership** — the claim was found at a location only the input identity controls.
5. **Locate the sigchain** for the primary key (try known stores; multiple stores allowed).
6. **Validate the sigchain** end-to-end (signatures, hash chain, monotonic seq, rotation closures).
7. **Check current status** of the input identity — its `add` must not be followed by a matching `revoke`, and any `expires_at` must not have passed.
8. **Walk other identities** in the sigchain. For each, re-fetch and re-verify its proof against its own channel (best-effort; some may be unreachable).
9. **Return the graph** with per-identity status.
### 8.3 Verifier Output Input: one identifier.
Output: a graph of identities reachable from the input, each with a
status. Example:
``` ```
Primary: nostr:npub1abc... Primary: ed25519:2152f8d19b791d24...
Verified identities: Verified identities:
github:jason valid github:jason valid
@ -282,85 +431,234 @@ Status: valid
Confidence: strong Confidence: strong
``` ```
`Confidence` is a function of: number of independently-verified identities, channel diversity, sigchain length and age. `Confidence` is implementation-defined but is typically a function of:
number of independently-verified subjects, channel diversity, sigchain
length and age.
### 8.4 Failure Modes ### 10.3 Algorithm (5 phases)
A verifier MUST distinguish between:
- **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 statuses MUST NOT be conflated. A verifier MUST surface the distinction to its caller. 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).
### 8.5 Network Fetching Is Required ### 10.4 Failure modes
A conformant verifier performs **real network fetches** for each channel:
- `dns:` — real DNS resolution against the system resolver (or a configurable resolver), reading the TXT record at `_kez.<domain>`. Verifying a TXT *value* a caller already obtained is a development helper, not conformant verification. A verifier MUST distinguish:
- `github:` — HTTPS fetch against `api.github.com` and/or `raw.githubusercontent.com` for the user's gist or profile README.
| 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`. - `web:` — HTTPS fetch of `/.well-known/kez.json`.
- `nostr:` — connect to one or more relays and fetch the relevant event. - `nostr:` — connect to one or more relays; fetch the relevant event.
- `bluesky:` — HTTPS fetch against the user's PDS. - `bluesky:` — HTTPS fetch against the user's PDS.
- `ap:` — HTTPS fetch of WebFinger followed by actor JSON.
A verifier that only reads local files is not a conformant verifier — it's a signature-checking helper. Conformant verifiers MAY expose a local-file mode for testing, but it MUST be clearly labeled as such. 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.
--- ---
## 9. Starting Points (Discovery) ## 11. Cryptographic Primitives
A KEZ verifier does not magically know every account a user has. It always starts from one identifier provided externally (by an app, a QR code, a chat message, a posting key). From that starting point it walks outward via the sigchain. | 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. |
This is the same model as the web: you don't know every URL, you start at one and follow links. KEZ is the signed-identity equivalent. The suite string names every choice (`<key-family>-<hash>-<canonicalization>`)
so a verifier can dispatch without guessing. Future suites follow the
same pattern.
--- ---
## 10. MVP Scope (v0.2) ## 12. Worked Example (reproducible)
A v0.2 implementation MUST support: 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).
1. The claim format defined in section 3, including the envelope (§3.2). **Inputs (TEST ONLY — do not use this key for anything real):**
2. All four proof encodings: JSON, Compact (`kez:z1:`), Markdown, and DNS TXT (sections 4.14.4).
3. Two proof channels with **real network fetch** (§8.5):
- GitHub gist / profile README
- DNS TXT (`_kez.<domain>`)
4. Two key types:
- `ed25519` (suite: `ed25519-sha512-jcs`)
- `secp256k1-schnorr` (suite: `nostr-secp256k1-schnorr-sha256-jcs`)
5. The sigchain format defined in section 6, including hash-chain validation and rotation closure.
6. A verifier CLI matching section 8, distinguishing all five failure modes (§8.4).
Anything beyond this list is post-MVP.
---
## 11. Suggested Implementation Layout
Language-agnostic, but recommended:
``` ```
kez-core claim + sigchain types, signing, verification, all four encodings Ed25519 seed (32 bytes, hex):
kez-channels network adapters: github, dns, web, nostr, bluesky, ... 4242424242424242424242424242424242424242424242424242424242424242
kez-cli command-line verifier
kez-web paste an identifier, see the graph
``` ```
Each language implementation lives in its own top-level directory (e.g. `rust/`, `ts/`, `go/`). All implementations conform to this spec; cross-language conformance is verified by sharing test vectors (see section 12). ```
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.
--- ---
## 12. Test Vectors ## 13. Versioning & Wire Compatibility
A `vectors/` directory at the repo root contains canonical inputs and expected outputs: Spec versions are `major.minor`. The current spec is **v0.3**.
- `vectors/claims/` — signed claim JSON + the expected JCS bytes + signature + compact encoding + markdown encoding. ### What's non-breaking (minor bump)
- `vectors/sigchains/` — full sigchain examples (valid, revoked, rotated, forked).
- `vectors/proofs/` — fixtures for each channel adapter.
Every implementation's test suite MUST run against these vectors. Two implementations conform iff they produce identical outputs for the same vectors. - 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.
--- ---
## 13. One-Sentence Summary ## 14. Changelog
KEZ is a portable identity graph where 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. | 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.