#!/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: (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/.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