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).
288 lines
10 KiB
Rust
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(_)));
|
|
}
|
|
}
|