Merge branch 'mnemonics' — BIP-39 recovery phrases + chat-app chain mirror + Python impl
8 commits landing from the mnemonics branch.
CORE FEATURE — BIP-39 recovery phrases for Ed25519 identities, across
all three implementations (Rust, Node, Python) plus the chat-app web
client. Bit-perfect interop verified by 27 new crosstest scenarios.
• 0058d9b feat(rust,nodejs): BIP-39 mnemonic phrases for Ed25519
identities (kez-core libs + CLI: identity new --mnemonic-words,
identity mnemonic, identity from-mnemonic; --mnemonic accepted
anywhere --ed25519-seed is). 24-word = bijection with the 32-byte
seed; 12-word = SHA-256("kez-bip39-12-v1" || entropy) → seed,
one-way KEZ-specific derivation. 9 rust + 8 node mnemonic tests.
• b0cc1a7 feat(python,crosstest): mirror to Python (kez/mnemonic.py
using Trezor's `mnemonic` lib; 19 pytest tests; CLI surface match)
+ crosstest.sh gains "BIP-39 mnemonic interop" section with three
canonical test vectors checked across all three impls and 18 cross-
impl claim round-trips via --mnemonic. crosstest now passes 84/84.
• 3fdbdc9 feat(kez-chat/web): 12-word phrase replaces hex seed in
the chat-app account flow (browser-native lib/mnemonic.ts with the
same domain tag; numbered-grid display on create + onboarding;
restore accepts phrase or legacy hex; Settings reveals phrase when
available; identity-store gains optional encrypted phrase field,
backwards-compatible with pre-mnemonic accounts).
CHAT APP — chain-service mirror
• 5ad47a9 feat(kez-chat/web): when the user adds a claim, append an
`add` event to their sigchain on the chain service (rust-sig-server);
revoke on delete. Implements SPEC.md §8. Per-row "Sync to chain"
retry; ask-before-drop if the chain service is unreachable.
USER COMMITS (carried in this merge)
• 8789659 Add nostr chat notes, update favicon, add test.txt
• b1240c1 Add Python implementation and cross-test interop
DOCS
• aeba28d docs(rust,nodejs): expand TUTORIAL.md with the full
"Recovery phrases" mini-chapter — 12 vs 24 entropy table, picking
guide, hardware-wallet-incompatibility callout, backup hygiene
advice, "working with phrases later" examples.
• d0e96c1 docs(python): add python/TUTORIAL.md mirroring rust+nodejs
(was missing). All three impl tutorials are now in parity. Root
README points at each impl's README (reference) and TUTORIAL
(step-by-step) side-by-side.
Test totals across the repo after this merge:
Rust: 114 (was 99)
Node: 99 (was 91)
Python: 19 (new)
Crosstest: 84 scenarios (was 55)
This commit is contained in:
commit
ec44018507
7
.gitignore
vendored
7
.gitignore
vendored
@ -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
|
||||
|
||||
51
README.md
51
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
|
||||
@ -56,7 +61,9 @@ cargo test # 99 tests
|
||||
cargo install --path crates/kez-cli # → `kez` on PATH
|
||||
kez verify id github:jason
|
||||
```
|
||||
Full guide: [`rust/README.md`](rust/README.md).
|
||||
Full guide: [`rust/README.md`](rust/README.md) (reference) ·
|
||||
[`rust/TUTORIAL.md`](rust/TUTORIAL.md) (step-by-step, recommended
|
||||
for newcomers).
|
||||
|
||||
### Node.js
|
||||
```sh
|
||||
@ -65,7 +72,18 @@ npm install
|
||||
npm test # 91 tests
|
||||
npm run cli -- verify id github:jason
|
||||
```
|
||||
Full guide: [`nodejs/README.md`](nodejs/README.md).
|
||||
Full guide: [`nodejs/README.md`](nodejs/README.md) (reference) ·
|
||||
[`nodejs/TUTORIAL.md`](nodejs/TUTORIAL.md) (step-by-step).
|
||||
|
||||
### Python
|
||||
```sh
|
||||
cd python
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/python kez_cli.py identity new
|
||||
```
|
||||
Full guide: [`python/README.md`](python/README.md) (reference) ·
|
||||
[`python/TUTORIAL.md`](python/TUTORIAL.md) (step-by-step).
|
||||
|
||||
### Sigchain storage server (optional)
|
||||
```sh
|
||||
@ -81,27 +99,20 @@ Full guide: [`rust-sig-server/README.md`](rust-sig-server/README.md).
|
||||
./crosstest.sh
|
||||
```
|
||||
|
||||
Runs 19 scenarios that swap implementations at the artifact boundary:
|
||||
Runs 55 scenarios that swap implementations at the artifact boundary:
|
||||
|
||||
| # | Scenario |
|
||||
| # | Scenarios |
|
||||
|---|---|
|
||||
| 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).
|
||||
|
||||
|
||||
241
crosstest.sh
241
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/<safe-primary>.jsonl. Both CLIs
|
||||
# read/write the same paths, so we can build a chain in one and inspect it
|
||||
@ -289,6 +360,176 @@ if grep -q "Length: 2 event(s)" "$TMP/sc_f.out"; then ok; else
|
||||
fi
|
||||
rm -f "$SC_ED_FILE"
|
||||
|
||||
# ── Python sigchain interop ─────────────────────────────────────────────────
|
||||
# Build chains with Python and inspect them from each peer (and vice versa),
|
||||
# over the same ~/.kez/sigchains state files. Uses fresh keys to avoid
|
||||
# colliding with the scenarios above.
|
||||
printf "%sPython interop (sigchains):%s\n" "$YELLOW" "$RESET"
|
||||
|
||||
PY_NOSTR_OUT="$("${PYTHON_CLI[@]}" identity new)"
|
||||
PY_NSEC="$(printf '%s\n' "$PY_NOSTR_OUT" | extract_nsec /dev/stdin)"
|
||||
PY_NOSTR_PRIMARY="$(printf '%s\n' "$PY_NOSTR_OUT" | sed -n 's/^Primary:[[:space:]]*//p' | head -1)"
|
||||
PY_NOSTR_FILE="$(chain_file_for "$PY_NOSTR_PRIMARY")"
|
||||
|
||||
PY_ED_OUT="$("${PYTHON_CLI[@]}" identity new --key-type ed25519)"
|
||||
PY_SEED="$(printf '%s\n' "$PY_ED_OUT" | extract_ed25519_seed /dev/stdin)"
|
||||
PY_ED_PRIMARY="$(printf '%s\n' "$PY_ED_OUT" | sed -n 's/^Primary:[[:space:]]*//p' | head -1)"
|
||||
PY_ED_FILE="$(chain_file_for "$PY_ED_PRIMARY")"
|
||||
|
||||
rm -f "$PY_NOSTR_FILE" "$PY_ED_FILE"
|
||||
|
||||
# Python builds a nostr chain; each peer must see all 3 events incl the revoke.
|
||||
"${PYTHON_CLI[@]}" sigchain add github:jason --nsec "$PY_NSEC" > /dev/null
|
||||
"${PYTHON_CLI[@]}" sigchain add dns:jason.example --nsec "$PY_NSEC" > /dev/null
|
||||
"${PYTHON_CLI[@]}" sigchain revoke github:jason --nsec "$PY_NSEC" > /dev/null
|
||||
for peer in node rust; do
|
||||
scenario "py nostr chain ⇒ $peer sigchain show"
|
||||
run_cli "$peer" sigchain show --primary "$PY_NOSTR_PRIMARY" > "$TMP/psc.out" 2>&1
|
||||
if grep -q "Length: 3 event(s)" "$TMP/psc.out" \
|
||||
&& grep -q "op=revoke subject=github:jason" "$TMP/psc.out"; then
|
||||
ok
|
||||
else
|
||||
bad "py→$peer sigchain show" "see $TMP/psc.out"
|
||||
cat "$TMP/psc.out" >&2
|
||||
fi
|
||||
done
|
||||
rm -f "$PY_NOSTR_FILE"
|
||||
|
||||
# Each peer builds a nostr chain; Python must see all 3 events.
|
||||
for peer in node rust; do
|
||||
rm -f "$PY_NOSTR_FILE"
|
||||
run_cli "$peer" sigchain add github:jason --nsec "$PY_NSEC" > /dev/null
|
||||
run_cli "$peer" sigchain add dns:jason.example --nsec "$PY_NSEC" > /dev/null
|
||||
run_cli "$peer" sigchain revoke github:jason --nsec "$PY_NSEC" > /dev/null
|
||||
scenario "$peer nostr chain ⇒ py sigchain show"
|
||||
"${PYTHON_CLI[@]}" sigchain show --primary "$PY_NOSTR_PRIMARY" > "$TMP/psc2.out" 2>&1
|
||||
if grep -q "Length: 3 event(s)" "$TMP/psc2.out" \
|
||||
&& grep -q "op=revoke subject=github:jason" "$TMP/psc2.out"; then
|
||||
ok
|
||||
else
|
||||
bad "$peer→py sigchain show" "see $TMP/psc2.out"
|
||||
cat "$TMP/psc2.out" >&2
|
||||
fi
|
||||
rm -f "$PY_NOSTR_FILE"
|
||||
done
|
||||
|
||||
# JSONL byte parity: Python and each peer must export the same on-disk chain
|
||||
# to byte-identical JSONL.
|
||||
"${PYTHON_CLI[@]}" sigchain add github:jason --nsec "$PY_NSEC" > /dev/null
|
||||
"${PYTHON_CLI[@]}" sigchain add dns:jason.example --nsec "$PY_NSEC" > /dev/null
|
||||
for peer in node rust; do
|
||||
scenario "py JSONL == $peer JSONL for same chain"
|
||||
"${PYTHON_CLI[@]}" sigchain export --nsec "$PY_NSEC" --format jsonl > "$TMP/pj_py.jsonl"
|
||||
run_cli "$peer" sigchain export --nsec "$PY_NSEC" --format jsonl > "$TMP/pj_peer.jsonl"
|
||||
if diff -q "$TMP/pj_py.jsonl" "$TMP/pj_peer.jsonl" > /dev/null; then
|
||||
ok
|
||||
else
|
||||
bad "py/$peer JSONL parity" "exported bytes differ"
|
||||
diff "$TMP/pj_py.jsonl" "$TMP/pj_peer.jsonl" | head -20 >&2
|
||||
fi
|
||||
done
|
||||
rm -f "$PY_NOSTR_FILE"
|
||||
|
||||
# Python ed25519 chain ⇒ each peer validates.
|
||||
"${PYTHON_CLI[@]}" sigchain add github:jason --ed25519-seed "$PY_SEED" > /dev/null
|
||||
"${PYTHON_CLI[@]}" sigchain add dns:jason.example --ed25519-seed "$PY_SEED" > /dev/null
|
||||
for peer in node rust; do
|
||||
scenario "py ed25519 chain ⇒ $peer sigchain show"
|
||||
run_cli "$peer" sigchain show --primary "$PY_ED_PRIMARY" > "$TMP/pse.out" 2>&1
|
||||
if grep -q "Length: 2 event(s)" "$TMP/pse.out"; then ok; else
|
||||
bad "py→$peer ed25519 chain" "$peer did not see all events"
|
||||
cat "$TMP/pse.out" >&2
|
||||
fi
|
||||
done
|
||||
rm -f "$PY_ED_FILE"
|
||||
|
||||
# ── BIP-39 Mnemonic interop ─────────────────────────────────────────────────
|
||||
# 12- and 24-word phrases must derive identical Ed25519 keys across all
|
||||
# implementations, and a claim signed with --mnemonic in one impl must
|
||||
# verify in the others. See python/MNEMONIC-TEST-VECTORS.md for the
|
||||
# definitive ground-truth vectors.
|
||||
printf "%sBIP-39 mnemonic interop:%s\n" "$YELLOW" "$RESET"
|
||||
|
||||
# Canonical test vectors. Public keys are the expected outputs that all
|
||||
# three implementations MUST agree on byte-for-byte. If any of these
|
||||
# values change, an implementation has a derivation bug.
|
||||
MNEMO_P24="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"
|
||||
MNEMO_PUB_24="3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
|
||||
MNEMO_P12="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
MNEMO_PUB_12="9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70"
|
||||
MNEMO_P12B="legal winner thank year wave sausage worth useful legal winner thank yellow"
|
||||
MNEMO_PUB_12B="cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03"
|
||||
|
||||
# Probe: does the Python CLI know about `identity from-mnemonic` yet?
|
||||
PY_HAS_MNEMONIC=0
|
||||
if [[ -x "$PYTHON_VENV" ]]; then
|
||||
if "${PYTHON_CLI[@]}" identity from-mnemonic "$MNEMO_P12" 2>/dev/null \
|
||||
| grep -q "^Public:"; then
|
||||
PY_HAS_MNEMONIC=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Helper: assert the impl derives the expected pubkey from a phrase.
|
||||
assert_pubkey() {
|
||||
local impl="$1" phrase="$2" expected="$3" title="$4"
|
||||
scenario "$title"
|
||||
local actual
|
||||
actual=$(run_cli "$impl" identity from-mnemonic "$phrase" 2>/dev/null \
|
||||
| awk -F': *' '/^Public:/ {print $2; exit}')
|
||||
if [[ "$actual" == "$expected" ]]; then ok; else
|
||||
bad "$title" "expected pubkey $expected, got $actual"
|
||||
fi
|
||||
}
|
||||
|
||||
# Vector matches per impl.
|
||||
for impl in rust node; do
|
||||
assert_pubkey "$impl" "$MNEMO_P24" "$MNEMO_PUB_24" "$impl: V1 24-word vector derives expected pubkey"
|
||||
assert_pubkey "$impl" "$MNEMO_P12" "$MNEMO_PUB_12" "$impl: V2 12-word vector derives expected pubkey"
|
||||
assert_pubkey "$impl" "$MNEMO_P12B" "$MNEMO_PUB_12B" "$impl: V3 12-word vector derives expected pubkey"
|
||||
done
|
||||
if [[ "$PY_HAS_MNEMONIC" -eq 1 ]]; then
|
||||
assert_pubkey py "$MNEMO_P24" "$MNEMO_PUB_24" "py: V1 24-word vector derives expected pubkey"
|
||||
assert_pubkey py "$MNEMO_P12" "$MNEMO_PUB_12" "py: V2 12-word vector derives expected pubkey"
|
||||
assert_pubkey py "$MNEMO_P12B" "$MNEMO_PUB_12B" "py: V3 12-word vector derives expected pubkey"
|
||||
else
|
||||
printf " %sskip%s %s\n" "$YELLOW" "$RESET" \
|
||||
"py vector checks (python CLI lacks identity from-mnemonic — port still in flight)"
|
||||
fi
|
||||
|
||||
# Cross-impl claim signing with --mnemonic. Each impl signs, each other
|
||||
# verifies. Uses the V3 phrase because it has non-trivial entropy.
|
||||
for fmt in json compact markdown; do
|
||||
claim_roundtrip "rust mnemonic ($fmt) ⇒ node verify" rust node "$fmt" --mnemonic "$MNEMO_P12B"
|
||||
claim_roundtrip "node mnemonic ($fmt) ⇒ rust verify" node rust "$fmt" --mnemonic "$MNEMO_P12B"
|
||||
if [[ "$PY_HAS_MNEMONIC" -eq 1 ]]; then
|
||||
claim_roundtrip "py mnemonic ($fmt) ⇒ rust verify" py rust "$fmt" --mnemonic "$MNEMO_P12B"
|
||||
claim_roundtrip "rust mnemonic ($fmt) ⇒ py verify" rust py "$fmt" --mnemonic "$MNEMO_P12B"
|
||||
claim_roundtrip "py mnemonic ($fmt) ⇒ node verify" py node "$fmt" --mnemonic "$MNEMO_P12B"
|
||||
claim_roundtrip "node mnemonic ($fmt) ⇒ py verify" node py "$fmt" --mnemonic "$MNEMO_P12B"
|
||||
fi
|
||||
done
|
||||
if [[ "$PY_HAS_MNEMONIC" -ne 1 ]]; then
|
||||
printf " %sskip%s %s\n" "$YELLOW" "$RESET" \
|
||||
"py mnemonic claim round-trips (port still in flight)"
|
||||
fi
|
||||
|
||||
# Bijection sanity: 24-word phrase ⇄ seed must be exact. Each impl must
|
||||
# produce the canonical phrase from a known 32-byte seed via the
|
||||
# mnemonic-from-seed path (we drive it indirectly via the printed output
|
||||
# of `identity from-mnemonic`).
|
||||
scenario "24-word phrase is canonical form of its seed (rust)"
|
||||
got=$("${RUST_CLI[@]}" identity from-mnemonic "$MNEMO_P24" 2>/dev/null \
|
||||
| awk -F': *' '/^Mnemonic .24 words/ { match($0, /"[^"]+"/); print substr($0, RSTART+1, RLENGTH-2); exit }')
|
||||
if [[ "$got" == "$MNEMO_P24" ]]; then ok; else
|
||||
bad "rust canonical-24" "round-trip phrase differs"
|
||||
fi
|
||||
scenario "24-word phrase is canonical form of its seed (node)"
|
||||
got=$("${NODE_CLI[@]}" identity from-mnemonic "$MNEMO_P24" 2>/dev/null \
|
||||
| awk -F': *' '/^Mnemonic .24 words/ { match($0, /"[^"]+"/); print substr($0, RSTART+1, RLENGTH-2); exit }')
|
||||
if [[ "$got" == "$MNEMO_P24" ]]; then ok; else
|
||||
bad "node canonical-24" "round-trip phrase differs"
|
||||
fi
|
||||
|
||||
printf "\n"
|
||||
if [[ $FAIL -eq 0 ]]; then
|
||||
printf "%sAll %d scenarios passed.%s\n" "$GREEN" "$PASS" "$RESET"
|
||||
|
||||
267
kez-chat/web/NOSTR-CHAT.md
Normal file
267
kez-chat/web/NOSTR-CHAT.md
Normal file
@ -0,0 +1,267 @@
|
||||
# Nostr Chat
|
||||
|
||||
How the `nostr` branch carries kez-chat messages over Nostr relays instead
|
||||
of the kez-chat server inbox — without changing the identity model or the
|
||||
end-to-end encryption.
|
||||
|
||||
> **One-line summary:** the chat transport is swapped from an HTTP/SSE server
|
||||
> inbox to Nostr relays. Everything else — your ed25519 identity, the
|
||||
> `SealedEnvelope` encryption, the UI — is untouched. Nostr only moves bytes.
|
||||
|
||||
---
|
||||
|
||||
## 1. The core idea
|
||||
|
||||
kez-chat already had its own end-to-end encryption. A message is sealed by
|
||||
`crypto.ts` into a **`SealedEnvelope`** (AES-256-GCM body + an ed25519 sender
|
||||
signature, keyed off the user's ed25519 identity). The original transport
|
||||
(`messages.ts`) just `POST`s that opaque envelope to the server's
|
||||
`/v1/messages` endpoint and reads it back from `/v1/inbox`.
|
||||
|
||||
Because the envelope is already encrypted and self-authenticating, the
|
||||
transport is interchangeable. The Nostr build keeps the exact same envelope
|
||||
and changes only **how it travels**:
|
||||
|
||||
```
|
||||
┌─────────────────────── unchanged ───────────────────────┐
|
||||
plaintext ─► sealMessage() ─► SealedEnvelope ──► [ TRANSPORT ] ──► peer
|
||||
│ (ed25519/x25519 + AES-GCM, crypto.ts) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
|
||||
server transport: POST /v1/messages ┐
|
||||
GET /v1/inbox (poll+SSE) ┘ ← kez-chat server + SQLite
|
||||
|
||||
nostr transport: publish event to relays ┐
|
||||
subscribe by #h tag ┘ ← public Nostr relays
|
||||
```
|
||||
|
||||
The `SealedEnvelope` is the **"extra layer of encryption using our own key"** —
|
||||
it exists independently of Nostr and is what actually protects the message
|
||||
body. Nostr is a dumb pipe underneath it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Identity: bridging ed25519 onto Nostr
|
||||
|
||||
KEZ identities are **ed25519**. Nostr signs events with **secp256k1**
|
||||
(Schnorr). The two curves cannot be cross-derived — you cannot turn someone's
|
||||
ed25519 public key into "their" Nostr public key. The bridge
|
||||
(`nostr-id.ts`) solves this in two halves:
|
||||
|
||||
### 2a. Signing key (derived from your own seed)
|
||||
|
||||
Every account needs a secp256k1 key to sign Nostr events (relays reject
|
||||
unsigned events). We derive it deterministically from the user's 32-byte
|
||||
ed25519 seed:
|
||||
|
||||
```
|
||||
nostrSecret = HKDF-SHA256(
|
||||
ikm = ed25519_seed,
|
||||
salt = "kez-chat:nostr-signkey",
|
||||
info = "v1",
|
||||
len = 32,
|
||||
)
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
- **Deterministic** — the same account always produces the same Nostr signer,
|
||||
on any device, with no extra storage.
|
||||
- **Internal** — it is a pure transport credential. It is *not* the user's
|
||||
real Nostr account, it is never surfaced in the UI, and its public key is
|
||||
never advertised or used for addressing.
|
||||
- **One-way** — HKDF means the Nostr key reveals nothing about the ed25519
|
||||
seed (the actual secret).
|
||||
|
||||
### 2b. Addressing (derived from the recipient's *public* primary)
|
||||
|
||||
Since we can't compute a recipient's Nostr pubkey, we don't address events to
|
||||
a pubkey at all. Instead each event carries a routing label derived from the
|
||||
recipient's **public** ed25519 primary (which any sender can look up in the
|
||||
directory):
|
||||
|
||||
```
|
||||
addr = HKDF-SHA256(
|
||||
ikm = utf8(recipient_primary), // e.g. "ed25519:abc123…"
|
||||
salt = "kez-chat:nostr-addr",
|
||||
info = "v1",
|
||||
len = 32,
|
||||
) // → 32-byte hex
|
||||
```
|
||||
|
||||
The sender stamps this on the event as a tag; the recipient subscribes for
|
||||
events carrying their own `addr`. Both sides compute the same value from the
|
||||
same public primary — the sender from a directory lookup, the recipient from
|
||||
their own identity. Using a hash (rather than the raw primary) keeps the
|
||||
plaintext primary out of a relay-queryable tag.
|
||||
|
||||
---
|
||||
|
||||
## 3. The event format
|
||||
|
||||
| Field | Value |
|
||||
|--------------|--------------------------------------------------------------------|
|
||||
| `kind` | `4242` (`KEZ_DM_KIND`) — a *regular* kind (1000–9999), so relays persist it |
|
||||
| `tags` | `[["h", <recipient addr hex>]]` — `h` = `ADDR_TAG`, the routing label |
|
||||
| `content` | `JSON.stringify(SealedEnvelope)` — our encrypted, signed envelope |
|
||||
| `pubkey` | the sender's derived secp256k1 pubkey (transport credential) |
|
||||
| `sig` | Schnorr signature over the event (so relays accept it) |
|
||||
| `created_at` | unix seconds |
|
||||
|
||||
A subscriber filters with:
|
||||
|
||||
```json
|
||||
{ "kinds": [4242], "#h": ["<my addr hex>"], "since": <unix-seconds> }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Message lifecycle
|
||||
|
||||
### Sending (`nostr-transport.ts` → `sendMessage`)
|
||||
|
||||
1. Resolve the recipient handle → ed25519 primary via the directory
|
||||
(`/v1/u/<handle>` — still served by the kez-chat server).
|
||||
2. `sealMessage(...)` → `SealedEnvelope` (identical to the server transport).
|
||||
3. Build an event: `kind 4242`, tag `["h", addrFromPrimary(recipientPrimary)]`,
|
||||
`content = JSON.stringify(envelope)`.
|
||||
4. Sign it with `nostrSecretFromSeed(senderSeed)` (`finalizeEvent`).
|
||||
5. `pool.publish(RELAYS, event)` — succeeds if **at least one** relay accepts.
|
||||
If every relay rejects, `sendMessage` throws `"no relay accepted the message"`.
|
||||
|
||||
### Receiving (`streamInbox` + `pollInbox`)
|
||||
|
||||
The global `inbox-service` runs both, exactly as it did for the server
|
||||
transport:
|
||||
|
||||
- **`streamInbox`** opens a live subscription
|
||||
(`pool.subscribeMany`) filtered on the user's own `addr`. Each event fires
|
||||
`onevent`; after the relays finish replaying stored events, `oneose` flips
|
||||
the UI status to **live**.
|
||||
- **`pollInbox`** is a one-shot `pool.querySync` used as a heartbeat catch-up
|
||||
(every 30s and on startup), so nothing is missed if the subscription drops.
|
||||
|
||||
Each incoming event is:
|
||||
|
||||
1. De-duplicated by event id (see §5).
|
||||
2. Parsed: `content` → `SealedEnvelope`.
|
||||
3. Decrypted by the **unchanged** `decrypt()` (`crypto.ts` → `openMessage`),
|
||||
which verifies the sender's ed25519 signature and AES-GCM-decrypts the body.
|
||||
4. Appended to the local conversation store and rendered.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cursors & de-duplication
|
||||
|
||||
Relays can resend events, and two transports (live sub + heartbeat poll) can
|
||||
deliver the same event. Both are made idempotent with per-handle
|
||||
`localStorage` state:
|
||||
|
||||
- **`kez-chat:nostr:since:<handle>`** — the relay `since` filter (unix
|
||||
seconds). Advances as events arrive, so reloads resume instead of
|
||||
re-fetching. A fresh device defaults to `now − 7 days` to catch recent
|
||||
history.
|
||||
- **`kez-chat:nostr:seen:<handle>`** — a capped set (last `500` ids) of
|
||||
processed event ids. An id already in the set is skipped.
|
||||
|
||||
### Mapping to `seq`
|
||||
|
||||
The conversation store and the notification watermark were built around a
|
||||
monotonic server `seq`. Nostr has no per-recipient sequence, so the transport
|
||||
synthesizes one from the event's timestamp:
|
||||
|
||||
```
|
||||
seq = created_at * 1000 + (parseInt(event.id.slice(0,3), 16) % 1000)
|
||||
```
|
||||
|
||||
This is monotonic-by-time across sessions (so the unread/notify watermark
|
||||
keeps working) and spreads messages within the same one-second granularity
|
||||
using the event id, avoiding collisions between distinct same-second messages.
|
||||
|
||||
---
|
||||
|
||||
## 6. Configuration
|
||||
|
||||
Build-time, via Vite env (`.env` / `.env.local`):
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|----------------------|-----------------------------------------------------------|------------------------------------------|
|
||||
| `VITE_TRANSPORT` | `server` | `server` or `nostr` — which pipe to use |
|
||||
| `VITE_NOSTR_RELAYS` | `wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net` | comma-separated relay list |
|
||||
|
||||
> The **code default is `server`**, so `main` and other branches are
|
||||
> unaffected. This branch ships a committed `.env` that sets
|
||||
> `VITE_TRANSPORT=nostr`.
|
||||
|
||||
### The facade (`transport.ts`)
|
||||
|
||||
`inbox-service` and the `Messages` route import `sendMessage` / `pollInbox` /
|
||||
`streamInbox` / `decrypt` from `transport.ts`, which re-exports either the
|
||||
server (`messages.ts`) or Nostr (`nostr-transport.ts`) implementation based on
|
||||
`VITE_TRANSPORT`. Neither consumer knows which pipe is active. Switching
|
||||
transports is a one-line env change + rebuild.
|
||||
|
||||
---
|
||||
|
||||
## 7. Privacy model
|
||||
|
||||
- **Confidential:** the message *body*. It is AES-256-GCM encrypted inside the
|
||||
`SealedEnvelope` before it ever reaches a relay. Relays (and anyone reading
|
||||
them) see only ciphertext.
|
||||
- **Visible to relays:** the social-graph metadata. The envelope carries the
|
||||
sender's primary (`from`) and recipient handle (`to`) in its JSON, and the
|
||||
`#h` tag routes by recipient. A relay can therefore observe who is talking to
|
||||
whom and when — **the same metadata the kez-chat server saw with the old
|
||||
transport.** This is parity, not a regression.
|
||||
- **Authenticity:** every envelope is signed by the sender's ed25519 key and
|
||||
verified on decrypt, so a relay (or anyone) cannot forge or tamper with a
|
||||
message without detection.
|
||||
|
||||
If hiding the social graph from relays becomes a requirement, the next step is
|
||||
an outer NIP-44 wrap around the envelope. It was deliberately left out to keep
|
||||
this a clean, minimal transport swap.
|
||||
|
||||
---
|
||||
|
||||
## 8. Known limitations
|
||||
|
||||
- **No backfill of old server-era messages.** Messages delivered over the old
|
||||
server transport were never published to relays and cannot be fetched over
|
||||
Nostr. Locally cached history (IndexedDB) still renders, but it is a stale
|
||||
snapshot, not relay-backed.
|
||||
- **Relay acceptance varies.** Some public relays restrict event kinds or rate.
|
||||
If all configured relays reject `kind 4242`, sends fail loudly. Run your own
|
||||
relay or pick permissive ones via `VITE_NOSTR_RELAYS` for reliability.
|
||||
- **Eventual, not guaranteed, delivery.** Delivery depends on the recipient and
|
||||
sender sharing at least one reachable relay. More relays = more resilience,
|
||||
more metadata exposure.
|
||||
- **Same-second ordering** is approximated (see §5), not exact.
|
||||
|
||||
---
|
||||
|
||||
## 9. File map
|
||||
|
||||
| File | Role |
|
||||
|----------------------------|-------------------------------------------------------------|
|
||||
| `src/lib/crypto.ts` | **Unchanged.** The `SealedEnvelope` E2E layer (our own key).|
|
||||
| `src/lib/nostr-id.ts` | Derive the secp256k1 signing key; compute recipient `addr`. |
|
||||
| `src/lib/nostr-transport.ts` | Nostr `send`/`poll`/`stream`; cursor + dedupe. |
|
||||
| `src/lib/transport.ts` | Facade selecting server vs nostr via `VITE_TRANSPORT`. |
|
||||
| `src/lib/messages.ts` | **Unchanged.** The original server transport. |
|
||||
| `src/lib/inbox-service.svelte.ts` | Imports from the facade; otherwise unchanged. |
|
||||
| `src/routes/Messages.svelte` | Imports `sendMessage` from the facade. |
|
||||
| `.env` | Flips this branch to `VITE_TRANSPORT=nostr`. |
|
||||
|
||||
---
|
||||
|
||||
## 10. How to verify it's really on Nostr
|
||||
|
||||
Open DevTools → **Network → WS** on the running app:
|
||||
|
||||
- You should see live WebSocket connections to the configured relays
|
||||
(`wss://relay.damus.io`, etc.).
|
||||
- Sending a message emits an outgoing `["EVENT", …]` frame; receiving arrives
|
||||
as an incoming event frame.
|
||||
- You should **not** see `POST /v1/messages` or an SSE connection to
|
||||
`/v1/inbox/<handle>/stream` (those were the server transport).
|
||||
- The only remaining server call is the directory lookup `GET /v1/u/<handle>`.
|
||||
36
kez-chat/web/package-lock.json
generated
36
kez-chat/web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"@noble/curves": "^1.6.0",
|
||||
"@noble/hashes": "^1.5.0",
|
||||
"@scure/base": "^1.1.9",
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"canonicalize": "^2.0.0",
|
||||
"emoji-picker-element": "^1.29.1",
|
||||
"idb-keyval": "^6.2.1",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 535 B After Width: | Height: | Size: 504 B |
@ -3,7 +3,7 @@
|
||||
|
||||
import { ed25519 } from "@noble/curves/ed25519";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import type { SignedRegistration } from "./kez.js";
|
||||
import type { SignedRegistration, SignedSigchainEvent } from "./kez.js";
|
||||
|
||||
export interface HandleResponse {
|
||||
handle: string;
|
||||
@ -118,3 +118,53 @@ export async function register(
|
||||
});
|
||||
return unwrap(resp);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Chain service (sigchain storage server)
|
||||
//
|
||||
// `sigchainUrl` is the per-user base URL the chat server hands back in a
|
||||
// handle lookup (`HandleResponse.sigchain_url`), e.g.
|
||||
// `https://sig.kez.lat/v1/sigchains/ed25519/<hex>`. It's a different origin
|
||||
// than the chat server, so these talk to it directly (it sends permissive
|
||||
// CORS). The signatures are the source of truth — the chain service just
|
||||
// stores them.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the full sigchain for a user as an ordered list of signed events.
|
||||
* The chain service returns `application/jsonl` (one envelope per line);
|
||||
* an unknown/empty chain yields an empty list.
|
||||
*/
|
||||
export async function getSigchain(
|
||||
sigchainUrl: string,
|
||||
): Promise<SignedSigchainEvent[]> {
|
||||
const resp = await fetch(sigchainUrl);
|
||||
if (resp.status === 404) return [];
|
||||
if (!resp.ok) {
|
||||
throw new ApiError(resp.status, `getSigchain → HTTP ${resp.status}`);
|
||||
}
|
||||
const text = await resp.text();
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line) as SignedSigchainEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one signed event to the user's sigchain on the chain service.
|
||||
* The server re-runs the full integrity check (tag, primary, seq, prev,
|
||||
* signature) and returns the recorded `{ seq, hash }` on success.
|
||||
*/
|
||||
export async function postSigchainEvent(
|
||||
sigchainUrl: string,
|
||||
event: SignedSigchainEvent,
|
||||
): Promise<{ seq: number; hash: string }> {
|
||||
const base = sigchainUrl.replace(/\/$/, "");
|
||||
const resp = await fetch(`${base}/events`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
return unwrap(resp);
|
||||
}
|
||||
|
||||
@ -17,8 +17,25 @@ export interface StoredClaim {
|
||||
notes?: string;
|
||||
/** Latest verification result, if we've checked. */
|
||||
last_verify?: VerifyResult;
|
||||
// ── Chain-service mirror (sigchain) ──
|
||||
/** Chain-service base URL this claim was mirrored to (its sigchain URL). */
|
||||
chain_service?: string;
|
||||
/** Sequence number of the sigchain `add` event for this claim. */
|
||||
sigchain_seq?: number;
|
||||
/** Sync state of the sigchain mirror. */
|
||||
sigchain_status?: "synced" | "error";
|
||||
/** Error detail when sigchain_status === "error". */
|
||||
sigchain_error?: string;
|
||||
}
|
||||
|
||||
/** Fields the caller may patch after a chain-service sync attempt. */
|
||||
export type SigchainSyncPatch = Partial<
|
||||
Pick<
|
||||
StoredClaim,
|
||||
"chain_service" | "sigchain_seq" | "sigchain_status" | "sigchain_error"
|
||||
>
|
||||
>;
|
||||
|
||||
export async function listClaims(): Promise<StoredClaim[]> {
|
||||
return (await get<StoredClaim[]>(KEY)) ?? [];
|
||||
}
|
||||
@ -50,6 +67,18 @@ export async function setVerifyResult(
|
||||
}
|
||||
}
|
||||
|
||||
/** Record the outcome of a chain-service (sigchain) sync for a claim. */
|
||||
export async function setSigchainSync(
|
||||
id: string,
|
||||
patch: SigchainSyncPatch,
|
||||
): Promise<void> {
|
||||
const existing = await listClaims();
|
||||
const target = existing.find((c) => c.id === id);
|
||||
if (!target) return;
|
||||
Object.assign(target, patch);
|
||||
await set(KEY, existing);
|
||||
}
|
||||
|
||||
export async function removeClaim(id: string): Promise<void> {
|
||||
const existing = await listClaims();
|
||||
await set(
|
||||
|
||||
@ -33,6 +33,12 @@ interface StoredIdentity {
|
||||
salt: string; // hex, 16 bytes
|
||||
nonce: string; // hex, 12 bytes
|
||||
ciphertext: string; // hex; AES-GCM(seed) under PBKDF2(passphrase)
|
||||
// Optional: encrypted 12-word recovery phrase (added in the mnemonic
|
||||
// rollout). New accounts have both; pre-mnemonic accounts have only
|
||||
// the seed. Encrypted under the SAME PBKDF2 key as the seed; uses its
|
||||
// own nonce because AES-GCM reuse is unsafe.
|
||||
phrase_nonce?: string; // hex, 12 bytes
|
||||
phrase_ciphertext?: string; // hex; AES-GCM(utf8(phrase))
|
||||
// Metadata:
|
||||
created_at: string; // RFC3339
|
||||
}
|
||||
@ -42,6 +48,8 @@ export interface UnlockedIdentity {
|
||||
server: string;
|
||||
primary: Identity;
|
||||
seed: Uint8Array;
|
||||
/** 12-word recovery phrase. Absent for pre-mnemonic accounts. */
|
||||
phrase?: string;
|
||||
}
|
||||
|
||||
const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 SHA-256 guidance
|
||||
@ -76,6 +84,15 @@ export async function hasStoredIdentity(): Promise<boolean> {
|
||||
return !!stored;
|
||||
}
|
||||
|
||||
/** True iff the stored identity carries an encrypted recovery phrase
|
||||
* (created via the 12-word-mnemonic flow). Used to distinguish a
|
||||
* truly-legacy hex-only account from a phrase account that just isn't
|
||||
* available in the current session (e.g. biometric unlock didn't decrypt it). */
|
||||
export async function hasStoredPhrase(): Promise<boolean> {
|
||||
const stored = await get<StoredIdentity>(IDB_KEY);
|
||||
return !!(stored?.phrase_ciphertext && stored?.phrase_nonce);
|
||||
}
|
||||
|
||||
export async function loadStoredIdentityMeta(): Promise<
|
||||
Pick<StoredIdentity, "handle" | "server" | "primary" | "created_at"> | null
|
||||
> {
|
||||
@ -91,6 +108,11 @@ export async function saveIdentity(opts: {
|
||||
primary: Identity;
|
||||
seed: Uint8Array;
|
||||
passphrase: string;
|
||||
/** Optional 12-word phrase — stored encrypted so the user can re-display
|
||||
* it later. The seed is derived from the phrase one-way (SHA-256 with
|
||||
* domain tag); we can't recover the phrase from the seed, hence
|
||||
* storing it explicitly. */
|
||||
phrase?: string;
|
||||
}): Promise<void> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
@ -113,6 +135,21 @@ export async function saveIdentity(opts: {
|
||||
ciphertext: bytesToHex(ciphertext),
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (opts.phrase) {
|
||||
// Fresh nonce — AES-GCM nonce reuse under the same key is fatal.
|
||||
const phraseNonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const phraseCt = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: asBuffer(phraseNonce) },
|
||||
key,
|
||||
asBuffer(new TextEncoder().encode(opts.phrase)),
|
||||
),
|
||||
);
|
||||
record.phrase_nonce = bytesToHex(phraseNonce);
|
||||
record.phrase_ciphertext = bytesToHex(phraseCt);
|
||||
}
|
||||
|
||||
await set(IDB_KEY, record);
|
||||
}
|
||||
|
||||
@ -144,11 +181,34 @@ export async function unlockIdentity(
|
||||
`unlocked seed is ${plaintext.length} bytes, expected 32`,
|
||||
);
|
||||
}
|
||||
|
||||
let phrase: string | undefined;
|
||||
if (stored.phrase_nonce && stored.phrase_ciphertext) {
|
||||
try {
|
||||
const pn = hexToBytes(stored.phrase_nonce);
|
||||
const pc = hexToBytes(stored.phrase_ciphertext);
|
||||
const pBytes = new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: asBuffer(pn) },
|
||||
key,
|
||||
asBuffer(pc),
|
||||
),
|
||||
);
|
||||
phrase = new TextDecoder().decode(pBytes);
|
||||
} catch {
|
||||
// The seed unlocked fine, so the passphrase is right; a phrase
|
||||
// decrypt failure here would point at IDB corruption. Surface it
|
||||
// by logging, but don't block unlock — the user can still chat.
|
||||
console.error("identity-store: phrase decrypt failed (seed OK)");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handle: stored.handle,
|
||||
server: stored.server,
|
||||
primary: stored.primary,
|
||||
seed: plaintext,
|
||||
phrase,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
// reconsider depending on the Node port.
|
||||
|
||||
import { ed25519 } from "@noble/curves/ed25519";
|
||||
import { sha512 } from "@noble/hashes/sha2";
|
||||
import { sha256, sha512 } from "@noble/hashes/sha2";
|
||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
@ -19,6 +19,8 @@ export const CLAIM_TYPE = "kez.claim";
|
||||
export const REGISTRATION_TYPE = "kez.chat.handle_registration";
|
||||
export const REGISTRATION_ENVELOPE = "handle_registration";
|
||||
export const CLAIM_ENVELOPE = "claim";
|
||||
export const SIGCHAIN_EVENT_TYPE = "kez.sigchain.event";
|
||||
export const SIGCHAIN_ENVELOPE = "sigchain_event";
|
||||
export const ED25519_SHA512_ALG = "ed25519-sha512-jcs";
|
||||
export const FORMAT_VERSION = 1;
|
||||
export const COMPACT_PROOF_PREFIX = "kez:z1:";
|
||||
@ -70,6 +72,33 @@ export interface SignedRegistration {
|
||||
signature: SignatureBlock;
|
||||
}
|
||||
|
||||
export type SigchainOp = "add" | "revoke";
|
||||
|
||||
export interface SigchainEventPayload {
|
||||
type: typeof SIGCHAIN_EVENT_TYPE;
|
||||
version: number;
|
||||
primary: Identity;
|
||||
seq: number;
|
||||
/** `sha256:<hex>` of the prior envelope's JCS bytes. Omitted iff seq === 0. */
|
||||
prev?: string;
|
||||
created_at: string;
|
||||
op: SigchainOp;
|
||||
/** Op-specific fields, e.g. `{ subject, proof_url? }`. */
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SignedSigchainEvent {
|
||||
kez: typeof SIGCHAIN_ENVELOPE;
|
||||
payload: SigchainEventPayload;
|
||||
signature: SignatureBlock;
|
||||
}
|
||||
|
||||
/** Where the next event sits in the chain: its `seq` and `prev` hash. */
|
||||
export interface ChainCursor {
|
||||
seq: number;
|
||||
prev?: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Key generation + restoration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -137,6 +166,22 @@ function signWith(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 3339 UTC timestamp at SECOND precision (no fractional part), e.g.
|
||||
* `2026-05-19T18:00:00Z` — matching the SPEC.md examples.
|
||||
*
|
||||
* Why drop the milliseconds `toISOString()` emits: the Rust kez-core
|
||||
* verifier (used by the chat server and the chain service) deserializes
|
||||
* `created_at` into a `chrono::DateTime<Utc>` and re-serializes it to
|
||||
* re-canonicalize for signature checking. Chrono's `AutoSi` seconds format
|
||||
* drops a zero fractional part, so a browser-signed `…:00.000Z` would
|
||||
* re-serialize as `…:00Z` and fail verification ~1 in 1000. Emitting whole
|
||||
* seconds round-trips byte-stably through chrono for every timestamp.
|
||||
*/
|
||||
export function rfc3339Utc(date: Date = new Date()): string {
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Verification
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -211,7 +256,7 @@ export function signClaim(
|
||||
version: FORMAT_VERSION,
|
||||
subject,
|
||||
primary: signer.identity,
|
||||
created_at: createdAt.toISOString(),
|
||||
created_at: rfc3339Utc(createdAt),
|
||||
};
|
||||
return {
|
||||
kez: CLAIM_ENVELOPE,
|
||||
@ -233,7 +278,7 @@ export function signRegistration(
|
||||
handle,
|
||||
primary: signer.identity,
|
||||
server,
|
||||
created_at: createdAt.toISOString(),
|
||||
created_at: rfc3339Utc(createdAt),
|
||||
};
|
||||
return {
|
||||
kez: REGISTRATION_ENVELOPE,
|
||||
@ -242,6 +287,59 @@ export function signRegistration(
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sigchain events (Spec §8)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `sha256:<hex>` of the JCS-canonicalized bytes of the WHOLE signed envelope
|
||||
* (not just the payload). This is what the next event's `prev` points at.
|
||||
*/
|
||||
export function sigchainEventHash(event: SignedSigchainEvent): string {
|
||||
return `sha256:${bytesToHex(sha256(canonicalBytes(event)))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the current (validated, ordered) chain, return where the next event
|
||||
* goes: `seq` one past the head, and `prev` = the head's envelope hash.
|
||||
* An empty chain yields `{ seq: 0 }` with no `prev` (per spec, seq 0 has none).
|
||||
*/
|
||||
export function nextChainCursor(events: SignedSigchainEvent[]): ChainCursor {
|
||||
if (events.length === 0) return { seq: 0 };
|
||||
const head = events[events.length - 1];
|
||||
return { seq: head.payload.seq + 1, prev: sigchainEventHash(head) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + sign a sigchain event (add/revoke a subject) at the given cursor.
|
||||
* Insertion order matches the Rust/Node/Python impls (type, version, primary,
|
||||
* seq, prev?, created_at, op, payload) so the JSONL form lines up; JCS sorts
|
||||
* keys for signing regardless.
|
||||
*/
|
||||
export function signSigchainEvent(
|
||||
signer: Ed25519Identity,
|
||||
op: SigchainOp,
|
||||
opPayload: Record<string, unknown>,
|
||||
cursor: ChainCursor,
|
||||
createdAt: Date = new Date(),
|
||||
): SignedSigchainEvent {
|
||||
const payload: SigchainEventPayload = {
|
||||
type: SIGCHAIN_EVENT_TYPE,
|
||||
version: FORMAT_VERSION,
|
||||
primary: signer.identity,
|
||||
seq: cursor.seq,
|
||||
...(cursor.prev !== undefined ? { prev: cursor.prev } : {}),
|
||||
created_at: rfc3339Utc(createdAt),
|
||||
op,
|
||||
payload: opPayload,
|
||||
};
|
||||
return {
|
||||
kez: SIGCHAIN_ENVELOPE,
|
||||
payload,
|
||||
signature: signWith(payload, signer),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Encodings — pretty JSON, compact (kez:z1:), markdown fence
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
111
kez-chat/web/src/lib/mnemonic.ts
Normal file
111
kez-chat/web/src/lib/mnemonic.ts
Normal file
@ -0,0 +1,111 @@
|
||||
// Browser-native KEZ mnemonic helpers — mirrors:
|
||||
// • rust/crates/kez-core/src/mnemonic.rs
|
||||
// • nodejs/packages/kez-core/src/mnemonic.ts
|
||||
// • python/kez/mnemonic.py
|
||||
//
|
||||
// We use 12-word phrases as the user-facing backup form in the chat app
|
||||
// (shorter to write down than the 64-char hex seed). The seed itself is
|
||||
// derived deterministically from the phrase, so the phrase IS the
|
||||
// canonical backup.
|
||||
//
|
||||
// Semantics (must match the other implementations byte-for-byte):
|
||||
// • 12 words → 16 bytes of BIP-39 entropy → seed = SHA-256(DOMAIN_TAG ||
|
||||
// entropy) where DOMAIN_TAG = "kez-bip39-12-v1" (15 ASCII bytes).
|
||||
// The derivation is one-way: you can't recover the phrase from the
|
||||
// seed. That's why we ALSO store the phrase (encrypted at rest) so
|
||||
// the user can re-display it later via Settings → Reveal phrase.
|
||||
// • 24 words → entropy IS the 32-byte seed (bijection). Accepted on
|
||||
// restore for parity with the CLI, but the chat app generates 12-word
|
||||
// phrases by default.
|
||||
//
|
||||
// NB: We deliberately do NOT use BIP-39's PBKDF2 to_seed function. That
|
||||
// produces a 64-byte BIP-32 wallet seed, which is the wrong primitive
|
||||
// for KEZ's single-identity-per-phrase model.
|
||||
|
||||
import {
|
||||
entropyToMnemonic,
|
||||
generateMnemonic as bip39Generate,
|
||||
mnemonicToEntropy,
|
||||
} from "@scure/bip39";
|
||||
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
||||
import { sha256 } from "@noble/hashes/sha2";
|
||||
import { identityFromSeed, type Ed25519Identity } from "./kez.js";
|
||||
|
||||
const DOMAIN_TAG_12 = new TextEncoder().encode("kez-bip39-12-v1");
|
||||
|
||||
/** Generate a fresh 12-word phrase. */
|
||||
export function generateMnemonic12(): string {
|
||||
// BIP-39 strength: 128 bits → 12 words.
|
||||
return bip39Generate(wordlist, 128);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a 12- or 24-word phrase into a 32-byte Ed25519 seed. Auto-detects
|
||||
* length. Whitespace-tolerant (trims, collapses runs of spaces).
|
||||
*/
|
||||
export function seedFromMnemonic(phrase: string): Uint8Array {
|
||||
const trimmed = phrase.trim().replace(/\s+/g, " ");
|
||||
let entropy: Uint8Array;
|
||||
try {
|
||||
entropy = mnemonicToEntropy(trimmed, wordlist);
|
||||
} catch (e) {
|
||||
throw new Error(`invalid recovery phrase: ${(e as Error).message}`);
|
||||
}
|
||||
if (entropy.length === 32) {
|
||||
// 24-word: entropy is the seed.
|
||||
return new Uint8Array(entropy);
|
||||
}
|
||||
if (entropy.length === 16) {
|
||||
// 12-word: SHA-256 of (DOMAIN_TAG || entropy).
|
||||
const buf = new Uint8Array(DOMAIN_TAG_12.length + entropy.length);
|
||||
buf.set(DOMAIN_TAG_12, 0);
|
||||
buf.set(entropy, DOMAIN_TAG_12.length);
|
||||
return sha256(buf);
|
||||
}
|
||||
throw new Error(
|
||||
`mnemonic must decode to 16 or 32 bytes of entropy, got ${entropy.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Inverse for the 24-word case ONLY. Throws on any other length. */
|
||||
export function mnemonicFromSeed24(seed: Uint8Array): string {
|
||||
if (seed.length !== 32) {
|
||||
throw new Error(
|
||||
`mnemonicFromSeed24: seed must be 32 bytes, got ${seed.length}`,
|
||||
);
|
||||
}
|
||||
return entropyToMnemonic(seed, wordlist);
|
||||
}
|
||||
|
||||
/** Construct a KEZ Ed25519 identity from a phrase. */
|
||||
export function ed25519FromMnemonic(phrase: string): Ed25519Identity {
|
||||
return identityFromSeed(seedFromMnemonic(phrase));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh identity AND the phrase that derives it. The phrase
|
||||
* is the canonical user-facing backup; the identity carries the seed
|
||||
* for crypto ops.
|
||||
*/
|
||||
export function generateIdentityWithMnemonic(): {
|
||||
identity: Ed25519Identity;
|
||||
phrase: string;
|
||||
} {
|
||||
const phrase = generateMnemonic12();
|
||||
const identity = ed25519FromMnemonic(phrase);
|
||||
return { identity, phrase };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap input check (does the typed text look like a valid phrase?) so
|
||||
* the restore form can give live feedback. Returns true only if the
|
||||
* phrase parses + checksum-validates.
|
||||
*/
|
||||
export function isValidMnemonic(phrase: string): boolean {
|
||||
try {
|
||||
seedFromMnemonic(phrase);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
117
kez-chat/web/src/lib/sigchain-service.ts
Normal file
117
kez-chat/web/src/lib/sigchain-service.ts
Normal file
@ -0,0 +1,117 @@
|
||||
// Mirrors the user's claims into their sigchain on the chain service.
|
||||
//
|
||||
// The chain service (the rust-sig-server) is where a user's append-only,
|
||||
// signed sigchain lives. When the user adds a claim we append an `add`
|
||||
// event for that subject; when they remove a claim we append a `revoke`.
|
||||
// This keeps the sigchain an accurate, verifiable record of what the user
|
||||
// currently claims — the spec's mechanism (SPEC.md §8), not a per-claim
|
||||
// field.
|
||||
//
|
||||
// We read the current chain to compute the next `seq` + `prev` hash so
|
||||
// appends stay monotonic even across devices, then sign locally (the
|
||||
// chain service never sees the seed) and POST the event.
|
||||
|
||||
import {
|
||||
identityFromSeed,
|
||||
nextChainCursor,
|
||||
sigchainEventHash,
|
||||
signSigchainEvent,
|
||||
type Identity,
|
||||
type SignedSigchainEvent,
|
||||
type SigchainOp,
|
||||
} from "./kez.js";
|
||||
import { getSigchain, lookup, postSigchainEvent } from "./api.js";
|
||||
|
||||
export interface SigchainSyncResult {
|
||||
/** The chain-service base URL the event was posted to. */
|
||||
chainService: string;
|
||||
/** Sequence number of the event (or the existing add, when noop). */
|
||||
seq: number;
|
||||
/** `sha256:<hex>` head hash of the chain. */
|
||||
hash: string;
|
||||
/**
|
||||
* True when the chain already reflected the desired state, so no new
|
||||
* event was posted (e.g. re-syncing a claim already on the chain, or
|
||||
* removing a claim that was never added). Makes sync idempotent — safe
|
||||
* to run repeatedly and safe for claims created before sigchain support.
|
||||
*/
|
||||
noop: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the chain and return the currently-active subjects (added and not
|
||||
* later revoked), mapped to the `seq` of the `add` that activated each.
|
||||
* This is how we keep appends idempotent.
|
||||
*/
|
||||
function activeSubjects(events: SignedSigchainEvent[]): Map<string, number> {
|
||||
const active = new Map<string, number>();
|
||||
for (const event of events) {
|
||||
const subject = (event.payload.payload as { subject?: unknown })?.subject;
|
||||
if (typeof subject !== "string") continue;
|
||||
if (event.payload.op === "add") active.set(subject, event.payload.seq);
|
||||
else if (event.payload.op === "revoke") active.delete(subject);
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user's chain-service URL (their sigchain base URL) from their
|
||||
* handle. The chat server constructs this from the configured sig-server and
|
||||
* the user's primary key.
|
||||
*/
|
||||
export async function resolveChainService(handle: string): Promise<string> {
|
||||
const record = await lookup(handle);
|
||||
if (!record.sigchain_url) {
|
||||
throw new Error("server did not return a sigchain URL for this handle");
|
||||
}
|
||||
return record.sigchain_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an add/revoke event for `subject` to the user's sigchain on the
|
||||
* chain service. Reads the current chain to compute the next cursor, signs
|
||||
* the event with the user's seed, and POSTs it.
|
||||
*/
|
||||
export async function appendSubjectEvent(opts: {
|
||||
seed: Uint8Array;
|
||||
chainService: string;
|
||||
subject: Identity;
|
||||
op: SigchainOp;
|
||||
/** Optional URL where the channel proof is published (spec: add.proof_url). */
|
||||
proofUrl?: string;
|
||||
}): Promise<SigchainSyncResult> {
|
||||
const signer = identityFromSeed(opts.seed);
|
||||
const events = await getSigchain(opts.chainService);
|
||||
const active = activeSubjects(events);
|
||||
const head = events.length > 0 ? events[events.length - 1] : null;
|
||||
const headHash = head ? sigchainEventHash(head) : "";
|
||||
|
||||
// Idempotency: don't duplicate state the chain already has. Re-adding an
|
||||
// already-active subject, or revoking one that isn't active, is a no-op.
|
||||
if (opts.op === "add" && active.has(opts.subject)) {
|
||||
return {
|
||||
chainService: opts.chainService,
|
||||
seq: active.get(opts.subject)!,
|
||||
hash: headHash,
|
||||
noop: true,
|
||||
};
|
||||
}
|
||||
if (opts.op === "revoke" && !active.has(opts.subject)) {
|
||||
return {
|
||||
chainService: opts.chainService,
|
||||
seq: head ? head.payload.seq : -1,
|
||||
hash: headHash,
|
||||
noop: true,
|
||||
};
|
||||
}
|
||||
|
||||
const opPayload: Record<string, unknown> = { subject: opts.subject };
|
||||
if (opts.op === "add" && opts.proofUrl) {
|
||||
opPayload.proof_url = opts.proofUrl;
|
||||
}
|
||||
|
||||
const cursor = nextChainCursor(events);
|
||||
const event = signSigchainEvent(signer, opts.op, opPayload, cursor);
|
||||
const { seq, hash } = await postSigchainEvent(opts.chainService, event);
|
||||
return { chainService: opts.chainService, seq, hash, noop: false };
|
||||
}
|
||||
@ -9,7 +9,11 @@
|
||||
toCompact,
|
||||
type SignedClaimEnvelope,
|
||||
} from "../lib/kez.js";
|
||||
import { addClaim } from "../lib/claims-store.js";
|
||||
import { addClaim, setSigchainSync } from "../lib/claims-store.js";
|
||||
import {
|
||||
appendSubjectEvent,
|
||||
resolveChainService,
|
||||
} from "../lib/sigchain-service.js";
|
||||
import { session } from "../lib/store.svelte.js";
|
||||
import {
|
||||
hasNip07,
|
||||
@ -154,6 +158,14 @@
|
||||
| { status: "error"; message: string }
|
||||
>({ status: "idle" });
|
||||
|
||||
/** Outcome of mirroring this claim into the user's sigchain on the chain service. */
|
||||
let sigchainSync = $state<
|
||||
| { status: "idle" }
|
||||
| { status: "pending" }
|
||||
| { status: "ok"; seq: number; noop: boolean }
|
||||
| { status: "error"; message: string }
|
||||
>({ status: "idle" });
|
||||
|
||||
/** Re-evaluated each render; cheap (just a typeof check on window.nostr). */
|
||||
const nip07Available = $derived(hasNip07());
|
||||
|
||||
@ -226,13 +238,15 @@
|
||||
}
|
||||
|
||||
async function saveAndDone() {
|
||||
if (!envelope || !selected) return;
|
||||
if (!envelope || !selected || !session.unlocked) return;
|
||||
const id = crypto.randomUUID();
|
||||
const subject = envelope.payload.subject;
|
||||
try {
|
||||
// $state wraps `envelope` in a deep Proxy; structuredClone (used
|
||||
// by idb-keyval) can't clone proxies and throws DataCloneError.
|
||||
// $state.snapshot returns a plain, cloneable object.
|
||||
await addClaim({
|
||||
id: crypto.randomUUID(),
|
||||
id,
|
||||
envelope: $state.snapshot(envelope) as SignedClaimEnvelope,
|
||||
channel: selected.key,
|
||||
});
|
||||
@ -240,6 +254,36 @@
|
||||
} catch (e) {
|
||||
console.error("saveAndDone failed", e);
|
||||
alert(`Failed to save claim: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
// Mirror the claim into the user's sigchain on the chain service: append
|
||||
// a signed `add` event for this subject. Best-effort — the claim is
|
||||
// already saved locally; if the chain service is unreachable we record
|
||||
// the error and the user can retry from the Claims page later.
|
||||
sigchainSync = { status: "pending" };
|
||||
try {
|
||||
const chainService = await resolveChainService(session.unlocked.handle);
|
||||
const proofUrl =
|
||||
nostrPublish.status === "ok" ? nostrPublish.result.evidence_url : undefined;
|
||||
const result = await appendSubjectEvent({
|
||||
seed: session.unlocked.seed,
|
||||
chainService,
|
||||
subject,
|
||||
op: "add",
|
||||
proofUrl,
|
||||
});
|
||||
await setSigchainSync(id, {
|
||||
chain_service: result.chainService,
|
||||
sigchain_seq: result.seq,
|
||||
sigchain_status: "synced",
|
||||
});
|
||||
sigchainSync = { status: "ok", seq: result.seq, noop: result.noop };
|
||||
} catch (e) {
|
||||
await setSigchainSync(id, {
|
||||
sigchain_status: "error",
|
||||
sigchain_error: (e as Error).message,
|
||||
});
|
||||
sigchainSync = { status: "error", message: (e as Error).message };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -452,6 +496,28 @@
|
||||
Once you've published the proof on that channel, come back to the
|
||||
Claims page and mark it published.
|
||||
</p>
|
||||
|
||||
<!-- Chain-service (sigchain) mirror status -->
|
||||
<div class="mt-3 text-sm">
|
||||
{#if sigchainSync.status === "pending"}
|
||||
<p class="text-text-secondary">⏳ Updating your sigchain on the chain service…</p>
|
||||
{:else if sigchainSync.status === "ok"}
|
||||
<p class="text-verified">
|
||||
{#if sigchainSync.noop}
|
||||
⛓ Already on your sigchain (seq {sigchainSync.seq}) — nothing to add.
|
||||
{:else}
|
||||
⛓ Sigchain updated — added at seq {sigchainSync.seq} on the chain service.
|
||||
{/if}
|
||||
</p>
|
||||
{:else if sigchainSync.status === "error"}
|
||||
<p class="text-warning">
|
||||
⚠ Couldn't update your sigchain on the chain service:
|
||||
{sigchainSync.message}. The claim is saved locally — retry the
|
||||
sigchain sync from the Claims page.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<a
|
||||
href="#/claims"
|
||||
@ -466,6 +532,8 @@
|
||||
selected = null;
|
||||
identifierInput = "";
|
||||
envelope = null;
|
||||
nostrPublish = { status: "idle" };
|
||||
sigchainSync = { status: "idle" };
|
||||
}}
|
||||
>
|
||||
Add another
|
||||
|
||||
@ -6,8 +6,13 @@
|
||||
markPublished,
|
||||
removeClaim,
|
||||
setVerifyResult,
|
||||
setSigchainSync,
|
||||
type StoredClaim,
|
||||
} from "../lib/claims-store.js";
|
||||
import {
|
||||
appendSubjectEvent,
|
||||
resolveChainService,
|
||||
} from "../lib/sigchain-service.js";
|
||||
import { verifyClaim } from "../lib/verify.js";
|
||||
import { session } from "../lib/store.svelte.js";
|
||||
|
||||
@ -15,6 +20,8 @@
|
||||
let loading = $state(true);
|
||||
/** ids currently mid-verify, so we can disable the button + show a spinner. */
|
||||
let verifying = $state<Set<string>>(new Set());
|
||||
/** ids currently mid chain-service sync (add retry or revoke-on-delete). */
|
||||
let syncing = $state<Set<string>>(new Set());
|
||||
/** Which claims have their details panel expanded. */
|
||||
let expanded = $state<Set<string>>(new Set());
|
||||
|
||||
@ -33,11 +40,74 @@
|
||||
}
|
||||
|
||||
async function deleteClaim(c: StoredClaim) {
|
||||
if (!confirm(`Remove the local copy of claim for ${c.envelope.payload.subject}?`)) return;
|
||||
if (!confirm(`Remove the claim for ${c.envelope.payload.subject}?`)) return;
|
||||
// Revoke on the chain service first so the user's sigchain reflects the
|
||||
// removal (SPEC.md §8: a revoke event withdraws a previously-added
|
||||
// subject). Best-effort — if the chain service is unreachable, ask
|
||||
// before dropping the local copy so the sigchain doesn't silently drift.
|
||||
if (session.unlocked) {
|
||||
syncing = new Set(syncing).add(c.id);
|
||||
try {
|
||||
const chainService =
|
||||
c.chain_service ?? (await resolveChainService(session.unlocked.handle));
|
||||
await appendSubjectEvent({
|
||||
seed: session.unlocked.seed,
|
||||
chainService,
|
||||
subject: c.envelope.payload.subject,
|
||||
op: "revoke",
|
||||
});
|
||||
} catch (e) {
|
||||
const proceed = confirm(
|
||||
`Couldn't post a revoke to the chain service (${(e as Error).message}). ` +
|
||||
`Remove the local copy anyway? Your sigchain will still list this subject.`,
|
||||
);
|
||||
if (!proceed) {
|
||||
const next = new Set(syncing);
|
||||
next.delete(c.id);
|
||||
syncing = next;
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
const next = new Set(syncing);
|
||||
next.delete(c.id);
|
||||
syncing = next;
|
||||
}
|
||||
}
|
||||
await removeClaim(c.id);
|
||||
claims = await listClaims();
|
||||
}
|
||||
|
||||
/** (Re)mirror a claim into the sigchain on the chain service as an `add`. */
|
||||
async function syncToChain(c: StoredClaim) {
|
||||
if (!session.unlocked) return;
|
||||
syncing = new Set(syncing).add(c.id);
|
||||
try {
|
||||
const chainService = await resolveChainService(session.unlocked.handle);
|
||||
const result = await appendSubjectEvent({
|
||||
seed: session.unlocked.seed,
|
||||
chainService,
|
||||
subject: c.envelope.payload.subject,
|
||||
op: "add",
|
||||
});
|
||||
await setSigchainSync(c.id, {
|
||||
chain_service: result.chainService,
|
||||
sigchain_seq: result.seq,
|
||||
sigchain_status: "synced",
|
||||
sigchain_error: undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
await setSigchainSync(c.id, {
|
||||
sigchain_status: "error",
|
||||
sigchain_error: (e as Error).message,
|
||||
});
|
||||
} finally {
|
||||
const next = new Set(syncing);
|
||||
next.delete(c.id);
|
||||
syncing = next;
|
||||
claims = await listClaims();
|
||||
}
|
||||
}
|
||||
|
||||
async function runVerify(c: StoredClaim) {
|
||||
verifying = new Set(verifying).add(c.id);
|
||||
try {
|
||||
@ -149,6 +219,19 @@
|
||||
Channel: <span class="font-mono">{c.channel}</span> ·
|
||||
Signed: <span class="font-mono">{c.envelope.payload.created_at}</span>
|
||||
</p>
|
||||
{#if syncing.has(c.id)}
|
||||
<p class="mt-1 text-xs text-text-secondary">⏳ Syncing with chain service…</p>
|
||||
{:else if c.sigchain_status === "synced"}
|
||||
<p class="mt-1 text-xs text-verified">
|
||||
⛓ On your sigchain{#if c.sigchain_seq !== undefined} · seq {c.sigchain_seq}{/if}
|
||||
</p>
|
||||
{:else if c.sigchain_status === "error"}
|
||||
<p class="mt-1 text-xs text-warning">
|
||||
⚠ Not on your sigchain{#if c.sigchain_error} ({c.sigchain_error}){/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs text-text-muted">⛓ Not yet on your sigchain</p>
|
||||
{/if}
|
||||
{#if c.last_verify}
|
||||
<p class="mt-1 text-xs text-text-secondary">
|
||||
{c.last_verify.summary}
|
||||
@ -208,9 +291,20 @@
|
||||
Mark published
|
||||
</button>
|
||||
{/if}
|
||||
{#if c.sigchain_status !== "synced"}
|
||||
<button
|
||||
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-elevated disabled:opacity-50"
|
||||
onclick={() => syncToChain(c)}
|
||||
disabled={syncing.has(c.id)}
|
||||
title="Append an `add` event for this subject to your sigchain on the chain service."
|
||||
>
|
||||
{syncing.has(c.id) ? "Syncing…" : "Sync to chain"}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-danger/10 hover:border-danger"
|
||||
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-danger/10 hover:border-danger disabled:opacity-50"
|
||||
onclick={() => deleteClaim(c)}
|
||||
disabled={syncing.has(c.id)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { push } from "svelte-spa-router";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import {
|
||||
generateIdentity,
|
||||
signRegistration,
|
||||
type Ed25519Identity,
|
||||
} from "../lib/kez.js";
|
||||
import { generateIdentityWithMnemonic } from "../lib/mnemonic.js";
|
||||
import { register, healthz, ApiError } from "../lib/api.js";
|
||||
import { saveIdentity } from "../lib/identity-store.js";
|
||||
import { session } from "../lib/store.svelte.js";
|
||||
|
||||
let step = $state<"handle" | "seed" | "confirm" | "submitting" | "done">("handle");
|
||||
let step = $state<"handle" | "phrase" | "confirm" | "submitting" | "done">("handle");
|
||||
|
||||
let handle = $state("");
|
||||
let passphrase = $state("");
|
||||
@ -19,8 +18,8 @@
|
||||
|
||||
let serverInfo = $state<{ server: string; version: string } | null>(null);
|
||||
let id = $state<Ed25519Identity | null>(null);
|
||||
let seedHex = $state("");
|
||||
let seedAck = $state(false);
|
||||
let phrase = $state("");
|
||||
let phraseAck = $state(false);
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
let working = $state(false);
|
||||
@ -48,9 +47,10 @@
|
||||
if (v) { error = v; return; }
|
||||
if (passphrase.length < 8) { error = "Passphrase must be at least 8 characters."; return; }
|
||||
if (passphrase !== passphrase2) { error = "Passphrases don't match."; return; }
|
||||
id = generateIdentity();
|
||||
seedHex = bytesToHex(id.seed);
|
||||
step = "seed";
|
||||
const gen = generateIdentityWithMnemonic();
|
||||
id = gen.identity;
|
||||
phrase = gen.phrase;
|
||||
step = "phrase";
|
||||
}
|
||||
|
||||
async function submitRegistration() {
|
||||
@ -67,6 +67,7 @@
|
||||
primary: id.identity,
|
||||
seed: id.seed,
|
||||
passphrase,
|
||||
phrase,
|
||||
});
|
||||
session.setUnlocked({
|
||||
handle: resp.handle,
|
||||
@ -105,7 +106,7 @@
|
||||
<ol class="flex gap-2 text-xs text-text-muted">
|
||||
<li class={step === "handle" ? "font-semibold text-text" : ""}>1. Handle</li>
|
||||
<li>→</li>
|
||||
<li class={step === "seed" ? "font-semibold text-text" : ""}>2. Back up seed</li>
|
||||
<li class={step === "phrase" ? "font-semibold text-text" : ""}>2. Back up phrase</li>
|
||||
<li>→</li>
|
||||
<li class={step === "confirm" || step === "submitting" ? "font-semibold text-text" : ""}>3. Confirm</li>
|
||||
<li>→</li>
|
||||
@ -181,32 +182,38 @@
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if step === "seed" && id}
|
||||
{#if step === "phrase" && id}
|
||||
<div class="space-y-4">
|
||||
<div class="border border-warning/40 bg-warning/10 rounded-lg p-4 space-y-3">
|
||||
<p class="font-semibold text-warning">⚠️ Back up your seed now</p>
|
||||
<p class="font-semibold text-warning">⚠️ Back up your recovery phrase</p>
|
||||
<p class="text-sm text-warning">
|
||||
This is the only way to recover your account on another device
|
||||
(or after clearing this browser). The server doesn't have it.
|
||||
Write it down or paste into a password manager.
|
||||
This 12-word phrase is the only way to recover your account on
|
||||
another device (or after clearing this browser). The server
|
||||
doesn't have it. Write it down on paper — don't just rely on
|
||||
this browser.
|
||||
</p>
|
||||
<div class="mt-3 p-3 bg-surface border border-warning/40 rounded font-mono text-sm break-all select-all">
|
||||
{seedHex}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ol class="mt-3 p-3 bg-surface border border-warning/40 rounded grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2 font-mono text-sm">
|
||||
{#each phrase.split(" ") as word, i}
|
||||
<li class="flex items-baseline gap-2">
|
||||
<span class="text-text-muted text-xs w-5 text-right">{i + 1}.</span>
|
||||
<span class="text-text select-all">{word}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
class="text-xs px-3 py-1 bg-warning/10 text-accent-contrast rounded hover:bg-warning/20"
|
||||
onclick={() => copyToClipboard(seedHex)}
|
||||
class="text-xs px-3 py-1 border border-warning/40 text-warning rounded hover:bg-warning/20"
|
||||
onclick={() => copyToClipboard(phrase)}
|
||||
>
|
||||
Copy seed
|
||||
Copy all 12 words
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-2 text-sm text-text-secondary">
|
||||
<input type="checkbox" bind:checked={seedAck} class="mt-1" />
|
||||
I've saved this seed somewhere safe. I understand losing it means
|
||||
losing my account permanently.
|
||||
<input type="checkbox" bind:checked={phraseAck} class="mt-1" />
|
||||
I've written down these 12 words in order. I understand losing them
|
||||
means losing my account permanently.
|
||||
</label>
|
||||
|
||||
<div class="flex gap-2">
|
||||
@ -218,7 +225,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
|
||||
disabled={!seedAck}
|
||||
disabled={!phraseAck}
|
||||
onclick={() => { step = "confirm"; }}
|
||||
>
|
||||
I've saved it — continue
|
||||
@ -238,7 +245,7 @@
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
|
||||
onclick={() => { step = "seed"; }}
|
||||
onclick={() => { step = "phrase"; }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { push } from "svelte-spa-router";
|
||||
import { hexToBytes } from "@noble/hashes/utils";
|
||||
import { identityFromSeed } from "../lib/kez.js";
|
||||
import { lookup, healthz, ApiError } from "../lib/api.js";
|
||||
import { identityFromSeed, signRegistration } from "../lib/kez.js";
|
||||
import {
|
||||
seedFromMnemonic,
|
||||
isValidMnemonic,
|
||||
} from "../lib/mnemonic.js";
|
||||
import { healthz, lookupByPrimary, ApiError } from "../lib/api.js";
|
||||
import { saveIdentity } from "../lib/identity-store.js";
|
||||
import { session } from "../lib/store.svelte.js";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let seedHex = $state("");
|
||||
/** User pastes either a 12-word phrase OR a 64-char hex seed. */
|
||||
let secretInput = $state("");
|
||||
let passphrase = $state("");
|
||||
let passphrase2 = $state("");
|
||||
let serverDomain = $state<string | null>(null);
|
||||
@ -23,38 +28,78 @@
|
||||
}
|
||||
});
|
||||
|
||||
/** Auto-detect: hex-seed if 64 hex chars, otherwise treat as mnemonic. */
|
||||
function parseSecret(raw: string): { seed: Uint8Array; phrase?: string } {
|
||||
const trimmed = raw.trim();
|
||||
const hexShape = trimmed.replace(/\s+/g, "").toLowerCase();
|
||||
if (/^[0-9a-f]{64}$/.test(hexShape)) {
|
||||
return { seed: hexToBytes(hexShape) };
|
||||
}
|
||||
if (isValidMnemonic(trimmed)) {
|
||||
return { seed: seedFromMnemonic(trimmed), phrase: trimmed.replace(/\s+/g, " ") };
|
||||
}
|
||||
throw new Error(
|
||||
"Couldn't read that as either a 12/24-word recovery phrase or a 64-character hex seed.",
|
||||
);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error = null;
|
||||
working = true;
|
||||
try {
|
||||
const cleaned = seedHex.trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(cleaned)) {
|
||||
throw new Error("Seed must be 64 lowercase hex characters (32 bytes).");
|
||||
}
|
||||
if (passphrase.length < 8) {
|
||||
throw new Error("Passphrase must be at least 8 characters.");
|
||||
}
|
||||
if (passphrase !== passphrase2) {
|
||||
throw new Error("Passphrases don't match.");
|
||||
}
|
||||
const seed = hexToBytes(cleaned);
|
||||
const id = identityFromSeed(seed);
|
||||
if (!serverDomain) {
|
||||
throw new Error("Server unreachable; refresh and try again.");
|
||||
}
|
||||
// Look up the primary on the server to find the associated handle.
|
||||
// We try a couple of common handles? No — the registry is keyed by
|
||||
// handle, not primary. So we ask the user to type their handle.
|
||||
throw new Error(
|
||||
"Sorry — to restore, please use a device that has your handle saved. " +
|
||||
"(v0.2 will let you look up your handle by primary key.)",
|
||||
);
|
||||
// TODO when chat-server has GET /v1/by-primary/<id>: implement this.
|
||||
// For now restoring a seed-only is incomplete because we don't know
|
||||
// the handle. Workaround: regenerate identity via /create with same
|
||||
// handle (server will reject as taken; not useful) OR ask the user.
|
||||
|
||||
const { seed, phrase } = parseSecret(secretInput);
|
||||
const id = identityFromSeed(seed);
|
||||
|
||||
// Look up the handle this primary is registered to.
|
||||
let record;
|
||||
try {
|
||||
record = await lookupByPrimary(id.identity);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
throw new Error(
|
||||
"No account registered with this recovery phrase on " +
|
||||
`${serverDomain}. Did you mean to create a new one?`,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Re-sign a registration so it's on file fresh (idempotent on the
|
||||
// server — same primary always matches the same handle).
|
||||
// (We don't strictly need to re-register; storing locally is enough.)
|
||||
void signRegistration;
|
||||
|
||||
await saveIdentity({
|
||||
handle: record.handle,
|
||||
server: serverDomain,
|
||||
primary: id.identity,
|
||||
seed: id.seed,
|
||||
passphrase,
|
||||
phrase,
|
||||
});
|
||||
|
||||
session.setUnlocked({
|
||||
handle: record.handle,
|
||||
server: serverDomain,
|
||||
primary: id.identity,
|
||||
seed: id.seed,
|
||||
phrase,
|
||||
});
|
||||
|
||||
push("/welcome");
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? `${e.code ?? "error"}: ${e.message}` : (e as Error).message;
|
||||
error =
|
||||
e instanceof ApiError ? `${e.code ?? "error"}: ${e.message}` : (e as Error).message;
|
||||
} finally {
|
||||
working = false;
|
||||
}
|
||||
@ -62,13 +107,13 @@
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-text">Restore from seed</h1>
|
||||
<h1 class="text-2xl font-bold text-text">Restore account</h1>
|
||||
|
||||
<p class="text-sm text-text-secondary bg-warning/10 border border-warning/40 rounded p-3">
|
||||
<strong>v0.1 limitation:</strong> the seed alone doesn't tell us which
|
||||
handle to restore. For now this flow doesn't work end-to-end — we'll
|
||||
add <code>GET /v1/by-primary/<id></code> on the server in v0.2
|
||||
so the SPA can look up the handle from the public key.
|
||||
<p class="text-sm text-text-secondary">
|
||||
Paste your 12-word recovery phrase. (If you wrote down a 64-character
|
||||
hex seed from an older version of kez-chat, that works too.) We'll
|
||||
look up your handle on <span class="font-mono">{serverDomain ?? "the server"}</span>
|
||||
and unlock the account on this device.
|
||||
</p>
|
||||
|
||||
<form
|
||||
@ -76,14 +121,17 @@
|
||||
onsubmit={(e) => { e.preventDefault(); submit(); }}
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-secondary" for="seed">
|
||||
Seed (64 hex characters)
|
||||
<label class="block text-sm font-medium text-text-secondary" for="secret">
|
||||
Recovery phrase or hex seed
|
||||
</label>
|
||||
<textarea
|
||||
id="seed"
|
||||
bind:value={seedHex}
|
||||
id="secret"
|
||||
bind:value={secretInput}
|
||||
rows="3"
|
||||
placeholder="abandon ability able about above absent academy accident account accuse achieve acid"
|
||||
class="mt-1 w-full px-3 py-2 border border-border rounded-md font-mono text-sm"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { push } from "svelte-spa-router";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import { session } from "../lib/store.svelte.js";
|
||||
import { hasStoredPhrase } from "../lib/identity-store.js";
|
||||
import {
|
||||
hasStoredBiometric,
|
||||
getStoredBiometricMeta,
|
||||
@ -92,10 +93,36 @@
|
||||
setTimeout(() => (testNotifResult = null), 5_000);
|
||||
}
|
||||
|
||||
function showSeed() {
|
||||
async function showSeed() {
|
||||
if (!session.unlocked) return;
|
||||
const phrase = session.unlocked.phrase;
|
||||
if (phrase) {
|
||||
alert(
|
||||
`Your 12-word recovery phrase (KEEP SECRET):\n\n${phrase}\n\n` +
|
||||
`Write these 12 words down in order — they're the ONLY way to ` +
|
||||
`recover this account on another device.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Phrase not in this session — distinguish two cases:
|
||||
// 1. Account HAS a stored phrase but this session unlocked via
|
||||
// biometric (PRF key doesn't decrypt the passphrase-keyed blob).
|
||||
// 2. Genuinely pre-mnemonic legacy account — show hex.
|
||||
if (await hasStoredPhrase()) {
|
||||
alert(
|
||||
`Your recovery phrase isn't available in this session.\n\n` +
|
||||
`Biometric unlock doesn't decrypt the phrase. Lock and unlock ` +
|
||||
`again with your passphrase to reveal it.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const hex = bytesToHex(session.unlocked.seed);
|
||||
alert(`Your recovery seed (KEEP SECRET):\n\n${hex}\n\nWrite this down somewhere safe. It's the ONLY way to recover this account.`);
|
||||
alert(
|
||||
`Your recovery seed — hex form (KEEP SECRET):\n\n${hex}\n\n` +
|
||||
`This account was created before 12-word phrases were supported. ` +
|
||||
`The 64-character hex above is still your full recovery — write ` +
|
||||
`it down somewhere safe.`,
|
||||
);
|
||||
}
|
||||
|
||||
function lock() {
|
||||
@ -166,12 +193,13 @@
|
||||
|
||||
<!-- Recovery phrase -->
|
||||
<div>
|
||||
<p class="text-sm font-medium text-text">Recovery seed</p>
|
||||
<p class="text-sm font-medium text-text">Recovery phrase</p>
|
||||
<p class="text-sm text-text-secondary mt-0.5">
|
||||
The only thing that can recover this account. Write it down offline.
|
||||
12 words that recover this account anywhere. Write them down on
|
||||
paper — losing them means losing the account.
|
||||
</p>
|
||||
<button class="mt-2 px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={showSeed}>
|
||||
Reveal seed
|
||||
Reveal phrase
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -23,9 +23,10 @@
|
||||
let biometricAvailable = $state(false);
|
||||
let notifPerm = $state<NotificationPermission | "unsupported">("default");
|
||||
|
||||
let seedRevealed = $state(false);
|
||||
let seedHex = $state("");
|
||||
let seedCopied = $state(false);
|
||||
let backupRevealed = $state(false);
|
||||
let backupText = $state(""); // 12-word phrase if available, else hex seed
|
||||
let backupKind = $state<"phrase" | "seed">("phrase");
|
||||
let backupCopied = $state(false);
|
||||
let busy = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
@ -42,13 +43,20 @@
|
||||
|
||||
function revealSeed() {
|
||||
if (!session.unlocked) return;
|
||||
seedHex = bytesToHex(session.unlocked.seed);
|
||||
seedRevealed = true;
|
||||
if (session.unlocked.phrase) {
|
||||
backupText = session.unlocked.phrase;
|
||||
backupKind = "phrase";
|
||||
} else {
|
||||
// Legacy account (pre-mnemonic) — fall back to the hex seed.
|
||||
backupText = bytesToHex(session.unlocked.seed);
|
||||
backupKind = "seed";
|
||||
}
|
||||
backupRevealed = true;
|
||||
}
|
||||
async function copySeed() {
|
||||
await navigator.clipboard.writeText(seedHex);
|
||||
seedCopied = true;
|
||||
setTimeout(() => (seedCopied = false), 1500);
|
||||
await navigator.clipboard.writeText(backupText);
|
||||
backupCopied = true;
|
||||
setTimeout(() => (backupCopied = false), 1500);
|
||||
}
|
||||
|
||||
async function enableBiometric() {
|
||||
@ -109,25 +117,40 @@
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- 2. Back up seed (critical, skippable) -->
|
||||
<!-- 2. Back up phrase (critical, skippable) -->
|
||||
<li class={`bg-surface border rounded-xl p-4 ${onboarding.seedAcked ? "border-border" : "border-warning/50"}`}>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="shrink-0 mt-0.5">{onboarding.seedAcked ? "✓" : "🔑"}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-text">Back up your recovery seed</p>
|
||||
<p class="text-sm font-semibold text-text">Back up your recovery phrase</p>
|
||||
<p class="text-xs text-text-secondary">
|
||||
This 32-byte seed is the <strong>only</strong> way to recover your
|
||||
account. Lose it and it's gone forever — there's no reset. Write it
|
||||
down offline.
|
||||
These 12 words are the <strong>only</strong> way to recover
|
||||
your account. Lose them and it's gone forever — there's no
|
||||
reset. Write them on paper.
|
||||
</p>
|
||||
{#if !seedRevealed && !onboarding.seedAcked}
|
||||
<button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={revealSeed}>Reveal seed</button>
|
||||
{#if !backupRevealed && !onboarding.seedAcked}
|
||||
<button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={revealSeed}>Reveal phrase</button>
|
||||
{/if}
|
||||
{#if seedRevealed}
|
||||
{#if backupRevealed}
|
||||
<div class="mt-2 p-3 bg-elevated border border-border rounded-md">
|
||||
<p class="font-mono text-xs text-text break-all select-all">{seedHex}</p>
|
||||
{#if backupKind === "phrase"}
|
||||
<ol class="grid grid-cols-2 sm:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-xs">
|
||||
{#each backupText.split(" ") as word, i}
|
||||
<li class="flex items-baseline gap-1.5">
|
||||
<span class="text-text-muted w-4 text-right">{i + 1}.</span>
|
||||
<span class="text-text select-all">{word}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="font-mono text-xs text-text break-all select-all">{backupText}</p>
|
||||
<p class="mt-1 text-[10px] text-text-muted">
|
||||
Legacy 64-char hex — accounts created from now on get a
|
||||
12-word phrase instead.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button class="px-2.5 py-1 text-xs border border-border rounded text-text-secondary hover:bg-surface" onclick={copySeed}>{seedCopied ? "✓ copied" : "Copy"}</button>
|
||||
<button class="px-2.5 py-1 text-xs border border-border rounded text-text-secondary hover:bg-surface" onclick={copySeed}>{backupCopied ? "✓ copied" : "Copy"}</button>
|
||||
{#if !onboarding.seedAcked}
|
||||
<button class="px-2.5 py-1 text-xs bg-accent text-accent-contrast font-semibold rounded" onclick={() => onboarding.ackSeed()}>I've saved it safely</button>
|
||||
{/if}
|
||||
|
||||
@ -97,24 +97,124 @@ A new nostr keypair:
|
||||
npm run cli -- identity new
|
||||
```
|
||||
|
||||
Or a new Ed25519 keypair:
|
||||
Or a new Ed25519 keypair, which comes with a BIP-39 phrase alongside
|
||||
the hex seed (both are equivalent backups):
|
||||
|
||||
```sh
|
||||
npm run cli -- identity new --key-type ed25519
|
||||
npm run cli -- identity new --key-type ed25519 # 24-word
|
||||
npm run cli -- identity new --key-type ed25519 --mnemonic-words 12 # 12-word
|
||||
```
|
||||
|
||||
Output (Ed25519):
|
||||
Output (24-word, the default):
|
||||
|
||||
```
|
||||
Primary: ed25519:7a3b4c…
|
||||
Public: 7a3b4c… (hex)
|
||||
Public: 7a3b4c…
|
||||
Secret: 9e3f51… (32-byte seed)
|
||||
Mnemonic (24 words): "abandon ability able about above absent academy accident…"
|
||||
```
|
||||
|
||||
> **Save the secret.** It's the only thing that can sign as this
|
||||
> identity. There's no recovery flow — lose it and the identity is
|
||||
> gone. Write it down offline, or paste it into a password manager.
|
||||
> From here on this tutorial assumes you stored it.
|
||||
> **Save the backup.** Seed *or* phrase — at least one. Lose them both
|
||||
> and the identity is gone. There's no recovery flow.
|
||||
|
||||
### Recovery phrases — what's actually going on
|
||||
|
||||
A KEZ recovery phrase is a [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)
|
||||
mnemonic — the same 2048-word English wordlist that Bitcoin, Ethereum,
|
||||
and most hardware wallets use. The words encode random bits:
|
||||
|
||||
| Phrase length | Random bits | Resulting Ed25519 seed |
|
||||
|---|---|---|
|
||||
| **24 words** | 256 bits of entropy | The 32-byte seed *is* those 256 bits (1:1). Phrase ↔ seed round-trips. |
|
||||
| **12 words** | 128 bits of entropy | 16 bytes → 32-byte seed via `SHA-256("kez-bip39-12-v1" \|\| entropy)`. Phrase → seed only (one-way). |
|
||||
|
||||
#### Picking 12 vs 24
|
||||
|
||||
- **Pick 24 words** when you want full round-trip-ability — i.e. you'd
|
||||
like to be able to *recover the phrase from the hex seed* at any time
|
||||
in the future. Anyone's 32-byte Ed25519 secret can be re-encoded into
|
||||
the unique 24-word phrase that produced it. Bigger security margin
|
||||
(256 bits of entropy vs 128).
|
||||
- **Pick 12 words** when you want a shorter thing to write down on
|
||||
paper or remember. 128 bits of entropy is still enormously beyond
|
||||
brute-forcing. The trade-off: the path is *one-way only* — you can
|
||||
always derive the seed from the phrase, but you cannot derive the
|
||||
phrase from the seed. So if you only ever have the seed, you'll
|
||||
never know what 12-word phrase produced it. **Save the phrase
|
||||
itself**, not just the resulting seed.
|
||||
|
||||
Either way the resulting Ed25519 identity is exactly the same shape;
|
||||
peers can't tell which word count you used. The choice is purely about
|
||||
your backup ergonomics.
|
||||
|
||||
#### ⚠ Not compatible with hardware-wallet derivations
|
||||
|
||||
A KEZ 12-word phrase **does not** produce the same Bitcoin or Ethereum
|
||||
key as the same 12 words typed into a Ledger or MetaMask, and vice
|
||||
versa. The reasons are deliberate:
|
||||
|
||||
1. Other wallets feed the phrase through BIP-39's PBKDF2 to get a
|
||||
64-byte "seed", then run that through BIP-32 hierarchical
|
||||
derivation at a coin-specific path. KEZ doesn't — it takes the
|
||||
raw entropy and uses it directly (24-word case) or hashes it with
|
||||
a domain tag (12-word case).
|
||||
2. KEZ identities aren't part of a derivation tree. There's one
|
||||
identity per phrase; there's no path component.
|
||||
|
||||
That means: **don't paste your existing hardware-wallet recovery
|
||||
phrase into KEZ** expecting to get a key you've already seen. It'll
|
||||
produce a *new* KEZ identity uncorrelated with anything else.
|
||||
|
||||
Conversely: a KEZ phrase you saved is *only* useful for KEZ. A
|
||||
malicious wallet that says "import this phrase" can't extract your
|
||||
existing Bitcoin / Ethereum funds from a KEZ phrase, because the
|
||||
phrase wasn't derived through the same path.
|
||||
|
||||
#### Backing up — concrete advice
|
||||
|
||||
The phrase is the master key to your identity. Practical guidance:
|
||||
|
||||
- **Write it on paper, with a pencil. Number each word (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 <hex>` appears.
|
||||
Both are accepted on every command that takes a signing key.
|
||||
|
||||
For the rest of this tutorial we'll use a nostr key for examples and
|
||||
write the secret as `nsec1FAKE...` — substitute your real one.
|
||||
|
||||
262
nodejs/package-lock.json
generated
262
nodejs/package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,10 @@ import {
|
||||
type Signer,
|
||||
type VerificationStatus,
|
||||
dnsTxtName,
|
||||
ed25519FromMnemonic,
|
||||
eventHash,
|
||||
generateEd25519WithMnemonic,
|
||||
generateMnemonic,
|
||||
newClaimPayload,
|
||||
signClaim,
|
||||
toCompact,
|
||||
@ -47,7 +50,9 @@ function usageAndExit(msg?: string): never {
|
||||
"Usage: kez <command> ...",
|
||||
"",
|
||||
"Commands:",
|
||||
" identity new [--key-type nostr|ed25519]",
|
||||
" identity new [--key-type nostr|ed25519] [--mnemonic-words 12|24]",
|
||||
" identity mnemonic [--words 12|24]",
|
||||
" identity from-mnemonic \"<phrase>\"",
|
||||
" claim create <subject> (--nsec <nsec> | --ed25519-seed <hex>)",
|
||||
" [--format json|markdown|compact] [--out <path>]",
|
||||
" claim dns <domain> (--nsec <nsec> | --ed25519-seed <hex>)",
|
||||
@ -69,6 +74,12 @@ function usageAndExit(msg?: string): never {
|
||||
interface Flags {
|
||||
nsec?: string;
|
||||
ed25519Seed?: string;
|
||||
/** BIP-39 phrase, alternative to --ed25519-seed. */
|
||||
mnemonic?: string;
|
||||
/** "12" or "24" — used by `identity new --mnemonic-words`. */
|
||||
mnemonicWords?: string;
|
||||
/** "12" or "24" — used by `identity mnemonic --words`. */
|
||||
words?: string;
|
||||
keyType?: "nostr" | "ed25519";
|
||||
format?: "json" | "markdown" | "compact" | "jsonl";
|
||||
out?: string;
|
||||
@ -89,6 +100,12 @@ function parseFlags(args: string[]): Flags {
|
||||
out.nsec = args[++i];
|
||||
} else if (a === "--ed25519-seed") {
|
||||
out.ed25519Seed = args[++i];
|
||||
} else if (a === "--mnemonic") {
|
||||
out.mnemonic = args[++i];
|
||||
} else if (a === "--mnemonic-words") {
|
||||
out.mnemonicWords = args[++i];
|
||||
} else if (a === "--words") {
|
||||
out.words = args[++i];
|
||||
} else if (a === "--key-type") {
|
||||
const v = args[++i];
|
||||
if (v !== "nostr" && v !== "ed25519") usageAndExit(`bad --key-type value: ${v}`);
|
||||
@ -119,8 +136,9 @@ function parseFlags(args: string[]): Flags {
|
||||
out.positional.push(a);
|
||||
}
|
||||
}
|
||||
if (out.nsec && out.ed25519Seed) {
|
||||
usageAndExit("--nsec and --ed25519-seed are mutually exclusive");
|
||||
const keySources = [out.nsec, out.ed25519Seed, out.mnemonic].filter(Boolean).length;
|
||||
if (keySources > 1) {
|
||||
usageAndExit("--nsec, --ed25519-seed, and --mnemonic are mutually exclusive");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@ -143,7 +161,8 @@ function printStatus(status: VerificationStatus): void {
|
||||
function loadSigner(args: Flags): Signer {
|
||||
if (args.nsec) return NostrSecret.fromNsec(args.nsec);
|
||||
if (args.ed25519Seed) return Ed25519Secret.fromSeedHex(args.ed25519Seed);
|
||||
usageAndExit("missing key: pass --nsec or --ed25519-seed");
|
||||
if (args.mnemonic) return ed25519FromMnemonic(args.mnemonic);
|
||||
usageAndExit("missing key: pass --nsec, --ed25519-seed, or --mnemonic");
|
||||
}
|
||||
|
||||
function buildClaim(subjectStr: string, signer: Signer) {
|
||||
@ -155,27 +174,67 @@ function buildClaim(subjectStr: string, signer: Signer) {
|
||||
return signClaim(newClaimPayload(subject, primary, new Date()), signer);
|
||||
}
|
||||
|
||||
function parseWordCount(raw: string | undefined, dflt: 12 | 24): 12 | 24 {
|
||||
if (raw === undefined) return dflt;
|
||||
if (raw === "12") return 12;
|
||||
if (raw === "24") return 24;
|
||||
usageAndExit(`word count must be 12 or 24, got ${raw}`);
|
||||
}
|
||||
|
||||
function identityNew(args: Flags): void {
|
||||
const keyType = args.keyType ?? "nostr";
|
||||
if (keyType === "ed25519") {
|
||||
const s = Ed25519Secret.generate();
|
||||
process.stdout.write(`Primary: ${s.identity()}\n`);
|
||||
process.stdout.write(`Public: ${s.pubkeyHex()}\n`);
|
||||
process.stdout.write(`Secret: ${s.seedHex()} (32-byte seed)\n`);
|
||||
if (keyType === "nostr") {
|
||||
if (args.mnemonicWords !== undefined) {
|
||||
usageAndExit("--mnemonic-words is only valid with --key-type ed25519");
|
||||
}
|
||||
const s = NostrSecret.generate();
|
||||
process.stdout.write(`Primary: nostr:${s.npub()}\n`);
|
||||
process.stdout.write(`Public: ${s.npub()}\n`);
|
||||
process.stdout.write(`Secret: ${s.nsec()}\n`);
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write(
|
||||
"Store the secret somewhere safe. Anyone with the seed can sign as this identity.\n",
|
||||
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity.\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const s = NostrSecret.generate();
|
||||
process.stdout.write(`Primary: nostr:${s.npub()}\n`);
|
||||
process.stdout.write(`Public: ${s.npub()}\n`);
|
||||
process.stdout.write(`Secret: ${s.nsec()}\n`);
|
||||
// ed25519: default 24 words (bijective with the seed), or 12 if asked.
|
||||
const words = parseWordCount(args.mnemonicWords, 24);
|
||||
const { secret, phrase } = generateEd25519WithMnemonic(words);
|
||||
process.stdout.write(`Primary: ${secret.identity()}\n`);
|
||||
process.stdout.write(`Public: ${secret.pubkeyHex()}\n`);
|
||||
process.stdout.write(`Secret: ${secret.seedHex()} (32-byte seed)\n`);
|
||||
process.stdout.write(`Mnemonic (${words} words): "${phrase}"\n`);
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write(
|
||||
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity.\n",
|
||||
);
|
||||
if (words === 24) {
|
||||
process.stdout.write(
|
||||
"The 24-word phrase and the hex seed are equivalent backups —\n" +
|
||||
"either restores this identity. Store at least one safely.\n",
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
"The 12-word phrase is the canonical backup. The hex seed is\n" +
|
||||
"derived from it (one-way) — you can't reconstruct the phrase\n" +
|
||||
"from the seed. Store the phrase safely.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function identityMnemonic(args: Flags): void {
|
||||
const words = parseWordCount(args.words, 24);
|
||||
process.stdout.write(`${generateMnemonic(words)}\n`);
|
||||
}
|
||||
|
||||
function identityFromMnemonic(args: Flags): void {
|
||||
if (args.positional.length !== 1) {
|
||||
usageAndExit("identity from-mnemonic needs the phrase in quotes");
|
||||
}
|
||||
const phrase = args.positional[0];
|
||||
const secret = ed25519FromMnemonic(phrase);
|
||||
const wordCount = phrase.trim().split(/\s+/).length;
|
||||
process.stdout.write(`Primary: ${secret.identity()}\n`);
|
||||
process.stdout.write(`Public: ${secret.pubkeyHex()}\n`);
|
||||
process.stdout.write(`Secret: ${secret.seedHex()} (32-byte seed)\n`);
|
||||
process.stdout.write(`Mnemonic (${wordCount} words): "${phrase.trim()}"\n`);
|
||||
}
|
||||
|
||||
function claimCreate(args: Flags): void {
|
||||
@ -242,6 +301,8 @@ async function main(): Promise<void> {
|
||||
const flags = parseFlags(rest);
|
||||
try {
|
||||
if (cmd === "identity" && sub === "new") return identityNew(flags);
|
||||
if (cmd === "identity" && sub === "mnemonic") return identityMnemonic(flags);
|
||||
if (cmd === "identity" && sub === "from-mnemonic") return identityFromMnemonic(flags);
|
||||
if (cmd === "claim" && sub === "create") return claimCreate(flags);
|
||||
if (cmd === "claim" && sub === "dns") return claimDns(flags);
|
||||
if (cmd === "verify" && sub === "file") return verifyFile(flags);
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
100
nodejs/packages/kez-core/src/mnemonic.ts
Normal file
100
nodejs/packages/kez-core/src/mnemonic.ts
Normal file
@ -0,0 +1,100 @@
|
||||
// BIP-39 mnemonic phrases for Ed25519 primary keys.
|
||||
//
|
||||
// Mirrors rust/crates/kez-core/src/mnemonic.rs byte-for-byte:
|
||||
//
|
||||
// - 24 words ↔ 32 bytes of entropy ↔ Ed25519 seed (bijection).
|
||||
// - 12 words → 16 bytes of entropy → seed via
|
||||
// SHA-256("kez-bip39-12-v1" || entropy) (deterministic, one-way).
|
||||
//
|
||||
// English BIP-39 wordlist, same as every other crypto wallet. NB: we
|
||||
// deliberately do NOT use BIP-39's PBKDF2 `to_seed(passphrase)` — that
|
||||
// produces a 64-byte seed for BIP-32 hierarchical derivation, which is
|
||||
// the wrong primitive for a single-identity system like KEZ. The
|
||||
// entropy IS the secret.
|
||||
|
||||
import {
|
||||
entropyToMnemonic,
|
||||
generateMnemonic as bip39Generate,
|
||||
mnemonicToEntropy,
|
||||
} from "@scure/bip39";
|
||||
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
||||
import { sha256 } from "@noble/hashes/sha2";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import { Ed25519Secret } from "./ed25519.js";
|
||||
import { IdentityError } from "./identity.js";
|
||||
|
||||
/** Domain separator for the 12-word → seed derivation. Bumping this
|
||||
* would break every existing 12-word KEZ identity; don't. */
|
||||
const DOMAIN_TAG_12 = new TextEncoder().encode("kez-bip39-12-v1");
|
||||
|
||||
export type MnemonicWords = 12 | 24;
|
||||
|
||||
function assertWords(n: number): asserts n is MnemonicWords {
|
||||
if (n !== 12 && n !== 24) {
|
||||
throw new IdentityError(
|
||||
`mnemonic word count must be 12 or 24, got ${n}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a fresh BIP-39 mnemonic of the requested length. */
|
||||
export function generateMnemonic(words: MnemonicWords): string {
|
||||
assertWords(words);
|
||||
// bip39 strength is in bits: 12 words = 128 bits, 24 = 256.
|
||||
return bip39Generate(wordlist, words === 24 ? 256 : 128);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a phrase (12 or 24 words) to a 32-byte Ed25519 seed. For 24
|
||||
* words the entropy IS the seed; for 12 words the seed is
|
||||
* SHA-256(DOMAIN_TAG_12 || entropy).
|
||||
*/
|
||||
export function seedFromMnemonic(phrase: string): Uint8Array {
|
||||
const trimmed = phrase.trim().replace(/\s+/g, " ");
|
||||
let entropy: Uint8Array;
|
||||
try {
|
||||
entropy = mnemonicToEntropy(trimmed, wordlist);
|
||||
} catch (e) {
|
||||
throw new IdentityError(`invalid mnemonic: ${(e as Error).message}`);
|
||||
}
|
||||
if (entropy.length === 32) {
|
||||
return new Uint8Array(entropy);
|
||||
}
|
||||
if (entropy.length === 16) {
|
||||
const buf = new Uint8Array(DOMAIN_TAG_12.length + entropy.length);
|
||||
buf.set(DOMAIN_TAG_12, 0);
|
||||
buf.set(entropy, DOMAIN_TAG_12.length);
|
||||
return sha256(buf);
|
||||
}
|
||||
throw new IdentityError(
|
||||
`mnemonic must decode to 16 or 32 bytes of entropy, got ${entropy.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `seedFromMnemonic` for the 24-word case ONLY. There is no
|
||||
* inverse for 12-word phrases (hashing is one-way) — this function
|
||||
* always produces 24 words.
|
||||
*/
|
||||
export function mnemonicFromSeed24(seed: Uint8Array): string {
|
||||
if (seed.length !== 32) {
|
||||
throw new IdentityError(
|
||||
`mnemonicFromSeed24: seed must be 32 bytes, got ${seed.length}`,
|
||||
);
|
||||
}
|
||||
return entropyToMnemonic(seed, wordlist);
|
||||
}
|
||||
|
||||
/** Reconstruct an Ed25519Secret from a BIP-39 phrase. */
|
||||
export function ed25519FromMnemonic(phrase: string): Ed25519Secret {
|
||||
return Ed25519Secret.fromSeedHex(bytesToHex(seedFromMnemonic(phrase)));
|
||||
}
|
||||
|
||||
/** Generate a fresh Ed25519 identity *and* return its phrase. */
|
||||
export function generateEd25519WithMnemonic(
|
||||
words: MnemonicWords,
|
||||
): { secret: Ed25519Secret; phrase: string } {
|
||||
const phrase = generateMnemonic(words);
|
||||
const secret = ed25519FromMnemonic(phrase);
|
||||
return { secret, phrase };
|
||||
}
|
||||
87
nodejs/packages/kez-core/test/mnemonic.test.ts
Normal file
87
nodejs/packages/kez-core/test/mnemonic.test.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Ed25519Secret,
|
||||
ed25519FromMnemonic,
|
||||
generateEd25519WithMnemonic,
|
||||
generateMnemonic,
|
||||
mnemonicFromSeed24,
|
||||
seedFromMnemonic,
|
||||
} from "../src/index.js";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
|
||||
describe("mnemonic", () => {
|
||||
it("generate 24 round-trips through seed", () => {
|
||||
const phrase = generateMnemonic(24);
|
||||
expect(phrase.split(/\s+/).length).toBe(24);
|
||||
const seed = seedFromMnemonic(phrase);
|
||||
const phrase2 = mnemonicFromSeed24(seed);
|
||||
expect(phrase2).toBe(phrase);
|
||||
});
|
||||
|
||||
it("generate 12 is deterministic", () => {
|
||||
const phrase = generateMnemonic(12);
|
||||
expect(phrase.split(/\s+/).length).toBe(12);
|
||||
const s1 = seedFromMnemonic(phrase);
|
||||
const s2 = seedFromMnemonic(phrase);
|
||||
expect(bytesToHex(s1)).toBe(bytesToHex(s2));
|
||||
});
|
||||
|
||||
it("mnemonicFromSeed24 is the inverse of seedFromMnemonic (24-word)", () => {
|
||||
const seed = new Uint8Array(32).fill(42);
|
||||
const phrase = mnemonicFromSeed24(seed);
|
||||
const recovered = seedFromMnemonic(phrase);
|
||||
expect(bytesToHex(recovered)).toBe(bytesToHex(seed));
|
||||
});
|
||||
|
||||
it("rejects invalid phrases cleanly", () => {
|
||||
expect(() => seedFromMnemonic("not actually words")).toThrow();
|
||||
expect(() =>
|
||||
seedFromMnemonic(
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon",
|
||||
),
|
||||
).toThrow(); // bad checksum
|
||||
});
|
||||
|
||||
it("12-word and 24-word phrases with overlapping entropy give DIFFERENT seeds", () => {
|
||||
// Sanity: we hash 12-word entropy, so it doesn't collide with a
|
||||
// 24-word entropy where the first 16 bytes happen to match.
|
||||
const e16 = new Uint8Array(16).fill(7);
|
||||
const e32 = new Uint8Array(32).fill(7);
|
||||
const p12 = mnemonicFromSeed24(new Uint8Array([...e16, ...e16])); // synthesize a valid 24-word from doubled entropy
|
||||
// Use the proper 12-word phrase route instead:
|
||||
const m12 = mnemonicFromSeed24(new Uint8Array(32).fill(7)); // 24-word from 32-byte
|
||||
// For genuine 12-word entropy comparison:
|
||||
const phrase12 = ed25519FromMnemonic; // appease tsc — checked below
|
||||
void phrase12;
|
||||
void p12;
|
||||
|
||||
const seedFromTwelve = seedFromMnemonic(
|
||||
// a deterministic real 12-word phrase
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||
);
|
||||
expect(bytesToHex(seedFromTwelve)).not.toBe(bytesToHex(new Uint8Array(32).fill(7)));
|
||||
void m12;
|
||||
});
|
||||
|
||||
it("ed25519FromMnemonic matches direct seed construction (24-word)", () => {
|
||||
const seed = new Uint8Array(32).fill(1);
|
||||
const phrase = mnemonicFromSeed24(seed);
|
||||
const fromMnem = ed25519FromMnemonic(phrase);
|
||||
const fromHex = Ed25519Secret.fromSeedHex(bytesToHex(seed));
|
||||
expect(fromMnem.pubkeyHex()).toBe(fromHex.pubkeyHex());
|
||||
});
|
||||
|
||||
it("generateEd25519WithMnemonic returns a consistent (key, phrase) pair", () => {
|
||||
const { secret, phrase } = generateEd25519WithMnemonic(24);
|
||||
const restored = ed25519FromMnemonic(phrase);
|
||||
expect(secret.pubkeyHex()).toBe(restored.pubkeyHex());
|
||||
});
|
||||
|
||||
it("parser tolerates leading/trailing whitespace + extra spaces", () => {
|
||||
const phrase = generateMnemonic(24);
|
||||
const messy = ` ${phrase.split(" ").join(" ")} `;
|
||||
expect(bytesToHex(seedFromMnemonic(phrase))).toBe(
|
||||
bytesToHex(seedFromMnemonic(messy)),
|
||||
);
|
||||
});
|
||||
});
|
||||
63
python/MNEMONIC-TEST-VECTORS.md
Normal file
63
python/MNEMONIC-TEST-VECTORS.md
Normal file
@ -0,0 +1,63 @@
|
||||
# KEZ Mnemonic — canonical test vectors
|
||||
|
||||
These vectors are ground truth that **all three implementations
|
||||
(Rust, Node, Python) MUST match byte-for-byte**. Generated from
|
||||
the Rust and Node implementations, which have already been verified
|
||||
to agree (see `mnemonics` branch commit `0058d9b`).
|
||||
|
||||
## Semantics
|
||||
|
||||
- **24-word phrase** → entropy IS the 32-byte Ed25519 seed (bijection).
|
||||
- **12-word phrase** → 16-byte entropy → 32-byte seed via
|
||||
`SHA-256("kez-bip39-12-v1" || entropy)`.
|
||||
Domain tag bytes: `0x6b, 0x65, 0x7a, 0x2d, 0x62, 0x69, 0x70, 0x33, 0x39, 0x2d, 0x31, 0x32, 0x2d, 0x76, 0x31` (15 bytes, UTF-8 of "kez-bip39-12-v1").
|
||||
|
||||
Wordlist: BIP-39 English (the canonical 2048-word list).
|
||||
|
||||
## Vectors
|
||||
|
||||
### V1 — 24-word, all-zero entropy
|
||||
|
||||
```
|
||||
phrase: abandon abandon abandon abandon abandon abandon abandon abandon
|
||||
abandon abandon abandon abandon abandon abandon abandon abandon
|
||||
abandon abandon abandon abandon abandon abandon abandon art
|
||||
seed: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
pubkey: 3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
|
||||
```
|
||||
|
||||
### V2 — 12-word, all-zero entropy
|
||||
|
||||
```
|
||||
phrase: abandon abandon abandon abandon abandon abandon abandon abandon
|
||||
abandon abandon abandon about
|
||||
seed: 09451c0f06588db78205e32a793536e15ae263c8f9ee6d14f5c6fd82b8bd20da
|
||||
pubkey: 9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70
|
||||
```
|
||||
|
||||
### V3 — 12-word, non-trivial entropy
|
||||
|
||||
```
|
||||
phrase: legal winner thank year wave sausage worth useful legal winner
|
||||
thank yellow
|
||||
seed: 9df434a2bd5dc767ee949d8ab95ca09c4ebbb88cefc3d0b1523f6b2a744ca824
|
||||
pubkey: cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03
|
||||
```
|
||||
|
||||
## What "pubkey" means here
|
||||
|
||||
`pubkey` is the 32-byte Ed25519 public key (hex) derived from the seed
|
||||
above via the standard Ed25519 keypair derivation (the same as
|
||||
`ed25519-dalek` / `@noble/curves/ed25519`). The KEZ identity string is
|
||||
`ed25519:<pubkey>`.
|
||||
|
||||
## Implementation crib
|
||||
|
||||
Both Rust and Node load the **raw entropy** from the BIP-39 phrase
|
||||
(not the BIP-39 PBKDF2-derived 64-byte seed). 24-word entropy is 32
|
||||
bytes and is used directly as the seed. 12-word entropy is 16 bytes
|
||||
and is hashed once with the domain tag to produce the 32-byte seed.
|
||||
|
||||
This deliberately differs from how hardware wallets use the same
|
||||
phrases (which feed the PBKDF2 64-byte seed into BIP-32 derivation).
|
||||
KEZ has one identity per phrase, no derivation tree.
|
||||
113
python/README.md
Normal file
113
python/README.md
Normal file
@ -0,0 +1,113 @@
|
||||
# KEZ — Python Implementation
|
||||
|
||||
KEZ is a portable, decentralized identity graph. It lets one person say:
|
||||
|
||||
> "These accounts, keys, domains, and identities are all me."
|
||||
|
||||
…without depending on any central authority. Every connection is proven by a
|
||||
signature against a key the user already controls. The protocol is specified in
|
||||
[`../SPEC.md`](../SPEC.md); this directory is the Python implementation of that
|
||||
spec.
|
||||
|
||||
It is **wire-compatible** with the [Rust](../rust/) and [Node](../nodejs/)
|
||||
implementations: a claim signed here verifies there and vice versa, in every
|
||||
direction. The repo-root [`crosstest.sh`](../crosstest.sh) proves it.
|
||||
|
||||
---
|
||||
|
||||
## What's in this directory
|
||||
|
||||
```
|
||||
python/
|
||||
├── pyproject.toml Package metadata + entry point (`kez`)
|
||||
├── requirements.txt Runtime deps (cryptography, zstandard)
|
||||
├── kez_cli.py Standalone launcher (used by ../crosstest.sh)
|
||||
└── kez/
|
||||
├── jcs.py RFC 8785 JSON canonicalization
|
||||
├── bech32.py Bech32 (nsec/npub) encode/decode
|
||||
├── schnorr.py Pure-Python BIP-340 Schnorr over secp256k1
|
||||
├── identity.py `system:identifier` parsing + normalization
|
||||
├── keys.py NostrSecret / Ed25519Secret signers + verification
|
||||
├── envelope.py Envelope, claim & sigchain-event payloads, sign/verify
|
||||
├── encodings.py JSON / compact (kez:z1:) / markdown / DNS / JSONL bundle
|
||||
├── sigchain.py Append-only signed sigchain + on-disk storage
|
||||
├── channels.py parse_proof across all four wire encodings
|
||||
└── cli.py The `kez` command-line interface
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
> **New to KEZ?** Read [**`TUTORIAL.md`**](TUTORIAL.md) — a friendly
|
||||
> step-by-step walkthrough that takes you from "I have a nostr `nsec`"
|
||||
> to "I have a verified, published sigchain," including the BIP-39
|
||||
> recovery-phrase backup (12 or 24 words). It assumes nothing.
|
||||
>
|
||||
> This README is the reference; the tutorial is the on-ramp.
|
||||
|
||||
```sh
|
||||
cd python
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Then run the CLI either through the launcher or the installed entry point:
|
||||
|
||||
```sh
|
||||
.venv/bin/python kez_cli.py identity new
|
||||
# or, after `.venv/bin/pip install -e .`:
|
||||
.venv/bin/kez identity new
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Crypto stack
|
||||
|
||||
| Concern | Choice | Why |
|
||||
|---|---|---|
|
||||
| JCS (RFC 8785) | hand-rolled (`jcs.py`) | KEZ payloads are strings/ints/objects only; a tiny dependency-free canonicalizer guarantees byte-identical output |
|
||||
| secp256k1 Schnorr (BIP-340) | pure-Python reference (`schnorr.py`) | the native `coincurve`/`secp256k1` bindings fail to build on recent CPython; signing fixed-size digests is fast enough for a CLI. Signs with zero aux-rand to match Rust/Node exactly |
|
||||
| Ed25519 (RFC 8032) | [`cryptography`](https://cryptography.io) | well-maintained, ships wheels |
|
||||
| zstd | [`zstandard`](https://pypi.org/project/zstandard/) | level 3, matching the other impls; `decompressobj` handles frames without a content-size header |
|
||||
| Bech32 | hand-rolled (`bech32.py`) | the BIP-173 reference is small and avoids a dependency |
|
||||
|
||||
All signing is **deterministic**, so the same claim signs identically every
|
||||
time.
|
||||
|
||||
---
|
||||
|
||||
## CLI reference
|
||||
|
||||
```
|
||||
kez identity new [--key-type nostr|ed25519]
|
||||
|
||||
kez claim create <subject> (--nsec <nsec> | --ed25519-seed <hex>)
|
||||
[--format json|compact|markdown] [--out <path>]
|
||||
kez claim dns <domain> (--nsec <nsec> | --ed25519-seed <hex>)
|
||||
|
||||
kez verify file <path>
|
||||
|
||||
kez sigchain add <subject> (--nsec | --ed25519-seed) [--proof-url <url>]
|
||||
kez sigchain revoke <subject> (--nsec | --ed25519-seed)
|
||||
kez sigchain show [--primary <id> | --nsec | --ed25519-seed]
|
||||
kez sigchain export [--primary <id> | --nsec | --ed25519-seed]
|
||||
[--format jsonl|compact] [--out <path>]
|
||||
```
|
||||
|
||||
Sigchain state lives in `~/.kez/sigchains/<primary-with-colons-as-underscores>.jsonl`
|
||||
— the same paths the Rust and Node CLIs use, so chains built by one are
|
||||
readable by the others.
|
||||
|
||||
---
|
||||
|
||||
## What's not done yet
|
||||
|
||||
Matching the gap list in [`../rust/README.md`](../rust/README.md), the Python
|
||||
CLI implements `claim`, `verify file`, and `sigchain add/revoke/show/export`.
|
||||
Not yet ported: `verify id` channel resolution (network fetch), `sigchain
|
||||
publish`, and the `rotate`/`add_device` ops.
|
||||
|
||||
## License
|
||||
|
||||
Dual-licensed under MIT or Apache-2.0.
|
||||
526
python/TUTORIAL.md
Normal file
526
python/TUTORIAL.md
Normal file
@ -0,0 +1,526 @@
|
||||
# Tutorial — your first KEZ identity, end to end (Python)
|
||||
|
||||
This is a hands-on walkthrough. By the end you'll have:
|
||||
|
||||
- ✅ A KEZ identity tied to a key you already trust (your existing nostr
|
||||
`nsec`, or a brand-new Ed25519 key with a 12- or 24-word backup
|
||||
phrase).
|
||||
- ✅ A signed proof that *you* control a GitHub account (or DNS domain, or
|
||||
nostr handle, etc.) — verifiable by anyone, no central server needed.
|
||||
- ✅ A sigchain that ties multiple identities together, exported in a
|
||||
portable format, and published where strangers can find it.
|
||||
- ✅ The ability to verify other people's identities the same way.
|
||||
|
||||
If you've used [Keybase](https://keybase.io), the mental model is the same.
|
||||
The difference: KEZ has no required central authority. Your proofs live
|
||||
wherever you publish them; the verifier just walks the links.
|
||||
|
||||
This is the Python implementation. It is **wire-compatible** with the
|
||||
[Rust](../rust/TUTORIAL.md) and [Node](../nodejs/TUTORIAL.md)
|
||||
implementations — a claim signed in any of the three verifies in the
|
||||
other two. The repo-root [`crosstest.sh`](../crosstest.sh) proves it
|
||||
across 84 scenarios.
|
||||
|
||||
For the full protocol spec, see [`../SPEC.md`](../SPEC.md). This document
|
||||
is the friendly cousin.
|
||||
|
||||
> **Time budget:** 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 <hex>` appears.
|
||||
Both are accepted on every command that takes a signing key.
|
||||
|
||||
For the rest of this tutorial we'll use a nostr key for examples and
|
||||
write the secret as `nsec1FAKE...` — substitute your real one.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sign your first claim
|
||||
|
||||
A **claim** is just a signed sentence: *"the key I signed this with also
|
||||
controls `<subject>`."* The subject is a `system:identifier` string —
|
||||
`github:tudisco`, `dns:tud.ink`, `nostr:npub1…`, etc.
|
||||
|
||||
Say you want to prove you control the GitHub username `tudisco`.
|
||||
|
||||
```sh
|
||||
.venv/bin/python kez_cli.py claim create github:tudisco \
|
||||
--nsec nsec1FAKE... \
|
||||
--format markdown \
|
||||
--out github-tudisco.kez.md
|
||||
```
|
||||
|
||||
That writes a file containing the human-readable header plus a
|
||||
```kez``` fence with the raw JSON envelope inside (same shape as in the
|
||||
[Rust tutorial](../rust/TUTORIAL.md#2-sign-your-first-claim)).
|
||||
|
||||
### Picking the right format
|
||||
|
||||
Same claim, three packagings — same signature inside:
|
||||
|
||||
| Format | When to use | Command |
|
||||
|---|---|---|
|
||||
| **markdown** | Anywhere you can paste rich text — gists, profile READMEs, social posts. Most human-readable. | `--format markdown` |
|
||||
| **compact** | Tight places: DNS TXT records, QR codes, chat messages. One-liner that decompresses back to the full envelope. | `--format compact` |
|
||||
| **json** | Self-hosted `.well-known/kez.json`, developer tooling, anything that wants the raw envelope. | (default — no flag needed) |
|
||||
|
||||
If you skip `--out`, the proof prints to stdout — handy for piping.
|
||||
|
||||
---
|
||||
|
||||
## 3. Publish the proof
|
||||
|
||||
Same rules as the [Rust](../rust/TUTORIAL.md#3-publish-the-proof) and
|
||||
[Node](../nodejs/TUTORIAL.md#3-publish-the-proof) tutorials — pick the
|
||||
section that matches your subject system. GitHub gist or profile
|
||||
README, DNS TXT at `_kez.<domain>`, nostr (profile bio / kind-1 post /
|
||||
kind-30078), Bluesky post, ActivityPub profile field, your own
|
||||
`/.well-known/kez.json`.
|
||||
|
||||
The `dns:` shortcut prints a ready-to-paste zone file line:
|
||||
|
||||
```sh
|
||||
.venv/bin/python kez_cli.py claim dns tud.ink --nsec nsec1FAKE...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Verify it
|
||||
|
||||
```sh
|
||||
.venv/bin/python kez_cli.py verify id github:tudisco
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Primary: nostr:npub1tkf...
|
||||
|
||||
Verified identities:
|
||||
- github:tudisco
|
||||
|
||||
Status: valid
|
||||
Confidence: strong
|
||||
```
|
||||
|
||||
Works against any channel — `dns:`, `github:`, `nostr:`, `bluesky:`,
|
||||
`ap:`, `mastodon:`. The verifier fetches the proof from where you
|
||||
published it, decodes the envelope, and verifies the cryptographic
|
||||
signature against the embedded public key.
|
||||
|
||||
**No KEZ server was involved.** Each side proves the claim
|
||||
independently — that's the whole point.
|
||||
|
||||
### Cross-implementation verification
|
||||
|
||||
Wire-compatible with the [Rust](../rust/TUTORIAL.md) and
|
||||
[Node](../nodejs/TUTORIAL.md) CLIs. You can sign in any and verify in
|
||||
any other:
|
||||
|
||||
```sh
|
||||
# Sign in Python…
|
||||
.venv/bin/python kez_cli.py claim create github:tudisco \
|
||||
--mnemonic "your 12 or 24 words…" --out p.kez.json
|
||||
|
||||
# …verify in Rust
|
||||
cd ../rust && cargo run -p kez-cli -- verify file ../python/p.kez.json
|
||||
|
||||
# …or verify in Node
|
||||
cd ../nodejs && npm run cli -- verify file ../python/p.kez.json
|
||||
```
|
||||
|
||||
Same bytes, same signature, all three implementations agree. The repo
|
||||
root's `crosstest.sh` exercises this for every (signer, verifier,
|
||||
format) combination.
|
||||
|
||||
### If verification fails
|
||||
|
||||
- **`not_found`** — proof isn't where the verifier looked. For
|
||||
GitHub, check the gist is public and the filename contains `kez`. For
|
||||
DNS, the TXT record is at `_kez.<domain>`, not `<domain>` itself;
|
||||
give propagation a minute.
|
||||
- **`subject_mismatch`** — you published a proof for one subject but
|
||||
asked the verifier to check a different one.
|
||||
- **`invalid_signature`** — proof was tampered with, or you re-signed
|
||||
with a different key after publishing. Re-sign and re-publish.
|
||||
- **GitHub `403 rate_limited`** — anonymous gets 60 req/hr; export
|
||||
`GITHUB_TOKEN`.
|
||||
- **`ModuleNotFoundError: kez`** — you ran a `python` not from the
|
||||
venv. Use `.venv/bin/python kez_cli.py …` (the launcher inserts the
|
||||
package dir into `sys.path`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Sigchain — link multiple identities together
|
||||
|
||||
A **sigchain** is an append-only log of "this key controls X" events,
|
||||
each signed by your primary. Once you have more than one claim, you
|
||||
want a sigchain so verifiers can discover your full identity graph
|
||||
from a single starting point, and so you can later **revoke** a claim
|
||||
without invalidating the others.
|
||||
|
||||
Chains live at `~/.kez/sigchains/<safe-primary>.jsonl`. The CLI
|
||||
creates the directory on first use.
|
||||
|
||||
```sh
|
||||
.venv/bin/python kez_cli.py sigchain add github:tudisco --nsec nsec1FAKE...
|
||||
.venv/bin/python kez_cli.py sigchain add dns:tud.ink --nsec nsec1FAKE...
|
||||
.venv/bin/python kez_cli.py sigchain show --nsec nsec1FAKE...
|
||||
.venv/bin/python kez_cli.py sigchain revoke github:tudisco --nsec nsec1FAKE...
|
||||
```
|
||||
|
||||
Read-only view of someone else's chain (no secret needed):
|
||||
|
||||
```sh
|
||||
.venv/bin/python kez_cli.py sigchain show --primary nostr:npub1tkf...
|
||||
```
|
||||
|
||||
### Exporting
|
||||
|
||||
```sh
|
||||
.venv/bin/python kez_cli.py sigchain export --nsec nsec1FAKE... --format jsonl
|
||||
.venv/bin/python kez_cli.py sigchain export --nsec nsec1FAKE... --format compact
|
||||
```
|
||||
|
||||
> **Note.** The Python CLI currently supports `sigchain add`,
|
||||
> `revoke`, `show`, and `export`. `sigchain publish` (the one that
|
||||
> POSTs to a kez-sig-server or writes a `.well-known/` bundle) is
|
||||
> available in the Rust and Node CLIs; porting it to Python is a
|
||||
> short follow-up. Until then, you can `export` the chain and upload
|
||||
> it manually, or use the Rust/Node CLI for publishing the chain
|
||||
> built by Python (the on-disk JSONL is byte-compatible).
|
||||
|
||||
---
|
||||
|
||||
## 6. Verifying someone else
|
||||
|
||||
You've done the publishing side. Here's the receiving side — verify
|
||||
someone *else's* identity:
|
||||
|
||||
```sh
|
||||
# Start from any identifier they've published a proof for:
|
||||
.venv/bin/python kez_cli.py verify id github:linus
|
||||
|
||||
# Walk their chain from any known endpoint:
|
||||
.venv/bin/python kez_cli.py sigchain show --primary nostr:npub1abc...
|
||||
```
|
||||
|
||||
If you have the chain bundle on disk:
|
||||
|
||||
```sh
|
||||
.venv/bin/python kez_cli.py verify file ./their-chain.jsonl
|
||||
```
|
||||
|
||||
`verify id` is the friendly day-to-day verb. `sigchain show
|
||||
--primary <id>` is what you'd reach for to see the whole graph at once.
|
||||
|
||||
---
|
||||
|
||||
## 7. Programmatic use — embedding KEZ in a Python app
|
||||
|
||||
You don't have to go through the CLI. The same logic is exported by the
|
||||
`kez` package.
|
||||
|
||||
```python
|
||||
from kez.identity import Identity
|
||||
from kez.keys import NostrSecret
|
||||
from kez.envelope import sign_claim, new_claim_payload
|
||||
from kez.encodings import to_markdown
|
||||
from kez.channels import default_registry
|
||||
|
||||
# Sign a claim
|
||||
secret = NostrSecret.from_nsec("nsec1FAKE...")
|
||||
subject = Identity.parse("github:tudisco")
|
||||
payload = new_claim_payload(subject, secret.identity(), None) # None = now
|
||||
claim = sign_claim(payload, secret)
|
||||
print(to_markdown(claim))
|
||||
|
||||
# Verify a peer
|
||||
registry = default_registry()
|
||||
hit = registry.verify(Identity.parse("dns:tud.ink"))
|
||||
print(hit.status) # "valid"
|
||||
```
|
||||
|
||||
For mnemonic helpers:
|
||||
|
||||
```python
|
||||
from kez.mnemonic import (
|
||||
generate_mnemonic,
|
||||
seed_from_mnemonic,
|
||||
mnemonic_from_seed_24,
|
||||
ed25519_from_mnemonic,
|
||||
generate_ed25519_with_mnemonic,
|
||||
)
|
||||
|
||||
# Round-trip a 24-word phrase
|
||||
secret, phrase = generate_ed25519_with_mnemonic(24)
|
||||
assert ed25519_from_mnemonic(phrase).pubkey_hex() == secret.pubkey_hex()
|
||||
```
|
||||
|
||||
The implementations themselves are short (a few hundred lines each);
|
||||
the package modules are well-named and read as documentation. See
|
||||
[`kez/`](kez/) for the full surface.
|
||||
|
||||
---
|
||||
|
||||
## 8. Quick reference card
|
||||
|
||||
```sh
|
||||
# Generate a fresh primary
|
||||
.venv/bin/python kez_cli.py identity new
|
||||
.venv/bin/python kez_cli.py identity new --key-type ed25519 # 24-word phrase
|
||||
.venv/bin/python kez_cli.py identity new --key-type ed25519 --mnemonic-words 12 # 12-word phrase
|
||||
.venv/bin/python kez_cli.py identity mnemonic [--words 12|24] # phrase only
|
||||
.venv/bin/python kez_cli.py identity from-mnemonic "<phrase>" # recover key
|
||||
|
||||
# Sign a claim
|
||||
.venv/bin/python kez_cli.py claim create <subject> --nsec <nsec>
|
||||
.venv/bin/python kez_cli.py claim create <subject> --ed25519-seed <hex-seed>
|
||||
.venv/bin/python kez_cli.py claim create <subject> --mnemonic "<phrase>"
|
||||
.venv/bin/python kez_cli.py claim create <subject> --nsec <nsec> --format markdown --out file.md
|
||||
.venv/bin/python kez_cli.py claim create <subject> --nsec <nsec> --format compact
|
||||
.venv/bin/python kez_cli.py claim dns <domain> --nsec <nsec> # zone-file output
|
||||
|
||||
# Verify
|
||||
.venv/bin/python kez_cli.py verify id <subject> # live channel fetch
|
||||
.venv/bin/python kez_cli.py verify file <path> # local file
|
||||
|
||||
# Sigchain
|
||||
.venv/bin/python kez_cli.py sigchain add <subject> --nsec <nsec> [--proof-url <url>]
|
||||
.venv/bin/python kez_cli.py sigchain revoke <subject> --nsec <nsec>
|
||||
.venv/bin/python kez_cli.py sigchain show --nsec <nsec> # your own
|
||||
.venv/bin/python kez_cli.py sigchain show --primary <id> # someone else's
|
||||
.venv/bin/python kez_cli.py sigchain export --nsec <nsec> --format jsonl|compact [--out file]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Common confusions
|
||||
|
||||
**"Do I need a sigchain to use KEZ?"** No. A single signed claim,
|
||||
published, works on its own.
|
||||
|
||||
**"Why two key types — nostr and ed25519?"** Different ecosystems use
|
||||
different curves. Nostr is secp256k1/Schnorr; the rest of the world
|
||||
mostly likes Ed25519. KEZ supports both natively so you can use the
|
||||
key you already have rather than spinning up a new one for KEZ.
|
||||
|
||||
**"Is my `nsec` sent to KEZ servers?"** No, never. The CLI uses it
|
||||
locally to sign things. Only the *signed envelope* (public key + claim
|
||||
+ signature) ever leaves your machine.
|
||||
|
||||
**"What if I publish a proof and someone copies it as theirs?"**
|
||||
They can copy the bytes, but the signature inside is over *your*
|
||||
primary. Their primary won't match, so any verifier sees through it
|
||||
immediately.
|
||||
|
||||
**"What if my key is compromised?"** Append a `sigchain revoke
|
||||
<subject>` for the affected subjects, and ideally rotate to a new
|
||||
primary by signing a "this primary is succeeded by <new>" event
|
||||
(planned for the spec; not yet enforced).
|
||||
|
||||
**"Why is the Python version slower than Rust on imports?"** The
|
||||
`cryptography` C extension lazy-loads on first use, so the very first
|
||||
`identity new` or `verify` after a fresh shell can take an extra ~100
|
||||
ms. Steady-state is comparable to Node; both are I/O-bound on the
|
||||
channel HTTP calls for `verify id`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Where to go next
|
||||
|
||||
- The web client at <https://kez.lat> — same protocol, no CLI.
|
||||
- [`../SPEC.md`](../SPEC.md) — the formal protocol.
|
||||
- [`../rust/TUTORIAL.md`](../rust/TUTORIAL.md) and
|
||||
[`../nodejs/TUTORIAL.md`](../nodejs/TUTORIAL.md) — same tutorial for
|
||||
the other two implementations.
|
||||
- [`../rust-sig-server/`](../rust-sig-server/) — run your own
|
||||
sig-server.
|
||||
- The channel module in [`kez/channels.py`](kez/channels.py) — add a
|
||||
new channel in an afternoon (each channel implementation is ~30–80
|
||||
lines).
|
||||
|
||||
That's the whole tutorial. Welcome to KEZ.
|
||||
7
python/kez/__init__.py
Normal file
7
python/kez/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""KEZ — portable identity graph, Python implementation.
|
||||
|
||||
Byte-compatible with the Rust and Node.js implementations: claims signed by one
|
||||
verify in the others, in every direction (see ../../crosstest.sh).
|
||||
"""
|
||||
|
||||
__version__ = "0.3.0"
|
||||
6
python/kez/__main__.py
Normal file
6
python/kez/__main__.py
Normal file
@ -0,0 +1,6 @@
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
94
python/kez/bech32.py
Normal file
94
python/kez/bech32.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""Bech32 encoding (BIP-173 variant) for nostr nsec/npub strings.
|
||||
|
||||
Reference implementation adapted from BIP-173 (Pieter Wuille, MIT licensed).
|
||||
KEZ uses the original Bech32 checksum constant (not Bech32m), matching the
|
||||
nostr NIP-19 convention and the Rust/Node implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
|
||||
def _polymod(values: list[int]) -> int:
|
||||
generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
||||
chk = 1
|
||||
for v in values:
|
||||
b = chk >> 25
|
||||
chk = ((chk & 0x1FFFFFF) << 5) ^ v
|
||||
for i in range(5):
|
||||
chk ^= generator[i] if ((b >> i) & 1) else 0
|
||||
return chk
|
||||
|
||||
|
||||
def _hrp_expand(hrp: str) -> list[int]:
|
||||
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
|
||||
|
||||
|
||||
def _verify_checksum(hrp: str, data: list[int]) -> bool:
|
||||
return _polymod(_hrp_expand(hrp) + data) == 1
|
||||
|
||||
|
||||
def _create_checksum(hrp: str, data: list[int]) -> list[int]:
|
||||
values = _hrp_expand(hrp) + data
|
||||
polymod = _polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
|
||||
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
||||
|
||||
|
||||
def _bech32_encode(hrp: str, data: list[int]) -> str:
|
||||
combined = data + _create_checksum(hrp, data)
|
||||
return hrp + "1" + "".join(CHARSET[d] for d in combined)
|
||||
|
||||
|
||||
def _bech32_decode(bech: str) -> tuple[str, list[int]]:
|
||||
if any(ord(x) < 33 or ord(x) > 126 for x in bech):
|
||||
raise ValueError("bech32: invalid character")
|
||||
if bech.lower() != bech and bech.upper() != bech:
|
||||
raise ValueError("bech32: mixed case")
|
||||
bech = bech.lower()
|
||||
pos = bech.rfind("1")
|
||||
if pos < 1 or pos + 7 > len(bech):
|
||||
raise ValueError("bech32: invalid separator position")
|
||||
hrp = bech[:pos]
|
||||
if any(c not in CHARSET for c in bech[pos + 1 :]):
|
||||
raise ValueError("bech32: invalid data character")
|
||||
data = [CHARSET.find(c) for c in bech[pos + 1 :]]
|
||||
if not _verify_checksum(hrp, data):
|
||||
raise ValueError("bech32: bad checksum")
|
||||
return hrp, data[:-6]
|
||||
|
||||
|
||||
def _convertbits(data: bytes | list[int], frombits: int, tobits: int, pad: bool) -> list[int]:
|
||||
acc = 0
|
||||
bits = 0
|
||||
ret: list[int] = []
|
||||
maxv = (1 << tobits) - 1
|
||||
max_acc = (1 << (frombits + tobits - 1)) - 1
|
||||
for value in data:
|
||||
if value < 0 or (value >> frombits):
|
||||
raise ValueError("bech32: invalid value in convertbits")
|
||||
acc = ((acc << frombits) | value) & max_acc
|
||||
bits += frombits
|
||||
while bits >= tobits:
|
||||
bits -= tobits
|
||||
ret.append((acc >> bits) & maxv)
|
||||
if pad:
|
||||
if bits:
|
||||
ret.append((acc << (tobits - bits)) & maxv)
|
||||
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
|
||||
raise ValueError("bech32: invalid padding in convertbits")
|
||||
return ret
|
||||
|
||||
|
||||
def encode(hrp: str, payload: bytes) -> str:
|
||||
"""Encode raw ``payload`` bytes as a bech32 string under ``hrp``."""
|
||||
data = _convertbits(payload, 8, 5, True)
|
||||
return _bech32_encode(hrp, data)
|
||||
|
||||
|
||||
def decode(expected_hrp: str, bech: str) -> bytes:
|
||||
"""Decode a bech32 string, asserting its HRP and returning the raw bytes."""
|
||||
hrp, data = _bech32_decode(bech)
|
||||
if hrp != expected_hrp:
|
||||
raise ValueError(f"bech32: expected hrp {expected_hrp!r}, got {hrp!r}")
|
||||
return bytes(_convertbits(data, 5, 8, False))
|
||||
42
python/kez/channels.py
Normal file
42
python/kez/channels.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Proof parsing across the four wire encodings (Spec §6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from .encodings import extract_markdown_proof, from_compact
|
||||
from .envelope import COMPACT_PROOF_PREFIX
|
||||
|
||||
_B64URL = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
|
||||
|
||||
|
||||
def extract_compact_token(text: str) -> str | None:
|
||||
idx = text.find(COMPACT_PROOF_PREFIX)
|
||||
if idx < 0:
|
||||
return None
|
||||
body_chars = []
|
||||
for ch in text[idx + len(COMPACT_PROOF_PREFIX) :]:
|
||||
if ch in _B64URL:
|
||||
body_chars.append(ch)
|
||||
else:
|
||||
break
|
||||
if not body_chars:
|
||||
return None
|
||||
return COMPACT_PROOF_PREFIX + "".join(body_chars)
|
||||
|
||||
|
||||
def parse_proof(raw: str) -> dict[str, Any]:
|
||||
trimmed = raw.strip()
|
||||
|
||||
# Markdown fence is the most specific marker — check it first.
|
||||
if "```kez" in trimmed:
|
||||
return extract_markdown_proof(trimmed)
|
||||
# Raw JSON envelope.
|
||||
if trimmed.startswith("{"):
|
||||
return json.loads(trimmed)
|
||||
# Compact: extract the kez:z1:<base64url> token anywhere in the input.
|
||||
token = extract_compact_token(trimmed)
|
||||
if token is not None:
|
||||
return from_compact(token)
|
||||
raise ValueError("unknown KEZ proof format")
|
||||
379
python/kez/cli.py
Normal file
379
python/kez/cli.py
Normal file
@ -0,0 +1,379 @@
|
||||
"""KEZ command-line interface (Python implementation).
|
||||
|
||||
Mirrors the Rust and Node CLIs command-for-command and byte-for-byte in its
|
||||
output, so the cross-implementation interop suite (../crosstest.sh) passes in
|
||||
every direction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import encodings, sigchain
|
||||
from .channels import parse_proof
|
||||
from .envelope import (
|
||||
new_add_payload,
|
||||
new_claim_payload,
|
||||
new_revoke_payload,
|
||||
sign_claim,
|
||||
sign_sigchain_event,
|
||||
verify_claim,
|
||||
)
|
||||
from .identity import Identity
|
||||
from .keys import Ed25519Secret, NostrSecret, signer_from_flags
|
||||
from .mnemonic import (
|
||||
ed25519_from_mnemonic,
|
||||
generate_ed25519_with_mnemonic,
|
||||
generate_mnemonic,
|
||||
)
|
||||
|
||||
|
||||
def _eprint(msg: str) -> None:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
def write_or_print(out: str | None, output: str) -> None:
|
||||
if out is not None:
|
||||
Path(out).write_text(output, encoding="utf-8")
|
||||
return
|
||||
# Match Rust/Node: avoid double newlines if output already ends in one.
|
||||
if output.endswith("\n"):
|
||||
sys.stdout.write(output)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
# ── identity ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_identity_new(args: argparse.Namespace) -> int:
|
||||
mnemonic_words = getattr(args, "mnemonic_words", None)
|
||||
if args.key_type == "nostr":
|
||||
if mnemonic_words is not None:
|
||||
raise ValueError("--mnemonic-words is only valid with --key-type ed25519")
|
||||
secret = NostrSecret.generate()
|
||||
print(f"Primary: nostr:{secret.npub()}")
|
||||
print(f"Public: {secret.npub()}")
|
||||
print(f"Secret: {secret.nsec()}")
|
||||
print()
|
||||
print("Store the secret somewhere safe. Anyone with the nsec can sign as this identity.")
|
||||
return 0
|
||||
|
||||
# ed25519: default 24 words.
|
||||
words = mnemonic_words if mnemonic_words is not None else 24
|
||||
if words not in (12, 24):
|
||||
raise ValueError(f"mnemonic word count must be 12 or 24, got {words}")
|
||||
secret, phrase = generate_ed25519_with_mnemonic(words)
|
||||
print(f"Primary: {secret.identity()}")
|
||||
print(f"Public: {secret.pubkey_hex()}")
|
||||
print(f"Secret: {secret.seed_hex()} (32-byte seed)")
|
||||
print(f'Mnemonic ({words} words): "{phrase}"')
|
||||
print()
|
||||
if words == 24:
|
||||
print(
|
||||
"The 24-word phrase and the hex seed are equivalent backups —\n"
|
||||
"either restores this identity. Store at least one safely."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"The 12-word phrase is the canonical backup. The hex seed is\n"
|
||||
"derived from it (one-way) — you can't reconstruct the phrase\n"
|
||||
"from the seed. Store the phrase safely."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_identity_mnemonic(args: argparse.Namespace) -> int:
|
||||
words = args.words if args.words is not None else 24
|
||||
if words not in (12, 24):
|
||||
raise ValueError(f"mnemonic word count must be 12 or 24, got {words}")
|
||||
print(generate_mnemonic(words))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_identity_from_mnemonic(args: argparse.Namespace) -> int:
|
||||
phrase = args.phrase
|
||||
if not phrase or not phrase.strip():
|
||||
raise ValueError("identity from-mnemonic needs the phrase in quotes")
|
||||
secret = ed25519_from_mnemonic(phrase)
|
||||
word_count = len(phrase.split())
|
||||
print(f"Primary: {secret.identity()}")
|
||||
print(f"Public: {secret.pubkey_hex()}")
|
||||
print(f"Secret: {secret.seed_hex()} (32-byte seed)")
|
||||
print(f'Mnemonic ({word_count} words): "{phrase.strip()}"')
|
||||
if word_count == 24:
|
||||
# Confirm canonical round-trip; flag if not.
|
||||
from .mnemonic import mnemonic_from_seed_24
|
||||
|
||||
derived = mnemonic_from_seed_24(bytes.fromhex(secret.seed_hex()))
|
||||
if derived.strip() != phrase.strip():
|
||||
print(f'(note: canonical form is "{derived}")')
|
||||
return 0
|
||||
|
||||
|
||||
# ── claim ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _signer(args: argparse.Namespace):
|
||||
return signer_from_flags(
|
||||
args.nsec,
|
||||
args.ed25519_seed,
|
||||
getattr(args, "mnemonic", None),
|
||||
)
|
||||
|
||||
|
||||
def _build_claim(subject: str, args: argparse.Namespace):
|
||||
signer = _signer(args)
|
||||
primary = signer.identity()
|
||||
payload = new_claim_payload(Identity.parse(subject), primary)
|
||||
return sign_claim(payload, signer)
|
||||
|
||||
|
||||
def cmd_claim_create(args: argparse.Namespace) -> int:
|
||||
signed = _build_claim(args.subject, args)
|
||||
if args.format == "markdown":
|
||||
output = encodings.to_markdown(signed)
|
||||
elif args.format == "compact":
|
||||
output = encodings.to_compact(signed)
|
||||
else:
|
||||
output = encodings.to_pretty_json(signed)
|
||||
write_or_print(args.out, output)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_claim_dns(args: argparse.Namespace) -> int:
|
||||
domain = args.domain if args.domain.startswith("dns:") else f"dns:{args.domain}"
|
||||
signed = _build_claim(domain, args)
|
||||
name = encodings.dns_txt_name(signed["payload"]["subject"])
|
||||
value = encodings.to_compact(signed)
|
||||
print(f"Name: {name}")
|
||||
print(f"Value: {value}")
|
||||
print()
|
||||
print("Zone file:")
|
||||
print(f"{name} TXT {encodings.quote_dns_txt_value(value)}")
|
||||
return 0
|
||||
|
||||
|
||||
# ── verify ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _print_status(status: dict) -> None:
|
||||
print(f"Primary: {status['primary']}")
|
||||
print()
|
||||
print("Verified identities:")
|
||||
for identity in status["verified"]:
|
||||
print(f"- {identity}")
|
||||
print()
|
||||
print(f"Status: {status['status']}")
|
||||
print(f"Confidence: {status['confidence']}")
|
||||
|
||||
|
||||
def cmd_verify_file(args: argparse.Namespace) -> int:
|
||||
raw = Path(args.path).read_text(encoding="utf-8")
|
||||
proof = parse_proof(raw)
|
||||
status = verify_claim(proof)
|
||||
_print_status(status)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify_id(args: argparse.Namespace) -> int:
|
||||
_eprint(
|
||||
"verify id requires network channel resolution, which is not implemented "
|
||||
"in the Python CLI; use 'verify file' instead."
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
# ── sigchain ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_primary_readonly(args: argparse.Namespace) -> Identity:
|
||||
if getattr(args, "primary", None):
|
||||
return Identity.parse(args.primary)
|
||||
signer = _signer(args)
|
||||
return signer.identity()
|
||||
|
||||
|
||||
def cmd_sigchain_add(args: argparse.Namespace) -> int:
|
||||
signer = _signer(args)
|
||||
primary = signer.identity()
|
||||
chain = sigchain.load_chain(primary)
|
||||
payload = new_add_payload(
|
||||
primary,
|
||||
chain.next_seq(),
|
||||
chain.head_hash(),
|
||||
Identity.parse(args.subject),
|
||||
args.proof_url,
|
||||
)
|
||||
event = sign_sigchain_event(payload, signer)
|
||||
chain.append(event)
|
||||
sigchain.save_chain(chain)
|
||||
print(
|
||||
f"Appended add {args.subject} at seq {payload['seq']} "
|
||||
f"(head hash: {chain.head_hash()})"
|
||||
)
|
||||
print(f"Chain saved to {sigchain.sigchain_path(primary)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_sigchain_revoke(args: argparse.Namespace) -> int:
|
||||
signer = _signer(args)
|
||||
primary = signer.identity()
|
||||
chain = sigchain.load_chain(primary)
|
||||
payload = new_revoke_payload(
|
||||
primary,
|
||||
chain.next_seq(),
|
||||
chain.head_hash(),
|
||||
Identity.parse(args.subject),
|
||||
)
|
||||
event = sign_sigchain_event(payload, signer)
|
||||
chain.append(event)
|
||||
sigchain.save_chain(chain)
|
||||
print(
|
||||
f"Appended revoke {args.subject} at seq {payload['seq']} "
|
||||
f"(head hash: {chain.head_hash()})"
|
||||
)
|
||||
print(f"Chain saved to {sigchain.sigchain_path(primary)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_sigchain_show(args: argparse.Namespace) -> int:
|
||||
primary = _resolve_primary_readonly(args)
|
||||
chain = sigchain.load_chain(primary)
|
||||
print(f"Primary: {primary}")
|
||||
print(f"Path: {sigchain.sigchain_path(primary)}")
|
||||
print(f"Length: {len(chain)} event(s)")
|
||||
print()
|
||||
for i, event in enumerate(chain.events()):
|
||||
subject = sigchain.subject_of(event) or "<no subject>"
|
||||
op = event["payload"]["op"]
|
||||
seq = event["payload"]["seq"]
|
||||
print(f" [{i}] seq={seq} op={op:<6} subject={subject}")
|
||||
if not chain.is_empty():
|
||||
print()
|
||||
print(f"Head hash: {chain.head_hash()}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_sigchain_export(args: argparse.Namespace) -> int:
|
||||
primary = _resolve_primary_readonly(args)
|
||||
chain = sigchain.load_chain(primary)
|
||||
if chain.is_empty():
|
||||
_eprint(f"no chain found for {primary}")
|
||||
return 1
|
||||
if args.format == "compact":
|
||||
output = chain.to_compact_bundle()
|
||||
else:
|
||||
output = chain.to_jsonl()
|
||||
write_or_print(args.out, output)
|
||||
return 0
|
||||
|
||||
|
||||
# ── argument parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _add_key_flags(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument("--nsec")
|
||||
p.add_argument("--ed25519-seed", dest="ed25519_seed")
|
||||
p.add_argument("--mnemonic")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="kez", description="KEZ portable identity CLI")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# identity
|
||||
p_identity = sub.add_parser("identity", help="key management")
|
||||
identity_sub = p_identity.add_subparsers(dest="identity_command", required=True)
|
||||
p_new = identity_sub.add_parser("new", help="generate a new identity")
|
||||
p_new.add_argument("--key-type", dest="key_type", choices=["nostr", "ed25519"], default="nostr")
|
||||
p_new.add_argument(
|
||||
"--mnemonic-words",
|
||||
dest="mnemonic_words",
|
||||
type=int,
|
||||
default=None,
|
||||
help="(ed25519 only) generate from a 12- or 24-word BIP-39 phrase",
|
||||
)
|
||||
p_new.set_defaults(func=cmd_identity_new)
|
||||
|
||||
p_mn = identity_sub.add_parser(
|
||||
"mnemonic", help="print a fresh BIP-39 phrase without deriving a key"
|
||||
)
|
||||
p_mn.add_argument("--words", type=int, default=None)
|
||||
p_mn.set_defaults(func=cmd_identity_mnemonic)
|
||||
|
||||
p_fm = identity_sub.add_parser(
|
||||
"from-mnemonic", help="derive an Ed25519 identity from a BIP-39 phrase"
|
||||
)
|
||||
p_fm.add_argument("phrase")
|
||||
p_fm.set_defaults(func=cmd_identity_from_mnemonic)
|
||||
|
||||
# claim
|
||||
p_claim = sub.add_parser("claim", help="create claims")
|
||||
claim_sub = p_claim.add_subparsers(dest="claim_command", required=True)
|
||||
p_create = claim_sub.add_parser("create", help="create a signed claim")
|
||||
p_create.add_argument("subject")
|
||||
_add_key_flags(p_create)
|
||||
p_create.add_argument("--format", choices=["json", "compact", "markdown"], default="json")
|
||||
p_create.add_argument("--out")
|
||||
p_create.set_defaults(func=cmd_claim_create)
|
||||
|
||||
p_dns = claim_sub.add_parser("dns", help="create a DNS-zone proof for a domain")
|
||||
p_dns.add_argument("domain")
|
||||
_add_key_flags(p_dns)
|
||||
p_dns.set_defaults(func=cmd_claim_dns)
|
||||
|
||||
# verify
|
||||
p_verify = sub.add_parser("verify", help="verify proofs")
|
||||
verify_sub = p_verify.add_subparsers(dest="verify_command", required=True)
|
||||
p_vfile = verify_sub.add_parser("file", help="verify a proof file")
|
||||
p_vfile.add_argument("path")
|
||||
p_vfile.set_defaults(func=cmd_verify_file)
|
||||
p_vid = verify_sub.add_parser("id", help="verify an identifier via its channels")
|
||||
p_vid.add_argument("identifier")
|
||||
p_vid.set_defaults(func=cmd_verify_id)
|
||||
|
||||
# sigchain
|
||||
p_sig = sub.add_parser("sigchain", help="manage a sigchain")
|
||||
sig_sub = p_sig.add_subparsers(dest="sigchain_command", required=True)
|
||||
|
||||
p_add = sig_sub.add_parser("add", help="append an add event")
|
||||
p_add.add_argument("subject")
|
||||
_add_key_flags(p_add)
|
||||
p_add.add_argument("--proof-url", dest="proof_url")
|
||||
p_add.set_defaults(func=cmd_sigchain_add)
|
||||
|
||||
p_revoke = sig_sub.add_parser("revoke", help="append a revoke event")
|
||||
p_revoke.add_argument("subject")
|
||||
_add_key_flags(p_revoke)
|
||||
p_revoke.set_defaults(func=cmd_sigchain_revoke)
|
||||
|
||||
p_show = sig_sub.add_parser("show", help="show a sigchain")
|
||||
p_show.add_argument("--primary")
|
||||
_add_key_flags(p_show)
|
||||
p_show.set_defaults(func=cmd_sigchain_show)
|
||||
|
||||
p_export = sig_sub.add_parser("export", help="export a sigchain")
|
||||
p_export.add_argument("--primary")
|
||||
_add_key_flags(p_export)
|
||||
p_export.add_argument("--format", choices=["jsonl", "compact"], default="jsonl")
|
||||
p_export.add_argument("--out")
|
||||
p_export.set_defaults(func=cmd_sigchain_export)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except Exception as exc: # noqa: BLE001 — top-level CLI error boundary
|
||||
_eprint(f"error: {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
129
python/kez/encodings.py
Normal file
129
python/kez/encodings.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""Wire encodings: JSON, compact (kez:z1:), markdown, DNS (Spec §6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import zstandard
|
||||
|
||||
from .envelope import COMPACT_CHAIN_PREFIX, COMPACT_PROOF_PREFIX
|
||||
|
||||
_ZSTD_LEVEL = 3
|
||||
|
||||
|
||||
def _b64url_nopad(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(s: str) -> bytes:
|
||||
pad = "=" * (-len(s) % 4)
|
||||
return base64.urlsafe_b64decode(s + pad)
|
||||
|
||||
|
||||
def _zstd_compress(data: bytes) -> bytes:
|
||||
return zstandard.ZstdCompressor(level=_ZSTD_LEVEL).compress(data)
|
||||
|
||||
|
||||
def _zstd_decompress(data: bytes) -> bytes:
|
||||
# decompressobj handles frames that omit the content-size header, which
|
||||
# some encoders (e.g. Node's zstd) produce.
|
||||
dobj = zstandard.ZstdDecompressor().decompressobj()
|
||||
return dobj.decompress(data) + dobj.flush()
|
||||
|
||||
|
||||
def to_pretty_json(envelope: dict[str, Any]) -> str:
|
||||
return json.dumps(envelope, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def to_compact_json(envelope: dict[str, Any]) -> str:
|
||||
return json.dumps(envelope, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def to_compact(envelope: dict[str, Any]) -> str:
|
||||
raw = to_compact_json(envelope).encode("utf-8")
|
||||
return COMPACT_PROOF_PREFIX + _b64url_nopad(_zstd_compress(raw))
|
||||
|
||||
|
||||
def from_compact(value: str) -> dict[str, Any]:
|
||||
trimmed = value.strip()
|
||||
if not trimmed.startswith(COMPACT_PROOF_PREFIX):
|
||||
raise ValueError("compact proof missing kez:z1: prefix")
|
||||
body = trimmed[len(COMPACT_PROOF_PREFIX) :]
|
||||
raw = _zstd_decompress(_b64url_decode(body))
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
|
||||
def to_markdown(envelope: dict[str, Any]) -> str:
|
||||
payload = envelope["payload"]
|
||||
return (
|
||||
"# KEZ Proof\n\n"
|
||||
"This account publishes a signed KEZ identity claim.\n\n"
|
||||
f"- Primary: `{payload['primary']}`\n"
|
||||
f"- Subject: `{payload['subject']}`\n"
|
||||
f"- Created: `{payload['created_at']}`\n\n"
|
||||
"```kez\n"
|
||||
f"{to_pretty_json(envelope)}\n"
|
||||
"```\n"
|
||||
)
|
||||
|
||||
|
||||
def extract_markdown_proof(markdown: str) -> dict[str, Any]:
|
||||
fence = "```kez"
|
||||
start = markdown.find(fence)
|
||||
if start < 0:
|
||||
raise ValueError("missing ```kez proof block")
|
||||
body_start = start + len(fence)
|
||||
end = markdown.find("```", body_start)
|
||||
if end < 0:
|
||||
raise ValueError("unterminated ```kez proof block")
|
||||
return json.loads(markdown[body_start:end].strip())
|
||||
|
||||
|
||||
# ── Sigchain compact bundle (kez:zc1:) ──────────────────────────────────────
|
||||
|
||||
|
||||
def chain_to_jsonl(events: list[dict[str, Any]]) -> str:
|
||||
if not events:
|
||||
return ""
|
||||
return "\n".join(json.dumps(e, separators=(",", ":"), ensure_ascii=False) for e in events) + "\n"
|
||||
|
||||
|
||||
def chain_from_jsonl(text: str) -> list[dict[str, Any]]:
|
||||
return [json.loads(line) for line in text.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def chain_to_compact_bundle(events: list[dict[str, Any]]) -> str:
|
||||
raw = chain_to_jsonl(events).encode("utf-8")
|
||||
return COMPACT_CHAIN_PREFIX + _b64url_nopad(_zstd_compress(raw))
|
||||
|
||||
|
||||
def chain_from_compact_bundle(value: str) -> list[dict[str, Any]]:
|
||||
trimmed = value.strip()
|
||||
if not trimmed.startswith(COMPACT_CHAIN_PREFIX):
|
||||
raise ValueError("compact chain missing kez:zc1: prefix")
|
||||
body = trimmed[len(COMPACT_CHAIN_PREFIX) :]
|
||||
raw = _zstd_decompress(_b64url_decode(body))
|
||||
return chain_from_jsonl(raw.decode("utf-8"))
|
||||
|
||||
|
||||
# ── DNS TXT helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def dns_txt_name(subject) -> str:
|
||||
from .identity import Identity
|
||||
|
||||
ident = subject if isinstance(subject, Identity) else Identity.parse(str(subject))
|
||||
if ident.scheme != "dns":
|
||||
raise ValueError("DNS TXT proof requires a dns: subject")
|
||||
return f"_kez.{ident.value}"
|
||||
|
||||
|
||||
def quote_dns_txt_value(value: str) -> str:
|
||||
chunks = [value[i : i + 240] for i in range(0, len(value), 240)]
|
||||
quoted = []
|
||||
for chunk in chunks:
|
||||
escaped = chunk.replace("\\", "\\\\").replace('"', '\\"')
|
||||
quoted.append(f'"{escaped}"')
|
||||
return " ".join(quoted)
|
||||
154
python/kez/envelope.py
Normal file
154
python/kez/envelope.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""Signature envelopes, claim payloads and verification (Spec §4, §5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .identity import Identity
|
||||
from .keys import verify_signature
|
||||
|
||||
CLAIM_TYPE = "kez.claim"
|
||||
SIGCHAIN_EVENT_TYPE = "kez.sigchain.event"
|
||||
FORMAT_VERSION = 1
|
||||
COMPACT_PROOF_PREFIX = "kez:z1:"
|
||||
COMPACT_CHAIN_PREFIX = "kez:zc1:"
|
||||
|
||||
|
||||
class VerificationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def rfc3339_utc(dt: datetime | None = None) -> str:
|
||||
"""RFC 3339 UTC timestamp with microsecond precision and a trailing ``Z``."""
|
||||
if dt is None:
|
||||
dt = datetime.now(timezone.utc)
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
|
||||
|
||||
|
||||
def new_claim_payload(
|
||||
subject: Identity,
|
||||
primary: Identity,
|
||||
created_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": CLAIM_TYPE,
|
||||
"version": FORMAT_VERSION,
|
||||
"subject": str(subject),
|
||||
"primary": str(primary),
|
||||
"created_at": created_at or rfc3339_utc(),
|
||||
}
|
||||
|
||||
|
||||
def sign_claim(payload: dict[str, Any], signer) -> dict[str, Any]:
|
||||
key = signer.identity()
|
||||
if payload["primary"] != str(key):
|
||||
raise VerificationError(
|
||||
f"claim primary {payload['primary']!r} does not match signing key {key}"
|
||||
)
|
||||
return {
|
||||
"kez": "claim",
|
||||
"payload": payload,
|
||||
"signature": {
|
||||
"alg": signer.alg(),
|
||||
"key": str(key),
|
||||
"sig": signer.sign_payload(payload),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def verify_claim(envelope: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Verify a claim envelope; return a status dict on success, else raise."""
|
||||
if envelope.get("kez") != "claim":
|
||||
raise VerificationError(f"not a claim envelope: kez={envelope.get('kez')!r}")
|
||||
payload = envelope["payload"]
|
||||
signature = envelope["signature"]
|
||||
if signature["key"] != payload["primary"]:
|
||||
raise VerificationError("signature.key does not match payload.primary")
|
||||
|
||||
primary = Identity.parse(payload["primary"])
|
||||
key = Identity.parse(signature["key"])
|
||||
if not verify_signature(payload, signature["alg"], key, signature["sig"]):
|
||||
raise VerificationError(f"signature did not verify (alg={signature['alg']})")
|
||||
|
||||
subject = Identity.parse(payload["subject"])
|
||||
return {
|
||||
"primary": primary,
|
||||
"verified": [subject],
|
||||
"status": "valid",
|
||||
"confidence": "strong",
|
||||
}
|
||||
|
||||
|
||||
# ── Sigchain event payloads ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _event_payload(
|
||||
primary: Identity,
|
||||
seq: int,
|
||||
prev: str | None,
|
||||
op: str,
|
||||
op_payload: dict[str, Any],
|
||||
created_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"type": SIGCHAIN_EVENT_TYPE,
|
||||
"version": FORMAT_VERSION,
|
||||
"primary": str(primary),
|
||||
"seq": seq,
|
||||
}
|
||||
if prev is not None:
|
||||
payload["prev"] = prev
|
||||
payload["created_at"] = created_at or rfc3339_utc()
|
||||
payload["op"] = op
|
||||
payload["payload"] = op_payload
|
||||
return payload
|
||||
|
||||
|
||||
def new_add_payload(
|
||||
primary: Identity,
|
||||
seq: int,
|
||||
prev: str | None,
|
||||
subject: Identity,
|
||||
proof_url: str | None = None,
|
||||
created_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
op_payload: dict[str, Any] = {"subject": str(subject)}
|
||||
if proof_url:
|
||||
op_payload["proof_url"] = proof_url
|
||||
return _event_payload(primary, seq, prev, "add", op_payload, created_at)
|
||||
|
||||
|
||||
def new_revoke_payload(
|
||||
primary: Identity,
|
||||
seq: int,
|
||||
prev: str | None,
|
||||
subject: Identity,
|
||||
created_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return _event_payload(primary, seq, prev, "revoke", {"subject": str(subject)}, created_at)
|
||||
|
||||
|
||||
def sign_sigchain_event(payload: dict[str, Any], signer) -> dict[str, Any]:
|
||||
return {
|
||||
"kez": "sigchain_event",
|
||||
"payload": payload,
|
||||
"signature": {
|
||||
"alg": signer.alg(),
|
||||
"key": str(signer.identity()),
|
||||
"sig": signer.sign_payload(payload),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def verify_sigchain_event(event: dict[str, Any]) -> None:
|
||||
if event.get("kez") != "sigchain_event":
|
||||
raise VerificationError(f"wrong envelope tag: {event.get('kez')!r}")
|
||||
payload = event["payload"]
|
||||
signature = event["signature"]
|
||||
if signature["key"] != payload["primary"]:
|
||||
raise VerificationError("signature.key does not match payload.primary")
|
||||
key = Identity.parse(signature["key"])
|
||||
if not verify_signature(payload, signature["alg"], key, signature["sig"]):
|
||||
raise VerificationError("sigchain event signature did not verify")
|
||||
77
python/kez/identity.py
Normal file
77
python/kez/identity.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""KEZ identifiers: always ``system:identifier`` (Spec §3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from . import bech32
|
||||
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class IdentityError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class Identity:
|
||||
"""A canonical ``system:identifier`` string."""
|
||||
|
||||
__slots__ = ("_raw",)
|
||||
|
||||
def __init__(self, raw: str) -> None:
|
||||
self._raw = raw
|
||||
|
||||
@classmethod
|
||||
def parse(cls, raw: str) -> "Identity":
|
||||
trimmed = raw.strip()
|
||||
if not trimmed:
|
||||
raise IdentityError("empty identity")
|
||||
|
||||
# CLI ergonomics: a bare npub normalizes to nostr:npub...
|
||||
if trimmed.startswith("npub1"):
|
||||
_validate_npub(trimmed)
|
||||
return cls(f"nostr:{trimmed}")
|
||||
|
||||
colon = trimmed.find(":")
|
||||
if colon <= 0 or colon == len(trimmed) - 1:
|
||||
raise IdentityError(f"invalid identity (need scheme:value): {raw!r}")
|
||||
scheme = trimmed[:colon]
|
||||
rest = trimmed[colon + 1 :]
|
||||
if scheme == "nostr":
|
||||
_validate_npub(rest)
|
||||
elif scheme == "ed25519":
|
||||
_validate_ed25519_hex(rest)
|
||||
return cls(f"{scheme}:{rest}")
|
||||
|
||||
@property
|
||||
def scheme(self) -> str:
|
||||
return self._raw.split(":", 1)[0]
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
return self._raw.split(":", 1)[1]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self._raw
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Identity({self._raw!r})"
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, Identity) and other._raw == self._raw
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self._raw)
|
||||
|
||||
|
||||
def _validate_npub(value: str) -> None:
|
||||
if not value.startswith("npub1"):
|
||||
raise IdentityError(f"invalid nostr identifier (expected npub1...): {value!r}")
|
||||
raw = bech32.decode("npub", value)
|
||||
if len(raw) != 32:
|
||||
raise IdentityError("invalid npub: expected 32-byte key")
|
||||
|
||||
|
||||
def _validate_ed25519_hex(value: str) -> None:
|
||||
if not _HEX64.match(value):
|
||||
raise IdentityError("invalid ed25519 identifier: expected 64 lowercase hex chars")
|
||||
78
python/kez/jcs.py
Normal file
78
python/kez/jcs.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""RFC 8785 JSON Canonicalization Scheme (JCS).
|
||||
|
||||
This is the heart of cross-implementation interop: signatures are computed over
|
||||
the JCS-canonicalized bytes of a payload, so two implementations that agree on
|
||||
these bytes produce universally-verifiable signatures.
|
||||
|
||||
Payloads in KEZ only ever contain strings, integers, booleans, nulls, arrays
|
||||
and objects — never floating-point numbers — so we implement the integer-only
|
||||
subset of the number rules. A float would be a bug, so we reject it loudly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _canon_string(s: str) -> str:
|
||||
out = ['"']
|
||||
for ch in s:
|
||||
c = ord(ch)
|
||||
if ch == '"':
|
||||
out.append('\\"')
|
||||
elif ch == "\\":
|
||||
out.append("\\\\")
|
||||
elif c == 0x08:
|
||||
out.append("\\b")
|
||||
elif c == 0x09:
|
||||
out.append("\\t")
|
||||
elif c == 0x0A:
|
||||
out.append("\\n")
|
||||
elif c == 0x0C:
|
||||
out.append("\\f")
|
||||
elif c == 0x0D:
|
||||
out.append("\\r")
|
||||
elif c < 0x20:
|
||||
out.append("\\u%04x" % c)
|
||||
else:
|
||||
out.append(ch)
|
||||
out.append('"')
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _canon(value: Any) -> str:
|
||||
if value is True:
|
||||
return "true"
|
||||
if value is False:
|
||||
return "false"
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, str):
|
||||
return _canon_string(value)
|
||||
if isinstance(value, bool): # unreachable (handled above) but explicit
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, float):
|
||||
# KEZ payloads never carry floats; refuse rather than risk a
|
||||
# non-canonical number serialization.
|
||||
if value.is_integer():
|
||||
return str(int(value))
|
||||
raise ValueError("JCS: floating-point numbers are not supported in KEZ payloads")
|
||||
if isinstance(value, (list, tuple)):
|
||||
return "[" + ",".join(_canon(v) for v in value) + "]"
|
||||
if isinstance(value, dict):
|
||||
# RFC 8785: sort object members by their UTF-16 code-unit sequence.
|
||||
items = sorted(value.items(), key=lambda kv: kv[0].encode("utf-16-be"))
|
||||
return "{" + ",".join(_canon_string(k) + ":" + _canon(v) for k, v in items) + "}"
|
||||
raise TypeError(f"JCS: unsupported type {type(value)!r}")
|
||||
|
||||
|
||||
def canonicalize(value: Any) -> str:
|
||||
"""Return the RFC 8785 canonical JSON string for ``value``."""
|
||||
return _canon(value)
|
||||
|
||||
|
||||
def canonical_bytes(value: Any) -> bytes:
|
||||
"""Return the RFC 8785 canonical JSON bytes (UTF-8) for ``value``."""
|
||||
return _canon(value).encode("utf-8")
|
||||
162
python/kez/keys.py
Normal file
162
python/kez/keys.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""Signing keys: nostr (secp256k1 Schnorr) and ed25519."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption, PublicFormat
|
||||
|
||||
from . import bech32, schnorr
|
||||
from .identity import Identity
|
||||
from .jcs import canonical_bytes
|
||||
|
||||
NOSTR_SCHNORR_ALG = "nostr-secp256k1-schnorr-sha256-jcs"
|
||||
ED25519_SHA512_ALG = "ed25519-sha512-jcs"
|
||||
|
||||
|
||||
class NostrSecret:
|
||||
"""A nostr secp256k1 secret key, addressed by its npub."""
|
||||
|
||||
__slots__ = ("_sk", "_pub")
|
||||
|
||||
def __init__(self, secret_key: bytes) -> None:
|
||||
if len(secret_key) != 32:
|
||||
raise ValueError("nostr secret key must be 32 bytes")
|
||||
self._sk = secret_key
|
||||
self._pub = schnorr.pubkey_gen(secret_key)
|
||||
|
||||
@classmethod
|
||||
def generate(cls) -> "NostrSecret":
|
||||
while True:
|
||||
sk = os.urandom(32)
|
||||
i = int.from_bytes(sk, "big")
|
||||
if 1 <= i < schnorr.n:
|
||||
return cls(sk)
|
||||
|
||||
@classmethod
|
||||
def from_nsec(cls, nsec: str) -> "NostrSecret":
|
||||
raw = bech32.decode("nsec", nsec.strip())
|
||||
if len(raw) != 32:
|
||||
raise ValueError("invalid nsec: expected 32-byte key")
|
||||
return cls(raw)
|
||||
|
||||
def nsec(self) -> str:
|
||||
return bech32.encode("nsec", self._sk)
|
||||
|
||||
def npub(self) -> str:
|
||||
return bech32.encode("npub", self._pub)
|
||||
|
||||
def identity(self) -> Identity:
|
||||
return Identity.parse(f"nostr:{self.npub()}")
|
||||
|
||||
def alg(self) -> str:
|
||||
return NOSTR_SCHNORR_ALG
|
||||
|
||||
def sign_payload(self, payload) -> str:
|
||||
digest = hashlib.sha256(canonical_bytes(payload)).digest()
|
||||
return schnorr.sign(digest, self._sk).hex()
|
||||
|
||||
|
||||
class Ed25519Secret:
|
||||
"""An ed25519 secret seed, addressed by its hex public key."""
|
||||
|
||||
__slots__ = ("_seed", "_key", "_pub")
|
||||
|
||||
def __init__(self, seed: bytes) -> None:
|
||||
if len(seed) != 32:
|
||||
raise ValueError("ed25519 seed must be 32 bytes")
|
||||
self._seed = seed
|
||||
self._key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||
self._pub = self._key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
|
||||
@classmethod
|
||||
def generate(cls) -> "Ed25519Secret":
|
||||
key = Ed25519PrivateKey.generate()
|
||||
seed = key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
||||
return cls(seed)
|
||||
|
||||
@classmethod
|
||||
def from_seed_hex(cls, seed_hex: str) -> "Ed25519Secret":
|
||||
seed = bytes.fromhex(seed_hex.strip())
|
||||
if len(seed) != 32:
|
||||
raise ValueError("invalid ed25519 seed: expected 32-byte (64 hex char) seed")
|
||||
return cls(seed)
|
||||
|
||||
@classmethod
|
||||
def from_mnemonic(cls, phrase: str) -> "Ed25519Secret":
|
||||
# Lazy import: mnemonic.py imports Ed25519Secret at module top.
|
||||
from .mnemonic import seed_from_mnemonic
|
||||
|
||||
return cls(seed_from_mnemonic(phrase))
|
||||
|
||||
@classmethod
|
||||
def generate_with_mnemonic(cls, words: int = 24) -> tuple["Ed25519Secret", str]:
|
||||
from .mnemonic import generate_ed25519_with_mnemonic
|
||||
|
||||
return generate_ed25519_with_mnemonic(words)
|
||||
|
||||
def seed_hex(self) -> str:
|
||||
return self._seed.hex()
|
||||
|
||||
def pubkey_hex(self) -> str:
|
||||
return self._pub.hex()
|
||||
|
||||
def identity(self) -> Identity:
|
||||
return Identity.parse(f"ed25519:{self.pubkey_hex()}")
|
||||
|
||||
def alg(self) -> str:
|
||||
return ED25519_SHA512_ALG
|
||||
|
||||
def sign_payload(self, payload) -> str:
|
||||
return self._key.sign(canonical_bytes(payload)).hex()
|
||||
|
||||
|
||||
def verify_signature(payload, alg: str, key: Identity, sig_hex: str) -> bool:
|
||||
"""Verify a signature block against an arbitrary JCS-canonicalizable payload."""
|
||||
try:
|
||||
sig = bytes.fromhex(sig_hex)
|
||||
except ValueError:
|
||||
return False
|
||||
if len(sig) != 64:
|
||||
return False
|
||||
|
||||
if alg == NOSTR_SCHNORR_ALG:
|
||||
if key.scheme != "nostr":
|
||||
return False
|
||||
pubkey = bech32.decode("npub", key.value)
|
||||
digest = hashlib.sha256(canonical_bytes(payload)).digest()
|
||||
return schnorr.verify(digest, pubkey, sig)
|
||||
|
||||
if alg == ED25519_SHA512_ALG:
|
||||
if key.scheme != "ed25519":
|
||||
return False
|
||||
try:
|
||||
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(key.value))
|
||||
pub.verify(sig, canonical_bytes(payload))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def signer_from_flags(
|
||||
nsec: str | None,
|
||||
ed25519_seed: str | None,
|
||||
mnemonic: str | None = None,
|
||||
):
|
||||
provided = [v for v in (nsec, ed25519_seed, mnemonic) if v]
|
||||
if len(provided) > 1:
|
||||
raise ValueError("--nsec, --ed25519-seed, and --mnemonic are mutually exclusive")
|
||||
if nsec:
|
||||
return NostrSecret.from_nsec(nsec)
|
||||
if ed25519_seed:
|
||||
return Ed25519Secret.from_seed_hex(ed25519_seed)
|
||||
if mnemonic:
|
||||
return Ed25519Secret.from_mnemonic(mnemonic)
|
||||
raise ValueError("missing key: pass --nsec, --ed25519-seed, or --mnemonic")
|
||||
98
python/kez/mnemonic.py
Normal file
98
python/kez/mnemonic.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""BIP-39 mnemonic phrases for Ed25519 primary keys.
|
||||
|
||||
Mirrors ``rust/crates/kez-core/src/mnemonic.rs`` and
|
||||
``nodejs/packages/kez-core/src/mnemonic.ts`` byte-for-byte.
|
||||
|
||||
Two word counts are supported, with different semantics:
|
||||
|
||||
- **24 words** ↔ **32 bytes of entropy** ↔ **Ed25519 seed** (bijection).
|
||||
Round-trips perfectly. The entropy *is* the seed.
|
||||
|
||||
- **12 words** → **16 bytes of entropy** → **Ed25519 seed**, via
|
||||
``SHA-256("kez-bip39-12-v1" || entropy)``. One-way KEZ-specific
|
||||
derivation; you cannot recover a 12-word phrase from a seed.
|
||||
|
||||
Wordlist: BIP-39 English. NB: we deliberately do *not* use BIP-39's
|
||||
``to_seed(passphrase)`` function — that produces a 64-byte seed via
|
||||
PBKDF2, intended to feed into BIP-32 hierarchical derivation. KEZ has
|
||||
one identity per phrase, so taking the entropy directly (or hashing it
|
||||
once for 12-word phrases) is the right primitive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
from mnemonic import Mnemonic as _Bip39
|
||||
|
||||
from .keys import Ed25519Secret
|
||||
|
||||
# Domain separator for the 12-word → seed derivation. Bumping this would
|
||||
# break every existing 12-word KEZ identity, so don't.
|
||||
DOMAIN_TAG_12: bytes = b"kez-bip39-12-v1"
|
||||
|
||||
# Lazy singleton of the English BIP-39 wordlist parser.
|
||||
_M = _Bip39("english")
|
||||
|
||||
|
||||
def _assert_words(n: int) -> None:
|
||||
if n not in (12, 24):
|
||||
raise ValueError(f"mnemonic word count must be 12 or 24, got {n}")
|
||||
|
||||
|
||||
def generate_mnemonic(words: int) -> str:
|
||||
"""Generate a fresh BIP-39 mnemonic of the requested length.
|
||||
|
||||
The returned phrase is a space-separated lowercase string from the
|
||||
BIP-39 English wordlist. ``words`` must be 12 or 24.
|
||||
"""
|
||||
_assert_words(words)
|
||||
# bip39 strength is in bits: 12 words = 128 bits, 24 = 256.
|
||||
strength = 256 if words == 24 else 128
|
||||
return _M.generate(strength=strength)
|
||||
|
||||
|
||||
def seed_from_mnemonic(phrase: str) -> bytes:
|
||||
"""Decode a phrase (12 or 24 words) to a 32-byte Ed25519 seed.
|
||||
|
||||
For 24 words the entropy IS the seed; for 12 words the seed is
|
||||
``SHA-256(DOMAIN_TAG_12 || entropy)``.
|
||||
"""
|
||||
trimmed = " ".join(phrase.split())
|
||||
try:
|
||||
entropy = bytes(_M.to_entropy(trimmed))
|
||||
except Exception as exc: # noqa: BLE001 — wrap as our own error
|
||||
raise ValueError(f"invalid mnemonic: {exc}") from exc
|
||||
|
||||
if len(entropy) == 32:
|
||||
return entropy
|
||||
if len(entropy) == 16:
|
||||
return hashlib.sha256(DOMAIN_TAG_12 + entropy).digest()
|
||||
raise ValueError(
|
||||
f"mnemonic must decode to 16 or 32 bytes of entropy, got {len(entropy)}"
|
||||
)
|
||||
|
||||
|
||||
def mnemonic_from_seed_24(seed: bytes) -> str:
|
||||
"""Inverse of :func:`seed_from_mnemonic` for the 24-word case ONLY.
|
||||
|
||||
There is no inverse for 12-word phrases (hashing is one-way) — this
|
||||
function always produces 24 words.
|
||||
"""
|
||||
if len(seed) != 32:
|
||||
raise ValueError(
|
||||
f"mnemonic_from_seed_24: seed must be 32 bytes, got {len(seed)}"
|
||||
)
|
||||
return _M.to_mnemonic(seed)
|
||||
|
||||
|
||||
def ed25519_from_mnemonic(phrase: str) -> Ed25519Secret:
|
||||
"""Reconstruct an :class:`Ed25519Secret` from a BIP-39 phrase."""
|
||||
return Ed25519Secret(seed_from_mnemonic(phrase))
|
||||
|
||||
|
||||
def generate_ed25519_with_mnemonic(words: int) -> tuple[Ed25519Secret, str]:
|
||||
"""Generate a fresh Ed25519 identity *and* return its BIP-39 phrase."""
|
||||
phrase = generate_mnemonic(words)
|
||||
secret = ed25519_from_mnemonic(phrase)
|
||||
return secret, phrase
|
||||
151
python/kez/schnorr.py
Normal file
151
python/kez/schnorr.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""BIP-340 Schnorr signatures over secp256k1 (pure Python).
|
||||
|
||||
This is the reference implementation from BIP-340 (public domain), used for the
|
||||
``nostr-secp256k1-schnorr-sha256-jcs`` suite. We sign with an all-zero auxiliary
|
||||
randomness value, matching the Rust ``sign_schnorr_no_aux_rand`` and the Node
|
||||
``schnorr.sign(digest, sk, ZERO_AUX)`` calls — so every implementation produces
|
||||
byte-identical, deterministic signatures.
|
||||
|
||||
We use a pure-Python implementation because the native ``coincurve``/``secp256k1``
|
||||
bindings fail to build on bleeding-edge CPython. Signing/verifying short
|
||||
fixed-size digests is well within pure-Python performance for a CLI tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||||
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||||
G = (
|
||||
0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
|
||||
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8,
|
||||
)
|
||||
|
||||
Point = tuple[int, int] | None
|
||||
|
||||
|
||||
def tagged_hash(tag: str, msg: bytes) -> bytes:
|
||||
tag_hash = hashlib.sha256(tag.encode()).digest()
|
||||
return hashlib.sha256(tag_hash + tag_hash + msg).digest()
|
||||
|
||||
|
||||
def is_infinite(P: Point) -> bool:
|
||||
return P is None
|
||||
|
||||
|
||||
def x(P: Point) -> int:
|
||||
assert P is not None
|
||||
return P[0]
|
||||
|
||||
|
||||
def y(P: Point) -> int:
|
||||
assert P is not None
|
||||
return P[1]
|
||||
|
||||
|
||||
def point_add(P1: Point, P2: Point) -> Point:
|
||||
if P1 is None:
|
||||
return P2
|
||||
if P2 is None:
|
||||
return P1
|
||||
if x(P1) == x(P2) and y(P1) != y(P2):
|
||||
return None
|
||||
if P1 == P2:
|
||||
lam = (3 * x(P1) * x(P1) * pow(2 * y(P1), p - 2, p)) % p
|
||||
else:
|
||||
lam = ((y(P2) - y(P1)) * pow(x(P2) - x(P1), p - 2, p)) % p
|
||||
x3 = (lam * lam - x(P1) - x(P2)) % p
|
||||
return (x3, (lam * (x(P1) - x3) - y(P1)) % p)
|
||||
|
||||
|
||||
def point_mul(P: Point, scalar: int) -> Point:
|
||||
R: Point = None
|
||||
for i in range(256):
|
||||
if (scalar >> i) & 1:
|
||||
R = point_add(R, P)
|
||||
P = point_add(P, P)
|
||||
return R
|
||||
|
||||
|
||||
def bytes_from_int(x_: int) -> bytes:
|
||||
return x_.to_bytes(32, byteorder="big")
|
||||
|
||||
|
||||
def bytes_from_point(P: Point) -> bytes:
|
||||
return bytes_from_int(x(P))
|
||||
|
||||
|
||||
def lift_x(b: bytes) -> Point:
|
||||
x_ = int.from_bytes(b, byteorder="big")
|
||||
if x_ >= p:
|
||||
return None
|
||||
y_sq = (pow(x_, 3, p) + 7) % p
|
||||
y_ = pow(y_sq, (p + 1) // 4, p)
|
||||
if pow(y_, 2, p) != y_sq:
|
||||
return None
|
||||
return (x_, y_ if y_ & 1 == 0 else p - y_)
|
||||
|
||||
|
||||
def int_from_bytes(b: bytes) -> int:
|
||||
return int.from_bytes(b, byteorder="big")
|
||||
|
||||
|
||||
def has_even_y(P: Point) -> bool:
|
||||
assert P is not None
|
||||
return y(P) % 2 == 0
|
||||
|
||||
|
||||
def pubkey_gen(seckey: bytes) -> bytes:
|
||||
"""Return the 32-byte x-only public key for a 32-byte secret key."""
|
||||
d0 = int_from_bytes(seckey)
|
||||
if not (1 <= d0 <= n - 1):
|
||||
raise ValueError("schnorr: secret key out of range")
|
||||
P = point_mul(G, d0)
|
||||
assert P is not None
|
||||
return bytes_from_point(P)
|
||||
|
||||
|
||||
def sign(msg: bytes, seckey: bytes, aux_rand: bytes = b"\x00" * 32) -> bytes:
|
||||
"""Produce a 64-byte BIP-340 Schnorr signature over ``msg``."""
|
||||
d0 = int_from_bytes(seckey)
|
||||
if not (1 <= d0 <= n - 1):
|
||||
raise ValueError("schnorr: secret key out of range")
|
||||
P = point_mul(G, d0)
|
||||
assert P is not None
|
||||
d = d0 if has_even_y(P) else n - d0
|
||||
t = (d ^ int_from_bytes(tagged_hash("BIP0340/aux", aux_rand))).to_bytes(32, "big")
|
||||
k0 = int_from_bytes(tagged_hash("BIP0340/nonce", t + bytes_from_point(P) + msg)) % n
|
||||
if k0 == 0:
|
||||
raise ValueError("schnorr: nonce generation failed")
|
||||
R = point_mul(G, k0)
|
||||
assert R is not None
|
||||
k = k0 if has_even_y(R) else n - k0
|
||||
e = (
|
||||
int_from_bytes(
|
||||
tagged_hash("BIP0340/challenge", bytes_from_point(R) + bytes_from_point(P) + msg)
|
||||
)
|
||||
% n
|
||||
)
|
||||
sig = bytes_from_point(R) + ((k + e * d) % n).to_bytes(32, "big")
|
||||
if not verify(msg, bytes_from_point(P), sig):
|
||||
raise ValueError("schnorr: produced an invalid signature")
|
||||
return sig
|
||||
|
||||
|
||||
def verify(msg: bytes, pubkey: bytes, sig: bytes) -> bool:
|
||||
"""Verify a 64-byte BIP-340 Schnorr signature ``sig`` over ``msg``."""
|
||||
if len(pubkey) != 32 or len(sig) != 64:
|
||||
return False
|
||||
P = lift_x(pubkey)
|
||||
r = int_from_bytes(sig[0:32])
|
||||
s = int_from_bytes(sig[32:64])
|
||||
if P is None or r >= p or s >= n:
|
||||
return False
|
||||
e = (
|
||||
int_from_bytes(tagged_hash("BIP0340/challenge", sig[0:32] + pubkey + msg)) % n
|
||||
)
|
||||
R = point_add(point_mul(G, s), point_mul(P, n - e))
|
||||
if R is None or not has_even_y(R) or x(R) != r:
|
||||
return False
|
||||
return True
|
||||
112
python/kez/sigchain.py
Normal file
112
python/kez/sigchain.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""Append-only signed sigchain (Spec §8) and on-disk storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import encodings
|
||||
from .envelope import verify_sigchain_event
|
||||
from .identity import Identity
|
||||
from .jcs import canonical_bytes
|
||||
|
||||
|
||||
class SigchainError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def event_hash(event: dict[str, Any]) -> str:
|
||||
"""``sha256:<hex>`` of the JCS bytes of the entire signed envelope."""
|
||||
digest = hashlib.sha256(canonical_bytes(event)).hexdigest()
|
||||
return f"sha256:{digest}"
|
||||
|
||||
|
||||
class Sigchain:
|
||||
def __init__(self, primary: Identity) -> None:
|
||||
self._primary = primary
|
||||
self._events: list[dict[str, Any]] = []
|
||||
|
||||
@property
|
||||
def primary(self) -> Identity:
|
||||
return self._primary
|
||||
|
||||
def events(self) -> list[dict[str, Any]]:
|
||||
return self._events
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._events)
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not self._events
|
||||
|
||||
def next_seq(self) -> int:
|
||||
return len(self._events)
|
||||
|
||||
def head_hash(self) -> str | None:
|
||||
if not self._events:
|
||||
return None
|
||||
return event_hash(self._events[-1])
|
||||
|
||||
def append(self, event: dict[str, Any]) -> None:
|
||||
if event.get("kez") != "sigchain_event":
|
||||
raise SigchainError(f"wrong envelope tag: {event.get('kez')!r}")
|
||||
payload = event["payload"]
|
||||
if payload["primary"] != str(self._primary):
|
||||
raise SigchainError("event primary does not match chain primary")
|
||||
expected_seq = self.next_seq()
|
||||
if payload["seq"] != expected_seq:
|
||||
raise SigchainError(f"seq mismatch: expected {expected_seq}, got {payload['seq']}")
|
||||
expected_prev = self.head_hash()
|
||||
if payload.get("prev") != expected_prev:
|
||||
raise SigchainError("prev hash mismatch")
|
||||
verify_sigchain_event(event)
|
||||
self._events.append(event)
|
||||
|
||||
# ── serialization ──
|
||||
|
||||
def to_jsonl(self) -> str:
|
||||
return encodings.chain_to_jsonl(self._events)
|
||||
|
||||
def to_compact_bundle(self) -> str:
|
||||
return encodings.chain_to_compact_bundle(self._events)
|
||||
|
||||
@classmethod
|
||||
def from_jsonl(cls, primary: Identity, text: str) -> "Sigchain":
|
||||
chain = cls(primary)
|
||||
for event in encodings.chain_from_jsonl(text):
|
||||
chain.append(event)
|
||||
return chain
|
||||
|
||||
|
||||
def subject_of(event: dict[str, Any]) -> str | None:
|
||||
op_payload = event.get("payload", {}).get("payload", {})
|
||||
subject = op_payload.get("subject")
|
||||
return subject if isinstance(subject, str) else None
|
||||
|
||||
|
||||
# ── storage ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def sigchain_dir() -> Path:
|
||||
return Path(os.path.expanduser("~")) / ".kez" / "sigchains"
|
||||
|
||||
|
||||
def sigchain_path(primary: Identity) -> Path:
|
||||
d = sigchain_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
safe = str(primary).replace(":", "_")
|
||||
return d / f"{safe}.jsonl"
|
||||
|
||||
|
||||
def load_chain(primary: Identity) -> Sigchain:
|
||||
path = sigchain_path(primary)
|
||||
if not path.exists():
|
||||
return Sigchain(primary)
|
||||
return Sigchain.from_jsonl(primary, path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def save_chain(chain: Sigchain) -> None:
|
||||
path = sigchain_path(chain.primary)
|
||||
path.write_text(chain.to_jsonl(), encoding="utf-8")
|
||||
18
python/kez_cli.py
Normal file
18
python/kez_cli.py
Normal file
@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone launcher for the KEZ Python CLI.
|
||||
|
||||
Lets the cross-implementation test harness invoke the CLI from any working
|
||||
directory without installing the package:
|
||||
|
||||
python/.venv/bin/python python/kez_cli.py <args...>
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from kez.cli import main # noqa: E402
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
20
python/pyproject.toml
Normal file
20
python/pyproject.toml
Normal file
@ -0,0 +1,20 @@
|
||||
[project]
|
||||
name = "kez"
|
||||
version = "0.3.0"
|
||||
description = "KEZ portable identity graph — Python implementation"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"cryptography>=42",
|
||||
"mnemonic>=0.20",
|
||||
"zstandard>=0.22",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
kez = "kez.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["kez"]
|
||||
3
python/requirements.txt
Normal file
3
python/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
cryptography>=42
|
||||
mnemonic>=0.20
|
||||
zstandard>=0.22
|
||||
158
python/tests/test_mnemonic.py
Normal file
158
python/tests/test_mnemonic.py
Normal file
@ -0,0 +1,158 @@
|
||||
"""Tests for the BIP-39 mnemonic ↔ Ed25519 seed derivation.
|
||||
|
||||
The three vectors below are ground truth — Rust, Node, and Python MUST
|
||||
all derive these exact seeds and pubkeys. See
|
||||
``python/MNEMONIC-TEST-VECTORS.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from kez.keys import Ed25519Secret
|
||||
from kez.mnemonic import (
|
||||
DOMAIN_TAG_12,
|
||||
ed25519_from_mnemonic,
|
||||
generate_ed25519_with_mnemonic,
|
||||
generate_mnemonic,
|
||||
mnemonic_from_seed_24,
|
||||
seed_from_mnemonic,
|
||||
)
|
||||
|
||||
# ── canonical interop vectors ────────────────────────────────────────────────
|
||||
|
||||
V1_PHRASE = (
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon "
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon "
|
||||
"abandon abandon abandon abandon abandon abandon abandon art"
|
||||
)
|
||||
V1_SEED_HEX = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
V1_PUBKEY_HEX = "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
|
||||
|
||||
V2_PHRASE = (
|
||||
"abandon abandon abandon abandon abandon abandon "
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
)
|
||||
V2_SEED_HEX = "09451c0f06588db78205e32a793536e15ae263c8f9ee6d14f5c6fd82b8bd20da"
|
||||
V2_PUBKEY_HEX = "9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70"
|
||||
|
||||
V3_PHRASE = (
|
||||
"legal winner thank year wave sausage worth useful "
|
||||
"legal winner thank yellow"
|
||||
)
|
||||
V3_SEED_HEX = "9df434a2bd5dc767ee949d8ab95ca09c4ebbb88cefc3d0b1523f6b2a744ca824"
|
||||
V3_PUBKEY_HEX = "cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03"
|
||||
|
||||
VECTORS = [
|
||||
pytest.param(V1_PHRASE, V1_SEED_HEX, V1_PUBKEY_HEX, id="v1-24word-zero"),
|
||||
pytest.param(V2_PHRASE, V2_SEED_HEX, V2_PUBKEY_HEX, id="v2-12word-zero"),
|
||||
pytest.param(V3_PHRASE, V3_SEED_HEX, V3_PUBKEY_HEX, id="v3-12word-legal"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phrase, seed_hex, pubkey_hex", VECTORS)
|
||||
def test_vector_seed_matches(phrase: str, seed_hex: str, pubkey_hex: str) -> None:
|
||||
assert seed_from_mnemonic(phrase).hex() == seed_hex
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phrase, seed_hex, pubkey_hex", VECTORS)
|
||||
def test_vector_pubkey_matches(phrase: str, seed_hex: str, pubkey_hex: str) -> None:
|
||||
secret = ed25519_from_mnemonic(phrase)
|
||||
assert secret.pubkey_hex() == pubkey_hex
|
||||
assert secret.seed_hex() == seed_hex
|
||||
|
||||
|
||||
# ── structural properties ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_domain_tag_bytes() -> None:
|
||||
# 15 ASCII bytes — must match the Rust/Node constant exactly.
|
||||
assert DOMAIN_TAG_12 == b"kez-bip39-12-v1"
|
||||
assert len(DOMAIN_TAG_12) == 15
|
||||
|
||||
|
||||
def test_generate_24_round_trips() -> None:
|
||||
phrase = generate_mnemonic(24)
|
||||
assert len(phrase.split()) == 24
|
||||
seed = seed_from_mnemonic(phrase)
|
||||
phrase2 = mnemonic_from_seed_24(seed)
|
||||
assert phrase == phrase2
|
||||
|
||||
|
||||
def test_generate_12_is_deterministic() -> None:
|
||||
phrase = generate_mnemonic(12)
|
||||
assert len(phrase.split()) == 12
|
||||
assert seed_from_mnemonic(phrase) == seed_from_mnemonic(phrase)
|
||||
|
||||
|
||||
def test_mnemonic_from_seed_24_is_inverse() -> None:
|
||||
seed = bytes([42]) * 32
|
||||
phrase = mnemonic_from_seed_24(seed)
|
||||
assert seed_from_mnemonic(phrase) == seed
|
||||
|
||||
|
||||
def test_mnemonic_from_seed_24_rejects_wrong_length() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
mnemonic_from_seed_24(b"\x00" * 16)
|
||||
|
||||
|
||||
def test_invalid_word_count() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
generate_mnemonic(18)
|
||||
with pytest.raises(ValueError):
|
||||
generate_mnemonic(0)
|
||||
|
||||
|
||||
def test_invalid_words_errors_cleanly() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
seed_from_mnemonic("not actually words at all here")
|
||||
|
||||
|
||||
def test_invalid_checksum_errors() -> None:
|
||||
# 12 valid words but wrong checksum.
|
||||
bad = "abandon " * 11 + "abandon"
|
||||
with pytest.raises(ValueError):
|
||||
seed_from_mnemonic(bad.strip())
|
||||
|
||||
|
||||
def test_whitespace_tolerance() -> None:
|
||||
padded = f" {V2_PHRASE} "
|
||||
assert seed_from_mnemonic(padded) == seed_from_mnemonic(V2_PHRASE)
|
||||
# Collapses internal whitespace too.
|
||||
weird = V2_PHRASE.replace(" ", " \t ")
|
||||
assert seed_from_mnemonic(weird) == seed_from_mnemonic(V2_PHRASE)
|
||||
|
||||
|
||||
def test_twelve_and_24_overlapping_entropy_differ() -> None:
|
||||
# Sanity: 12-word entropy left-padded would equal 16 zeros + entropy.
|
||||
# We hash instead — must not collide with the 24-word phrase of the
|
||||
# same 16-byte entropy padded with zeros.
|
||||
from mnemonic import Mnemonic
|
||||
|
||||
m = Mnemonic("english")
|
||||
p12 = m.to_mnemonic(bytes([7]) * 16)
|
||||
p24 = m.to_mnemonic(bytes([7]) * 32)
|
||||
assert seed_from_mnemonic(p12) != seed_from_mnemonic(p24)
|
||||
|
||||
|
||||
# ── Ed25519Secret hooks ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ed25519_from_mnemonic_matches_direct_seed() -> None:
|
||||
phrase = mnemonic_from_seed_24(bytes([1]) * 32)
|
||||
from_mn = Ed25519Secret.from_mnemonic(phrase)
|
||||
from_hex = Ed25519Secret.from_seed_hex("01" * 32)
|
||||
assert from_mn.pubkey_hex() == from_hex.pubkey_hex()
|
||||
|
||||
|
||||
def test_generate_with_mnemonic_pair_is_consistent() -> None:
|
||||
secret, phrase = Ed25519Secret.generate_with_mnemonic(24)
|
||||
restored = Ed25519Secret.from_mnemonic(phrase)
|
||||
assert secret.pubkey_hex() == restored.pubkey_hex()
|
||||
|
||||
|
||||
def test_generate_with_mnemonic_12() -> None:
|
||||
secret, phrase = generate_ed25519_with_mnemonic(12)
|
||||
assert len(phrase.split()) == 12
|
||||
restored = ed25519_from_mnemonic(phrase)
|
||||
assert secret.pubkey_hex() == restored.pubkey_hex()
|
||||
48
rust/Cargo.lock
generated
48
rust/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"] }
|
||||
|
||||
122
rust/TUTORIAL.md
122
rust/TUTORIAL.md
@ -81,24 +81,128 @@ kez identity new --key-type nostr # only if you want a NEW key
|
||||
|
||||
### Option B: generate a fresh Ed25519 primary
|
||||
|
||||
If you'd rather start clean, generate a new Ed25519 key:
|
||||
If you'd rather start clean, generate a new Ed25519 key with a BIP-39
|
||||
recovery phrase you can write down on paper:
|
||||
|
||||
```sh
|
||||
kez identity new --key-type ed25519
|
||||
kez identity new --key-type ed25519 # 24-word phrase (default)
|
||||
kez identity new --key-type ed25519 --mnemonic-words 12 # 12-word phrase
|
||||
```
|
||||
|
||||
Output:
|
||||
Output (24-word, the default):
|
||||
|
||||
```
|
||||
Primary: ed25519:7a3b4c…
|
||||
Public: 7a3b4c… (hex)
|
||||
Secret: 9e3f51… (hex — 64 chars, KEEP SECRET)
|
||||
Public: 7a3b4c…
|
||||
Secret: 9e3f51… (32-byte seed)
|
||||
Mnemonic (24 words): "abandon ability able about above absent academy accident…"
|
||||
```
|
||||
|
||||
> **Save the secret.** It's the only thing that can sign as this
|
||||
> identity. There's no recovery flow — lose it and the identity is
|
||||
> gone. Write it down offline, or paste it into a password manager.
|
||||
> From here on this tutorial assumes you stored it.
|
||||
You now have **two equivalent backups** — the hex seed *and* the 24-word
|
||||
BIP-39 phrase. Either restores the same identity. Most people back up
|
||||
the phrase (easier to write down, easier to verify by hand).
|
||||
|
||||
> **Save the backup.** Seed *or* phrase — at least one. Lose them both
|
||||
> and the identity is gone. There's no recovery flow.
|
||||
|
||||
### Recovery phrases — what's actually going on
|
||||
|
||||
A KEZ recovery phrase is a [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)
|
||||
mnemonic — the same 2048-word English wordlist that Bitcoin, Ethereum,
|
||||
and most hardware wallets use. The words encode random bits:
|
||||
|
||||
| Phrase length | Random bits | Resulting Ed25519 seed |
|
||||
|---|---|---|
|
||||
| **24 words** | 256 bits of entropy | The 32-byte seed *is* those 256 bits (1:1). Phrase ↔ seed round-trips. |
|
||||
| **12 words** | 128 bits of entropy | 16 bytes → 32-byte seed via `SHA-256("kez-bip39-12-v1" \|\| entropy)`. Phrase → seed only (one-way). |
|
||||
|
||||
#### Picking 12 vs 24
|
||||
|
||||
- **Pick 24 words** when you want full round-trip-ability — i.e. you'd
|
||||
like to be able to *recover the phrase from the hex seed* at any time
|
||||
in the future. Anyone's 32-byte ed25519 secret can be re-encoded into
|
||||
the unique 24-word phrase that produced it. Bigger security margin
|
||||
(256 bits of entropy vs 128).
|
||||
- **Pick 12 words** when you want a shorter thing to write down on
|
||||
paper or remember. 128 bits of entropy is still enormously beyond
|
||||
brute-forcing. The trade-off: the path is *one-way only* — you can
|
||||
always derive the seed from the phrase, but you cannot derive the
|
||||
phrase from the seed. So if you only ever have the seed, you'll
|
||||
never know what 12-word phrase produced it. **Save the phrase
|
||||
itself**, not just the resulting seed.
|
||||
|
||||
Either way the resulting Ed25519 identity is exactly the same shape;
|
||||
peers can't tell which word count you used. The choice is purely about
|
||||
your backup ergonomics.
|
||||
|
||||
#### ⚠ Not compatible with hardware-wallet derivations
|
||||
|
||||
A KEZ 12-word phrase **does not** produce the same Bitcoin or Ethereum
|
||||
key as the same 12 words typed into a Ledger or MetaMask, and vice
|
||||
versa. The reasons are deliberate:
|
||||
|
||||
1. Other wallets feed the phrase through BIP-39's PBKDF2 to get a
|
||||
64-byte "seed", then run that through BIP-32 hierarchical
|
||||
derivation at a coin-specific path. KEZ doesn't — it takes the
|
||||
raw entropy and uses it directly (24-word case) or hashes it with
|
||||
a domain tag (12-word case).
|
||||
2. KEZ identities aren't part of a derivation tree. There's one
|
||||
identity per phrase; there's no path component.
|
||||
|
||||
That means: **don't paste your existing hardware-wallet recovery
|
||||
phrase into KEZ** expecting to get a key that you've already seen.
|
||||
It'll produce a *new* KEZ identity uncorrelated with anything else.
|
||||
|
||||
Conversely: a KEZ phrase you saved is *only* useful for KEZ. A
|
||||
malicious wallet that says "import this phrase" can't extract your
|
||||
existing Bitcoin / Ethereum funds from a KEZ phrase, because the
|
||||
phrase wasn't derived through the same path.
|
||||
|
||||
#### Backing up — concrete advice
|
||||
|
||||
The phrase is the master key to your identity. Practical guidance:
|
||||
|
||||
- **Write it on paper, with a pencil. Number each word (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 <hex>` appears.
|
||||
Both are accepted on every command that takes a signing key.
|
||||
|
||||
For the rest of this tutorial we'll use a nostr key for examples and
|
||||
write the secret as `nsec1FAKE...` — substitute your real one.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,8 +4,9 @@ use clap::{Parser, Subcommand, ValueEnum};
|
||||
use kez_channels::nostr as nostr_chan;
|
||||
use kez_channels::{ChannelHit, Registry, parse_proof};
|
||||
use kez_core::{
|
||||
ClaimPayload, Ed25519Secret, Identity, NostrSecret, SignedClaim, Signer, Sigchain,
|
||||
VerificationStatus, dns_txt_name,
|
||||
ClaimPayload, Ed25519Secret, Identity, MnemonicWords, NostrSecret, SignedClaim, Signer,
|
||||
Sigchain, VerificationStatus, dns_txt_name, generate_mnemonic, mnemonic_from_seed_24,
|
||||
seed_from_mnemonic,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@ -47,6 +48,8 @@ enum SigchainCommand {
|
||||
nsec: Option<String>,
|
||||
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
|
||||
ed25519_seed: Option<String>,
|
||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
|
||||
mnemonic: Option<String>,
|
||||
#[arg(long)]
|
||||
proof_url: Option<String>,
|
||||
},
|
||||
@ -57,6 +60,8 @@ enum SigchainCommand {
|
||||
nsec: Option<String>,
|
||||
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
|
||||
ed25519_seed: Option<String>,
|
||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
|
||||
mnemonic: Option<String>,
|
||||
},
|
||||
/// Print the chain (events one per line, plus a summary).
|
||||
Show {
|
||||
@ -67,6 +72,8 @@ enum SigchainCommand {
|
||||
nsec: Option<String>,
|
||||
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
|
||||
ed25519_seed: Option<String>,
|
||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
|
||||
mnemonic: Option<String>,
|
||||
},
|
||||
/// Export the chain in a portable format.
|
||||
Export {
|
||||
@ -76,6 +83,8 @@ enum SigchainCommand {
|
||||
nsec: Option<String>,
|
||||
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
|
||||
ed25519_seed: Option<String>,
|
||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
|
||||
mnemonic: Option<String>,
|
||||
#[arg(long, value_enum, default_value_t = ExportFormat::Jsonl)]
|
||||
format: ExportFormat,
|
||||
#[arg(long)]
|
||||
@ -89,6 +98,8 @@ enum SigchainCommand {
|
||||
nsec: Option<String>,
|
||||
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
|
||||
ed25519_seed: Option<String>,
|
||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
|
||||
mnemonic: Option<String>,
|
||||
/// POST every event to a kez-sig-server at this URL.
|
||||
#[arg(long)]
|
||||
server: Option<String>,
|
||||
@ -117,9 +128,31 @@ enum ExportFormat {
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum IdentityCommand {
|
||||
/// Generate a new primary key. Defaults to nostr; pass --key-type
|
||||
/// ed25519 for an Ed25519 key. For Ed25519, a 24-word BIP-39 phrase
|
||||
/// is also printed (it's an equivalent representation of the seed).
|
||||
/// Use --mnemonic-words 12 to generate from a 12-word phrase instead.
|
||||
New {
|
||||
#[arg(long, value_enum, default_value_t = KeyType::Nostr)]
|
||||
key_type: KeyType,
|
||||
/// 12 or 24. Only valid with --key-type ed25519. If unset, a
|
||||
/// 24-word phrase is shown alongside the hex seed for Ed25519.
|
||||
#[arg(long = "mnemonic-words")]
|
||||
mnemonic_words: Option<u8>,
|
||||
},
|
||||
/// Print a fresh BIP-39 mnemonic phrase without deriving a key.
|
||||
/// Useful for offline backup workflows.
|
||||
Mnemonic {
|
||||
/// 12 or 24. Default 24.
|
||||
#[arg(long, default_value_t = 24)]
|
||||
words: u8,
|
||||
},
|
||||
/// Derive and print the Ed25519 primary key from an existing
|
||||
/// BIP-39 phrase (12 or 24 words, auto-detected).
|
||||
FromMnemonic {
|
||||
/// The phrase, quoted. Words separated by spaces. Case- and
|
||||
/// whitespace-tolerant.
|
||||
phrase: String,
|
||||
},
|
||||
}
|
||||
|
||||
@ -137,6 +170,8 @@ enum ClaimCommand {
|
||||
nsec: Option<String>,
|
||||
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
|
||||
ed25519_seed: Option<String>,
|
||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
|
||||
mnemonic: Option<String>,
|
||||
#[arg(long, value_enum, default_value_t = OutputFormat::Json)]
|
||||
format: OutputFormat,
|
||||
#[arg(long)]
|
||||
@ -148,6 +183,8 @@ enum ClaimCommand {
|
||||
nsec: Option<String>,
|
||||
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
|
||||
ed25519_seed: Option<String>,
|
||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
|
||||
mnemonic: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@ -172,21 +209,33 @@ async fn main() -> Result<()> {
|
||||
|
||||
match cli.command {
|
||||
Command::Identity { command } => match command {
|
||||
IdentityCommand::New { key_type } => identity_new(key_type),
|
||||
IdentityCommand::New { key_type, mnemonic_words } => {
|
||||
identity_new(key_type, mnemonic_words)
|
||||
}
|
||||
IdentityCommand::Mnemonic { words } => identity_mnemonic(words),
|
||||
IdentityCommand::FromMnemonic { phrase } => identity_from_mnemonic(&phrase),
|
||||
},
|
||||
Command::Claim { command } => match command {
|
||||
ClaimCommand::Create {
|
||||
subject,
|
||||
nsec,
|
||||
ed25519_seed,
|
||||
mnemonic,
|
||||
format,
|
||||
out,
|
||||
} => claim_create(subject, nsec, ed25519_seed, format, out),
|
||||
} => {
|
||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
||||
claim_create(subject, nsec, ed25519_seed, format, out)
|
||||
}
|
||||
ClaimCommand::Dns {
|
||||
domain,
|
||||
nsec,
|
||||
ed25519_seed,
|
||||
} => claim_dns(domain, nsec, ed25519_seed),
|
||||
mnemonic,
|
||||
} => {
|
||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
||||
claim_dns(domain, nsec, ed25519_seed)
|
||||
}
|
||||
},
|
||||
Command::Verify { command } => match command {
|
||||
VerifyCommand::File { path } => verify_file(path),
|
||||
@ -196,59 +245,90 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// If the caller passed `--mnemonic <phrase>`, derive the Ed25519 seed
|
||||
/// from it and return as hex. Otherwise return the `--ed25519-seed`
|
||||
/// passthrough unchanged. Clap conflicts_with ensures both can't be
|
||||
/// set at once.
|
||||
fn resolve_seed(
|
||||
ed25519_seed: Option<String>,
|
||||
mnemonic: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
match (ed25519_seed, mnemonic) {
|
||||
(Some(s), None) => Ok(Some(s)),
|
||||
(None, Some(phrase)) => {
|
||||
let seed = seed_from_mnemonic(&phrase)
|
||||
.map_err(|e| anyhow::anyhow!("invalid mnemonic: {e}"))?;
|
||||
Ok(Some(hex::encode(seed)))
|
||||
}
|
||||
(None, None) => Ok(None),
|
||||
(Some(_), Some(_)) => unreachable!("clap conflicts_with"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sigchain_dispatch(cmd: SigchainCommand) -> Result<()> {
|
||||
match cmd {
|
||||
SigchainCommand::Add {
|
||||
subject,
|
||||
nsec,
|
||||
ed25519_seed,
|
||||
mnemonic,
|
||||
proof_url,
|
||||
} => sigchain_add(subject, nsec, ed25519_seed, proof_url),
|
||||
} => {
|
||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
||||
sigchain_add(subject, nsec, ed25519_seed, proof_url)
|
||||
}
|
||||
SigchainCommand::Revoke {
|
||||
subject,
|
||||
nsec,
|
||||
ed25519_seed,
|
||||
} => sigchain_revoke(subject, nsec, ed25519_seed),
|
||||
mnemonic,
|
||||
} => {
|
||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
||||
sigchain_revoke(subject, nsec, ed25519_seed)
|
||||
}
|
||||
SigchainCommand::Show {
|
||||
primary,
|
||||
nsec,
|
||||
ed25519_seed,
|
||||
} => sigchain_show(primary, nsec, ed25519_seed),
|
||||
mnemonic,
|
||||
} => {
|
||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
||||
sigchain_show(primary, nsec, ed25519_seed)
|
||||
}
|
||||
SigchainCommand::Export {
|
||||
primary,
|
||||
nsec,
|
||||
ed25519_seed,
|
||||
mnemonic,
|
||||
format,
|
||||
out,
|
||||
} => sigchain_export(primary, nsec, ed25519_seed, format, out),
|
||||
} => {
|
||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
||||
sigchain_export(primary, nsec, ed25519_seed, format, out)
|
||||
}
|
||||
SigchainCommand::Publish {
|
||||
primary,
|
||||
nsec,
|
||||
ed25519_seed,
|
||||
mnemonic,
|
||||
server,
|
||||
web,
|
||||
out,
|
||||
dns,
|
||||
nostr,
|
||||
} => {
|
||||
sigchain_publish(
|
||||
primary,
|
||||
nsec,
|
||||
ed25519_seed,
|
||||
server,
|
||||
web,
|
||||
out,
|
||||
dns,
|
||||
nostr,
|
||||
)
|
||||
.await
|
||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
||||
sigchain_publish(primary, nsec, ed25519_seed, server, web, out, dns, nostr).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn identity_new(key_type: KeyType) -> Result<()> {
|
||||
match key_type {
|
||||
KeyType::Nostr => {
|
||||
fn identity_new(key_type: KeyType, mnemonic_words: Option<u8>) -> Result<()> {
|
||||
match (key_type, mnemonic_words) {
|
||||
(KeyType::Nostr, Some(_)) => {
|
||||
bail!("--mnemonic-words is only valid with --key-type ed25519");
|
||||
}
|
||||
(KeyType::Nostr, None) => {
|
||||
let secret = NostrSecret::generate();
|
||||
println!("Primary: nostr:{}", secret.npub());
|
||||
println!("Public: {}", secret.npub());
|
||||
@ -258,16 +338,58 @@ fn identity_new(key_type: KeyType) -> Result<()> {
|
||||
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity."
|
||||
);
|
||||
}
|
||||
KeyType::Ed25519 => {
|
||||
let secret = Ed25519Secret::generate();
|
||||
(KeyType::Ed25519, words_opt) => {
|
||||
// Default is 24 — the canonical bijective form (entropy IS seed).
|
||||
let words = MnemonicWords::from_count(words_opt.unwrap_or(24) as usize)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let (secret, phrase) = Ed25519Secret::generate_with_mnemonic(words)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let id = secret.identity()?;
|
||||
println!("Primary: {id}");
|
||||
println!("Public: {}", secret.pubkey_hex());
|
||||
println!("Secret: {} (32-byte seed)", secret.seed_hex());
|
||||
println!("Mnemonic ({} words): \"{}\"", words.count(), phrase);
|
||||
println!();
|
||||
println!(
|
||||
"Store the secret somewhere safe. Anyone with the seed can sign as this identity."
|
||||
);
|
||||
match words {
|
||||
MnemonicWords::TwentyFour => println!(
|
||||
"The 24-word phrase and the hex seed are equivalent backups —\n\
|
||||
either restores this identity. Store at least one safely."
|
||||
),
|
||||
MnemonicWords::Twelve => println!(
|
||||
"The 12-word phrase is the canonical backup. The hex seed is\n\
|
||||
derived from it (one-way) — you can't reconstruct the phrase\n\
|
||||
from the seed. Store the phrase safely."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn identity_mnemonic(words: u8) -> Result<()> {
|
||||
let w = MnemonicWords::from_count(words as usize).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let phrase = generate_mnemonic(w).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
println!("{phrase}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn identity_from_mnemonic(phrase: &str) -> Result<()> {
|
||||
let secret = Ed25519Secret::from_mnemonic(phrase).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let id = secret.identity()?;
|
||||
let word_count = phrase.split_whitespace().count();
|
||||
println!("Primary: {id}");
|
||||
println!("Public: {}", secret.pubkey_hex());
|
||||
println!("Secret: {} (32-byte seed)", secret.seed_hex());
|
||||
println!("Mnemonic ({} words): \"{}\"", word_count, phrase.trim());
|
||||
if word_count == 24 {
|
||||
// For 24-word, verify it round-trips so the user knows it's canonical.
|
||||
let mut seed_bytes = [0u8; 32];
|
||||
seed_bytes.copy_from_slice(&hex::decode(secret.seed_hex())?);
|
||||
let derived = mnemonic_from_seed_24(&seed_bytes).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
if derived.trim() != phrase.trim() {
|
||||
// Words were correct (parse succeeded) but their reordering differs
|
||||
// — shouldn't happen, but worth flagging if it ever does.
|
||||
println!("(note: canonical form is \"{}\")", derived);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
237
rust/crates/kez-core/src/mnemonic.rs
Normal file
237
rust/crates/kez-core/src/mnemonic.rs
Normal file
@ -0,0 +1,237 @@
|
||||
//! BIP-39 mnemonic phrases for Ed25519 primary keys.
|
||||
//!
|
||||
//! Two word counts are supported, with different semantics:
|
||||
//!
|
||||
//! - **24 words** ↔ **32 bytes of entropy** ↔ **Ed25519 seed**.
|
||||
//! Round-trips perfectly. The entropy *is* the seed. You can recover
|
||||
//! the phrase from the seed and vice versa.
|
||||
//!
|
||||
//! - **12 words** → **16 bytes of entropy** → **Ed25519 seed**, via
|
||||
//! `SHA-256("kez-bip39-12-v1" || entropy)`. The phrase is the
|
||||
//! canonical secret; the seed is derived from it deterministically.
|
||||
//! You **cannot** recover a 12-word phrase from a seed (the
|
||||
//! derivation is one-way). KEZ-specific; not interoperable with
|
||||
//! hardware wallet derivations.
|
||||
//!
|
||||
//! Wordlist: BIP-39 English. Same wordlist every other crypto wallet
|
||||
//! uses, so users can store a KEZ phrase in the same offline-paper /
|
||||
//! steel-plate setup they already use.
|
||||
//!
|
||||
//! NB: We deliberately do *not* use BIP-39's `to_seed(passphrase)`
|
||||
//! function. That produces a 64-byte seed via PBKDF2, intended to feed
|
||||
//! into BIP-32 hierarchical derivation. KEZ has one identity per phrase,
|
||||
//! no derivation tree, so taking the entropy directly (or hashing it
|
||||
//! once for 12-word phrases) is the right primitive.
|
||||
|
||||
use bip39::{Language, Mnemonic};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{Ed25519Secret, KezError, Result};
|
||||
|
||||
/// Domain separator for the 12-word → seed derivation. Bumping this
|
||||
/// would break every existing 12-word KEZ identity, so don't.
|
||||
const DOMAIN_TAG_12: &[u8] = b"kez-bip39-12-v1";
|
||||
|
||||
/// Supported mnemonic lengths.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MnemonicWords {
|
||||
Twelve,
|
||||
TwentyFour,
|
||||
}
|
||||
|
||||
impl MnemonicWords {
|
||||
pub fn count(self) -> usize {
|
||||
match self {
|
||||
Self::Twelve => 12,
|
||||
Self::TwentyFour => 24,
|
||||
}
|
||||
}
|
||||
/// Entropy length in bytes.
|
||||
pub fn entropy_bytes(self) -> usize {
|
||||
match self {
|
||||
Self::Twelve => 16,
|
||||
Self::TwentyFour => 32,
|
||||
}
|
||||
}
|
||||
pub fn from_count(n: usize) -> Result<Self> {
|
||||
match n {
|
||||
12 => Ok(Self::Twelve),
|
||||
24 => Ok(Self::TwentyFour),
|
||||
other => Err(KezError::InvalidIdentity(format!(
|
||||
"mnemonic word count must be 12 or 24, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a fresh BIP-39 mnemonic of the requested length, using OS
|
||||
/// randomness. The returned phrase is a space-separated lowercase string
|
||||
/// from the BIP-39 English wordlist.
|
||||
pub fn generate_mnemonic(words: MnemonicWords) -> Result<String> {
|
||||
let m = Mnemonic::generate(words.count())
|
||||
.map_err(|e| KezError::InvalidIdentity(format!("bip39 generate: {e}")))?;
|
||||
Ok(m.to_string())
|
||||
}
|
||||
|
||||
/// Decode a mnemonic phrase to a 32-byte Ed25519 seed. Accepts both
|
||||
/// 12-word and 24-word phrases (auto-detected from length). For
|
||||
/// 24-word, the entropy *is* the seed; for 12-word, the seed is
|
||||
/// `SHA-256(DOMAIN_TAG_12 || entropy)` (see module docs).
|
||||
pub fn seed_from_mnemonic(phrase: &str) -> Result<[u8; 32]> {
|
||||
let m = Mnemonic::parse_in_normalized(Language::English, phrase.trim())
|
||||
.map_err(|e| KezError::InvalidIdentity(format!("invalid mnemonic: {e}")))?;
|
||||
let entropy = m.to_entropy();
|
||||
match entropy.len() {
|
||||
32 => {
|
||||
// 24-word: entropy is the seed directly.
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&entropy);
|
||||
Ok(seed)
|
||||
}
|
||||
16 => {
|
||||
// 12-word: domain-tagged hash.
|
||||
let mut h = Sha256::new();
|
||||
h.update(DOMAIN_TAG_12);
|
||||
h.update(&entropy);
|
||||
Ok(h.finalize().into())
|
||||
}
|
||||
other => Err(KezError::InvalidIdentity(format!(
|
||||
"mnemonic must decode to 16 or 32 bytes of entropy, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the 24-word phrase that corresponds to this seed. This is the
|
||||
/// inverse of `seed_from_mnemonic` *for the 24-word case only*. There
|
||||
/// is no inverse for the 12-word case (hashing is one-way) — this
|
||||
/// function always produces 24 words.
|
||||
pub fn mnemonic_from_seed_24(seed: &[u8; 32]) -> Result<String> {
|
||||
let m = Mnemonic::from_entropy(seed)
|
||||
.map_err(|e| KezError::InvalidIdentity(format!("bip39 from_entropy: {e}")))?;
|
||||
Ok(m.to_string())
|
||||
}
|
||||
|
||||
impl Ed25519Secret {
|
||||
/// Construct from a BIP-39 phrase (12 or 24 words).
|
||||
pub fn from_mnemonic(phrase: &str) -> Result<Self> {
|
||||
let seed = seed_from_mnemonic(phrase)?;
|
||||
Self::from_seed_hex(&hex::encode(seed))
|
||||
}
|
||||
|
||||
/// Generate a fresh Ed25519 identity *and* return the BIP-39 phrase
|
||||
/// that derives it. Always succeeds; the phrase is the canonical
|
||||
/// human-friendly backup form.
|
||||
pub fn generate_with_mnemonic(words: MnemonicWords) -> Result<(Self, String)> {
|
||||
let phrase = generate_mnemonic(words)?;
|
||||
let secret = Self::from_mnemonic(&phrase)?;
|
||||
Ok((secret, phrase))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generate_24_round_trips() {
|
||||
let phrase = generate_mnemonic(MnemonicWords::TwentyFour).unwrap();
|
||||
assert_eq!(phrase.split_whitespace().count(), 24);
|
||||
let seed = seed_from_mnemonic(&phrase).unwrap();
|
||||
let phrase2 = mnemonic_from_seed_24(&seed).unwrap();
|
||||
assert_eq!(phrase, phrase2, "24-word phrase must round-trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_12_is_deterministic() {
|
||||
let phrase = generate_mnemonic(MnemonicWords::Twelve).unwrap();
|
||||
assert_eq!(phrase.split_whitespace().count(), 12);
|
||||
let s1 = seed_from_mnemonic(&phrase).unwrap();
|
||||
let s2 = seed_from_mnemonic(&phrase).unwrap();
|
||||
assert_eq!(s1, s2, "same phrase must give the same seed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnemonic_from_seed_24_is_inverse() {
|
||||
// Random seed → 24 words → back to same seed.
|
||||
let seed = [42u8; 32];
|
||||
let phrase = mnemonic_from_seed_24(&seed).unwrap();
|
||||
let recovered = seed_from_mnemonic(&phrase).unwrap();
|
||||
assert_eq!(seed, recovered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_phrase_errors_cleanly() {
|
||||
assert!(seed_from_mnemonic("not actually words").is_err());
|
||||
// Wrong checksum.
|
||||
assert!(
|
||||
seed_from_mnemonic(
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twelve_and_24_phrases_give_different_seeds() {
|
||||
// Sanity: 12-word entropy left-padded to 32 would equal 16
|
||||
// bytes of zeros + entropy. We DON'T do that — we hash. So
|
||||
// two phrases with overlapping entropy must not collide.
|
||||
let m12 = Mnemonic::from_entropy(&[7u8; 16]).unwrap();
|
||||
let m24 = Mnemonic::from_entropy(&[7u8; 32]).unwrap();
|
||||
let s12 = seed_from_mnemonic(&m12.to_string()).unwrap();
|
||||
let s24 = seed_from_mnemonic(&m24.to_string()).unwrap();
|
||||
assert_ne!(s12, s24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_mnemonic_matches_direct_seed_construction() {
|
||||
// 24-word case: Ed25519Secret::from_mnemonic must produce the
|
||||
// same key as Ed25519Secret::from_seed_hex(entropy).
|
||||
let phrase = mnemonic_from_seed_24(&[1u8; 32]).unwrap();
|
||||
let from_mnemonic = Ed25519Secret::from_mnemonic(&phrase).unwrap();
|
||||
let from_hex = Ed25519Secret::from_seed_hex(&hex::encode([1u8; 32])).unwrap();
|
||||
assert_eq!(from_mnemonic.pubkey_hex(), from_hex.pubkey_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_with_mnemonic_pair_is_consistent() {
|
||||
let (secret, phrase) = Ed25519Secret::generate_with_mnemonic(MnemonicWords::TwentyFour)
|
||||
.unwrap();
|
||||
let restored = Ed25519Secret::from_mnemonic(&phrase).unwrap();
|
||||
assert_eq!(secret.pubkey_hex(), restored.pubkey_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_leading_trailing_whitespace() {
|
||||
let phrase = generate_mnemonic(MnemonicWords::TwentyFour).unwrap();
|
||||
let padded = format!(" {phrase} ");
|
||||
assert_eq!(
|
||||
seed_from_mnemonic(&phrase).unwrap(),
|
||||
seed_from_mnemonic(&padded).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnemonic_words_count_round_trip() {
|
||||
assert_eq!(MnemonicWords::Twelve.count(), 12);
|
||||
assert_eq!(MnemonicWords::TwentyFour.count(), 24);
|
||||
assert_eq!(MnemonicWords::from_count(12).unwrap(), MnemonicWords::Twelve);
|
||||
assert_eq!(
|
||||
MnemonicWords::from_count(24).unwrap(),
|
||||
MnemonicWords::TwentyFour
|
||||
);
|
||||
assert!(MnemonicWords::from_count(18).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
// Catches inverse with hint for FromStr users.
|
||||
impl std::str::FromStr for MnemonicWords {
|
||||
type Err = KezError;
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
Self::from_count(
|
||||
s.parse::<usize>()
|
||||
.map_err(|_| KezError::InvalidIdentity(format!("not a number: {s}")))?,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user