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).
300 lines
14 KiB
Bash
Executable File
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
|