Kez/nodejs/TUTORIAL.md
Jason Tudisco 0058d9b421 feat(rust,nodejs): BIP-39 mnemonic phrases for Ed25519 identities
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>
2026-06-05 17:41:01 -06:00

20 KiB
Raw Blame History

Tutorial — your first KEZ identity, end to end (Node.js)

This is a hands-on walkthrough. By the end you'll have:

  • A KEZ identity tied to a key you already trust (your existing nostr nsec, or a brand-new Ed25519 key).
  • A signed proof that you control a GitHub account (or DNS domain, or nostr handle, etc.) — verifiable by anyone, no central server needed.
  • A sigchain that ties multiple identities together, exported in a portable format, and published where strangers can find it.
  • The ability to verify other people's identities the same way.

If you've used Keybase, the mental model is the same. The difference: KEZ has no required central authority. Your proofs live wherever you publish them; the verifier just walks the links.

This is the Node.js implementation. It is wire-compatible with the Rust implementation — a claim signed by npm run cli verifies in cargo run and vice versa.

For the full protocol spec, see ../SPEC.md. This document is the friendly cousin.

Time budget: 1015 minutes for the first claim. A bit more if you want to set up DNS or a sigchain publish.


0. Install

You'll need:

  • Node.js 22+ — earlier versions don't have the global WebSocket the nostr channel relies on. Check with node --version.
  • npm 9+ for workspaces.

Then:

git clone https://git.ptud.biz/DukeInc/Kez.git
cd Kez/nodejs
npm install
npm test                     # optional: run all vitest suites

Verify the CLI works:

npm run cli -- --help

You should see subcommands identity, claim, verify, and sigchain.

Note on --. The bare -- before the subcommand stops npm from swallowing flags. Every example below uses npm run cli -- <stuff>.

Want a global kez command instead? From inside nodejs/packages/kez-cli/ run npm link once. After that, plain kez claim create … works from anywhere — substitute kez for npm run cli -- in every example below.

Optional but recommended: export GITHUB_TOKEN=ghp_... in your shell before verifying github claims. Anonymous GitHub limits you to 60 requests/hour; with a token it's 5000/hour. Any read-only token works; KEZ never sends it anywhere but api.github.com.


1. Pick your primary key

Your primary key is the one private key the rest of your identity hangs off of. It signs every claim you make. Two choices:

If you already use nostr (Damus, Amethyst, primal, etc.), you already have an nsec1... private key. Use it. KEZ understands nostr keys natively as Schnorr/secp256k1.

Export the nsec from your nostr client (every client has a way — usually Settings → Keys → Show / Export). Keep it secret; treat it the same as a wallet seed.

Warning. Pasting your nsec into a CLI is fine on a machine you trust. Don't do it on a shared box, and consider whether you want shell history to remember it (unset HISTFILE for the session, or prefix the command with a space if HISTCONTROL=ignorespace).

You don't need any command to "register" an existing nsec — just pass it with --nsec on the first claim you sign.

Option B: generate a fresh primary

A new nostr keypair:

npm run cli -- identity new

Or a new Ed25519 keypair, which comes with a 24-word BIP-39 phrase alongside the hex seed (both are equivalent backups):

npm run cli -- identity new --key-type ed25519                       # 24-word
npm run cli -- identity new --key-type ed25519 --mnemonic-words 12   # 12-word

Output (24-word, the default):

Primary: ed25519:7a3b4c…
Public:  7a3b4c…
Secret:  9e3f51… (32-byte seed)
Mnemonic (24 words): "abandon ability able about above absent academy accident…"

12 vs 24. 24 words is fully round-trippable: phrase ↔ seed are bijective. 12 words is shorter to memorize, but the seed is derived from the phrase one-way (KEZ-specific SHA-256 step), so you cannot derive a 12-word phrase from a hex seed. Pick whichever you'll actually back up.

You can also get just a phrase, or restore an existing one:

npm run cli -- identity mnemonic                                 # fresh 24 words
npm run cli -- identity mnemonic --words 12                      # fresh 12 words
npm run cli -- identity from-mnemonic "abandon ability able …"   # recover the key

Save the backup. Seed or phrase — at least one. Lose them both and the identity is gone. There's no recovery flow.

Throughout the rest of this tutorial you can substitute --mnemonic "your phrase here" anywhere --ed25519-seed <hex> appears.

For the rest of this tutorial we'll use a nostr key for examples and write the secret as nsec1FAKE... — substitute your real one.


2. Sign your first claim

