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).
15 KiB
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:
{
"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:
{
"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.algis 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.
keyMUST equalpayload.primary(same key, expressed as asystem:identifierstring in the encoding native to its system).sigis the signature, hex-encoded (lowercase, no0xprefix). The signature coversJCS(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))>
z1is 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:
# 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:
{
"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_sigis the new key signing over the JCS of the rotation event withnew_key_sigomitted, 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
seqMUST be strictly monotonic, starting at 0.prevMUST equalsha256:+ hex SHA-256 of the JCS-canonicalized envelope (not just payload) of the prior entry. The first entry (seq: 0) has noprevfield.- A verifier MUST reject any sigchain with a broken hash chain, missing
prev, or non-monotonicseq. - Multiple sigchain copies MAY exist across stores; the longest valid chain wins. If two valid chains diverge at the same
seqwith 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
- Fetch the proof at the channel native to the input identifier's system (real network fetch — see §8.5).
- Decode the proof from its on-the-wire encoding (JSON, compact, or Markdown) into a signature envelope.
- Verify signature against the embedded primary key over
JCS(payload). - Confirm channel ownership — the claim was found at a location only the input identity controls.
- Locate the sigchain for the primary key (try known stores; multiple stores allowed).
- Validate the sigchain end-to-end (signatures, hash chain, monotonic seq, rotation closures).
- Check current status of the input identity — its
addmust not be followed by a matchingrevoke, and anyexpires_atmust not have passed. - 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).
- 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_athas 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 againstapi.github.comand/orraw.githubusercontent.comfor 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:
- The claim format defined in section 3, including the envelope (§3.2).
- All four proof encodings: JSON, Compact (
kez:z1:), Markdown, and DNS TXT (sections 4.1–4.4). - Two proof channels with real network fetch (§8.5):
- GitHub gist / profile README
- DNS TXT (
_kez.<domain>)
- Two key types:
ed25519(suite:ed25519-sha512-jcs)secp256k1-schnorr(suite:nostr-secp256k1-schnorr-sha256-jcs)
- The sigchain format defined in section 6, including hash-chain validation and rotation closure.
- 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.