#!/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") 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 } # 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 # ── 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" 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