Kez/SPEC.md
Tudisco d0db6f00f1 Initial implementation of KEZ — protocol, two impls, and storage server
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).
2026-05-24 14:41:00 -06:00

15 KiB
Raw Blame History

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.
  • 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:

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