From b1240c13e55bbb4174ebf79ce6030661028993be Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 1 Jun 2026 13:29:45 -0600 Subject: [PATCH 1/8] Add Python implementation and cross-test interop Add a Python port of the KEZ CLI under python/, mirroring the Rust and Node implementations command-for-command and byte-for-byte: - Pure-Python JCS (RFC 8785), BIP-340 Schnorr, and Bech32; cryptography for Ed25519 and zstandard for the compact zstd framing. - Full CLI: identity new, claim create/dns, verify file, and sigchain add/revoke/show/export. Wire Python into crosstest.sh with 35 new scenarios covering Python against both Rust and Node, in every direction, across all wire formats, both key types, DNS proofs, and sigchains (incl. JSONL byte parity). All 55 scenarios pass. Update root README and .gitignore for the new implementation. --- .gitignore | 7 + README.md | 43 +++--- crosstest.sh | 154 +++++++++++++++++++++ python/README.md | 106 ++++++++++++++ python/kez/__init__.py | 7 + python/kez/__main__.py | 6 + python/kez/bech32.py | 94 +++++++++++++ python/kez/channels.py | 42 ++++++ python/kez/cli.py | 299 ++++++++++++++++++++++++++++++++++++++++ python/kez/encodings.py | 129 +++++++++++++++++ python/kez/envelope.py | 154 +++++++++++++++++++++ python/kez/identity.py | 77 +++++++++++ python/kez/jcs.py | 78 +++++++++++ python/kez/keys.py | 142 +++++++++++++++++++ python/kez/schnorr.py | 151 ++++++++++++++++++++ python/kez/sigchain.py | 112 +++++++++++++++ python/kez_cli.py | 18 +++ python/pyproject.toml | 19 +++ python/requirements.txt | 2 + 19 files changed, 1622 insertions(+), 18 deletions(-) create mode 100644 python/README.md create mode 100644 python/kez/__init__.py create mode 100644 python/kez/__main__.py create mode 100644 python/kez/bech32.py create mode 100644 python/kez/channels.py create mode 100644 python/kez/cli.py create mode 100644 python/kez/encodings.py create mode 100644 python/kez/envelope.py create mode 100644 python/kez/identity.py create mode 100644 python/kez/jcs.py create mode 100644 python/kez/keys.py create mode 100644 python/kez/schnorr.py create mode 100644 python/kez/sigchain.py create mode 100644 python/kez_cli.py create mode 100644 python/pyproject.toml create mode 100644 python/requirements.txt diff --git a/.gitignore b/.gitignore index 7b60514..d1306dc 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 73db254..1e3fc69 100644 --- a/README.md +++ b/README.md @@ -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 @@ -67,6 +72,15 @@ npm run cli -- verify id github:jason ``` Full guide: [`nodejs/README.md`](nodejs/README.md). +### 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). + ### Sigchain storage server (optional) ```sh cd rust-sig-server @@ -81,27 +95,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 | |---|---| -| 1–2 | nostr-signed JSON claim, both directions | -| 3–4 | nostr-signed compact claim, both directions | -| 5–6 | nostr-signed markdown claim, both directions | -| 7–8 | nostr-signed DNS zone form, both directions | -| 9–10 | ed25519-signed JSON claim, both directions | -| 11–12 | ed25519-signed compact claim, both directions | -| 13–14 | 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 | +| 1–14 | Rust ↔ Node: JSON / compact / markdown / DNS claims, nostr + ed25519 | +| 15–20 | Rust ↔ Node sigchains: build in one, parse + show in the other; JSONL byte parity | +| 21–44 | **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). diff --git a/crosstest.sh b/crosstest.sh index b2ea405..9173d8c 100755 --- a/crosstest.sh +++ b/crosstest.sh @@ -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/.jsonl. Both CLIs # read/write the same paths, so we can build a chain in one and inspect it @@ -289,6 +360,89 @@ 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" + printf "\n" if [[ $FAIL -eq 0 ]]; then printf "%sAll %d scenarios passed.%s\n" "$GREEN" "$PASS" "$RESET" diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..b2f6be0 --- /dev/null +++ b/python/README.md @@ -0,0 +1,106 @@ +# 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 + +```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 (--nsec | --ed25519-seed ) + [--format json|compact|markdown] [--out ] +kez claim dns (--nsec | --ed25519-seed ) + +kez verify file + +kez sigchain add (--nsec | --ed25519-seed) [--proof-url ] +kez sigchain revoke (--nsec | --ed25519-seed) +kez sigchain show [--primary | --nsec | --ed25519-seed] +kez sigchain export [--primary | --nsec | --ed25519-seed] + [--format jsonl|compact] [--out ] +``` + +Sigchain state lives in `~/.kez/sigchains/.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. diff --git a/python/kez/__init__.py b/python/kez/__init__.py new file mode 100644 index 0000000..bf27091 --- /dev/null +++ b/python/kez/__init__.py @@ -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" diff --git a/python/kez/__main__.py b/python/kez/__main__.py new file mode 100644 index 0000000..dbdd066 --- /dev/null +++ b/python/kez/__main__.py @@ -0,0 +1,6 @@ +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/kez/bech32.py b/python/kez/bech32.py new file mode 100644 index 0000000..adbd1df --- /dev/null +++ b/python/kez/bech32.py @@ -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)) diff --git a/python/kez/channels.py b/python/kez/channels.py new file mode 100644 index 0000000..998ae79 --- /dev/null +++ b/python/kez/channels.py @@ -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: 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") diff --git a/python/kez/cli.py b/python/kez/cli.py new file mode 100644 index 0000000..84ad65c --- /dev/null +++ b/python/kez/cli.py @@ -0,0 +1,299 @@ +"""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 + + +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: + if args.key_type == "ed25519": + secret = Ed25519Secret.generate() + print(f"Primary: {secret.identity()}") + print(f"Public: {secret.pubkey_hex()}") + print(f"Secret: {secret.seed_hex()} (32-byte seed)") + print() + print("Store the secret somewhere safe. Anyone with the seed can sign as this identity.") + else: + 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 + + +# ── claim ───────────────────────────────────────────────────────────────────── + + +def _build_claim(subject: str, args: argparse.Namespace): + signer = signer_from_flags(args.nsec, args.ed25519_seed) + 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_from_flags(args.nsec, args.ed25519_seed) + return signer.identity() + + +def cmd_sigchain_add(args: argparse.Namespace) -> int: + signer = signer_from_flags(args.nsec, args.ed25519_seed) + 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_from_flags(args.nsec, args.ed25519_seed) + 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 "" + 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") + + +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.set_defaults(func=cmd_identity_new) + + # 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()) diff --git a/python/kez/encodings.py b/python/kez/encodings.py new file mode 100644 index 0000000..fe82c03 --- /dev/null +++ b/python/kez/encodings.py @@ -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) diff --git a/python/kez/envelope.py b/python/kez/envelope.py new file mode 100644 index 0000000..57e54d4 --- /dev/null +++ b/python/kez/envelope.py @@ -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") diff --git a/python/kez/identity.py b/python/kez/identity.py new file mode 100644 index 0000000..cba9019 --- /dev/null +++ b/python/kez/identity.py @@ -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") diff --git a/python/kez/jcs.py b/python/kez/jcs.py new file mode 100644 index 0000000..3d02734 --- /dev/null +++ b/python/kez/jcs.py @@ -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") diff --git a/python/kez/keys.py b/python/kez/keys.py new file mode 100644 index 0000000..e5d87ea --- /dev/null +++ b/python/kez/keys.py @@ -0,0 +1,142 @@ +"""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) + + 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): + if nsec and ed25519_seed: + raise ValueError("pass only one of --nsec or --ed25519-seed") + if nsec: + return NostrSecret.from_nsec(nsec) + if ed25519_seed: + return Ed25519Secret.from_seed_hex(ed25519_seed) + raise ValueError("missing key: pass --nsec or --ed25519-seed") diff --git a/python/kez/schnorr.py b/python/kez/schnorr.py new file mode 100644 index 0000000..c18ddc6 --- /dev/null +++ b/python/kez/schnorr.py @@ -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 diff --git a/python/kez/sigchain.py b/python/kez/sigchain.py new file mode 100644 index 0000000..38aa5b2 --- /dev/null +++ b/python/kez/sigchain.py @@ -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:`` 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") diff --git a/python/kez_cli.py b/python/kez_cli.py new file mode 100644 index 0000000..db5b9ca --- /dev/null +++ b/python/kez_cli.py @@ -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 +""" + +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()) diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..2d3f202 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "kez" +version = "0.3.0" +description = "KEZ portable identity graph — Python implementation" +requires-python = ">=3.10" +dependencies = [ + "cryptography>=42", + "zstandard>=0.22", +] + +[project.scripts] +kez = "kez.cli:main" + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["kez"] diff --git a/python/requirements.txt b/python/requirements.txt new file mode 100644 index 0000000..0f9027e --- /dev/null +++ b/python/requirements.txt @@ -0,0 +1,2 @@ +cryptography>=42 +zstandard>=0.22 From 878965924b75117d9599785157ec086c5a07da4d Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 1 Jun 2026 13:31:48 -0600 Subject: [PATCH 2/8] Add nostr chat notes, update favicon, add test.txt --- kez-chat/web/NOSTR-CHAT.md | 267 ++++++++++++++++++++++++++++++++ kez-chat/web/public/favicon.ico | Bin 535 -> 504 bytes test.txt | 2 + 3 files changed, 269 insertions(+) create mode 100644 kez-chat/web/NOSTR-CHAT.md create mode 100644 test.txt diff --git a/kez-chat/web/NOSTR-CHAT.md b/kez-chat/web/NOSTR-CHAT.md new file mode 100644 index 0000000..6c5e7d4 --- /dev/null +++ b/kez-chat/web/NOSTR-CHAT.md @@ -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 (1000–9999), so relays persist it | +| `tags` | `[["h", ]]` — `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": [""], "since": } +``` + +--- + +## 4. Message lifecycle + +### Sending (`nostr-transport.ts` → `sendMessage`) + +1. Resolve the recipient handle → ed25519 primary via the directory + (`/v1/u/` — 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:`** — 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:`** — 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//stream` (those were the server transport). +- The only remaining server call is the directory lookup `GET /v1/u/`. diff --git a/kez-chat/web/public/favicon.ico b/kez-chat/web/public/favicon.ico index bfa4c206286339503ccddb1a115b0826e4def47d..c086dacff3503e2603fc6a5ef7c2c407ff285898 100644 GIT binary patch delta 453 zcmV;$0XqJd1o#6G000310RS*C000312ms;%kq|$Blu1NERCt{2nz3rbFc5}4`%Z>H z7ehK2O6Ut@%v>lp_X)Z*Tj#8Oi4J{(ZibFs+lT0qt#6@nqAHFX2MZ)yr)co^0Yn)a9FEfgfQ0D4K$L9t+t5F#mW{r+y)#;@H$J-1b^(INhmBu< zej7C}Z{A{57=Q^{?i~OtodMvS0F>`Vj1vQd5M=-q4xpBe$uoCKuh9UkeogU0b%`2Z+dJ?WK?ne@sx`!&a5?w5s&2?iVi0NQjc vMoc7q$VVxRSxNe!n^JhxfKqtagM{e^-2p5@6DFx#00000NkvXXu0mjfbQZ@D delta 484 zcmVBkq|$Bvq?ljRCt{2nmulVFc^lJs*YF; zAQl!dOT-a6W{5mT$`Kg5XX6C90LmGfElaPEE3owr5pwkbCKhV=iTLqHJ-mt(8S_5h z54JD{i$4v_GXDD0^SpOr(}QW#i)qu$a%oPSkk2X0r8(RiJb)MQ)C-_8p3QBTbg1xT z(?ctN01Q|n+GIdhnhD9=pr$e*r`jl+az84;o(d z*CZu0b`#5x`(3#xin&odMi&7fBQ9iOMZccPt;cKtQ*9-ZDsyn{qySTH=C(bXcN73i z6~r`vqXD=EU>YD?9>Bu>a@WNEdfmi+z(KGdF&RPdhWe0?VLxVNYdGj;YxvZFt>I%2 ahD^T!*KH1*dmD)W0000 Date: Fri, 5 Jun 2026 17:41:01 -0600 Subject: [PATCH 3/8] feat(rust,nodejs): BIP-39 mnemonic phrases for Ed25519 identities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the canonical wallet-style backup form (12 or 24 BIP-39 English words) to both implementations. Wire-compatible — bit-identical seed derivation across Rust and Node. Semantics: • 24 words ↔ 32 bytes of entropy ↔ Ed25519 seed (bijection). Phrase ↔ seed round-trips exactly. • 12 words → 16 bytes of entropy → seed via SHA-256("kez-bip39-12-v1" || entropy). Deterministic but one-way; you can't recover a 12-word phrase from a seed. The 12-word case is KEZ-specific (not interoperable with hardware- wallet BIP-32 derivations). The 24-word case is. Both use the BIP-39 English wordlist so users can paper-back-up alongside other wallets. We deliberately do NOT use BIP-39's PBKDF2 to_seed(passphrase) — that produces a 64-byte seed for BIP-32 hierarchical derivation, which is the wrong primitive for KEZ's single-identity-per-phrase model. Rust (kez-core): • New mod mnemonic with MnemonicWords, generate_mnemonic, seed_from_mnemonic, mnemonic_from_seed_24. • Ed25519Secret::{from_mnemonic, generate_with_mnemonic}. • Dep: bip39 v2.0 with the `rand` feature for OS-RNG generation. • 9 unit tests, all green. Rust (kez-cli): • `identity new --key-type ed25519` now also prints a 24-word phrase (default), with --mnemonic-words 12 to use 12 instead. • `identity mnemonic [--words 12|24]` — print a fresh phrase only. • `identity from-mnemonic ""` — derive the key from a phrase. • `--mnemonic ` is now accepted everywhere `--ed25519-seed ` was (claim create/dns, sigchain add/revoke/show/export/ publish), mutually exclusive with --ed25519-seed and --nsec via clap conflicts_with_all. Node (@kez/core): • New mnemonic.ts with the parallel API: generateMnemonic, seedFromMnemonic, mnemonicFromSeed24, ed25519FromMnemonic, generateEd25519WithMnemonic. • Dep: @scure/bip39 v2.x (note: import path is "@scure/bip39/wordlists/english.js" with the .js suffix in v2). • 8 vitest cases mirroring the Rust tests, all green. Node (@kez/cli): • Same CLI surface added: identity new --mnemonic-words 12|24, identity mnemonic --words 12|24, identity from-mnemonic "". • --mnemonic flag accepted alongside --nsec / --ed25519-seed in the flag parser, with mutex enforcement; loadSigner dispatches it. Verified cross-implementation interop: • Same 24-word phrase → identical Ed25519 pubkey in Rust and Node. • Same 12-word phrase → identical pubkey (proves the SHA-256 domain-tagged derivation matches byte-for-byte). • A claim signed in Rust with --mnemonic verifies in Node (Status: valid). Tests: 114 Rust + 99 Node total, zero regressions. TUTORIAL.md updated in both rust/ and nodejs/ with the new section in "Pick your primary key" plus a callout that --mnemonic can substitute for --ed25519-seed throughout the rest of the tutorial. Co-Authored-By: Claude Opus 4.7 --- nodejs/TUTORIAL.md | 34 ++- nodejs/package-lock.json | 262 ++++++++++-------- nodejs/packages/kez-cli/src/cli.ts | 95 +++++-- nodejs/packages/kez-core/package.json | 1 + nodejs/packages/kez-core/src/index.ts | 8 + nodejs/packages/kez-core/src/mnemonic.ts | 100 +++++++ .../packages/kez-core/test/mnemonic.test.ts | 87 ++++++ rust/Cargo.lock | 48 ++++ rust/Cargo.toml | 1 + rust/TUTORIAL.md | 38 ++- rust/crates/kez-cli/Cargo.toml | 1 + rust/crates/kez-cli/src/main.rs | 178 ++++++++++-- rust/crates/kez-core/Cargo.toml | 1 + rust/crates/kez-core/src/lib.rs | 5 + rust/crates/kez-core/src/mnemonic.rs | 237 ++++++++++++++++ 15 files changed, 918 insertions(+), 178 deletions(-) create mode 100644 nodejs/packages/kez-core/src/mnemonic.ts create mode 100644 nodejs/packages/kez-core/test/mnemonic.test.ts create mode 100644 rust/crates/kez-core/src/mnemonic.rs diff --git a/nodejs/TUTORIAL.md b/nodejs/TUTORIAL.md index 0281306..b66df3b 100644 --- a/nodejs/TUTORIAL.md +++ b/nodejs/TUTORIAL.md @@ -97,24 +97,42 @@ A new nostr keypair: npm run cli -- identity new ``` -Or a new Ed25519 keypair: +Or a new Ed25519 keypair, which comes with a 24-word BIP-39 phrase +alongside the hex seed (both are equivalent backups): ```sh -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. +> **12 vs 24.** 24 words is fully round-trippable: phrase ↔ seed are +> bijective. 12 words is shorter to memorize, but the seed is derived +> from the phrase one-way (KEZ-specific SHA-256 step), so you cannot +> derive a 12-word phrase from a hex seed. Pick whichever you'll +> actually back up. + +You can also get just a phrase, or restore an existing one: + +```sh +npm run cli -- identity mnemonic # fresh 24 words +npm run cli -- identity mnemonic --words 12 # fresh 12 words +npm run cli -- identity from-mnemonic "abandon ability able …" # recover the key +``` + +> **Save the backup.** Seed *or* phrase — at least one. Lose them both +> and the identity is gone. There's no recovery flow. + +Throughout the rest of this tutorial you can substitute +`--mnemonic "your phrase here"` anywhere `--ed25519-seed ` appears. For the rest of this tutorial we'll use a nostr key for examples and write the secret as `nsec1FAKE...` — substitute your real one. diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index cf83304..fd12904 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -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" } } diff --git a/nodejs/packages/kez-cli/src/cli.ts b/nodejs/packages/kez-cli/src/cli.ts index 6227d48..60c0d7e 100755 --- a/nodejs/packages/kez-cli/src/cli.ts +++ b/nodejs/packages/kez-cli/src/cli.ts @@ -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 ...", "", "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 \"\"", " claim create (--nsec | --ed25519-seed )", " [--format json|markdown|compact] [--out ]", " claim dns (--nsec | --ed25519-seed )", @@ -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 { 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); diff --git a/nodejs/packages/kez-core/package.json b/nodejs/packages/kez-core/package.json index 9eb2874..8b4a4d2 100644 --- a/nodejs/packages/kez-core/package.json +++ b/nodejs/packages/kez-core/package.json @@ -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" } } diff --git a/nodejs/packages/kez-core/src/index.ts b/nodejs/packages/kez-core/src/index.ts index 344046b..dc253f8 100644 --- a/nodejs/packages/kez-core/src/index.ts +++ b/nodejs/packages/kez-core/src/index.ts @@ -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"; diff --git a/nodejs/packages/kez-core/src/mnemonic.ts b/nodejs/packages/kez-core/src/mnemonic.ts new file mode 100644 index 0000000..1eadf89 --- /dev/null +++ b/nodejs/packages/kez-core/src/mnemonic.ts @@ -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 }; +} diff --git a/nodejs/packages/kez-core/test/mnemonic.test.ts b/nodejs/packages/kez-core/test/mnemonic.test.ts new file mode 100644 index 0000000..83b70a0 --- /dev/null +++ b/nodejs/packages/kez-core/test/mnemonic.test.ts @@ -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)), + ); + }); +}); diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 10c38e4..6b1c32c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -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" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 5fbcb96..e261487 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -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"] } diff --git a/rust/TUTORIAL.md b/rust/TUTORIAL.md index b193df8..b2cb1dc 100644 --- a/rust/TUTORIAL.md +++ b/rust/TUTORIAL.md @@ -84,21 +84,43 @@ kez identity new --key-type nostr # only if you want a NEW key If you'd rather start clean, generate a new Ed25519 key: ```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). + +> **12 vs 24.** 24 words is fully round-trippable: phrase ↔ seed are +> bijective. 12 words is shorter to memorize, but the seed is derived +> from the phrase one-way (KEZ-specific SHA-256 step), so you cannot +> derive a 12-word phrase from a hex seed. Pick whichever you'll +> actually remember to back up. + +You can also get just a phrase without a key, or restore from a phrase +you wrote down earlier: + +```sh +kez identity mnemonic # print a fresh 24-word phrase +kez identity mnemonic --words 12 # 12-word +kez identity from-mnemonic "abandon ability able …" # recover the key +``` + +> **Save the backup.** Seed *or* phrase — at least one. Lose them both +> and the identity is gone. There's no recovery flow. + +Throughout the rest of this tutorial you can substitute +`--mnemonic "your phrase here"` anywhere `--ed25519-seed ` appears. For the rest of this tutorial we'll use a nostr key for examples and write the secret as `nsec1FAKE...` — substitute your real one. diff --git a/rust/crates/kez-cli/Cargo.toml b/rust/crates/kez-cli/Cargo.toml index ff857bc..7f44684 100644 --- a/rust/crates/kez-cli/Cargo.toml +++ b/rust/crates/kez-cli/Cargo.toml @@ -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 diff --git a/rust/crates/kez-cli/src/main.rs b/rust/crates/kez-cli/src/main.rs index efd4ef4..6b5f46a 100644 --- a/rust/crates/kez-cli/src/main.rs +++ b/rust/crates/kez-cli/src/main.rs @@ -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, #[arg(long = "ed25519-seed", conflicts_with = "nsec")] ed25519_seed: Option, + #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])] + mnemonic: Option, #[arg(long)] proof_url: Option, }, @@ -57,6 +60,8 @@ enum SigchainCommand { nsec: Option, #[arg(long = "ed25519-seed", conflicts_with = "nsec")] ed25519_seed: Option, + #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])] + mnemonic: Option, }, /// Print the chain (events one per line, plus a summary). Show { @@ -67,6 +72,8 @@ enum SigchainCommand { nsec: Option, #[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])] ed25519_seed: Option, + #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])] + mnemonic: Option, }, /// Export the chain in a portable format. Export { @@ -76,6 +83,8 @@ enum SigchainCommand { nsec: Option, #[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])] ed25519_seed: Option, + #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])] + mnemonic: Option, #[arg(long, value_enum, default_value_t = ExportFormat::Jsonl)] format: ExportFormat, #[arg(long)] @@ -89,6 +98,8 @@ enum SigchainCommand { nsec: Option, #[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])] ed25519_seed: Option, + #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])] + mnemonic: Option, /// POST every event to a kez-sig-server at this URL. #[arg(long)] server: Option, @@ -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, + }, + /// 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, #[arg(long = "ed25519-seed", conflicts_with = "nsec")] ed25519_seed: Option, + #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])] + mnemonic: Option, #[arg(long, value_enum, default_value_t = OutputFormat::Json)] format: OutputFormat, #[arg(long)] @@ -148,6 +183,8 @@ enum ClaimCommand { nsec: Option, #[arg(long = "ed25519-seed", conflicts_with = "nsec")] ed25519_seed: Option, + #[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])] + mnemonic: Option, }, } @@ -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 `, 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, + mnemonic: Option, +) -> Result> { + 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) -> 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(()) diff --git a/rust/crates/kez-core/Cargo.toml b/rust/crates/kez-core/Cargo.toml index 284a636..26ab716 100644 --- a/rust/crates/kez-core/Cargo.toml +++ b/rust/crates/kez-core/Cargo.toml @@ -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 diff --git a/rust/crates/kez-core/src/lib.rs b/rust/crates/kez-core/src/lib.rs index c9ca132..77b6d5a 100644 --- a/rust/crates/kez-core/src/lib.rs +++ b/rust/crates/kez-core/src/lib.rs @@ -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; diff --git a/rust/crates/kez-core/src/mnemonic.rs b/rust/crates/kez-core/src/mnemonic.rs new file mode 100644 index 0000000..7a83532 --- /dev/null +++ b/rust/crates/kez-core/src/mnemonic.rs @@ -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 { + 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 { + 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 { + 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 { + 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::from_count( + s.parse::() + .map_err(|_| KezError::InvalidIdentity(format!("not a number: {s}")))?, + ) + } +} From b0cc1a74a069f6d51375757ba61618964eeb74b1 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Fri, 5 Jun 2026 17:50:34 -0600 Subject: [PATCH 4/8] feat(python,crosstest): mirror BIP-39 mnemonic to Python + add interop scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the three-way BIP-39 mnemonic surface (Rust + Node landed in 0058d9b) and pins down byte-for-byte agreement with crosstest scenarios. Python (mirrors rust/crates/kez-core/src/mnemonic.rs + nodejs's mnemonic.ts): • python/kez/mnemonic.py — generate_mnemonic, seed_from_mnemonic, mnemonic_from_seed_24, ed25519_from_mnemonic, generate_ed25519_with_mnemonic. Same 24-word-bijection / 12-word- SHA-256-domain-tagged semantics. Uses Trezor's `mnemonic` library (v0.21) for the BIP-39 wordlist + entropy parsing; deliberately does NOT use BIP-39's PBKDF2 to_seed function. • python/kez/keys.py — Ed25519Secret.from_mnemonic() + generate_with_mnemonic() classmethods; signer_from_flags widened to accept --mnemonic. • python/kez/cli.py — identity new --mnemonic-words, identity mnemonic [--words], identity from-mnemonic; --mnemonic flag on claim create/dns and sigchain add/revoke/show/export. Output format matches Rust + Node verbatim so the crosstest harness can grep Primary/Public/Secret/Mnemonic lines. • python/tests/test_mnemonic.py — 19 tests covering all three canonical vectors (exact-match Secret + Public hex), round-trip, determinism, whitespace tolerance, bad-checksum, bad-word-count, the literal domain-tag bytes, and the 12-vs-24 entropy-overlap non-collision case. Note: --mnemonic is NOT added to `sigchain publish` because that subcommand doesn't exist in the Python CLI yet (rust + node only). When the publish surface is ported, --mnemonic should follow it the same way. Ground truth — python/MNEMONIC-TEST-VECTORS.md: V1: 24-word zero-entropy phrase ("abandon… art") seed = 0000…0000 pubkey = 3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29 V2: 12-word zero-entropy phrase ("abandon… about") seed = 09451c0f06588db78205e32a793536e15ae263c8f9ee6d14f5c6fd82b8bd20da pubkey = 9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70 V3: 12-word "legal winner thank year wave sausage worth useful legal winner thank yellow" seed = 9df434a2bd5dc767ee949d8ab95ca09c4ebbb88cefc3d0b1523f6b2a744ca824 pubkey = cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03 The domain tag for the 12-word derivation is exactly the 15 ASCII bytes of "kez-bip39-12-v1", documented in the spec doc. crosstest.sh — new "BIP-39 mnemonic interop" section: • Vector match: each impl × each vector × Public hex == expected (9 scenarios). Catches any silent derivation drift. • Cross-impl claim signing via --mnemonic: every signer ↔ verifier pair (rust↔node, rust↔py, node↔py), every format (json/compact/ markdown). 6 pairings × 3 formats = 18 scenarios. • Bijection sanity: the 24-word phrase printed by `identity from- mnemonic` round-trips to itself byte-for-byte (rust + node). • Python-involving scenarios auto-skip if `python/.venv/bin/python kez_cli.py identity from-mnemonic` returns non-zero, so the harness stays runnable on machines where Python isn't set up. Verified end-to-end: `bash crosstest.sh` reports "All 84 scenarios passed." Test totals across implementations: Rust: 114 (9 mnemonic-specific in kez-core) Node: 99 (8 mnemonic-specific in @kez/core) Python: 19 (mnemonic only; was no test suite before) Crosstest: 84 scenarios end-to-end Co-Authored-By: Claude Opus 4.7 --- crosstest.sh | 87 ++++++++++++++++++ python/MNEMONIC-TEST-VECTORS.md | 63 +++++++++++++ python/kez/cli.py | 104 ++++++++++++++++++--- python/kez/keys.py | 28 +++++- python/kez/mnemonic.py | 98 ++++++++++++++++++++ python/pyproject.toml | 1 + python/requirements.txt | 1 + python/tests/test_mnemonic.py | 158 ++++++++++++++++++++++++++++++++ 8 files changed, 524 insertions(+), 16 deletions(-) create mode 100644 python/MNEMONIC-TEST-VECTORS.md create mode 100644 python/kez/mnemonic.py create mode 100644 python/tests/test_mnemonic.py diff --git a/crosstest.sh b/crosstest.sh index 9173d8c..fdef22c 100755 --- a/crosstest.sh +++ b/crosstest.sh @@ -443,6 +443,93 @@ for peer in node rust; do 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" diff --git a/python/MNEMONIC-TEST-VECTORS.md b/python/MNEMONIC-TEST-VECTORS.md new file mode 100644 index 0000000..62b563c --- /dev/null +++ b/python/MNEMONIC-TEST-VECTORS.md @@ -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:`. + +## 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. diff --git a/python/kez/cli.py b/python/kez/cli.py index 84ad65c..9da14a3 100644 --- a/python/kez/cli.py +++ b/python/kez/cli.py @@ -23,6 +23,11 @@ from .envelope import ( ) 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: @@ -44,28 +49,83 @@ def write_or_print(out: str | None, output: str) -> None: def cmd_identity_new(args: argparse.Namespace) -> int: - if args.key_type == "ed25519": - secret = Ed25519Secret.generate() - print(f"Primary: {secret.identity()}") - print(f"Public: {secret.pubkey_hex()}") - print(f"Secret: {secret.seed_hex()} (32-byte seed)") - print() - print("Store the secret somewhere safe. Anyone with the seed can sign as this identity.") - else: + 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_from_flags(args.nsec, args.ed25519_seed) + signer = _signer(args) primary = signer.identity() payload = new_claim_payload(Identity.parse(subject), primary) return sign_claim(payload, signer) @@ -132,12 +192,12 @@ def cmd_verify_id(args: argparse.Namespace) -> int: def _resolve_primary_readonly(args: argparse.Namespace) -> Identity: if getattr(args, "primary", None): return Identity.parse(args.primary) - signer = signer_from_flags(args.nsec, args.ed25519_seed) + signer = _signer(args) return signer.identity() def cmd_sigchain_add(args: argparse.Namespace) -> int: - signer = signer_from_flags(args.nsec, args.ed25519_seed) + signer = _signer(args) primary = signer.identity() chain = sigchain.load_chain(primary) payload = new_add_payload( @@ -159,7 +219,7 @@ def cmd_sigchain_add(args: argparse.Namespace) -> int: def cmd_sigchain_revoke(args: argparse.Namespace) -> int: - signer = signer_from_flags(args.nsec, args.ed25519_seed) + signer = _signer(args) primary = signer.identity() chain = sigchain.load_chain(primary) payload = new_revoke_payload( @@ -217,6 +277,7 @@ def cmd_sigchain_export(args: argparse.Namespace) -> int: 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: @@ -228,8 +289,27 @@ def build_parser() -> argparse.ArgumentParser: 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) diff --git a/python/kez/keys.py b/python/kez/keys.py index e5d87ea..bddb6d3 100644 --- a/python/kez/keys.py +++ b/python/kez/keys.py @@ -87,6 +87,19 @@ class Ed25519Secret: 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() @@ -132,11 +145,18 @@ def verify_signature(payload, alg: str, key: Identity, sig_hex: str) -> bool: return False -def signer_from_flags(nsec: str | None, ed25519_seed: str | None): - if nsec and ed25519_seed: - raise ValueError("pass only one of --nsec or --ed25519-seed") +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) - raise ValueError("missing key: pass --nsec or --ed25519-seed") + if mnemonic: + return Ed25519Secret.from_mnemonic(mnemonic) + raise ValueError("missing key: pass --nsec, --ed25519-seed, or --mnemonic") diff --git a/python/kez/mnemonic.py b/python/kez/mnemonic.py new file mode 100644 index 0000000..64fc02f --- /dev/null +++ b/python/kez/mnemonic.py @@ -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 diff --git a/python/pyproject.toml b/python/pyproject.toml index 2d3f202..f15f6aa 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -5,6 +5,7 @@ description = "KEZ portable identity graph — Python implementation" requires-python = ">=3.10" dependencies = [ "cryptography>=42", + "mnemonic>=0.20", "zstandard>=0.22", ] diff --git a/python/requirements.txt b/python/requirements.txt index 0f9027e..ae72724 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -1,2 +1,3 @@ cryptography>=42 +mnemonic>=0.20 zstandard>=0.22 diff --git a/python/tests/test_mnemonic.py b/python/tests/test_mnemonic.py new file mode 100644 index 0000000..547ae1d --- /dev/null +++ b/python/tests/test_mnemonic.py @@ -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() From 3fdbdc9fcfdeff5ee4d85757507731b8dcf68d47 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Fri, 5 Jun 2026 18:14:52 -0600 Subject: [PATCH 5/8] feat(kez-chat/web): 12-word recovery phrase replaces hex seed in account flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the BIP-39 mnemonic surface (CLI + libs landed in 0058d9b / b0cc1a7) into the chat app's user-facing account flow. Match the same SHA-256 domain-tag derivation as Rust / Node / Python — a phrase generated in the browser verifies against the spec vectors in python/MNEMONIC-TEST-VECTORS.md byte-for-byte. • New lib/mnemonic.ts: browser-native helpers (generateMnemonic12, seedFromMnemonic, mnemonicFromSeed24, ed25519FromMnemonic, generateIdentityWithMnemonic, isValidMnemonic). Uses @scure/bip39 (same lib as Node impl) + the same domain tag "kez-bip39-12-v1". 12-word phrases by default; restore accepts 24-word too for parity with the CLI. • lib/identity-store.ts: StoredIdentity gains optional phrase_nonce + phrase_ciphertext, encrypted under the SAME PBKDF2-derived key as the seed (fresh nonce — AES-GCM reuse is fatal). unlockIdentity returns the phrase when present. New hasStoredPhrase() helper distinguishes "phrase exists but not accessible in this session" (biometric unlock) from "truly legacy hex-only account". • CreateAccount: generates via generateIdentityWithMnemonic. Step 2 now shows the 12 words in a numbered grid with a "copy all" button and a real ack checkbox before continuing. Step indicator updated to "2. Back up phrase". • Restore: was previously a stub that always threw "v0.1 limitation". Now actually works — accepts either a 12/24-word phrase OR a legacy 64-char hex seed (auto-detected), looks up the handle via /v1/by-primary, derives the seed, saves identity, unlocks, routes to /welcome. • Settings: "Reveal seed" → "Reveal phrase". Three-state output: - phrase in session → show 12 words - phrase stored but biometric session → tell user to passphrase- unlock to reveal - truly legacy → show hex seed with explanation • Welcome (onboarding): "Back up your recovery seed" step renders the phrase as a numbered grid when available, falls back to the hex block with a "Legacy 64-char hex" caption for pre-mnemonic accounts. Biometric unlock continues to surface only the seed (the phrase blob is encrypted under the passphrase-derived key, not the PRF-derived key) — documented in the Settings UX. Encrypting under PRF too is a v0.3 follow-up. Backwards compatible: existing accounts (which have only the seed-ciphertext) unlock fine; their phrase fields stay undefined; the UI falls back to the hex flow throughout. Co-Authored-By: Claude Opus 4.7 --- kez-chat/web/package-lock.json | 36 ++++-- kez-chat/web/package.json | 1 + kez-chat/web/src/lib/identity-store.ts | 60 ++++++++++ kez-chat/web/src/lib/mnemonic.ts | 111 +++++++++++++++++++ kez-chat/web/src/routes/CreateAccount.svelte | 59 +++++----- kez-chat/web/src/routes/Restore.svelte | 110 ++++++++++++------ kez-chat/web/src/routes/Settings.svelte | 38 ++++++- kez-chat/web/src/routes/Welcome.svelte | 59 +++++++--- 8 files changed, 383 insertions(+), 91 deletions(-) create mode 100644 kez-chat/web/src/lib/mnemonic.ts diff --git a/kez-chat/web/package-lock.json b/kez-chat/web/package-lock.json index 3293800..55812e0 100644 --- a/kez-chat/web/package-lock.json +++ b/kez-chat/web/package-lock.json @@ -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", diff --git a/kez-chat/web/package.json b/kez-chat/web/package.json index 0196881..913d2ca 100644 --- a/kez-chat/web/package.json +++ b/kez-chat/web/package.json @@ -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", diff --git a/kez-chat/web/src/lib/identity-store.ts b/kez-chat/web/src/lib/identity-store.ts index c12487a..0a29c93 100644 --- a/kez-chat/web/src/lib/identity-store.ts +++ b/kez-chat/web/src/lib/identity-store.ts @@ -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 { 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 { + const stored = await get(IDB_KEY); + return !!(stored?.phrase_ciphertext && stored?.phrase_nonce); +} + export async function loadStoredIdentityMeta(): Promise< Pick | 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 { 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, }; } diff --git a/kez-chat/web/src/lib/mnemonic.ts b/kez-chat/web/src/lib/mnemonic.ts new file mode 100644 index 0000000..8349b25 --- /dev/null +++ b/kez-chat/web/src/lib/mnemonic.ts @@ -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; + } +} diff --git a/kez-chat/web/src/routes/CreateAccount.svelte b/kez-chat/web/src/routes/CreateAccount.svelte index 76014c0..72bba95 100644 --- a/kez-chat/web/src/routes/CreateAccount.svelte +++ b/kez-chat/web/src/routes/CreateAccount.svelte @@ -1,17 +1,16 @@
-

