Merge branch 'mnemonics' — BIP-39 recovery phrases + chat-app chain mirror + Python impl

8 commits landing from the mnemonics branch.

CORE FEATURE — BIP-39 recovery phrases for Ed25519 identities, across
all three implementations (Rust, Node, Python) plus the chat-app web
client. Bit-perfect interop verified by 27 new crosstest scenarios.

  • 0058d9b feat(rust,nodejs): BIP-39 mnemonic phrases for Ed25519
    identities (kez-core libs + CLI: identity new --mnemonic-words,
    identity mnemonic, identity from-mnemonic; --mnemonic accepted
    anywhere --ed25519-seed is). 24-word = bijection with the 32-byte
    seed; 12-word = SHA-256("kez-bip39-12-v1" || entropy) → seed,
    one-way KEZ-specific derivation. 9 rust + 8 node mnemonic tests.
  • b0cc1a7 feat(python,crosstest): mirror to Python (kez/mnemonic.py
    using Trezor's `mnemonic` lib; 19 pytest tests; CLI surface match)
    + crosstest.sh gains "BIP-39 mnemonic interop" section with three
    canonical test vectors checked across all three impls and 18 cross-
    impl claim round-trips via --mnemonic. crosstest now passes 84/84.
  • 3fdbdc9 feat(kez-chat/web): 12-word phrase replaces hex seed in
    the chat-app account flow (browser-native lib/mnemonic.ts with the
    same domain tag; numbered-grid display on create + onboarding;
    restore accepts phrase or legacy hex; Settings reveals phrase when
    available; identity-store gains optional encrypted phrase field,
    backwards-compatible with pre-mnemonic accounts).

CHAT APP — chain-service mirror
  • 5ad47a9 feat(kez-chat/web): when the user adds a claim, append an
    `add` event to their sigchain on the chain service (rust-sig-server);
    revoke on delete. Implements SPEC.md §8. Per-row "Sync to chain"
    retry; ask-before-drop if the chain service is unreachable.

USER COMMITS (carried in this merge)
  • 8789659 Add nostr chat notes, update favicon, add test.txt
  • b1240c1 Add Python implementation and cross-test interop

DOCS
  • aeba28d docs(rust,nodejs): expand TUTORIAL.md with the full
    "Recovery phrases" mini-chapter — 12 vs 24 entropy table, picking
    guide, hardware-wallet-incompatibility callout, backup hygiene
    advice, "working with phrases later" examples.
  • d0e96c1 docs(python): add python/TUTORIAL.md mirroring rust+nodejs
    (was missing). All three impl tutorials are now in parity. Root
    README points at each impl's README (reference) and TUTORIAL
    (step-by-step) side-by-side.

Test totals across the repo after this merge:
  Rust:     114 (was 99)
  Node:      99 (was 91)
  Python:    19 (new)
  Crosstest: 84 scenarios (was 55)
This commit is contained in:
Jason Tudisco 2026-06-06 13:38:47 -06:00
commit ec44018507
55 changed files with 4869 additions and 299 deletions

7
.gitignore vendored
View File

@ -11,6 +11,13 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Python
.venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
# Local runtime state
*.db
*.db-journal

View File

@ -17,13 +17,15 @@ relay event). Anyone can verify the graph without trusting a server.
├── SPEC.md ← The protocol. Language-agnostic, normative.
├── rust/ ← Rust implementation (kez-core, kez-channels, kez-cli)
├── nodejs/ ← TypeScript/Node implementation (same shape, same CLI)
├── python/ ← Python implementation (same shape, same CLI)
├── rust-sig-server/ ← Optional HTTP store for sigchains (axum + SQLite)
├── crosstest.sh ← Interop test: artifacts move between implementations
└── README.md ← (this file)
```
Two parallel implementations. **Wire-compatible**: a claim signed in Rust
verifies in Node and vice versa. The cross-test harness proves it.
Three parallel implementations. **Wire-compatible**: a claim signed in Rust
verifies in Node and Python and vice versa, in every direction. The cross-test
harness proves it.
A separate [`rust-sig-server/`](rust-sig-server/) crate provides an optional
HTTP storage tier for sigchains — useful when a user doesn't want to set up
@ -41,6 +43,9 @@ Start here:
- [**`nodejs/README.md`**](nodejs/README.md) — Node/TypeScript port:
same shape as Rust, npm workspaces layout, crypto stack rationale,
CLI reference.
- [**`python/README.md`**](python/README.md) — Python port: single
`kez` package, virtualenv setup, crypto stack rationale (pure-Python
BIP-340 Schnorr + `cryptography` for Ed25519), CLI reference.
- [**`rust-sig-server/README.md`**](rust-sig-server/README.md) — the
optional storage server: API reference, no-auth design + threat
model, deployment recipes (bare-metal, Docker, PaaS), and how
@ -56,7 +61,9 @@ cargo test # 99 tests
cargo install --path crates/kez-cli # → `kez` on PATH
kez verify id github:jason
```
Full guide: [`rust/README.md`](rust/README.md).
Full guide: [`rust/README.md`](rust/README.md) (reference) ·
[`rust/TUTORIAL.md`](rust/TUTORIAL.md) (step-by-step, recommended
for newcomers).
### Node.js
```sh
@ -65,7 +72,18 @@ npm install
npm test # 91 tests
npm run cli -- verify id github:jason
```
Full guide: [`nodejs/README.md`](nodejs/README.md).
Full guide: [`nodejs/README.md`](nodejs/README.md) (reference) ·
[`nodejs/TUTORIAL.md`](nodejs/TUTORIAL.md) (step-by-step).
### Python
```sh
cd python
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python kez_cli.py identity new
```
Full guide: [`python/README.md`](python/README.md) (reference) ·
[`python/TUTORIAL.md`](python/TUTORIAL.md) (step-by-step).
### Sigchain storage server (optional)
```sh
@ -81,27 +99,20 @@ Full guide: [`rust-sig-server/README.md`](rust-sig-server/README.md).
./crosstest.sh
```
Runs 19 scenarios that swap implementations at the artifact boundary:
Runs 55 scenarios that swap implementations at the artifact boundary:
| # | Scenario |
| # | Scenarios |
|---|---|
| 12 | nostr-signed JSON claim, both directions |
| 34 | nostr-signed compact claim, both directions |
| 56 | nostr-signed markdown claim, both directions |
| 78 | nostr-signed DNS zone form, both directions |
| 910 | ed25519-signed JSON claim, both directions |
| 1112 | ed25519-signed compact claim, both directions |
| 1314 | ed25519-signed markdown claim, both directions |
| 15 | rust builds 3-event nostr sigchain → node parses + shows |
| 16 | rust-exported sigchain JSONL == node-exported JSONL (byte-identical) |
| 17 | node builds 3-event nostr sigchain → rust parses + shows |
| 18 | rust builds ed25519 sigchain → node parses + shows |
| 19 | node builds ed25519 sigchain → rust parses + shows |
| 114 | Rust ↔ Node: JSON / compact / markdown / DNS claims, nostr + ed25519 |
| 1520 | Rust ↔ Node sigchains: build in one, parse + show in the other; JSONL byte parity |
| 2144 | **Python ↔ Rust and Python ↔ Node** claims: every format × key type, both directions |
| — | Python ↔ both peers DNS zone form, both directions |
| — | Python ↔ both peers sigchains: build/show both ways, JSONL byte parity, ed25519 |
If all 19 pass: JCS canonicalization, both signature suites (BIP-340 Schnorr
If all 55 pass: JCS canonicalization, both signature suites (BIP-340 Schnorr
and Ed25519), the compact `kez:z1:` zstd+base64url encoding, the Markdown
fence, the DNS TXT shape, and the sigchain JSONL bundle format are all
byte-compatible across implementations.
byte-compatible across all three implementations.
Pass `-v` for verbose output (echoes intermediate commands and proofs).

View File

@ -23,6 +23,14 @@ if [[ ! -f "$TSX_LOADER" ]]; then
exit 1
fi
NODE_CLI=(node --import "$TSX_LOADER" "$ROOT/nodejs/packages/kez-cli/src/cli.ts")
# Python CLI runs inside its own virtualenv so its native deps (cryptography,
# zstandard) are isolated from the system interpreter.
PYTHON_VENV="$ROOT/python/.venv/bin/python"
if [[ ! -x "$PYTHON_VENV" ]]; then
printf "Python venv not found at %s — run 'cd python && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt' first\n" "$PYTHON_VENV" >&2
exit 1
fi
PYTHON_CLI=("$PYTHON_VENV" "$ROOT/python/kez_cli.py")
VERBOSE=0
[[ "${1:-}" == "-v" ]] && VERBOSE=1
@ -73,6 +81,35 @@ assert_verify_valid() {
fi
}
# Dispatch to one of the three implementations by name. Keeps the Python
# interop scenarios below readable without juggling array variables.
run_cli() {
local which="$1"; shift
case "$which" in
rust) "${RUST_CLI[@]}" "$@" ;;
node) "${NODE_CLI[@]}" "$@" ;;
py) "${PYTHON_CLI[@]}" "$@" ;;
*) printf "unknown impl: %s\n" "$which" >&2; return 2 ;;
esac
}
# Sign a claim with one impl and verify it with another, for a given wire
# format. Remaining args are the signing-key flags (--nsec / --ed25519-seed).
claim_roundtrip() {
local title="$1" signer="$2" verifier="$3" fmt="$4"; shift 4
scenario "$title"
case "$fmt" in
json)
run_cli "$signer" claim create github:jason "$@" > "$TMP/rt.proof" 2>"$TMP/rt.err" ;;
markdown)
run_cli "$signer" claim create github:jason "$@" --format markdown --out "$TMP/rt.proof" 2>"$TMP/rt.err" ;;
*)
run_cli "$signer" claim create github:jason "$@" --format "$fmt" > "$TMP/rt.proof" 2>"$TMP/rt.err" ;;
esac
run_cli "$verifier" verify file "$TMP/rt.proof" > "$TMP/rt.out" 2>&1
assert_verify_valid "$title" "$TMP/rt.out" && ok
}
# Pre-flight: build Rust release once (much faster reruns).
printf "%sBuilding Rust impl…%s\n" "$YELLOW" "$RESET"
cargo build --quiet --manifest-path "$ROOT/rust/Cargo.toml" -p kez-cli
@ -181,6 +218,40 @@ scenario "node ed25519 markdown ⇒ rust verify file"
"${RUST_CLI[@]}" verify file "$TMP/n.kez.md" > "$TMP/n.out" 2>&1
assert_verify_valid "node→rust ed25519 markdown" "$TMP/n.out" && ok
# ── Python interop (claims) ─────────────────────────────────────────────────
# The Python implementation must round-trip with BOTH peers in BOTH directions,
# across every wire encoding and both key types — proving its JCS bytes,
# signatures (pure-Python BIP-340 Schnorr + ed25519) and zstd compact framing
# are all byte-compatible with Rust and Node.
printf "%sPython interop (claims):%s\n" "$YELLOW" "$RESET"
for peer in node rust; do
for fmt in json compact markdown; do
for kt in nostr ed25519; do
if [[ "$kt" == nostr ]]; then key=(--nsec "$NSEC"); else key=(--ed25519-seed "$SEED"); fi
claim_roundtrip "py $kt $fmt$peer verify" py "$peer" "$fmt" "${key[@]}"
claim_roundtrip "$peer $kt $fmt ⇒ py verify" "$peer" py "$fmt" "${key[@]}"
done
done
done
# Python DNS zone form → both peers verify the extracted compact token.
for peer in node rust; do
scenario "py DNS zone form ⇒ $peer verify file"
"${PYTHON_CLI[@]}" claim dns jason.example.com --nsec "$NSEC" > "$TMP/pd.dns"
awk '/^Value:/ {print $2}' "$TMP/pd.dns" > "$TMP/pd.compact"
run_cli "$peer" verify file "$TMP/pd.compact" > "$TMP/pd.out" 2>&1
assert_verify_valid "py DNS→$peer compact" "$TMP/pd.out" && ok
done
# Each peer's DNS compact token → Python verifies.
for peer in node rust; do
scenario "$peer DNS zone form ⇒ py verify file"
run_cli "$peer" claim dns jason.example.com --nsec "$NSEC" > "$TMP/xd.dns"
awk '/^Value:/ {print $2}' "$TMP/xd.dns" > "$TMP/xd.compact"
"${PYTHON_CLI[@]}" verify file "$TMP/xd.compact" > "$TMP/xd.out" 2>&1
assert_verify_valid "$peer DNS→py compact" "$TMP/xd.out" && ok
done
# ── Sigchain interop ────────────────────────────────────────────────────────
# Sigchain state lives in ~/.kez/sigchains/<safe-primary>.jsonl. Both CLIs
# read/write the same paths, so we can build a chain in one and inspect it
@ -289,6 +360,176 @@ if grep -q "Length: 2 event(s)" "$TMP/sc_f.out"; then ok; else
fi
rm -f "$SC_ED_FILE"
# ── Python sigchain interop ─────────────────────────────────────────────────
# Build chains with Python and inspect them from each peer (and vice versa),
# over the same ~/.kez/sigchains state files. Uses fresh keys to avoid
# colliding with the scenarios above.
printf "%sPython interop (sigchains):%s\n" "$YELLOW" "$RESET"
PY_NOSTR_OUT="$("${PYTHON_CLI[@]}" identity new)"
PY_NSEC="$(printf '%s\n' "$PY_NOSTR_OUT" | extract_nsec /dev/stdin)"
PY_NOSTR_PRIMARY="$(printf '%s\n' "$PY_NOSTR_OUT" | sed -n 's/^Primary:[[:space:]]*//p' | head -1)"
PY_NOSTR_FILE="$(chain_file_for "$PY_NOSTR_PRIMARY")"
PY_ED_OUT="$("${PYTHON_CLI[@]}" identity new --key-type ed25519)"
PY_SEED="$(printf '%s\n' "$PY_ED_OUT" | extract_ed25519_seed /dev/stdin)"
PY_ED_PRIMARY="$(printf '%s\n' "$PY_ED_OUT" | sed -n 's/^Primary:[[:space:]]*//p' | head -1)"
PY_ED_FILE="$(chain_file_for "$PY_ED_PRIMARY")"
rm -f "$PY_NOSTR_FILE" "$PY_ED_FILE"
# Python builds a nostr chain; each peer must see all 3 events incl the revoke.
"${PYTHON_CLI[@]}" sigchain add github:jason --nsec "$PY_NSEC" > /dev/null
"${PYTHON_CLI[@]}" sigchain add dns:jason.example --nsec "$PY_NSEC" > /dev/null
"${PYTHON_CLI[@]}" sigchain revoke github:jason --nsec "$PY_NSEC" > /dev/null
for peer in node rust; do
scenario "py nostr chain ⇒ $peer sigchain show"
run_cli "$peer" sigchain show --primary "$PY_NOSTR_PRIMARY" > "$TMP/psc.out" 2>&1
if grep -q "Length: 3 event(s)" "$TMP/psc.out" \
&& grep -q "op=revoke subject=github:jason" "$TMP/psc.out"; then
ok
else
bad "py→$peer sigchain show" "see $TMP/psc.out"
cat "$TMP/psc.out" >&2
fi
done
rm -f "$PY_NOSTR_FILE"
# Each peer builds a nostr chain; Python must see all 3 events.
for peer in node rust; do
rm -f "$PY_NOSTR_FILE"
run_cli "$peer" sigchain add github:jason --nsec "$PY_NSEC" > /dev/null
run_cli "$peer" sigchain add dns:jason.example --nsec "$PY_NSEC" > /dev/null
run_cli "$peer" sigchain revoke github:jason --nsec "$PY_NSEC" > /dev/null
scenario "$peer nostr chain ⇒ py sigchain show"
"${PYTHON_CLI[@]}" sigchain show --primary "$PY_NOSTR_PRIMARY" > "$TMP/psc2.out" 2>&1
if grep -q "Length: 3 event(s)" "$TMP/psc2.out" \
&& grep -q "op=revoke subject=github:jason" "$TMP/psc2.out"; then
ok
else
bad "$peer→py sigchain show" "see $TMP/psc2.out"
cat "$TMP/psc2.out" >&2
fi
rm -f "$PY_NOSTR_FILE"
done
# JSONL byte parity: Python and each peer must export the same on-disk chain
# to byte-identical JSONL.
"${PYTHON_CLI[@]}" sigchain add github:jason --nsec "$PY_NSEC" > /dev/null
"${PYTHON_CLI[@]}" sigchain add dns:jason.example --nsec "$PY_NSEC" > /dev/null
for peer in node rust; do
scenario "py JSONL == $peer JSONL for same chain"
"${PYTHON_CLI[@]}" sigchain export --nsec "$PY_NSEC" --format jsonl > "$TMP/pj_py.jsonl"
run_cli "$peer" sigchain export --nsec "$PY_NSEC" --format jsonl > "$TMP/pj_peer.jsonl"
if diff -q "$TMP/pj_py.jsonl" "$TMP/pj_peer.jsonl" > /dev/null; then
ok
else
bad "py/$peer JSONL parity" "exported bytes differ"
diff "$TMP/pj_py.jsonl" "$TMP/pj_peer.jsonl" | head -20 >&2
fi
done
rm -f "$PY_NOSTR_FILE"
# Python ed25519 chain ⇒ each peer validates.
"${PYTHON_CLI[@]}" sigchain add github:jason --ed25519-seed "$PY_SEED" > /dev/null
"${PYTHON_CLI[@]}" sigchain add dns:jason.example --ed25519-seed "$PY_SEED" > /dev/null
for peer in node rust; do
scenario "py ed25519 chain ⇒ $peer sigchain show"
run_cli "$peer" sigchain show --primary "$PY_ED_PRIMARY" > "$TMP/pse.out" 2>&1
if grep -q "Length: 2 event(s)" "$TMP/pse.out"; then ok; else
bad "py→$peer ed25519 chain" "$peer did not see all events"
cat "$TMP/pse.out" >&2
fi
done
rm -f "$PY_ED_FILE"
# ── BIP-39 Mnemonic interop ─────────────────────────────────────────────────
# 12- and 24-word phrases must derive identical Ed25519 keys across all
# implementations, and a claim signed with --mnemonic in one impl must
# verify in the others. See python/MNEMONIC-TEST-VECTORS.md for the
# definitive ground-truth vectors.
printf "%sBIP-39 mnemonic interop:%s\n" "$YELLOW" "$RESET"
# Canonical test vectors. Public keys are the expected outputs that all
# three implementations MUST agree on byte-for-byte. If any of these
# values change, an implementation has a derivation bug.
MNEMO_P24="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"
MNEMO_PUB_24="3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
MNEMO_P12="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
MNEMO_PUB_12="9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70"
MNEMO_P12B="legal winner thank year wave sausage worth useful legal winner thank yellow"
MNEMO_PUB_12B="cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03"
# Probe: does the Python CLI know about `identity from-mnemonic` yet?
PY_HAS_MNEMONIC=0
if [[ -x "$PYTHON_VENV" ]]; then
if "${PYTHON_CLI[@]}" identity from-mnemonic "$MNEMO_P12" 2>/dev/null \
| grep -q "^Public:"; then
PY_HAS_MNEMONIC=1
fi
fi
# Helper: assert the impl derives the expected pubkey from a phrase.
assert_pubkey() {
local impl="$1" phrase="$2" expected="$3" title="$4"
scenario "$title"
local actual
actual=$(run_cli "$impl" identity from-mnemonic "$phrase" 2>/dev/null \
| awk -F': *' '/^Public:/ {print $2; exit}')
if [[ "$actual" == "$expected" ]]; then ok; else
bad "$title" "expected pubkey $expected, got $actual"
fi
}
# Vector matches per impl.
for impl in rust node; do
assert_pubkey "$impl" "$MNEMO_P24" "$MNEMO_PUB_24" "$impl: V1 24-word vector derives expected pubkey"
assert_pubkey "$impl" "$MNEMO_P12" "$MNEMO_PUB_12" "$impl: V2 12-word vector derives expected pubkey"
assert_pubkey "$impl" "$MNEMO_P12B" "$MNEMO_PUB_12B" "$impl: V3 12-word vector derives expected pubkey"
done
if [[ "$PY_HAS_MNEMONIC" -eq 1 ]]; then
assert_pubkey py "$MNEMO_P24" "$MNEMO_PUB_24" "py: V1 24-word vector derives expected pubkey"
assert_pubkey py "$MNEMO_P12" "$MNEMO_PUB_12" "py: V2 12-word vector derives expected pubkey"
assert_pubkey py "$MNEMO_P12B" "$MNEMO_PUB_12B" "py: V3 12-word vector derives expected pubkey"
else
printf " %sskip%s %s\n" "$YELLOW" "$RESET" \
"py vector checks (python CLI lacks identity from-mnemonic — port still in flight)"
fi
# Cross-impl claim signing with --mnemonic. Each impl signs, each other
# verifies. Uses the V3 phrase because it has non-trivial entropy.
for fmt in json compact markdown; do
claim_roundtrip "rust mnemonic ($fmt) ⇒ node verify" rust node "$fmt" --mnemonic "$MNEMO_P12B"
claim_roundtrip "node mnemonic ($fmt) ⇒ rust verify" node rust "$fmt" --mnemonic "$MNEMO_P12B"
if [[ "$PY_HAS_MNEMONIC" -eq 1 ]]; then
claim_roundtrip "py mnemonic ($fmt) ⇒ rust verify" py rust "$fmt" --mnemonic "$MNEMO_P12B"
claim_roundtrip "rust mnemonic ($fmt) ⇒ py verify" rust py "$fmt" --mnemonic "$MNEMO_P12B"
claim_roundtrip "py mnemonic ($fmt) ⇒ node verify" py node "$fmt" --mnemonic "$MNEMO_P12B"
claim_roundtrip "node mnemonic ($fmt) ⇒ py verify" node py "$fmt" --mnemonic "$MNEMO_P12B"
fi
done
if [[ "$PY_HAS_MNEMONIC" -ne 1 ]]; then
printf " %sskip%s %s\n" "$YELLOW" "$RESET" \
"py mnemonic claim round-trips (port still in flight)"
fi
# Bijection sanity: 24-word phrase ⇄ seed must be exact. Each impl must
# produce the canonical phrase from a known 32-byte seed via the
# mnemonic-from-seed path (we drive it indirectly via the printed output
# of `identity from-mnemonic`).
scenario "24-word phrase is canonical form of its seed (rust)"
got=$("${RUST_CLI[@]}" identity from-mnemonic "$MNEMO_P24" 2>/dev/null \
| awk -F': *' '/^Mnemonic .24 words/ { match($0, /"[^"]+"/); print substr($0, RSTART+1, RLENGTH-2); exit }')
if [[ "$got" == "$MNEMO_P24" ]]; then ok; else
bad "rust canonical-24" "round-trip phrase differs"
fi
scenario "24-word phrase is canonical form of its seed (node)"
got=$("${NODE_CLI[@]}" identity from-mnemonic "$MNEMO_P24" 2>/dev/null \
| awk -F': *' '/^Mnemonic .24 words/ { match($0, /"[^"]+"/); print substr($0, RSTART+1, RLENGTH-2); exit }')
if [[ "$got" == "$MNEMO_P24" ]]; then ok; else
bad "node canonical-24" "round-trip phrase differs"
fi
printf "\n"
if [[ $FAIL -eq 0 ]]; then
printf "%sAll %d scenarios passed.%s\n" "$GREEN" "$PASS" "$RESET"

