Adds the canonical wallet-style backup form (12 or 24 BIP-39 English
words) to both implementations. Wire-compatible — bit-identical seed
derivation across Rust and Node.
Semantics:
• 24 words ↔ 32 bytes of entropy ↔ Ed25519 seed (bijection).
Phrase ↔ seed round-trips exactly.
• 12 words → 16 bytes of entropy → seed via
SHA-256("kez-bip39-12-v1" || entropy). Deterministic but one-way;
you can't recover a 12-word phrase from a seed.
The 12-word case is KEZ-specific (not interoperable with hardware-
wallet BIP-32 derivations). The 24-word case is. Both use the BIP-39
English wordlist so users can paper-back-up alongside other wallets.
We deliberately do NOT use BIP-39's PBKDF2 to_seed(passphrase) — that
produces a 64-byte seed for BIP-32 hierarchical derivation, which is
the wrong primitive for KEZ's single-identity-per-phrase model.
Rust (kez-core):
• New mod mnemonic with MnemonicWords, generate_mnemonic,
seed_from_mnemonic, mnemonic_from_seed_24.
• Ed25519Secret::{from_mnemonic, generate_with_mnemonic}.
• Dep: bip39 v2.0 with the `rand` feature for OS-RNG generation.
• 9 unit tests, all green.
Rust (kez-cli):
• `identity new --key-type ed25519` now also prints a 24-word phrase
(default), with --mnemonic-words 12 to use 12 instead.
• `identity mnemonic [--words 12|24]` — print a fresh phrase only.
• `identity from-mnemonic "<phrase>"` — derive the key from a phrase.
• `--mnemonic <phrase>` is now accepted everywhere `--ed25519-seed
<hex>` was (claim create/dns, sigchain add/revoke/show/export/
publish), mutually exclusive with --ed25519-seed and --nsec via
clap conflicts_with_all.
Node (@kez/core):
• New mnemonic.ts with the parallel API:
generateMnemonic, seedFromMnemonic, mnemonicFromSeed24,
ed25519FromMnemonic, generateEd25519WithMnemonic.
• Dep: @scure/bip39 v2.x (note: import path is
"@scure/bip39/wordlists/english.js" with the .js suffix in v2).
• 8 vitest cases mirroring the Rust tests, all green.
Node (@kez/cli):
• Same CLI surface added: identity new --mnemonic-words 12|24,
identity mnemonic --words 12|24, identity from-mnemonic "<phrase>".
• --mnemonic flag accepted alongside --nsec / --ed25519-seed in the
flag parser, with mutex enforcement; loadSigner dispatches it.
Verified cross-implementation interop:
• Same 24-word phrase → identical Ed25519 pubkey in Rust and Node.
• Same 12-word phrase → identical pubkey (proves the SHA-256
domain-tagged derivation matches byte-for-byte).
• A claim signed in Rust with --mnemonic verifies in Node (Status:
valid).
Tests: 114 Rust + 99 Node total, zero regressions.
TUTORIAL.md updated in both rust/ and nodejs/ with the new section in
"Pick your primary key" plus a callout that --mnemonic can substitute
for --ed25519-seed throughout the rest of the tutorial.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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. This directory
is the Rust implementation of that spec.
If you've used Keybase, 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 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— a friendly step-by-step walkthrough that takes you from "I have a nostrnsec" to "I have a verified, published sigchain." It assumes nothing.This README is the reference; the tutorial is the on-ramp.
# 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.
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:
# 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:
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:
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--web --out <path>— write the JSONL bundle to a file (you upload it tohttps://<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/ and implements one
trait:
#[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 |
dns: |
_kez.<domain> TXT via system resolver |
No |
github.rs |
github: |
Public gists then <user>/<user> profile README |
No (60 req/hr anon, 5000 with GITHUB_TOKEN) |
nostr.rs |
nostr: |
Kind-30078 events from damus / nos.lol / primal relays | No |
bluesky.rs |
bluesky: |
Author feed via the public Bluesky AppView | No |
activitypub.rs |
ap:, mastodon: |
WebFinger → actor JSON → profile fields + bio | No |
Each channel has a sibling test file in
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.
-
Add the channel file. Create
crates/kez-channels/src/<system>.rs. ImplementChannel. Keep pure helpers (URL builders, parsers) as standalonepub fns so they can be unit-tested without I/O. -
Register it. In
lib.rs, addpub mod <system>;and a line inRegistry::with_defaults:r.register(Arc::new(my_channel::MyChannel::new().map_err(ChannelError::Other)?));If one adapter handles multiple identifier prefixes, use
register_as:let adapter = Arc::new(...); r.register(adapter.clone()); // canonical r.register_as("alias", adapter); // alias -
Add tests. Create
crates/kez-channels/tests/<system>.rs. For HTTP channels, usewiremockwith a constructor likeMyChannel::with_base(client, mock_server.uri()). For network-protocol channels (DNS, nostr), abstract the fetcher behind a trait and inject a fake. -
Done.
kez verify id <system>:...now works through the CLI without any CLI changes.
Library use
The crates are usable directly:
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:
{
"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 (seekez sigchain ...above), and a separate chain server can store them, butverify iddoesn't yet fetch a chain to check for revocations. Today every verify is a single one-shot proof check.rotateandadd_deviceops are also not implemented yet. expires_atenforcement — the field exists onClaimPayloadand serializes correctly, butSignedClaim::verifydoesn't reject expired proofs yet.- Typed
VerificationStatus.status— currently hardcoded strings ("valid","strong"). TheChannelErrorenum 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_TOKENenv var read would raise this to 5,000/hr.
See ../SPEC.md for the full v0.2 spec these gaps reference.
Tests
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).