Restore from seed

+

Restore account

-

- v0.1 limitation: the seed alone doesn't tell us which - handle to restore. For now this flow doesn't work end-to-end — we'll - add GET /v1/by-primary/<id> on the server in v0.2 - so the SPA can look up the handle from the public key. +

+ 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 {serverDomain ?? "the server"} + and unlock the account on this device.

{ e.preventDefault(); submit(); }} >
-
diff --git a/kez-chat/web/src/routes/Settings.svelte b/kez-chat/web/src/routes/Settings.svelte index 17222ef..15ec8fa 100644 --- a/kez-chat/web/src/routes/Settings.svelte +++ b/kez-chat/web/src/routes/Settings.svelte @@ -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 seed

+

Recovery phrase

- 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.

diff --git a/kez-chat/web/src/routes/Welcome.svelte b/kez-chat/web/src/routes/Welcome.svelte index 43829eb..6a77b30 100644 --- a/kez-chat/web/src/routes/Welcome.svelte +++ b/kez-chat/web/src/routes/Welcome.svelte @@ -23,9 +23,10 @@ let biometricAvailable = $state(false); let notifPerm = $state("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 @@
- +
  • {onboarding.seedAcked ? "✓" : "🔑"}
    -

    Back up your recovery seed

    +

    Back up your recovery phrase

    - This 32-byte seed is the only 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 only way to recover + your account. Lose them and it's gone forever — there's no + reset. Write them on paper.

    - {#if !seedRevealed && !onboarding.seedAcked} - + {#if !backupRevealed && !onboarding.seedAcked} + {/if} - {#if seedRevealed} + {#if backupRevealed}
    -

    {seedHex}

    + {#if backupKind === "phrase"} +
      + {#each backupText.split(" ") as word, i} +
    1. + {i + 1}. + {word} +
    2. + {/each} +
    + {:else} +

    {backupText}

    +

    + Legacy 64-char hex — accounts created from now on get a + 12-word phrase instead. +

    + {/if}
    - + {#if !onboarding.seedAcked} {/if} From 5ad47a917dbf035cefe3a7350639aa77cd653e9b Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Fri, 5 Jun 2026 22:43:16 -0600 Subject: [PATCH 6/8] feat(kez-chat/web): mirror local claims to chain-service sigchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user adds a claim, append an `add` event to their sigchain on the chain service (rust-sig-server); when they remove a claim, append a `revoke`. Implements SPEC.md §8 — the sigchain is now the canonical, verifiable record of what the user currently claims, not a per-claim field. • lib/sigchain-service.ts: new module that fetches the current chain to compute the next seq + prev hash, signs the event locally (the chain service never sees the seed), and POSTs. Returns a typed SigchainSyncResult so the caller can record the seq + status. • lib/kez.ts: sigchain event types + helpers (nextChainCursor, sigchainEventHash, signSigchainEvent, SignedSigchainEvent / SigchainOp types). Mirrors the Rust + Node + Python core surface. • lib/api.ts: getSigchain (GET full chain) + postSigchainEvent (POST one signed event) wrappers against the chain service. • lib/claims-store: StoredClaim gains chain_service, sigchain_seq, sigchain_status ("synced" | "error"), sigchain_error fields + setSigchainSync helper. • routes/AddClaim: on successful claim creation, fires an `add` to the chain service in the background; surfaces sync errors with a "Retry sync" button. • routes/Claims: a `revoke` is posted to the chain service first when the user removes a claim. Best-effort — if the service is unreachable, asks before dropping the local copy so the chain doesn't silently drift. Per-row "Sync to chain" button retries failed adds. Co-Authored-By: Claude Opus 4.7 --- kez-chat/web/src/lib/api.ts | 52 +++++++++- kez-chat/web/src/lib/claims-store.ts | 29 ++++++ kez-chat/web/src/lib/kez.ts | 104 +++++++++++++++++++- kez-chat/web/src/lib/sigchain-service.ts | 117 +++++++++++++++++++++++ kez-chat/web/src/routes/AddClaim.svelte | 74 +++++++++++++- kez-chat/web/src/routes/Claims.svelte | 98 ++++++++++++++++++- 6 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 kez-chat/web/src/lib/sigchain-service.ts diff --git a/kez-chat/web/src/lib/api.ts b/kez-chat/web/src/lib/api.ts index ff06f50..b73e5d2 100644 --- a/kez-chat/web/src/lib/api.ts +++ b/kez-chat/web/src/lib/api.ts @@ -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/`. 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 { + 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); +} diff --git a/kez-chat/web/src/lib/claims-store.ts b/kez-chat/web/src/lib/claims-store.ts index 333102c..40acdf4 100644 --- a/kez-chat/web/src/lib/claims-store.ts +++ b/kez-chat/web/src/lib/claims-store.ts @@ -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 { return (await get(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 { + 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 { const existing = await listClaims(); await set( diff --git a/kez-chat/web/src/lib/kez.ts b/kez-chat/web/src/lib/kez.ts index ac21362..393cd59 100644 --- a/kez-chat/web/src/lib/kez.ts +++ b/kez-chat/web/src/lib/kez.ts @@ -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:` 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; +} + +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` 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:` 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, + 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 // ───────────────────────────────────────────────────────────────────────────── diff --git a/kez-chat/web/src/lib/sigchain-service.ts b/kez-chat/web/src/lib/sigchain-service.ts new file mode 100644 index 0000000..e30935e --- /dev/null +++ b/kez-chat/web/src/lib/sigchain-service.ts @@ -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:` 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 { + const active = new Map(); + 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 { + 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 { + 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 = { 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 }; +} diff --git a/kez-chat/web/src/routes/AddClaim.svelte b/kez-chat/web/src/routes/AddClaim.svelte index 30761e5..740b655 100644 --- a/kez-chat/web/src/routes/AddClaim.svelte +++ b/kez-chat/web/src/routes/AddClaim.svelte @@ -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 }; } } @@ -452,6 +496,28 @@ Once you've published the proof on that channel, come back to the Claims page and mark it published.

    + + +
    + {#if sigchainSync.status === "pending"} +

    ⏳ Updating your sigchain on the chain service…

    + {:else if sigchainSync.status === "ok"} +

    + {#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} +

    + {:else if sigchainSync.status === "error"} +

    + ⚠ Couldn't update your sigchain on the chain service: + {sigchainSync.message}. The claim is saved locally — retry the + sigchain sync from the Claims page. +

    + {/if} +
    +
    Add another diff --git a/kez-chat/web/src/routes/Claims.svelte b/kez-chat/web/src/routes/Claims.svelte index 7195cba..1c0f2e7 100644 --- a/kez-chat/web/src/routes/Claims.svelte +++ b/kez-chat/web/src/routes/Claims.svelte @@ -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>(new Set()); + /** ids currently mid chain-service sync (add retry or revoke-on-delete). */ + let syncing = $state>(new Set()); /** Which claims have their details panel expanded. */ let expanded = $state>(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: {c.channel} · Signed: {c.envelope.payload.created_at}

    + {#if syncing.has(c.id)} +

    ⏳ Syncing with chain service…

    + {:else if c.sigchain_status === "synced"} +

    + ⛓ On your sigchain{#if c.sigchain_seq !== undefined} · seq {c.sigchain_seq}{/if} +

    + {:else if c.sigchain_status === "error"} +

    + ⚠ Not on your sigchain{#if c.sigchain_error} ({c.sigchain_error}){/if} +

    + {:else} +

    ⛓ Not yet on your sigchain

    + {/if} {#if c.last_verify}

    {c.last_verify.summary} @@ -208,9 +291,20 @@ Mark published {/if} + {#if c.sigchain_status !== "synced"} + + {/if} From aeba28d9e55b29c67d9104b351d8e20428ff82fa Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Fri, 5 Jun 2026 22:53:59 -0600 Subject: [PATCH 7/8] docs(rust,nodejs): expand TUTORIAL.md recovery-phrase section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the "Pick your primary key" → Option B block in both tutorials into a proper "Recovery phrases" mini-chapter: • Table comparing 24-word (256 bits, bijection) vs 12-word (128 bits, one-way SHA-256 derivation). • Decision guide — why someone would actually pick 12 over 24 (and vice versa). Explicitly: "save the phrase, not just the seed" for the 12-word case. • Wallet-incompatibility callout — KEZ phrases don't produce the same key as the same phrase in Ledger / MetaMask / Bitcoin wallets. Explains the two deliberate reasons (no BIP-39 PBKDF2, no BIP-32 derivation tree), and the inverse — KEZ phrases can't be used to extract funds from a hardware-wallet recovery so a malicious importer can't phish that direction either. • Concrete backup advice — pencil on paper, numbered words, fireproof storage, don't photograph it, don't cloud-sync it, don't split it, don't permute it. Calls out which password-manager patterns are OK vs not. • "Working with phrases later" — clean examples of `identity mnemonic` (no key derived) and `identity from-mnemonic` (recover an existing key), with the note that the recovered output is byte-for-byte identical to what `identity new` originally printed. Same content in both the Rust and Node tutorials, command examples adapted to each CLI invocation style. Co-Authored-By: Claude Opus 4.7 --- nodejs/TUTORIAL.md | 114 ++++++++++++++++++++++++++++++++++++++------- rust/TUTORIAL.md | 114 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 196 insertions(+), 32 deletions(-) diff --git a/nodejs/TUTORIAL.md b/nodejs/TUTORIAL.md index b66df3b..b00fcd2 100644 --- a/nodejs/TUTORIAL.md +++ b/nodejs/TUTORIAL.md @@ -97,8 +97,8 @@ A new nostr keypair: npm run cli -- identity new ``` -Or a new Ed25519 keypair, which comes with a 24-word BIP-39 phrase -alongside the hex seed (both are equivalent backups): +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 # 24-word @@ -114,25 +114,107 @@ Secret: 9e3f51… (32-byte seed) Mnemonic (24 words): "abandon ability able about above absent academy accident…" ``` -> **12 vs 24.** 24 words is fully round-trippable: phrase ↔ seed are -> bijective. 12 words is shorter to memorize, but the seed is derived -> from the phrase one-way (KEZ-specific SHA-256 step), so you cannot -> derive a 12-word phrase from a hex seed. Pick whichever you'll -> actually back up. - -You can also get just a phrase, or restore an existing one: - -```sh -npm run cli -- identity mnemonic # fresh 24 words -npm run cli -- identity mnemonic --words 12 # fresh 12 words -npm run cli -- identity from-mnemonic "abandon ability able …" # recover the key -``` - > **Save the backup.** Seed *or* phrase — at least one. Lose them both > and the identity is gone. There's no recovery flow. +### 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 (1–12 or 1–24) + 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 ` 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. diff --git a/rust/TUTORIAL.md b/rust/TUTORIAL.md index b2cb1dc..1f481eb 100644 --- a/rust/TUTORIAL.md +++ b/rust/TUTORIAL.md @@ -81,7 +81,8 @@ 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 # 24-word phrase (default) @@ -101,26 +102,107 @@ 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). -> **12 vs 24.** 24 words is fully round-trippable: phrase ↔ seed are -> bijective. 12 words is shorter to memorize, but the seed is derived -> from the phrase one-way (KEZ-specific SHA-256 step), so you cannot -> derive a 12-word phrase from a hex seed. Pick whichever you'll -> actually remember to back up. - -You can also get just a phrase without a key, or restore from a phrase -you wrote down earlier: - -```sh -kez identity mnemonic # print a fresh 24-word phrase -kez identity mnemonic --words 12 # 12-word -kez identity from-mnemonic "abandon ability able …" # recover the key -``` - > **Save the backup.** Seed *or* phrase — at least one. Lose them both > and the identity is gone. There's no recovery flow. +### 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 (1–12 or 1–24) + 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 ` 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. From d0e96c17fb685d42cac7ebbf59c8b003e3e4cbb2 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Fri, 5 Jun 2026 22:57:52 -0600 Subject: [PATCH 8/8] docs(python): add TUTORIAL.md mirroring rust/nodejs + link from READMEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the parallel tutorial set across all three implementations. Python now has the same friendly step-by-step walkthrough that the Rust and Node sides have had since the original tutorial commits. Python tutorial content mirrors the others 1:1, adapted for the Python invocation style (.venv/bin/python kez_cli.py …), plus: • Programmatic section uses Python imports (NostrSecret.from_nsec, sign_claim, default_registry, etc.) instead of the TS imports from the Node tutorial. • Same "Recovery phrases" mini-chapter as rust/nodejs — both 12-word AND 24-word are explained, with the entropy table, picking guide, hardware-wallet-incompatibility callout, concrete backup advice ("pencil + paper, numbered words, fireproof, don't split, don't permute"), and "Working with phrases later" examples (`identity mnemonic`, `identity from-mnemonic`). • Notes that `sigchain publish` isn't in the Python CLI yet (only add/revoke/show/export) — match the actual current surface; the JSONL the Python CLI produces is byte-compatible with Rust/Node, so users can build the chain in Python and publish via either of the other CLIs in the meantime. • Troubleshooting includes ModuleNotFoundError: kez (a Python- specific footgun when running outside the venv). • Links to ../rust/TUTORIAL.md and ../nodejs/TUTORIAL.md as parallel references throughout. python/README.md now opens with the same "New to KEZ? Read TUTORIAL.md" callout as the rust and nodejs READMEs do. Root README's quick-start blocks for each implementation now reference BOTH the impl README (reference) AND the impl TUTORIAL (step-by-step, on-ramp) instead of just the README. Co-Authored-By: Claude Opus 4.7 --- README.md | 10 +- python/README.md | 7 + python/TUTORIAL.md | 526 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 540 insertions(+), 3 deletions(-) create mode 100644 python/TUTORIAL.md diff --git a/README.md b/README.md index 1e3fc69..9aec743 100644 --- a/README.md +++ b/README.md @@ -61,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 @@ -70,7 +72,8 @@ 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 @@ -79,7 +82,8 @@ 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). +Full guide: [`python/README.md`](python/README.md) (reference) · +[`python/TUTORIAL.md`](python/TUTORIAL.md) (step-by-step). ### Sigchain storage server (optional) ```sh diff --git a/python/README.md b/python/README.md index b2f6be0..e3b75bc 100644 --- a/python/README.md +++ b/python/README.md @@ -39,6 +39,13 @@ python/ ## 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 diff --git a/python/TUTORIAL.md b/python/TUTORIAL.md new file mode 100644 index 0000000..43d2508 --- /dev/null +++ b/python/TUTORIAL.md @@ -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:** 10–15 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 (1–12 or 1–24) + 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 ` 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 ``."* 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.`, 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.`, not `` 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/.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 ` 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 "" # recover key + +# Sign a claim +.venv/bin/python kez_cli.py claim create --nsec +.venv/bin/python kez_cli.py claim create --ed25519-seed +.venv/bin/python kez_cli.py claim create --mnemonic "" +.venv/bin/python kez_cli.py claim create --nsec --format markdown --out file.md +.venv/bin/python kez_cli.py claim create --nsec --format compact +.venv/bin/python kez_cli.py claim dns --nsec # zone-file output + +# Verify +.venv/bin/python kez_cli.py verify id # live channel fetch +.venv/bin/python kez_cli.py verify file # local file + +# Sigchain +.venv/bin/python kez_cli.py sigchain add --nsec [--proof-url ] +.venv/bin/python kez_cli.py sigchain revoke --nsec +.venv/bin/python kez_cli.py sigchain show --nsec # your own +.venv/bin/python kez_cli.py sigchain show --primary # someone else's +.venv/bin/python kez_cli.py sigchain export --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 +` for the affected subjects, and ideally rotate to a new +primary by signing a "this primary is succeeded by " 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 — 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 ~30–80 + lines). + +That's the whole tutorial. Welcome to KEZ.