//! 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 = Result; /// 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; } /// A small registry mapping `system:` → channel adapter. Lets the CLI (and any /// future caller) dispatch a `verify ` request without knowing /// which adapters are loaded. #[derive(Default, Clone)] pub struct Registry { channels: HashMap<&'static str, Arc>, } impl Registry { pub fn new() -> Self { Self::default() } /// Build a registry with the channels shipped in this crate. pub fn with_defaults() -> ChannelResult { 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) { 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) { self.channels.insert(system, channel); } pub fn get(&self, system: &str) -> Option> { self.channels.get(system).cloned() } pub async fn verify(&self, identity: &Identity) -> ChannelResult { 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 { 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 { 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: 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:` 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 { 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(_))); } }