use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use bech32::{FromBase32, ToBase32, Variant}; use chrono::{DateTime, Utc}; use ed25519_dalek::{ Signature as Ed25519Signature, Signer as Ed25519Signer, SigningKey as Ed25519SigningKey, VerifyingKey as Ed25519VerifyingKey, }; use rand::RngCore; use secp256k1::schnorr::Signature; use secp256k1::{Keypair, Message, Secp256k1, SecretKey, XOnlyPublicKey}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fmt; use std::str::FromStr; pub mod mnemonic; pub use mnemonic::{ MnemonicWords, generate_mnemonic, mnemonic_from_seed_24, seed_from_mnemonic, }; pub const CLAIM_TYPE: &str = "kez.claim"; pub const SIGCHAIN_EVENT_TYPE: &str = "kez.sigchain.event"; pub const FORMAT_VERSION: u8 = 1; pub const NOSTR_SCHNORR_ALG: &str = "nostr-secp256k1-schnorr-sha256-jcs"; pub const ED25519_SHA512_ALG: &str = "ed25519-sha512-jcs"; pub const COMPACT_PROOF_PREFIX: &str = "kez:z1:"; pub const COMPACT_CHAIN_PREFIX: &str = "kez:zc1:"; #[derive(Debug, thiserror::Error)] pub enum KezError { #[error("unsupported key algorithm: {0}")] UnsupportedAlgorithm(String), #[error("invalid identity: {0}")] InvalidIdentity(String), #[error("invalid nostr key")] InvalidNostrKey, #[error("invalid signature")] InvalidSignature, #[error("canonical json failed: {0}")] CanonicalJson(String), #[error("json failed: {0}")] Json(#[from] serde_json::Error), #[error("bech32 failed: {0}")] Bech32(#[from] bech32::Error), #[error("secp256k1 failed: {0}")] Secp256k1(#[from] secp256k1::Error), #[error("hex failed: {0}")] Hex(#[from] hex::FromHexError), #[error("base64 failed: {0}")] Base64(#[from] base64::DecodeError), #[error("io failed: {0}")] Io(#[from] std::io::Error), } pub type Result = std::result::Result; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct Identity(String); impl Identity { pub fn parse(value: impl AsRef) -> Result { let raw = value.as_ref().trim(); if raw.is_empty() { return Err(KezError::InvalidIdentity(raw.to_owned())); } if raw.starts_with("npub1") { validate_npub(raw)?; return Ok(Self(format!("nostr:{raw}"))); } let Some((scheme, rest)) = raw.split_once(':') else { return Err(KezError::InvalidIdentity(raw.to_owned())); }; if scheme.is_empty() || rest.is_empty() { return Err(KezError::InvalidIdentity(raw.to_owned())); } if scheme == "nostr" { validate_npub(rest)?; } else if scheme == "ed25519" { validate_ed25519_hex(rest)?; } Ok(Self(format!("{scheme}:{rest}"))) } pub fn scheme(&self) -> &str { self.0 .split_once(':') .map(|(scheme, _)| scheme) .unwrap_or("") } pub fn value(&self) -> &str { self.0.split_once(':').map(|(_, value)| value).unwrap_or("") } pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Display for Identity { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } impl FromStr for Identity { type Err = KezError; fn from_str(s: &str) -> Result { Self::parse(s) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ClaimPayload { #[serde(rename = "type")] pub kind: String, pub version: u8, pub subject: Identity, pub primary: Identity, pub created_at: DateTime, #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option>, } impl ClaimPayload { pub fn new(subject: Identity, primary: Identity, created_at: DateTime) -> Self { Self { kind: CLAIM_TYPE.to_owned(), version: FORMAT_VERSION, subject, primary, created_at, expires_at: None, } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SignatureBlock { pub alg: String, pub key: Identity, pub sig: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SignedClaim { pub kez: String, pub payload: ClaimPayload, pub signature: SignatureBlock, } /// A signing key of either supported type. Lets `SignedClaim::sign_with` /// dispatch on the underlying algorithm without exposing two near-identical /// entry points. pub enum Signer<'a> { Nostr(&'a NostrSecret), Ed25519(&'a Ed25519Secret), } impl SignedClaim { /// Sign with a nostr/secp256k1 primary key (back-compat convenience). pub fn sign(payload: ClaimPayload, signer: &NostrSecret) -> Result { Self::sign_with(payload, Signer::Nostr(signer)) } /// Sign with an Ed25519 primary key (back-compat convenience). pub fn sign_ed25519(payload: ClaimPayload, signer: &Ed25519Secret) -> Result { Self::sign_with(payload, Signer::Ed25519(signer)) } /// Unified signing entry: dispatches on the signer type. pub fn sign_with(payload: ClaimPayload, signer: Signer<'_>) -> Result { let (alg, key, sig_hex) = match signer { Signer::Nostr(s) => { let key = Identity::parse(format!("nostr:{}", s.npub()))?; let sig = sign_jcs_schnorr_hex(&payload, s)?; (NOSTR_SCHNORR_ALG.to_owned(), key, sig) } Signer::Ed25519(s) => { let key = s.identity()?; let jcs = canonical_bytes(&payload)?; let sig = s.sign(&jcs); (ED25519_SHA512_ALG.to_owned(), key, hex::encode(sig)) } }; Ok(Self { kez: "claim".to_owned(), payload, signature: SignatureBlock { alg, key, sig: sig_hex, }, }) } pub fn verify(&self) -> Result { if self.signature.key != self.payload.primary { return Err(KezError::InvalidSignature); } match self.signature.alg.as_str() { NOSTR_SCHNORR_ALG => { verify_jcs_schnorr_hex( &self.payload, self.signature.key.value(), &self.signature.sig, )?; } ED25519_SHA512_ALG => { let jcs = canonical_bytes(&self.payload)?; verify_ed25519_hex(self.signature.key.value(), &jcs, &self.signature.sig)?; } other => return Err(KezError::UnsupportedAlgorithm(other.to_owned())), } Ok(VerificationStatus { primary: self.payload.primary.clone(), verified: vec![self.payload.subject.clone()], status: "valid".to_owned(), confidence: "strong".to_owned(), }) } pub fn to_pretty_json(&self) -> Result { Ok(serde_json::to_string_pretty(self)?) } pub fn to_markdown_proof(&self) -> Result { let json = self.to_pretty_json()?; Ok(format!( "# KEZ Proof\n\nThis account publishes a signed KEZ identity claim.\n\n- Primary: `{}`\n- Subject: `{}`\n- Created: `{}`\n\n```kez\n{}\n```\n", self.payload.primary, self.payload.subject, self.payload.created_at, json )) } pub fn to_compact(&self) -> Result { encode_compact_claim(self) } } /// Spec §6 sigchain event payload. JCS-canonicalized + signed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SigchainEventPayload { #[serde(rename = "type")] pub kind: String, pub version: u8, /// The primary key this event belongs to. After a `rotate`, this is /// the new primary; before, the old one. pub primary: Identity, pub seq: u64, /// `sha256:` of JCS of the prior signed envelope. None iff `seq == 0`. #[serde(skip_serializing_if = "Option::is_none")] pub prev: Option, pub created_at: DateTime, /// Operation name: "add" | "revoke" | "rotate" | "add_device". pub op: String, /// Op-specific fields, kept as Value so the schema can grow without a /// breaking type change. pub payload: serde_json::Value, } /// Convenience constructors for the supported ops. Use these instead of /// hand-rolling `op`/`payload` strings so impls stay consistent. impl SigchainEventPayload { /// `add` op: assert that `primary` controls `subject`. pub fn new_add( primary: Identity, seq: u64, prev: Option, subject: Identity, proof_url: Option, created_at: DateTime, ) -> Self { let mut payload = serde_json::Map::new(); payload.insert("subject".to_owned(), serde_json::json!(subject)); if let Some(url) = proof_url { payload.insert("proof_url".to_owned(), serde_json::json!(url)); } Self { kind: SIGCHAIN_EVENT_TYPE.to_owned(), version: FORMAT_VERSION, primary, seq, prev, created_at, op: "add".to_owned(), payload: serde_json::Value::Object(payload), } } /// `revoke` op: retract a previously-added subject. pub fn new_revoke( primary: Identity, seq: u64, prev: Option, subject: Identity, created_at: DateTime, ) -> Self { Self { kind: SIGCHAIN_EVENT_TYPE.to_owned(), version: FORMAT_VERSION, primary, seq, prev, created_at, op: "revoke".to_owned(), payload: serde_json::json!({ "subject": subject }), } } /// Pull the `subject` field out of an add/revoke payload, if present. pub fn subject(&self) -> Option { self.payload .get("subject") .and_then(|v| v.as_str()) .and_then(|s| Identity::parse(s).ok()) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SignedSigchainEvent { pub kez: String, pub payload: SigchainEventPayload, pub signature: SignatureBlock, } impl SignedSigchainEvent { /// Sign with a nostr key (back-compat convenience). pub fn sign(payload: SigchainEventPayload, signer: &NostrSecret) -> Result { Self::sign_with(payload, Signer::Nostr(signer)) } /// Sign with an Ed25519 key. pub fn sign_ed25519( payload: SigchainEventPayload, signer: &Ed25519Secret, ) -> Result { Self::sign_with(payload, Signer::Ed25519(signer)) } /// Unified signing entry — same dispatch as `SignedClaim::sign_with`. pub fn sign_with(payload: SigchainEventPayload, signer: Signer<'_>) -> Result { let (alg, key, sig_hex) = match signer { Signer::Nostr(s) => { let key = Identity::parse(format!("nostr:{}", s.npub()))?; let sig = sign_jcs_schnorr_hex(&payload, s)?; (NOSTR_SCHNORR_ALG.to_owned(), key, sig) } Signer::Ed25519(s) => { let key = s.identity()?; let jcs = canonical_bytes(&payload)?; let sig = s.sign(&jcs); (ED25519_SHA512_ALG.to_owned(), key, hex::encode(sig)) } }; Ok(Self { kez: "sigchain_event".to_owned(), payload, signature: SignatureBlock { alg, key, sig: sig_hex, }, }) } /// Verify the signature over `payload`. Dispatches on `signature.alg`. pub fn verify(&self) -> Result<()> { if self.signature.key != self.payload.primary { return Err(KezError::InvalidSignature); } match self.signature.alg.as_str() { NOSTR_SCHNORR_ALG => verify_jcs_schnorr_hex( &self.payload, self.signature.key.value(), &self.signature.sig, ), ED25519_SHA512_ALG => { let jcs = canonical_bytes(&self.payload)?; verify_ed25519_hex(self.signature.key.value(), &jcs, &self.signature.sig) } other => Err(KezError::UnsupportedAlgorithm(other.to_owned())), } } /// `sha256:` of the JCS-canonicalized envelope. Used by `prev` /// on the next event in the chain. pub fn hash(&self) -> Result { let bytes = canonical_bytes(self)?; Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) } } // ───────────────────────────────────────────────────────────────────────────── // Sigchain — append-only, validated chain of signed events for one primary. // ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, thiserror::Error)] pub enum SigchainError { #[error("expected primary {expected}, got {found}")] WrongPrimary { expected: Identity, found: Identity }, #[error("expected seq {expected}, got {found}")] SeqMismatch { expected: u64, found: u64 }, #[error("prev hash mismatch: expected {expected:?}, got {found:?}")] PrevMismatch { expected: Option, found: Option, }, #[error("signature failed: {0}")] BadSignature(#[from] KezError), #[error("envelope tag must be \"sigchain_event\", got {0:?}")] WrongEnvelopeTag(String), #[error("sigchain is empty")] Empty, #[error("invalid JSONL: {0}")] BadJsonl(String), } /// An ordered, validated chain of signed events for a single primary. /// All mutations go through `append` (or the `sign_*` helpers), which /// enforce the spec §6.2 integrity rules. #[derive(Debug, Clone)] pub struct Sigchain { primary: Identity, events: Vec, } impl Sigchain { /// Create an empty sigchain for `primary`. The first appended event /// must have seq 0 and no prev. pub fn new(primary: Identity) -> Self { Self { primary, events: Vec::new(), } } pub fn primary(&self) -> &Identity { &self.primary } pub fn head(&self) -> Option<&SignedSigchainEvent> { self.events.last() } /// `sha256:` of the head envelope. `None` iff the chain is empty. pub fn head_hash(&self) -> Result> { match self.head() { None => Ok(None), Some(head) => Ok(Some(head.hash()?)), } } /// The seq the next appended event must use. pub fn next_seq(&self) -> u64 { self.events.last().map(|e| e.payload.seq + 1).unwrap_or(0) } pub fn len(&self) -> usize { self.events.len() } pub fn is_empty(&self) -> bool { self.events.is_empty() } pub fn events(&self) -> &[SignedSigchainEvent] { &self.events } /// Append a signed event. Validates: envelope tag, primary, seq /// monotonicity, prev hash chain, and signature. pub fn append(&mut self, event: SignedSigchainEvent) -> std::result::Result<(), SigchainError> { if event.kez != "sigchain_event" { return Err(SigchainError::WrongEnvelopeTag(event.kez.clone())); } if event.payload.primary != self.primary { return Err(SigchainError::WrongPrimary { expected: self.primary.clone(), found: event.payload.primary.clone(), }); } let expected_seq = self.next_seq(); if event.payload.seq != expected_seq { return Err(SigchainError::SeqMismatch { expected: expected_seq, found: event.payload.seq, }); } let expected_prev = self.head_hash()?; if event.payload.prev != expected_prev { return Err(SigchainError::PrevMismatch { expected: expected_prev, found: event.payload.prev.clone(), }); } event.verify()?; self.events.push(event); Ok(()) } /// Full-chain re-validation (signatures + integrity). Useful after /// loading from external storage. pub fn validate(&self) -> std::result::Result<(), SigchainError> { let mut rebuilt = Sigchain::new(self.primary.clone()); for event in &self.events { rebuilt.append(event.clone())?; } Ok(()) } /// True if the most recent op for `subject` is `revoke`. False if the /// most recent op is `add` or the subject was never added. pub fn is_revoked(&self, subject: &Identity) -> bool { for event in self.events.iter().rev() { let Some(s) = event.payload.subject() else { continue; }; if &s == subject { return event.payload.op == "revoke"; } } false } /// True if `subject` was added (and not later revoked). pub fn is_active(&self, subject: &Identity) -> bool { for event in self.events.iter().rev() { let Some(s) = event.payload.subject() else { continue; }; if &s == subject { return event.payload.op == "add"; } } false } /// Convenience: build, sign, and append an `add` event in one call. pub fn sign_add( &mut self, subject: Identity, proof_url: Option, signer: Signer<'_>, ) -> std::result::Result<&SignedSigchainEvent, SigchainError> { let prev = self.head_hash()?; let payload = SigchainEventPayload::new_add( self.primary.clone(), self.next_seq(), prev, subject, proof_url, Utc::now(), ); let signed = SignedSigchainEvent::sign_with(payload, signer)?; self.append(signed)?; Ok(self.events.last().unwrap()) } /// Convenience: build, sign, and append a `revoke` event. pub fn sign_revoke( &mut self, subject: Identity, signer: Signer<'_>, ) -> std::result::Result<&SignedSigchainEvent, SigchainError> { let prev = self.head_hash()?; let payload = SigchainEventPayload::new_revoke( self.primary.clone(), self.next_seq(), prev, subject, Utc::now(), ); let signed = SignedSigchainEvent::sign_with(payload, signer)?; self.append(signed)?; Ok(self.events.last().unwrap()) } /// Serialize as JSONL (one envelope per line). The portable format /// used by `kez sigchain export` and the chain server's GET endpoint. pub fn to_jsonl(&self) -> Result { let mut out = String::new(); for event in &self.events { out.push_str(&serde_json::to_string(event)?); out.push('\n'); } Ok(out) } /// Encode the whole chain as `kez:zc1:`. /// Portable single-string form for embedding in nostr events, DNS TXT /// values, ActivityPub fields, etc. pub fn to_compact_bundle(&self) -> Result { let jsonl = self.to_jsonl()?; let compressed = zstd::encode_all(jsonl.as_bytes(), 3)?; Ok(format!( "{COMPACT_CHAIN_PREFIX}{}", URL_SAFE_NO_PAD.encode(compressed) )) } /// Inverse of `to_compact_bundle`. pub fn from_compact_bundle(value: &str) -> std::result::Result { let encoded = value.trim().strip_prefix(COMPACT_CHAIN_PREFIX).ok_or_else(|| { SigchainError::BadJsonl(format!("missing {COMPACT_CHAIN_PREFIX} prefix")) })?; let compressed = URL_SAFE_NO_PAD .decode(encoded) .map_err(|e| SigchainError::BadJsonl(format!("base64url: {e}")))?; let jsonl = zstd::decode_all(compressed.as_slice()) .map_err(|e| SigchainError::BadJsonl(format!("zstd: {e}")))?; let text = String::from_utf8(jsonl) .map_err(|e| SigchainError::BadJsonl(format!("utf8: {e}")))?; Self::from_jsonl(&text) } /// Parse a JSONL bundle. Validates as it appends, so the result is /// guaranteed to be a well-formed chain. pub fn from_jsonl(text: &str) -> std::result::Result { let mut lines = text.lines().filter(|l| !l.trim().is_empty()); let first = lines .next() .ok_or_else(|| SigchainError::BadJsonl("empty input".to_owned()))?; let first_event: SignedSigchainEvent = serde_json::from_str(first) .map_err(|e| SigchainError::BadJsonl(format!("line 0: {e}")))?; let mut chain = Sigchain::new(first_event.payload.primary.clone()); chain.append(first_event)?; for (i, line) in lines.enumerate() { let event: SignedSigchainEvent = serde_json::from_str(line) .map_err(|e| SigchainError::BadJsonl(format!("line {}: {e}", i + 1)))?; chain.append(event)?; } Ok(chain) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerificationStatus { pub primary: Identity, pub verified: Vec, pub status: String, pub confidence: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct NostrSecret { secret_key: SecretKey, } impl NostrSecret { pub fn generate() -> Self { let mut rng = rand::thread_rng(); Self { secret_key: SecretKey::new(&mut rng), } } pub fn from_nsec(nsec: &str) -> Result { let (hrp, data, variant) = bech32::decode(nsec)?; if hrp != "nsec" || variant != Variant::Bech32 { return Err(KezError::InvalidNostrKey); } let bytes = Vec::::from_base32(&data)?; let secret_key = SecretKey::from_slice(&bytes)?; Ok(Self { secret_key }) } pub fn nsec(&self) -> String { bech32::encode( "nsec", self.secret_key.secret_bytes().to_base32(), Variant::Bech32, ) .expect("nsec encoding cannot fail for static hrp") } pub fn npub(&self) -> String { let secp = Secp256k1::new(); let keypair = Keypair::from_secret_key(&secp, &self.secret_key); let (public_key, _) = XOnlyPublicKey::from_keypair(&keypair); bech32::encode("npub", public_key.serialize().to_base32(), Variant::Bech32) .expect("npub encoding cannot fail for static hrp") } /// 32-byte x-only public key as lowercase hex (the form nostr filters /// and event `pubkey` fields use). pub fn pubkey_hex(&self) -> String { let secp = Secp256k1::new(); let keypair = Keypair::from_secret_key(&secp, &self.secret_key); let (public_key, _) = XOnlyPublicKey::from_keypair(&keypair); hex::encode(public_key.serialize()) } /// BIP-340 Schnorr signature over a 32-byte digest (no aux rand). /// Used for signing NIP-01 nostr event ids when publishing. pub fn sign_raw(&self, digest: &[u8; 32]) -> Result<[u8; 64]> { let msg = Message::from_digest_slice(digest)?; let secp = Secp256k1::new(); let keypair = Keypair::from_secret_key(&secp, &self.secret_key); let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair); Ok(*sig.as_ref()) } } #[derive(Clone)] pub struct Ed25519Secret { signing_key: Ed25519SigningKey, } impl Ed25519Secret { pub fn generate() -> Self { let mut rng = rand::thread_rng(); let mut seed = [0u8; 32]; rng.fill_bytes(&mut seed); Self { signing_key: Ed25519SigningKey::from_bytes(&seed), } } pub fn from_seed_hex(seed_hex: &str) -> Result { let bytes = hex::decode(seed_hex)?; let seed: [u8; 32] = bytes.try_into().map_err(|_| { KezError::InvalidIdentity("ed25519 seed must be 32 bytes / 64 hex chars".to_owned()) })?; Ok(Self { signing_key: Ed25519SigningKey::from_bytes(&seed), }) } /// 32-byte seed (secret material) as lowercase hex. Anyone with this can sign as this identity. pub fn seed_hex(&self) -> String { hex::encode(self.signing_key.to_bytes()) } /// 32-byte public key as lowercase hex. pub fn pubkey_hex(&self) -> String { hex::encode(self.signing_key.verifying_key().to_bytes()) } pub fn identity(&self) -> Result { Identity::parse(format!("ed25519:{}", self.pubkey_hex())) } /// Sign raw bytes (no pre-hash — RFC 8032 PureEdDSA does SHA-512 internally). pub fn sign(&self, message: &[u8]) -> [u8; 64] { self.signing_key.sign(message).to_bytes() } } impl std::fmt::Debug for Ed25519Secret { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Never print the seed. f.debug_struct("Ed25519Secret") .field("pubkey_hex", &self.pubkey_hex()) .finish() } } impl PartialEq for Ed25519Secret { fn eq(&self, other: &Self) -> bool { self.signing_key.to_bytes() == other.signing_key.to_bytes() } } impl Eq for Ed25519Secret {} pub fn dns_txt_name(identity: &Identity) -> Result { if identity.scheme() != "dns" { return Err(KezError::InvalidIdentity(identity.to_string())); } Ok(format!("_kez.{}", identity.value())) } /// For a `nostr:npub1...` identity, return the 32-byte x-only pubkey as a /// lowercase hex string. This is the form nostr relay filters require. pub fn nostr_pubkey_hex(identity: &Identity) -> Result { if identity.scheme() != "nostr" { return Err(KezError::InvalidIdentity(identity.to_string())); } let pk = decode_npub(identity.value())?; Ok(hex::encode(pk.serialize())) } pub fn dns_txt_value(claim: &SignedClaim) -> Result { Ok(format!("kez1:{}", serde_json::to_string(claim)?)) } pub fn parse_dns_txt_value(value: &str) -> Result { let json = value .strip_prefix("kez1:") .ok_or_else(|| KezError::InvalidIdentity("DNS TXT proof missing kez1 prefix".to_owned()))?; Ok(serde_json::from_str(json)?) } pub fn encode_compact_claim(claim: &SignedClaim) -> Result { let json = serde_json::to_vec(claim)?; let compressed = zstd::encode_all(json.as_slice(), 3)?; Ok(format!( "{COMPACT_PROOF_PREFIX}{}", URL_SAFE_NO_PAD.encode(compressed) )) } pub fn decode_compact_claim(value: &str) -> Result { let encoded = value .trim() .strip_prefix(COMPACT_PROOF_PREFIX) .ok_or_else(|| { KezError::InvalidIdentity("compact proof missing kez:z1 prefix".to_owned()) })?; let compressed = URL_SAFE_NO_PAD.decode(encoded)?; let json = zstd::decode_all(compressed.as_slice())?; Ok(serde_json::from_slice(&json)?) } pub fn extract_markdown_proof(markdown: &str) -> Result { let fence = "```kez"; let Some(start) = markdown.find(fence) else { return Err(KezError::InvalidIdentity( "missing ```kez proof block".to_owned(), )); }; let body_start = start + fence.len(); let Some(end) = markdown[body_start..].find("```") else { return Err(KezError::InvalidIdentity( "unterminated ```kez proof block".to_owned(), )); }; let json = markdown[body_start..body_start + end].trim(); Ok(serde_json::from_str(json)?) } pub fn canonical_bytes(value: &T) -> Result> { serde_jcs::to_vec(value).map_err(|err| KezError::CanonicalJson(err.to_string())) } pub fn from_json(json: &str) -> Result { Ok(serde_json::from_str(json)?) } fn sign_jcs_schnorr_hex(payload: &T, signer: &NostrSecret) -> Result { let digest = Sha256::digest(canonical_bytes(payload)?); let msg = Message::from_digest_slice(&digest)?; let secp = Secp256k1::new(); let keypair = Keypair::from_secret_key(&secp, &signer.secret_key); let signature = secp.sign_schnorr_no_aux_rand(&msg, &keypair); Ok(hex::encode(signature.as_ref())) } /// Verify a `SignatureBlock` against an arbitrary payload. Dispatches on /// `signature.alg`. Used by `SignedClaim::verify` and /// `SignedSigchainEvent::verify` internally; downstream crates (e.g. the /// chat-server's handle-registration verifier) call it for non-claim /// payloads that share the envelope shape. pub fn verify_envelope( payload: &T, signature: &SignatureBlock, ) -> Result<()> { match signature.alg.as_str() { NOSTR_SCHNORR_ALG => { verify_jcs_schnorr_hex(payload, signature.key.value(), &signature.sig) } ED25519_SHA512_ALG => { let jcs = canonical_bytes(payload)?; verify_ed25519_hex(signature.key.value(), &jcs, &signature.sig) } other => Err(KezError::UnsupportedAlgorithm(other.to_owned())), } } fn verify_jcs_schnorr_hex(payload: &T, npub: &str, sig: &str) -> Result<()> { let public_key = decode_npub(npub)?; let signature = Signature::from_slice(&hex::decode(sig)?)?; let digest = Sha256::digest(canonical_bytes(payload)?); let msg = Message::from_digest_slice(&digest)?; let secp = Secp256k1::verification_only(); secp.verify_schnorr(&signature, &msg, &public_key)?; Ok(()) } /// Verify a raw ed25519 signature (no JCS / no envelope wrapping). /// /// `pubkey_hex` and `sig_hex` are lowercase-hex 32-byte and 64-byte values /// respectively. `message` is the exact bytes the signer signed over. /// Used by kez-chat-server for the per-request auth header — anywhere /// outside the kez.claim / kez.handle_registration envelopes. pub fn verify_ed25519_hex(pubkey_hex: &str, message: &[u8], sig_hex: &str) -> Result<()> { let pubkey_bytes: [u8; 32] = hex::decode(pubkey_hex)? .try_into() .map_err(|_| KezError::InvalidIdentity("ed25519 pubkey must be 32 bytes".to_owned()))?; let sig_bytes: [u8; 64] = hex::decode(sig_hex)? .try_into() .map_err(|_| KezError::InvalidSignature)?; let verifying_key = Ed25519VerifyingKey::from_bytes(&pubkey_bytes) .map_err(|_| KezError::InvalidSignature)?; let signature = Ed25519Signature::from_bytes(&sig_bytes); verifying_key .verify_strict(message, &signature) .map_err(|_| KezError::InvalidSignature) } fn validate_npub(npub: &str) -> Result<()> { decode_npub(npub).map(|_| ()) } fn validate_ed25519_hex(value: &str) -> Result<()> { if value.len() != 64 { return Err(KezError::InvalidIdentity(format!( "ed25519 pubkey must be 64 hex chars, got {} chars", value.len() ))); } if !value.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { return Err(KezError::InvalidIdentity(format!( "ed25519 pubkey must be lowercase hex: {value}" ))); } Ok(()) } fn decode_npub(npub: &str) -> Result { let (hrp, data, variant) = bech32::decode(npub)?; if hrp != "npub" || variant != Variant::Bech32 { return Err(KezError::InvalidNostrKey); } let bytes = Vec::::from_base32(&data)?; Ok(XOnlyPublicKey::from_slice(&bytes)?) } #[cfg(test)] mod tests { use super::*; #[test] fn signs_and_verifies_claim() { let secret = NostrSecret::generate(); let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); let subject = Identity::parse("dns:jason.example.com").unwrap(); let payload = ClaimPayload::new(subject, primary.clone(), Utc::now()); let signed = SignedClaim::sign(payload, &secret).unwrap(); let status = signed.verify().unwrap(); assert_eq!(status.primary, primary); assert_eq!(status.status, "valid"); } #[test] fn parses_bare_npub_as_nostr_identity() { let secret = NostrSecret::generate(); let npub = secret.npub(); let identity = Identity::parse(&npub).unwrap(); assert_eq!(identity.as_str(), format!("nostr:{npub}")); } #[test] fn round_trips_dns_txt_value() { let secret = NostrSecret::generate(); let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); let subject = Identity::parse("dns:jason.example.com").unwrap(); let signed = SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap(); let txt = dns_txt_value(&signed).unwrap(); let parsed = parse_dns_txt_value(&txt).unwrap(); assert_eq!(parsed, signed); } #[test] fn extracts_markdown_proof() { let secret = NostrSecret::generate(); let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); let subject = Identity::parse("github:jason").unwrap(); let signed = SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap(); let markdown = signed.to_markdown_proof().unwrap(); let parsed = extract_markdown_proof(&markdown).unwrap(); assert_eq!(parsed, signed); } #[test] fn round_trips_compact_claim() { let secret = NostrSecret::generate(); let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); let subject = Identity::parse("github:jason").unwrap(); let signed = SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap(); let compact = signed.to_compact().unwrap(); let parsed = decode_compact_claim(&compact).unwrap(); assert!(compact.starts_with(COMPACT_PROOF_PREFIX)); assert_eq!(parsed, signed); parsed.verify().unwrap(); } #[test] fn tampered_claim_fails_verification() { let secret = NostrSecret::generate(); let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); let subject = Identity::parse("github:jason").unwrap(); let mut signed = SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap(); signed.payload.subject = Identity::parse("github:mallory").unwrap(); assert!(signed.verify().is_err()); } #[test] fn identity_parse_rejects_invalid_inputs() { assert!(Identity::parse("").is_err(), "empty must fail"); assert!(Identity::parse(" ").is_err(), "whitespace-only must fail"); assert!(Identity::parse("no-colon").is_err(), "missing colon must fail"); assert!(Identity::parse(":missing-scheme").is_err()); assert!(Identity::parse("scheme:").is_err()); assert!( Identity::parse("nostr:not-a-real-npub").is_err(), "nostr scheme must validate the npub" ); } #[test] fn identity_parse_round_trips_scheme_and_value() { let id = Identity::parse("github:jason").unwrap(); assert_eq!(id.scheme(), "github"); assert_eq!(id.value(), "jason"); assert_eq!(id.as_str(), "github:jason"); } #[test] fn verify_rejects_unsupported_algorithm() { let secret = NostrSecret::generate(); let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); let subject = Identity::parse("github:jason").unwrap(); let mut signed = SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap(); signed.signature.alg = "made-up-suite".to_owned(); let err = signed.verify().unwrap_err(); assert!(matches!(err, KezError::UnsupportedAlgorithm(_))); } #[test] fn verify_rejects_signature_key_not_matching_primary() { let secret_a = NostrSecret::generate(); let secret_b = NostrSecret::generate(); let primary_a = Identity::parse(format!("nostr:{}", secret_a.npub())).unwrap(); let primary_b = Identity::parse(format!("nostr:{}", secret_b.npub())).unwrap(); let subject = Identity::parse("github:jason").unwrap(); let mut signed = SignedClaim::sign( ClaimPayload::new(subject, primary_a.clone(), Utc::now()), &secret_a, ) .unwrap(); // Swap the envelope's declared signing key to a different identity. signed.signature.key = primary_b; assert!(signed.verify().is_err()); } #[test] fn nostr_pubkey_hex_returns_32_byte_lowercase() { let secret = NostrSecret::generate(); let id = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); let hex_pk = nostr_pubkey_hex(&id).unwrap(); assert_eq!(hex_pk.len(), 64, "x-only pubkey is 32 bytes = 64 hex chars"); assert_eq!(hex_pk, hex_pk.to_lowercase()); } #[test] fn nostr_pubkey_hex_rejects_non_nostr() { let id = Identity::parse("github:jason").unwrap(); assert!(nostr_pubkey_hex(&id).is_err()); } #[test] fn dns_txt_name_requires_dns_scheme() { let dns = Identity::parse("dns:jason.example.com").unwrap(); assert_eq!(dns_txt_name(&dns).unwrap(), "_kez.jason.example.com"); let github = Identity::parse("github:jason").unwrap(); assert!(dns_txt_name(&github).is_err()); } #[test] fn decode_compact_claim_rejects_missing_prefix() { assert!(decode_compact_claim("hello").is_err()); assert!(decode_compact_claim("kez1:foo").is_err()); } #[test] fn extract_markdown_proof_rejects_missing_or_unterminated_fence() { assert!(extract_markdown_proof("no fence here").is_err()); assert!(extract_markdown_proof("```kez\n{ unterminated").is_err()); } #[test] fn ed25519_round_trips_seed_hex() { let secret = Ed25519Secret::generate(); let seed = secret.seed_hex(); let restored = Ed25519Secret::from_seed_hex(&seed).unwrap(); assert_eq!(restored.pubkey_hex(), secret.pubkey_hex()); assert_eq!(restored, secret); } #[test] fn ed25519_identity_is_lowercase_hex() { let secret = Ed25519Secret::generate(); let id = secret.identity().unwrap(); assert_eq!(id.scheme(), "ed25519"); assert_eq!(id.value().len(), 64); assert_eq!(id.value(), id.value().to_lowercase()); } #[test] fn identity_parse_validates_ed25519_format() { assert!(Identity::parse("ed25519:tooshort").is_err()); assert!(Identity::parse(&format!("ed25519:{}", "Z".repeat(64))).is_err()); assert!(Identity::parse(&format!("ed25519:{}", "AB".repeat(32))).is_err()); // uppercase assert!(Identity::parse(&format!("ed25519:{}", "ab".repeat(32))).is_ok()); } #[test] fn signs_and_verifies_ed25519_claim() { let secret = Ed25519Secret::generate(); let primary = secret.identity().unwrap(); let subject = Identity::parse("github:jason").unwrap(); let payload = ClaimPayload::new(subject.clone(), primary.clone(), Utc::now()); let signed = SignedClaim::sign_ed25519(payload, &secret).unwrap(); assert_eq!(signed.signature.alg, ED25519_SHA512_ALG); let status = signed.verify().unwrap(); assert_eq!(status.primary, primary); assert_eq!(status.verified, vec![subject]); assert_eq!(status.status, "valid"); } #[test] fn tampered_ed25519_claim_fails_verification() { let secret = Ed25519Secret::generate(); let primary = secret.identity().unwrap(); let subject = Identity::parse("github:jason").unwrap(); let mut signed = SignedClaim::sign_ed25519( ClaimPayload::new(subject, primary, Utc::now()), &secret, ) .unwrap(); signed.payload.subject = Identity::parse("github:mallory").unwrap(); assert!(signed.verify().is_err()); } #[test] fn ed25519_sign_with_unified_entry_point() { let secret = Ed25519Secret::generate(); let primary = secret.identity().unwrap(); let subject = Identity::parse("github:jason").unwrap(); let payload = ClaimPayload::new(subject, primary, Utc::now()); let signed = SignedClaim::sign_with(payload, Signer::Ed25519(&secret)).unwrap(); signed.verify().unwrap(); } // ── Sigchain ──────────────────────────────────────────────────────────── fn nostr_signer() -> (NostrSecret, Identity) { let s = NostrSecret::generate(); let id = Identity::parse(format!("nostr:{}", s.npub())).unwrap(); (s, id) } #[test] fn sigchain_appends_and_validates() { let (secret, primary) = nostr_signer(); let mut chain = Sigchain::new(primary.clone()); assert!(chain.is_empty()); assert_eq!(chain.next_seq(), 0); let subject = Identity::parse("github:jason").unwrap(); chain .sign_add(subject.clone(), None, Signer::Nostr(&secret)) .unwrap(); assert_eq!(chain.len(), 1); assert_eq!(chain.next_seq(), 1); assert!(chain.is_active(&subject)); assert!(!chain.is_revoked(&subject)); chain.validate().unwrap(); } #[test] fn sigchain_revoke_flips_is_active() { let (secret, primary) = nostr_signer(); let mut chain = Sigchain::new(primary); let subject = Identity::parse("github:jason").unwrap(); chain .sign_add(subject.clone(), None, Signer::Nostr(&secret)) .unwrap(); chain .sign_revoke(subject.clone(), Signer::Nostr(&secret)) .unwrap(); assert!(chain.is_revoked(&subject)); assert!(!chain.is_active(&subject)); chain.validate().unwrap(); } #[test] fn sigchain_rejects_wrong_primary() { let (secret_a, primary_a) = nostr_signer(); let (_secret_b, primary_b) = nostr_signer(); let mut chain = Sigchain::new(primary_a); // Build an event whose payload claims a different primary. let payload = SigchainEventPayload::new_add( primary_b, 0, None, Identity::parse("github:jason").unwrap(), None, Utc::now(), ); let signed = SignedSigchainEvent::sign(payload, &secret_a).unwrap(); let err = chain.append(signed).unwrap_err(); assert!(matches!(err, SigchainError::WrongPrimary { .. })); } #[test] fn sigchain_rejects_seq_skip() { let (secret, primary) = nostr_signer(); let mut chain = Sigchain::new(primary.clone()); chain .sign_add( Identity::parse("github:a").unwrap(), None, Signer::Nostr(&secret), ) .unwrap(); // Hand-craft an event with seq=2 (skipping seq=1) but correct prev. let head_hash = chain.head_hash().unwrap(); let payload = SigchainEventPayload::new_add( primary, 2, head_hash, Identity::parse("github:b").unwrap(), None, Utc::now(), ); let signed = SignedSigchainEvent::sign(payload, &secret).unwrap(); let err = chain.append(signed).unwrap_err(); assert!(matches!(err, SigchainError::SeqMismatch { .. })); } #[test] fn sigchain_rejects_bad_prev_hash() { let (secret, primary) = nostr_signer(); let mut chain = Sigchain::new(primary.clone()); chain .sign_add( Identity::parse("github:a").unwrap(), None, Signer::Nostr(&secret), ) .unwrap(); // seq=1 but with the wrong prev hash. let payload = SigchainEventPayload::new_add( primary, 1, Some("sha256:0000".to_owned()), Identity::parse("github:b").unwrap(), None, Utc::now(), ); let signed = SignedSigchainEvent::sign(payload, &secret).unwrap(); let err = chain.append(signed).unwrap_err(); assert!(matches!(err, SigchainError::PrevMismatch { .. })); } #[test] fn sigchain_round_trips_jsonl() { let (secret, primary) = nostr_signer(); let mut chain = Sigchain::new(primary); let subject = Identity::parse("github:jason").unwrap(); chain .sign_add(subject.clone(), None, Signer::Nostr(&secret)) .unwrap(); chain .sign_revoke(subject.clone(), Signer::Nostr(&secret)) .unwrap(); let jsonl = chain.to_jsonl().unwrap(); let restored = Sigchain::from_jsonl(&jsonl).unwrap(); assert_eq!(restored.events(), chain.events()); assert!(restored.is_revoked(&subject)); } #[test] fn sigchain_from_jsonl_detects_tamper() { let (secret, primary) = nostr_signer(); let mut chain = Sigchain::new(primary); chain .sign_add( Identity::parse("github:a").unwrap(), None, Signer::Nostr(&secret), ) .unwrap(); chain .sign_add( Identity::parse("github:b").unwrap(), None, Signer::Nostr(&secret), ) .unwrap(); let mut jsonl = chain.to_jsonl().unwrap(); // Flip a byte in the middle line's content. jsonl = jsonl.replacen("github:b", "github:c", 1); let err = Sigchain::from_jsonl(&jsonl).unwrap_err(); // Either signature breaks first, or prev-hash check catches re-serialization. assert!(matches!( err, SigchainError::BadSignature(_) | SigchainError::PrevMismatch { .. } )); } #[test] fn sigchain_round_trips_compact_bundle() { let (secret, primary) = nostr_signer(); let mut chain = Sigchain::new(primary); chain .sign_add( Identity::parse("github:jason").unwrap(), None, Signer::Nostr(&secret), ) .unwrap(); chain .sign_revoke( Identity::parse("github:jason").unwrap(), Signer::Nostr(&secret), ) .unwrap(); let compact = chain.to_compact_bundle().unwrap(); assert!(compact.starts_with(COMPACT_CHAIN_PREFIX)); let restored = Sigchain::from_compact_bundle(&compact).unwrap(); assert_eq!(restored.events(), chain.events()); } #[test] fn nostr_secret_pubkey_hex_matches_nostr_pubkey_hex_helper() { let s = NostrSecret::generate(); let id = Identity::parse(format!("nostr:{}", s.npub())).unwrap(); assert_eq!(s.pubkey_hex(), nostr_pubkey_hex(&id).unwrap()); } #[test] fn nostr_secret_sign_raw_produces_valid_64_byte_sig() { let s = NostrSecret::generate(); let digest = [42u8; 32]; let sig = s.sign_raw(&digest).unwrap(); assert_eq!(sig.len(), 64); // Verifying with secp256k1 directly proves the sig is correct. let sig_obj = Signature::from_slice(&sig).unwrap(); let pubkey = decode_npub(&s.npub()).unwrap(); let msg = Message::from_digest_slice(&digest).unwrap(); let secp = Secp256k1::verification_only(); secp.verify_schnorr(&sig_obj, &msg, &pubkey).unwrap(); } #[test] fn sigchain_works_with_ed25519_signer() { let secret = Ed25519Secret::generate(); let primary = secret.identity().unwrap(); let mut chain = Sigchain::new(primary); let subject = Identity::parse("github:jason").unwrap(); chain .sign_add(subject.clone(), None, Signer::Ed25519(&secret)) .unwrap(); assert!(chain.is_active(&subject)); chain.validate().unwrap(); } }