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

282 lines
8.4 KiB
Rust

//! Integration tests: stand up the real router on a random local port and
//! drive it with `reqwest`. No mocks — exercises the full HTTP + SQLite +
//! kez-core validation path.
use std::net::SocketAddr;
use chrono::Utc;
use kez_core::{
Identity, NostrSecret, SigchainEventPayload, SignedSigchainEvent,
};
use kez_sig_server::{AppState, Store, router};
use reqwest::StatusCode;
use serde_json::Value;
struct TestServer {
base: String,
#[allow(dead_code)]
handle: tokio::task::JoinHandle<()>,
}
async fn spawn_server() -> TestServer {
let store = Store::open_in_memory().unwrap();
let app = router(AppState { store });
let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
TestServer {
base: format!("http://{addr}"),
handle,
}
}
fn fresh_nostr() -> (NostrSecret, Identity) {
let s = NostrSecret::generate();
let id = Identity::parse(format!("nostr:{}", s.npub())).unwrap();
(s, id)
}
fn signed_add(
secret: &NostrSecret,
primary: &Identity,
seq: u64,
prev: Option<String>,
subject: &str,
) -> SignedSigchainEvent {
let payload = SigchainEventPayload::new_add(
primary.clone(),
seq,
prev,
Identity::parse(subject).unwrap(),
None,
Utc::now(),
);
SignedSigchainEvent::sign(payload, secret).unwrap()
}
fn chain_url(base: &str, primary: &Identity) -> String {
format!("{base}/v1/sigchains/{}/{}", primary.scheme(), primary.value())
}
#[tokio::test]
async fn healthz_returns_ok() {
let server = spawn_server().await;
let resp = reqwest::get(format!("{}/v1/healthz", server.base))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["status"], "ok");
}
#[tokio::test]
async fn empty_chain_returns_404() {
let server = spawn_server().await;
let (_, primary) = fresh_nostr();
let resp = reqwest::get(chain_url(&server.base, &primary)).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn post_then_get_round_trip() {
let server = spawn_server().await;
let (secret, primary) = fresh_nostr();
let event = signed_add(&secret, &primary, 0, None, "github:jason");
let client = reqwest::Client::new();
let post = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&event)
.send()
.await
.unwrap();
assert_eq!(post.status(), StatusCode::CREATED);
let posted: Value = post.json().await.unwrap();
assert_eq!(posted["seq"], 0);
let head_hash_after_post = posted["hash"].as_str().unwrap().to_owned();
assert!(head_hash_after_post.starts_with("sha256:"));
// GET returns the event we just stored, as JSONL.
let get = reqwest::get(chain_url(&server.base, &primary)).await.unwrap();
assert_eq!(get.status(), StatusCode::OK);
assert_eq!(
get.headers()["content-type"]
.to_str()
.unwrap()
.split(';')
.next()
.unwrap(),
"application/jsonl"
);
let body = get.text().await.unwrap();
let lines: Vec<&str> = body.trim().lines().collect();
assert_eq!(lines.len(), 1);
let round_tripped: SignedSigchainEvent = serde_json::from_str(lines[0]).unwrap();
assert_eq!(round_tripped, event);
}
#[tokio::test]
async fn head_endpoint_returns_latest() {
let server = spawn_server().await;
let (secret, primary) = fresh_nostr();
let client = reqwest::Client::new();
// seq 0
let e0 = signed_add(&secret, &primary, 0, None, "github:a");
client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e0)
.send()
.await
.unwrap();
// seq 1 — prev = sha256 of e0 envelope
let e1 = signed_add(&secret, &primary, 1, Some(e0.hash().unwrap()), "github:b");
client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e1)
.send()
.await
.unwrap();
let head_resp = reqwest::get(format!("{}/head", chain_url(&server.base, &primary)))
.await
.unwrap();
assert_eq!(head_resp.status(), StatusCode::OK);
let head: SignedSigchainEvent = head_resp.json().await.unwrap();
assert_eq!(head, e1);
}
#[tokio::test]
async fn rejects_event_for_wrong_primary_url() {
let server = spawn_server().await;
let (secret_a, primary_a) = fresh_nostr();
let (_secret_b, primary_b) = fresh_nostr();
// Event signed for A's key, but POSTed under B's URL.
let event = signed_add(&secret_a, &primary_a, 0, None, "github:jason");
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/events", chain_url(&server.base, &primary_b)))
.json(&event)
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn rejects_bad_signature() {
let server = spawn_server().await;
let (secret, primary) = fresh_nostr();
let mut event = signed_add(&secret, &primary, 0, None, "github:jason");
// Flip the sig hex's last byte (still 128 chars, but no longer valid).
let mut sig_bytes = event.signature.sig.into_bytes();
let last = sig_bytes.len() - 1;
sig_bytes[last] = if sig_bytes[last] == b'a' { b'b' } else { b'a' };
event.signature.sig = String::from_utf8(sig_bytes).unwrap();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&event)
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn rejects_seq_skip() {
let server = spawn_server().await;
let (secret, primary) = fresh_nostr();
let client = reqwest::Client::new();
// seq 0 — succeeds
let e0 = signed_add(&secret, &primary, 0, None, "github:a");
let r0 = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e0)
.send()
.await
.unwrap();
assert_eq!(r0.status(), StatusCode::CREATED);
// seq 2 (skipping 1) — must be rejected as Conflict
let e2 = signed_add(&secret, &primary, 2, Some(e0.hash().unwrap()), "github:b");
let r2 = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e2)
.send()
.await
.unwrap();
assert_eq!(r2.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn rejects_bad_prev_hash() {
let server = spawn_server().await;
let (secret, primary) = fresh_nostr();
let client = reqwest::Client::new();
let e0 = signed_add(&secret, &primary, 0, None, "github:a");
client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e0)
.send()
.await
.unwrap();
// seq 1 with a bogus prev hash.
let e1 = signed_add(&secret, &primary, 1, Some("sha256:dead".into()), "github:b");
let resp = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e1)
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn rejects_duplicate_seq() {
let server = spawn_server().await;
let (secret, primary) = fresh_nostr();
let client = reqwest::Client::new();
let e0 = signed_add(&secret, &primary, 0, None, "github:a");
let r0 = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e0)
.send()
.await
.unwrap();
assert_eq!(r0.status(), StatusCode::CREATED);
// Posting the same seq-0 event again must fail.
let r0_dup = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e0)
.send()
.await
.unwrap();
assert_eq!(r0_dup.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn invalid_primary_in_url_is_bad_request() {
let server = spawn_server().await;
// Not a valid `system:value` shape (no value after the colon).
let resp = reqwest::get(format!("{}/v1/sigchains/nostr/garbage", server.base))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}