KEZ is a portable, decentralized identity graph: a person signs claims
linking their many accounts, publishes those claims in places only the
claimed account can publish to, and anyone can verify the connections
without trusting a central server.
Layout
------
- SPEC.md Language-agnostic protocol spec (v0.2)
- rust/ Rust implementation: kez-core, kez-channels, kez-cli
- nodejs/ TypeScript port at full parity
- rust-sig-server/ Optional axum + SQLite storage server for sigchains
- crosstest.sh Cross-implementation interop harness
Capabilities (both implementations, byte-compatible)
----------------------------------------------------
- Two primary-key algorithms: nostr/secp256k1 Schnorr (BIP-340) and
Ed25519 (RFC 8032). Identifiers: nostr:npub1... and ed25519:<hex>.
- JCS (RFC 8785) canonicalization for everything signed.
- Four proof encodings: JSON envelope, compact (kez:z1:<base64url(zstd(json))>),
Markdown fence, DNS TXT.
- Five channel plugins (no API keys, no auth needed for any of them):
dns: system resolver, _kez.<domain> TXT records
github: public gist scan + <user>/<user> profile README fallback
nostr: kind-30078 events from default relays
bluesky: public AppView author feed
ap: WebFinger + actor JSON (alias mastodon:)
- Identical CLI surface:
kez identity new [--key-type nostr|ed25519]
kez claim create <subject> (--nsec | --ed25519-seed) [--format ...] [--out ...]
kez claim dns <domain> (--nsec | --ed25519-seed)
kez verify file <path>
kez verify id <identifier>
kez sigchain add|revoke|show|export|publish
- Sigchains: append-only signed log per primary, hash-chained per spec §6,
stored locally at ~/.kez/sigchains/, exportable as JSONL or kez:zc1: bundle.
- Sigchain publish destinations: chain server, web (file dump), DNS (zone
record print), nostr (kind-30078 wrapping event).
kez-sig-server
--------------
Optional storage tier. Axum + SQLite, single binary, no external deps.
- No auth — the cryptography is the access control. The server validates
every signature, every seq, every prev hash before storing.
- REST API: POST /v1/sigchains/{scheme}/{id}/events (append signed event,
201 with new head hash or 4xx); GET /{scheme}/{id} (full chain as JSONL);
GET /head; GET /healthz.
- Designed for one central instance for now; the design doesn't preclude
running more later (clients gain a configurable list, verifiers
reconcile per spec §6.2).
- Channel-based publishing remains the always-available fallback if the
server is unavailable.
Tests
-----
- rust/ 99 tests
- rust-sig-server/ 10 integration tests (real HTTP, real SQLite)
- nodejs/ 91 tests (vitest)
- crosstest.sh 19 cross-impl scenarios — proves JCS bytes,
Schnorr + Ed25519 sigs, all four claim encodings,
and the sigchain JSONL bundle are byte-compatible
between Rust and Node in both directions.
What's not done yet
-------------------
- verify id consulting the sigchain for revocations (data path exists,
just not wired into the verifier output).
- rotate and add_device sigchain ops (types reserved).
- expires_at enforcement during claim verification.
- Typed VerificationStatus.status reflecting the five failure modes.
- Auth-required publishers (GitHub gist, Bluesky, ActivityPub).
367 lines
15 KiB
Markdown
367 lines
15 KiB
Markdown
# KEZ Specification
|
||
|
||
**Status:** Draft v0.2
|
||
**Date:** 2026-05-23
|
||
|
||
KEZ is a portable, decentralized identity graph. It lets a person say:
|
||
|
||
> "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.1 Primary Key
|
||
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:
|
||
|
||
- `nostr` / `secp256k1` (Schnorr signatures, BIP-340)
|
||
- `ed25519`
|
||
|
||
Future, non-normative: passkeys, Bluesky DID keys, Ethereum keys, GPG keys.
|
||
|
||
### 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
|
||
|
||
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.
|
||
|
||
Canonical examples:
|
||
|
||
| System | Example |
|
||
|------------|--------------------------------------|
|
||
| `nostr` | `nostr:npub1abc...` |
|
||
| `github` | `github:jason` |
|
||
| `dns` | `dns:jason.example.com` |
|
||
| `bluesky` | `bluesky:jason.bsky.social` |
|
||
| `did` | `did:plc:abc...` |
|
||
| `ens` | `ens:jason.eth` |
|
||
| `mastodon` | `mastodon:@jason@server.social` |
|
||
| `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.
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 3. The Claim Format
|
||
|
||
A claim is a UTF-8 JSON object with the following required fields:
|
||
|
||
```json
|
||
{
|
||
"type": "kez.claim",
|
||
"version": 1,
|
||
"primary": "nostr:npub1abc...",
|
||
"subject": "github:jason",
|
||
"created_at": "2026-05-19T18:00:00Z"
|
||
}
|
||
```
|
||
|
||
KEZ uses dotted, namespaced type strings throughout: `kez.claim`, `kez.sigchain.event`, `kez.device`, etc.
|
||
|
||
Optional fields:
|
||
|
||
- `expires_at` — RFC 3339 timestamp; after this time the claim is treated as expired.
|
||
- `nonce` — opaque string to prevent replay across publishing channels.
|
||
- `note` — short human-readable note (≤256 chars).
|
||
|
||
### 3.1 Canonicalization
|
||
For signing and verification, the claim MUST be serialized as **JCS** (RFC 8785, JSON Canonicalization Scheme). Implementations MUST NOT rely on incidental key ordering.
|
||
|
||
### 3.2 Signature Envelope
|
||
A signed claim is wrapped in:
|
||
|
||
```json
|
||
{
|
||
"kez": "claim",
|
||
"payload": { ... the claim object ... },
|
||
"signature": {
|
||
"alg": "nostr-secp256k1-schnorr-sha256-jcs",
|
||
"key": "nostr:npub1abc...",
|
||
"sig": "<hex signature over JCS(payload)>"
|
||
}
|
||
}
|
||
```
|
||
|
||
- `kez` (top-level) tags the envelope kind: `"claim"`, `"sigchain_event"`, etc. This lets generic tooling route envelopes without parsing the payload.
|
||
- `alg` is the **full signature suite**, identifying key family + hash + canonicalization. Defined suites in v1:
|
||
- `nostr-secp256k1-schnorr-sha256-jcs` — BIP-340 Schnorr over secp256k1, SHA-256, JCS.
|
||
- `ed25519-sha512-jcs` — Ed25519 (RFC 8032), JCS-canonicalized payload.
|
||
- `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`.
|
||
|
||
---
|
||
|
||
## 4. Proof 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.
|
||
|
||
### 4.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:`)
|
||
A 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))>
|
||
```
|
||
|
||
- `z1` is the compact format version. Future versions (`z2`, …) MAY change the compression or framing.
|
||
- Compression: zstd, default level 3.
|
||
- Encoding: base64url (RFC 4648 §5), **no padding**.
|
||
- The decoded payload MUST be a valid signature envelope per section 3.2.
|
||
|
||
Implementations MUST support encoding and decoding `kez:z1:`.
|
||
|
||
### 4.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: `nostr:npub1abc...`
|
||
- 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, and parse as JSON. Surrounding prose is informational only.
|
||
|
||
### 4.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** (section 4.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.
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 5. Publishing Proofs
|
||
|
||
The claim must be published somewhere only the claimed identity can publish to. Each `system` defines its own channel(s) and default encoding:
|
||
|
||
| 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"`.
|
||
|
||
### 6.1 Operations
|
||
|
||
- **`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
|
||
|
||
KEZ is store-agnostic. The verifier 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
|
||
- Local filesystem (for development/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.
|
||
|
||
---
|
||
|
||
## 8. Verification
|
||
|
||
### 8.1 Verifier Input
|
||
```
|
||
kez verify <identifier>
|
||
```
|
||
|
||
Example: `kez verify github:jason`
|
||
|
||
### 8.2 Algorithm
|
||
|
||
1. **Fetch the proof** at the channel native to the input identifier's system (real network fetch — see §8.5).
|
||
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
|
||
|
||
```
|
||
Primary: nostr:npub1abc...
|
||
|
||
Verified identities:
|
||
github:jason valid
|
||
dns:jason.example valid
|
||
bluesky:jason.bsky.social valid
|
||
ens:jason.eth unreachable
|
||
Status: valid
|
||
Confidence: strong
|
||
```
|
||
|
||
`Confidence` is a function of: number of independently-verified identities, channel diversity, sigchain length and age.
|
||
|
||
### 8.4 Failure Modes
|
||
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.
|
||
|
||
### 8.5 Network Fetching Is Required
|
||
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.
|
||
- `github:` — HTTPS fetch against `api.github.com` and/or `raw.githubusercontent.com` for the user's gist or profile README.
|
||
- `web:` — HTTPS fetch of `/.well-known/kez.json`.
|
||
- `nostr:` — connect to one or more relays and fetch the relevant event.
|
||
- `bluesky:` — HTTPS fetch against the user's PDS.
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 9. Starting Points (Discovery)
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 10. MVP Scope (v0.2)
|
||
|
||
A v0.2 implementation MUST support:
|
||
|
||
1. The claim format defined in section 3, including the envelope (§3.2).
|
||
2. All four proof encodings: JSON, Compact (`kez:z1:`), Markdown, and DNS TXT (sections 4.1–4.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
|
||
kez-channels network adapters: github, dns, web, nostr, bluesky, ...
|
||
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).
|
||
|
||
---
|
||
|
||
## 12. Test Vectors
|
||
|
||
A `vectors/` directory at the repo root contains canonical inputs and expected outputs:
|
||
|
||
- `vectors/claims/` — signed claim JSON + the expected JCS bytes + signature + compact encoding + markdown encoding.
|
||
- `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.
|
||
|
||
---
|
||
|
||
## 13. One-Sentence Summary
|
||
|
||
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.
|