267
kez-chat/web/NOSTR-CHAT.md Normal file
View File

@ -0,0 +1,267 @@
# Nostr Chat
How the `nostr` branch carries kez-chat messages over Nostr relays instead
of the kez-chat server inbox — without changing the identity model or the
end-to-end encryption.
> **One-line summary:** the chat transport is swapped from an HTTP/SSE server
> inbox to Nostr relays. Everything else — your ed25519 identity, the
> `SealedEnvelope` encryption, the UI — is untouched. Nostr only moves bytes.
---
## 1. The core idea
kez-chat already had its own end-to-end encryption. A message is sealed by
`crypto.ts` into a **`SealedEnvelope`** (AES-256-GCM body + an ed25519 sender
signature, keyed off the user's ed25519 identity). The original transport
(`messages.ts`) just `POST`s that opaque envelope to the server's
`/v1/messages` endpoint and reads it back from `/v1/inbox`.
Because the envelope is already encrypted and self-authenticating, the
transport is interchangeable. The Nostr build keeps the exact same envelope
and changes only **how it travels**:
```
┌─────────────────────── unchanged ───────────────────────┐
plaintext ─► sealMessage() ─► SealedEnvelope ──► [ TRANSPORT ] ──► peer
│ (ed25519/x25519 + AES-GCM, crypto.ts) │
└──────────────────────────────────────────────────────────┘
server transport: POST /v1/messages ┐
GET /v1/inbox (poll+SSE) ┘ ← kez-chat server + SQLite
nostr transport: publish event to relays ┐
subscribe by #h tag ┘ ← public Nostr relays
```
The `SealedEnvelope` is the **"extra layer of encryption using our own key"** —
it exists independently of Nostr and is what actually protects the message
body. Nostr is a dumb pipe underneath it.
---
## 2. Identity: bridging ed25519 onto Nostr
KEZ identities are **ed25519**. Nostr signs events with **secp256k1**
(Schnorr). The two curves cannot be cross-derived — you cannot turn someone's
ed25519 public key into "their" Nostr public key. The bridge
(`nostr-id.ts`) solves this in two halves:
### 2a. Signing key (derived from your own seed)
Every account needs a secp256k1 key to sign Nostr events (relays reject
unsigned events). We derive it deterministically from the user's 32-byte
ed25519 seed:
```
nostrSecret = HKDF-SHA256(
ikm = ed25519_seed,
salt = "kez-chat:nostr-signkey",
info = "v1",
len = 32,
)
```
Properties:
- **Deterministic** — the same account always produces the same Nostr signer,
on any device, with no extra storage.
- **Internal** — it is a pure transport credential. It is *not* the user's
real Nostr account, it is never surfaced in the UI, and its public key is
never advertised or used for addressing.
- **One-way** — HKDF means the Nostr key reveals nothing about the ed25519
seed (the actual secret).
### 2b. Addressing (derived from the recipient's *public* primary)
Since we can't compute a recipient's Nostr pubkey, we don't address events to
a pubkey at all. Instead each event carries a routing label derived from the
recipient's **public** ed25519 primary (which any sender can look up in the
directory):
```
addr = HKDF-SHA256(
ikm = utf8(recipient_primary), // e.g. "ed25519:abc123…"
salt = "kez-chat:nostr-addr",
info = "v1",
len = 32,
) // → 32-byte hex
```
The sender stamps this on the event as a tag; the recipient subscribes for
events carrying their own `addr`. Both sides compute the same value from the
same public primary — the sender from a directory lookup, the recipient from
their own identity. Using a hash (rather than the raw primary) keeps the
plaintext primary out of a relay-queryable tag.
---
## 3. The event format
| Field | Value |
|--------------|--------------------------------------------------------------------|
| `kind` | `4242` (`KEZ_DM_KIND`) — a *regular* kind (10009999), so relays persist it |
| `tags` | `[["h", <recipient addr hex>]]``h` = `ADDR_TAG`, the routing label |
| `content` | `JSON.stringify(SealedEnvelope)` — our encrypted, signed envelope |
| `pubkey` | the sender's derived secp256k1 pubkey (transport credential) |
| `sig` | Schnorr signature over the event (so relays accept it) |
| `created_at` | unix seconds |
A subscriber filters with:
```json
{ "kinds": [4242], "#h": ["<my addr hex>"], "since": <unix-seconds> }
```
---
## 4. Message lifecycle
### Sending (`nostr-transport.ts``sendMessage`)
1. Resolve the recipient handle → ed25519 primary via the directory
(`/v1/u/<handle>` — still served by the kez-chat server).
2. `sealMessage(...)``SealedEnvelope` (identical to the server transport).
3. Build an event: `kind 4242`, tag `["h", addrFromPrimary(recipientPrimary)]`,
`content = JSON.stringify(envelope)`.
4. Sign it with `nostrSecretFromSeed(senderSeed)` (`finalizeEvent`).
5. `pool.publish(RELAYS, event)` — succeeds if **at least one** relay accepts.
If every relay rejects, `sendMessage` throws `"no relay accepted the message"`.
### Receiving (`streamInbox` + `pollInbox`)
The global `inbox-service` runs both, exactly as it did for the server
transport:
- **`streamInbox`** opens a live subscription
(`pool.subscribeMany`) filtered on the user's own `addr`. Each event fires
`onevent`; after the relays finish replaying stored events, `oneose` flips
the UI status to **live**.
- **`pollInbox`** is a one-shot `pool.querySync` used as a heartbeat catch-up
(every 30s and on startup), so nothing is missed if the subscription drops.
Each incoming event is:
1. De-duplicated by event id (see §5).
2. Parsed: `content``SealedEnvelope`.
3. Decrypted by the **unchanged** `decrypt()` (`crypto.ts``openMessage`),
which verifies the sender's ed25519 signature and AES-GCM-decrypts the body.
4. Appended to the local conversation store and rendered.
---
## 5. Cursors & de-duplication
Relays can resend events, and two transports (live sub + heartbeat poll) can
deliver the same event. Both are made idempotent with per-handle
`localStorage` state:
- **`kez-chat:nostr:since:<handle>`** — the relay `since` filter (unix
seconds). Advances as events arrive, so reloads resume instead of
re-fetching. A fresh device defaults to `now 7 days` to catch recent
history.
- **`kez-chat:nostr:seen:<handle>`** — a capped set (last `500` ids) of
processed event ids. An id already in the set is skipped.
### Mapping to `seq`
The conversation store and the notification watermark were built around a
monotonic server `seq`. Nostr has no per-recipient sequence, so the transport
synthesizes one from the event's timestamp:
```
seq = created_at * 1000 + (parseInt(event.id.slice(0,3), 16) % 1000)
```
This is monotonic-by-time across sessions (so the unread/notify watermark
keeps working) and spreads messages within the same one-second granularity
using the event id, avoiding collisions between distinct same-second messages.
---
## 6. Configuration
Build-time, via Vite env (`.env` / `.env.local`):
| Variable | Default | Meaning |
|----------------------|-----------------------------------------------------------|------------------------------------------|
| `VITE_TRANSPORT` | `server` | `server` or `nostr` — which pipe to use |
| `VITE_NOSTR_RELAYS` | `wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net` | comma-separated relay list |
> The **code default is `server`**, so `main` and other branches are
> unaffected. This branch ships a committed `.env` that sets
> `VITE_TRANSPORT=nostr`.
### The facade (`transport.ts`)
`inbox-service` and the `Messages` route import `sendMessage` / `pollInbox` /
`streamInbox` / `decrypt` from `transport.ts`, which re-exports either the
server (`messages.ts`) or Nostr (`nostr-transport.ts`) implementation based on
`VITE_TRANSPORT`. Neither consumer knows which pipe is active. Switching
transports is a one-line env change + rebuild.
---
## 7. Privacy model
- **Confidential:** the message *body*. It is AES-256-GCM encrypted inside the
`SealedEnvelope` before it ever reaches a relay. Relays (and anyone reading
them) see only ciphertext.
- **Visible to relays:** the social-graph metadata. The envelope carries the
sender's primary (`from`) and recipient handle (`to`) in its JSON, and the
`#h` tag routes by recipient. A relay can therefore observe who is talking to
whom and when — **the same metadata the kez-chat server saw with the old
transport.** This is parity, not a regression.
- **Authenticity:** every envelope is signed by the sender's ed25519 key and
verified on decrypt, so a relay (or anyone) cannot forge or tamper with a
message without detection.
If hiding the social graph from relays becomes a requirement, the next step is
an outer NIP-44 wrap around the envelope. It was deliberately left out to keep
this a clean, minimal transport swap.
---
## 8. Known limitations
- **No backfill of old server-era messages.** Messages delivered over the old
server transport were never published to relays and cannot be fetched over
Nostr. Locally cached history (IndexedDB) still renders, but it is a stale
snapshot, not relay-backed.
- **Relay acceptance varies.** Some public relays restrict event kinds or rate.
If all configured relays reject `kind 4242`, sends fail loudly. Run your own
relay or pick permissive ones via `VITE_NOSTR_RELAYS` for reliability.
- **Eventual, not guaranteed, delivery.** Delivery depends on the recipient and
sender sharing at least one reachable relay. More relays = more resilience,
more metadata exposure.
- **Same-second ordering** is approximated (see §5), not exact.
---
## 9. File map
| File | Role |
|----------------------------|-------------------------------------------------------------|
| `src/lib/crypto.ts` | **Unchanged.** The `SealedEnvelope` E2E layer (our own key).|
| `src/lib/nostr-id.ts` | Derive the secp256k1 signing key; compute recipient `addr`. |
| `src/lib/nostr-transport.ts` | Nostr `send`/`poll`/`stream`; cursor + dedupe. |
| `src/lib/transport.ts` | Facade selecting server vs nostr via `VITE_TRANSPORT`. |
| `src/lib/messages.ts` | **Unchanged.** The original server transport. |
| `src/lib/inbox-service.svelte.ts` | Imports from the facade; otherwise unchanged. |
| `src/routes/Messages.svelte` | Imports `sendMessage` from the facade. |
| `.env` | Flips this branch to `VITE_TRANSPORT=nostr`. |
---
## 10. How to verify it's really on Nostr
Open DevTools → **Network → WS** on the running app:
- You should see live WebSocket connections to the configured relays
(`wss://relay.damus.io`, etc.).
- Sending a message emits an outgoing `["EVENT", …]` frame; receiving arrives
as an incoming event frame.
- You should **not** see `POST /v1/messages` or an SSE connection to
`/v1/inbox/<handle>/stream` (those were the server transport).
- The only remaining server call is the directory lookup `GET /v1/u/<handle>`.

View File

@ -12,6 +12,7 @@
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
"@scure/base": "^1.1.9",
"@scure/bip39": "^2.2.0",
"canonicalize": "^2.0.0",
"emoji-picker-element": "^1.29.1",
"idb-keyval": "^6.2.1",
@ -3164,22 +3165,22 @@
}
},
"node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"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.0.1",
"@scure/base": "2.0.0"
"@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.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"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"
@ -3189,9 +3190,9 @@
}
},
"node_modules/@scure/bip39/node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"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/"
@ -6051,6 +6052,19 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",

View File

@ -14,6 +14,7 @@
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
"@scure/base": "^1.1.9",
"@scure/bip39": "^2.2.0",
"canonicalize": "^2.0.0",
"emoji-picker-element": "^1.29.1",
"idb-keyval": "^6.2.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 504 B

View File

@ -3,7 +3,7 @@
import { ed25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils";
import type { SignedRegistration } from "./kez.js";
import type { SignedRegistration, SignedSigchainEvent } from "./kez.js";
export interface HandleResponse {
handle: string;
@ -118,3 +118,53 @@ export async function register(
});
return unwrap(resp);
}
// ─────────────────────────────────────────────────────────────────────────────
// Chain service (sigchain storage server)
//
// `sigchainUrl` is the per-user base URL the chat server hands back in a
// handle lookup (`HandleResponse.sigchain_url`), e.g.
// `https://sig.kez.lat/v1/sigchains/ed25519/<hex>`. It's a different origin
// than the chat server, so these talk to it directly (it sends permissive
// CORS). The signatures are the source of truth — the chain service just
// stores them.
// ─────────────────────────────────────────────────────────────────────────────
/**
* Fetch the full sigchain for a user as an ordered list of signed events.
* The chain service returns `application/jsonl` (one envelope per line);
* an unknown/empty chain yields an empty list.
*/
export async function getSigchain(
sigchainUrl: string,
): Promise<SignedSigchainEvent[]> {
const resp = await fetch(sigchainUrl);
if (resp.status === 404) return [];
if (!resp.ok) {
throw new ApiError(resp.status, `getSigchain → HTTP ${resp.status}`);
}
const text = await resp.text();
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as SignedSigchainEvent);
}
/**
* Append one signed event to the user's sigchain on the chain service.
* The server re-runs the full integrity check (tag, primary, seq, prev,
* signature) and returns the recorded `{ seq, hash }` on success.
*/
export async function postSigchainEvent(
sigchainUrl: string,
event: SignedSigchainEvent,
): Promise<{ seq: number; hash: string }> {
const base = sigchainUrl.replace(/\/$/, "");
const resp = await fetch(`${base}/events`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(event),
});
return unwrap(resp);
}

View File

@ -17,8 +17,25 @@ export interface StoredClaim {
notes?: string;
/** Latest verification result, if we've checked. */
last_verify?: VerifyResult;
// ── Chain-service mirror (sigchain) ──
/** Chain-service base URL this claim was mirrored to (its sigchain URL). */
chain_service?: string;
/** Sequence number of the sigchain `add` event for this claim. */
sigchain_seq?: number;
/** Sync state of the sigchain mirror. */
sigchain_status?: "synced" | "error";
/** Error detail when sigchain_status === "error". */
sigchain_error?: string;
}
/** Fields the caller may patch after a chain-service sync attempt. */
export type SigchainSyncPatch = Partial<
Pick<
StoredClaim,
"chain_service" | "sigchain_seq" | "sigchain_status" | "sigchain_error"
>
>;
export async function listClaims(): Promise<StoredClaim[]> {
return (await get<StoredClaim[]>(KEY)) ?? [];
}
@ -50,6 +67,18 @@ export async function setVerifyResult(
}
}
/** Record the outcome of a chain-service (sigchain) sync for a claim. */
export async function setSigchainSync(
id: string,
patch: SigchainSyncPatch,
): Promise<void> {
const existing = await listClaims();
const target = existing.find((c) => c.id === id);
if (!target) return;
Object.assign(target, patch);
await set(KEY, existing);
}
export async function removeClaim(id: string): Promise<void> {
const existing = await listClaims();
await set(

View File

@ -33,6 +33,12 @@ interface StoredIdentity {
salt: string; // hex, 16 bytes
nonce: string; // hex, 12 bytes
ciphertext: string; // hex; AES-GCM(seed) under PBKDF2(passphrase)
// Optional: encrypted 12-word recovery phrase (added in the mnemonic
// rollout). New accounts have both; pre-mnemonic accounts have only
// the seed. Encrypted under the SAME PBKDF2 key as the seed; uses its
// own nonce because AES-GCM reuse is unsafe.
phrase_nonce?: string; // hex, 12 bytes
phrase_ciphertext?: string; // hex; AES-GCM(utf8(phrase))
// Metadata:
created_at: string; // RFC3339
}
@ -42,6 +48,8 @@ export interface UnlockedIdentity {
server: string;
primary: Identity;
seed: Uint8Array;
/** 12-word recovery phrase. Absent for pre-mnemonic accounts. */
phrase?: string;
}
const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 SHA-256 guidance
@ -76,6 +84,15 @@ export async function hasStoredIdentity(): Promise<boolean> {
return !!stored;
}
/** True iff the stored identity carries an encrypted recovery phrase
* (created via the 12-word-mnemonic flow). Used to distinguish a
* truly-legacy hex-only account from a phrase account that just isn't
* available in the current session (e.g. biometric unlock didn't decrypt it). */
export async function hasStoredPhrase(): Promise<boolean> {
const stored = await get<StoredIdentity>(IDB_KEY);
return !!(stored?.phrase_ciphertext && stored?.phrase_nonce);
}
export async function loadStoredIdentityMeta(): Promise<
Pick<StoredIdentity, "handle" | "server" | "primary" | "created_at"> | null
> {
@ -91,6 +108,11 @@ export async function saveIdentity(opts: {
primary: Identity;
seed: Uint8Array;
passphrase: string;
/** Optional 12-word phrase stored encrypted so the user can re-display
* it later. The seed is derived from the phrase one-way (SHA-256 with
* domain tag); we can't recover the phrase from the seed, hence
* storing it explicitly. */
phrase?: string;
}): Promise<void> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const nonce = crypto.getRandomValues(new Uint8Array(12));
@ -113,6 +135,21 @@ export async function saveIdentity(opts: {
ciphertext: bytesToHex(ciphertext),
created_at: new Date().toISOString(),
};
if (opts.phrase) {
// Fresh nonce — AES-GCM nonce reuse under the same key is fatal.
const phraseNonce = crypto.getRandomValues(new Uint8Array(12));
const phraseCt = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: asBuffer(phraseNonce) },
key,
asBuffer(new TextEncoder().encode(opts.phrase)),
),
);
record.phrase_nonce = bytesToHex(phraseNonce);
record.phrase_ciphertext = bytesToHex(phraseCt);
}
await set(IDB_KEY, record);
}
@ -144,11 +181,34 @@ export async function unlockIdentity(
`unlocked seed is ${plaintext.length} bytes, expected 32`,
);
}
let phrase: string | undefined;
if (stored.phrase_nonce && stored.phrase_ciphertext) {
try {
const pn = hexToBytes(stored.phrase_nonce);
const pc = hexToBytes(stored.phrase_ciphertext);
const pBytes = new Uint8Array(
await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: asBuffer(pn) },
key,
asBuffer(pc),
),
);
phrase = new TextDecoder().decode(pBytes);
} catch {
// The seed unlocked fine, so the passphrase is right; a phrase
// decrypt failure here would point at IDB corruption. Surface it
// by logging, but don't block unlock — the user can still chat.
console.error("identity-store: phrase decrypt failed (seed OK)");
}
}
return {
handle: stored.handle,
server: stored.server,
primary: stored.primary,
seed: plaintext,
phrase,
};
}

View File