A claim is just a signed sentence: "the key I signed this with also controls <subject>." The subject is a system:identifier string — github:tudisco, dns:tud.ink, nostr:npub1…, etc.

Say you want to prove you control the GitHub username tudisco.

npm run cli -- claim create github:tudisco \
  --nsec nsec1FAKE... \
  --format markdown \
  --out github-tudisco.kez.md

That writes a file like:

# KEZ Proof

This account publishes a signed KEZ identity claim.

- Primary: `nostr:npub1tkf…`
- Subject: `github:tudisco`
- Created: `2026-05-27T19:21:46Z`

```kez
{
  "kez": "claim",
  "payload": { ... },
  "signature": {
    "alg": "nostr-schnorr-bip340-jcs",
    "key": "nostr:npub1tkf…",
    "sig": "abc123…"
  }
}

### Picking the right format

Same claim, three packagings — same signature inside:

| Format | When to use | Command |
|---|---|---|
| **markdown** | Anywhere you can paste rich text — gists, profile READMEs, social posts. Most human-readable. | `--format markdown` |
| **compact** | Tight places: DNS TXT records, QR codes, chat messages. One-liner that decompresses back to the full envelope. | `--format compact` |
| **json** | Self-hosted `.well-known/kez.json`, developer tooling, anything that wants the raw envelope. | (default — no flag needed) |

If you skip `--out`, the proof prints to stdout — handy for piping.

---

## 3. Publish the proof

This is where KEZ does its job: you put the signed claim in a place that
only *that specific account* could have put it. Anyone who can fetch
that place can then verify it themselves.

Pick the section that matches the subject system you claimed.

### GitHub

You signed `github:tudisco`. Publish the markdown block to either:

**A public gist named `kez.md`** — easiest.
1. Go to <https://gist.github.com/>.
2. New gist → filename `kez.md` → paste the contents of
   `github-tudisco.kez.md`.
3. Click **Create public gist**.

**Or your profile README** — fancier but you only get one.
1. Make a repo named the same as your username (e.g.
   `tudisco/tudisco`). GitHub treats it as your profile README.
2. Add the markdown block to `README.md`.
3. Push.

KEZ's GitHub verifier checks public gists first, then the profile
README.

### DNS — your own domain

You signed `dns:tud.ink`. The CLI generates a ready-to-paste zone-file
line for you:

```sh
npm run cli -- claim dns tud.ink --nsec nsec1FAKE...

Output (abbreviated):

_kez.tud.ink. 3600 IN TXT
  "kez:z1:KLUv_WAsACUHAD…<chunk 1>…"
  "<chunk 2>…"

Add that TXT record at _kez.<your-domain> in your DNS provider's console (Cloudflare, Route 53, Gandi, Porkbun — wherever you registered the domain). Most providers will accept the whole compact string in one field and split it for you; the multi-chunk form above is the safe one for providers that don't.

Wait a minute or two for propagation, then you can verify it.

Nostr — your own npub

You signed nostr:npub1.... Three places work (verifiers check all of them):

  • Profile about field (kind-0 event) — easiest, one-time. Edit your nostr profile and paste the markdown block into your bio.
  • A normal post (kind-1) containing the markdown block — quickest if you're already active.
  • A NIP-78 kind-30078 event with d tag = kez — cleanest for tooling, but most clients don't expose it.

Bluesky

Post the markdown block (or just the compact kez:z1:… string) as a public post on the account you claimed. The verifier scans your recent posts.

Mastodon / ActivityPub

You signed ap:@user@instance. Add the markdown block to your profile metadata field (most instances expose 4 of them), or post it as a pinned toot. The verifier resolves via WebFinger → actor JSON → checks those fields.

Your own website

You signed web:https://example.com. Upload the JSON form to https://example.com/.well-known/kez.json:

npm run cli -- claim create web:https://example.com --nsec nsec1FAKE... > kez.json
scp kez.json youruser@example.com:/var/www/.well-known/kez.json

Make sure it's publicly fetchable (no auth gate).


4. Verify it

This is the moment of truth. Pretend you're a stranger checking that the claim is real:

npm run cli -- verify id github:tudisco

Output:

Primary: nostr:npub1tkf...

Verified identities:
- github:tudisco

Status: valid
Confidence: strong

Same shape for any channel:

npm run cli -- verify id dns:tud.ink
npm run cli -- verify id nostr:npub1tkf...
npm run cli -- verify id bluesky:tudisco.bsky.social
npm run cli -- verify id ap:@tudisco@mastodon.social
npm run cli -- verify id web:https://tud.ink

