Kez/rust/README.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

357 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 central server. 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, ~1,500 lines of Rust, **81 tests**.
---
## Quick start
```sh
# Build everything
cargo build
# Run the test suite
cargo test
```
### End-to-end walkthrough
**1. Create a primary key.**
```sh
cargo run -p kez-cli -- 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)
cargo run -p kez-cli -- claim create github:jason \
--nsec nsec1... --format markdown --out github-jason.kez.md
# Compact (one-liner for QR codes, chat, DNS TXT)
cargo run -p kez-cli -- claim create github:jason --nsec nsec1... --format compact
# JSON envelope (for /.well-known/kez.json)
cargo run -p kez-cli -- 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
cargo run -p kez-cli -- 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
cargo run -p kez-cli -- verify id dns:jason.example.com
cargo run -p kez-cli -- verify id github:jason
cargo run -p kez-cli -- verify id nostr:npub1...
cargo run -p kez-cli -- verify id bluesky:jason.bsky.social
cargo run -p kez-cli -- verify id ap:@jason@mastodon.social
cargo run -p kez-cli -- 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`).