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

288 lines
10 KiB
Rust

//! Channel adapters for KEZ.
//!
//! A `Channel` knows how to fetch a published proof for a given `system:` and
//! verify it against the channel's ownership rules. Each channel lives in its
//! own module (one per file) so adding a new system (`bluesky`, `web`, …) is a
//! self-contained drop-in.
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use kez_core::{Identity, SignedClaim, VerificationStatus};
use thiserror::Error;
pub mod activitypub;
pub mod bluesky;
pub mod dns;
pub mod github;
pub mod nostr;
/// The single error type every channel returns. Variants map directly to the
/// failure modes the spec (§8.4) requires a verifier to distinguish.
#[derive(Debug, Error)]
pub enum ChannelError {
#[error("channel unreachable: {0}")]
Unreachable(String),
#[error("no KEZ proof found for {0}")]
NotFound(Identity),
#[error("proof failed verification: {0}")]
Invalid(#[source] anyhow::Error),
#[error("proof subject {found} did not match expected identity {expected}")]
SubjectMismatch { expected: Identity, found: Identity },
#[error("no channel registered for system: {0}")]
NoChannelForSystem(String),
#[error("{0}")]
Other(#[source] anyhow::Error),
}
pub type ChannelResult<T> = Result<T, ChannelError>;
/// Output of a successful verification.
#[derive(Debug, Clone)]
pub struct ChannelHit {
pub proof: SignedClaim,
pub status: VerificationStatus,
}
/// A channel adapter: fetch + verify a published proof for one `system:`.
#[async_trait]
pub trait Channel: Send + Sync {
/// The `system` prefix this channel handles (e.g. "github", "dns").
fn system(&self) -> &'static str;
/// Fetch the proof for `identity` from the channel and verify it.
/// Implementations MUST confirm the proof's subject equals `identity`.
async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult<ChannelHit>;
}
/// A small registry mapping `system:` → channel adapter. Lets the CLI (and any
/// future caller) dispatch a `verify <identifier>` request without knowing
/// which adapters are loaded.
#[derive(Default, Clone)]
pub struct Registry {
channels: HashMap<&'static str, Arc<dyn Channel>>,
}
impl Registry {
pub fn new() -> Self {
Self::default()
}
/// Build a registry with the channels shipped in this crate.
pub fn with_defaults() -> ChannelResult<Self> {
let mut r = Self::new();
r.register(Arc::new(
github::GithubChannel::new().map_err(ChannelError::Other)?,
));
r.register(Arc::new(dns::DnsChannel::new()));
r.register(Arc::new(nostr::NostrChannel::new()));
r.register(Arc::new(
bluesky::BlueskyChannel::new().map_err(ChannelError::Other)?,
));
let ap = Arc::new(
activitypub::ActivityPubChannel::new().map_err(ChannelError::Other)?,
);
r.register(ap.clone()); // canonical: ap:
r.register_as("mastodon", ap); // alias: mastodon:
Ok(r)
}
pub fn register(&mut self, channel: Arc<dyn Channel>) {
self.channels.insert(channel.system(), channel);
}
/// Register a channel under an additional alias scheme. Useful when one
/// adapter handles multiple identifier prefixes (e.g. `ap:` and
/// `mastodon:` both routed to `ActivityPubChannel`).
pub fn register_as(&mut self, system: &'static str, channel: Arc<dyn Channel>) {
self.channels.insert(system, channel);
}
pub fn get(&self, system: &str) -> Option<Arc<dyn Channel>> {
self.channels.get(system).cloned()
}
pub async fn verify(&self, identity: &Identity) -> ChannelResult<ChannelHit> {
let channel = self
.get(identity.scheme())
.ok_or_else(|| ChannelError::NoChannelForSystem(identity.scheme().to_owned()))?;
channel.fetch_and_verify(identity).await
}
}
/// Helper used by every channel: parse a raw proof string, verify its signature,
/// and confirm its subject matches what we asked for. Lives at the crate root
/// because it's identical for every channel.
pub fn parse_and_verify_for(raw: &str, expected: &Identity) -> ChannelResult<ChannelHit> {
let proof = parse_proof(raw).map_err(ChannelError::Invalid)?;
let status = proof
.verify()
.map_err(|err| ChannelError::Invalid(err.into()))?;
if proof.payload.subject != *expected {
return Err(ChannelError::SubjectMismatch {
expected: expected.clone(),
found: proof.payload.subject,
});
}
Ok(ChannelHit { proof, status })
}
/// Best-effort parse of any of the four wire encodings (compact, JSON,
/// Markdown, legacy DNS). For the compact form, the prefix may be embedded
/// in surrounding prose (e.g. a Mastodon bio or a Bluesky post) — we extract
/// the base64url token after `kez:z1:` regardless of what comes before.
pub fn parse_proof(raw: &str) -> anyhow::Result<SignedClaim> {
use anyhow::bail;
use kez_core::{decode_compact_claim, extract_markdown_proof, from_json, parse_dns_txt_value};
let trimmed = raw.trim();
// Markdown fence is the most specific marker — check it first.
if trimmed.contains("```kez") {
return Ok(extract_markdown_proof(trimmed)?);
}
// Raw JSON envelope.
if trimmed.starts_with('{') {
return Ok(from_json(trimmed)?);
}
// Legacy DNS prefix — JSON payload, won't be embedded in prose.
if trimmed.starts_with("kez1:") {
return Ok(parse_dns_txt_value(trimmed)?);
}
// Compact: extract the kez:z1:<base64url> token anywhere in the input.
if let Some(token) = extract_compact_token(trimmed) {
return Ok(decode_compact_claim(&token)?);
}
bail!("unknown KEZ proof format")
}
/// Pure: find a `kez:z1:<base64url>` token anywhere in `text` and return it.
/// The token ends at the first non-base64url-alphabet character.
pub fn extract_compact_token(text: &str) -> Option<String> {
use kez_core::COMPACT_PROOF_PREFIX;
let idx = text.find(COMPACT_PROOF_PREFIX)?;
let after = &text[idx + COMPACT_PROOF_PREFIX.len()..];
let body: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect();
if body.is_empty() {
None
} else {
Some(format!("{COMPACT_PROOF_PREFIX}{body}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use kez_core::{ClaimPayload, NostrSecret, SignedClaim};
fn make_signed(subject: &str) -> SignedClaim {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse(subject).unwrap();
SignedClaim::sign(
ClaimPayload::new(subject, primary, Utc::now()),
&secret,
)
.unwrap()
}
#[test]
fn parse_proof_handles_all_four_encodings() {
let signed = make_signed("github:jason");
// JSON
let json = signed.to_pretty_json().unwrap();
let p_json = parse_proof(&json).unwrap();
assert_eq!(p_json, signed);
// Compact
let compact = signed.to_compact().unwrap();
let p_compact = parse_proof(&compact).unwrap();
assert_eq!(p_compact, signed);
// Markdown
let md = signed.to_markdown_proof().unwrap();
let p_md = parse_proof(&md).unwrap();
assert_eq!(p_md, signed);
// Legacy DNS (`kez1:` prefix)
let dns = kez_core::dns_txt_value(&signed).unwrap();
let p_dns = parse_proof(&dns).unwrap();
assert_eq!(p_dns, signed);
}
#[test]
fn parse_proof_rejects_unknown_format() {
let err = parse_proof("just some text").unwrap_err();
assert!(err.to_string().contains("unknown"));
}
#[test]
fn parse_proof_extracts_compact_token_from_surrounding_prose() {
let signed = make_signed("ap:@jason@mastodon.social");
let compact = signed.to_compact().unwrap();
let bio = format!("hello world! my proof: {compact} — verify it");
let parsed = parse_proof(&bio).unwrap();
assert_eq!(parsed, signed);
}
#[test]
fn extract_compact_token_stops_at_non_base64url_char() {
let text = "before kez:z1:KLUv_QBYabc. after";
let token = extract_compact_token(text).unwrap();
assert_eq!(token, "kez:z1:KLUv_QBYabc");
}
#[test]
fn extract_compact_token_returns_none_when_missing() {
assert!(extract_compact_token("nothing here").is_none());
assert!(extract_compact_token("kez:z1:").is_none(), "empty body");
}
#[test]
fn parse_and_verify_for_flags_subject_mismatch() {
let signed = make_signed("github:jason");
let json = signed.to_pretty_json().unwrap();
let wrong = Identity::parse("github:mallory").unwrap();
let err = parse_and_verify_for(&json, &wrong).unwrap_err();
assert!(matches!(err, ChannelError::SubjectMismatch { .. }));
}
#[test]
fn parse_and_verify_for_passes_on_match() {
let signed = make_signed("github:jason");
let json = signed.to_pretty_json().unwrap();
let expected = Identity::parse("github:jason").unwrap();
let hit = parse_and_verify_for(&json, &expected).unwrap();
assert_eq!(hit.proof, signed);
}
#[test]
fn registry_dispatches_by_scheme() {
let registry = Registry::with_defaults().unwrap();
assert!(registry.get("github").is_some());
assert!(registry.get("dns").is_some());
assert!(registry.get("nostr").is_some());
assert!(registry.get("bluesky").is_some());
assert!(registry.get("ap").is_some());
assert!(
registry.get("mastodon").is_some(),
"mastodon: must alias to ap:"
);
assert!(registry.get("did").is_none(), "did: is not implemented yet");
}
#[tokio::test]
async fn registry_reports_unknown_system() {
let registry = Registry::new();
let identity = Identity::parse("github:jason").unwrap();
let err = registry.verify(&identity).await.unwrap_err();
assert!(matches!(err, ChannelError::NoChannelForSystem(_)));
}
}