Compare commits
No commits in common. "ec44018507cefbda1a6d57825e0a736970e55b8c" and "52fe2c225f156c1ae4d5602bd78cabf2ab3814d5" have entirely different histories.
ec44018507
...
52fe2c225f
7
.gitignore
vendored
7
.gitignore
vendored
@ -11,13 +11,6 @@ npm-debug.log*
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# Python
|
|
||||||
.venv/
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*.egg-info/
|
|
||||||
.pytest_cache/
|
|
||||||
|
|
||||||
# Local runtime state
|
# Local runtime state
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|||||||
51
README.md
51
README.md
@ -17,15 +17,13 @@ relay event). Anyone can verify the graph without trusting a server.
|
|||||||
├── SPEC.md ← The protocol. Language-agnostic, normative.
|
├── SPEC.md ← The protocol. Language-agnostic, normative.
|
||||||
├── rust/ ← Rust implementation (kez-core, kez-channels, kez-cli)
|
├── rust/ ← Rust implementation (kez-core, kez-channels, kez-cli)
|
||||||
├── nodejs/ ← TypeScript/Node implementation (same shape, same 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)
|
├── rust-sig-server/ ← Optional HTTP store for sigchains (axum + SQLite)
|
||||||
├── crosstest.sh ← Interop test: artifacts move between implementations
|
├── crosstest.sh ← Interop test: artifacts move between implementations
|
||||||
└── README.md ← (this file)
|
└── README.md ← (this file)
|
||||||
```
|
```
|
||||||
|
|
||||||
Three parallel implementations. **Wire-compatible**: a claim signed in Rust
|
Two parallel implementations. **Wire-compatible**: a claim signed in Rust
|
||||||
verifies in Node and Python and vice versa, in every direction. The cross-test
|
verifies in Node and vice versa. The cross-test harness proves it.
|
||||||
harness proves it.
|
|
||||||
|
|
||||||
A separate [`rust-sig-server/`](rust-sig-server/) crate provides an optional
|
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
|
HTTP storage tier for sigchains — useful when a user doesn't want to set up
|
||||||
@ -43,9 +41,6 @@ Start here:
|
|||||||
- [**`nodejs/README.md`**](nodejs/README.md) — Node/TypeScript port:
|
- [**`nodejs/README.md`**](nodejs/README.md) — Node/TypeScript port:
|
||||||
same shape as Rust, npm workspaces layout, crypto stack rationale,
|
same shape as Rust, npm workspaces layout, crypto stack rationale,
|
||||||
CLI reference.
|
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
|
- [**`rust-sig-server/README.md`**](rust-sig-server/README.md) — the
|
||||||
optional storage server: API reference, no-auth design + threat
|
optional storage server: API reference, no-auth design + threat
|
||||||
model, deployment recipes (bare-metal, Docker, PaaS), and how
|
model, deployment recipes (bare-metal, Docker, PaaS), and how
|
||||||
@ -61,9 +56,7 @@ cargo test # 99 tests
|
|||||||
cargo install --path crates/kez-cli # → `kez` on PATH
|
cargo install --path crates/kez-cli # → `kez` on PATH
|
||||||
kez verify id github:jason
|
kez verify id github:jason
|
||||||
```
|
```
|
||||||
Full guide: [`rust/README.md`](rust/README.md) (reference) ·
|
Full guide: [`rust/README.md`](rust/README.md).
|
||||||
[`rust/TUTORIAL.md`](rust/TUTORIAL.md) (step-by-step, recommended
|
|
||||||
for newcomers).
|
|
||||||
|
|
||||||
### Node.js
|
### Node.js
|
||||||
```sh
|
```sh
|
||||||
@ -72,18 +65,7 @@ npm install
|
|||||||
npm test # 91 tests
|
npm test # 91 tests
|
||||||
npm run cli -- verify id github:jason
|
npm run cli -- verify id github:jason
|
||||||
```
|
```
|
||||||
Full guide: [`nodejs/README.md`](nodejs/README.md) (reference) ·
|
Full guide: [`nodejs/README.md`](nodejs/README.md).
|
||||||
[`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)
|
### Sigchain storage server (optional)
|
||||||
```sh
|
```sh
|
||||||
@ -99,20 +81,27 @@ Full guide: [`rust-sig-server/README.md`](rust-sig-server/README.md).
|
|||||||
./crosstest.sh
|
./crosstest.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs 55 scenarios that swap implementations at the artifact boundary:
|
Runs 19 scenarios that swap implementations at the artifact boundary:
|
||||||
|
|
||||||
| # | Scenarios |
|
| # | Scenario |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 1–14 | Rust ↔ Node: JSON / compact / markdown / DNS claims, nostr + ed25519 |
|
| 1–2 | nostr-signed JSON claim, both directions |
|
||||||
| 15–20 | Rust ↔ Node sigchains: build in one, parse + show in the other; JSONL byte parity |
|
| 3–4 | nostr-signed compact claim, both directions |
|
||||||
| 21–44 | **Python ↔ Rust and Python ↔ Node** claims: every format × key type, both directions |
|
| 5–6 | nostr-signed markdown claim, both directions |
|
||||||
| — | Python ↔ both peers DNS zone form, both directions |
|
| 7–8 | nostr-signed DNS zone form, both directions |
|
||||||
| — | Python ↔ both peers sigchains: build/show both ways, JSONL byte parity, ed25519 |
|
| 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 |
|
||||||
|
|
||||||
If all 55 pass: JCS canonicalization, both signature suites (BIP-340 Schnorr
|
If all 19 pass: JCS canonicalization, both signature suites (BIP-340 Schnorr
|
||||||
and Ed25519), the compact `kez:z1:` zstd+base64url encoding, the Markdown
|
and Ed25519), the compact `kez:z1:` zstd+base64url encoding, the Markdown
|
||||||
fence, the DNS TXT shape, and the sigchain JSONL bundle format are all
|
fence, the DNS TXT shape, and the sigchain JSONL bundle format are all
|
||||||
byte-compatible across all three implementations.
|
byte-compatible across implementations.
|
||||||
|
|
||||||
Pass `-v` for verbose output (echoes intermediate commands and proofs).
|
Pass `-v` for verbose output (echoes intermediate commands and proofs).
|
||||||
|
|
||||||
|
|||||||
241
crosstest.sh
241
crosstest.sh
@ -23,14 +23,6 @@ if [[ ! -f "$TSX_LOADER" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
NODE_CLI=(node --import "$TSX_LOADER" "$ROOT/nodejs/packages/kez-cli/src/cli.ts")
|
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
|
VERBOSE=0
|
||||||
[[ "${1:-}" == "-v" ]] && VERBOSE=1
|
[[ "${1:-}" == "-v" ]] && VERBOSE=1
|
||||||
@ -81,35 +73,6 @@ assert_verify_valid() {
|
|||||||
fi
|
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).
|
# Pre-flight: build Rust release once (much faster reruns).
|
||||||
printf "%sBuilding Rust impl…%s\n" "$YELLOW" "$RESET"
|
printf "%sBuilding Rust impl…%s\n" "$YELLOW" "$RESET"
|
||||||
cargo build --quiet --manifest-path "$ROOT/rust/Cargo.toml" -p kez-cli
|
cargo build --quiet --manifest-path "$ROOT/rust/Cargo.toml" -p kez-cli
|
||||||
@ -218,40 +181,6 @@ scenario "node ed25519 markdown ⇒ rust verify file"
|
|||||||
"${RUST_CLI[@]}" verify file "$TMP/n.kez.md" > "$TMP/n.out" 2>&1
|
"${RUST_CLI[@]}" verify file "$TMP/n.kez.md" > "$TMP/n.out" 2>&1
|
||||||
assert_verify_valid "node→rust ed25519 markdown" "$TMP/n.out" && ok
|
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 interop ────────────────────────────────────────────────────────
|
||||||
# Sigchain state lives in ~/.kez/sigchains/<safe-primary>.jsonl. Both CLIs
|
# 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
|
# read/write the same paths, so we can build a chain in one and inspect it
|
||||||
@ -360,176 +289,6 @@ if grep -q "Length: 2 event(s)" "$TMP/sc_f.out"; then ok; else
|
|||||||
fi
|
fi
|
||||||
rm -f "$SC_ED_FILE"
|
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"
|
printf "\n"
|
||||||
if [[ $FAIL -eq 0 ]]; then
|
if [[ $FAIL -eq 0 ]]; then
|
||||||
printf "%sAll %d scenarios passed.%s\n" "$GREEN" "$PASS" "$RESET"
|
printf "%sAll %d scenarios passed.%s\n" "$GREEN" "$PASS" "$RESET"
|
||||||
|
|||||||
@ -1,267 +0,0 @@
|
|||||||
# 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,7 +12,6 @@
|
|||||||
"@noble/curves": "^1.6.0",
|
"@noble/curves": "^1.6.0",
|
||||||
"@noble/hashes": "^1.5.0",
|
"@noble/hashes": "^1.5.0",
|
||||||
"@scure/base": "^1.1.9",
|
"@scure/base": "^1.1.9",
|
||||||
"@scure/bip39": "^2.2.0",
|
|
||||||
"canonicalize": "^2.0.0",
|
"canonicalize": "^2.0.0",
|
||||||
"emoji-picker-element": "^1.29.1",
|
"emoji-picker-element": "^1.29.1",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
@ -3165,22 +3164,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scure/bip39": {
|
"node_modules/@scure/bip39": {
|
||||||
"version": "2.2.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||||
"integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
|
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "2.2.0",
|
"@noble/hashes": "2.0.1",
|
||||||
"@scure/base": "2.2.0"
|
"@scure/base": "2.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
|
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
|
||||||
"version": "2.2.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.19.0"
|
"node": ">= 20.19.0"
|
||||||
@ -3190,9 +3189,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scure/bip39/node_modules/@scure/base": {
|
"node_modules/@scure/bip39/node_modules/@scure/base": {
|
||||||
"version": "2.2.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
@ -6052,19 +6051,6 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/nostr-wasm": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
|
||||||
|
|||||||
@ -14,7 +14,6 @@
|
|||||||
"@noble/curves": "^1.6.0",
|
"@noble/curves": "^1.6.0",
|
||||||
"@noble/hashes": "^1.5.0",
|
"@noble/hashes": "^1.5.0",
|
||||||
"@scure/base": "^1.1.9",
|
"@scure/base": "^1.1.9",
|
||||||
"@scure/bip39": "^2.2.0",
|
|
||||||
"canonicalize": "^2.0.0",
|
"canonicalize": "^2.0.0",
|
||||||
"emoji-picker-element": "^1.29.1",
|
"emoji-picker-element": "^1.29.1",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 504 B After Width: | Height: | Size: 535 B |
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { ed25519 } from "@noble/curves/ed25519";
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
import { bytesToHex } from "@noble/hashes/utils";
|
import { bytesToHex } from "@noble/hashes/utils";
|
||||||
import type { SignedRegistration, SignedSigchainEvent } from "./kez.js";
|
import type { SignedRegistration } from "./kez.js";
|
||||||
|
|
||||||
export interface HandleResponse {
|
export interface HandleResponse {
|
||||||
handle: string;
|
handle: string;
|
||||||
@ -118,53 +118,3 @@ export async function register(
|
|||||||
});
|
});
|
||||||
return unwrap(resp);
|
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,25 +17,8 @@ export interface StoredClaim {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
/** Latest verification result, if we've checked. */
|
/** Latest verification result, if we've checked. */
|
||||||
last_verify?: VerifyResult;
|
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[]> {
|
export async function listClaims(): Promise<StoredClaim[]> {
|
||||||
return (await get<StoredClaim[]>(KEY)) ?? [];
|
return (await get<StoredClaim[]>(KEY)) ?? [];
|
||||||
}
|
}
|
||||||
@ -67,18 +50,6 @@ 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> {
|
export async function removeClaim(id: string): Promise<void> {
|
||||||
const existing = await listClaims();
|
const existing = await listClaims();
|
||||||
await set(
|
await set(
|
||||||
|
|||||||
@ -33,12 +33,6 @@ interface StoredIdentity {
|
|||||||
salt: string; // hex, 16 bytes
|
salt: string; // hex, 16 bytes
|
||||||
nonce: string; // hex, 12 bytes
|
nonce: string; // hex, 12 bytes
|
||||||
ciphertext: string; // hex; AES-GCM(seed) under PBKDF2(passphrase)
|
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:
|
// Metadata:
|
||||||
created_at: string; // RFC3339
|
created_at: string; // RFC3339
|
||||||
}
|
}
|
||||||
@ -48,8 +42,6 @@ export interface UnlockedIdentity {
|
|||||||
server: string;
|
server: string;
|
||||||
primary: Identity;
|
primary: Identity;
|
||||||
seed: Uint8Array;
|
seed: Uint8Array;
|
||||||
/** 12-word recovery phrase. Absent for pre-mnemonic accounts. */
|
|
||||||
phrase?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 SHA-256 guidance
|
const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 SHA-256 guidance
|
||||||
@ -84,15 +76,6 @@ export async function hasStoredIdentity(): Promise<boolean> {
|
|||||||
return !!stored;
|
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<
|
export async function loadStoredIdentityMeta(): Promise<
|
||||||
Pick<StoredIdentity, "handle" | "server" | "primary" | "created_at"> | null
|
Pick<StoredIdentity, "handle" | "server" | "primary" | "created_at"> | null
|
||||||
> {
|
> {
|
||||||
@ -108,11 +91,6 @@ export async function saveIdentity(opts: {
|
|||||||
primary: Identity;
|
primary: Identity;
|
||||||
seed: Uint8Array;
|
seed: Uint8Array;
|
||||||
passphrase: string;
|
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> {
|
}): Promise<void> {
|
||||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||||
@ -135,21 +113,6 @@ export async function saveIdentity(opts: {
|
|||||||
ciphertext: bytesToHex(ciphertext),
|
ciphertext: bytesToHex(ciphertext),
|
||||||
created_at: new Date().toISOString(),
|
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);
|
await set(IDB_KEY, record);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,34 +144,11 @@ export async function unlockIdentity(
|
|||||||
`unlocked seed is ${plaintext.length} bytes, expected 32`,
|
`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 {
|
return {
|
||||||
handle: stored.handle,
|
handle: stored.handle,
|
||||||
server: stored.server,
|
server: stored.server,
|
||||||
primary: stored.primary,
|
primary: stored.primary,
|
||||||
seed: plaintext,
|
seed: plaintext,
|
||||||
phrase,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
// reconsider depending on the Node port.
|
// reconsider depending on the Node port.
|
||||||
|
|
||||||
import { ed25519 } from "@noble/curves/ed25519";
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
import { sha256, sha512 } from "@noble/hashes/sha2";
|
import { sha512 } from "@noble/hashes/sha2";
|
||||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||||
import canonicalize from "canonicalize";
|
import canonicalize from "canonicalize";
|
||||||
|
|
||||||
@ -19,8 +19,6 @@ export const CLAIM_TYPE = "kez.claim";
|
|||||||
export const REGISTRATION_TYPE = "kez.chat.handle_registration";
|
export const REGISTRATION_TYPE = "kez.chat.handle_registration";
|
||||||
export const REGISTRATION_ENVELOPE = "handle_registration";
|
export const REGISTRATION_ENVELOPE = "handle_registration";
|
||||||
export const CLAIM_ENVELOPE = "claim";
|
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 ED25519_SHA512_ALG = "ed25519-sha512-jcs";
|
||||||
export const FORMAT_VERSION = 1;
|
export const FORMAT_VERSION = 1;
|
||||||
export const COMPACT_PROOF_PREFIX = "kez:z1:";
|
export const COMPACT_PROOF_PREFIX = "kez:z1:";
|
||||||
@ -72,33 +70,6 @@ export interface SignedRegistration {
|
|||||||
signature: SignatureBlock;
|
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
|
// Key generation + restoration
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -166,22 +137,6 @@ 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
|
// Verification
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -256,7 +211,7 @@ export function signClaim(
|
|||||||
version: FORMAT_VERSION,
|
version: FORMAT_VERSION,
|
||||||
subject,
|
subject,
|
||||||
primary: signer.identity,
|
primary: signer.identity,
|
||||||
created_at: rfc3339Utc(createdAt),
|
created_at: createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
kez: CLAIM_ENVELOPE,
|
kez: CLAIM_ENVELOPE,
|
||||||
@ -278,7 +233,7 @@ export function signRegistration(
|
|||||||
handle,
|
handle,
|
||||||
primary: signer.identity,
|
primary: signer.identity,
|
||||||
server,
|
server,
|
||||||
created_at: rfc3339Utc(createdAt),
|
created_at: createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
kez: REGISTRATION_ENVELOPE,
|
kez: REGISTRATION_ENVELOPE,
|
||||||
@ -287,59 +242,6 @@ 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
|
// Encodings — pretty JSON, compact (kez:z1:), markdown fence
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
// 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,11 +9,7 @@
|
|||||||
toCompact,
|
toCompact,
|
||||||
type SignedClaimEnvelope,
|
type SignedClaimEnvelope,
|
||||||
} from "../lib/kez.js";
|
} from "../lib/kez.js";
|
||||||
import { addClaim, setSigchainSync } from "../lib/claims-store.js";
|
import { addClaim } from "../lib/claims-store.js";
|
||||||
import {
|
|
||||||
appendSubjectEvent,
|
|
||||||
resolveChainService,
|
|
||||||
} from "../lib/sigchain-service.js";
|
|
||||||
import { session } from "../lib/store.svelte.js";
|
import { session } from "../lib/store.svelte.js";
|
||||||
import {
|
import {
|
||||||
hasNip07,
|
hasNip07,
|
||||||
@ -158,14 +154,6 @@
|
|||||||
| { status: "error"; message: string }
|
| { status: "error"; message: string }
|
||||||
>({ status: "idle" });
|
>({ 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). */
|
/** Re-evaluated each render; cheap (just a typeof check on window.nostr). */
|
||||||
const nip07Available = $derived(hasNip07());
|
const nip07Available = $derived(hasNip07());
|
||||||
|
|
||||||
@ -238,15 +226,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveAndDone() {
|
async function saveAndDone() {
|
||||||
if (!envelope || !selected || !session.unlocked) return;
|
if (!envelope || !selected) return;
|
||||||
const id = crypto.randomUUID();
|
|
||||||
const subject = envelope.payload.subject;
|
|
||||||
try {
|
try {
|
||||||
// $state wraps `envelope` in a deep Proxy; structuredClone (used
|
// $state wraps `envelope` in a deep Proxy; structuredClone (used
|
||||||
// by idb-keyval) can't clone proxies and throws DataCloneError.
|
// by idb-keyval) can't clone proxies and throws DataCloneError.
|
||||||
// $state.snapshot returns a plain, cloneable object.
|
// $state.snapshot returns a plain, cloneable object.
|
||||||
await addClaim({
|
await addClaim({
|
||||||
id,
|
id: crypto.randomUUID(),
|
||||||
envelope: $state.snapshot(envelope) as SignedClaimEnvelope,
|
envelope: $state.snapshot(envelope) as SignedClaimEnvelope,
|
||||||
channel: selected.key,
|
channel: selected.key,
|
||||||
});
|
});
|
||||||
@ -254,36 +240,6 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("saveAndDone failed", e);
|
console.error("saveAndDone failed", e);
|
||||||
alert(`Failed to save claim: ${(e as Error).message}`);
|
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>
|
</script>
|
||||||
@ -496,28 +452,6 @@
|
|||||||
Once you've published the proof on that channel, come back to the
|
Once you've published the proof on that channel, come back to the
|
||||||
Claims page and mark it published.
|
Claims page and mark it published.
|
||||||
</p>
|
</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">
|
<div class="mt-4 flex gap-2">
|
||||||
<a
|
<a
|
||||||
href="#/claims"
|
href="#/claims"
|
||||||
@ -532,8 +466,6 @@
|
|||||||
selected = null;
|
selected = null;
|
||||||
identifierInput = "";
|
identifierInput = "";
|
||||||
envelope = null;
|
envelope = null;
|
||||||
nostrPublish = { status: "idle" };
|
|
||||||
sigchainSync = { status: "idle" };
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add another
|
Add another
|
||||||
|
|||||||
@ -6,13 +6,8 @@
|
|||||||
markPublished,
|
markPublished,
|
||||||
removeClaim,
|
removeClaim,
|
||||||
setVerifyResult,
|
setVerifyResult,
|
||||||
setSigchainSync,
|
|
||||||
type StoredClaim,
|
type StoredClaim,
|
||||||
} from "../lib/claims-store.js";
|
} from "../lib/claims-store.js";
|
||||||
import {
|
|
||||||
appendSubjectEvent,
|
|
||||||
resolveChainService,
|
|
||||||
} from "../lib/sigchain-service.js";
|
|
||||||
import { verifyClaim } from "../lib/verify.js";
|
import { verifyClaim } from "../lib/verify.js";
|
||||||
import { session } from "../lib/store.svelte.js";
|
import { session } from "../lib/store.svelte.js";
|
||||||
|
|
||||||
@ -20,8 +15,6 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
/** ids currently mid-verify, so we can disable the button + show a spinner. */
|
/** ids currently mid-verify, so we can disable the button + show a spinner. */
|
||||||
let verifying = $state<Set<string>>(new Set());
|
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. */
|
/** Which claims have their details panel expanded. */
|
||||||
let expanded = $state<Set<string>>(new Set());
|
let expanded = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
@ -40,74 +33,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteClaim(c: StoredClaim) {
|
async function deleteClaim(c: StoredClaim) {
|
||||||
if (!confirm(`Remove the claim for ${c.envelope.payload.subject}?`)) return;
|
if (!confirm(`Remove the local copy of 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);
|
await removeClaim(c.id);
|
||||||
claims = await listClaims();
|
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) {
|
async function runVerify(c: StoredClaim) {
|
||||||
verifying = new Set(verifying).add(c.id);
|
verifying = new Set(verifying).add(c.id);
|
||||||
try {
|
try {
|
||||||
@ -219,19 +149,6 @@
|
|||||||
Channel: <span class="font-mono">{c.channel}</span> ·
|
Channel: <span class="font-mono">{c.channel}</span> ·
|
||||||
Signed: <span class="font-mono">{c.envelope.payload.created_at}</span>
|
Signed: <span class="font-mono">{c.envelope.payload.created_at}</span>
|
||||||
</p>
|
</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}
|
{#if c.last_verify}
|
||||||
<p class="mt-1 text-xs text-text-secondary">
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
{c.last_verify.summary}
|
{c.last_verify.summary}
|
||||||
@ -291,20 +208,9 @@
|
|||||||
Mark published
|
Mark published
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
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"
|
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-danger/10 hover:border-danger"
|
||||||
onclick={() => deleteClaim(c)}
|
onclick={() => deleteClaim(c)}
|
||||||
disabled={syncing.has(c.id)}
|
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { push } from "svelte-spa-router";
|
import { push } from "svelte-spa-router";
|
||||||
|
import { bytesToHex } from "@noble/hashes/utils";
|
||||||
import {
|
import {
|
||||||
|
generateIdentity,
|
||||||
signRegistration,
|
signRegistration,
|
||||||
type Ed25519Identity,
|
type Ed25519Identity,
|
||||||
} from "../lib/kez.js";
|
} from "../lib/kez.js";
|
||||||
import { generateIdentityWithMnemonic } from "../lib/mnemonic.js";
|
|
||||||
import { register, healthz, ApiError } from "../lib/api.js";
|
import { register, healthz, ApiError } from "../lib/api.js";
|
||||||
import { saveIdentity } from "../lib/identity-store.js";
|
import { saveIdentity } from "../lib/identity-store.js";
|
||||||
import { session } from "../lib/store.svelte.js";
|
import { session } from "../lib/store.svelte.js";
|
||||||
|
|
||||||
let step = $state<"handle" | "phrase" | "confirm" | "submitting" | "done">("handle");
|
let step = $state<"handle" | "seed" | "confirm" | "submitting" | "done">("handle");
|
||||||
|
|
||||||
let handle = $state("");
|
let handle = $state("");
|
||||||
let passphrase = $state("");
|
let passphrase = $state("");
|
||||||
@ -18,8 +19,8 @@
|
|||||||
|
|
||||||
let serverInfo = $state<{ server: string; version: string } | null>(null);
|
let serverInfo = $state<{ server: string; version: string } | null>(null);
|
||||||
let id = $state<Ed25519Identity | null>(null);
|
let id = $state<Ed25519Identity | null>(null);
|
||||||
let phrase = $state("");
|
let seedHex = $state("");
|
||||||
let phraseAck = $state(false);
|
let seedAck = $state(false);
|
||||||
|
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let working = $state(false);
|
let working = $state(false);
|
||||||
@ -47,10 +48,9 @@
|
|||||||
if (v) { error = v; return; }
|
if (v) { error = v; return; }
|
||||||
if (passphrase.length < 8) { error = "Passphrase must be at least 8 characters."; return; }
|
if (passphrase.length < 8) { error = "Passphrase must be at least 8 characters."; return; }
|
||||||
if (passphrase !== passphrase2) { error = "Passphrases don't match."; return; }
|
if (passphrase !== passphrase2) { error = "Passphrases don't match."; return; }
|
||||||
const gen = generateIdentityWithMnemonic();
|
id = generateIdentity();
|
||||||
id = gen.identity;
|
seedHex = bytesToHex(id.seed);
|
||||||
phrase = gen.phrase;
|
step = "seed";
|
||||||
step = "phrase";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitRegistration() {
|
async function submitRegistration() {
|
||||||
@ -67,7 +67,6 @@
|
|||||||
primary: id.identity,
|
primary: id.identity,
|
||||||
seed: id.seed,
|
seed: id.seed,
|
||||||
passphrase,
|
passphrase,
|
||||||
phrase,
|
|
||||||
});
|
});
|
||||||
session.setUnlocked({
|
session.setUnlocked({
|
||||||
handle: resp.handle,
|
handle: resp.handle,
|
||||||
@ -106,7 +105,7 @@
|
|||||||
<ol class="flex gap-2 text-xs text-text-muted">
|
<ol class="flex gap-2 text-xs text-text-muted">
|
||||||
<li class={step === "handle" ? "font-semibold text-text" : ""}>1. Handle</li>
|
<li class={step === "handle" ? "font-semibold text-text" : ""}>1. Handle</li>
|
||||||
<li>→</li>
|
<li>→</li>
|
||||||
<li class={step === "phrase" ? "font-semibold text-text" : ""}>2. Back up phrase</li>
|
<li class={step === "seed" ? "font-semibold text-text" : ""}>2. Back up seed</li>
|
||||||
<li>→</li>
|
<li>→</li>
|
||||||
<li class={step === "confirm" || step === "submitting" ? "font-semibold text-text" : ""}>3. Confirm</li>
|
<li class={step === "confirm" || step === "submitting" ? "font-semibold text-text" : ""}>3. Confirm</li>
|
||||||
<li>→</li>
|
<li>→</li>
|
||||||
@ -182,38 +181,32 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if step === "phrase" && id}
|
{#if step === "seed" && id}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="border border-warning/40 bg-warning/10 rounded-lg p-4 space-y-3">
|
<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 recovery phrase</p>
|
<p class="font-semibold text-warning">⚠️ Back up your seed now</p>
|
||||||
<p class="text-sm text-warning">
|
<p class="text-sm text-warning">
|
||||||
This 12-word phrase is the only way to recover your account on
|
This is the only way to recover your account on another device
|
||||||
another device (or after clearing this browser). The server
|
(or after clearing this browser). The server doesn't have it.
|
||||||
doesn't have it. Write it down on paper — don't just rely on
|
Write it down or paste into a password manager.
|
||||||
this browser.
|
|
||||||
</p>
|
</p>
|
||||||
<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">
|
<div class="mt-3 p-3 bg-surface border border-warning/40 rounded font-mono text-sm break-all select-all">
|
||||||
{#each phrase.split(" ") as word, i}
|
{seedHex}
|
||||||
<li class="flex items-baseline gap-2">
|
</div>
|
||||||
<span class="text-text-muted text-xs w-5 text-right">{i + 1}.</span>
|
<div class="flex gap-2">
|
||||||
<span class="text-text select-all">{word}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
<div class="flex gap-2 flex-wrap">
|
|
||||||
<button
|
<button
|
||||||
class="text-xs px-3 py-1 border border-warning/40 text-warning rounded hover:bg-warning/20"
|
class="text-xs px-3 py-1 bg-warning/10 text-accent-contrast rounded hover:bg-warning/20"
|
||||||
onclick={() => copyToClipboard(phrase)}
|
onclick={() => copyToClipboard(seedHex)}
|
||||||
>
|
>
|
||||||
Copy all 12 words
|
Copy seed
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-start gap-2 text-sm text-text-secondary">
|
<label class="flex items-start gap-2 text-sm text-text-secondary">
|
||||||
<input type="checkbox" bind:checked={phraseAck} class="mt-1" />
|
<input type="checkbox" bind:checked={seedAck} class="mt-1" />
|
||||||
I've written down these 12 words in order. I understand losing them
|
I've saved this seed somewhere safe. I understand losing it means
|
||||||
means losing my account permanently.
|
losing my account permanently.
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@ -225,7 +218,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
|
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
|
||||||
disabled={!phraseAck}
|
disabled={!seedAck}
|
||||||
onclick={() => { step = "confirm"; }}
|
onclick={() => { step = "confirm"; }}
|
||||||
>
|
>
|
||||||
I've saved it — continue
|
I've saved it — continue
|
||||||
@ -245,7 +238,7 @@
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
|
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
|
||||||
onclick={() => { step = "phrase"; }}
|
onclick={() => { step = "seed"; }}
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,18 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { push } from "svelte-spa-router";
|
import { push } from "svelte-spa-router";
|
||||||
import { hexToBytes } from "@noble/hashes/utils";
|
import { hexToBytes } from "@noble/hashes/utils";
|
||||||
import { identityFromSeed, signRegistration } from "../lib/kez.js";
|
import { identityFromSeed } from "../lib/kez.js";
|
||||||
import {
|
import { lookup, healthz, ApiError } from "../lib/api.js";
|
||||||
seedFromMnemonic,
|
|
||||||
isValidMnemonic,
|
|
||||||
} from "../lib/mnemonic.js";
|
|
||||||
import { healthz, lookupByPrimary, ApiError } from "../lib/api.js";
|
|
||||||
import { saveIdentity } from "../lib/identity-store.js";
|
import { saveIdentity } from "../lib/identity-store.js";
|
||||||
import { session } from "../lib/store.svelte.js";
|
import { session } from "../lib/store.svelte.js";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
/** User pastes either a 12-word phrase OR a 64-char hex seed. */
|
let seedHex = $state("");
|
||||||
let secretInput = $state("");
|
|
||||||
let passphrase = $state("");
|
let passphrase = $state("");
|
||||||
let passphrase2 = $state("");
|
let passphrase2 = $state("");
|
||||||
let serverDomain = $state<string | null>(null);
|
let serverDomain = $state<string | null>(null);
|
||||||
@ -28,78 +23,38 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 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() {
|
async function submit() {
|
||||||
error = null;
|
error = null;
|
||||||
working = true;
|
working = true;
|
||||||
try {
|
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) {
|
if (passphrase.length < 8) {
|
||||||
throw new Error("Passphrase must be at least 8 characters.");
|
throw new Error("Passphrase must be at least 8 characters.");
|
||||||
}
|
}
|
||||||
if (passphrase !== passphrase2) {
|
if (passphrase !== passphrase2) {
|
||||||
throw new Error("Passphrases don't match.");
|
throw new Error("Passphrases don't match.");
|
||||||
}
|
}
|
||||||
|
const seed = hexToBytes(cleaned);
|
||||||
|
const id = identityFromSeed(seed);
|
||||||
if (!serverDomain) {
|
if (!serverDomain) {
|
||||||
throw new Error("Server unreachable; refresh and try again.");
|
throw new Error("Server unreachable; refresh and try again.");
|
||||||
}
|
}
|
||||||
|
// Look up the primary on the server to find the associated handle.
|
||||||
const { seed, phrase } = parseSecret(secretInput);
|
// We try a couple of common handles? No — the registry is keyed by
|
||||||
const id = identityFromSeed(seed);
|
// handle, not primary. So we ask the user to type their handle.
|
||||||
|
throw new Error(
|
||||||
// Look up the handle this primary is registered to.
|
"Sorry — to restore, please use a device that has your handle saved. " +
|
||||||
let record;
|
"(v0.2 will let you look up your handle by primary key.)",
|
||||||
try {
|
);
|
||||||
record = await lookupByPrimary(id.identity);
|
// TODO when chat-server has GET /v1/by-primary/<id>: implement this.
|
||||||
} catch (e) {
|
// For now restoring a seed-only is incomplete because we don't know
|
||||||
if (e instanceof ApiError && e.status === 404) {
|
// the handle. Workaround: regenerate identity via /create with same
|
||||||
throw new Error(
|
// handle (server will reject as taken; not useful) OR ask the user.
|
||||||
"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) {
|
} catch (e) {
|
||||||
error =
|
error = e instanceof ApiError ? `${e.code ?? "error"}: ${e.message}` : (e as Error).message;
|
||||||
e instanceof ApiError ? `${e.code ?? "error"}: ${e.message}` : (e as Error).message;
|
|
||||||
} finally {
|
} finally {
|
||||||
working = false;
|
working = false;
|
||||||
}
|
}
|
||||||
@ -107,13 +62,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h1 class="text-2xl font-bold text-text">Restore account</h1>
|
<h1 class="text-2xl font-bold text-text">Restore from seed</h1>
|
||||||
|
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary bg-warning/10 border border-warning/40 rounded p-3">
|
||||||
Paste your 12-word recovery phrase. (If you wrote down a 64-character
|
<strong>v0.1 limitation:</strong> the seed alone doesn't tell us which
|
||||||
hex seed from an older version of kez-chat, that works too.) We'll
|
handle to restore. For now this flow doesn't work end-to-end — we'll
|
||||||
look up your handle on <span class="font-mono">{serverDomain ?? "the server"}</span>
|
add <code>GET /v1/by-primary/<id></code> on the server in v0.2
|
||||||
and unlock the account on this device.
|
so the SPA can look up the handle from the public key.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@ -121,17 +76,14 @@
|
|||||||
onsubmit={(e) => { e.preventDefault(); submit(); }}
|
onsubmit={(e) => { e.preventDefault(); submit(); }}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-text-secondary" for="secret">
|
<label class="block text-sm font-medium text-text-secondary" for="seed">
|
||||||
Recovery phrase or hex seed
|
Seed (64 hex characters)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="secret"
|
id="seed"
|
||||||
bind:value={secretInput}
|
bind:value={seedHex}
|
||||||
rows="3"
|
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"
|
class="mt-1 w-full px-3 py-2 border border-border rounded-md font-mono text-sm"
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
import { push } from "svelte-spa-router";
|
import { push } from "svelte-spa-router";
|
||||||
import { bytesToHex } from "@noble/hashes/utils";
|
import { bytesToHex } from "@noble/hashes/utils";
|
||||||
import { session } from "../lib/store.svelte.js";
|
import { session } from "../lib/store.svelte.js";
|
||||||
import { hasStoredPhrase } from "../lib/identity-store.js";
|
|
||||||
import {
|
import {
|
||||||
hasStoredBiometric,
|
hasStoredBiometric,
|
||||||
getStoredBiometricMeta,
|
getStoredBiometricMeta,
|
||||||
@ -93,36 +92,10 @@
|
|||||||
setTimeout(() => (testNotifResult = null), 5_000);
|
setTimeout(() => (testNotifResult = null), 5_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showSeed() {
|
function showSeed() {
|
||||||
if (!session.unlocked) return;
|
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);
|
const hex = bytesToHex(session.unlocked.seed);
|
||||||
alert(
|
alert(`Your recovery seed (KEEP SECRET):\n\n${hex}\n\nWrite this down somewhere safe. It's the ONLY way to recover this account.`);
|
||||||
`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() {
|
function lock() {
|
||||||
@ -193,13 +166,12 @@
|
|||||||
|
|
||||||
<!-- Recovery phrase -->
|
<!-- Recovery phrase -->
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-text">Recovery phrase</p>
|
<p class="text-sm font-medium text-text">Recovery seed</p>
|
||||||
<p class="text-sm text-text-secondary mt-0.5">
|
<p class="text-sm text-text-secondary mt-0.5">
|
||||||
12 words that recover this account anywhere. Write them down on
|
The only thing that can recover this account. Write it down offline.
|
||||||
paper — losing them means losing the account.
|
|
||||||
</p>
|
</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}>
|
<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 phrase
|
Reveal seed
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -23,10 +23,9 @@
|
|||||||
let biometricAvailable = $state(false);
|
let biometricAvailable = $state(false);
|
||||||
let notifPerm = $state<NotificationPermission | "unsupported">("default");
|
let notifPerm = $state<NotificationPermission | "unsupported">("default");
|
||||||
|
|
||||||
let backupRevealed = $state(false);
|
let seedRevealed = $state(false);
|
||||||
let backupText = $state(""); // 12-word phrase if available, else hex seed
|
let seedHex = $state("");
|
||||||
let backupKind = $state<"phrase" | "seed">("phrase");
|
let seedCopied = $state(false);
|
||||||
let backupCopied = $state(false);
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@ -43,20 +42,13 @@
|
|||||||
|
|
||||||
function revealSeed() {
|
function revealSeed() {
|
||||||
if (!session.unlocked) return;
|
if (!session.unlocked) return;
|
||||||
if (session.unlocked.phrase) {
|
seedHex = bytesToHex(session.unlocked.seed);
|
||||||
backupText = session.unlocked.phrase;
|
seedRevealed = true;
|
||||||
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() {
|
async function copySeed() {
|
||||||
await navigator.clipboard.writeText(backupText);
|
await navigator.clipboard.writeText(seedHex);
|
||||||
backupCopied = true;
|
seedCopied = true;
|
||||||
setTimeout(() => (backupCopied = false), 1500);
|
setTimeout(() => (seedCopied = false), 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enableBiometric() {
|
async function enableBiometric() {
|
||||||
@ -117,40 +109,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- 2. Back up phrase (critical, skippable) -->
|
<!-- 2. Back up seed (critical, skippable) -->
|
||||||
<li class={`bg-surface border rounded-xl p-4 ${onboarding.seedAcked ? "border-border" : "border-warning/50"}`}>
|
<li class={`bg-surface border rounded-xl p-4 ${onboarding.seedAcked ? "border-border" : "border-warning/50"}`}>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="shrink-0 mt-0.5">{onboarding.seedAcked ? "✓" : "🔑"}</span>
|
<span class="shrink-0 mt-0.5">{onboarding.seedAcked ? "✓" : "🔑"}</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-semibold text-text">Back up your recovery phrase</p>
|
<p class="text-sm font-semibold text-text">Back up your recovery seed</p>
|
||||||
<p class="text-xs text-text-secondary">
|
<p class="text-xs text-text-secondary">
|
||||||
These 12 words are the <strong>only</strong> way to recover
|
This 32-byte seed is the <strong>only</strong> way to recover your
|
||||||
your account. Lose them and it's gone forever — there's no
|
account. Lose it and it's gone forever — there's no reset. Write it
|
||||||
reset. Write them on paper.
|
down offline.
|
||||||
</p>
|
</p>
|
||||||
{#if !backupRevealed && !onboarding.seedAcked}
|
{#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 phrase</button>
|
<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}
|
{/if}
|
||||||
{#if backupRevealed}
|
{#if seedRevealed}
|
||||||
<div class="mt-2 p-3 bg-elevated border border-border rounded-md">
|
<div class="mt-2 p-3 bg-elevated border border-border rounded-md">
|
||||||
{#if backupKind === "phrase"}
|
<p class="font-mono text-xs text-text break-all select-all">{seedHex}</p>
|
||||||
<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">
|
<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}>{backupCopied ? "✓ copied" : "Copy"}</button>
|
<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>
|
||||||
{#if !onboarding.seedAcked}
|
{#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>
|
<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}
|
{/if}
|
||||||
|
|||||||
@ -97,124 +97,24 @@ A new nostr keypair:
|
|||||||
npm run cli -- identity new
|
npm run cli -- identity new
|
||||||
```
|
```
|
||||||
|
|
||||||
Or a new Ed25519 keypair, which comes with a BIP-39 phrase alongside
|
Or a new Ed25519 keypair:
|
||||||
the hex seed (both are equivalent backups):
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run cli -- identity new --key-type ed25519 # 24-word
|
npm run cli -- identity new --key-type ed25519
|
||||||
npm run cli -- identity new --key-type ed25519 --mnemonic-words 12 # 12-word
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Output (24-word, the default):
|
Output (Ed25519):
|
||||||
|
|
||||||
```
|
```
|
||||||
Primary: ed25519:7a3b4c…
|
Primary: ed25519:7a3b4c…
|
||||||
Public: 7a3b4c…
|
Public: 7a3b4c… (hex)
|
||||||
Secret: 9e3f51… (32-byte seed)
|
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
|
> **Save the secret.** It's the only thing that can sign as this
|
||||||
> and the identity is gone. There's no recovery flow.
|
> identity. There's no recovery flow — lose it and the identity is
|
||||||
|
> gone. Write it down offline, or paste it into a password manager.
|
||||||
### Recovery phrases — what's actually going on
|
> From here on this tutorial assumes you stored it.
|
||||||
|
|
||||||
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
|
For the rest of this tutorial we'll use a nostr key for examples and
|
||||||
write the secret as `nsec1FAKE...` — substitute your real one.
|
write the secret as `nsec1FAKE...` — substitute your real one.
|
||||||
|
|||||||
262
nodejs/package-lock.json
generated
262
nodejs/package-lock.json
generated
@ -549,9 +549,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
|
||||||
"integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
|
"integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -563,9 +563,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
|
||||||
"integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
|
"integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -577,9 +577,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
|
||||||
"integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
|
"integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -591,9 +591,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
|
||||||
"integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
|
"integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -605,9 +605,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
|
||||||
"integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
|
"integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -619,9 +619,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
|
||||||
"integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
|
"integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -633,9 +633,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
|
||||||
"integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
|
"integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -650,9 +650,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
|
||||||
"integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
|
"integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -667,9 +667,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
|
"integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -684,9 +684,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
|
"integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -701,9 +701,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
|
"integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -718,9 +718,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
|
"integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -735,9 +735,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
|
"integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -752,9 +752,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
|
"integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -769,9 +769,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
|
"integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -786,9 +786,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
|
"integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -803,9 +803,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
|
"integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@ -820,9 +820,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
|
"integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -837,9 +837,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
|
"integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -854,9 +854,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
|
||||||
"integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
|
"integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -868,9 +868,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
|
||||||
"integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
|
"integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -882,9 +882,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
|
||||||
"integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
|
"integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -896,9 +896,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
|
||||||
"integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
|
"integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@ -910,9 +910,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
|
"integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -924,9 +924,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
|
||||||
"integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
|
"integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -946,40 +946,6 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scure/bip39": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@noble/hashes": "2.2.0",
|
|
||||||
"@scure/base": "2.2.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.19.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@scure/bip39/node_modules/@scure/base": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@ -988,9 +954,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.20",
|
"version": "22.19.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||||
"integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==",
|
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -1421,13 +1387,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||||
"integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
|
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.9"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
@ -1437,34 +1403,41 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.61.1",
|
"@rollup/rollup-android-arm-eabi": "4.60.4",
|
||||||
"@rollup/rollup-android-arm64": "4.61.1",
|
"@rollup/rollup-android-arm64": "4.60.4",
|
||||||
"@rollup/rollup-darwin-arm64": "4.61.1",
|
"@rollup/rollup-darwin-arm64": "4.60.4",
|
||||||
"@rollup/rollup-darwin-x64": "4.61.1",
|
"@rollup/rollup-darwin-x64": "4.60.4",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.61.1",
|
"@rollup/rollup-freebsd-arm64": "4.60.4",
|
||||||
"@rollup/rollup-freebsd-x64": "4.61.1",
|
"@rollup/rollup-freebsd-x64": "4.60.4",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.61.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.60.4",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.61.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.61.1",
|
"@rollup/rollup-linux-arm64-musl": "4.60.4",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.61.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.61.1",
|
"@rollup/rollup-linux-loong64-musl": "4.60.4",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.61.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.61.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.60.4",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.61.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.61.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.60.4",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.61.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.61.1",
|
"@rollup/rollup-linux-x64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.61.1",
|
"@rollup/rollup-linux-x64-musl": "4.60.4",
|
||||||
"@rollup/rollup-openbsd-x64": "4.61.1",
|
"@rollup/rollup-openbsd-x64": "4.60.4",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.61.1",
|
"@rollup/rollup-openharmony-arm64": "4.60.4",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.61.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.60.4",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.61.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.60.4",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.61.1",
|
"@rollup/rollup-win32-x64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.61.1",
|
"@rollup/rollup-win32-x64-msvc": "4.60.4",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rollup/node_modules/@types/estree": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
@ -1548,9 +1521,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.22.4",
|
"version": "4.22.3",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
|
||||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -2211,7 +2184,6 @@
|
|||||||
"@noble/curves": "^1.6.0",
|
"@noble/curves": "^1.6.0",
|
||||||
"@noble/hashes": "^1.5.0",
|
"@noble/hashes": "^1.5.0",
|
||||||
"@scure/base": "^1.1.9",
|
"@scure/base": "^1.1.9",
|
||||||
"@scure/bip39": "^2.2.0",
|
|
||||||
"canonicalize": "^2.0.0"
|
"canonicalize": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,10 +25,7 @@ import {
|
|||||||
type Signer,
|
type Signer,
|
||||||
type VerificationStatus,
|
type VerificationStatus,
|
||||||
dnsTxtName,
|
dnsTxtName,
|
||||||
ed25519FromMnemonic,
|
|
||||||
eventHash,
|
eventHash,
|
||||||
generateEd25519WithMnemonic,
|
|
||||||
generateMnemonic,
|
|
||||||
newClaimPayload,
|
newClaimPayload,
|
||||||
signClaim,
|
signClaim,
|
||||||
toCompact,
|
toCompact,
|
||||||
@ -50,9 +47,7 @@ function usageAndExit(msg?: string): never {
|
|||||||
"Usage: kez <command> ...",
|
"Usage: kez <command> ...",
|
||||||
"",
|
"",
|
||||||
"Commands:",
|
"Commands:",
|
||||||
" identity new [--key-type nostr|ed25519] [--mnemonic-words 12|24]",
|
" identity new [--key-type nostr|ed25519]",
|
||||||
" identity mnemonic [--words 12|24]",
|
|
||||||
" identity from-mnemonic \"<phrase>\"",
|
|
||||||
" claim create <subject> (--nsec <nsec> | --ed25519-seed <hex>)",
|
" claim create <subject> (--nsec <nsec> | --ed25519-seed <hex>)",
|
||||||
" [--format json|markdown|compact] [--out <path>]",
|
" [--format json|markdown|compact] [--out <path>]",
|
||||||
" claim dns <domain> (--nsec <nsec> | --ed25519-seed <hex>)",
|
" claim dns <domain> (--nsec <nsec> | --ed25519-seed <hex>)",
|
||||||
@ -74,12 +69,6 @@ function usageAndExit(msg?: string): never {
|
|||||||
interface Flags {
|
interface Flags {
|
||||||
nsec?: string;
|
nsec?: string;
|
||||||
ed25519Seed?: string;
|
ed25519Seed?: string;
|
||||||
/** BIP-39 phrase, alternative to --ed25519-seed. */
|
|
||||||
mnemonic?: string;
|
|
||||||
/** "12" or "24" — used by `identity new --mnemonic-words`. */
|
|
||||||
mnemonicWords?: string;
|
|
||||||
/** "12" or "24" — used by `identity mnemonic --words`. */
|
|
||||||
words?: string;
|
|
||||||
keyType?: "nostr" | "ed25519";
|
keyType?: "nostr" | "ed25519";
|
||||||
format?: "json" | "markdown" | "compact" | "jsonl";
|
format?: "json" | "markdown" | "compact" | "jsonl";
|
||||||
out?: string;
|
out?: string;
|
||||||
@ -100,12 +89,6 @@ function parseFlags(args: string[]): Flags {
|
|||||||
out.nsec = args[++i];
|
out.nsec = args[++i];
|
||||||
} else if (a === "--ed25519-seed") {
|
} else if (a === "--ed25519-seed") {
|
||||||
out.ed25519Seed = args[++i];
|
out.ed25519Seed = args[++i];
|
||||||
} else if (a === "--mnemonic") {
|
|
||||||
out.mnemonic = args[++i];
|
|
||||||
} else if (a === "--mnemonic-words") {
|
|
||||||
out.mnemonicWords = args[++i];
|
|
||||||
} else if (a === "--words") {
|
|
||||||
out.words = args[++i];
|
|
||||||
} else if (a === "--key-type") {
|
} else if (a === "--key-type") {
|
||||||
const v = args[++i];
|
const v = args[++i];
|
||||||
if (v !== "nostr" && v !== "ed25519") usageAndExit(`bad --key-type value: ${v}`);
|
if (v !== "nostr" && v !== "ed25519") usageAndExit(`bad --key-type value: ${v}`);
|
||||||
@ -136,9 +119,8 @@ function parseFlags(args: string[]): Flags {
|
|||||||
out.positional.push(a);
|
out.positional.push(a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const keySources = [out.nsec, out.ed25519Seed, out.mnemonic].filter(Boolean).length;
|
if (out.nsec && out.ed25519Seed) {
|
||||||
if (keySources > 1) {
|
usageAndExit("--nsec and --ed25519-seed are mutually exclusive");
|
||||||
usageAndExit("--nsec, --ed25519-seed, and --mnemonic are mutually exclusive");
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@ -161,8 +143,7 @@ function printStatus(status: VerificationStatus): void {
|
|||||||
function loadSigner(args: Flags): Signer {
|
function loadSigner(args: Flags): Signer {
|
||||||
if (args.nsec) return NostrSecret.fromNsec(args.nsec);
|
if (args.nsec) return NostrSecret.fromNsec(args.nsec);
|
||||||
if (args.ed25519Seed) return Ed25519Secret.fromSeedHex(args.ed25519Seed);
|
if (args.ed25519Seed) return Ed25519Secret.fromSeedHex(args.ed25519Seed);
|
||||||
if (args.mnemonic) return ed25519FromMnemonic(args.mnemonic);
|
usageAndExit("missing key: pass --nsec or --ed25519-seed");
|
||||||
usageAndExit("missing key: pass --nsec, --ed25519-seed, or --mnemonic");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildClaim(subjectStr: string, signer: Signer) {
|
function buildClaim(subjectStr: string, signer: Signer) {
|
||||||
@ -174,67 +155,27 @@ function buildClaim(subjectStr: string, signer: Signer) {
|
|||||||
return signClaim(newClaimPayload(subject, primary, new Date()), signer);
|
return signClaim(newClaimPayload(subject, primary, new Date()), signer);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseWordCount(raw: string | undefined, dflt: 12 | 24): 12 | 24 {
|
|
||||||
if (raw === undefined) return dflt;
|
|
||||||
if (raw === "12") return 12;
|
|
||||||
if (raw === "24") return 24;
|
|
||||||
usageAndExit(`word count must be 12 or 24, got ${raw}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function identityNew(args: Flags): void {
|
function identityNew(args: Flags): void {
|
||||||
const keyType = args.keyType ?? "nostr";
|
const keyType = args.keyType ?? "nostr";
|
||||||
if (keyType === "nostr") {
|
if (keyType === "ed25519") {
|
||||||
if (args.mnemonicWords !== undefined) {
|
const s = Ed25519Secret.generate();
|
||||||
usageAndExit("--mnemonic-words is only valid with --key-type ed25519");
|
process.stdout.write(`Primary: ${s.identity()}\n`);
|
||||||
}
|
process.stdout.write(`Public: ${s.pubkeyHex()}\n`);
|
||||||
const s = NostrSecret.generate();
|
process.stdout.write(`Secret: ${s.seedHex()} (32-byte seed)\n`);
|
||||||
process.stdout.write(`Primary: nostr:${s.npub()}\n`);
|
|
||||||
process.stdout.write(`Public: ${s.npub()}\n`);
|
|
||||||
process.stdout.write(`Secret: ${s.nsec()}\n`);
|
|
||||||
process.stdout.write("\n");
|
process.stdout.write("\n");
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity.\n",
|
"Store the secret somewhere safe. Anyone with the seed can sign as this identity.\n",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// ed25519: default 24 words (bijective with the seed), or 12 if asked.
|
const s = NostrSecret.generate();
|
||||||
const words = parseWordCount(args.mnemonicWords, 24);
|
process.stdout.write(`Primary: nostr:${s.npub()}\n`);
|
||||||
const { secret, phrase } = generateEd25519WithMnemonic(words);
|
process.stdout.write(`Public: ${s.npub()}\n`);
|
||||||
process.stdout.write(`Primary: ${secret.identity()}\n`);
|
process.stdout.write(`Secret: ${s.nsec()}\n`);
|
||||||
process.stdout.write(`Public: ${secret.pubkeyHex()}\n`);
|
|
||||||
process.stdout.write(`Secret: ${secret.seedHex()} (32-byte seed)\n`);
|
|
||||||
process.stdout.write(`Mnemonic (${words} words): "${phrase}"\n`);
|
|
||||||
process.stdout.write("\n");
|
process.stdout.write("\n");
|
||||||
if (words === 24) {
|
process.stdout.write(
|
||||||
process.stdout.write(
|
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity.\n",
|
||||||
"The 24-word phrase and the hex seed are equivalent backups —\n" +
|
);
|
||||||
"either restores this identity. Store at least one safely.\n",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
process.stdout.write(
|
|
||||||
"The 12-word phrase is the canonical backup. The hex seed is\n" +
|
|
||||||
"derived from it (one-way) — you can't reconstruct the phrase\n" +
|
|
||||||
"from the seed. Store the phrase safely.\n",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function identityMnemonic(args: Flags): void {
|
|
||||||
const words = parseWordCount(args.words, 24);
|
|
||||||
process.stdout.write(`${generateMnemonic(words)}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function identityFromMnemonic(args: Flags): void {
|
|
||||||
if (args.positional.length !== 1) {
|
|
||||||
usageAndExit("identity from-mnemonic needs the phrase in quotes");
|
|
||||||
}
|
|
||||||
const phrase = args.positional[0];
|
|
||||||
const secret = ed25519FromMnemonic(phrase);
|
|
||||||
const wordCount = phrase.trim().split(/\s+/).length;
|
|
||||||
process.stdout.write(`Primary: ${secret.identity()}\n`);
|
|
||||||
process.stdout.write(`Public: ${secret.pubkeyHex()}\n`);
|
|
||||||
process.stdout.write(`Secret: ${secret.seedHex()} (32-byte seed)\n`);
|
|
||||||
process.stdout.write(`Mnemonic (${wordCount} words): "${phrase.trim()}"\n`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function claimCreate(args: Flags): void {
|
function claimCreate(args: Flags): void {
|
||||||
@ -301,8 +242,6 @@ async function main(): Promise<void> {
|
|||||||
const flags = parseFlags(rest);
|
const flags = parseFlags(rest);
|
||||||
try {
|
try {
|
||||||
if (cmd === "identity" && sub === "new") return identityNew(flags);
|
if (cmd === "identity" && sub === "new") return identityNew(flags);
|
||||||
if (cmd === "identity" && sub === "mnemonic") return identityMnemonic(flags);
|
|
||||||
if (cmd === "identity" && sub === "from-mnemonic") return identityFromMnemonic(flags);
|
|
||||||
if (cmd === "claim" && sub === "create") return claimCreate(flags);
|
if (cmd === "claim" && sub === "create") return claimCreate(flags);
|
||||||
if (cmd === "claim" && sub === "dns") return claimDns(flags);
|
if (cmd === "claim" && sub === "dns") return claimDns(flags);
|
||||||
if (cmd === "verify" && sub === "file") return verifyFile(flags);
|
if (cmd === "verify" && sub === "file") return verifyFile(flags);
|
||||||
|
|||||||
@ -12,7 +12,6 @@
|
|||||||
"@noble/curves": "^1.6.0",
|
"@noble/curves": "^1.6.0",
|
||||||
"@noble/hashes": "^1.5.0",
|
"@noble/hashes": "^1.5.0",
|
||||||
"@scure/base": "^1.1.9",
|
"@scure/base": "^1.1.9",
|
||||||
"@scure/bip39": "^2.2.0",
|
|
||||||
"canonicalize": "^2.0.0"
|
"canonicalize": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,11 +58,3 @@ export {
|
|||||||
parseDnsTxtValue,
|
parseDnsTxtValue,
|
||||||
} from "./encodings.js";
|
} from "./encodings.js";
|
||||||
export { canonicalBytes, canonicalString } from "./jcs.js";
|
export { canonicalBytes, canonicalString } from "./jcs.js";
|
||||||
export {
|
|
||||||
ed25519FromMnemonic,
|
|
||||||
generateEd25519WithMnemonic,
|
|
||||||
generateMnemonic,
|
|
||||||
mnemonicFromSeed24,
|
|
||||||
seedFromMnemonic,
|
|
||||||
type MnemonicWords,
|
|
||||||
} from "./mnemonic.js";
|
|
||||||
|
|||||||
@ -1,100 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
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)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
# 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
113
python/README.md
@ -1,113 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,526 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
"""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"
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import sys
|
|
||||||
|
|
||||||
from .cli import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
"""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))
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,379 +0,0 @@
|
|||||||
"""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())
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
[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"]
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
cryptography>=42
|
|
||||||
mnemonic>=0.20
|
|
||||||
zstandard>=0.22
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
"""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,12 +76,6 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arrayvec"
|
|
||||||
version = "0.7.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assert-json-diff"
|
name = "assert-json-diff"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@ -133,28 +127,6 @@ version = "0.9.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445"
|
checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bip39"
|
|
||||||
version = "2.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
|
|
||||||
dependencies = [
|
|
||||||
"bitcoin_hashes",
|
|
||||||
"rand 0.8.6",
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
"serde",
|
|
||||||
"unicode-normalization",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitcoin_hashes"
|
|
||||||
version = "0.14.100"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f"
|
|
||||||
dependencies = [
|
|
||||||
"hex-conservative",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
@ -737,15 +709,6 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hex-conservative"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
|
|
||||||
dependencies = [
|
|
||||||
"arrayvec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hickory-net"
|
name = "hickory-net"
|
||||||
version = "0.26.1"
|
version = "0.26.1"
|
||||||
@ -1201,7 +1164,6 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dirs",
|
"dirs",
|
||||||
"hex",
|
|
||||||
"kez-channels",
|
"kez-channels",
|
||||||
"kez-core",
|
"kez-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@ -1214,7 +1176,6 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bech32",
|
"bech32",
|
||||||
"bip39",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"hex",
|
"hex",
|
||||||
@ -2294,15 +2255,6 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-normalization"
|
|
||||||
version = "0.1.25"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
|
||||||
dependencies = [
|
|
||||||
"tinyvec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
|
|||||||
@ -16,7 +16,6 @@ anyhow = "1.0"
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
bech32 = "0.9"
|
bech32 = "0.9"
|
||||||
bip39 = { version = "2.0", features = ["rand"] }
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
|
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
|
||||||
|
|||||||
122
rust/TUTORIAL.md
122
rust/TUTORIAL.md
@ -81,128 +81,24 @@ kez identity new --key-type nostr # only if you want a NEW key
|
|||||||
|
|
||||||
### Option B: generate a fresh Ed25519 primary
|
### Option B: generate a fresh Ed25519 primary
|
||||||
|
|
||||||
If you'd rather start clean, generate a new Ed25519 key with a BIP-39
|
If you'd rather start clean, generate a new Ed25519 key:
|
||||||
recovery phrase you can write down on paper:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
kez identity new --key-type ed25519 # 24-word phrase (default)
|
kez identity new --key-type ed25519
|
||||||
kez identity new --key-type ed25519 --mnemonic-words 12 # 12-word phrase
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Output (24-word, the default):
|
Output:
|
||||||
|
|
||||||
```
|
```
|
||||||
Primary: ed25519:7a3b4c…
|
Primary: ed25519:7a3b4c…
|
||||||
Public: 7a3b4c…
|
Public: 7a3b4c… (hex)
|
||||||
Secret: 9e3f51… (32-byte seed)
|
Secret: 9e3f51… (hex — 64 chars, KEEP SECRET)
|
||||||
Mnemonic (24 words): "abandon ability able about above absent academy accident…"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You now have **two equivalent backups** — the hex seed *and* the 24-word
|
> **Save the secret.** It's the only thing that can sign as this
|
||||||
BIP-39 phrase. Either restores the same identity. Most people back up
|
> identity. There's no recovery flow — lose it and the identity is
|
||||||
the phrase (easier to write down, easier to verify by hand).
|
> 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 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
|
For the rest of this tutorial we'll use a nostr key for examples and
|
||||||
write the secret as `nsec1FAKE...` — substitute your real one.
|
write the secret as `nsec1FAKE...` — substitute your real one.
|
||||||
|
|||||||
@ -14,7 +14,6 @@ anyhow.workspace = true
|
|||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
hex.workspace = true
|
|
||||||
kez-channels = { path = "../kez-channels" }
|
kez-channels = { path = "../kez-channels" }
|
||||||
kez-core = { path = "../kez-core" }
|
kez-core = { path = "../kez-core" }
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
|||||||
@ -4,9 +4,8 @@ use clap::{Parser, Subcommand, ValueEnum};
|
|||||||
use kez_channels::nostr as nostr_chan;
|
use kez_channels::nostr as nostr_chan;
|
||||||
use kez_channels::{ChannelHit, Registry, parse_proof};
|
use kez_channels::{ChannelHit, Registry, parse_proof};
|
||||||
use kez_core::{
|
use kez_core::{
|
||||||
ClaimPayload, Ed25519Secret, Identity, MnemonicWords, NostrSecret, SignedClaim, Signer,
|
ClaimPayload, Ed25519Secret, Identity, NostrSecret, SignedClaim, Signer, Sigchain,
|
||||||
Sigchain, VerificationStatus, dns_txt_name, generate_mnemonic, mnemonic_from_seed_24,
|
VerificationStatus, dns_txt_name,
|
||||||
seed_from_mnemonic,
|
|
||||||
};
|
};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@ -48,8 +47,6 @@ enum SigchainCommand {
|
|||||||
nsec: Option<String>,
|
nsec: Option<String>,
|
||||||
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
|
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
|
||||||
ed25519_seed: Option<String>,
|
ed25519_seed: Option<String>,
|
||||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
|
|
||||||
mnemonic: Option<String>,
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
proof_url: Option<String>,
|
proof_url: Option<String>,
|
||||||
},
|
},
|
||||||
@ -60,8 +57,6 @@ enum SigchainCommand {
|
|||||||
nsec: Option<String>,
|
nsec: Option<String>,
|
||||||
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
|
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
|
||||||
ed25519_seed: Option<String>,
|
ed25519_seed: Option<String>,
|
||||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
|
|
||||||
mnemonic: Option<String>,
|
|
||||||
},
|
},
|
||||||
/// Print the chain (events one per line, plus a summary).
|
/// Print the chain (events one per line, plus a summary).
|
||||||
Show {
|
Show {
|
||||||
@ -72,8 +67,6 @@ enum SigchainCommand {
|
|||||||
nsec: Option<String>,
|
nsec: Option<String>,
|
||||||
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
|
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
|
||||||
ed25519_seed: Option<String>,
|
ed25519_seed: Option<String>,
|
||||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
|
|
||||||
mnemonic: Option<String>,
|
|
||||||
},
|
},
|
||||||
/// Export the chain in a portable format.
|
/// Export the chain in a portable format.
|
||||||
Export {
|
Export {
|
||||||
@ -83,8 +76,6 @@ enum SigchainCommand {
|
|||||||
nsec: Option<String>,
|
nsec: Option<String>,
|
||||||
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
|
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
|
||||||
ed25519_seed: Option<String>,
|
ed25519_seed: Option<String>,
|
||||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
|
|
||||||
mnemonic: Option<String>,
|
|
||||||
#[arg(long, value_enum, default_value_t = ExportFormat::Jsonl)]
|
#[arg(long, value_enum, default_value_t = ExportFormat::Jsonl)]
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@ -98,8 +89,6 @@ enum SigchainCommand {
|
|||||||
nsec: Option<String>,
|
nsec: Option<String>,
|
||||||
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
|
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
|
||||||
ed25519_seed: Option<String>,
|
ed25519_seed: Option<String>,
|
||||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
|
|
||||||
mnemonic: Option<String>,
|
|
||||||
/// POST every event to a kez-sig-server at this URL.
|
/// POST every event to a kez-sig-server at this URL.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
server: Option<String>,
|
server: Option<String>,
|
||||||
@ -128,31 +117,9 @@ enum ExportFormat {
|
|||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
enum IdentityCommand {
|
enum IdentityCommand {
|
||||||
/// Generate a new primary key. Defaults to nostr; pass --key-type
|
|
||||||
/// ed25519 for an Ed25519 key. For Ed25519, a 24-word BIP-39 phrase
|
|
||||||
/// is also printed (it's an equivalent representation of the seed).
|
|
||||||
/// Use --mnemonic-words 12 to generate from a 12-word phrase instead.
|
|
||||||
New {
|
New {
|
||||||
#[arg(long, value_enum, default_value_t = KeyType::Nostr)]
|
#[arg(long, value_enum, default_value_t = KeyType::Nostr)]
|
||||||
key_type: KeyType,
|
key_type: KeyType,
|
||||||
/// 12 or 24. Only valid with --key-type ed25519. If unset, a
|
|
||||||
/// 24-word phrase is shown alongside the hex seed for Ed25519.
|
|
||||||
#[arg(long = "mnemonic-words")]
|
|
||||||
mnemonic_words: Option<u8>,
|
|
||||||
},
|
|
||||||
/// Print a fresh BIP-39 mnemonic phrase without deriving a key.
|
|
||||||
/// Useful for offline backup workflows.
|
|
||||||
Mnemonic {
|
|
||||||
/// 12 or 24. Default 24.
|
|
||||||
#[arg(long, default_value_t = 24)]
|
|
||||||
words: u8,
|
|
||||||
},
|
|
||||||
/// Derive and print the Ed25519 primary key from an existing
|
|
||||||
/// BIP-39 phrase (12 or 24 words, auto-detected).
|
|
||||||
FromMnemonic {
|
|
||||||
/// The phrase, quoted. Words separated by spaces. Case- and
|
|
||||||
/// whitespace-tolerant.
|
|
||||||
phrase: String,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,8 +137,6 @@ enum ClaimCommand {
|
|||||||
nsec: Option<String>,
|
nsec: Option<String>,
|
||||||
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
|
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
|
||||||
ed25519_seed: Option<String>,
|
ed25519_seed: Option<String>,
|
||||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
|
|
||||||
mnemonic: Option<String>,
|
|
||||||
#[arg(long, value_enum, default_value_t = OutputFormat::Json)]
|
#[arg(long, value_enum, default_value_t = OutputFormat::Json)]
|
||||||
format: OutputFormat,
|
format: OutputFormat,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@ -183,8 +148,6 @@ enum ClaimCommand {
|
|||||||
nsec: Option<String>,
|
nsec: Option<String>,
|
||||||
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
|
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
|
||||||
ed25519_seed: Option<String>,
|
ed25519_seed: Option<String>,
|
||||||
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
|
|
||||||
mnemonic: Option<String>,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,33 +172,21 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Identity { command } => match command {
|
Command::Identity { command } => match command {
|
||||||
IdentityCommand::New { key_type, mnemonic_words } => {
|
IdentityCommand::New { key_type } => identity_new(key_type),
|
||||||
identity_new(key_type, mnemonic_words)
|
|
||||||
}
|
|
||||||
IdentityCommand::Mnemonic { words } => identity_mnemonic(words),
|
|
||||||
IdentityCommand::FromMnemonic { phrase } => identity_from_mnemonic(&phrase),
|
|
||||||
},
|
},
|
||||||
Command::Claim { command } => match command {
|
Command::Claim { command } => match command {
|
||||||
ClaimCommand::Create {
|
ClaimCommand::Create {
|
||||||
subject,
|
subject,
|
||||||
nsec,
|
nsec,
|
||||||
ed25519_seed,
|
ed25519_seed,
|
||||||
mnemonic,
|
|
||||||
format,
|
format,
|
||||||
out,
|
out,
|
||||||
} => {
|
} => claim_create(subject, nsec, ed25519_seed, format, out),
|
||||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
|
||||||
claim_create(subject, nsec, ed25519_seed, format, out)
|
|
||||||
}
|
|
||||||
ClaimCommand::Dns {
|
ClaimCommand::Dns {
|
||||||
domain,
|
domain,
|
||||||
nsec,
|
nsec,
|
||||||
ed25519_seed,
|
ed25519_seed,
|
||||||
mnemonic,
|
} => claim_dns(domain, nsec, ed25519_seed),
|
||||||
} => {
|
|
||||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
|
||||||
claim_dns(domain, nsec, ed25519_seed)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Command::Verify { command } => match command {
|
Command::Verify { command } => match command {
|
||||||
VerifyCommand::File { path } => verify_file(path),
|
VerifyCommand::File { path } => verify_file(path),
|
||||||
@ -245,90 +196,59 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If the caller passed `--mnemonic <phrase>`, derive the Ed25519 seed
|
|
||||||
/// from it and return as hex. Otherwise return the `--ed25519-seed`
|
|
||||||
/// passthrough unchanged. Clap conflicts_with ensures both can't be
|
|
||||||
/// set at once.
|
|
||||||
fn resolve_seed(
|
|
||||||
ed25519_seed: Option<String>,
|
|
||||||
mnemonic: Option<String>,
|
|
||||||
) -> Result<Option<String>> {
|
|
||||||
match (ed25519_seed, mnemonic) {
|
|
||||||
(Some(s), None) => Ok(Some(s)),
|
|
||||||
(None, Some(phrase)) => {
|
|
||||||
let seed = seed_from_mnemonic(&phrase)
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid mnemonic: {e}"))?;
|
|
||||||
Ok(Some(hex::encode(seed)))
|
|
||||||
}
|
|
||||||
(None, None) => Ok(None),
|
|
||||||
(Some(_), Some(_)) => unreachable!("clap conflicts_with"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sigchain_dispatch(cmd: SigchainCommand) -> Result<()> {
|
async fn sigchain_dispatch(cmd: SigchainCommand) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
SigchainCommand::Add {
|
SigchainCommand::Add {
|
||||||
subject,
|
subject,
|
||||||
nsec,
|
nsec,
|
||||||
ed25519_seed,
|
ed25519_seed,
|
||||||
mnemonic,
|
|
||||||
proof_url,
|
proof_url,
|
||||||
} => {
|
} => sigchain_add(subject, nsec, ed25519_seed, proof_url),
|
||||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
|
||||||
sigchain_add(subject, nsec, ed25519_seed, proof_url)
|
|
||||||
}
|
|
||||||
SigchainCommand::Revoke {
|
SigchainCommand::Revoke {
|
||||||
subject,
|
subject,
|
||||||
nsec,
|
nsec,
|
||||||
ed25519_seed,
|
ed25519_seed,
|
||||||
mnemonic,
|
} => sigchain_revoke(subject, nsec, ed25519_seed),
|
||||||
} => {
|
|
||||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
|
||||||
sigchain_revoke(subject, nsec, ed25519_seed)
|
|
||||||
}
|
|
||||||
SigchainCommand::Show {
|
SigchainCommand::Show {
|
||||||
primary,
|
primary,
|
||||||
nsec,
|
nsec,
|
||||||
ed25519_seed,
|
ed25519_seed,
|
||||||
mnemonic,
|
} => sigchain_show(primary, nsec, ed25519_seed),
|
||||||
} => {
|
|
||||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
|
||||||
sigchain_show(primary, nsec, ed25519_seed)
|
|
||||||
}
|
|
||||||
SigchainCommand::Export {
|
SigchainCommand::Export {
|
||||||
primary,
|
primary,
|
||||||
nsec,
|
nsec,
|
||||||
ed25519_seed,
|
ed25519_seed,
|
||||||
mnemonic,
|
|
||||||
format,
|
format,
|
||||||
out,
|
out,
|
||||||
} => {
|
} => sigchain_export(primary, nsec, ed25519_seed, format, out),
|
||||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
|
||||||
sigchain_export(primary, nsec, ed25519_seed, format, out)
|
|
||||||
}
|
|
||||||
SigchainCommand::Publish {
|
SigchainCommand::Publish {
|
||||||
primary,
|
primary,
|
||||||
nsec,
|
nsec,
|
||||||
ed25519_seed,
|
ed25519_seed,
|
||||||
mnemonic,
|
|
||||||
server,
|
server,
|
||||||
web,
|
web,
|
||||||
out,
|
out,
|
||||||
dns,
|
dns,
|
||||||
nostr,
|
nostr,
|
||||||
} => {
|
} => {
|
||||||
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
|
sigchain_publish(
|
||||||
sigchain_publish(primary, nsec, ed25519_seed, server, web, out, dns, nostr).await
|
primary,
|
||||||
|
nsec,
|
||||||
|
ed25519_seed,
|
||||||
|
server,
|
||||||
|
web,
|
||||||
|
out,
|
||||||
|
dns,
|
||||||
|
nostr,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn identity_new(key_type: KeyType, mnemonic_words: Option<u8>) -> Result<()> {
|
fn identity_new(key_type: KeyType) -> Result<()> {
|
||||||
match (key_type, mnemonic_words) {
|
match key_type {
|
||||||
(KeyType::Nostr, Some(_)) => {
|
KeyType::Nostr => {
|
||||||
bail!("--mnemonic-words is only valid with --key-type ed25519");
|
|
||||||
}
|
|
||||||
(KeyType::Nostr, None) => {
|
|
||||||
let secret = NostrSecret::generate();
|
let secret = NostrSecret::generate();
|
||||||
println!("Primary: nostr:{}", secret.npub());
|
println!("Primary: nostr:{}", secret.npub());
|
||||||
println!("Public: {}", secret.npub());
|
println!("Public: {}", secret.npub());
|
||||||
@ -338,58 +258,16 @@ fn identity_new(key_type: KeyType, mnemonic_words: Option<u8>) -> Result<()> {
|
|||||||
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity."
|
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
(KeyType::Ed25519, words_opt) => {
|
KeyType::Ed25519 => {
|
||||||
// Default is 24 — the canonical bijective form (entropy IS seed).
|
let secret = Ed25519Secret::generate();
|
||||||
let words = MnemonicWords::from_count(words_opt.unwrap_or(24) as usize)
|
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
||||||
let (secret, phrase) = Ed25519Secret::generate_with_mnemonic(words)
|
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
||||||
let id = secret.identity()?;
|
let id = secret.identity()?;
|
||||||
println!("Primary: {id}");
|
println!("Primary: {id}");
|
||||||
println!("Public: {}", secret.pubkey_hex());
|
println!("Public: {}", secret.pubkey_hex());
|
||||||
println!("Secret: {} (32-byte seed)", secret.seed_hex());
|
println!("Secret: {} (32-byte seed)", secret.seed_hex());
|
||||||
println!("Mnemonic ({} words): \"{}\"", words.count(), phrase);
|
|
||||||
println!();
|
println!();
|
||||||
match words {
|
println!(
|
||||||
MnemonicWords::TwentyFour => println!(
|
"Store the secret somewhere safe. Anyone with the seed can sign as this identity."
|
||||||
"The 24-word phrase and the hex seed are equivalent backups —\n\
|
);
|
||||||
either restores this identity. Store at least one safely."
|
|
||||||
),
|
|
||||||
MnemonicWords::Twelve => println!(
|
|
||||||
"The 12-word phrase is the canonical backup. The hex seed is\n\
|
|
||||||
derived from it (one-way) — you can't reconstruct the phrase\n\
|
|
||||||
from the seed. Store the phrase safely."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn identity_mnemonic(words: u8) -> Result<()> {
|
|
||||||
let w = MnemonicWords::from_count(words as usize).map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
||||||
let phrase = generate_mnemonic(w).map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
||||||
println!("{phrase}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn identity_from_mnemonic(phrase: &str) -> Result<()> {
|
|
||||||
let secret = Ed25519Secret::from_mnemonic(phrase).map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
||||||
let id = secret.identity()?;
|
|
||||||
let word_count = phrase.split_whitespace().count();
|
|
||||||
println!("Primary: {id}");
|
|
||||||
println!("Public: {}", secret.pubkey_hex());
|
|
||||||
println!("Secret: {} (32-byte seed)", secret.seed_hex());
|
|
||||||
println!("Mnemonic ({} words): \"{}\"", word_count, phrase.trim());
|
|
||||||
if word_count == 24 {
|
|
||||||
// For 24-word, verify it round-trips so the user knows it's canonical.
|
|
||||||
let mut seed_bytes = [0u8; 32];
|
|
||||||
seed_bytes.copy_from_slice(&hex::decode(secret.seed_hex())?);
|
|
||||||
let derived = mnemonic_from_seed_24(&seed_bytes).map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
||||||
if derived.trim() != phrase.trim() {
|
|
||||||
// Words were correct (parse succeeded) but their reordering differs
|
|
||||||
// — shouldn't happen, but worth flagging if it ever does.
|
|
||||||
println!("(note: canonical form is \"{}\")", derived);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -6,7 +6,6 @@ edition.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
bech32.workspace = true
|
bech32.workspace = true
|
||||||
bip39.workspace = true
|
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
ed25519-dalek.workspace = true
|
ed25519-dalek.workspace = true
|
||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
|
|||||||
@ -15,11 +15,6 @@ use sha2::{Digest, Sha256};
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
pub mod mnemonic;
|
|
||||||
pub use mnemonic::{
|
|
||||||
MnemonicWords, generate_mnemonic, mnemonic_from_seed_24, seed_from_mnemonic,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const CLAIM_TYPE: &str = "kez.claim";
|
pub const CLAIM_TYPE: &str = "kez.claim";
|
||||||
pub const SIGCHAIN_EVENT_TYPE: &str = "kez.sigchain.event";
|
pub const SIGCHAIN_EVENT_TYPE: &str = "kez.sigchain.event";
|
||||||
pub const FORMAT_VERSION: u8 = 1;
|
pub const FORMAT_VERSION: u8 = 1;
|
||||||
|
|||||||
@ -1,237 +0,0 @@
|
|||||||
//! 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