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>
This commit is contained in:
Jason Tudisco 2026-06-05 17:41:01 -06:00
parent 878965924b
commit 0058d9b421
15 changed files with 918 additions and 178 deletions

View File

@ -97,24 +97,42 @@ A new nostr keypair:
npm run cli -- identity new npm run cli -- identity new
``` ```
Or a new Ed25519 keypair: Or a new Ed25519 keypair, which comes with a 24-word BIP-39 phrase
alongside the hex seed (both are equivalent backups):
```sh ```sh
npm run cli -- identity new --key-type ed25519 npm run cli -- identity new --key-type ed25519 # 24-word
npm run cli -- identity new --key-type ed25519 --mnemonic-words 12 # 12-word
``` ```
Output (Ed25519): Output (24-word, the default):
``` ```
Primary: ed25519:7a3b4c… Primary: ed25519:7a3b4c…
Public: 7a3b4c… (hex) Public: 7a3b4c…
Secret: 9e3f51… (32-byte seed) Secret: 9e3f51… (32-byte seed)
Mnemonic (24 words): "abandon ability able about above absent academy accident…"
``` ```
> **Save the secret.** It's the only thing that can sign as this > **12 vs 24.** 24 words is fully round-trippable: phrase ↔ seed are
> identity. There's no recovery flow — lose it and the identity is > bijective. 12 words is shorter to memorize, but the seed is derived
> gone. Write it down offline, or paste it into a password manager. > from the phrase one-way (KEZ-specific SHA-256 step), so you cannot
> From here on this tutorial assumes you stored it. > 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:
```sh
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 For the rest of this tutorial we'll use a nostr key for examples and
write the secret as `nsec1FAKE...` — substitute your real one. write the secret as `nsec1FAKE...` — substitute your real one.

262
nodejs/package-lock.json generated
View File

@ -549,9 +549,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
"integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -563,9 +563,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
"integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -577,9 +577,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
"integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -591,9 +591,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
"integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -605,9 +605,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
"integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -619,9 +619,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
"integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -633,9 +633,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
"integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -650,9 +650,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
"integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -667,9 +667,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
"integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -684,9 +684,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
"integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -701,9 +701,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
"integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -718,9 +718,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-musl": { "node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
"integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -735,9 +735,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
"integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -752,9 +752,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-musl": { "node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
"integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -769,9 +769,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
"integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -786,9 +786,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
"integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -803,9 +803,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
"integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -820,9 +820,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
"integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -837,9 +837,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
"integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -854,9 +854,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": { "node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
"integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -868,9 +868,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
"integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -882,9 +882,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
"integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -896,9 +896,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
"integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -910,9 +910,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
"integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -924,9 +924,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
"integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -946,6 +946,40 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@scure/bip39": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
"integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0",
"@scure/base": "2.2.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@ -954,9 +988,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.19", "version": "22.19.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1387,13 +1421,13 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.60.4", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.9"
}, },
"bin": { "bin": {
"rollup": "dist/bin/rollup" "rollup": "dist/bin/rollup"
@ -1403,41 +1437,34 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm-eabi": "4.61.1",
"@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-android-arm64": "4.61.1",
"@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.61.1",
"@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-darwin-x64": "4.61.1",
"@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.61.1",
"@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.61.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
"@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.61.1",
"@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.61.1",
"@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.61.1",
"@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.61.1",
"@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.61.1",
"@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.61.1",
"@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.61.1",
"@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.61.1",
"@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.61.1",
"@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.61.1",
"@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.61.1",
"@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.61.1",
"@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openbsd-x64": "4.61.1",
"@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.61.1",
"@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.61.1",
"@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.61.1",
"@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.61.1",
"@rollup/rollup-win32-x64-msvc": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.61.1",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rollup/node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/siginfo": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@ -1521,9 +1548,9 @@
} }
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.22.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2184,6 +2211,7 @@
"@noble/curves": "^1.6.0", "@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0", "@noble/hashes": "^1.5.0",
"@scure/base": "^1.1.9", "@scure/base": "^1.1.9",
"@scure/bip39": "^2.2.0",
"canonicalize": "^2.0.0" "canonicalize": "^2.0.0"
} }
} }

View File

@ -25,7 +25,10 @@ import {
type Signer, type Signer,
type VerificationStatus, type VerificationStatus,
dnsTxtName, dnsTxtName,
ed25519FromMnemonic,
eventHash, eventHash,
generateEd25519WithMnemonic,
generateMnemonic,
newClaimPayload, newClaimPayload,
signClaim, signClaim,
toCompact, toCompact,
@ -47,7 +50,9 @@ function usageAndExit(msg?: string): never {
"Usage: kez <command> ...", "Usage: kez <command> ...",
"", "",
"Commands:", "Commands:",
" identity new [--key-type nostr|ed25519]", " identity new [--key-type nostr|ed25519] [--mnemonic-words 12|24]",
" identity mnemonic [--words 12|24]",
" identity from-mnemonic \"<phrase>\"",
" claim create <subject> (--nsec <nsec> | --ed25519-seed <hex>)", " claim create <subject> (--nsec <nsec> | --ed25519-seed <hex>)",
" [--format json|markdown|compact] [--out <path>]", " [--format json|markdown|compact] [--out <path>]",
" claim dns <domain> (--nsec <nsec> | --ed25519-seed <hex>)", " claim dns <domain> (--nsec <nsec> | --ed25519-seed <hex>)",
@ -69,6 +74,12 @@ function usageAndExit(msg?: string): never {
interface Flags { interface Flags {
nsec?: string; nsec?: string;
ed25519Seed?: string; ed25519Seed?: string;
/** BIP-39 phrase, alternative to --ed25519-seed. */
mnemonic?: string;
/** "12" or "24" — used by `identity new --mnemonic-words`. */
mnemonicWords?: string;
/** "12" or "24" — used by `identity mnemonic --words`. */
words?: string;
keyType?: "nostr" | "ed25519"; keyType?: "nostr" | "ed25519";
format?: "json" | "markdown" | "compact" | "jsonl"; format?: "json" | "markdown" | "compact" | "jsonl";
out?: string; out?: string;
@ -89,6 +100,12 @@ function parseFlags(args: string[]): Flags {
out.nsec = args[++i]; out.nsec = args[++i];
} else if (a === "--ed25519-seed") { } else if (a === "--ed25519-seed") {
out.ed25519Seed = args[++i]; out.ed25519Seed = args[++i];
} else if (a === "--mnemonic") {
out.mnemonic = args[++i];
} else if (a === "--mnemonic-words") {
out.mnemonicWords = args[++i];
} else if (a === "--words") {
out.words = args[++i];
} else if (a === "--key-type") { } else if (a === "--key-type") {
const v = args[++i]; const v = args[++i];
if (v !== "nostr" && v !== "ed25519") usageAndExit(`bad --key-type value: ${v}`); if (v !== "nostr" && v !== "ed25519") usageAndExit(`bad --key-type value: ${v}`);
@ -119,8 +136,9 @@ function parseFlags(args: string[]): Flags {
out.positional.push(a); out.positional.push(a);
} }
} }
if (out.nsec && out.ed25519Seed) { const keySources = [out.nsec, out.ed25519Seed, out.mnemonic].filter(Boolean).length;
usageAndExit("--nsec and --ed25519-seed are mutually exclusive"); if (keySources > 1) {
usageAndExit("--nsec, --ed25519-seed, and --mnemonic are mutually exclusive");
} }
return out; return out;
} }
@ -143,7 +161,8 @@ function printStatus(status: VerificationStatus): void {
function loadSigner(args: Flags): Signer { function loadSigner(args: Flags): Signer {
if (args.nsec) return NostrSecret.fromNsec(args.nsec); if (args.nsec) return NostrSecret.fromNsec(args.nsec);
if (args.ed25519Seed) return Ed25519Secret.fromSeedHex(args.ed25519Seed); if (args.ed25519Seed) return Ed25519Secret.fromSeedHex(args.ed25519Seed);
usageAndExit("missing key: pass --nsec or --ed25519-seed"); if (args.mnemonic) return ed25519FromMnemonic(args.mnemonic);
usageAndExit("missing key: pass --nsec, --ed25519-seed, or --mnemonic");
} }
function buildClaim(subjectStr: string, signer: Signer) { function buildClaim(subjectStr: string, signer: Signer) {
@ -155,27 +174,67 @@ function buildClaim(subjectStr: string, signer: Signer) {
return signClaim(newClaimPayload(subject, primary, new Date()), signer); return signClaim(newClaimPayload(subject, primary, new Date()), signer);
} }
function parseWordCount(raw: string | undefined, dflt: 12 | 24): 12 | 24 {
if (raw === undefined) return dflt;
if (raw === "12") return 12;
if (raw === "24") return 24;
usageAndExit(`word count must be 12 or 24, got ${raw}`);
}
function identityNew(args: Flags): void { function identityNew(args: Flags): void {
const keyType = args.keyType ?? "nostr"; const keyType = args.keyType ?? "nostr";
if (keyType === "ed25519") { if (keyType === "nostr") {
const s = Ed25519Secret.generate(); if (args.mnemonicWords !== undefined) {
process.stdout.write(`Primary: ${s.identity()}\n`); usageAndExit("--mnemonic-words is only valid with --key-type ed25519");
process.stdout.write(`Public: ${s.pubkeyHex()}\n`); }
process.stdout.write(`Secret: ${s.seedHex()} (32-byte seed)\n`); const s = NostrSecret.generate();
process.stdout.write(`Primary: nostr:${s.npub()}\n`);
process.stdout.write(`Public: ${s.npub()}\n`);
process.stdout.write(`Secret: ${s.nsec()}\n`);
process.stdout.write("\n"); process.stdout.write("\n");
process.stdout.write( process.stdout.write(
"Store the secret somewhere safe. Anyone with the seed can sign as this identity.\n", "Store the secret somewhere safe. Anyone with the nsec can sign as this identity.\n",
); );
return; return;
} }
const s = NostrSecret.generate(); // ed25519: default 24 words (bijective with the seed), or 12 if asked.
process.stdout.write(`Primary: nostr:${s.npub()}\n`); const words = parseWordCount(args.mnemonicWords, 24);
process.stdout.write(`Public: ${s.npub()}\n`); const { secret, phrase } = generateEd25519WithMnemonic(words);
process.stdout.write(`Secret: ${s.nsec()}\n`); process.stdout.write(`Primary: ${secret.identity()}\n`);
process.stdout.write(`Public: ${secret.pubkeyHex()}\n`);
process.stdout.write(`Secret: ${secret.seedHex()} (32-byte seed)\n`);
process.stdout.write(`Mnemonic (${words} words): "${phrase}"\n`);
process.stdout.write("\n"); process.stdout.write("\n");
process.stdout.write( if (words === 24) {
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity.\n", process.stdout.write(
); "The 24-word phrase and the hex seed are equivalent backups —\n" +
"either restores this identity. Store at least one safely.\n",
);
} else {
process.stdout.write(
"The 12-word phrase is the canonical backup. The hex seed is\n" +
"derived from it (one-way) — you can't reconstruct the phrase\n" +
"from the seed. Store the phrase safely.\n",
);
}
}
function identityMnemonic(args: Flags): void {
const words = parseWordCount(args.words, 24);
process.stdout.write(`${generateMnemonic(words)}\n`);
}
function identityFromMnemonic(args: Flags): void {
if (args.positional.length !== 1) {
usageAndExit("identity from-mnemonic needs the phrase in quotes");
}
const phrase = args.positional[0];
const secret = ed25519FromMnemonic(phrase);
const wordCount = phrase.trim().split(/\s+/).length;
process.stdout.write(`Primary: ${secret.identity()}\n`);
process.stdout.write(`Public: ${secret.pubkeyHex()}\n`);
process.stdout.write(`Secret: ${secret.seedHex()} (32-byte seed)\n`);
process.stdout.write(`Mnemonic (${wordCount} words): "${phrase.trim()}"\n`);
} }
function claimCreate(args: Flags): void { function claimCreate(args: Flags): void {
@ -242,6 +301,8 @@ async function main(): Promise<void> {
const flags = parseFlags(rest); const flags = parseFlags(rest);
try { try {
if (cmd === "identity" && sub === "new") return identityNew(flags); if (cmd === "identity" && sub === "new") return identityNew(flags);
if (cmd === "identity" && sub === "mnemonic") return identityMnemonic(flags);
if (cmd === "identity" && sub === "from-mnemonic") return identityFromMnemonic(flags);
if (cmd === "claim" && sub === "create") return claimCreate(flags); if (cmd === "claim" && sub === "create") return claimCreate(flags);
if (cmd === "claim" && sub === "dns") return claimDns(flags); if (cmd === "claim" && sub === "dns") return claimDns(flags);
if (cmd === "verify" && sub === "file") return verifyFile(flags); if (cmd === "verify" && sub === "file") return verifyFile(flags);

View File

@ -12,6 +12,7 @@
"@noble/curves": "^1.6.0", "@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0", "@noble/hashes": "^1.5.0",
"@scure/base": "^1.1.9", "@scure/base": "^1.1.9",
"@scure/bip39": "^2.2.0",
"canonicalize": "^2.0.0" "canonicalize": "^2.0.0"
} }
} }

View File

@ -58,3 +58,11 @@ export {
parseDnsTxtValue, parseDnsTxtValue,
} from "./encodings.js"; } from "./encodings.js";
export { canonicalBytes, canonicalString } from "./jcs.js"; export { canonicalBytes, canonicalString } from "./jcs.js";
export {
ed25519FromMnemonic,
generateEd25519WithMnemonic,
generateMnemonic,
mnemonicFromSeed24,
seedFromMnemonic,
type MnemonicWords,
} from "./mnemonic.js";

View File

@ -0,0 +1,100 @@
// BIP-39 mnemonic phrases for Ed25519 primary keys.
//
// Mirrors rust/crates/kez-core/src/mnemonic.rs byte-for-byte:
//
// - 24 words ↔ 32 bytes of entropy ↔ Ed25519 seed (bijection).
// - 12 words → 16 bytes of entropy → seed via
// SHA-256("kez-bip39-12-v1" || entropy) (deterministic, one-way).
//
// English BIP-39 wordlist, same as every other crypto wallet. NB: 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 a single-identity system like KEZ. The
// entropy IS the secret.
import {
entropyToMnemonic,
generateMnemonic as bip39Generate,
mnemonicToEntropy,
} from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english.js";
import { sha256 } from "@noble/hashes/sha2";
import { bytesToHex } from "@noble/hashes/utils";
import { Ed25519Secret } from "./ed25519.js";
import { IdentityError } from "./identity.js";
/** Domain separator for the 12-word seed derivation. Bumping this
* would break every existing 12-word KEZ identity; don't. */
const DOMAIN_TAG_12 = new TextEncoder().encode("kez-bip39-12-v1");
export type MnemonicWords = 12 | 24;
function assertWords(n: number): asserts n is MnemonicWords {
if (n !== 12 && n !== 24) {
throw new IdentityError(
`mnemonic word count must be 12 or 24, got ${n}`,
);
}
}
/** Generate a fresh BIP-39 mnemonic of the requested length. */
export function generateMnemonic(words: MnemonicWords): string {
assertWords(words);
// bip39 strength is in bits: 12 words = 128 bits, 24 = 256.
return bip39Generate(wordlist, words === 24 ? 256 : 128);
}
/**
* Decode a phrase (12 or 24 words) to a 32-byte Ed25519 seed. For 24
* words the entropy IS the seed; for 12 words the seed is
* SHA-256(DOMAIN_TAG_12 || entropy).
*/
export function seedFromMnemonic(phrase: string): Uint8Array {
const trimmed = phrase.trim().replace(/\s+/g, " ");
let entropy: Uint8Array;
try {
entropy = mnemonicToEntropy(trimmed, wordlist);
} catch (e) {
throw new IdentityError(`invalid mnemonic: ${(e as Error).message}`);
}
if (entropy.length === 32) {
return new Uint8Array(entropy);
}
if (entropy.length === 16) {
const buf = new Uint8Array(DOMAIN_TAG_12.length + entropy.length);
buf.set(DOMAIN_TAG_12, 0);
buf.set(entropy, DOMAIN_TAG_12.length);
return sha256(buf);
}
throw new IdentityError(
`mnemonic must decode to 16 or 32 bytes of entropy, got ${entropy.length}`,
);
}
/**
* Inverse of `seedFromMnemonic` for the 24-word case ONLY. There is no
* inverse for 12-word phrases (hashing is one-way) this function
* always produces 24 words.
*/
export function mnemonicFromSeed24(seed: Uint8Array): string {
if (seed.length !== 32) {
throw new IdentityError(
`mnemonicFromSeed24: seed must be 32 bytes, got ${seed.length}`,
);
}
return entropyToMnemonic(seed, wordlist);
}
/** Reconstruct an Ed25519Secret from a BIP-39 phrase. */
export function ed25519FromMnemonic(phrase: string): Ed25519Secret {
return Ed25519Secret.fromSeedHex(bytesToHex(seedFromMnemonic(phrase)));
}
/** Generate a fresh Ed25519 identity *and* return its phrase. */
export function generateEd25519WithMnemonic(
words: MnemonicWords,
): { secret: Ed25519Secret; phrase: string } {
const phrase = generateMnemonic(words);
const secret = ed25519FromMnemonic(phrase);
return { secret, phrase };
}

View File

@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
Ed25519Secret,
ed25519FromMnemonic,
generateEd25519WithMnemonic,
generateMnemonic,
mnemonicFromSeed24,
seedFromMnemonic,
} from "../src/index.js";
import { bytesToHex } from "@noble/hashes/utils";
describe("mnemonic", () => {
it("generate 24 round-trips through seed", () => {
const phrase = generateMnemonic(24);
expect(phrase.split(/\s+/).length).toBe(24);
const seed = seedFromMnemonic(phrase);
const phrase2 = mnemonicFromSeed24(seed);
expect(phrase2).toBe(phrase);
});
it("generate 12 is deterministic", () => {
const phrase = generateMnemonic(12);
expect(phrase.split(/\s+/).length).toBe(12);
const s1 = seedFromMnemonic(phrase);
const s2 = seedFromMnemonic(phrase);
expect(bytesToHex(s1)).toBe(bytesToHex(s2));
});
it("mnemonicFromSeed24 is the inverse of seedFromMnemonic (24-word)", () => {
const seed = new Uint8Array(32).fill(42);
const phrase = mnemonicFromSeed24(seed);
const recovered = seedFromMnemonic(phrase);
expect(bytesToHex(recovered)).toBe(bytesToHex(seed));
});
it("rejects invalid phrases cleanly", () => {
expect(() => seedFromMnemonic("not actually words")).toThrow();
expect(() =>
seedFromMnemonic(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon",
),
).toThrow(); // bad checksum
});
it("12-word and 24-word phrases with overlapping entropy give DIFFERENT seeds", () => {
// Sanity: we hash 12-word entropy, so it doesn't collide with a
// 24-word entropy where the first 16 bytes happen to match.
const e16 = new Uint8Array(16).fill(7);
const e32 = new Uint8Array(32).fill(7);
const p12 = mnemonicFromSeed24(new Uint8Array([...e16, ...e16])); // synthesize a valid 24-word from doubled entropy
// Use the proper 12-word phrase route instead:
const m12 = mnemonicFromSeed24(new Uint8Array(32).fill(7)); // 24-word from 32-byte
// For genuine 12-word entropy comparison:
const phrase12 = ed25519FromMnemonic; // appease tsc — checked below
void phrase12;
void p12;
const seedFromTwelve = seedFromMnemonic(
// a deterministic real 12-word phrase
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
);
expect(bytesToHex(seedFromTwelve)).not.toBe(bytesToHex(new Uint8Array(32).fill(7)));
void m12;
});
it("ed25519FromMnemonic matches direct seed construction (24-word)", () => {
const seed = new Uint8Array(32).fill(1);
const phrase = mnemonicFromSeed24(seed);
const fromMnem = ed25519FromMnemonic(phrase);
const fromHex = Ed25519Secret.fromSeedHex(bytesToHex(seed));
expect(fromMnem.pubkeyHex()).toBe(fromHex.pubkeyHex());
});
it("generateEd25519WithMnemonic returns a consistent (key, phrase) pair", () => {
const { secret, phrase } = generateEd25519WithMnemonic(24);
const restored = ed25519FromMnemonic(phrase);
expect(secret.pubkeyHex()).toBe(restored.pubkeyHex());
});
it("parser tolerates leading/trailing whitespace + extra spaces", () => {
const phrase = generateMnemonic(24);
const messy = ` ${phrase.split(" ").join(" ")} `;
expect(bytesToHex(seedFromMnemonic(phrase))).toBe(
bytesToHex(seedFromMnemonic(messy)),
);
});
});

48
rust/Cargo.lock generated
View File

@ -76,6 +76,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]] [[package]]
name = "assert-json-diff" name = "assert-json-diff"
version = "2.0.2" version = "2.0.2"
@ -127,6 +133,28 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445"
[[package]]
name = "bip39"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
dependencies = [
"bitcoin_hashes",
"rand 0.8.6",
"rand_core 0.6.4",
"serde",
"unicode-normalization",
]
[[package]]
name = "bitcoin_hashes"
version = "0.14.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f"
dependencies = [
"hex-conservative",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.11.1"
@ -709,6 +737,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
dependencies = [
"arrayvec",
]
[[package]] [[package]]
name = "hickory-net" name = "hickory-net"
version = "0.26.1" version = "0.26.1"
@ -1164,6 +1201,7 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"dirs", "dirs",
"hex",
"kez-channels", "kez-channels",
"kez-core", "kez-core",
"reqwest", "reqwest",
@ -1176,6 +1214,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"base64", "base64",
"bech32", "bech32",
"bip39",
"chrono", "chrono",
"ed25519-dalek", "ed25519-dalek",
"hex", "hex",
@ -2255,6 +2294,15 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"

View File

@ -16,6 +16,7 @@ anyhow = "1.0"
async-trait = "0.1" async-trait = "0.1"
base64 = "0.22" base64 = "0.22"
bech32 = "0.9" bech32 = "0.9"
bip39 = { version = "2.0", features = ["rand"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
ed25519-dalek = { version = "2.1", features = ["rand_core"] } ed25519-dalek = { version = "2.1", features = ["rand_core"] }

View File

@ -84,21 +84,43 @@ kez identity new --key-type nostr # only if you want a NEW key
If you'd rather start clean, generate a new Ed25519 key: If you'd rather start clean, generate a new Ed25519 key:
```sh ```sh
kez identity new --key-type ed25519 kez identity new --key-type ed25519 # 24-word phrase (default)
kez identity new --key-type ed25519 --mnemonic-words 12 # 12-word phrase
``` ```
Output: Output (24-word, the default):
``` ```
Primary: ed25519:7a3b4c… Primary: ed25519:7a3b4c…
Public: 7a3b4c… (hex) Public: 7a3b4c…
Secret: 9e3f51… (hex — 64 chars, KEEP SECRET) Secret: 9e3f51… (32-byte seed)
Mnemonic (24 words): "abandon ability able about above absent academy accident…"
``` ```
> **Save the secret.** It's the only thing that can sign as this You now have **two equivalent backups** — the hex seed *and* the 24-word
> identity. There's no recovery flow — lose it and the identity is BIP-39 phrase. Either restores the same identity. Most people back up
> gone. Write it down offline, or paste it into a password manager. the phrase (easier to write down, easier to verify by hand).
> From here on this tutorial assumes you stored it.
> **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 remember to back up.
You can also get just a phrase without a key, or restore from a phrase
you wrote down earlier:
```sh
kez identity mnemonic # print a fresh 24-word phrase
kez identity mnemonic --words 12 # 12-word
kez 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 For the rest of this tutorial we'll use a nostr key for examples and
write the secret as `nsec1FAKE...` — substitute your real one. write the secret as `nsec1FAKE...` — substitute your real one.

