Kez/rust/README.md
Tudisco eae98fead0 docs: prefer cargo install + bare kez binary in examples
Rename the CLI binary from `kez-cli` to `kez` (via a [[bin]] section in
the package's Cargo.toml; package name and `-p kez-cli` invocations stay
the same so the workspace build, tests, and the cross-test harness are
unaffected).

Then update the READMEs to recommend `cargo install --path` once at the
top of Quick Start, after which every example is the much shorter
`kez ...` form. Mention `cargo run -p kez-cli --` as the dev iteration
alternative for anyone who doesn't want to install.

- rust/README.md: 11 `cargo run -p kez-cli --` → `kez` substitutions,
  plus a stale "81 tests" → "99 tests" fix.
- README.md (root): Quick start gains a `cargo install` line.
- rust-sig-server/README.md: Quick start uses `kez-sig-server`
  (post-install) with `cargo run` as the dev alternative; "Try it"
  section rewritten to use the actual `kez sigchain` CLI (which now
  exists) instead of the stale "hand-build via kez-core" workaround.
2026-05-24 15:29:32 -06:00

362 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
```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`).