The verifier:

  1. Figured out which channel from the prefix.
  2. Fetched the proof from where you published it (gist, TXT, etc.).
  3. Decoded the envelope.
  4. Verified the cryptographic signature against the key inside.

No KEZ server was involved. Each side of the conversation independently proves the claim — that's the whole point.

Cross-implementation verification

This is wire-compatible with the Rust CLI. You can sign in one and verify in the other:

# Sign in Node…
npm run cli -- claim create github:tudisco --nsec nsec1FAKE... --out p.kez.md

# …verify the same file in Rust
cd ../rust && cargo run -p kez-cli -- verify file ../nodejs/p.kez.md

Same bytes, same signature, both implementations agree.

If verification fails

A few common ones:

  • not_found — the proof isn't where the verifier looked. For GitHub, check the gist is public and the filename contains kez. For DNS, the TXT record is at _kez.<domain>, not <domain> itself; give propagation a minute.
  • subject_mismatch — you published a proof for one subject but asked the verifier to check a different one. The claim's subject must equal the identifier you're verifying.
  • invalid_signature — the proof was tampered with, or you re-signed with a different key after publishing. Re-sign and re-publish.
  • GitHub 403 rate_limited — anonymous gets 60 req/hr; export GITHUB_TOKEN.
  • Nostr "WebSocket is not defined" — your Node is older than 22. Upgrade.

A sigchain is an append-only log of "this key controls X" events, each signed by your primary. Once you have more than one claim, you want a sigchain so:

  • Verifiers can discover your full identity graph from a single starting point.
  • You can later revoke a claim (e.g., you lost access to that github account) without invalidating the others.
  • Old events stay verifiable; the chain head is the current truth.

Chains live at ~/.kez/sigchains/<safe-primary>.jsonl. The CLI creates the directory on first use; you don't manage it manually.

Add the github claim you already signed:

npm run cli -- sigchain add github:tudisco --nsec nsec1FAKE...

Add a DNS claim too:

npm run cli -- sigchain add dns:tud.ink --nsec nsec1FAKE...

You can optionally include a --proof-url pointing to where you published this claim's proof (your gist URL, etc.). Verifiers can use it to skip discovery.

Inspect what you've got:

npm run cli -- sigchain show --nsec nsec1FAKE...

Output:

Primary:    nostr:npub1tkf...
Path:       /home/you/.kez/sigchains/nostr_npub1tkf….jsonl
Length:     2 events
Head:       sha256:9c3a…
Events:
  1. add  github:tudisco       proof_url=https://gist.github.com/tudisco/abc
  2. add  dns:tud.ink

Read-only view of a published chain (no secret needed):

npm run cli -- sigchain show --primary nostr:npub1tkf...

This is what other people will do to inspect your identity graph.

Revoking

If you ever lose control of an account (your github gets hacked, you sell a domain), revoke that subject:

npm run cli -- sigchain revoke github:tudisco --nsec nsec1FAKE...

That appends a revoke event. Subsequent verifications treat that subject as "no longer claimed" by your primary, even if the old proof is still out there.


6. Publish your sigchain

Now make your chain discoverable so anyone with your primary can walk it. Options, in rough order of how much infra they need:

To a kez-sig-server (zero setup)

