# KEZ — Rust Implementation KEZ is a portable, decentralized identity graph. It lets one person say: > "These accounts, keys, domains, and identities are all me." …without depending on any central authority to vouch for it. Every connection is proven by a signature against a key the user already controls — a nostr key, an Ed25519 key, a passkey, an Ethereum key, a GPG key, whatever they've got. The protocol itself is specified in [`../SPEC.md`](../SPEC.md). This directory is the Rust implementation of that spec. If you've used [Keybase](https://keybase.io), the mental model is similar: you publish a signed "I control X" proof in a place only X can publish to (your gist, your DNS, your nostr key), and anyone can fetch + verify it. The difference: KEZ has no *required* central server. There is an optional [chain server](../rust-sig-server/) for sigchain storage, but using it is a convenience — the protocol works the same whether your sigchain lives there, in a gist, in DNS, in a nostr event, or on your own website. The proofs live wherever you publish them; the verifier just walks the links. --- ## What's in this directory ``` rust/ ├── Cargo.toml Workspace manifest ├── crates/ │ ├── kez-core/ Types, signing, verification, JCS, all four encodings │ ├── kez-channels/ One file per channel (github, dns, nostr, bluesky, ap) │ └── kez-cli/ Thin CLI that dispatches through the channel registry └── README.md (this file) ``` Three crates, ~2,500 lines of Rust, **99 tests**. --- ## Quick start > **New to KEZ?** Read [**`TUTORIAL.md`**](TUTORIAL.md) — a friendly > step-by-step walkthrough that takes you from "I have a nostr `nsec`" > to "I have a verified, published sigchain." It assumes nothing. > > This README is the reference; the tutorial is the on-ramp. ```sh # Build, test, and install the `kez` binary to ~/.cargo/bin (one time) cargo build cargo test cargo install --path crates/kez-cli ``` After that, the examples below use bare `kez`. For dev iteration without installing, substitute `cargo run -p kez-cli --` for `kez` in any command. ### End-to-end walkthrough **1. Create a primary key.** ```sh kez identity new ``` Outputs: ``` Primary: nostr:npub1tkf... Public: npub1tkf... Secret: nsec1... ``` Save the `nsec` somewhere safe — it's the only thing that can sign as this identity. **2. Sign a claim** that this primary key also controls your GitHub account. Pick the output format that fits where you'll publish: ```sh # Markdown (for a GitHub gist or profile README) kez claim create github:jason \ --nsec nsec1... --format markdown --out github-jason.kez.md # Compact (one-liner for QR codes, chat, DNS TXT) kez claim create github:jason --nsec nsec1... --format compact # JSON envelope (for /.well-known/kez.json) kez claim create github:jason --nsec nsec1... ``` **3. Publish the proof** somewhere only the claimed account can publish to: | Channel | Where to put the proof | |---|---| | `github:` | A public gist whose filename includes `kez`, or your `/` profile README | | `dns:` | TXT record at `_kez.` (use `kez claim dns ...` to get the zone-file line) | | `nostr:` | A kind-30078 event published by the same key | | `bluesky:` | A public post containing the compact form or the Markdown fence | | `ap:` / `mastodon:` | Your profile metadata field (preferred) or anywhere in your bio | **4. Verify it** from anywhere: ```sh kez verify id github:jason ``` Output: ``` Primary: nostr:npub1tkf... Verified identities: - github:jason Status: valid Confidence: strong ``` --- ## CLI reference ### `identity new` Generate a new primary key. Defaults to nostr/secp256k1 (prints `nsec` / `npub`); pass `--key-type ed25519` to generate an Ed25519 key instead (prints the 32-byte seed and pubkey, both in hex). Stores nothing on disk. ### `claim create (--nsec | --ed25519-seed ) [--format json|markdown|compact] [--out ]` Sign a KEZ claim asserting that the supplied signing key also controls ``. Pass exactly one of `--nsec` (nostr) or `--ed25519-seed` (Ed25519). Defaults to JSON output. `--out` writes to a file; otherwise prints to stdout. ### `claim dns (--nsec | --ed25519-seed )` Like `claim create dns:` but additionally prints a ready-to-paste zone-file line with the proof properly chunked into TXT segments. ### `verify file ` Parse and verify a local proof file (any encoding). Developer helper — not a real channel. ### `verify id ` Fetch the proof for `` from its native channel and verify it. The identifier's `system:` prefix selects the channel plugin: ```sh kez verify id dns:jason.example.com kez verify id github:jason kez verify id nostr:npub1... kez verify id bluesky:jason.bsky.social kez verify id ap:@jason@mastodon.social kez verify id mastodon:@jason@mastodon.social ``` ### `sigchain add --nsec | --ed25519-seed [--proof-url ]` Append an `add` event to the local sigchain for the signing key. Chain files live at `~/.kez/sigchains/.jsonl`. ### `sigchain revoke --nsec | --ed25519-seed` Append a `revoke` event for a previously added subject. ### `sigchain show [--primary ] | [--nsec | --ed25519-seed]` Print the chain: primary, file path, length, one line per event, head hash. Read-only — `--primary` works without a key. ### `sigchain export [--primary ] | [--nsec | --ed25519-seed] [--format jsonl|compact] [--out ]` Export the chain in a portable format (`jsonl` per spec §6, or `compact` = `kez:zc1:`). ### `sigchain publish [--primary ] | [--nsec | --ed25519-seed] [destinations...]` Push the chain to one or more places. Destinations are flags and any combination can be passed: - `--server ` — POST every event to a [kez-sig-server](../rust-sig-server) - `--web --out ` — write the JSONL bundle to a file (you upload it to `https:///.well-known/kez-sigchain.jsonl`) - `--dns ` — print the TXT zone records for `_kez-chain.` - `--nostr ` — publish the compact bundle as a kind-30078 event signed by your nostr key (requires `--nsec`) --- ## Channels Every channel lives in its own file under [`crates/kez-channels/src/`](crates/kez-channels/src/) and implements one trait: ```rust #[async_trait] pub trait Channel: Send + Sync { fn system(&self) -> &'static str; async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult; } ``` | File | System | Fetches | API key needed? | |---|---|---|---| | [`dns.rs`](crates/kez-channels/src/dns.rs) | `dns:` | `_kez.` TXT via system resolver | No | | [`github.rs`](crates/kez-channels/src/github.rs) | `github:` | Public gists then `/` profile README | No (60 req/hr anon, 5000 with `GITHUB_TOKEN`) | | [`nostr.rs`](crates/kez-channels/src/nostr.rs) | `nostr:` | Kind-30078 events from damus / nos.lol / primal relays | No | | [`bluesky.rs`](crates/kez-channels/src/bluesky.rs) | `bluesky:` | Author feed via the public Bluesky AppView | No | | [`activitypub.rs`](crates/kez-channels/src/activitypub.rs) | `ap:`, `mastodon:` | WebFinger → actor JSON → profile fields + bio | No | Each channel has a sibling test file in [`crates/kez-channels/tests/`](crates/kez-channels/tests/) using either `wiremock` (HTTP channels) or a fake fetcher trait (DNS, nostr). --- ## Adding a new channel The pattern is small and self-contained. 1. **Add the channel file.** Create `crates/kez-channels/src/.rs`. Implement `Channel`. Keep pure helpers (URL builders, parsers) as standalone `pub fn`s so they can be unit-tested without I/O. 2. **Register it.** In [`lib.rs`](crates/kez-channels/src/lib.rs), add `pub mod ;` and a line in `Registry::with_defaults`: ```rust r.register(Arc::new(my_channel::MyChannel::new().map_err(ChannelError::Other)?)); ``` If one adapter handles multiple identifier prefixes, use `register_as`: ```rust let adapter = Arc::new(...); r.register(adapter.clone()); // canonical r.register_as("alias", adapter); // alias ``` 3. **Add tests.** Create `crates/kez-channels/tests/.rs`. For HTTP channels, use `wiremock` with a constructor like `MyChannel::with_base(client, mock_server.uri())`. For network-protocol channels (DNS, nostr), abstract the fetcher behind a trait and inject a fake. 4. **Done.** `kez verify id :...` now works through the CLI without any CLI changes. --- ## Library use The crates are usable directly: ```rust use kez_channels::Registry; use kez_core::Identity; let registry = Registry::with_defaults()?; let identity = Identity::parse("github:jason")?; let hit = registry.verify(&identity).await?; println!("verified {} via {}", hit.proof.payload.subject, identity.scheme()); ``` `kez-core` exports the claim/envelope types, signing primitives, and the four encoding round-trips. `kez-channels` exports the `Channel` trait, the `ChannelError` enum, the `Registry`, and one module per built-in channel. --- ## Proof formats A signed claim is one envelope, four wire forms. **Envelope shape:** ```json { "kez": "claim", "payload": { "type": "kez.claim", "version": 1, "subject": "github:jason", "primary": "nostr:npub1...", "created_at": "2026-05-22T12:00:00Z" }, "signature": { "alg": "nostr-secp256k1-schnorr-sha256-jcs", "key": "nostr:npub1...", "sig": "" } } ``` | Form | Where | Encoding | |---|---|---| | JSON | `/.well-known/kez.json`, HTTP APIs | Standard JSON of the envelope | | Compact | DNS TXT, QR codes, chat | `kez:z1:` | | Markdown | GitHub gist, README, bio | Human prose + a ```` ```kez ```` fenced block | | Legacy DNS | (deprecated) | `kez1:` — parser still accepts it | Signatures are computed over **JCS** (RFC 8785) of the payload, not the envelope. That makes the bytes-being-signed deterministic across implementations. --- ## Failure modes A verifier returns one of five distinct statuses (mapped to `ChannelError` variants): | Variant | Meaning | |---|---| | `Unreachable(_)` | Channel couldn't be reached (DNS failure, HTTP 5xx, relay down) | | `NotFound(_)` | Channel reachable but no KEZ proof was found | | `Invalid(_)` | A proof was found but failed signature or format check | | `SubjectMismatch { expected, found }` | Signature valid, but the proof claims a different subject than what was requested | | `NoChannelForSystem(_)` | The identifier's `system:` has no registered channel | The CLI surfaces these as error messages today; the typed enum is in place for the verifier to expose them programmatically. --- ## What's not done yet This implementation covers the spec's v0.2 MVP plus four channels. Known gaps: - **Sigchain walking during `verify`** — the sigchain type and CLI commands exist (see `kez sigchain ...` above), and a separate [chain server](../rust-sig-server/) can store them, but `verify id` doesn't yet fetch a chain to check for revocations. Today every verify is a single one-shot proof check. `rotate` and `add_device` ops are also not implemented yet. - **`expires_at` enforcement** — the field exists on `ClaimPayload` and serializes correctly, but `SignedClaim::verify` doesn't reject expired proofs yet. - **Typed `VerificationStatus.status`** — currently hardcoded strings (`"valid"`, `"strong"`). The `ChannelError` enum is ready to plumb the five failure modes through into the CLI output. - **Nostr event signature verification** — for the common case (subject == primary == the npub) the embedded KEZ proof's own signature is sufficient. Cross-key proofs (e.g. ed25519 primary claiming a nostr identity) need NIP-01 event-sig verification to be safe. - **GitHub authentication** — anonymous requests work but are limited to 60 req/hr per IP. A `GITHUB_TOKEN` env var read would raise this to 5,000/hr. See [`../SPEC.md`](../SPEC.md) for the full v0.2 spec these gaps reference. --- ## Tests ```sh cargo test # all 81 tests cargo test -p kez-core # claim/envelope/encoding tests (15) cargo test -p kez-channels # channel logic + integration (61) cargo test -p kez-channels --test github # one channel's integration tests ``` No network is hit in the test suite — HTTP channels use `wiremock`, DNS uses a fake `TxtResolver`, nostr uses a fake `NostrFetcher`. --- ## License Dual-licensed under MIT or Apache-2.0 (see workspace `Cargo.toml`).