The existing README is a solid reference but assumes you already know
what KEZ is and what each subcommand does. Add a parallel TUTORIAL.md
that takes a complete newcomer from "I have a nostr nsec" to "I have
a published, verified sigchain" in ~15 minutes.
Sections (~500 lines):
0. Install (incl. cargo-run alternative + GITHUB_TOKEN tip)
1. Pick your primary key — use your existing nsec (recommended) OR
generate a fresh ed25519. Concrete warnings about nsec handling.
2. Sign your first claim — full markdown/compact/json walkthrough
with a real github:tudisco example.
3. Publish the proof — separate concrete how-tos per channel:
github (gist + profile README), DNS (zone-file output), nostr
(3 places it can live), bluesky, ActivityPub, your own website.
4. Verify it — `kez verify id` + a full "if verification fails"
troubleshooting block (not_found, subject_mismatch, bad sig,
github rate limit).
5. Sigchain basics — when you actually need one, add/show/revoke,
where chain files live on disk.
6. Publish your sigchain — server, web (.well-known), DNS,
nostr (kind-30078), and how to combine destinations.
7. Verify someone else — the reverse direction (verify id, walk
a chain by --primary, verify a chain bundle from disk).
8. Quick-reference command card.
9. Common confusions FAQ — sigchain optional? two key types?
nsec leakage? proof copying? key rotation?
10. Where to go next — kez.lat, SPEC.md, sig-server, channel plugin
trait.
All commands cross-checked against crates/kez-cli/src/main.rs (every
flag and output format quoted in the tutorial actually exists in the
binary).
README now points to TUTORIAL.md as the on-ramp; the existing reference
content stays put.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
368 lines
12 KiB
Markdown
368 lines
12 KiB
Markdown
# 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 `<user>/<user>` profile README |
|
|
| `dns:` | TXT record at `_kez.<domain>` (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 <subject> (--nsec <nsec> | --ed25519-seed <hex>) [--format json|markdown|compact] [--out <path>]`
|
|
Sign a KEZ claim asserting that the supplied signing key also controls
|
|
`<subject>`. 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 <domain> (--nsec <nsec> | --ed25519-seed <hex>)`
|
|
Like `claim create dns:<domain>` but additionally prints a ready-to-paste
|
|
zone-file line with the proof properly chunked into TXT segments.
|
|
|
|
### `verify file <path>`
|
|
Parse and verify a local proof file (any encoding). Developer helper — not a
|
|
real channel.
|
|
|
|
### `verify id <identifier>`
|
|
Fetch the proof for `<identifier>` 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 <subject> --nsec | --ed25519-seed [--proof-url <url>]`
|
|
Append an `add` event to the local sigchain for the signing key. Chain
|
|
files live at `~/.kez/sigchains/<safe-primary>.jsonl`.
|
|
|
|
### `sigchain revoke <subject> --nsec | --ed25519-seed`
|
|
Append a `revoke` event for a previously added subject.
|
|
|
|
### `sigchain show [--primary <id>] | [--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 <id>] | [--nsec | --ed25519-seed] [--format jsonl|compact] [--out <path>]`
|
|
Export the chain in a portable format (`jsonl` per spec §6, or
|
|
`compact` = `kez:zc1:<base64url(zstd(jsonl))>`).
|
|
|
|
### `sigchain publish [--primary <id>] | [--nsec | --ed25519-seed] [destinations...]`
|
|
Push the chain to one or more places. Destinations are flags and any
|
|
combination can be passed:
|
|
|
|
- `--server <url>` — POST every event to a [kez-sig-server](../rust-sig-server)
|
|
- `--web --out <path>` — write the JSONL bundle to a file (you upload it
|
|
to `https://<your-domain>/.well-known/kez-sigchain.jsonl`)
|
|
- `--dns <domain>` — print the TXT zone records for `_kez-chain.<domain>`
|
|
- `--nostr <relay>` — 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<ChannelHit>;
|
|
}
|
|
```
|
|
|
|
| File | System | Fetches | API key needed? |
|
|
|---|---|---|---|
|
|
| [`dns.rs`](crates/kez-channels/src/dns.rs) | `dns:` | `_kez.<domain>` TXT via system resolver | No |
|
|
| [`github.rs`](crates/kez-channels/src/github.rs) | `github:` | Public gists then `<user>/<user>` 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/<system>.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 <system>;` 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/<system>.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 <system>:...` 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": "<hex>"
|
|
}
|
|
}
|
|
```
|
|
|
|
| Form | Where | Encoding |
|
|
|---|---|---|
|
|
| JSON | `/.well-known/kez.json`, HTTP APIs | Standard JSON of the envelope |
|
|
| Compact | DNS TXT, QR codes, chat | `kez:z1:<base64url-no-pad(zstd(JSON))>` |
|
|
| Markdown | GitHub gist, README, bio | Human prose + a ```` ```kez ```` fenced block |
|
|
| Legacy DNS | (deprecated) | `kez1:<raw JSON>` — 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`).
|