View File

@ -14,6 +14,7 @@ anyhow.workspace = true
chrono.workspace = true chrono.workspace = true
clap.workspace = true clap.workspace = true
dirs = "5" dirs = "5"
hex.workspace = true
kez-channels = { path = "../kez-channels" } kez-channels = { path = "../kez-channels" }
kez-core = { path = "../kez-core" } kez-core = { path = "../kez-core" }
reqwest.workspace = true reqwest.workspace = true

View File

@ -4,8 +4,9 @@ use clap::{Parser, Subcommand, ValueEnum};
use kez_channels::nostr as nostr_chan; use kez_channels::nostr as nostr_chan;
use kez_channels::{ChannelHit, Registry, parse_proof}; use kez_channels::{ChannelHit, Registry, parse_proof};
use kez_core::{ use kez_core::{
ClaimPayload, Ed25519Secret, Identity, NostrSecret, SignedClaim, Signer, Sigchain, ClaimPayload, Ed25519Secret, Identity, MnemonicWords, NostrSecret, SignedClaim, Signer,
VerificationStatus, dns_txt_name, Sigchain, VerificationStatus, dns_txt_name, generate_mnemonic, mnemonic_from_seed_24,
seed_from_mnemonic,
}; };
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -47,6 +48,8 @@ enum SigchainCommand {
nsec: Option<String>, nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")] #[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>, ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
mnemonic: Option<String>,
#[arg(long)] #[arg(long)]
proof_url: Option<String>, proof_url: Option<String>,
}, },
@ -57,6 +60,8 @@ enum SigchainCommand {
nsec: Option<String>, nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")] #[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>, ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
mnemonic: Option<String>,
}, },
/// Print the chain (events one per line, plus a summary). /// Print the chain (events one per line, plus a summary).
Show { Show {
@ -67,6 +72,8 @@ enum SigchainCommand {
nsec: Option<String>, nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])] #[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
ed25519_seed: Option<String>, ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
mnemonic: Option<String>,
}, },
/// Export the chain in a portable format. /// Export the chain in a portable format.
Export { Export {
@ -76,6 +83,8 @@ enum SigchainCommand {
nsec: Option<String>, nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])] #[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
ed25519_seed: Option<String>, ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
mnemonic: Option<String>,
#[arg(long, value_enum, default_value_t = ExportFormat::Jsonl)] #[arg(long, value_enum, default_value_t = ExportFormat::Jsonl)]
format: ExportFormat, format: ExportFormat,
#[arg(long)] #[arg(long)]
@ -89,6 +98,8 @@ enum SigchainCommand {
nsec: Option<String>, nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])] #[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
ed25519_seed: Option<String>, ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
mnemonic: Option<String>,
/// POST every event to a kez-sig-server at this URL. /// POST every event to a kez-sig-server at this URL.
#[arg(long)] #[arg(long)]
server: Option<String>, server: Option<String>,
@ -117,9 +128,31 @@ enum ExportFormat {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
enum IdentityCommand { enum IdentityCommand {
/// Generate a new primary key. Defaults to nostr; pass --key-type
/// ed25519 for an Ed25519 key. For Ed25519, a 24-word BIP-39 phrase
/// is also printed (it's an equivalent representation of the seed).
/// Use --mnemonic-words 12 to generate from a 12-word phrase instead.
New { New {
#[arg(long, value_enum, default_value_t = KeyType::Nostr)] #[arg(long, value_enum, default_value_t = KeyType::Nostr)]
key_type: KeyType, key_type: KeyType,
/// 12 or 24. Only valid with --key-type ed25519. If unset, a
/// 24-word phrase is shown alongside the hex seed for Ed25519.
#[arg(long = "mnemonic-words")]
mnemonic_words: Option<u8>,
},
/// Print a fresh BIP-39 mnemonic phrase without deriving a key.
/// Useful for offline backup workflows.
Mnemonic {
/// 12 or 24. Default 24.
#[arg(long, default_value_t = 24)]
words: u8,
},
/// Derive and print the Ed25519 primary key from an existing
/// BIP-39 phrase (12 or 24 words, auto-detected).
FromMnemonic {
/// The phrase, quoted. Words separated by spaces. Case- and
/// whitespace-tolerant.
phrase: String,
}, },
} }
@ -137,6 +170,8 @@ enum ClaimCommand {
nsec: Option<String>, nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")] #[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>, ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
mnemonic: Option<String>,
#[arg(long, value_enum, default_value_t = OutputFormat::Json)] #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
format: OutputFormat, format: OutputFormat,
#[arg(long)] #[arg(long)]
@ -148,6 +183,8 @@ enum ClaimCommand {
nsec: Option<String>, nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")] #[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>, ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
mnemonic: Option<String>,
}, },
} }
@ -172,21 +209,33 @@ async fn main() -> Result<()> {
match cli.command { match cli.command {
Command::Identity { command } => match command { Command::Identity { command } => match command {
IdentityCommand::New { key_type } => identity_new(key_type), IdentityCommand::New { key_type, mnemonic_words } => {
identity_new(key_type, mnemonic_words)
}
IdentityCommand::Mnemonic { words } => identity_mnemonic(words),
IdentityCommand::FromMnemonic { phrase } => identity_from_mnemonic(&phrase),
}, },
Command::Claim { command } => match command { Command::Claim { command } => match command {
ClaimCommand::Create { ClaimCommand::Create {
subject, subject,
nsec, nsec,
ed25519_seed, ed25519_seed,
mnemonic,
format, format,
out, out,
} => claim_create(subject, nsec, ed25519_seed, format, out), } => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
claim_create(subject, nsec, ed25519_seed, format, out)
}
ClaimCommand::Dns { ClaimCommand::Dns {
domain, domain,
nsec, nsec,
ed25519_seed, ed25519_seed,
} => claim_dns(domain, nsec, ed25519_seed), mnemonic,
} => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
claim_dns(domain, nsec, ed25519_seed)
}
}, },
Command::Verify { command } => match command { Command::Verify { command } => match command {
VerifyCommand::File { path } => verify_file(path), VerifyCommand::File { path } => verify_file(path),
@ -196,59 +245,90 @@ async fn main() -> Result<()> {
} }
} }
/// If the caller passed `--mnemonic <phrase>`, derive the Ed25519 seed
/// from it and return as hex. Otherwise return the `--ed25519-seed`
/// passthrough unchanged. Clap conflicts_with ensures both can't be
/// set at once.
fn resolve_seed(
ed25519_seed: Option<String>,
mnemonic: Option<String>,
) -> Result<Option<String>> {
match (ed25519_seed, mnemonic) {
(Some(s), None) => Ok(Some(s)),
(None, Some(phrase)) => {
let seed = seed_from_mnemonic(&phrase)
.map_err(|e| anyhow::anyhow!("invalid mnemonic: {e}"))?;
Ok(Some(hex::encode(seed)))
}
(None, None) => Ok(None),
(Some(_), Some(_)) => unreachable!("clap conflicts_with"),
}
}
async fn sigchain_dispatch(cmd: SigchainCommand) -> Result<()> { async fn sigchain_dispatch(cmd: SigchainCommand) -> Result<()> {
match cmd { match cmd {
SigchainCommand::Add { SigchainCommand::Add {
subject, subject,
nsec, nsec,
ed25519_seed, ed25519_seed,
mnemonic,
proof_url, proof_url,
} => sigchain_add(subject, nsec, ed25519_seed, proof_url), } => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
sigchain_add(subject, nsec, ed25519_seed, proof_url)
}
SigchainCommand::Revoke { SigchainCommand::Revoke {
subject, subject,
nsec, nsec,
ed25519_seed, ed25519_seed,
} => sigchain_revoke(subject, nsec, ed25519_seed), mnemonic,
} => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
sigchain_revoke(subject, nsec, ed25519_seed)
}
SigchainCommand::Show { SigchainCommand::Show {
primary, primary,
nsec, nsec,
ed25519_seed, ed25519_seed,
} => sigchain_show(primary, nsec, ed25519_seed), mnemonic,
} => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
sigchain_show(primary, nsec, ed25519_seed)
}
SigchainCommand::Export { SigchainCommand::Export {
primary, primary,
nsec, nsec,
ed25519_seed, ed25519_seed,
mnemonic,
format, format,
out, out,
} => sigchain_export(primary, nsec, ed25519_seed, format, out), } => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
sigchain_export(primary, nsec, ed25519_seed, format, out)
}
SigchainCommand::Publish { SigchainCommand::Publish {
primary, primary,
nsec, nsec,
ed25519_seed, ed25519_seed,
mnemonic,
server, server,
web, web,
out, out,
dns, dns,
nostr, nostr,
} => { } => {
sigchain_publish( let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
primary, sigchain_publish(primary, nsec, ed25519_seed, server, web, out, dns, nostr).await
nsec,
ed25519_seed,
server,
web,
out,
dns,
nostr,
)
.await
} }
} }
} }
fn identity_new(key_type: KeyType) -> Result<()> { fn identity_new(key_type: KeyType, mnemonic_words: Option<u8>) -> Result<()> {
match key_type { match (key_type, mnemonic_words) {
KeyType::Nostr => { (KeyType::Nostr, Some(_)) => {
bail!("--mnemonic-words is only valid with --key-type ed25519");
}
(KeyType::Nostr, None) => {
let secret = NostrSecret::generate(); let secret = NostrSecret::generate();
println!("Primary: nostr:{}", secret.npub()); println!("Primary: nostr:{}", secret.npub());
println!("Public: {}", secret.npub()); println!("Public: {}", secret.npub());
@ -258,16 +338,58 @@ fn identity_new(key_type: KeyType) -> Result<()> {
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity." "Store the secret somewhere safe. Anyone with the nsec can sign as this identity."
); );
} }
KeyType::Ed25519 => { (KeyType::Ed25519, words_opt) => {
let secret = Ed25519Secret::generate(); // Default is 24 — the canonical bijective form (entropy IS seed).
let words = MnemonicWords::from_count(words_opt.unwrap_or(24) as usize)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (secret, phrase) = Ed25519Secret::generate_with_mnemonic(words)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let id = secret.identity()?; let id = secret.identity()?;
println!("Primary: {id}"); println!("Primary: {id}");
println!("Public: {}", secret.pubkey_hex()); println!("Public: {}", secret.pubkey_hex());
println!("Secret: {} (32-byte seed)", secret.seed_hex()); println!("Secret: {} (32-byte seed)", secret.seed_hex());
println!("Mnemonic ({} words): \"{}\"", words.count(), phrase);
println!(); println!();
println!( match words {
"Store the secret somewhere safe. Anyone with the seed can sign as this identity." MnemonicWords::TwentyFour => println!(
); "The 24-word phrase and the hex seed are equivalent backups —\n\
either restores this identity. Store at least one safely."
),
MnemonicWords::Twelve => println!(
"The 12-word phrase is the canonical backup. The hex seed is\n\
derived from it (one-way) you can't reconstruct the phrase\n\
from the seed. Store the phrase safely."
),
}
}
}
Ok(())
}
fn identity_mnemonic(words: u8) -> Result<()> {
let w = MnemonicWords::from_count(words as usize).map_err(|e| anyhow::anyhow!("{e}"))?;
let phrase = generate_mnemonic(w).map_err(|e| anyhow::anyhow!("{e}"))?;
println!("{phrase}");
Ok(())
}
fn identity_from_mnemonic(phrase: &str) -> Result<()> {
let secret = Ed25519Secret::from_mnemonic(phrase).map_err(|e| anyhow::anyhow!("{e}"))?;
let id = secret.identity()?;
let word_count = phrase.split_whitespace().count();
println!("Primary: {id}");
println!("Public: {}", secret.pubkey_hex());
println!("Secret: {} (32-byte seed)", secret.seed_hex());
println!("Mnemonic ({} words): \"{}\"", word_count, phrase.trim());
if word_count == 24 {
// For 24-word, verify it round-trips so the user knows it's canonical.
let mut seed_bytes = [0u8; 32];
seed_bytes.copy_from_slice(&hex::decode(secret.seed_hex())?);
let derived = mnemonic_from_seed_24(&seed_bytes).map_err(|e| anyhow::anyhow!("{e}"))?;
if derived.trim() != phrase.trim() {
// Words were correct (parse succeeded) but their reordering differs
// — shouldn't happen, but worth flagging if it ever does.
println!("(note: canonical form is \"{}\")", derived);
} }
} }
Ok(()) Ok(())

View File

@ -6,6 +6,7 @@ edition.workspace = true
[dependencies] [dependencies]
base64.workspace = true base64.workspace = true
bech32.workspace = true bech32.workspace = true
bip39.workspace = true
chrono.workspace = true chrono.workspace = true
ed25519-dalek.workspace = true ed25519-dalek.workspace = true
hex.workspace = true hex.workspace = true

View File

@ -15,6 +15,11 @@ use sha2::{Digest, Sha256};
use std::fmt; use std::fmt;
use std::str::FromStr; use std::str::FromStr;
pub mod mnemonic;
pub use mnemonic::{
MnemonicWords, generate_mnemonic, mnemonic_from_seed_24, seed_from_mnemonic,
};
pub const CLAIM_TYPE: &str = "kez.claim"; pub const CLAIM_TYPE: &str = "kez.claim";
pub const SIGCHAIN_EVENT_TYPE: &str = "kez.sigchain.event"; pub const SIGCHAIN_EVENT_TYPE: &str = "kez.sigchain.event";
pub const FORMAT_VERSION: u8 = 1; pub const FORMAT_VERSION: u8 = 1;

View File

@ -0,0 +1,237 @@
//! BIP-39 mnemonic phrases for Ed25519 primary keys.
//!
//! Two word counts are supported, with different semantics:
//!
//! - **24 words** ↔ **32 bytes of entropy** ↔ **Ed25519 seed**.
//! Round-trips perfectly. The entropy *is* the seed. You can recover
//! the phrase from the seed and vice versa.
//!
//! - **12 words** → **16 bytes of entropy** → **Ed25519 seed**, via
//! `SHA-256("kez-bip39-12-v1" || entropy)`. The phrase is the
//! canonical secret; the seed is derived from it deterministically.
//! You **cannot** recover a 12-word phrase from a seed (the
//! derivation is one-way). KEZ-specific; not interoperable with
//! hardware wallet derivations.
//!
//! Wordlist: BIP-39 English. Same wordlist every other crypto wallet
//! uses, so users can store a KEZ phrase in the same offline-paper /
//! steel-plate setup they already use.
//!
//! NB: We deliberately do *not* use BIP-39's `to_seed(passphrase)`
//! function. That produces a 64-byte seed via PBKDF2, intended to feed
//! into BIP-32 hierarchical derivation. KEZ has one identity per phrase,
//! no derivation tree, so taking the entropy directly (or hashing it
//! once for 12-word phrases) is the right primitive.
use bip39::{Language, Mnemonic};
use sha2::{Digest, Sha256};
use std::str::FromStr;
use crate::{Ed25519Secret, KezError, Result};
/// Domain separator for the 12-word → seed derivation. Bumping this
/// would break every existing 12-word KEZ identity, so don't.
const DOMAIN_TAG_12: &[u8] = b"kez-bip39-12-v1";
/// Supported mnemonic lengths.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MnemonicWords {
Twelve,
TwentyFour,
}
impl MnemonicWords {
pub fn count(self) -> usize {
match self {
Self::Twelve => 12,
Self::TwentyFour => 24,
}
}
/// Entropy length in bytes.
pub fn entropy_bytes(self) -> usize {
match self {
Self::Twelve => 16,
Self::TwentyFour => 32,
}
}
pub fn from_count(n: usize) -> Result<Self> {
match n {
12 => Ok(Self::Twelve),
24 => Ok(Self::TwentyFour),
other => Err(KezError::InvalidIdentity(format!(
"mnemonic word count must be 12 or 24, got {other}"
))),
}
}
}
/// Generate a fresh BIP-39 mnemonic of the requested length, using OS
/// randomness. The returned phrase is a space-separated lowercase string
/// from the BIP-39 English wordlist.
pub fn generate_mnemonic(words: MnemonicWords) -> Result<String> {
let m = Mnemonic::generate(words.count())
.map_err(|e| KezError::InvalidIdentity(format!("bip39 generate: {e}")))?;
Ok(m.to_string())
}
/// Decode a mnemonic phrase to a 32-byte Ed25519 seed. Accepts both
/// 12-word and 24-word phrases (auto-detected from length). For
/// 24-word, the entropy *is* the seed; for 12-word, the seed is
/// `SHA-256(DOMAIN_TAG_12 || entropy)` (see module docs).
pub fn seed_from_mnemonic(phrase: &str) -> Result<[u8; 32]> {
let m = Mnemonic::parse_in_normalized(Language::English, phrase.trim())
.map_err(|e| KezError::InvalidIdentity(format!("invalid mnemonic: {e}")))?;
let entropy = m.to_entropy();
match entropy.len() {
32 => {
// 24-word: entropy is the seed directly.
let mut seed = [0u8; 32];
seed.copy_from_slice(&entropy);
Ok(seed)
}
16 => {
// 12-word: domain-tagged hash.
let mut h = Sha256::new();
h.update(DOMAIN_TAG_12);
h.update(&entropy);
Ok(h.finalize().into())
}
other => Err(KezError::InvalidIdentity(format!(
"mnemonic must decode to 16 or 32 bytes of entropy, got {other}"
))),
}
}
/// Derive the 24-word phrase that corresponds to this seed. This is the
/// inverse of `seed_from_mnemonic` *for the 24-word case only*. There
/// is no inverse for the 12-word case (hashing is one-way) — this
/// function always produces 24 words.
pub fn mnemonic_from_seed_24(seed: &[u8; 32]) -> Result<String> {
let m = Mnemonic::from_entropy(seed)
.map_err(|e| KezError::InvalidIdentity(format!("bip39 from_entropy: {e}")))?;
Ok(m.to_string())
}
impl Ed25519Secret {
/// Construct from a BIP-39 phrase (12 or 24 words).
pub fn from_mnemonic(phrase: &str) -> Result<Self> {
let seed = seed_from_mnemonic(phrase)?;
Self::from_seed_hex(&hex::encode(seed))
}
/// Generate a fresh Ed25519 identity *and* return the BIP-39 phrase
/// that derives it. Always succeeds; the phrase is the canonical
/// human-friendly backup form.
pub fn generate_with_mnemonic(words: MnemonicWords) -> Result<(Self, String)> {
let phrase = generate_mnemonic(words)?;
let secret = Self::from_mnemonic(&phrase)?;
Ok((secret, phrase))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_24_round_trips() {
let phrase = generate_mnemonic(MnemonicWords::TwentyFour).unwrap();
assert_eq!(phrase.split_whitespace().count(), 24);
let seed = seed_from_mnemonic(&phrase).unwrap();
let phrase2 = mnemonic_from_seed_24(&seed).unwrap();
assert_eq!(phrase, phrase2, "24-word phrase must round-trip");
}
#[test]
fn generate_12_is_deterministic() {
let phrase = generate_mnemonic(MnemonicWords::Twelve).unwrap();
assert_eq!(phrase.split_whitespace().count(), 12);
let s1 = seed_from_mnemonic(&phrase).unwrap();
let s2 = seed_from_mnemonic(&phrase).unwrap();
assert_eq!(s1, s2, "same phrase must give the same seed");
}
#[test]
fn mnemonic_from_seed_24_is_inverse() {
// Random seed → 24 words → back to same seed.
let seed = [42u8; 32];
let phrase = mnemonic_from_seed_24(&seed).unwrap();
let recovered = seed_from_mnemonic(&phrase).unwrap();
assert_eq!(seed, recovered);
}
#[test]
fn invalid_phrase_errors_cleanly() {
assert!(seed_from_mnemonic("not actually words").is_err());
// Wrong checksum.
assert!(
seed_from_mnemonic(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
)
.is_err()
);
}
#[test]
fn twelve_and_24_phrases_give_different_seeds() {
// Sanity: 12-word entropy left-padded to 32 would equal 16
// bytes of zeros + entropy. We DON'T do that — we hash. So
// two phrases with overlapping entropy must not collide.
let m12 = Mnemonic::from_entropy(&[7u8; 16]).unwrap();
let m24 = Mnemonic::from_entropy(&[7u8; 32]).unwrap();
let s12 = seed_from_mnemonic(&m12.to_string()).unwrap();
let s24 = seed_from_mnemonic(&m24.to_string()).unwrap();
assert_ne!(s12, s24);
}
#[test]
fn from_mnemonic_matches_direct_seed_construction() {
// 24-word case: Ed25519Secret::from_mnemonic must produce the
// same key as Ed25519Secret::from_seed_hex(entropy).
let phrase = mnemonic_from_seed_24(&[1u8; 32]).unwrap();
let from_mnemonic = Ed25519Secret::from_mnemonic(&phrase).unwrap();
let from_hex = Ed25519Secret::from_seed_hex(&hex::encode([1u8; 32])).unwrap();
assert_eq!(from_mnemonic.pubkey_hex(), from_hex.pubkey_hex());
}
#[test]
fn generate_with_mnemonic_pair_is_consistent() {
let (secret, phrase) = Ed25519Secret::generate_with_mnemonic(MnemonicWords::TwentyFour)
.unwrap();
let restored = Ed25519Secret::from_mnemonic(&phrase).unwrap();
assert_eq!(secret.pubkey_hex(), restored.pubkey_hex());
}
#[test]
fn parser_accepts_leading_trailing_whitespace() {
let phrase = generate_mnemonic(MnemonicWords::TwentyFour).unwrap();
let padded = format!(" {phrase} ");
assert_eq!(
seed_from_mnemonic(&phrase).unwrap(),
seed_from_mnemonic(&padded).unwrap()
);
}
#[test]
fn mnemonic_words_count_round_trip() {
assert_eq!(MnemonicWords::Twelve.count(), 12);
assert_eq!(MnemonicWords::TwentyFour.count(), 24);
assert_eq!(MnemonicWords::from_count(12).unwrap(), MnemonicWords::Twelve);
assert_eq!(
MnemonicWords::from_count(24).unwrap(),
MnemonicWords::TwentyFour
);
assert!(MnemonicWords::from_count(18).is_err());
}
}
// Catches inverse with hint for FromStr users.
impl std::str::FromStr for MnemonicWords {
type Err = KezError;
fn from_str(s: &str) -> Result<Self> {
Self::from_count(
s.parse::<usize>()
.map_err(|_| KezError::InvalidIdentity(format!("not a number: {s}")))?,
)
}
}