Kez/crosstest.sh
Tudisco d0db6f00f1 Initial implementation of KEZ — protocol, two impls, and storage server
KEZ is a portable, decentralized identity graph: a person signs claims
linking their many accounts, publishes those claims in places only the
claimed account can publish to, and anyone can verify the connections
without trusting a central server.

Layout
------
- SPEC.md            Language-agnostic protocol spec (v0.2)
- rust/              Rust implementation: kez-core, kez-channels, kez-cli
- nodejs/            TypeScript port at full parity
- rust-sig-server/   Optional axum + SQLite storage server for sigchains
- crosstest.sh       Cross-implementation interop harness

Capabilities (both implementations, byte-compatible)
----------------------------------------------------
- Two primary-key algorithms: nostr/secp256k1 Schnorr (BIP-340) and
  Ed25519 (RFC 8032). Identifiers: nostr:npub1... and ed25519:<hex>.
- JCS (RFC 8785) canonicalization for everything signed.
- Four proof encodings: JSON envelope, compact (kez:z1:<base64url(zstd(json))>),
  Markdown fence, DNS TXT.
- Five channel plugins (no API keys, no auth needed for any of them):
    dns:        system resolver, _kez.<domain> TXT records
    github:     public gist scan + <user>/<user> profile README fallback
    nostr:      kind-30078 events from default relays
    bluesky:    public AppView author feed
    ap:         WebFinger + actor JSON (alias mastodon:)
- Identical CLI surface:
    kez identity new [--key-type nostr|ed25519]
    kez claim create <subject> (--nsec | --ed25519-seed) [--format ...] [--out ...]
    kez claim dns <domain>     (--nsec | --ed25519-seed)
    kez verify file <path>
    kez verify id <identifier>
    kez sigchain add|revoke|show|export|publish
- Sigchains: append-only signed log per primary, hash-chained per spec §6,
  stored locally at ~/.kez/sigchains/, exportable as JSONL or kez:zc1: bundle.
- Sigchain publish destinations: chain server, web (file dump), DNS (zone
  record print), nostr (kind-30078 wrapping event).

kez-sig-server
--------------
Optional storage tier. Axum + SQLite, single binary, no external deps.

- No auth — the cryptography is the access control. The server validates
  every signature, every seq, every prev hash before storing.
- REST API: POST /v1/sigchains/{scheme}/{id}/events (append signed event,
  201 with new head hash or 4xx); GET /{scheme}/{id} (full chain as JSONL);
  GET /head; GET /healthz.
- Designed for one central instance for now; the design doesn't preclude
  running more later (clients gain a configurable list, verifiers
  reconcile per spec §6.2).
- Channel-based publishing remains the always-available fallback if the
  server is unavailable.

Tests
-----
- rust/                 99 tests
- rust-sig-server/      10 integration tests (real HTTP, real SQLite)
- nodejs/               91 tests (vitest)
- crosstest.sh          19 cross-impl scenarios — proves JCS bytes,
                        Schnorr + Ed25519 sigs, all four claim encodings,
                        and the sigchain JSONL bundle are byte-compatible
                        between Rust and Node in both directions.

What's not done yet
-------------------
- verify id consulting the sigchain for revocations (data path exists,
  just not wired into the verifier output).
- rotate and add_device sigchain ops (types reserved).
- expires_at enforcement during claim verification.
- Typed VerificationStatus.status reflecting the five failure modes.
- Auth-required publishers (GitHub gist, Bluesky, ActivityPub).
2026-05-24 14:41:00 -06:00

300 lines
14 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")
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
}
# 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/<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"
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