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).
282 lines
8.4 KiB
Rust
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);
|
|
}
|