If you have access to a kez-sig-server (one runs at https://sig.kez.lat):

npm run cli -- sigchain publish --nsec nsec1FAKE... \
  --server https://sig.kez.lat

Each event is POSTed to the server, which exposes them at predictable URLs. Cheap, fast, but you're trusting that server to stay up. Mitigate by also publishing to one of the channels below.

To your own website (self-sovereign)

Export the chain bundle and host it yourself:

npm run cli -- sigchain publish --nsec nsec1FAKE... \
  --web --out kez-sigchain.jsonl

Then upload kez-sigchain.jsonl to https://<your-domain>/.well-known/kez-sigchain.jsonl. Verifiers fetch it directly. Hardest to censor; you own it.

To DNS

npm run cli -- sigchain publish --nsec nsec1FAKE... --dns tud.ink

Prints a TXT record at _kez-chain.<domain> containing the compressed chain. Add it to your zone. Works for short chains; for long chains, prefer --web (TXT records are size-limited).

To nostr

npm run cli -- sigchain publish --nsec nsec1FAKE... \
  --nostr wss://relay.damus.io

Publishes the compact bundle as a kind-30078 event on that relay. Any nostr client / verifier subscribed can find it.

Pick more than one

publish accepts any combination of these flags — you can mirror to all four in one shot:

npm run cli -- sigchain publish --nsec nsec1FAKE... \
  --server https://sig.kez.lat \
  --web --out kez-sigchain.jsonl \
  --dns tud.ink \
  --nostr wss://relay.damus.io

Redundancy is good. If one channel goes down, the others still serve your identity graph.

Export-only (no publish)

If you want to see the bundle without publishing:

npm run cli -- sigchain export --nsec nsec1FAKE... --format compact > my-chain.txt
npm run cli -- sigchain export --nsec nsec1FAKE... --format jsonl   > my-chain.jsonl

7. Verifying someone else

You've done the publishing side. Here's the receiving side — how to verify someone else's identity:

# Start from any identifier they've published a proof for.
npm run cli -- verify id github:linus

# Or walk their chain from any known endpoint:
npm run cli -- sigchain show --primary nostr:npub1abc...

If you have the chain bundle on disk:

npm run cli -- verify file ./their-chain.jsonl

verify id is the friendly day-to-day verb. sigchain show --primary <id> is what you'd reach for to see the whole graph at once.


8. Programmatic use — embedding KEZ in a Node app

You don't have to go through the CLI. The same logic is exported as a library by the @kez/core and @kez/channels workspace packages.

import {
  Identity,
  NostrSecret,
  newClaimPayload,
  signClaim,
  toMarkdown,
} from "@kez/core";
import { defaultRegistry } from "@kez/channels";

// Sign a claim
const secret  = NostrSecret.fromNsec("nsec1FAKE...");
const subject = Identity.parse("github:tudisco");
const payload = newClaimPayload(subject, secret.identity(), new Date());
const claim   = signClaim(payload, secret);
console.log(toMarkdown(claim));

// Verify a peer
const registry = await defaultRegistry();
const hit = await registry.verify(Identity.parse("dns:tud.ink"));
console.log(hit.status); // "valid"

For testing without hitting the live channels, every channel takes an injectable fetcher (TxtResolver, NostrFetcher, etc.) — see the package READMEs and __tests__/ folders for the exact shapes. The implementations themselves are <300 lines each.


9. Quick reference card

# Generate a fresh primary
npm run cli -- identity new
npm run cli -- identity new --key-type ed25519

# Sign a claim
npm run cli -- claim create <subject> --nsec <nsec>                  # nostr key
npm run cli -- claim create <subject> --ed25519-seed <hex>           # ed25519 key
npm run cli -- claim create <subject> --nsec <nsec> --format markdown --out file.md
npm run cli -- claim create <subject> --nsec <nsec> --format compact # one-liner
npm run cli -- claim dns <domain> --nsec <nsec>                      # zone-file output

# Verify
npm run cli -- verify id <subject>                                   # live channel fetch
npm run cli -- verify file <path>                                    # local file

# Sigchain
npm run cli -- sigchain add <subject> --nsec <nsec> [--proof-url <url>]
npm run cli -- sigchain revoke <subject> --nsec <nsec>
npm run cli -- sigchain show --nsec <nsec>                           # your own
npm run cli -- sigchain show --primary <id>                          # someone else's
npm run cli -- sigchain export --nsec <nsec> --format jsonl|compact [--out file]
npm run cli -- sigchain publish --nsec <nsec> \
  [--server <url>] [--web --out <path>] [--dns <domain>] [--nostr <relay>]

10. Common confusions

"Do I need a sigchain to use KEZ?" No. A single signed claim, published, works on its own. The sigchain is for when you have several claims and want them discoverable together (and revocable).

"Why two key types — nostr and ed25519?" Different ecosystems use different curves. Nostr is secp256k1/Schnorr; the rest of the world mostly likes Ed25519. KEZ supports both natively so you can use the key you already have rather than spinning up a new one for KEZ specifically.

"Is my nsec sent to KEZ servers?" No, never. The CLI uses it locally to sign things. Only the signed envelope (public key + claim

  • signature) ever leaves your machine.

"What if I publish a proof and then someone else copies it and publishes it as theirs?" They can copy the bytes, but the signature inside is over your primary. Their primary won't match, so any verifier sees through it immediately.

"What if my key is compromised?" Append a sigchain revoke <subject> for the affected subjects, and ideally rotate to a new primary by signing a final "this primary is succeeded by " event (planned for the spec; not yet enforced by the CLI in v0.1).

"Is the Node version slower than Rust?" For everything but sigchain export of large chains, no — both use the same Noble curves underneath and the verifier is I/O-bound on the channel HTTP call. For batch sigchain work, the Rust binary will be a touch faster.


11. Where to go next

That's the whole tutorial. Welcome to KEZ.