@ -7,7 +7,7 @@
// reconsider depending on the Node port.
import { ed25519 } from "@noble/curves/ed25519";
import { sha512 } from "@noble/hashes/sha2";
import { sha256, sha512 } from "@noble/hashes/sha2";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import canonicalize from "canonicalize";
@ -19,6 +19,8 @@ export const CLAIM_TYPE = "kez.claim";
export const REGISTRATION_TYPE = "kez.chat.handle_registration";
export const REGISTRATION_ENVELOPE = "handle_registration";
export const CLAIM_ENVELOPE = "claim";
export const SIGCHAIN_EVENT_TYPE = "kez.sigchain.event";
export const SIGCHAIN_ENVELOPE = "sigchain_event";
export const ED25519_SHA512_ALG = "ed25519-sha512-jcs";
export const FORMAT_VERSION = 1;
export const COMPACT_PROOF_PREFIX = "kez:z1:";
@ -70,6 +72,33 @@ export interface SignedRegistration {
signature: SignatureBlock;
}
export type SigchainOp = "add" | "revoke";
export interface SigchainEventPayload {
type: typeof SIGCHAIN_EVENT_TYPE;
version: number;
primary: Identity;
seq: number;
/** `sha256:<hex>` of the prior envelope's JCS bytes. Omitted iff seq === 0. */
prev?: string;
created_at: string;
op: SigchainOp;
/** Op-specific fields, e.g. `{ subject, proof_url? }`. */
payload: Record<string, unknown>;
}
export interface SignedSigchainEvent {
kez: typeof SIGCHAIN_ENVELOPE;
payload: SigchainEventPayload;
signature: SignatureBlock;
}
/** Where the next event sits in the chain: its `seq` and `prev` hash. */
export interface ChainCursor {
seq: number;
prev?: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Key generation + restoration
// ─────────────────────────────────────────────────────────────────────────────
@ -137,6 +166,22 @@ function signWith(
};
}
/**
* RFC 3339 UTC timestamp at SECOND precision (no fractional part), e.g.
* `2026-05-19T18:00:00Z` matching the SPEC.md examples.
*
* Why drop the milliseconds `toISOString()` emits: the Rust kez-core
* verifier (used by the chat server and the chain service) deserializes
* `created_at` into a `chrono::DateTime<Utc>` and re-serializes it to
* re-canonicalize for signature checking. Chrono's `AutoSi` seconds format
* drops a zero fractional part, so a browser-signed `…:00.000Z` would
* re-serialize as `…:00Z` and fail verification ~1 in 1000. Emitting whole
* seconds round-trips byte-stably through chrono for every timestamp.
*/
export function rfc3339Utc(date: Date = new Date()): string {
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
}
// ─────────────────────────────────────────────────────────────────────────────
// Verification
// ─────────────────────────────────────────────────────────────────────────────
@ -211,7 +256,7 @@ export function signClaim(
version: FORMAT_VERSION,
subject,
primary: signer.identity,
created_at: createdAt.toISOString(),
created_at: rfc3339Utc(createdAt),
};
return {
kez: CLAIM_ENVELOPE,
@ -233,7 +278,7 @@ export function signRegistration(
handle,
primary: signer.identity,
server,
created_at: createdAt.toISOString(),
created_at: rfc3339Utc(createdAt),
};
return {
kez: REGISTRATION_ENVELOPE,
@ -242,6 +287,59 @@ export function signRegistration(
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Sigchain events (Spec §8)
// ─────────────────────────────────────────────────────────────────────────────
/**
* `sha256:<hex>` of the JCS-canonicalized bytes of the WHOLE signed envelope
* (not just the payload). This is what the next event's `prev` points at.
*/
export function sigchainEventHash(event: SignedSigchainEvent): string {
return `sha256:${bytesToHex(sha256(canonicalBytes(event)))}`;
}
/**
* Given the current (validated, ordered) chain, return where the next event
* goes: `seq` one past the head, and `prev` = the head's envelope hash.
* An empty chain yields `{ seq: 0 }` with no `prev` (per spec, seq 0 has none).
*/
export function nextChainCursor(events: SignedSigchainEvent[]): ChainCursor {
if (events.length === 0) return { seq: 0 };
const head = events[events.length - 1];
return { seq: head.payload.seq + 1, prev: sigchainEventHash(head) };
}
/**
* Build + sign a sigchain event (add/revoke a subject) at the given cursor.
* Insertion order matches the Rust/Node/Python impls (type, version, primary,
* seq, prev?, created_at, op, payload) so the JSONL form lines up; JCS sorts
* keys for signing regardless.
*/
export function signSigchainEvent(
signer: Ed25519Identity,
op: SigchainOp,
opPayload: Record<string, unknown>,
cursor: ChainCursor,
createdAt: Date = new Date(),
): SignedSigchainEvent {
const payload: SigchainEventPayload = {
type: SIGCHAIN_EVENT_TYPE,
version: FORMAT_VERSION,
primary: signer.identity,
seq: cursor.seq,
...(cursor.prev !== undefined ? { prev: cursor.prev } : {}),
created_at: rfc3339Utc(createdAt),
op,
payload: opPayload,
};
return {
kez: SIGCHAIN_ENVELOPE,
payload,
signature: signWith(payload, signer),
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Encodings — pretty JSON, compact (kez:z1:), markdown fence
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -0,0 +1,111 @@
// Browser-native KEZ mnemonic helpers — mirrors:
// • rust/crates/kez-core/src/mnemonic.rs
// • nodejs/packages/kez-core/src/mnemonic.ts
// • python/kez/mnemonic.py
//
// We use 12-word phrases as the user-facing backup form in the chat app
// (shorter to write down than the 64-char hex seed). The seed itself is
// derived deterministically from the phrase, so the phrase IS the
// canonical backup.
//
// Semantics (must match the other implementations byte-for-byte):
// • 12 words → 16 bytes of BIP-39 entropy → seed = SHA-256(DOMAIN_TAG ||
// entropy) where DOMAIN_TAG = "kez-bip39-12-v1" (15 ASCII bytes).
// The derivation is one-way: you can't recover the phrase from the
// seed. That's why we ALSO store the phrase (encrypted at rest) so
// the user can re-display it later via Settings → Reveal phrase.
// • 24 words → entropy IS the 32-byte seed (bijection). Accepted on
// restore for parity with the CLI, but the chat app generates 12-word
// phrases by default.
//
// NB: We deliberately do NOT use BIP-39's PBKDF2 to_seed function. That
// produces a 64-byte BIP-32 wallet seed, which is the wrong primitive
// for KEZ's single-identity-per-phrase model.
import {
entropyToMnemonic,
generateMnemonic as bip39Generate,
mnemonicToEntropy,
} from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english.js";
import { sha256 } from "@noble/hashes/sha2";
import { identityFromSeed, type Ed25519Identity } from "./kez.js";
const DOMAIN_TAG_12 = new TextEncoder().encode("kez-bip39-12-v1");
/** Generate a fresh 12-word phrase. */
export function generateMnemonic12(): string {
// BIP-39 strength: 128 bits → 12 words.
return bip39Generate(wordlist, 128);
}
/**
* Decode a 12- or 24-word phrase into a 32-byte Ed25519 seed. Auto-detects
* length. Whitespace-tolerant (trims, collapses runs of spaces).
*/
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 Error(`invalid recovery phrase: ${(e as Error).message}`);
}
if (entropy.length === 32) {
// 24-word: entropy is the seed.
return new Uint8Array(entropy);
}
if (entropy.length === 16) {
// 12-word: SHA-256 of (DOMAIN_TAG || entropy).
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 Error(
`mnemonic must decode to 16 or 32 bytes of entropy, got ${entropy.length}`,
);
}
/** Inverse for the 24-word case ONLY. Throws on any other length. */
export function mnemonicFromSeed24(seed: Uint8Array): string {
if (seed.length !== 32) {
throw new Error(
`mnemonicFromSeed24: seed must be 32 bytes, got ${seed.length}`,
);
}
return entropyToMnemonic(seed, wordlist);
}
/** Construct a KEZ Ed25519 identity from a phrase. */
export function ed25519FromMnemonic(phrase: string): Ed25519Identity {
return identityFromSeed(seedFromMnemonic(phrase));
}
/**
* Generate a fresh identity AND the phrase that derives it. The phrase
* is the canonical user-facing backup; the identity carries the seed
* for crypto ops.
*/
export function generateIdentityWithMnemonic(): {
identity: Ed25519Identity;
phrase: string;
} {
const phrase = generateMnemonic12();
const identity = ed25519FromMnemonic(phrase);
return { identity, phrase };
}
/**
* Cheap input check (does the typed text look like a valid phrase?) so
* the restore form can give live feedback. Returns true only if the
* phrase parses + checksum-validates.
*/
export function isValidMnemonic(phrase: string): boolean {
try {
seedFromMnemonic(phrase);
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,117 @@
// Mirrors the user's claims into their sigchain on the chain service.
//
// The chain service (the rust-sig-server) is where a user's append-only,
// signed sigchain lives. When the user adds a claim we append an `add`
// event for that subject; when they remove a claim we append a `revoke`.
// This keeps the sigchain an accurate, verifiable record of what the user
// currently claims — the spec's mechanism (SPEC.md §8), not a per-claim
// field.
//
// We read the current chain to compute the next `seq` + `prev` hash so
// appends stay monotonic even across devices, then sign locally (the
// chain service never sees the seed) and POST the event.
import {
identityFromSeed,
nextChainCursor,
sigchainEventHash,
signSigchainEvent,
type Identity,
type SignedSigchainEvent,
type SigchainOp,
} from "./kez.js";
import { getSigchain, lookup, postSigchainEvent } from "./api.js";
export interface SigchainSyncResult {
/** The chain-service base URL the event was posted to. */
chainService: string;
/** Sequence number of the event (or the existing add, when noop). */
seq: number;
/** `sha256:<hex>` head hash of the chain. */
hash: string;
/**
* True when the chain already reflected the desired state, so no new
* event was posted (e.g. re-syncing a claim already on the chain, or
* removing a claim that was never added). Makes sync idempotent safe
* to run repeatedly and safe for claims created before sigchain support.
*/
noop: boolean;
}
/**
* Walk the chain and return the currently-active subjects (added and not
* later revoked), mapped to the `seq` of the `add` that activated each.
* This is how we keep appends idempotent.
*/
function activeSubjects(events: SignedSigchainEvent[]): Map<string, number> {
const active = new Map<string, number>();
for (const event of events) {
const subject = (event.payload.payload as { subject?: unknown })?.subject;
if (typeof subject !== "string") continue;
if (event.payload.op === "add") active.set(subject, event.payload.seq);
else if (event.payload.op === "revoke") active.delete(subject);
}
return active;
}
/**
* Resolve the user's chain-service URL (their sigchain base URL) from their
* handle. The chat server constructs this from the configured sig-server and
* the user's primary key.
*/
export async function resolveChainService(handle: string): Promise<string> {
const record = await lookup(handle);
if (!record.sigchain_url) {
throw new Error("server did not return a sigchain URL for this handle");
}
return record.sigchain_url;
}
/**
* Append an add/revoke event for `subject` to the user's sigchain on the
* chain service. Reads the current chain to compute the next cursor, signs
* the event with the user's seed, and POSTs it.
*/
export async function appendSubjectEvent(opts: {
seed: Uint8Array;
chainService: string;
subject: Identity;
op: SigchainOp;
/** Optional URL where the channel proof is published (spec: add.proof_url). */
proofUrl?: string;
}): Promise<SigchainSyncResult> {
const signer = identityFromSeed(opts.seed);
const events = await getSigchain(opts.chainService);
const active = activeSubjects(events);
const head = events.length > 0 ? events[events.length - 1] : null;
const headHash = head ? sigchainEventHash(head) : "";
// Idempotency: don't duplicate state the chain already has. Re-adding an
// already-active subject, or revoking one that isn't active, is a no-op.
if (opts.op === "add" && active.has(opts.subject)) {
return {
chainService: opts.chainService,
seq: active.get(opts.subject)!,
hash: headHash,
noop: true,
};
}
if (opts.op === "revoke" && !active.has(opts.subject)) {
return {
chainService: opts.chainService,
seq: head ? head.payload.seq : -1,
hash: headHash,
noop: true,
};
}
const opPayload: Record<string, unknown> = { subject: opts.subject };
if (opts.op === "add" && opts.proofUrl) {
opPayload.proof_url = opts.proofUrl;
}
const cursor = nextChainCursor(events);
const event = signSigchainEvent(signer, opts.op, opPayload, cursor);
const { seq, hash } = await postSigchainEvent(opts.chainService, event);
return { chainService: opts.chainService, seq, hash, noop: false };
}

View File

@ -9,7 +9,11 @@
toCompact,
type SignedClaimEnvelope,
} from "../lib/kez.js";
import { addClaim } from "../lib/claims-store.js";
import { addClaim, setSigchainSync } from "../lib/claims-store.js";
import {
appendSubjectEvent,
resolveChainService,
} from "../lib/sigchain-service.js";
import { session } from "../lib/store.svelte.js";
import {
hasNip07,
@ -154,6 +158,14 @@
| { status: "error"; message: string }
>({ status: "idle" });
/** Outcome of mirroring this claim into the user's sigchain on the chain service. */
let sigchainSync = $state<
| { status: "idle" }
| { status: "pending" }
| { status: "ok"; seq: number; noop: boolean }
| { status: "error"; message: string }
>({ status: "idle" });
/** Re-evaluated each render; cheap (just a typeof check on window.nostr). */
const nip07Available = $derived(hasNip07());
@ -226,13 +238,15 @@
}
async function saveAndDone() {
if (!envelope || !selected) return;
if (!envelope || !selected || !session.unlocked) return;
const id = crypto.randomUUID();
const subject = envelope.payload.subject;
try {
// $state wraps `envelope` in a deep Proxy; structuredClone (used
// by idb-keyval) can't clone proxies and throws DataCloneError.
// $state.snapshot returns a plain, cloneable object.
await addClaim({
id: crypto.randomUUID(),
id,
envelope: $state.snapshot(envelope) as SignedClaimEnvelope,
channel: selected.key,
});
@ -240,6 +254,36 @@
} catch (e) {
console.error("saveAndDone failed", e);
alert(`Failed to save claim: ${(e as Error).message}`);
return;
}
// Mirror the claim into the user's sigchain on the chain service: append
// a signed `add` event for this subject. Best-effort — the claim is
// already saved locally; if the chain service is unreachable we record
// the error and the user can retry from the Claims page later.
sigchainSync = { status: "pending" };
try {
const chainService = await resolveChainService(session.unlocked.handle);
const proofUrl =
nostrPublish.status === "ok" ? nostrPublish.result.evidence_url : undefined;
const result = await appendSubjectEvent({
seed: session.unlocked.seed,
chainService,
subject,
op: "add",
proofUrl,
});
await setSigchainSync(id, {
chain_service: result.chainService,
sigchain_seq: result.seq,
sigchain_status: "synced",
});
sigchainSync = { status: "ok", seq: result.seq, noop: result.noop };
} catch (e) {
await setSigchainSync(id, {
sigchain_status: "error",
sigchain_error: (e as Error).message,
});
sigchainSync = { status: "error", message: (e as Error).message };
}
}
</script>
@ -452,6 +496,28 @@
Once you've published the proof on that channel, come back to the
Claims page and mark it published.
</p>
<!-- Chain-service (sigchain) mirror status -->
<div class="mt-3 text-sm">
{#if sigchainSync.status === "pending"}
<p class="text-text-secondary">⏳ Updating your sigchain on the chain service…</p>
{:else if sigchainSync.status === "ok"}
<p class="text-verified">
{#if sigchainSync.noop}
⛓ Already on your sigchain (seq {sigchainSync.seq}) — nothing to add.
{:else}
⛓ Sigchain updated — added at seq {sigchainSync.seq} on the chain service.
{/if}
</p>
{:else if sigchainSync.status === "error"}
<p class="text-warning">
⚠ Couldn't update your sigchain on the chain service:
{sigchainSync.message}. The claim is saved locally — retry the
sigchain sync from the Claims page.
</p>
{/if}
</div>
<div class="mt-4 flex gap-2">
<a
href="#/claims"
@ -466,6 +532,8 @@
selected = null;
identifierInput = "";
envelope = null;
nostrPublish = { status: "idle" };
sigchainSync = { status: "idle" };
}}
>
Add another

View File

@ -6,8 +6,13 @@
markPublished,
removeClaim,
setVerifyResult,
setSigchainSync,
type StoredClaim,
} from "../lib/claims-store.js";
import {
appendSubjectEvent,
resolveChainService,
} from "../lib/sigchain-service.js";
import { verifyClaim } from "../lib/verify.js";
import { session } from "../lib/store.svelte.js";
@ -15,6 +20,8 @@
let loading = $state(true);
/** ids currently mid-verify, so we can disable the button + show a spinner. */
let verifying = $state<Set<string>>(new Set());
/** ids currently mid chain-service sync (add retry or revoke-on-delete). */
let syncing = $state<Set<string>>(new Set());
/** Which claims have their details panel expanded. */
let expanded = $state<Set<string>>(new Set());
@ -33,11 +40,74 @@
}
async function deleteClaim(c: StoredClaim) {
if (!confirm(`Remove the local copy of claim for ${c.envelope.payload.subject}?`)) return;
if (!confirm(`Remove the claim for ${c.envelope.payload.subject}?`)) return;
// Revoke on the chain service first so the user's sigchain reflects the
// removal (SPEC.md §8: a revoke event withdraws a previously-added
// subject). Best-effort — if the chain service is unreachable, ask
// before dropping the local copy so the sigchain doesn't silently drift.
if (session.unlocked) {
syncing = new Set(syncing).add(c.id);
try {
const chainService =
c.chain_service ?? (await resolveChainService(session.unlocked.handle));
await appendSubjectEvent({
seed: session.unlocked.seed,
chainService,
subject: c.envelope.payload.subject,
op: "revoke",
});
} catch (e) {
const proceed = confirm(
`Couldn't post a revoke to the chain service (${(e as Error).message}). ` +
`Remove the local copy anyway? Your sigchain will still list this subject.`,
);
if (!proceed) {
const next = new Set(syncing);
next.delete(c.id);
syncing = next;
return;
}
} finally {
const next = new Set(syncing);
next.delete(c.id);
syncing = next;
}
}
await removeClaim(c.id);
claims = await listClaims();
}
/** (Re)mirror a claim into the sigchain on the chain service as an `add`. */
async function syncToChain(c: StoredClaim) {
if (!session.unlocked) return;
syncing = new Set(syncing).add(c.id);
try {
const chainService = await resolveChainService(session.unlocked.handle);
const result = await appendSubjectEvent({
seed: session.unlocked.seed,
chainService,
subject: c.envelope.payload.subject,
op: "add",
});
await setSigchainSync(c.id, {
chain_service: result.chainService,
sigchain_seq: result.seq,
sigchain_status: "synced",
sigchain_error: undefined,
});
} catch (e) {
await setSigchainSync(c.id, {
sigchain_status: "error",
sigchain_error: (e as Error).message,
});
} finally {
const next = new Set(syncing);
next.delete(c.id);
syncing = next;
claims = await listClaims();
}
}
async function runVerify(c: StoredClaim) {
verifying = new Set(verifying).add(c.id);
try {
@ -149,6 +219,19 @@
Channel: <span class="font-mono">{c.channel}</span> ·
Signed: <span class="font-mono">{c.envelope.payload.created_at}</span>
</p>
{#if syncing.has(c.id)}
<p class="mt-1 text-xs text-text-secondary">⏳ Syncing with chain service…</p>
{:else if c.sigchain_status === "synced"}
<p class="mt-1 text-xs text-verified">
⛓ On your sigchain{#if c.sigchain_seq !== undefined} · seq {c.sigchain_seq}{/if}
</p>
{:else if c.sigchain_status === "error"}
<p class="mt-1 text-xs text-warning">
⚠ Not on your sigchain{#if c.sigchain_error} ({c.sigchain_error}){/if}
</p>
{:else}
<p class="mt-1 text-xs text-text-muted">⛓ Not yet on your sigchain</p>
{/if}
{#if c.last_verify}
<p class="mt-1 text-xs text-text-secondary">
{c.last_verify.summary}
@ -208,9 +291,20 @@
Mark published
</button>
{/if}
{#if c.sigchain_status !== "synced"}
<button
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-elevated disabled:opacity-50"
onclick={() => syncToChain(c)}
disabled={syncing.has(c.id)}
title="Append an `add` event for this subject to your sigchain on the chain service."
>
{syncing.has(c.id) ? "Syncing…" : "Sync to chain"}
</button>
{/if}
<button
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-danger/10 hover:border-danger"
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-danger/10 hover:border-danger disabled:opacity-50"
onclick={() => deleteClaim(c)}
disabled={syncing.has(c.id)}
>
Remove
</button>

View File

@ -1,17 +1,16 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils";
import {
generateIdentity,
signRegistration,
type Ed25519Identity,
} from "../lib/kez.js";
import { generateIdentityWithMnemonic } from "../lib/mnemonic.js";
import { register, healthz, ApiError } from "../lib/api.js";
import { saveIdentity } from "../lib/identity-store.js";
import { session } from "../lib/store.svelte.js";
let step = $state<"handle" | "seed" | "confirm" | "submitting" | "done">("handle");
let step = $state<"handle" | "phrase" | "confirm" | "submitting" | "done">("handle");
let handle = $state("");
let passphrase = $state("");
@ -19,8 +18,8 @@
let serverInfo = $state<{ server: string; version: string } | null>(null);
let id = $state<Ed25519Identity | null>(null);
let seedHex = $state("");
let seedAck = $state(false);
let phrase = $state("");
let phraseAck = $state(false);
let error = $state<string | null>(null);
let working = $state(false);
@ -48,9 +47,10 @@
if (v) { error = v; return; }
if (passphrase.length < 8) { error = "Passphrase must be at least 8 characters."; return; }
if (passphrase !== passphrase2) { error = "Passphrases don't match."; return; }
id = generateIdentity();
seedHex = bytesToHex(id.seed);
step = "seed";
const gen = generateIdentityWithMnemonic();
id = gen.identity;
phrase = gen.phrase;
step = "phrase";
}
async function submitRegistration() {
@ -67,6 +67,7 @@
primary: id.identity,
seed: id.seed,
passphrase,
phrase,
});
session.setUnlocked({
handle: resp.handle,
@ -105,7 +106,7 @@
<ol class="flex gap-2 text-xs text-text-muted">
<li class={step === "handle" ? "font-semibold text-text" : ""}>1. Handle</li>
<li></li>
<li class={step === "seed" ? "font-semibold text-text" : ""}>2. Back up seed</li>
<li class={step === "phrase" ? "font-semibold text-text" : ""}>2. Back up phrase</li>
<li></li>
<li class={step === "confirm" || step === "submitting" ? "font-semibold text-text" : ""}>3. Confirm</li>
<li></li>
@ -181,32 +182,38 @@
</form>
{/if}
{#if step === "seed" && id}
{#if step === "phrase" && id}
<div class="space-y-4">
<div class="border border-warning/40 bg-warning/10 rounded-lg p-4 space-y-3">
<p class="font-semibold text-warning">⚠️ Back up your seed now</p>
<p class="font-semibold text-warning">⚠️ Back up your recovery phrase</p>
<p class="text-sm text-warning">
This is the only way to recover your account on another device
(or after clearing this browser). The server doesn't have it.
Write it down or paste into a password manager.
This 12-word phrase is the only way to recover your account on
another device (or after clearing this browser). The server
doesn't have it. Write it down on paper — don't just rely on
this browser.
</p>
<div class="mt-3 p-3 bg-surface border border-warning/40 rounded font-mono text-sm break-all select-all">
{seedHex}
</div>
<div class="flex gap-2">
<ol class="mt-3 p-3 bg-surface border border-warning/40 rounded grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2 font-mono text-sm">
{#each phrase.split(" ") as word, i}
<li class="flex items-baseline gap-2">
<span class="text-text-muted text-xs w-5 text-right">{i + 1}.</span>
<span class="text-text select-all">{word}</span>
</li>
{/each}
</ol>
<div class="flex gap-2 flex-wrap">
<button
class="text-xs px-3 py-1 bg-warning/10 text-accent-contrast rounded hover:bg-warning/20"
onclick={() => copyToClipboard(seedHex)}
class="text-xs px-3 py-1 border border-warning/40 text-warning rounded hover:bg-warning/20"
onclick={() => copyToClipboard(phrase)}
>
Copy seed
Copy all 12 words
</button>
</div>
</div>
<label class="flex items-start gap-2 text-sm text-text-secondary">
<input type="checkbox" bind:checked={seedAck} class="mt-1" />
I've saved this seed somewhere safe. I understand losing it means
losing my account permanently.
<input type="checkbox" bind:checked={phraseAck} class="mt-1" />
I've written down these 12 words in order. I understand losing them
means losing my account permanently.
</label>
<div class="flex gap-2">
@ -218,7 +225,7 @@
</button>
<button
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={!seedAck}
disabled={!phraseAck}
onclick={() => { step = "confirm"; }}
>
I've saved it — continue
@ -238,7 +245,7 @@
<div class="flex gap-2">
<button
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
onclick={() => { step = "seed"; }}
onclick={() => { step = "phrase"; }}
>
Back
</button>

View File

@ -1,13 +1,18 @@
<script lang="ts">
import { push } from "svelte-spa-router";
import { hexToBytes } from "@noble/hashes/utils";
import { identityFromSeed } from "../lib/kez.js";
import { lookup, healthz, ApiError } from "../lib/api.js";
import { identityFromSeed, signRegistration } from "../lib/kez.js";
import {
seedFromMnemonic,
isValidMnemonic,
} from "../lib/mnemonic.js";
import { healthz, lookupByPrimary, ApiError } from "../lib/api.js";
import { saveIdentity } from "../lib/identity-store.js";
import { session } from "../lib/store.svelte.js";
import { onMount } from "svelte";
let seedHex = $state("");
/** User pastes either a 12-word phrase OR a 64-char hex seed. */
let secretInput = $state("");
let passphrase = $state("");
let passphrase2 = $state("");
let serverDomain = $state<string | null>(null);
@ -23,38 +28,78 @@
}
});
/** Auto-detect: hex-seed if 64 hex chars, otherwise treat as mnemonic. */
function parseSecret(raw: string): { seed: Uint8Array; phrase?: string } {
const trimmed = raw.trim();
const hexShape = trimmed.replace(/\s+/g, "").toLowerCase();
if (/^[0-9a-f]{64}$/.test(hexShape)) {
return { seed: hexToBytes(hexShape) };
}
if (isValidMnemonic(trimmed)) {
return { seed: seedFromMnemonic(trimmed), phrase: trimmed.replace(/\s+/g, " ") };
}
throw new Error(
"Couldn't read that as either a 12/24-word recovery phrase or a 64-character hex seed.",
);
}
async function submit() {
error = null;
working = true;
try {
const cleaned = seedHex.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(cleaned)) {
throw new Error("Seed must be 64 lowercase hex characters (32 bytes).");
}
if (passphrase.length < 8) {
throw new Error("Passphrase must be at least 8 characters.");
}
if (passphrase !== passphrase2) {
throw new Error("Passphrases don't match.");
}
const seed = hexToBytes(cleaned);
const id = identityFromSeed(seed);
if (!serverDomain) {
throw new Error("Server unreachable; refresh and try again.");
}
// Look up the primary on the server to find the associated handle.
// We try a couple of common handles? No — the registry is keyed by
// handle, not primary. So we ask the user to type their handle.
throw new Error(
"Sorry — to restore, please use a device that has your handle saved. " +
"(v0.2 will let you look up your handle by primary key.)",
);
// TODO when chat-server has GET /v1/by-primary/<id>: implement this.
// For now restoring a seed-only is incomplete because we don't know
// the handle. Workaround: regenerate identity via /create with same
// handle (server will reject as taken; not useful) OR ask the user.
const { seed, phrase } = parseSecret(secretInput);
const id = identityFromSeed(seed);
// Look up the handle this primary is registered to.
let record;
try {
record = await lookupByPrimary(id.identity);
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
throw new Error(
"No account registered with this recovery phrase on " +
`${serverDomain}. Did you mean to create a new one?`,
);
}
throw e;
}
// Re-sign a registration so it's on file fresh (idempotent on the
// server — same primary always matches the same handle).
// (We don't strictly need to re-register; storing locally is enough.)
void signRegistration;
await saveIdentity({
handle: record.handle,
server: serverDomain,
primary: id.identity,
seed: id.seed,
passphrase,
phrase,
});
session.setUnlocked({
handle: record.handle,
server: serverDomain,
primary: id.identity,
seed: id.seed,
phrase,
});
push("/welcome");
} catch (e) {
error = e instanceof ApiError ? `${e.code ?? "error"}: ${e.message}` : (e as Error).message;
error =
e instanceof ApiError ? `${e.code ?? "error"}: ${e.message}` : (e as Error).message;
} finally {
working = false;
}
@ -62,13 +107,13 @@
</script>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-text">Restore from seed</h1>
<h1 class="text-2xl font-bold text-text">Restore account</h1>
<p class="text-sm text-text-secondary bg-warning/10 border border-warning/40 rounded p-3">
<strong>v0.1 limitation:</strong> the seed alone doesn't tell us which
handle to restore. For now this flow doesn't work end-to-end — we'll
add <code>GET /v1/by-primary/&lt;id&gt;</code> on the server in v0.2
so the SPA can look up the handle from the public key.
<p class="text-sm text-text-secondary">
Paste your 12-word recovery phrase. (If you wrote down a 64-character
hex seed from an older version of kez-chat, that works too.) We'll
look up your handle on <span class="font-mono">{serverDomain ?? "the server"}</span>
and unlock the account on this device.
</p>
<form
@ -76,14 +121,17 @@
onsubmit={(e) => { e.preventDefault(); submit(); }}
>
<div>
<label class="block text-sm font-medium text-text-secondary" for="seed">
Seed (64 hex characters)
<label class="block text-sm font-medium text-text-secondary" for="secret">
Recovery phrase or hex seed
</label>
<textarea
id="seed"
bind:value={seedHex}
id="secret"
bind:value={secretInput}
rows="3"
placeholder="abandon ability able about above absent academy accident account accuse achieve acid"
class="mt-1 w-full px-3 py-2 border border-border rounded-md font-mono text-sm"
autocomplete="off"
spellcheck="false"
></textarea>
</div>

View File

@ -3,6 +3,7 @@
import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils";
import { session } from "../lib/store.svelte.js";
import { hasStoredPhrase } from "../lib/identity-store.js";
import {
hasStoredBiometric,
getStoredBiometricMeta,
@ -92,10 +93,36 @@
setTimeout(() => (testNotifResult = null), 5_000);
}
function showSeed() {
async function showSeed() {
if (!session.unlocked) return;
const phrase = session.unlocked.phrase;
if (phrase) {
alert(
`Your 12-word recovery phrase (KEEP SECRET):\n\n${phrase}\n\n` +
`Write these 12 words down in order — they're the ONLY way to ` +
`recover this account on another device.`,
);
return;
}
// Phrase not in this session — distinguish two cases:
// 1. Account HAS a stored phrase but this session unlocked via
// biometric (PRF key doesn't decrypt the passphrase-keyed blob).
// 2. Genuinely pre-mnemonic legacy account — show hex.
if (await hasStoredPhrase()) {
alert(
`Your recovery phrase isn't available in this session.\n\n` +
`Biometric unlock doesn't decrypt the phrase. Lock and unlock ` +
`again with your passphrase to reveal it.`,
);
return;
}
const hex = bytesToHex(session.unlocked.seed);
alert(`Your recovery seed (KEEP SECRET):\n\n${hex}\n\nWrite this down somewhere safe. It's the ONLY way to recover this account.`);
alert(
`Your recovery seed — hex form (KEEP SECRET):\n\n${hex}\n\n` +
`This account was created before 12-word phrases were supported. ` +
`The 64-character hex above is still your full recovery — write ` +
`it down somewhere safe.`,
);
}
function lock() {
@ -166,12 +193,13 @@
<!-- Recovery phrase -->
<div>
<p class="text-sm font-medium text-text">Recovery seed</p>
<p class="text-sm font-medium text-text">Recovery phrase</p>
<p class="text-sm text-text-secondary mt-0.5">
The only thing that can recover this account. Write it down offline.
12 words that recover this account anywhere. Write them down on
paper — losing them means losing the account.
</p>
<button class="mt-2 px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={showSeed}>
Reveal seed
Reveal phrase
</button>
</div>
</section>

View File

@ -23,9 +23,10 @@
let biometricAvailable = $state(false);
let notifPerm = $state<NotificationPermission | "unsupported">("default");
let seedRevealed = $state(false);
let seedHex = $state("");
let seedCopied = $state(false);
let backupRevealed = $state(false);
let backupText = $state(""); // 12-word phrase if available, else hex seed
let backupKind = $state<"phrase" | "seed">("phrase");
let backupCopied = $state(false);
let busy = $state(false);
onMount(async () => {
@ -42,13 +43,20 @@
function revealSeed() {
if (!session.unlocked) return;
seedHex = bytesToHex(session.unlocked.seed);
seedRevealed = true;
if (session.unlocked.phrase) {
backupText = session.unlocked.phrase;
backupKind = "phrase";
} else {
// Legacy account (pre-mnemonic) — fall back to the hex seed.
backupText = bytesToHex(session.unlocked.seed);
backupKind = "seed";
}
backupRevealed = true;
}
async function copySeed() {
await navigator.clipboard.writeText(seedHex);
seedCopied = true;
setTimeout(() => (seedCopied = false), 1500);
await navigator.clipboard.writeText(backupText);
backupCopied = true;
setTimeout(() => (backupCopied = false), 1500);
}
async function enableBiometric() {
@ -109,25 +117,40 @@
</div>
</li>
<!-- 2. Back up seed (critical, skippable) -->
<!-- 2. Back up phrase (critical, skippable) -->
<li class={`bg-surface border rounded-xl p-4 ${onboarding.seedAcked ? "border-border" : "border-warning/50"}`}>
<div class="flex items-start gap-3">
<span class="shrink-0 mt-0.5">{onboarding.seedAcked ? "✓" : "🔑"}</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text">Back up your recovery seed</p>
<p class="text-sm font-semibold text-text">Back up your recovery phrase</p>
<p class="text-xs text-text-secondary">
This 32-byte seed is the <strong>only</strong> way to recover your
account. Lose it and it's gone forever — there's no reset. Write it
down offline.
These 12 words are the <strong>only</strong> way to recover
your account. Lose them and it's gone forever — there's no
reset. Write them on paper.
</p>
{#if !seedRevealed && !onboarding.seedAcked}
<button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={revealSeed}>Reveal seed</button>
{#if !backupRevealed && !onboarding.seedAcked}
<button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={revealSeed}>Reveal phrase</button>
{/if}
{#if seedRevealed}
{#if backupRevealed}
<div class="mt-2 p-3 bg-elevated border border-border rounded-md">
<p class="font-mono text-xs text-text break-all select-all">{seedHex}</p>
{#if backupKind === "phrase"}
<ol class="grid grid-cols-2 sm:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-xs">
{#each backupText.split(" ") as word, i}
<li class="flex items-baseline gap-1.5">
<span class="text-text-muted w-4 text-right">{i + 1}.</span>
<span class="text-text select-all">{word}</span>
</li>
{/each}
</ol>
{:else}
<p class="font-mono text-xs text-text break-all select-all">{backupText}</p>
<p class="mt-1 text-[10px] text-text-muted">
Legacy 64-char hex — accounts created from now on get a
12-word phrase instead.
</p>
{/if}
<div class="mt-2 flex gap-2">
<button class="px-2.5 py-1 text-xs border border-border rounded text-text-secondary hover:bg-surface" onclick={copySeed}>{seedCopied ? "✓ copied" : "Copy"}</button>
<button class="px-2.5 py-1 text-xs border border-border rounded text-text-secondary hover:bg-surface" onclick={copySeed}>{backupCopied ? "✓ copied" : "Copy"}</button>
{#if !onboarding.seedAcked}
<button class="px-2.5 py-1 text-xs bg-accent text-accent-contrast font-semibold rounded" onclick={() => onboarding.ackSeed()}>I've saved it safely</button>
{/if}

View File

@ -97,24 +97,124 @@ A new nostr keypair:
npm run cli -- identity new
```
Or a new Ed25519 keypair:
Or a new Ed25519 keypair, which comes with a BIP-39 phrase alongside
the hex seed (both are equivalent backups):
```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…
Public: 7a3b4c… (hex)
Public: 7a3b4c…
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
> identity. There's no recovery flow — lose it and the identity is
> gone. Write it down offline, or paste it into a password manager.
> From here on this tutorial assumes you stored it.
> **Save the backup.** Seed *or* phrase — at least one. Lose them both
> and the identity is gone. There's no recovery flow.
### Recovery phrases — what's actually going on
A KEZ recovery phrase is a [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)
mnemonic — the same 2048-word English wordlist that Bitcoin, Ethereum,
and most hardware wallets use. The words encode random bits:
| Phrase length | Random bits | Resulting Ed25519 seed |
|---|---|---|
| **24 words** | 256 bits of entropy | The 32-byte seed *is* those 256 bits (1:1). Phrase ↔ seed round-trips. |
| **12 words** | 128 bits of entropy | 16 bytes → 32-byte seed via `SHA-256("kez-bip39-12-v1" \|\| entropy)`. Phrase → seed only (one-way). |
#### Picking 12 vs 24
- **Pick 24 words** when you want full round-trip-ability — i.e. you'd
like to be able to *recover the phrase from the hex seed* at any time
in the future. Anyone's 32-byte Ed25519 secret can be re-encoded into
the unique 24-word phrase that produced it. Bigger security margin
(256 bits of entropy vs 128).
- **Pick 12 words** when you want a shorter thing to write down on
paper or remember. 128 bits of entropy is still enormously beyond
brute-forcing. The trade-off: the path is *one-way only* — you can
always derive the seed from the phrase, but you cannot derive the
phrase from the seed. So if you only ever have the seed, you'll
never know what 12-word phrase produced it. **Save the phrase
itself**, not just the resulting seed.
Either way the resulting Ed25519 identity is exactly the same shape;
peers can't tell which word count you used. The choice is purely about
your backup ergonomics.
#### ⚠ Not compatible with hardware-wallet derivations
A KEZ 12-word phrase **does not** produce the same Bitcoin or Ethereum
key as the same 12 words typed into a Ledger or MetaMask, and vice
versa. The reasons are deliberate:
1. Other wallets feed the phrase through BIP-39's PBKDF2 to get a
64-byte "seed", then run that through BIP-32 hierarchical
derivation at a coin-specific path. KEZ doesn't — it takes the
raw entropy and uses it directly (24-word case) or hashes it with
a domain tag (12-word case).
2. KEZ identities aren't part of a derivation tree. There's one
identity per phrase; there's no path component.
That means: **don't paste your existing hardware-wallet recovery
phrase into KEZ** expecting to get a key you've already seen. It'll
produce a *new* KEZ identity uncorrelated with anything else.
Conversely: a KEZ phrase you saved is *only* useful for KEZ. A
malicious wallet that says "import this phrase" can't extract your
existing Bitcoin / Ethereum funds from a KEZ phrase, because the
phrase wasn't derived through the same path.
#### Backing up — concrete advice
The phrase is the master key to your identity. Practical guidance:
- **Write it on paper, with a pencil. Number each word (112 or 124)
so you can later verify the order.** A photograph or cloud document
is one breach away from compromise.
- **Store the paper somewhere fireproof.** Safe-deposit boxes, lockable
desk drawers, etched-stainless-steel cards if you're paranoid.
- **Never type the phrase into a website, chat app, or password
manager that auto-syncs.** Local-only password managers (KeePassXC,
1Password locked vault) are OK; cloud-synced managers are a softer
target.
- **Don't split it across two locations "for safety".** Half a BIP-39
phrase weakens the entropy more than it protects against loss. If you
need redundancy, make two complete paper copies in different physical
locations.
- **Don't be cute.** Don't permute the words "because they're easy to
remember in this order." The wordlist position matters; reorder and
you change the key (and the BIP-39 checksum will reject it on
restore anyway).
### Working with phrases later
You can generate a fresh phrase without producing a key, or recover
the key from a phrase you wrote down earlier:
```sh
# Print a fresh 24-word phrase (or 12, with --words 12). No key derived.
npm run cli -- identity mnemonic
npm run cli -- identity mnemonic --words 12
# Recover the Ed25519 key from a phrase. Word count auto-detected.
npm run cli -- identity from-mnemonic "abandon ability able about above absent
academy accident account accuse achieve acid acoustic acquire across act
action actor actress actual adapt add addict address"
```
The recovered output is identical, byte-for-byte, to what was printed
when you first ran `identity new` — same `Primary:`, same `Public:`,
same `Secret:`.
Throughout the rest of this tutorial you can substitute
`--mnemonic "your phrase here"` anywhere `--ed25519-seed <hex>` appears.
Both are accepted on every command that takes a signing key.
For the rest of this tutorial we'll use a nostr key for examples and
write the secret as `nsec1FAKE...` — substitute your real one.

262
nodejs/package-lock.json generated
View File

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

View File

@ -25,7 +25,10 @@ import {
type Signer,
type VerificationStatus,
dnsTxtName,
ed25519FromMnemonic,
eventHash,
generateEd25519WithMnemonic,
generateMnemonic,
newClaimPayload,
signClaim,
toCompact,
@ -47,7 +50,9 @@ function usageAndExit(msg?: string): never {
"Usage: kez <command> ...",
"",
"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>)",
" [--format json|markdown|compact] [--out <path>]",
" claim dns <domain> (--nsec <nsec> | --ed25519-seed <hex>)",
@ -69,6 +74,12 @@ function usageAndExit(msg?: string): never {
interface Flags {
nsec?: 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";
format?: "json" | "markdown" | "compact" | "jsonl";
out?: string;
@ -89,6 +100,12 @@ function parseFlags(args: string[]): Flags {
out.nsec = args[++i];
} else if (a === "--ed25519-seed") {
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") {
const v = args[++i];
if (v !== "nostr" && v !== "ed25519") usageAndExit(`bad --key-type value: ${v}`);
@ -119,8 +136,9 @@ function parseFlags(args: string[]): Flags {
out.positional.push(a);
}
}
if (out.nsec && out.ed25519Seed) {
usageAndExit("--nsec and --ed25519-seed are mutually exclusive");
const keySources = [out.nsec, out.ed25519Seed, out.mnemonic].filter(Boolean).length;
if (keySources > 1) {
usageAndExit("--nsec, --ed25519-seed, and --mnemonic are mutually exclusive");
}
return out;
}
@ -143,7 +161,8 @@ function printStatus(status: VerificationStatus): void {
function loadSigner(args: Flags): Signer {
if (args.nsec) return NostrSecret.fromNsec(args.nsec);
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) {
@ -155,27 +174,67 @@ function buildClaim(subjectStr: string, signer: 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 {
const keyType = args.keyType ?? "nostr";
if (keyType === "ed25519") {
const s = Ed25519Secret.generate();
process.stdout.write(`Primary: ${s.identity()}\n`);
process.stdout.write(`Public: ${s.pubkeyHex()}\n`);
process.stdout.write(`Secret: ${s.seedHex()} (32-byte seed)\n`);
if (keyType === "nostr") {
if (args.mnemonicWords !== undefined) {
usageAndExit("--mnemonic-words is only valid with --key-type ed25519");
}
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(
"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;
}
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`);
// ed25519: default 24 words (bijective with the seed), or 12 if asked.
const words = parseWordCount(args.mnemonicWords, 24);
const { secret, phrase } = generateEd25519WithMnemonic(words);
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(
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity.\n",
);
if (words === 24) {
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 {
@ -242,6 +301,8 @@ async function main(): Promise<void> {
const flags = parseFlags(rest);
try {
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 === "dns") return claimDns(flags);
if (cmd === "verify" && sub === "file") return verifyFile(flags);

View File

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

View File

@ -58,3 +58,11 @@ export {
parseDnsTxtValue,
} from "./encodings.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)),
);
});
});

View File

@ -0,0 +1,63 @@
# KEZ Mnemonic — canonical test vectors
These vectors are ground truth that **all three implementations
(Rust, Node, Python) MUST match byte-for-byte**. Generated from
the Rust and Node implementations, which have already been verified
to agree (see `mnemonics` branch commit `0058d9b`).
## Semantics
- **24-word phrase** → entropy IS the 32-byte Ed25519 seed (bijection).
- **12-word phrase** → 16-byte entropy → 32-byte seed via
`SHA-256("kez-bip39-12-v1" || entropy)`.
Domain tag bytes: `0x6b, 0x65, 0x7a, 0x2d, 0x62, 0x69, 0x70, 0x33, 0x39, 0x2d, 0x31, 0x32, 0x2d, 0x76, 0x31` (15 bytes, UTF-8 of "kez-bip39-12-v1").
Wordlist: BIP-39 English (the canonical 2048-word list).
## Vectors
### V1 — 24-word, all-zero entropy
```
phrase: abandon abandon abandon abandon abandon abandon abandon abandon
abandon abandon abandon abandon abandon abandon abandon abandon
abandon abandon abandon abandon abandon abandon abandon art
seed: 0000000000000000000000000000000000000000000000000000000000000000
pubkey: 3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
```
### V2 — 12-word, all-zero entropy
```
phrase: abandon abandon abandon abandon abandon abandon abandon abandon
abandon abandon abandon about
seed: 09451c0f06588db78205e32a793536e15ae263c8f9ee6d14f5c6fd82b8bd20da
pubkey: 9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70
```
### V3 — 12-word, non-trivial entropy
```
phrase: legal winner thank year wave sausage worth useful legal winner
thank yellow
seed: 9df434a2bd5dc767ee949d8ab95ca09c4ebbb88cefc3d0b1523f6b2a744ca824
pubkey: cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03
```
## What "pubkey" means here
`pubkey` is the 32-byte Ed25519 public key (hex) derived from the seed
above via the standard Ed25519 keypair derivation (the same as
`ed25519-dalek` / `@noble/curves/ed25519`). The KEZ identity string is
`ed25519:<pubkey>`.
## Implementation crib
Both Rust and Node load the **raw entropy** from the BIP-39 phrase
(not the BIP-39 PBKDF2-derived 64-byte seed). 24-word entropy is 32
bytes and is used directly as the seed. 12-word entropy is 16 bytes
and is hashed once with the domain tag to produce the 32-byte seed.
This deliberately differs from how hardware wallets use the same
phrases (which feed the PBKDF2 64-byte seed into BIP-32 derivation).
KEZ has one identity per phrase, no derivation tree.

113
python/README.md Normal file
View File

@ -0,0 +1,113 @@
# KEZ — Python 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. Every connection is proven by a
signature against a key the user already controls. The protocol is specified in
[`../SPEC.md`](../SPEC.md); this directory is the Python implementation of that
spec.
It is **wire-compatible** with the [Rust](../rust/) and [Node](../nodejs/)
implementations: a claim signed here verifies there and vice versa, in every
direction. The repo-root [`crosstest.sh`](../crosstest.sh) proves it.
---
## What's in this directory
```
python/
├── pyproject.toml Package metadata + entry point (`kez`)
├── requirements.txt Runtime deps (cryptography, zstandard)
├── kez_cli.py Standalone launcher (used by ../crosstest.sh)
└── kez/
├── jcs.py RFC 8785 JSON canonicalization
├── bech32.py Bech32 (nsec/npub) encode/decode
├── schnorr.py Pure-Python BIP-340 Schnorr over secp256k1
├── identity.py `system:identifier` parsing + normalization
├── keys.py NostrSecret / Ed25519Secret signers + verification
├── envelope.py Envelope, claim & sigchain-event payloads, sign/verify
├── encodings.py JSON / compact (kez:z1:) / markdown / DNS / JSONL bundle
├── sigchain.py Append-only signed sigchain + on-disk storage
├── channels.py parse_proof across all four wire encodings
└── cli.py The `kez` command-line interface
```
---
## Setup
> **New to KEZ?** Read [**`TUTORIAL.md`**](TUTORIAL.md) — a friendly
> step-by-step walkthrough that takes you from "I have a nostr `nsec`"
> to "I have a verified, published sigchain," including the BIP-39
> recovery-phrase backup (12 or 24 words). It assumes nothing.
>
> This README is the reference; the tutorial is the on-ramp.
```sh
cd python
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```
Then run the CLI either through the launcher or the installed entry point:
```sh
.venv/bin/python kez_cli.py identity new
# or, after `.venv/bin/pip install -e .`:
.venv/bin/kez identity new
```
---
## Crypto stack
| Concern | Choice | Why |
|---|---|---|
| JCS (RFC 8785) | hand-rolled (`jcs.py`) | KEZ payloads are strings/ints/objects only; a tiny dependency-free canonicalizer guarantees byte-identical output |
| secp256k1 Schnorr (BIP-340) | pure-Python reference (`schnorr.py`) | the native `coincurve`/`secp256k1` bindings fail to build on recent CPython; signing fixed-size digests is fast enough for a CLI. Signs with zero aux-rand to match Rust/Node exactly |
| Ed25519 (RFC 8032) | [`cryptography`](https://cryptography.io) | well-maintained, ships wheels |
| zstd | [`zstandard`](https://pypi.org/project/zstandard/) | level 3, matching the other impls; `decompressobj` handles frames without a content-size header |
| Bech32 | hand-rolled (`bech32.py`) | the BIP-173 reference is small and avoids a dependency |
All signing is **deterministic**, so the same claim signs identically every
time.
---
## CLI reference
```
kez identity new [--key-type nostr|ed25519]
kez claim create <subject> (--nsec <nsec> | --ed25519-seed <hex>)
[--format json|compact|markdown] [--out <path>]
kez claim dns <domain> (--nsec <nsec> | --ed25519-seed <hex>)
kez verify file <path>
kez sigchain add <subject> (--nsec | --ed25519-seed) [--proof-url <url>]
kez sigchain revoke <subject> (--nsec | --ed25519-seed)
kez sigchain show [--primary <id> | --nsec | --ed25519-seed]
kez sigchain export [--primary <id> | --nsec | --ed25519-seed]
[--format jsonl|compact] [--out <path>]
```
Sigchain state lives in `~/.kez/sigchains/<primary-with-colons-as-underscores>.jsonl`
— the same paths the Rust and Node CLIs use, so chains built by one are
readable by the others.
---
## What's not done yet
Matching the gap list in [`../rust/README.md`](../rust/README.md), the Python
CLI implements `claim`, `verify file`, and `sigchain add/revoke/show/export`.
Not yet ported: `verify id` channel resolution (network fetch), `sigchain
publish`, and the `rotate`/`add_device` ops.
## License
Dual-licensed under MIT or Apache-2.0.

526
python/TUTORIAL.md Normal file
View File

@ -0,0 +1,526 @@
# Tutorial — your first KEZ identity, end to end (Python)
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 with a 12- or 24-word backup
phrase).
- ✅ 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](https://keybase.io), 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 Python implementation. It is **wire-compatible** with the
[Rust](../rust/TUTORIAL.md) and [Node](../nodejs/TUTORIAL.md)
implementations — a claim signed in any of the three verifies in the
other two. The repo-root [`crosstest.sh`](../crosstest.sh) proves it
across 84 scenarios.
For the full protocol spec, see [`../SPEC.md`](../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 **Python 3.10+** and standard build tooling for the
`cryptography` + `zstandard` native deps (clang/gcc on macOS / Linux,
or pre-built wheels on most platforms).
```sh
git clone https://git.ptud.biz/DukeInc/Kez.git
cd Kez/python
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```
Verify the CLI works:
```sh
.venv/bin/python kez_cli.py --help
```
You should see subcommands `identity`, `claim`, `verify`, and `sigchain`.
> **Want a global `kez` command instead?** From inside `python/` run
> `.venv/bin/pip install -e .` once. After that, plain `kez claim
> create …` works (provided your shell has `.venv/bin` on `PATH`, or
> you activate the venv). Substitute `kez` for `.venv/bin/python
> kez_cli.py` 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:
### Option A: use your existing nostr key (recommended if you have one)
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:
```sh
.venv/bin/python kez_cli.py identity new
```
Or a new Ed25519 keypair, which comes with a BIP-39 phrase alongside
the hex seed (both are equivalent backups):
```sh
.venv/bin/python kez_cli.py identity new --key-type ed25519 # 24-word
.venv/bin/python kez_cli.py 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…"
```
> **Save the backup.** Seed *or* phrase — at least one. Lose them both
> and the identity is gone. There's no recovery flow.
### Recovery phrases — what's actually going on
A KEZ recovery phrase is a [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)
mnemonic — the same 2048-word English wordlist that Bitcoin, Ethereum,
and most hardware wallets use. The words encode random bits:
| Phrase length | Random bits | Resulting Ed25519 seed |
|---|---|---|
| **24 words** | 256 bits of entropy | The 32-byte seed *is* those 256 bits (1:1). Phrase ↔ seed round-trips. |
| **12 words** | 128 bits of entropy | 16 bytes → 32-byte seed via `SHA-256("kez-bip39-12-v1" \|\| entropy)`. Phrase → seed only (one-way). |
#### Picking 12 vs 24
- **Pick 24 words** when you want full round-trip-ability — i.e. you'd
like to be able to *recover the phrase from the hex seed* at any time
in the future. Anyone's 32-byte Ed25519 secret can be re-encoded into
the unique 24-word phrase that produced it. Bigger security margin
(256 bits of entropy vs 128).
- **Pick 12 words** when you want a shorter thing to write down on
paper or remember. 128 bits of entropy is still enormously beyond
brute-forcing. The trade-off: the path is *one-way only* — you can
always derive the seed from the phrase, but you cannot derive the
phrase from the seed. So if you only ever have the seed, you'll
never know what 12-word phrase produced it. **Save the phrase
itself**, not just the resulting seed.
Either way the resulting Ed25519 identity is exactly the same shape;
peers can't tell which word count you used. The choice is purely about
your backup ergonomics.
#### ⚠ Not compatible with hardware-wallet derivations
A KEZ 12-word phrase **does not** produce the same Bitcoin or Ethereum
key as the same 12 words typed into a Ledger or MetaMask, and vice
versa. The reasons are deliberate:
1. Other wallets feed the phrase through BIP-39's PBKDF2 to get a
64-byte "seed", then run that through BIP-32 hierarchical
derivation at a coin-specific path. KEZ doesn't — it takes the
raw entropy and uses it directly (24-word case) or hashes it with
a domain tag (12-word case).
2. KEZ identities aren't part of a derivation tree. There's one
identity per phrase; there's no path component.
That means: **don't paste your existing hardware-wallet recovery
phrase into KEZ** expecting to get a key you've already seen. It'll
produce a *new* KEZ identity uncorrelated with anything else.
Conversely: a KEZ phrase you saved is *only* useful for KEZ. A
malicious wallet that says "import this phrase" can't extract your
existing Bitcoin / Ethereum funds from a KEZ phrase, because the
phrase wasn't derived through the same path.
#### Backing up — concrete advice
The phrase is the master key to your identity. Practical guidance:
- **Write it on paper, with a pencil. Number each word (112 or 124)
so you can later verify the order.** A photograph or cloud document
is one breach away from compromise.
- **Store the paper somewhere fireproof.** Safe-deposit boxes, lockable
desk drawers, etched-stainless-steel cards if you're paranoid.
- **Never type the phrase into a website, chat app, or password
manager that auto-syncs.** Local-only password managers (KeePassXC,
1Password locked vault) are OK; cloud-synced managers are a softer
target.
- **Don't split it across two locations "for safety".** Half a BIP-39
phrase weakens the entropy more than it protects against loss. If you
need redundancy, make two complete paper copies in different physical
locations.
- **Don't be cute.** Don't permute the words "because they're easy to
remember in this order." The wordlist position matters; reorder and
you change the key (and the BIP-39 checksum will reject it on
restore anyway).
### Working with phrases later
You can generate a fresh phrase without producing a key, or recover
the key from a phrase you wrote down earlier:
```sh
# Print a fresh 24-word phrase (or 12, with --words 12). No key derived.
.venv/bin/python kez_cli.py identity mnemonic
.venv/bin/python kez_cli.py identity mnemonic --words 12
# Recover the Ed25519 key from a phrase. Word count auto-detected.
.venv/bin/python kez_cli.py identity from-mnemonic "abandon ability able about
above absent academy accident account accuse achieve acid acoustic acquire
across act action actor actress actual adapt add addict address"
```
The recovered output is identical, byte-for-byte, to what was printed
when you first ran `identity new` — same `Primary:`, same `Public:`,
same `Secret:`.
Throughout the rest of this tutorial you can substitute
`--mnemonic "your phrase here"` anywhere `--ed25519-seed <hex>` appears.
Both are accepted on every command that takes a signing key.
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`.
```sh
.venv/bin/python kez_cli.py claim create github:tudisco \
--nsec nsec1FAKE... \
--format markdown \
--out github-tudisco.kez.md
```
That writes a file containing the human-readable header plus a
```kez``` fence with the raw JSON envelope inside (same shape as in the
[Rust tutorial](../rust/TUTORIAL.md#2-sign-your-first-claim)).
### 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
Same rules as the [Rust](../rust/TUTORIAL.md#3-publish-the-proof) and
[Node](../nodejs/TUTORIAL.md#3-publish-the-proof) tutorials — pick the
section that matches your subject system. GitHub gist or profile
README, DNS TXT at `_kez.<domain>`, nostr (profile bio / kind-1 post /
kind-30078), Bluesky post, ActivityPub profile field, your own
`/.well-known/kez.json`.
The `dns:` shortcut prints a ready-to-paste zone file line:
```sh
.venv/bin/python kez_cli.py claim dns tud.ink --nsec nsec1FAKE...
```
---
## 4. Verify it
```sh
.venv/bin/python kez_cli.py verify id github:tudisco
```
Output:
```
Primary: nostr:npub1tkf...
Verified identities:
- github:tudisco
Status: valid
Confidence: strong
```
Works against any channel — `dns:`, `github:`, `nostr:`, `bluesky:`,
`ap:`, `mastodon:`. The verifier fetches the proof from where you
published it, decodes the envelope, and verifies the cryptographic
signature against the embedded public key.
**No KEZ server was involved.** Each side proves the claim
independently — that's the whole point.
### Cross-implementation verification
Wire-compatible with the [Rust](../rust/TUTORIAL.md) and
[Node](../nodejs/TUTORIAL.md) CLIs. You can sign in any and verify in
any other:
```sh
# Sign in Python…
.venv/bin/python kez_cli.py claim create github:tudisco \
--mnemonic "your 12 or 24 words…" --out p.kez.json
# …verify in Rust
cd ../rust && cargo run -p kez-cli -- verify file ../python/p.kez.json
# …or verify in Node
cd ../nodejs && npm run cli -- verify file ../python/p.kez.json
```
Same bytes, same signature, all three implementations agree. The repo
root's `crosstest.sh` exercises this for every (signer, verifier,
format) combination.
### If verification fails
- **`not_found`** — 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.
- **`invalid_signature`** — 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`.
- **`ModuleNotFoundError: kez`** — you ran a `python` not from the
venv. Use `.venv/bin/python kez_cli.py …` (the launcher inserts the
package dir into `sys.path`).
---
## 5. Sigchain — link multiple identities together
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, and so you can later **revoke** a claim
without invalidating the others.
Chains live at `~/.kez/sigchains/<safe-primary>.jsonl`. The CLI
creates the directory on first use.
```sh
.venv/bin/python kez_cli.py sigchain add github:tudisco --nsec nsec1FAKE...
.venv/bin/python kez_cli.py sigchain add dns:tud.ink --nsec nsec1FAKE...
.venv/bin/python kez_cli.py sigchain show --nsec nsec1FAKE...
.venv/bin/python kez_cli.py sigchain revoke github:tudisco --nsec nsec1FAKE...
```
Read-only view of someone else's chain (no secret needed):
```sh
.venv/bin/python kez_cli.py sigchain show --primary nostr:npub1tkf...
```
### Exporting
```sh
.venv/bin/python kez_cli.py sigchain export --nsec nsec1FAKE... --format jsonl
.venv/bin/python kez_cli.py sigchain export --nsec nsec1FAKE... --format compact
```
> **Note.** The Python CLI currently supports `sigchain add`,
> `revoke`, `show`, and `export`. `sigchain publish` (the one that
> POSTs to a kez-sig-server or writes a `.well-known/` bundle) is
> available in the Rust and Node CLIs; porting it to Python is a
> short follow-up. Until then, you can `export` the chain and upload
> it manually, or use the Rust/Node CLI for publishing the chain
> built by Python (the on-disk JSONL is byte-compatible).
---
## 6. Verifying someone else
You've done the publishing side. Here's the receiving side — verify
someone *else's* identity:
```sh
# Start from any identifier they've published a proof for:
.venv/bin/python kez_cli.py verify id github:linus
# Walk their chain from any known endpoint:
.venv/bin/python kez_cli.py sigchain show --primary nostr:npub1abc...
```
If you have the chain bundle on disk:
```sh
.venv/bin/python kez_cli.py 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.
---
## 7. Programmatic use — embedding KEZ in a Python app
You don't have to go through the CLI. The same logic is exported by the
`kez` package.
```python
from kez.identity import Identity
from kez.keys import NostrSecret
from kez.envelope import sign_claim, new_claim_payload
from kez.encodings import to_markdown
from kez.channels import default_registry
# Sign a claim
secret = NostrSecret.from_nsec("nsec1FAKE...")
subject = Identity.parse("github:tudisco")
payload = new_claim_payload(subject, secret.identity(), None) # None = now
claim = sign_claim(payload, secret)
print(to_markdown(claim))
# Verify a peer
registry = default_registry()
hit = registry.verify(Identity.parse("dns:tud.ink"))
print(hit.status) # "valid"
```
For mnemonic helpers:
```python
from kez.mnemonic import (
generate_mnemonic,
seed_from_mnemonic,
mnemonic_from_seed_24,
ed25519_from_mnemonic,
generate_ed25519_with_mnemonic,
)
# Round-trip a 24-word phrase
secret, phrase = generate_ed25519_with_mnemonic(24)
assert ed25519_from_mnemonic(phrase).pubkey_hex() == secret.pubkey_hex()
```
The implementations themselves are short (a few hundred lines each);
the package modules are well-named and read as documentation. See
[`kez/`](kez/) for the full surface.
---
## 8. Quick reference card
```sh
# Generate a fresh primary
.venv/bin/python kez_cli.py identity new
.venv/bin/python kez_cli.py identity new --key-type ed25519 # 24-word phrase
.venv/bin/python kez_cli.py identity new --key-type ed25519 --mnemonic-words 12 # 12-word phrase
.venv/bin/python kez_cli.py identity mnemonic [--words 12|24] # phrase only
.venv/bin/python kez_cli.py identity from-mnemonic "<phrase>" # recover key
# Sign a claim
.venv/bin/python kez_cli.py claim create <subject> --nsec <nsec>
.venv/bin/python kez_cli.py claim create <subject> --ed25519-seed <hex-seed>
.venv/bin/python kez_cli.py claim create <subject> --mnemonic "<phrase>"
.venv/bin/python kez_cli.py claim create <subject> --nsec <nsec> --format markdown --out file.md
.venv/bin/python kez_cli.py claim create <subject> --nsec <nsec> --format compact
.venv/bin/python kez_cli.py claim dns <domain> --nsec <nsec> # zone-file output
# Verify
.venv/bin/python kez_cli.py verify id <subject> # live channel fetch
.venv/bin/python kez_cli.py verify file <path> # local file
# Sigchain
.venv/bin/python kez_cli.py sigchain add <subject> --nsec <nsec> [--proof-url <url>]
.venv/bin/python kez_cli.py sigchain revoke <subject> --nsec <nsec>
.venv/bin/python kez_cli.py sigchain show --nsec <nsec> # your own
.venv/bin/python kez_cli.py sigchain show --primary <id> # someone else's
.venv/bin/python kez_cli.py sigchain export --nsec <nsec> --format jsonl|compact [--out file]
```
---
## 9. Common confusions
**"Do I need a sigchain to use KEZ?"** No. A single signed claim,
published, works on its own.
**"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.
**"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 someone copies 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 "this primary is succeeded by <new>" event
(planned for the spec; not yet enforced).
**"Why is the Python version slower than Rust on imports?"** The
`cryptography` C extension lazy-loads on first use, so the very first
`identity new` or `verify` after a fresh shell can take an extra ~100
ms. Steady-state is comparable to Node; both are I/O-bound on the
channel HTTP calls for `verify id`.
---
## 10. Where to go next
- The web client at <https://kez.lat> — same protocol, no CLI.
- [`../SPEC.md`](../SPEC.md) — the formal protocol.
- [`../rust/TUTORIAL.md`](../rust/TUTORIAL.md) and
[`../nodejs/TUTORIAL.md`](../nodejs/TUTORIAL.md) — same tutorial for
the other two implementations.
- [`../rust-sig-server/`](../rust-sig-server/) — run your own
sig-server.
- The channel module in [`kez/channels.py`](kez/channels.py) — add a
new channel in an afternoon (each channel implementation is ~3080
lines).
That's the whole tutorial. Welcome to KEZ.

7
python/kez/__init__.py Normal file
View File

@ -0,0 +1,7 @@
"""KEZ — portable identity graph, Python implementation.
Byte-compatible with the Rust and Node.js implementations: claims signed by one
verify in the others, in every direction (see ../../crosstest.sh).
"""
__version__ = "0.3.0"

6
python/kez/__main__.py Normal file
View File

@ -0,0 +1,6 @@
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())

94
python/kez/bech32.py Normal file
View File

@ -0,0 +1,94 @@
"""Bech32 encoding (BIP-173 variant) for nostr nsec/npub strings.
Reference implementation adapted from BIP-173 (Pieter Wuille, MIT licensed).
KEZ uses the original Bech32 checksum constant (not Bech32m), matching the
nostr NIP-19 convention and the Rust/Node implementations.
"""
from __future__ import annotations
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def _polymod(values: list[int]) -> int:
generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
chk = 1
for v in values:
b = chk >> 25
chk = ((chk & 0x1FFFFFF) << 5) ^ v
for i in range(5):
chk ^= generator[i] if ((b >> i) & 1) else 0
return chk
def _hrp_expand(hrp: str) -> list[int]:
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
def _verify_checksum(hrp: str, data: list[int]) -> bool:
return _polymod(_hrp_expand(hrp) + data) == 1
def _create_checksum(hrp: str, data: list[int]) -> list[int]:
values = _hrp_expand(hrp) + data
polymod = _polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
def _bech32_encode(hrp: str, data: list[int]) -> str:
combined = data + _create_checksum(hrp, data)
return hrp + "1" + "".join(CHARSET[d] for d in combined)
def _bech32_decode(bech: str) -> tuple[str, list[int]]:
if any(ord(x) < 33 or ord(x) > 126 for x in bech):
raise ValueError("bech32: invalid character")
if bech.lower() != bech and bech.upper() != bech:
raise ValueError("bech32: mixed case")
bech = bech.lower()
pos = bech.rfind("1")
if pos < 1 or pos + 7 > len(bech):
raise ValueError("bech32: invalid separator position")
hrp = bech[:pos]
if any(c not in CHARSET for c in bech[pos + 1 :]):
raise ValueError("bech32: invalid data character")
data = [CHARSET.find(c) for c in bech[pos + 1 :]]
if not _verify_checksum(hrp, data):
raise ValueError("bech32: bad checksum")
return hrp, data[:-6]
def _convertbits(data: bytes | list[int], frombits: int, tobits: int, pad: bool) -> list[int]:
acc = 0
bits = 0
ret: list[int] = []
maxv = (1 << tobits) - 1
max_acc = (1 << (frombits + tobits - 1)) - 1
for value in data:
if value < 0 or (value >> frombits):
raise ValueError("bech32: invalid value in convertbits")
acc = ((acc << frombits) | value) & max_acc
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append((acc >> bits) & maxv)
if pad:
if bits:
ret.append((acc << (tobits - bits)) & maxv)
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
raise ValueError("bech32: invalid padding in convertbits")
return ret
def encode(hrp: str, payload: bytes) -> str:
"""Encode raw ``payload`` bytes as a bech32 string under ``hrp``."""
data = _convertbits(payload, 8, 5, True)
return _bech32_encode(hrp, data)
def decode(expected_hrp: str, bech: str) -> bytes:
"""Decode a bech32 string, asserting its HRP and returning the raw bytes."""
hrp, data = _bech32_decode(bech)
if hrp != expected_hrp:
raise ValueError(f"bech32: expected hrp {expected_hrp!r}, got {hrp!r}")
return bytes(_convertbits(data, 5, 8, False))

42
python/kez/channels.py Normal file
View File

@ -0,0 +1,42 @@
"""Proof parsing across the four wire encodings (Spec §6)."""
from __future__ import annotations
import json
from typing import Any
from .encodings import extract_markdown_proof, from_compact
from .envelope import COMPACT_PROOF_PREFIX
_B64URL = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
def extract_compact_token(text: str) -> str | None:
idx = text.find(COMPACT_PROOF_PREFIX)
if idx < 0:
return None
body_chars = []
for ch in text[idx + len(COMPACT_PROOF_PREFIX) :]:
if ch in _B64URL:
body_chars.append(ch)
else:
break
if not body_chars:
return None
return COMPACT_PROOF_PREFIX + "".join(body_chars)
def parse_proof(raw: str) -> dict[str, Any]:
trimmed = raw.strip()
# Markdown fence is the most specific marker — check it first.
if "```kez" in trimmed:
return extract_markdown_proof(trimmed)
# Raw JSON envelope.
if trimmed.startswith("{"):
return json.loads(trimmed)
# Compact: extract the kez:z1:<base64url> token anywhere in the input.
token = extract_compact_token(trimmed)
if token is not None:
return from_compact(token)
raise ValueError("unknown KEZ proof format")

379
python/kez/cli.py Normal file
View File

@ -0,0 +1,379 @@
"""KEZ command-line interface (Python implementation).
Mirrors the Rust and Node CLIs command-for-command and byte-for-byte in its
output, so the cross-implementation interop suite (../crosstest.sh) passes in
every direction.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from . import encodings, sigchain
from .channels import parse_proof
from .envelope import (
new_add_payload,
new_claim_payload,
new_revoke_payload,
sign_claim,
sign_sigchain_event,
verify_claim,
)
from .identity import Identity
from .keys import Ed25519Secret, NostrSecret, signer_from_flags
from .mnemonic import (
ed25519_from_mnemonic,
generate_ed25519_with_mnemonic,
generate_mnemonic,
)
def _eprint(msg: str) -> None:
print(msg, file=sys.stderr)
def write_or_print(out: str | None, output: str) -> None:
if out is not None:
Path(out).write_text(output, encoding="utf-8")
return
# Match Rust/Node: avoid double newlines if output already ends in one.
if output.endswith("\n"):
sys.stdout.write(output)
else:
print(output)
# ── identity ────────────────────────────────────────────────────────────────
def cmd_identity_new(args: argparse.Namespace) -> int:
mnemonic_words = getattr(args, "mnemonic_words", None)
if args.key_type == "nostr":
if mnemonic_words is not None:
raise ValueError("--mnemonic-words is only valid with --key-type ed25519")
secret = NostrSecret.generate()
print(f"Primary: nostr:{secret.npub()}")
print(f"Public: {secret.npub()}")
print(f"Secret: {secret.nsec()}")
print()
print("Store the secret somewhere safe. Anyone with the nsec can sign as this identity.")
return 0
# ed25519: default 24 words.
words = mnemonic_words if mnemonic_words is not None else 24
if words not in (12, 24):
raise ValueError(f"mnemonic word count must be 12 or 24, got {words}")
secret, phrase = generate_ed25519_with_mnemonic(words)
print(f"Primary: {secret.identity()}")
print(f"Public: {secret.pubkey_hex()}")
print(f"Secret: {secret.seed_hex()} (32-byte seed)")
print(f'Mnemonic ({words} words): "{phrase}"')
print()
if words == 24:
print(
"The 24-word phrase and the hex seed are equivalent backups —\n"
"either restores this identity. Store at least one safely."
)
else:
print(
"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."
)
return 0
def cmd_identity_mnemonic(args: argparse.Namespace) -> int:
words = args.words if args.words is not None else 24
if words not in (12, 24):
raise ValueError(f"mnemonic word count must be 12 or 24, got {words}")
print(generate_mnemonic(words))
return 0
def cmd_identity_from_mnemonic(args: argparse.Namespace) -> int:
phrase = args.phrase
if not phrase or not phrase.strip():
raise ValueError("identity from-mnemonic needs the phrase in quotes")
secret = ed25519_from_mnemonic(phrase)
word_count = len(phrase.split())
print(f"Primary: {secret.identity()}")
print(f"Public: {secret.pubkey_hex()}")
print(f"Secret: {secret.seed_hex()} (32-byte seed)")
print(f'Mnemonic ({word_count} words): "{phrase.strip()}"')
if word_count == 24:
# Confirm canonical round-trip; flag if not.
from .mnemonic import mnemonic_from_seed_24
derived = mnemonic_from_seed_24(bytes.fromhex(secret.seed_hex()))
if derived.strip() != phrase.strip():
print(f'(note: canonical form is "{derived}")')
return 0
# ── claim ─────────────────────────────────────────────────────────────────────
def _signer(args: argparse.Namespace):
return signer_from_flags(
args.nsec,
args.ed25519_seed,
getattr(args, "mnemonic", None),
)
def _build_claim(subject: str, args: argparse.Namespace):
signer = _signer(args)
primary = signer.identity()
payload = new_claim_payload(Identity.parse(subject), primary)
return sign_claim(payload, signer)
def cmd_claim_create(args: argparse.Namespace) -> int:
signed = _build_claim(args.subject, args)
if args.format == "markdown":
output = encodings.to_markdown(signed)
elif args.format == "compact":
output = encodings.to_compact(signed)
else:
output = encodings.to_pretty_json(signed)
write_or_print(args.out, output)
return 0
def cmd_claim_dns(args: argparse.Namespace) -> int:
domain = args.domain if args.domain.startswith("dns:") else f"dns:{args.domain}"
signed = _build_claim(domain, args)
name = encodings.dns_txt_name(signed["payload"]["subject"])
value = encodings.to_compact(signed)
print(f"Name: {name}")
print(f"Value: {value}")
print()
print("Zone file:")
print(f"{name} TXT {encodings.quote_dns_txt_value(value)}")
return 0
# ── verify ────────────────────────────────────────────────────────────────────
def _print_status(status: dict) -> None:
print(f"Primary: {status['primary']}")
print()
print("Verified identities:")
for identity in status["verified"]:
print(f"- {identity}")
print()
print(f"Status: {status['status']}")
print(f"Confidence: {status['confidence']}")
def cmd_verify_file(args: argparse.Namespace) -> int:
raw = Path(args.path).read_text(encoding="utf-8")
proof = parse_proof(raw)
status = verify_claim(proof)
_print_status(status)
return 0
def cmd_verify_id(args: argparse.Namespace) -> int:
_eprint(
"verify id requires network channel resolution, which is not implemented "
"in the Python CLI; use 'verify file' instead."
)
return 2
# ── sigchain ──────────────────────────────────────────────────────────────────
def _resolve_primary_readonly(args: argparse.Namespace) -> Identity:
if getattr(args, "primary", None):
return Identity.parse(args.primary)
signer = _signer(args)
return signer.identity()
def cmd_sigchain_add(args: argparse.Namespace) -> int:
signer = _signer(args)
primary = signer.identity()
chain = sigchain.load_chain(primary)
payload = new_add_payload(
primary,
chain.next_seq(),
chain.head_hash(),
Identity.parse(args.subject),
args.proof_url,
)
event = sign_sigchain_event(payload, signer)
chain.append(event)
sigchain.save_chain(chain)
print(
f"Appended add {args.subject} at seq {payload['seq']} "
f"(head hash: {chain.head_hash()})"
)
print(f"Chain saved to {sigchain.sigchain_path(primary)}")
return 0
def cmd_sigchain_revoke(args: argparse.Namespace) -> int:
signer = _signer(args)
primary = signer.identity()
chain = sigchain.load_chain(primary)
payload = new_revoke_payload(
primary,
chain.next_seq(),
chain.head_hash(),
Identity.parse(args.subject),
)
event = sign_sigchain_event(payload, signer)
chain.append(event)
sigchain.save_chain(chain)
print(
f"Appended revoke {args.subject} at seq {payload['seq']} "
f"(head hash: {chain.head_hash()})"
)
print(f"Chain saved to {sigchain.sigchain_path(primary)}")
return 0
def cmd_sigchain_show(args: argparse.Namespace) -> int:
primary = _resolve_primary_readonly(args)
chain = sigchain.load_chain(primary)
print(f"Primary: {primary}")
print(f"Path: {sigchain.sigchain_path(primary)}")
print(f"Length: {len(chain)} event(s)")
print()
for i, event in enumerate(chain.events()):
subject = sigchain.subject_of(event) or "<no subject>"
op = event["payload"]["op"]
seq = event["payload"]["seq"]
print(f" [{i}] seq={seq} op={op:<6} subject={subject}")
if not chain.is_empty():
print()
print(f"Head hash: {chain.head_hash()}")
return 0
def cmd_sigchain_export(args: argparse.Namespace) -> int:
primary = _resolve_primary_readonly(args)
chain = sigchain.load_chain(primary)
if chain.is_empty():
_eprint(f"no chain found for {primary}")
return 1
if args.format == "compact":
output = chain.to_compact_bundle()
else:
output = chain.to_jsonl()
write_or_print(args.out, output)
return 0
# ── argument parsing ──────────────────────────────────────────────────────────
def _add_key_flags(p: argparse.ArgumentParser) -> None:
p.add_argument("--nsec")
p.add_argument("--ed25519-seed", dest="ed25519_seed")
p.add_argument("--mnemonic")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="kez", description="KEZ portable identity CLI")
sub = parser.add_subparsers(dest="command", required=True)
# identity
p_identity = sub.add_parser("identity", help="key management")
identity_sub = p_identity.add_subparsers(dest="identity_command", required=True)
p_new = identity_sub.add_parser("new", help="generate a new identity")
p_new.add_argument("--key-type", dest="key_type", choices=["nostr", "ed25519"], default="nostr")
p_new.add_argument(
"--mnemonic-words",
dest="mnemonic_words",
type=int,
default=None,
help="(ed25519 only) generate from a 12- or 24-word BIP-39 phrase",
)
p_new.set_defaults(func=cmd_identity_new)
p_mn = identity_sub.add_parser(
"mnemonic", help="print a fresh BIP-39 phrase without deriving a key"
)
p_mn.add_argument("--words", type=int, default=None)
p_mn.set_defaults(func=cmd_identity_mnemonic)
p_fm = identity_sub.add_parser(
"from-mnemonic", help="derive an Ed25519 identity from a BIP-39 phrase"
)
p_fm.add_argument("phrase")
p_fm.set_defaults(func=cmd_identity_from_mnemonic)
# claim
p_claim = sub.add_parser("claim", help="create claims")
claim_sub = p_claim.add_subparsers(dest="claim_command", required=True)
p_create = claim_sub.add_parser("create", help="create a signed claim")
p_create.add_argument("subject")
_add_key_flags(p_create)
p_create.add_argument("--format", choices=["json", "compact", "markdown"], default="json")
p_create.add_argument("--out")
p_create.set_defaults(func=cmd_claim_create)
p_dns = claim_sub.add_parser("dns", help="create a DNS-zone proof for a domain")
p_dns.add_argument("domain")
_add_key_flags(p_dns)
p_dns.set_defaults(func=cmd_claim_dns)
# verify
p_verify = sub.add_parser("verify", help="verify proofs")
verify_sub = p_verify.add_subparsers(dest="verify_command", required=True)
p_vfile = verify_sub.add_parser("file", help="verify a proof file")
p_vfile.add_argument("path")
p_vfile.set_defaults(func=cmd_verify_file)
p_vid = verify_sub.add_parser("id", help="verify an identifier via its channels")
p_vid.add_argument("identifier")
p_vid.set_defaults(func=cmd_verify_id)
# sigchain
p_sig = sub.add_parser("sigchain", help="manage a sigchain")
sig_sub = p_sig.add_subparsers(dest="sigchain_command", required=True)
p_add = sig_sub.add_parser("add", help="append an add event")
p_add.add_argument("subject")
_add_key_flags(p_add)
p_add.add_argument("--proof-url", dest="proof_url")
p_add.set_defaults(func=cmd_sigchain_add)
p_revoke = sig_sub.add_parser("revoke", help="append a revoke event")
p_revoke.add_argument("subject")
_add_key_flags(p_revoke)
p_revoke.set_defaults(func=cmd_sigchain_revoke)
p_show = sig_sub.add_parser("show", help="show a sigchain")
p_show.add_argument("--primary")
_add_key_flags(p_show)
p_show.set_defaults(func=cmd_sigchain_show)
p_export = sig_sub.add_parser("export", help="export a sigchain")
p_export.add_argument("--primary")
_add_key_flags(p_export)
p_export.add_argument("--format", choices=["jsonl", "compact"], default="jsonl")
p_export.add_argument("--out")
p_export.set_defaults(func=cmd_sigchain_export)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except Exception as exc: # noqa: BLE001 — top-level CLI error boundary
_eprint(f"error: {exc}")
return 1
if __name__ == "__main__":
sys.exit(main())

129
python/kez/encodings.py Normal file
View File

@ -0,0 +1,129 @@
"""Wire encodings: JSON, compact (kez:z1:), markdown, DNS (Spec §6)."""
from __future__ import annotations
import base64
import json
from typing import Any
import zstandard
from .envelope import COMPACT_CHAIN_PREFIX, COMPACT_PROOF_PREFIX
_ZSTD_LEVEL = 3
def _b64url_nopad(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def _zstd_compress(data: bytes) -> bytes:
return zstandard.ZstdCompressor(level=_ZSTD_LEVEL).compress(data)
def _zstd_decompress(data: bytes) -> bytes:
# decompressobj handles frames that omit the content-size header, which
# some encoders (e.g. Node's zstd) produce.
dobj = zstandard.ZstdDecompressor().decompressobj()
return dobj.decompress(data) + dobj.flush()
def to_pretty_json(envelope: dict[str, Any]) -> str:
return json.dumps(envelope, indent=2, ensure_ascii=False)
def to_compact_json(envelope: dict[str, Any]) -> str:
return json.dumps(envelope, separators=(",", ":"), ensure_ascii=False)
def to_compact(envelope: dict[str, Any]) -> str:
raw = to_compact_json(envelope).encode("utf-8")
return COMPACT_PROOF_PREFIX + _b64url_nopad(_zstd_compress(raw))
def from_compact(value: str) -> dict[str, Any]:
trimmed = value.strip()
if not trimmed.startswith(COMPACT_PROOF_PREFIX):
raise ValueError("compact proof missing kez:z1: prefix")
body = trimmed[len(COMPACT_PROOF_PREFIX) :]
raw = _zstd_decompress(_b64url_decode(body))
return json.loads(raw.decode("utf-8"))
def to_markdown(envelope: dict[str, Any]) -> str:
payload = envelope["payload"]
return (
"# KEZ Proof\n\n"
"This account publishes a signed KEZ identity claim.\n\n"
f"- Primary: `{payload['primary']}`\n"
f"- Subject: `{payload['subject']}`\n"
f"- Created: `{payload['created_at']}`\n\n"
"```kez\n"
f"{to_pretty_json(envelope)}\n"
"```\n"
)
def extract_markdown_proof(markdown: str) -> dict[str, Any]:
fence = "```kez"
start = markdown.find(fence)
if start < 0:
raise ValueError("missing ```kez proof block")
body_start = start + len(fence)
end = markdown.find("```", body_start)
if end < 0:
raise ValueError("unterminated ```kez proof block")
return json.loads(markdown[body_start:end].strip())
# ── Sigchain compact bundle (kez:zc1:) ──────────────────────────────────────
def chain_to_jsonl(events: list[dict[str, Any]]) -> str:
if not events:
return ""
return "\n".join(json.dumps(e, separators=(",", ":"), ensure_ascii=False) for e in events) + "\n"
def chain_from_jsonl(text: str) -> list[dict[str, Any]]:
return [json.loads(line) for line in text.splitlines() if line.strip()]
def chain_to_compact_bundle(events: list[dict[str, Any]]) -> str:
raw = chain_to_jsonl(events).encode("utf-8")
return COMPACT_CHAIN_PREFIX + _b64url_nopad(_zstd_compress(raw))
def chain_from_compact_bundle(value: str) -> list[dict[str, Any]]:
trimmed = value.strip()
if not trimmed.startswith(COMPACT_CHAIN_PREFIX):
raise ValueError("compact chain missing kez:zc1: prefix")
body = trimmed[len(COMPACT_CHAIN_PREFIX) :]
raw = _zstd_decompress(_b64url_decode(body))
return chain_from_jsonl(raw.decode("utf-8"))
# ── DNS TXT helpers ─────────────────────────────────────────────────────────
def dns_txt_name(subject) -> str:
from .identity import Identity
ident = subject if isinstance(subject, Identity) else Identity.parse(str(subject))
if ident.scheme != "dns":
raise ValueError("DNS TXT proof requires a dns: subject")
return f"_kez.{ident.value}"
def quote_dns_txt_value(value: str) -> str:
chunks = [value[i : i + 240] for i in range(0, len(value), 240)]
quoted = []
for chunk in chunks:
escaped = chunk.replace("\\", "\\\\").replace('"', '\\"')
quoted.append(f'"{escaped}"')
return " ".join(quoted)

154
python/kez/envelope.py Normal file
View File

@ -0,0 +1,154 @@
"""Signature envelopes, claim payloads and verification (Spec §4, §5)."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from .identity import Identity
from .keys import verify_signature
CLAIM_TYPE = "kez.claim"
SIGCHAIN_EVENT_TYPE = "kez.sigchain.event"
FORMAT_VERSION = 1
COMPACT_PROOF_PREFIX = "kez:z1:"
COMPACT_CHAIN_PREFIX = "kez:zc1:"
class VerificationError(Exception):
pass
def rfc3339_utc(dt: datetime | None = None) -> str:
"""RFC 3339 UTC timestamp with microsecond precision and a trailing ``Z``."""
if dt is None:
dt = datetime.now(timezone.utc)
dt = dt.astimezone(timezone.utc)
return dt.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
def new_claim_payload(
subject: Identity,
primary: Identity,
created_at: str | None = None,
) -> dict[str, Any]:
return {
"type": CLAIM_TYPE,
"version": FORMAT_VERSION,
"subject": str(subject),
"primary": str(primary),
"created_at": created_at or rfc3339_utc(),
}
def sign_claim(payload: dict[str, Any], signer) -> dict[str, Any]:
key = signer.identity()
if payload["primary"] != str(key):
raise VerificationError(
f"claim primary {payload['primary']!r} does not match signing key {key}"
)
return {
"kez": "claim",
"payload": payload,
"signature": {
"alg": signer.alg(),
"key": str(key),
"sig": signer.sign_payload(payload),
},
}
def verify_claim(envelope: dict[str, Any]) -> dict[str, Any]:
"""Verify a claim envelope; return a status dict on success, else raise."""
if envelope.get("kez") != "claim":
raise VerificationError(f"not a claim envelope: kez={envelope.get('kez')!r}")
payload = envelope["payload"]
signature = envelope["signature"]
if signature["key"] != payload["primary"]:
raise VerificationError("signature.key does not match payload.primary")
primary = Identity.parse(payload["primary"])
key = Identity.parse(signature["key"])
if not verify_signature(payload, signature["alg"], key, signature["sig"]):
raise VerificationError(f"signature did not verify (alg={signature['alg']})")
subject = Identity.parse(payload["subject"])
return {
"primary": primary,
"verified": [subject],
"status": "valid",
"confidence": "strong",
}
# ── Sigchain event payloads ─────────────────────────────────────────────────
def _event_payload(
primary: Identity,
seq: int,
prev: str | None,
op: str,
op_payload: dict[str, Any],
created_at: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"type": SIGCHAIN_EVENT_TYPE,
"version": FORMAT_VERSION,
"primary": str(primary),
"seq": seq,
}
if prev is not None:
payload["prev"] = prev
payload["created_at"] = created_at or rfc3339_utc()
payload["op"] = op
payload["payload"] = op_payload
return payload
def new_add_payload(
primary: Identity,
seq: int,
prev: str | None,
subject: Identity,
proof_url: str | None = None,
created_at: str | None = None,
) -> dict[str, Any]:
op_payload: dict[str, Any] = {"subject": str(subject)}
if proof_url:
op_payload["proof_url"] = proof_url
return _event_payload(primary, seq, prev, "add", op_payload, created_at)
def new_revoke_payload(
primary: Identity,
seq: int,
prev: str | None,
subject: Identity,
created_at: str | None = None,
) -> dict[str, Any]:
return _event_payload(primary, seq, prev, "revoke", {"subject": str(subject)}, created_at)
def sign_sigchain_event(payload: dict[str, Any], signer) -> dict[str, Any]:
return {
"kez": "sigchain_event",
"payload": payload,
"signature": {
"alg": signer.alg(),
"key": str(signer.identity()),
"sig": signer.sign_payload(payload),
},
}
def verify_sigchain_event(event: dict[str, Any]) -> None:
if event.get("kez") != "sigchain_event":
raise VerificationError(f"wrong envelope tag: {event.get('kez')!r}")
payload = event["payload"]
signature = event["signature"]
if signature["key"] != payload["primary"]:
raise VerificationError("signature.key does not match payload.primary")
key = Identity.parse(signature["key"])
if not verify_signature(payload, signature["alg"], key, signature["sig"]):
raise VerificationError("sigchain event signature did not verify")

77
python/kez/identity.py Normal file
View File

@ -0,0 +1,77 @@
"""KEZ identifiers: always ``system:identifier`` (Spec §3)."""
from __future__ import annotations
import re
from . import bech32
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
class IdentityError(ValueError):
pass
class Identity:
"""A canonical ``system:identifier`` string."""
__slots__ = ("_raw",)
def __init__(self, raw: str) -> None:
self._raw = raw
@classmethod
def parse(cls, raw: str) -> "Identity":
trimmed = raw.strip()
if not trimmed:
raise IdentityError("empty identity")
# CLI ergonomics: a bare npub normalizes to nostr:npub...
if trimmed.startswith("npub1"):
_validate_npub(trimmed)
return cls(f"nostr:{trimmed}")
colon = trimmed.find(":")
if colon <= 0 or colon == len(trimmed) - 1:
raise IdentityError(f"invalid identity (need scheme:value): {raw!r}")
scheme = trimmed[:colon]
rest = trimmed[colon + 1 :]
if scheme == "nostr":
_validate_npub(rest)
elif scheme == "ed25519":
_validate_ed25519_hex(rest)
return cls(f"{scheme}:{rest}")
@property
def scheme(self) -> str:
return self._raw.split(":", 1)[0]
@property
def value(self) -> str:
return self._raw.split(":", 1)[1]
def __str__(self) -> str:
return self._raw
def __repr__(self) -> str:
return f"Identity({self._raw!r})"
def __eq__(self, other: object) -> bool:
return isinstance(other, Identity) and other._raw == self._raw
def __hash__(self) -> int:
return hash(self._raw)
def _validate_npub(value: str) -> None:
if not value.startswith("npub1"):
raise IdentityError(f"invalid nostr identifier (expected npub1...): {value!r}")
raw = bech32.decode("npub", value)
if len(raw) != 32:
raise IdentityError("invalid npub: expected 32-byte key")
def _validate_ed25519_hex(value: str) -> None:
if not _HEX64.match(value):
raise IdentityError("invalid ed25519 identifier: expected 64 lowercase hex chars")

78
python/kez/jcs.py Normal file
View File

@ -0,0 +1,78 @@
"""RFC 8785 JSON Canonicalization Scheme (JCS).
This is the heart of cross-implementation interop: signatures are computed over
the JCS-canonicalized bytes of a payload, so two implementations that agree on
these bytes produce universally-verifiable signatures.
Payloads in KEZ only ever contain strings, integers, booleans, nulls, arrays
and objects never floating-point numbers so we implement the integer-only
subset of the number rules. A float would be a bug, so we reject it loudly.
"""
from __future__ import annotations
from typing import Any
def _canon_string(s: str) -> str:
out = ['"']
for ch in s:
c = ord(ch)
if ch == '"':
out.append('\\"')
elif ch == "\\":
out.append("\\\\")
elif c == 0x08:
out.append("\\b")
elif c == 0x09:
out.append("\\t")
elif c == 0x0A:
out.append("\\n")
elif c == 0x0C:
out.append("\\f")
elif c == 0x0D:
out.append("\\r")
elif c < 0x20:
out.append("\\u%04x" % c)
else:
out.append(ch)
out.append('"')
return "".join(out)
def _canon(value: Any) -> str:
if value is True:
return "true"
if value is False:
return "false"
if value is None:
return "null"
if isinstance(value, str):
return _canon_string(value)
if isinstance(value, bool): # unreachable (handled above) but explicit
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
# KEZ payloads never carry floats; refuse rather than risk a
# non-canonical number serialization.
if value.is_integer():
return str(int(value))
raise ValueError("JCS: floating-point numbers are not supported in KEZ payloads")
if isinstance(value, (list, tuple)):
return "[" + ",".join(_canon(v) for v in value) + "]"
if isinstance(value, dict):
# RFC 8785: sort object members by their UTF-16 code-unit sequence.
items = sorted(value.items(), key=lambda kv: kv[0].encode("utf-16-be"))
return "{" + ",".join(_canon_string(k) + ":" + _canon(v) for k, v in items) + "}"
raise TypeError(f"JCS: unsupported type {type(value)!r}")
def canonicalize(value: Any) -> str:
"""Return the RFC 8785 canonical JSON string for ``value``."""
return _canon(value)
def canonical_bytes(value: Any) -> bytes:
"""Return the RFC 8785 canonical JSON bytes (UTF-8) for ``value``."""
return _canon(value).encode("utf-8")

162
python/kez/keys.py Normal file
View File

@ -0,0 +1,162 @@
"""Signing keys: nostr (secp256k1 Schnorr) and ed25519."""
from __future__ import annotations
import hashlib
import os
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption, PublicFormat
from . import bech32, schnorr
from .identity import Identity
from .jcs import canonical_bytes
NOSTR_SCHNORR_ALG = "nostr-secp256k1-schnorr-sha256-jcs"
ED25519_SHA512_ALG = "ed25519-sha512-jcs"
class NostrSecret:
"""A nostr secp256k1 secret key, addressed by its npub."""
__slots__ = ("_sk", "_pub")
def __init__(self, secret_key: bytes) -> None:
if len(secret_key) != 32:
raise ValueError("nostr secret key must be 32 bytes")
self._sk = secret_key
self._pub = schnorr.pubkey_gen(secret_key)
@classmethod
def generate(cls) -> "NostrSecret":
while True:
sk = os.urandom(32)
i = int.from_bytes(sk, "big")
if 1 <= i < schnorr.n:
return cls(sk)
@classmethod
def from_nsec(cls, nsec: str) -> "NostrSecret":
raw = bech32.decode("nsec", nsec.strip())
if len(raw) != 32:
raise ValueError("invalid nsec: expected 32-byte key")
return cls(raw)
def nsec(self) -> str:
return bech32.encode("nsec", self._sk)
def npub(self) -> str:
return bech32.encode("npub", self._pub)
def identity(self) -> Identity:
return Identity.parse(f"nostr:{self.npub()}")
def alg(self) -> str:
return NOSTR_SCHNORR_ALG
def sign_payload(self, payload) -> str:
digest = hashlib.sha256(canonical_bytes(payload)).digest()
return schnorr.sign(digest, self._sk).hex()
class Ed25519Secret:
"""An ed25519 secret seed, addressed by its hex public key."""
__slots__ = ("_seed", "_key", "_pub")
def __init__(self, seed: bytes) -> None:
if len(seed) != 32:
raise ValueError("ed25519 seed must be 32 bytes")
self._seed = seed
self._key = Ed25519PrivateKey.from_private_bytes(seed)
self._pub = self._key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
@classmethod
def generate(cls) -> "Ed25519Secret":
key = Ed25519PrivateKey.generate()
seed = key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
return cls(seed)
@classmethod
def from_seed_hex(cls, seed_hex: str) -> "Ed25519Secret":
seed = bytes.fromhex(seed_hex.strip())
if len(seed) != 32:
raise ValueError("invalid ed25519 seed: expected 32-byte (64 hex char) seed")
return cls(seed)
@classmethod
def from_mnemonic(cls, phrase: str) -> "Ed25519Secret":
# Lazy import: mnemonic.py imports Ed25519Secret at module top.
from .mnemonic import seed_from_mnemonic
return cls(seed_from_mnemonic(phrase))
@classmethod
def generate_with_mnemonic(cls, words: int = 24) -> tuple["Ed25519Secret", str]:
from .mnemonic import generate_ed25519_with_mnemonic
return generate_ed25519_with_mnemonic(words)
def seed_hex(self) -> str:
return self._seed.hex()
def pubkey_hex(self) -> str:
return self._pub.hex()
def identity(self) -> Identity:
return Identity.parse(f"ed25519:{self.pubkey_hex()}")
def alg(self) -> str:
return ED25519_SHA512_ALG
def sign_payload(self, payload) -> str:
return self._key.sign(canonical_bytes(payload)).hex()
def verify_signature(payload, alg: str, key: Identity, sig_hex: str) -> bool:
"""Verify a signature block against an arbitrary JCS-canonicalizable payload."""
try:
sig = bytes.fromhex(sig_hex)
except ValueError:
return False
if len(sig) != 64:
return False
if alg == NOSTR_SCHNORR_ALG:
if key.scheme != "nostr":
return False
pubkey = bech32.decode("npub", key.value)
digest = hashlib.sha256(canonical_bytes(payload)).digest()
return schnorr.verify(digest, pubkey, sig)
if alg == ED25519_SHA512_ALG:
if key.scheme != "ed25519":
return False
try:
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(key.value))
pub.verify(sig, canonical_bytes(payload))
return True
except Exception:
return False
return False
def signer_from_flags(
nsec: str | None,
ed25519_seed: str | None,
mnemonic: str | None = None,
):
provided = [v for v in (nsec, ed25519_seed, mnemonic) if v]
if len(provided) > 1:
raise ValueError("--nsec, --ed25519-seed, and --mnemonic are mutually exclusive")
if nsec:
return NostrSecret.from_nsec(nsec)
if ed25519_seed:
return Ed25519Secret.from_seed_hex(ed25519_seed)
if mnemonic:
return Ed25519Secret.from_mnemonic(mnemonic)
raise ValueError("missing key: pass --nsec, --ed25519-seed, or --mnemonic")

98
python/kez/mnemonic.py Normal file
View File

@ -0,0 +1,98 @@
"""BIP-39 mnemonic phrases for Ed25519 primary keys.
Mirrors ``rust/crates/kez-core/src/mnemonic.rs`` and
``nodejs/packages/kez-core/src/mnemonic.ts`` byte-for-byte.
Two word counts are supported, with different semantics:
- **24 words** **32 bytes of entropy** **Ed25519 seed** (bijection).
Round-trips perfectly. The entropy *is* the seed.
- **12 words** **16 bytes of entropy** **Ed25519 seed**, via
``SHA-256("kez-bip39-12-v1" || entropy)``. One-way KEZ-specific
derivation; you cannot recover a 12-word phrase from a seed.
Wordlist: BIP-39 English. 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, so taking the entropy directly (or hashing it
once for 12-word phrases) is the right primitive.
"""
from __future__ import annotations
import hashlib
from mnemonic import Mnemonic as _Bip39
from .keys import Ed25519Secret
# Domain separator for the 12-word → seed derivation. Bumping this would
# break every existing 12-word KEZ identity, so don't.
DOMAIN_TAG_12: bytes = b"kez-bip39-12-v1"
# Lazy singleton of the English BIP-39 wordlist parser.
_M = _Bip39("english")
def _assert_words(n: int) -> None:
if n not in (12, 24):
raise ValueError(f"mnemonic word count must be 12 or 24, got {n}")
def generate_mnemonic(words: int) -> str:
"""Generate a fresh BIP-39 mnemonic of the requested length.
The returned phrase is a space-separated lowercase string from the
BIP-39 English wordlist. ``words`` must be 12 or 24.
"""
_assert_words(words)
# bip39 strength is in bits: 12 words = 128 bits, 24 = 256.
strength = 256 if words == 24 else 128
return _M.generate(strength=strength)
def seed_from_mnemonic(phrase: str) -> bytes:
"""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)``.
"""
trimmed = " ".join(phrase.split())
try:
entropy = bytes(_M.to_entropy(trimmed))
except Exception as exc: # noqa: BLE001 — wrap as our own error
raise ValueError(f"invalid mnemonic: {exc}") from exc
if len(entropy) == 32:
return entropy
if len(entropy) == 16:
return hashlib.sha256(DOMAIN_TAG_12 + entropy).digest()
raise ValueError(
f"mnemonic must decode to 16 or 32 bytes of entropy, got {len(entropy)}"
)
def mnemonic_from_seed_24(seed: bytes) -> str:
"""Inverse of :func:`seed_from_mnemonic` for the 24-word case ONLY.
There is no inverse for 12-word phrases (hashing is one-way) this
function always produces 24 words.
"""
if len(seed) != 32:
raise ValueError(
f"mnemonic_from_seed_24: seed must be 32 bytes, got {len(seed)}"
)
return _M.to_mnemonic(seed)
def ed25519_from_mnemonic(phrase: str) -> Ed25519Secret:
"""Reconstruct an :class:`Ed25519Secret` from a BIP-39 phrase."""
return Ed25519Secret(seed_from_mnemonic(phrase))
def generate_ed25519_with_mnemonic(words: int) -> tuple[Ed25519Secret, str]:
"""Generate a fresh Ed25519 identity *and* return its BIP-39 phrase."""
phrase = generate_mnemonic(words)
secret = ed25519_from_mnemonic(phrase)
return secret, phrase

151
python/kez/schnorr.py Normal file
View File

@ -0,0 +1,151 @@
"""BIP-340 Schnorr signatures over secp256k1 (pure Python).
This is the reference implementation from BIP-340 (public domain), used for the
``nostr-secp256k1-schnorr-sha256-jcs`` suite. We sign with an all-zero auxiliary
randomness value, matching the Rust ``sign_schnorr_no_aux_rand`` and the Node
``schnorr.sign(digest, sk, ZERO_AUX)`` calls so every implementation produces
byte-identical, deterministic signatures.
We use a pure-Python implementation because the native ``coincurve``/``secp256k1``
bindings fail to build on bleeding-edge CPython. Signing/verifying short
fixed-size digests is well within pure-Python performance for a CLI tool.
"""
from __future__ import annotations
import hashlib
p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
G = (
0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8,
)
Point = tuple[int, int] | None
def tagged_hash(tag: str, msg: bytes) -> bytes:
tag_hash = hashlib.sha256(tag.encode()).digest()
return hashlib.sha256(tag_hash + tag_hash + msg).digest()
def is_infinite(P: Point) -> bool:
return P is None
def x(P: Point) -> int:
assert P is not None
return P[0]
def y(P: Point) -> int:
assert P is not None
return P[1]
def point_add(P1: Point, P2: Point) -> Point:
if P1 is None:
return P2
if P2 is None:
return P1
if x(P1) == x(P2) and y(P1) != y(P2):
return None
if P1 == P2:
lam = (3 * x(P1) * x(P1) * pow(2 * y(P1), p - 2, p)) % p
else:
lam = ((y(P2) - y(P1)) * pow(x(P2) - x(P1), p - 2, p)) % p
x3 = (lam * lam - x(P1) - x(P2)) % p
return (x3, (lam * (x(P1) - x3) - y(P1)) % p)
def point_mul(P: Point, scalar: int) -> Point:
R: Point = None
for i in range(256):
if (scalar >> i) & 1:
R = point_add(R, P)
P = point_add(P, P)
return R
def bytes_from_int(x_: int) -> bytes:
return x_.to_bytes(32, byteorder="big")
def bytes_from_point(P: Point) -> bytes:
return bytes_from_int(x(P))
def lift_x(b: bytes) -> Point:
x_ = int.from_bytes(b, byteorder="big")
if x_ >= p:
return None
y_sq = (pow(x_, 3, p) + 7) % p
y_ = pow(y_sq, (p + 1) // 4, p)
if pow(y_, 2, p) != y_sq:
return None
return (x_, y_ if y_ & 1 == 0 else p - y_)
def int_from_bytes(b: bytes) -> int:
return int.from_bytes(b, byteorder="big")
def has_even_y(P: Point) -> bool:
assert P is not None
return y(P) % 2 == 0
def pubkey_gen(seckey: bytes) -> bytes:
"""Return the 32-byte x-only public key for a 32-byte secret key."""
d0 = int_from_bytes(seckey)
if not (1 <= d0 <= n - 1):
raise ValueError("schnorr: secret key out of range")
P = point_mul(G, d0)
assert P is not None
return bytes_from_point(P)
def sign(msg: bytes, seckey: bytes, aux_rand: bytes = b"\x00" * 32) -> bytes:
"""Produce a 64-byte BIP-340 Schnorr signature over ``msg``."""
d0 = int_from_bytes(seckey)
if not (1 <= d0 <= n - 1):
raise ValueError("schnorr: secret key out of range")
P = point_mul(G, d0)
assert P is not None
d = d0 if has_even_y(P) else n - d0
t = (d ^ int_from_bytes(tagged_hash("BIP0340/aux", aux_rand))).to_bytes(32, "big")
k0 = int_from_bytes(tagged_hash("BIP0340/nonce", t + bytes_from_point(P) + msg)) % n
if k0 == 0:
raise ValueError("schnorr: nonce generation failed")
R = point_mul(G, k0)
assert R is not None
k = k0 if has_even_y(R) else n - k0
e = (
int_from_bytes(
tagged_hash("BIP0340/challenge", bytes_from_point(R) + bytes_from_point(P) + msg)
)
% n
)
sig = bytes_from_point(R) + ((k + e * d) % n).to_bytes(32, "big")
if not verify(msg, bytes_from_point(P), sig):
raise ValueError("schnorr: produced an invalid signature")
return sig
def verify(msg: bytes, pubkey: bytes, sig: bytes) -> bool:
"""Verify a 64-byte BIP-340 Schnorr signature ``sig`` over ``msg``."""
if len(pubkey) != 32 or len(sig) != 64:
return False
P = lift_x(pubkey)
r = int_from_bytes(sig[0:32])
s = int_from_bytes(sig[32:64])
if P is None or r >= p or s >= n:
return False
e = (
int_from_bytes(tagged_hash("BIP0340/challenge", sig[0:32] + pubkey + msg)) % n
)
R = point_add(point_mul(G, s), point_mul(P, n - e))
if R is None or not has_even_y(R) or x(R) != r:
return False
return True

112
python/kez/sigchain.py Normal file
View File

@ -0,0 +1,112 @@
"""Append-only signed sigchain (Spec §8) and on-disk storage."""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
from typing import Any
from . import encodings
from .envelope import verify_sigchain_event
from .identity import Identity
from .jcs import canonical_bytes
class SigchainError(Exception):
pass
def event_hash(event: dict[str, Any]) -> str:
"""``sha256:<hex>`` of the JCS bytes of the entire signed envelope."""
digest = hashlib.sha256(canonical_bytes(event)).hexdigest()
return f"sha256:{digest}"
class Sigchain:
def __init__(self, primary: Identity) -> None:
self._primary = primary
self._events: list[dict[str, Any]] = []
@property
def primary(self) -> Identity:
return self._primary
def events(self) -> list[dict[str, Any]]:
return self._events
def __len__(self) -> int:
return len(self._events)
def is_empty(self) -> bool:
return not self._events
def next_seq(self) -> int:
return len(self._events)
def head_hash(self) -> str | None:
if not self._events:
return None
return event_hash(self._events[-1])
def append(self, event: dict[str, Any]) -> None:
if event.get("kez") != "sigchain_event":
raise SigchainError(f"wrong envelope tag: {event.get('kez')!r}")
payload = event["payload"]
if payload["primary"] != str(self._primary):
raise SigchainError("event primary does not match chain primary")
expected_seq = self.next_seq()
if payload["seq"] != expected_seq:
raise SigchainError(f"seq mismatch: expected {expected_seq}, got {payload['seq']}")
expected_prev = self.head_hash()
if payload.get("prev") != expected_prev:
raise SigchainError("prev hash mismatch")
verify_sigchain_event(event)
self._events.append(event)
# ── serialization ──
def to_jsonl(self) -> str:
return encodings.chain_to_jsonl(self._events)
def to_compact_bundle(self) -> str:
return encodings.chain_to_compact_bundle(self._events)
@classmethod
def from_jsonl(cls, primary: Identity, text: str) -> "Sigchain":
chain = cls(primary)
for event in encodings.chain_from_jsonl(text):
chain.append(event)
return chain
def subject_of(event: dict[str, Any]) -> str | None:
op_payload = event.get("payload", {}).get("payload", {})
subject = op_payload.get("subject")
return subject if isinstance(subject, str) else None
# ── storage ─────────────────────────────────────────────────────────────────
def sigchain_dir() -> Path:
return Path(os.path.expanduser("~")) / ".kez" / "sigchains"
def sigchain_path(primary: Identity) -> Path:
d = sigchain_dir()
d.mkdir(parents=True, exist_ok=True)
safe = str(primary).replace(":", "_")
return d / f"{safe}.jsonl"
def load_chain(primary: Identity) -> Sigchain:
path = sigchain_path(primary)
if not path.exists():
return Sigchain(primary)
return Sigchain.from_jsonl(primary, path.read_text(encoding="utf-8"))
def save_chain(chain: Sigchain) -> None:
path = sigchain_path(chain.primary)
path.write_text(chain.to_jsonl(), encoding="utf-8")

18
python/kez_cli.py Normal file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""Standalone launcher for the KEZ Python CLI.
Lets the cross-implementation test harness invoke the CLI from any working
directory without installing the package:
python/.venv/bin/python python/kez_cli.py <args...>
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from kez.cli import main # noqa: E402
if __name__ == "__main__":
sys.exit(main())

20
python/pyproject.toml Normal file
View File

@ -0,0 +1,20 @@
[project]
name = "kez"
version = "0.3.0"
description = "KEZ portable identity graph — Python implementation"
requires-python = ">=3.10"
dependencies = [
"cryptography>=42",
"mnemonic>=0.20",
"zstandard>=0.22",
]
[project.scripts]
kez = "kez.cli:main"
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["kez"]

3
python/requirements.txt Normal file
View File

@ -0,0 +1,3 @@
cryptography>=42
mnemonic>=0.20
zstandard>=0.22

View File

@ -0,0 +1,158 @@
"""Tests for the BIP-39 mnemonic ↔ Ed25519 seed derivation.
The three vectors below are ground truth Rust, Node, and Python MUST
all derive these exact seeds and pubkeys. See
``python/MNEMONIC-TEST-VECTORS.md``.
"""
from __future__ import annotations
import pytest
from kez.keys import Ed25519Secret
from kez.mnemonic import (
DOMAIN_TAG_12,
ed25519_from_mnemonic,
generate_ed25519_with_mnemonic,
generate_mnemonic,
mnemonic_from_seed_24,
seed_from_mnemonic,
)
# ── canonical interop vectors ────────────────────────────────────────────────
V1_PHRASE = (
"abandon abandon abandon abandon abandon abandon abandon abandon "
"abandon abandon abandon abandon abandon abandon abandon abandon "
"abandon abandon abandon abandon abandon abandon abandon art"
)
V1_SEED_HEX = "0000000000000000000000000000000000000000000000000000000000000000"
V1_PUBKEY_HEX = "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
V2_PHRASE = (
"abandon abandon abandon abandon abandon abandon "
"abandon abandon abandon abandon abandon about"
)
V2_SEED_HEX = "09451c0f06588db78205e32a793536e15ae263c8f9ee6d14f5c6fd82b8bd20da"
V2_PUBKEY_HEX = "9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70"
V3_PHRASE = (
"legal winner thank year wave sausage worth useful "
"legal winner thank yellow"
)
V3_SEED_HEX = "9df434a2bd5dc767ee949d8ab95ca09c4ebbb88cefc3d0b1523f6b2a744ca824"
V3_PUBKEY_HEX = "cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03"
VECTORS = [
pytest.param(V1_PHRASE, V1_SEED_HEX, V1_PUBKEY_HEX, id="v1-24word-zero"),
pytest.param(V2_PHRASE, V2_SEED_HEX, V2_PUBKEY_HEX, id="v2-12word-zero"),
pytest.param(V3_PHRASE, V3_SEED_HEX, V3_PUBKEY_HEX, id="v3-12word-legal"),
]
@pytest.mark.parametrize("phrase, seed_hex, pubkey_hex", VECTORS)
def test_vector_seed_matches(phrase: str, seed_hex: str, pubkey_hex: str) -> None:
assert seed_from_mnemonic(phrase).hex() == seed_hex
@pytest.mark.parametrize("phrase, seed_hex, pubkey_hex", VECTORS)
def test_vector_pubkey_matches(phrase: str, seed_hex: str, pubkey_hex: str) -> None:
secret = ed25519_from_mnemonic(phrase)
assert secret.pubkey_hex() == pubkey_hex
assert secret.seed_hex() == seed_hex
# ── structural properties ───────────────────────────────────────────────────
def test_domain_tag_bytes() -> None:
# 15 ASCII bytes — must match the Rust/Node constant exactly.
assert DOMAIN_TAG_12 == b"kez-bip39-12-v1"
assert len(DOMAIN_TAG_12) == 15
def test_generate_24_round_trips() -> None:
phrase = generate_mnemonic(24)
assert len(phrase.split()) == 24
seed = seed_from_mnemonic(phrase)
phrase2 = mnemonic_from_seed_24(seed)
assert phrase == phrase2
def test_generate_12_is_deterministic() -> None:
phrase = generate_mnemonic(12)
assert len(phrase.split()) == 12
assert seed_from_mnemonic(phrase) == seed_from_mnemonic(phrase)
def test_mnemonic_from_seed_24_is_inverse() -> None:
seed = bytes([42]) * 32
phrase = mnemonic_from_seed_24(seed)
assert seed_from_mnemonic(phrase) == seed
def test_mnemonic_from_seed_24_rejects_wrong_length() -> None:
with pytest.raises(ValueError):
mnemonic_from_seed_24(b"\x00" * 16)
def test_invalid_word_count() -> None:
with pytest.raises(ValueError):
generate_mnemonic(18)
with pytest.raises(ValueError):
generate_mnemonic(0)
def test_invalid_words_errors_cleanly() -> None:
with pytest.raises(ValueError):
seed_from_mnemonic("not actually words at all here")
def test_invalid_checksum_errors() -> None:
# 12 valid words but wrong checksum.
bad = "abandon " * 11 + "abandon"
with pytest.raises(ValueError):
seed_from_mnemonic(bad.strip())
def test_whitespace_tolerance() -> None:
padded = f" {V2_PHRASE} "
assert seed_from_mnemonic(padded) == seed_from_mnemonic(V2_PHRASE)
# Collapses internal whitespace too.
weird = V2_PHRASE.replace(" ", " \t ")
assert seed_from_mnemonic(weird) == seed_from_mnemonic(V2_PHRASE)
def test_twelve_and_24_overlapping_entropy_differ() -> None:
# Sanity: 12-word entropy left-padded would equal 16 zeros + entropy.
# We hash instead — must not collide with the 24-word phrase of the
# same 16-byte entropy padded with zeros.
from mnemonic import Mnemonic
m = Mnemonic("english")
p12 = m.to_mnemonic(bytes([7]) * 16)
p24 = m.to_mnemonic(bytes([7]) * 32)
assert seed_from_mnemonic(p12) != seed_from_mnemonic(p24)
# ── Ed25519Secret hooks ─────────────────────────────────────────────────────
def test_ed25519_from_mnemonic_matches_direct_seed() -> None:
phrase = mnemonic_from_seed_24(bytes([1]) * 32)
from_mn = Ed25519Secret.from_mnemonic(phrase)
from_hex = Ed25519Secret.from_seed_hex("01" * 32)
assert from_mn.pubkey_hex() == from_hex.pubkey_hex()
def test_generate_with_mnemonic_pair_is_consistent() -> None:
secret, phrase = Ed25519Secret.generate_with_mnemonic(24)
restored = Ed25519Secret.from_mnemonic(phrase)
assert secret.pubkey_hex() == restored.pubkey_hex()
def test_generate_with_mnemonic_12() -> None:
secret, phrase = generate_ed25519_with_mnemonic(12)
assert len(phrase.split()) == 12
restored = ed25519_from_mnemonic(phrase)
assert secret.pubkey_hex() == restored.pubkey_hex()

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"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@ -127,6 +133,28 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "bitflags"
version = "2.11.1"
@ -709,6 +737,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "hickory-net"
version = "0.26.1"
@ -1164,6 +1201,7 @@ dependencies = [
"chrono",
"clap",
"dirs",
"hex",
"kez-channels",
"kez-core",
"reqwest",
@ -1176,6 +1214,7 @@ version = "0.1.0"
dependencies = [
"base64",
"bech32",
"bip39",
"chrono",
"ed25519-dalek",
"hex",
@ -2255,6 +2294,15 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "unicode-xid"
version = "0.2.6"

View File

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

View File

@ -81,24 +81,128 @@ kez identity new --key-type nostr # only if you want a NEW key
### Option B: generate a fresh Ed25519 primary
If you'd rather start clean, generate a new Ed25519 key:
If you'd rather start clean, generate a new Ed25519 key with a BIP-39
recovery phrase you can write down on paper:
```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…
Public: 7a3b4c… (hex)
Secret: 9e3f51… (hex — 64 chars, KEEP SECRET)
Public: 7a3b4c…
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
> identity. There's no recovery flow — lose it and the identity is
> gone. Write it down offline, or paste it into a password manager.
> From here on this tutorial assumes you stored it.
You now have **two equivalent backups** — the hex seed *and* the 24-word
BIP-39 phrase. Either restores the same identity. Most people back up
the phrase (easier to write down, easier to verify by hand).
> **Save the backup.** Seed *or* phrase — at least one. Lose them both
> and the identity is gone. There's no recovery flow.
### Recovery phrases — what's actually going on
A KEZ recovery phrase is a [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)
mnemonic — the same 2048-word English wordlist that Bitcoin, Ethereum,
and most hardware wallets use. The words encode random bits:
| Phrase length | Random bits | Resulting Ed25519 seed |
|---|---|---|
| **24 words** | 256 bits of entropy | The 32-byte seed *is* those 256 bits (1:1). Phrase ↔ seed round-trips. |
| **12 words** | 128 bits of entropy | 16 bytes → 32-byte seed via `SHA-256("kez-bip39-12-v1" \|\| entropy)`. Phrase → seed only (one-way). |
#### Picking 12 vs 24
- **Pick 24 words** when you want full round-trip-ability — i.e. you'd
like to be able to *recover the phrase from the hex seed* at any time
in the future. Anyone's 32-byte ed25519 secret can be re-encoded into
the unique 24-word phrase that produced it. Bigger security margin
(256 bits of entropy vs 128).
- **Pick 12 words** when you want a shorter thing to write down on
paper or remember. 128 bits of entropy is still enormously beyond
brute-forcing. The trade-off: the path is *one-way only* — you can
always derive the seed from the phrase, but you cannot derive the
phrase from the seed. So if you only ever have the seed, you'll
never know what 12-word phrase produced it. **Save the phrase
itself**, not just the resulting seed.
Either way the resulting Ed25519 identity is exactly the same shape;
peers can't tell which word count you used. The choice is purely about
your backup ergonomics.
#### ⚠ Not compatible with hardware-wallet derivations
A KEZ 12-word phrase **does not** produce the same Bitcoin or Ethereum
key as the same 12 words typed into a Ledger or MetaMask, and vice
versa. The reasons are deliberate:
1. Other wallets feed the phrase through BIP-39's PBKDF2 to get a
64-byte "seed", then run that through BIP-32 hierarchical
derivation at a coin-specific path. KEZ doesn't — it takes the
raw entropy and uses it directly (24-word case) or hashes it with
a domain tag (12-word case).
2. KEZ identities aren't part of a derivation tree. There's one
identity per phrase; there's no path component.
That means: **don't paste your existing hardware-wallet recovery
phrase into KEZ** expecting to get a key that you've already seen.
It'll produce a *new* KEZ identity uncorrelated with anything else.
Conversely: a KEZ phrase you saved is *only* useful for KEZ. A
malicious wallet that says "import this phrase" can't extract your
existing Bitcoin / Ethereum funds from a KEZ phrase, because the
phrase wasn't derived through the same path.
#### Backing up — concrete advice
The phrase is the master key to your identity. Practical guidance:
- **Write it on paper, with a pencil. Number each word (112 or 124)
so you can later verify the order.** A photograph or cloud document
is one breach away from compromise.
- **Store the paper somewhere fireproof.** Safe-deposit boxes, lockable
desk drawers, etched-stainless-steel cards if you're paranoid.
- **Never type the phrase into a website, chat app, or password
manager that auto-syncs.** Local-only password managers (KeePassXC,
1Password locked vault) are OK; cloud-synced managers are a softer
target.
- **Don't split it across two locations "for safety".** Half a BIP-39
phrase weakens the entropy more than it protects against loss. If you
need redundancy, make two complete paper copies in different physical
locations.
- **Don't be cute.** Don't permute the words "because they're easy to
remember in this order." The wordlist position matters; reorder and
you change the key (and the BIP-39 checksum will reject it on
restore anyway).
### Working with phrases later
You can generate a fresh phrase without producing a key, or recover
the key from a phrase you wrote down earlier:
```sh
# Print a fresh 24-word phrase (or 12, with --words 12). No key derived.
kez identity mnemonic
kez identity mnemonic --words 12
# Recover the Ed25519 key from a phrase. Word count auto-detected.
kez identity from-mnemonic "abandon ability able about above absent academy
accident account accuse achieve acid acoustic acquire across act action
actor actress actual adapt add addict address"
```
The recovered output is identical, byte-for-byte, to what was printed
when you first ran `identity new` — same `Primary:`, same `Public:`,
same `Secret:`.
Throughout the rest of this tutorial you can substitute
`--mnemonic "your phrase here"` anywhere `--ed25519-seed <hex>` appears.
Both are accepted on every command that takes a signing key.
For the rest of this tutorial we'll use a nostr key for examples and
write the secret as `nsec1FAKE...` — substitute your real one.

View File

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

View File

@ -4,8 +4,9 @@ use clap::{Parser, Subcommand, ValueEnum};
use kez_channels::nostr as nostr_chan;
use kez_channels::{ChannelHit, Registry, parse_proof};
use kez_core::{
ClaimPayload, Ed25519Secret, Identity, NostrSecret, SignedClaim, Signer, Sigchain,
VerificationStatus, dns_txt_name,
ClaimPayload, Ed25519Secret, Identity, MnemonicWords, NostrSecret, SignedClaim, Signer,
Sigchain, VerificationStatus, dns_txt_name, generate_mnemonic, mnemonic_from_seed_24,
seed_from_mnemonic,
};
use std::fs;
use std::path::{Path, PathBuf};
@ -47,6 +48,8 @@ enum SigchainCommand {
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
mnemonic: Option<String>,
#[arg(long)]
proof_url: Option<String>,
},
@ -57,6 +60,8 @@ enum SigchainCommand {
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
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).
Show {
@ -67,6 +72,8 @@ enum SigchainCommand {
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
mnemonic: Option<String>,
},
/// Export the chain in a portable format.
Export {
@ -76,6 +83,8 @@ enum SigchainCommand {
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
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)]
format: ExportFormat,
#[arg(long)]
@ -89,6 +98,8 @@ enum SigchainCommand {
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
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.
#[arg(long)]
server: Option<String>,
@ -117,9 +128,31 @@ enum ExportFormat {
#[derive(Debug, Subcommand)]
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 {
#[arg(long, value_enum, default_value_t = KeyType::Nostr)]
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>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
mnemonic: Option<String>,
#[arg(long, value_enum, default_value_t = OutputFormat::Json)]
format: OutputFormat,
#[arg(long)]
@ -148,6 +183,8 @@ enum ClaimCommand {
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
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 {
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 {
ClaimCommand::Create {
subject,
nsec,
ed25519_seed,
mnemonic,
format,
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 {
domain,
nsec,
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 {
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<()> {
match cmd {
SigchainCommand::Add {
subject,
nsec,
ed25519_seed,
mnemonic,
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 {
subject,
nsec,
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 {
primary,
nsec,
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 {
primary,
nsec,
ed25519_seed,
mnemonic,
format,
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 {
primary,
nsec,
ed25519_seed,
mnemonic,
server,
web,
out,
dns,
nostr,
} => {
sigchain_publish(
primary,
nsec,
ed25519_seed,
server,
web,
out,
dns,
nostr,
)
.await
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
sigchain_publish(primary, nsec, ed25519_seed, server, web, out, dns, nostr).await
}
}
}
fn identity_new(key_type: KeyType) -> Result<()> {
match key_type {
KeyType::Nostr => {
fn identity_new(key_type: KeyType, mnemonic_words: Option<u8>) -> Result<()> {
match (key_type, mnemonic_words) {
(KeyType::Nostr, Some(_)) => {
bail!("--mnemonic-words is only valid with --key-type ed25519");
}
(KeyType::Nostr, None) => {
let secret = NostrSecret::generate();
println!("Primary: nostr:{}", 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."
);
}
KeyType::Ed25519 => {
let secret = Ed25519Secret::generate();
(KeyType::Ed25519, words_opt) => {
// 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()?;
println!("Primary: {id}");
println!("Public: {}", secret.pubkey_hex());
println!("Secret: {} (32-byte seed)", secret.seed_hex());
println!("Mnemonic ({} words): \"{}\"", words.count(), phrase);
println!();
println!(
"Store the secret somewhere safe. Anyone with the seed can sign as this identity."
);
match words {
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(())

View File

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

View File

@ -15,6 +15,11 @@ use sha2::{Digest, Sha256};
use std::fmt;
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 SIGCHAIN_EVENT_TYPE: &str = "kez.sigchain.event";
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}")))?,
)
}
}

2
test.txt Normal file
View File

@ -0,0 +1,2 @@
bla