Kez/crosstest.sh
Jason Tudisco b1240c13e5 Add Python implementation and cross-test interop
Add a Python port of the KEZ CLI under python/, mirroring the Rust and
Node implementations command-for-command and byte-for-byte:

- Pure-Python JCS (RFC 8785), BIP-340 Schnorr, and Bech32; cryptography
  for Ed25519 and zstandard for the compact zstd framing.
- Full CLI: identity new, claim create/dns, verify file, and
  sigchain add/revoke/show/export.

Wire Python into crosstest.sh with 35 new scenarios covering Python
against both Rust and Node, in every direction, across all wire formats,
both key types, DNS proofs, and sigchains (incl. JSONL byte parity).
All 55 scenarios pass.

Update root README and .gitignore for the new implementation.
2026-06-01 13:29:45 -06:00

454 lines
21 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"
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