Completes the three-way BIP-39 mnemonic surface (Rust + Node landed in
0058d9b) and pins down byte-for-byte agreement with crosstest scenarios.
Python (mirrors rust/crates/kez-core/src/mnemonic.rs + nodejs's mnemonic.ts):
• python/kez/mnemonic.py — generate_mnemonic, seed_from_mnemonic,
mnemonic_from_seed_24, ed25519_from_mnemonic,
generate_ed25519_with_mnemonic. Same 24-word-bijection / 12-word-
SHA-256-domain-tagged semantics. Uses Trezor's `mnemonic` library
(v0.21) for the BIP-39 wordlist + entropy parsing; deliberately does
NOT use BIP-39's PBKDF2 to_seed function.
• python/kez/keys.py — Ed25519Secret.from_mnemonic() +
generate_with_mnemonic() classmethods; signer_from_flags widened to
accept --mnemonic.
• python/kez/cli.py — identity new --mnemonic-words, identity
mnemonic [--words], identity from-mnemonic; --mnemonic flag on
claim create/dns and sigchain add/revoke/show/export. Output format
matches Rust + Node verbatim so the crosstest harness can grep
Primary/Public/Secret/Mnemonic lines.
• python/tests/test_mnemonic.py — 19 tests covering all three
canonical vectors (exact-match Secret + Public hex), round-trip,
determinism, whitespace tolerance, bad-checksum, bad-word-count,
the literal domain-tag bytes, and the 12-vs-24 entropy-overlap
non-collision case.
Note: --mnemonic is NOT added to `sigchain publish` because that
subcommand doesn't exist in the Python CLI yet (rust + node only). When
the publish surface is ported, --mnemonic should follow it the same way.
Ground truth — python/MNEMONIC-TEST-VECTORS.md:
V1: 24-word zero-entropy phrase ("abandon… art")
seed = 0000…0000
pubkey = 3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
V2: 12-word zero-entropy phrase ("abandon… about")
seed = 09451c0f06588db78205e32a793536e15ae263c8f9ee6d14f5c6fd82b8bd20da
pubkey = 9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70
V3: 12-word "legal winner thank year wave sausage worth useful legal winner thank yellow"
seed = 9df434a2bd5dc767ee949d8ab95ca09c4ebbb88cefc3d0b1523f6b2a744ca824
pubkey = cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03
The domain tag for the 12-word derivation is exactly the 15 ASCII
bytes of "kez-bip39-12-v1", documented in the spec doc.
crosstest.sh — new "BIP-39 mnemonic interop" section:
• Vector match: each impl × each vector × Public hex == expected (9
scenarios). Catches any silent derivation drift.
• Cross-impl claim signing via --mnemonic: every signer ↔ verifier
pair (rust↔node, rust↔py, node↔py), every format (json/compact/
markdown). 6 pairings × 3 formats = 18 scenarios.
• Bijection sanity: the 24-word phrase printed by `identity from-
mnemonic` round-trips to itself byte-for-byte (rust + node).
• Python-involving scenarios auto-skip if `python/.venv/bin/python
kez_cli.py identity from-mnemonic` returns non-zero, so the harness
stays runnable on machines where Python isn't set up.
Verified end-to-end: `bash crosstest.sh` reports
"All 84 scenarios passed."
Test totals across implementations:
Rust: 114 (9 mnemonic-specific in kez-core)
Node: 99 (8 mnemonic-specific in @kez/core)
Python: 19 (mnemonic only; was no test suite before)
Crosstest: 84 scenarios end-to-end
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
541 lines
26 KiB
Bash
Executable File
541 lines
26 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Cross-implementation interop tests for KEZ.
|
|
#
|
|
# Generates artifacts with one implementation and verifies them with the
|
|
# other, in every direction. Proves the JCS canonicalization, the signature
|
|
# format, and all four wire encodings are byte-compatible.
|
|
#
|
|
# Usage:
|
|
# ./crosstest.sh # run every scenario
|
|
# ./crosstest.sh -v # verbose (echo every command + intermediate output)
|
|
#
|
|
# Exits 0 iff every scenario passes.
|
|
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
|
RUST_CLI=(cargo run --quiet --manifest-path "$ROOT/rust/Cargo.toml" -p kez-cli --)
|
|
# Use the absolute path to the installed tsx loader so the Node CLI works
|
|
# regardless of the caller's cwd.
|
|
TSX_LOADER="$ROOT/nodejs/node_modules/tsx/dist/esm/index.mjs"
|
|
if [[ ! -f "$TSX_LOADER" ]]; then
|
|
printf "tsx loader not found at %s — run 'cd nodejs && npm install' first\n" "$TSX_LOADER" >&2
|
|
exit 1
|
|
fi
|
|
NODE_CLI=(node --import "$TSX_LOADER" "$ROOT/nodejs/packages/kez-cli/src/cli.ts")
|
|
# Python CLI runs inside its own virtualenv so its native deps (cryptography,
|
|
# zstandard) are isolated from the system interpreter.
|
|
PYTHON_VENV="$ROOT/python/.venv/bin/python"
|
|
if [[ ! -x "$PYTHON_VENV" ]]; then
|
|
printf "Python venv not found at %s — run 'cd python && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt' first\n" "$PYTHON_VENV" >&2
|
|
exit 1
|
|
fi
|
|
PYTHON_CLI=("$PYTHON_VENV" "$ROOT/python/kez_cli.py")
|
|
|
|
VERBOSE=0
|
|
[[ "${1:-}" == "-v" ]] && VERBOSE=1
|
|
|
|
TMP="$(mktemp -d)"
|
|
trap 'rm -rf "$TMP"' EXIT
|
|
|
|
PASS=0
|
|
FAIL=0
|
|
RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'; DIM=$'\e[2m'; RESET=$'\e[0m'
|
|
|
|
scenario() {
|
|
local title="$1"
|
|
printf " %-60s " "$title"
|
|
}
|
|
|
|
ok() { printf "%spass%s\n" "$GREEN" "$RESET"; PASS=$((PASS+1)); }
|
|
bad() {
|
|
printf "%sFAIL%s\n" "$RED" "$RESET"
|
|
FAIL=$((FAIL+1))
|
|
shift
|
|
if [[ $# -gt 0 ]]; then
|
|
printf " %s\n" "$@" >&2
|
|
fi
|
|
}
|
|
|
|
vlog() { [[ $VERBOSE -eq 1 ]] && printf "%s %s%s\n" "$DIM" "$*" "$RESET" >&2 || true; }
|
|
|
|
extract_nsec() {
|
|
awk -F': *' '/^Secret:/ {print $2; exit}' "$1"
|
|
}
|
|
|
|
# Pull the 32-byte hex seed out of `identity new --key-type ed25519` output.
|
|
# That output is "Secret: <hex> (32-byte seed)".
|
|
extract_ed25519_seed() {
|
|
awk -F': *' '/^Secret:/ { sub(/ \(.*$/, "", $2); print $2; exit }' "$1"
|
|
}
|
|
|
|
assert_verify_valid() {
|
|
local label="$1"
|
|
local file="$2"
|
|
if grep -q '^Status: valid' "$file"; then
|
|
return 0
|
|
else
|
|
cat "$file" >&2
|
|
bad "$label" "verifier did not report Status: valid"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Dispatch to one of the three implementations by name. Keeps the Python
|
|
# interop scenarios below readable without juggling array variables.
|
|
run_cli() {
|
|
local which="$1"; shift
|
|
case "$which" in
|
|
rust) "${RUST_CLI[@]}" "$@" ;;
|
|
node) "${NODE_CLI[@]}" "$@" ;;
|
|
py) "${PYTHON_CLI[@]}" "$@" ;;
|
|
*) printf "unknown impl: %s\n" "$which" >&2; return 2 ;;
|
|
esac
|
|
}
|
|
|
|
# Sign a claim with one impl and verify it with another, for a given wire
|
|
# format. Remaining args are the signing-key flags (--nsec / --ed25519-seed).
|
|
claim_roundtrip() {
|
|
local title="$1" signer="$2" verifier="$3" fmt="$4"; shift 4
|
|
scenario "$title"
|
|
case "$fmt" in
|
|
json)
|
|
run_cli "$signer" claim create github:jason "$@" > "$TMP/rt.proof" 2>"$TMP/rt.err" ;;
|
|
markdown)
|
|
run_cli "$signer" claim create github:jason "$@" --format markdown --out "$TMP/rt.proof" 2>"$TMP/rt.err" ;;
|
|
*)
|
|
run_cli "$signer" claim create github:jason "$@" --format "$fmt" > "$TMP/rt.proof" 2>"$TMP/rt.err" ;;
|
|
esac
|
|
run_cli "$verifier" verify file "$TMP/rt.proof" > "$TMP/rt.out" 2>&1
|
|
assert_verify_valid "$title" "$TMP/rt.out" && ok
|
|
}
|
|
|
|
# Pre-flight: build Rust release once (much faster reruns).
|
|
printf "%sBuilding Rust impl…%s\n" "$YELLOW" "$RESET"
|
|
cargo build --quiet --manifest-path "$ROOT/rust/Cargo.toml" -p kez-cli
|
|
|
|
printf "%sCross-implementation interop:%s\n" "$YELLOW" "$RESET"
|
|
|
|
# Generate one shared identity (Node — Rust accepts the same nsec).
|
|
"${NODE_CLI[@]}" identity new > "$TMP/identity.txt"
|
|
NSEC="$(extract_nsec "$TMP/identity.txt")"
|
|
vlog "shared nsec: $NSEC"
|
|
|
|
# ── Scenario 1: Rust signs JSON → Node verifies ─────────────────────────────
|
|
scenario "rust signs JSON ⇒ node verify file"
|
|
"${RUST_CLI[@]}" claim create github:jason --nsec "$NSEC" > "$TMP/a.json"
|
|
"${NODE_CLI[@]}" verify file "$TMP/a.json" > "$TMP/a.out" 2>&1
|
|
assert_verify_valid "rust→node JSON" "$TMP/a.out" && ok
|
|
|
|
# ── Scenario 2: Node signs JSON → Rust verifies ─────────────────────────────
|
|
scenario "node signs JSON ⇒ rust verify file"
|
|
"${NODE_CLI[@]}" claim create github:jason --nsec "$NSEC" > "$TMP/b.json"
|
|
"${RUST_CLI[@]}" verify file "$TMP/b.json" > "$TMP/b.out" 2>&1
|
|
assert_verify_valid "node→rust JSON" "$TMP/b.out" && ok
|
|
|
|
# ── Scenario 3: Rust signs compact → Node verifies ──────────────────────────
|
|
scenario "rust signs compact ⇒ node verify file"
|
|
"${RUST_CLI[@]}" claim create github:jason --nsec "$NSEC" --format compact > "$TMP/c.kez"
|
|
"${NODE_CLI[@]}" verify file "$TMP/c.kez" > "$TMP/c.out" 2>&1
|
|
assert_verify_valid "rust→node compact" "$TMP/c.out" && ok
|
|
|
|
# ── Scenario 4: Node signs compact → Rust verifies ──────────────────────────
|
|
scenario "node signs compact ⇒ rust verify file"
|
|
"${NODE_CLI[@]}" claim create github:jason --nsec "$NSEC" --format compact > "$TMP/d.kez"
|
|
"${RUST_CLI[@]}" verify file "$TMP/d.kez" > "$TMP/d.out" 2>&1
|
|
assert_verify_valid "node→rust compact" "$TMP/d.out" && ok
|
|
|
|
# ── Scenario 5: Rust signs markdown → Node verifies ─────────────────────────
|
|
scenario "rust signs markdown ⇒ node verify file"
|
|
"${RUST_CLI[@]}" claim create github:jason --nsec "$NSEC" --format markdown \
|
|
--out "$TMP/e.kez.md"
|
|
"${NODE_CLI[@]}" verify file "$TMP/e.kez.md" > "$TMP/e.out" 2>&1
|
|
assert_verify_valid "rust→node markdown" "$TMP/e.out" && ok
|
|
|
|
# ── Scenario 6: Node signs markdown → Rust verifies ─────────────────────────
|
|
scenario "node signs markdown ⇒ rust verify file"
|
|
"${NODE_CLI[@]}" claim create github:jason --nsec "$NSEC" --format markdown \
|
|
--out "$TMP/f.kez.md"
|
|
"${RUST_CLI[@]}" verify file "$TMP/f.kez.md" > "$TMP/f.out" 2>&1
|
|
assert_verify_valid "node→rust markdown" "$TMP/f.out" && ok
|
|
|
|
# ── Scenario 7: Rust signs DNS-shape proof → Node verifies compact body ─────
|
|
scenario "rust DNS zone form ⇒ node verify file"
|
|
"${RUST_CLI[@]}" claim dns jason.example.com --nsec "$NSEC" > "$TMP/g.dns"
|
|
# Extract just the compact token from "Value: kez:z1:..." line.
|
|
awk '/^Value:/ {print $2}' "$TMP/g.dns" > "$TMP/g.compact"
|
|
"${NODE_CLI[@]}" verify file "$TMP/g.compact" > "$TMP/g.out" 2>&1
|
|
assert_verify_valid "rust DNS→node compact" "$TMP/g.out" && ok
|
|
|
|
# ── Scenario 8: Node DNS compact → Rust verifies ────────────────────────────
|
|
scenario "node DNS zone form ⇒ rust verify file"
|
|
"${NODE_CLI[@]}" claim dns jason.example.com --nsec "$NSEC" > "$TMP/h.dns"
|
|
awk '/^Value:/ {print $2}' "$TMP/h.dns" > "$TMP/h.compact"
|
|
"${RUST_CLI[@]}" verify file "$TMP/h.compact" > "$TMP/h.out" 2>&1
|
|
assert_verify_valid "node DNS→rust compact" "$TMP/h.out" && ok
|
|
|
|
# ── Ed25519 interop ─────────────────────────────────────────────────────────
|
|
# Generate one shared ed25519 seed (Node — Rust accepts the same hex).
|
|
"${NODE_CLI[@]}" identity new --key-type ed25519 > "$TMP/ed_identity.txt"
|
|
SEED="$(extract_ed25519_seed "$TMP/ed_identity.txt")"
|
|
vlog "shared ed25519 seed: $SEED"
|
|
|
|
# ── Scenario 9: Rust signs ed25519 JSON → Node verifies ─────────────────────
|
|
scenario "rust ed25519 JSON ⇒ node verify file"
|
|
"${RUST_CLI[@]}" claim create github:jason --ed25519-seed "$SEED" > "$TMP/i.json"
|
|
"${NODE_CLI[@]}" verify file "$TMP/i.json" > "$TMP/i.out" 2>&1
|
|
assert_verify_valid "rust→node ed25519 JSON" "$TMP/i.out" && ok
|
|
|
|
# ── Scenario 10: Node signs ed25519 JSON → Rust verifies ────────────────────
|
|
scenario "node ed25519 JSON ⇒ rust verify file"
|
|
"${NODE_CLI[@]}" claim create github:jason --ed25519-seed "$SEED" > "$TMP/j.json"
|
|
"${RUST_CLI[@]}" verify file "$TMP/j.json" > "$TMP/j.out" 2>&1
|
|
assert_verify_valid "node→rust ed25519 JSON" "$TMP/j.out" && ok
|
|
|
|
# ── Scenario 11: Rust signs ed25519 compact → Node verifies ─────────────────
|
|
scenario "rust ed25519 compact ⇒ node verify file"
|
|
"${RUST_CLI[@]}" claim create github:jason --ed25519-seed "$SEED" --format compact > "$TMP/k.kez"
|
|
"${NODE_CLI[@]}" verify file "$TMP/k.kez" > "$TMP/k.out" 2>&1
|
|
assert_verify_valid "rust→node ed25519 compact" "$TMP/k.out" && ok
|
|
|
|
# ── Scenario 12: Node signs ed25519 compact → Rust verifies ─────────────────
|
|
scenario "node ed25519 compact ⇒ rust verify file"
|
|
"${NODE_CLI[@]}" claim create github:jason --ed25519-seed "$SEED" --format compact > "$TMP/l.kez"
|
|
"${RUST_CLI[@]}" verify file "$TMP/l.kez" > "$TMP/l.out" 2>&1
|
|
assert_verify_valid "node→rust ed25519 compact" "$TMP/l.out" && ok
|
|
|
|
# ── Scenario 13: Rust signs ed25519 markdown → Node verifies ────────────────
|
|
scenario "rust ed25519 markdown ⇒ node verify file"
|
|
"${RUST_CLI[@]}" claim create github:jason --ed25519-seed "$SEED" --format markdown \
|
|
--out "$TMP/m.kez.md"
|
|
"${NODE_CLI[@]}" verify file "$TMP/m.kez.md" > "$TMP/m.out" 2>&1
|
|
assert_verify_valid "rust→node ed25519 markdown" "$TMP/m.out" && ok
|
|
|
|
# ── Scenario 14: Node signs ed25519 markdown → Rust verifies ────────────────
|
|
scenario "node ed25519 markdown ⇒ rust verify file"
|
|
"${NODE_CLI[@]}" claim create github:jason --ed25519-seed "$SEED" --format markdown \
|
|
--out "$TMP/n.kez.md"
|
|
"${RUST_CLI[@]}" verify file "$TMP/n.kez.md" > "$TMP/n.out" 2>&1
|
|
assert_verify_valid "node→rust ed25519 markdown" "$TMP/n.out" && ok
|
|
|
|
# ── Python interop (claims) ─────────────────────────────────────────────────
|
|
# The Python implementation must round-trip with BOTH peers in BOTH directions,
|
|
# across every wire encoding and both key types — proving its JCS bytes,
|
|
# signatures (pure-Python BIP-340 Schnorr + ed25519) and zstd compact framing
|
|
# are all byte-compatible with Rust and Node.
|
|
printf "%sPython interop (claims):%s\n" "$YELLOW" "$RESET"
|
|
for peer in node rust; do
|
|
for fmt in json compact markdown; do
|
|
for kt in nostr ed25519; do
|
|
if [[ "$kt" == nostr ]]; then key=(--nsec "$NSEC"); else key=(--ed25519-seed "$SEED"); fi
|
|
claim_roundtrip "py $kt $fmt ⇒ $peer verify" py "$peer" "$fmt" "${key[@]}"
|
|
claim_roundtrip "$peer $kt $fmt ⇒ py verify" "$peer" py "$fmt" "${key[@]}"
|
|
done
|
|
done
|
|
done
|
|
|
|
# Python DNS zone form → both peers verify the extracted compact token.
|
|
for peer in node rust; do
|
|
scenario "py DNS zone form ⇒ $peer verify file"
|
|
"${PYTHON_CLI[@]}" claim dns jason.example.com --nsec "$NSEC" > "$TMP/pd.dns"
|
|
awk '/^Value:/ {print $2}' "$TMP/pd.dns" > "$TMP/pd.compact"
|
|
run_cli "$peer" verify file "$TMP/pd.compact" > "$TMP/pd.out" 2>&1
|
|
assert_verify_valid "py DNS→$peer compact" "$TMP/pd.out" && ok
|
|
done
|
|
|
|
# Each peer's DNS compact token → Python verifies.
|
|
for peer in node rust; do
|
|
scenario "$peer DNS zone form ⇒ py verify file"
|
|
run_cli "$peer" claim dns jason.example.com --nsec "$NSEC" > "$TMP/xd.dns"
|
|
awk '/^Value:/ {print $2}' "$TMP/xd.dns" > "$TMP/xd.compact"
|
|
"${PYTHON_CLI[@]}" verify file "$TMP/xd.compact" > "$TMP/xd.out" 2>&1
|
|
assert_verify_valid "$peer DNS→py compact" "$TMP/xd.out" && ok
|
|
done
|
|
|
|
# ── Sigchain interop ────────────────────────────────────────────────────────
|
|
# Sigchain state lives in ~/.kez/sigchains/<safe-primary>.jsonl. Both CLIs
|
|
# read/write the same paths, so we can build a chain in one and inspect it
|
|
# from the other. We isolate per scenario by using fresh keys so the state
|
|
# files don't collide with anything the user already has.
|
|
|
|
CHAIN_DIR="$HOME/.kez/sigchains"
|
|
|
|
# Helper: derive the JSONL path for a primary identifier "scheme:value".
|
|
chain_file_for() {
|
|
local primary="$1"
|
|
echo "$CHAIN_DIR/${primary/:/_}.jsonl"
|
|
}
|
|
|
|
# Scenarios use two separate keys (nostr + ed25519) so chains don't overlap
|
|
# with anything else.
|
|
"${RUST_CLI[@]}" identity new > "$TMP/sc_nostr_identity.txt"
|
|
SC_NSEC="$(extract_nsec "$TMP/sc_nostr_identity.txt")"
|
|
"${RUST_CLI[@]}" identity new --key-type ed25519 > "$TMP/sc_ed_identity.txt"
|
|
SC_SEED="$(extract_ed25519_seed "$TMP/sc_ed_identity.txt")"
|
|
|
|
# Derive the chain primaries so we can locate the JSONL files. We can't use
|
|
# `awk -F': *'` here because the primary itself contains `:` (`nostr:npub...`,
|
|
# `ed25519:hex...`) — that'd truncate the value.
|
|
SC_NOSTR_PRIMARY=$(sed -n 's/^Primary:[[:space:]]*//p' "$TMP/sc_nostr_identity.txt" | head -1)
|
|
SC_ED_PRIMARY=$(sed -n 's/^Primary:[[:space:]]*//p' "$TMP/sc_ed_identity.txt" | head -1)
|
|
SC_NOSTR_FILE="$(chain_file_for "$SC_NOSTR_PRIMARY")"
|
|
SC_ED_FILE="$(chain_file_for "$SC_ED_PRIMARY")"
|
|
|
|
# Clean any pre-existing state from these primaries.
|
|
rm -f "$SC_NOSTR_FILE" "$SC_ED_FILE"
|
|
|
|
# ── Scenario 15: Rust builds chain (nostr) → Node parses + validates ────────
|
|
scenario "rust nostr chain ⇒ node sigchain show"
|
|
"${RUST_CLI[@]}" sigchain add github:jason --nsec "$SC_NSEC" > /dev/null
|
|
"${RUST_CLI[@]}" sigchain add dns:jason.example --nsec "$SC_NSEC" > /dev/null
|
|
"${RUST_CLI[@]}" sigchain revoke github:jason --nsec "$SC_NSEC" > /dev/null
|
|
"${NODE_CLI[@]}" sigchain show --primary "$SC_NOSTR_PRIMARY" > "$TMP/sc_a.out" 2>&1
|
|
if grep -q "Length: 3 event(s)" "$TMP/sc_a.out" \
|
|
&& grep -q "op=revoke subject=github:jason" "$TMP/sc_a.out"; then
|
|
ok
|
|
else
|
|
bad "rust→node sigchain show" "see $TMP/sc_a.out for details"
|
|
cat "$TMP/sc_a.out" >&2
|
|
fi
|
|
|
|
# ── Scenario 16: Rust JSONL export round-trips through Node JSONL export ────
|
|
# Easiest way to prove byte-level interop given the current CLI: have both
|
|
# implementations export the *same* on-disk chain and compare the JSONL.
|
|
# The chain was just built by Rust; now ask Node to export from the same
|
|
# state file and assert the byte contents match.
|
|
scenario "rust JSONL == node JSONL for same chain"
|
|
"${RUST_CLI[@]}" sigchain export --nsec "$SC_NSEC" --format jsonl > "$TMP/sc_b_rust.jsonl"
|
|
"${NODE_CLI[@]}" sigchain export --nsec "$SC_NSEC" --format jsonl > "$TMP/sc_b_node.jsonl"
|
|
if diff -q "$TMP/sc_b_rust.jsonl" "$TMP/sc_b_node.jsonl" > /dev/null; then
|
|
ok
|
|
else
|
|
bad "JSONL byte parity" "rust and node disagree on the exported bytes"
|
|
diff "$TMP/sc_b_rust.jsonl" "$TMP/sc_b_node.jsonl" | head -20 >&2
|
|
fi
|
|
|
|
# Reset state, swap directions.
|
|
rm -f "$SC_NOSTR_FILE"
|
|
|
|
# ── Scenario 17: Node builds chain → Rust shows + validates ─────────────────
|
|
scenario "node nostr chain ⇒ rust sigchain show"
|
|
"${NODE_CLI[@]}" sigchain add github:jason --nsec "$SC_NSEC" > /dev/null
|
|
"${NODE_CLI[@]}" sigchain add dns:jason.example --nsec "$SC_NSEC" > /dev/null
|
|
"${NODE_CLI[@]}" sigchain revoke github:jason --nsec "$SC_NSEC" > /dev/null
|
|
"${RUST_CLI[@]}" sigchain show --primary "$SC_NOSTR_PRIMARY" > "$TMP/sc_c.out" 2>&1
|
|
if grep -q "Length: 3 event(s)" "$TMP/sc_c.out" \
|
|
&& grep -q "op=revoke subject=github:jason" "$TMP/sc_c.out"; then
|
|
ok
|
|
else
|
|
bad "node→rust sigchain show" "rust did not see all 3 events"
|
|
cat "$TMP/sc_c.out" >&2
|
|
fi
|
|
|
|
# (A "compact bundle Rust↔Node round-trip" scenario would go here, but
|
|
# neither CLI has a `sigchain import` command yet. Both impls' unit tests
|
|
# cover the local round-trip; we'll add a cross-impl version once import
|
|
# lands in the CLI.)
|
|
|
|
# Reset state.
|
|
rm -f "$SC_NOSTR_FILE"
|
|
|
|
# ── Scenario 19: Rust ed25519 chain → Node validates ────────────────────────
|
|
scenario "rust ed25519 chain ⇒ node sigchain show"
|
|
"${RUST_CLI[@]}" sigchain add github:jason --ed25519-seed "$SC_SEED" > /dev/null
|
|
"${RUST_CLI[@]}" sigchain add dns:jason.example --ed25519-seed "$SC_SEED" > /dev/null
|
|
"${NODE_CLI[@]}" sigchain show --primary "$SC_ED_PRIMARY" > "$TMP/sc_e.out" 2>&1
|
|
if grep -q "Length: 2 event(s)" "$TMP/sc_e.out"; then ok; else
|
|
bad "rust→node ed25519 chain" "node did not see all events"
|
|
cat "$TMP/sc_e.out" >&2
|
|
fi
|
|
rm -f "$SC_ED_FILE"
|
|
|
|
# ── Scenario 20: Node ed25519 chain → Rust validates ────────────────────────
|
|
scenario "node ed25519 chain ⇒ rust sigchain show"
|
|
"${NODE_CLI[@]}" sigchain add github:jason --ed25519-seed "$SC_SEED" > /dev/null
|
|
"${NODE_CLI[@]}" sigchain add dns:jason.example --ed25519-seed "$SC_SEED" > /dev/null
|
|
"${RUST_CLI[@]}" sigchain show --primary "$SC_ED_PRIMARY" > "$TMP/sc_f.out" 2>&1
|
|
if grep -q "Length: 2 event(s)" "$TMP/sc_f.out"; then ok; else
|
|
bad "node→rust ed25519 chain" "rust did not see all events"
|
|
cat "$TMP/sc_f.out" >&2
|
|
fi
|
|
rm -f "$SC_ED_FILE"
|
|
|
|
# ── Python sigchain interop ─────────────────────────────────────────────────
|
|
# Build chains with Python and inspect them from each peer (and vice versa),
|
|
# over the same ~/.kez/sigchains state files. Uses fresh keys to avoid
|
|
# colliding with the scenarios above.
|
|
printf "%sPython interop (sigchains):%s\n" "$YELLOW" "$RESET"
|
|
|
|
PY_NOSTR_OUT="$("${PYTHON_CLI[@]}" identity new)"
|
|
PY_NSEC="$(printf '%s\n' "$PY_NOSTR_OUT" | extract_nsec /dev/stdin)"
|
|
PY_NOSTR_PRIMARY="$(printf '%s\n' "$PY_NOSTR_OUT" | sed -n 's/^Primary:[[:space:]]*//p' | head -1)"
|
|
PY_NOSTR_FILE="$(chain_file_for "$PY_NOSTR_PRIMARY")"
|
|
|
|
PY_ED_OUT="$("${PYTHON_CLI[@]}" identity new --key-type ed25519)"
|
|
PY_SEED="$(printf '%s\n' "$PY_ED_OUT" | extract_ed25519_seed /dev/stdin)"
|
|
PY_ED_PRIMARY="$(printf '%s\n' "$PY_ED_OUT" | sed -n 's/^Primary:[[:space:]]*//p' | head -1)"
|
|
PY_ED_FILE="$(chain_file_for "$PY_ED_PRIMARY")"
|
|
|
|
rm -f "$PY_NOSTR_FILE" "$PY_ED_FILE"
|
|
|
|
# Python builds a nostr chain; each peer must see all 3 events incl the revoke.
|
|
"${PYTHON_CLI[@]}" sigchain add github:jason --nsec "$PY_NSEC" > /dev/null
|
|
"${PYTHON_CLI[@]}" sigchain add dns:jason.example --nsec "$PY_NSEC" > /dev/null
|
|
"${PYTHON_CLI[@]}" sigchain revoke github:jason --nsec "$PY_NSEC" > /dev/null
|
|
for peer in node rust; do
|
|
scenario "py nostr chain ⇒ $peer sigchain show"
|
|
run_cli "$peer" sigchain show --primary "$PY_NOSTR_PRIMARY" > "$TMP/psc.out" 2>&1
|
|
if grep -q "Length: 3 event(s)" "$TMP/psc.out" \
|
|
&& grep -q "op=revoke subject=github:jason" "$TMP/psc.out"; then
|
|
ok
|
|
else
|
|
bad "py→$peer sigchain show" "see $TMP/psc.out"
|
|
cat "$TMP/psc.out" >&2
|
|
fi
|
|
done
|
|
rm -f "$PY_NOSTR_FILE"
|
|
|
|
# Each peer builds a nostr chain; Python must see all 3 events.
|
|
for peer in node rust; do
|
|
rm -f "$PY_NOSTR_FILE"
|
|
run_cli "$peer" sigchain add github:jason --nsec "$PY_NSEC" > /dev/null
|
|
run_cli "$peer" sigchain add dns:jason.example --nsec "$PY_NSEC" > /dev/null
|
|
run_cli "$peer" sigchain revoke github:jason --nsec "$PY_NSEC" > /dev/null
|
|
scenario "$peer nostr chain ⇒ py sigchain show"
|
|
"${PYTHON_CLI[@]}" sigchain show --primary "$PY_NOSTR_PRIMARY" > "$TMP/psc2.out" 2>&1
|
|
if grep -q "Length: 3 event(s)" "$TMP/psc2.out" \
|
|
&& grep -q "op=revoke subject=github:jason" "$TMP/psc2.out"; then
|
|
ok
|
|
else
|
|
bad "$peer→py sigchain show" "see $TMP/psc2.out"
|
|
cat "$TMP/psc2.out" >&2
|
|
fi
|
|
rm -f "$PY_NOSTR_FILE"
|
|
done
|
|
|
|
# JSONL byte parity: Python and each peer must export the same on-disk chain
|
|
# to byte-identical JSONL.
|
|
"${PYTHON_CLI[@]}" sigchain add github:jason --nsec "$PY_NSEC" > /dev/null
|
|
"${PYTHON_CLI[@]}" sigchain add dns:jason.example --nsec "$PY_NSEC" > /dev/null
|
|
for peer in node rust; do
|
|
scenario "py JSONL == $peer JSONL for same chain"
|
|
"${PYTHON_CLI[@]}" sigchain export --nsec "$PY_NSEC" --format jsonl > "$TMP/pj_py.jsonl"
|
|
run_cli "$peer" sigchain export --nsec "$PY_NSEC" --format jsonl > "$TMP/pj_peer.jsonl"
|
|
if diff -q "$TMP/pj_py.jsonl" "$TMP/pj_peer.jsonl" > /dev/null; then
|
|
ok
|
|
else
|
|
bad "py/$peer JSONL parity" "exported bytes differ"
|
|
diff "$TMP/pj_py.jsonl" "$TMP/pj_peer.jsonl" | head -20 >&2
|
|
fi
|
|
done
|
|
rm -f "$PY_NOSTR_FILE"
|
|
|
|
# Python ed25519 chain ⇒ each peer validates.
|
|
"${PYTHON_CLI[@]}" sigchain add github:jason --ed25519-seed "$PY_SEED" > /dev/null
|
|
"${PYTHON_CLI[@]}" sigchain add dns:jason.example --ed25519-seed "$PY_SEED" > /dev/null
|
|
for peer in node rust; do
|
|
scenario "py ed25519 chain ⇒ $peer sigchain show"
|
|
run_cli "$peer" sigchain show --primary "$PY_ED_PRIMARY" > "$TMP/pse.out" 2>&1
|
|
if grep -q "Length: 2 event(s)" "$TMP/pse.out"; then ok; else
|
|
bad "py→$peer ed25519 chain" "$peer did not see all events"
|
|
cat "$TMP/pse.out" >&2
|
|
fi
|
|
done
|
|
rm -f "$PY_ED_FILE"
|
|
|
|
# ── BIP-39 Mnemonic interop ─────────────────────────────────────────────────
|
|
# 12- and 24-word phrases must derive identical Ed25519 keys across all
|
|
# implementations, and a claim signed with --mnemonic in one impl must
|
|
# verify in the others. See python/MNEMONIC-TEST-VECTORS.md for the
|
|
# definitive ground-truth vectors.
|
|
printf "%sBIP-39 mnemonic interop:%s\n" "$YELLOW" "$RESET"
|
|
|
|
# Canonical test vectors. Public keys are the expected outputs that all
|
|
# three implementations MUST agree on byte-for-byte. If any of these
|
|
# values change, an implementation has a derivation bug.
|
|
MNEMO_P24="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"
|
|
MNEMO_PUB_24="3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
|
|
MNEMO_P12="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
MNEMO_PUB_12="9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70"
|
|
MNEMO_P12B="legal winner thank year wave sausage worth useful legal winner thank yellow"
|
|
MNEMO_PUB_12B="cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03"
|
|
|
|
# Probe: does the Python CLI know about `identity from-mnemonic` yet?
|
|
PY_HAS_MNEMONIC=0
|
|
if [[ -x "$PYTHON_VENV" ]]; then
|
|
if "${PYTHON_CLI[@]}" identity from-mnemonic "$MNEMO_P12" 2>/dev/null \
|
|
| grep -q "^Public:"; then
|
|
PY_HAS_MNEMONIC=1
|
|
fi
|
|
fi
|
|
|
|
# Helper: assert the impl derives the expected pubkey from a phrase.
|
|
assert_pubkey() {
|
|
local impl="$1" phrase="$2" expected="$3" title="$4"
|
|
scenario "$title"
|
|
local actual
|
|
actual=$(run_cli "$impl" identity from-mnemonic "$phrase" 2>/dev/null \
|
|
| awk -F': *' '/^Public:/ {print $2; exit}')
|
|
if [[ "$actual" == "$expected" ]]; then ok; else
|
|
bad "$title" "expected pubkey $expected, got $actual"
|
|
fi
|
|
}
|
|
|
|
# Vector matches per impl.
|
|
for impl in rust node; do
|
|
assert_pubkey "$impl" "$MNEMO_P24" "$MNEMO_PUB_24" "$impl: V1 24-word vector derives expected pubkey"
|
|
assert_pubkey "$impl" "$MNEMO_P12" "$MNEMO_PUB_12" "$impl: V2 12-word vector derives expected pubkey"
|
|
assert_pubkey "$impl" "$MNEMO_P12B" "$MNEMO_PUB_12B" "$impl: V3 12-word vector derives expected pubkey"
|
|
done
|
|
if [[ "$PY_HAS_MNEMONIC" -eq 1 ]]; then
|
|
assert_pubkey py "$MNEMO_P24" "$MNEMO_PUB_24" "py: V1 24-word vector derives expected pubkey"
|
|
assert_pubkey py "$MNEMO_P12" "$MNEMO_PUB_12" "py: V2 12-word vector derives expected pubkey"
|
|
assert_pubkey py "$MNEMO_P12B" "$MNEMO_PUB_12B" "py: V3 12-word vector derives expected pubkey"
|
|
else
|
|
printf " %sskip%s %s\n" "$YELLOW" "$RESET" \
|
|
"py vector checks (python CLI lacks identity from-mnemonic — port still in flight)"
|
|
fi
|
|
|
|
# Cross-impl claim signing with --mnemonic. Each impl signs, each other
|
|
# verifies. Uses the V3 phrase because it has non-trivial entropy.
|
|
for fmt in json compact markdown; do
|
|
claim_roundtrip "rust mnemonic ($fmt) ⇒ node verify" rust node "$fmt" --mnemonic "$MNEMO_P12B"
|
|
claim_roundtrip "node mnemonic ($fmt) ⇒ rust verify" node rust "$fmt" --mnemonic "$MNEMO_P12B"
|
|
if [[ "$PY_HAS_MNEMONIC" -eq 1 ]]; then
|
|
claim_roundtrip "py mnemonic ($fmt) ⇒ rust verify" py rust "$fmt" --mnemonic "$MNEMO_P12B"
|
|
claim_roundtrip "rust mnemonic ($fmt) ⇒ py verify" rust py "$fmt" --mnemonic "$MNEMO_P12B"
|
|
claim_roundtrip "py mnemonic ($fmt) ⇒ node verify" py node "$fmt" --mnemonic "$MNEMO_P12B"
|
|
claim_roundtrip "node mnemonic ($fmt) ⇒ py verify" node py "$fmt" --mnemonic "$MNEMO_P12B"
|
|
fi
|
|
done
|
|
if [[ "$PY_HAS_MNEMONIC" -ne 1 ]]; then
|
|
printf " %sskip%s %s\n" "$YELLOW" "$RESET" \
|
|
"py mnemonic claim round-trips (port still in flight)"
|
|
fi
|
|
|
|
# Bijection sanity: 24-word phrase ⇄ seed must be exact. Each impl must
|
|
# produce the canonical phrase from a known 32-byte seed via the
|
|
# mnemonic-from-seed path (we drive it indirectly via the printed output
|
|
# of `identity from-mnemonic`).
|
|
scenario "24-word phrase is canonical form of its seed (rust)"
|
|
got=$("${RUST_CLI[@]}" identity from-mnemonic "$MNEMO_P24" 2>/dev/null \
|
|
| awk -F': *' '/^Mnemonic .24 words/ { match($0, /"[^"]+"/); print substr($0, RSTART+1, RLENGTH-2); exit }')
|
|
if [[ "$got" == "$MNEMO_P24" ]]; then ok; else
|
|
bad "rust canonical-24" "round-trip phrase differs"
|
|
fi
|
|
scenario "24-word phrase is canonical form of its seed (node)"
|
|
got=$("${NODE_CLI[@]}" identity from-mnemonic "$MNEMO_P24" 2>/dev/null \
|
|
| awk -F': *' '/^Mnemonic .24 words/ { match($0, /"[^"]+"/); print substr($0, RSTART+1, RLENGTH-2); exit }')
|
|
if [[ "$got" == "$MNEMO_P24" ]]; then ok; else
|
|
bad "node canonical-24" "round-trip phrase differs"
|
|
fi
|
|
|
|
printf "\n"
|
|
if [[ $FAIL -eq 0 ]]; then
|
|
printf "%sAll %d scenarios passed.%s\n" "$GREEN" "$PASS" "$RESET"
|
|
exit 0
|
|
else
|
|
printf "%s%d passed, %d failed.%s\n" "$RED" "$PASS" "$FAIL" "$RESET"
|
|
exit 1
|
|
fi
|