From 0494b1e49b8f9c844ecb891dd171b92e19ea503f Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 18:12:13 +0100 Subject: [PATCH 01/19] feat(auths-keri): model Ed25519 verkey transferability (accept B/Ed25519N witness keys) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KERI witnesses are conventionally non-transferable identifiers: their verkey carries the CESR code B (Ed25519N) rather than D (Ed25519). Previously KeriPublicKey::Ed25519 was a bare [u8; 32] tuple whose is_transferable() was hardcoded to true, and parse() rejected the B code outright — so a receipt signed by a standard witness could not be verified even though the raw key and Ed25519 algorithm are identical to a working D key. Mirror the existing P-256 1AAJ/1AAI modeling on Ed25519: the variant now carries the transferability recorded from its code (D = transferable, B = not), so parse() accepts B, is_transferable()/cesr_prefix() are truthful, and to_qb64() round-trips B correctly. verify_signature() is curve-only and unchanged. Readers that built or matched the old tuple variant are updated; raw-byte constructors route through KeriPublicKey::ed25519() (one construction path). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/auths-id/src/keri/shared_kel.rs | 6 +- crates/auths-keri/src/crypto.rs | 14 +- crates/auths-keri/src/keys.rs | 122 +++++++++++++----- crates/auths-pairing-daemon/src/auth.rs | 17 +-- crates/auths-pairing-daemon/src/handlers.rs | 6 +- crates/auths-pairing-protocol/src/response.rs | 4 +- crates/auths-pairing-protocol/src/token.rs | 4 +- .../auths-sdk/src/domains/compliance/dsse.rs | 2 +- crates/auths-sdk/src/keri/resolver.rs | 4 +- 9 files changed, 121 insertions(+), 58 deletions(-) diff --git a/crates/auths-id/src/keri/shared_kel.rs b/crates/auths-id/src/keri/shared_kel.rs index 55cbb1f4..3e1a06ed 100644 --- a/crates/auths-id/src/keri/shared_kel.rs +++ b/crates/auths-id/src/keri/shared_kel.rs @@ -262,7 +262,9 @@ pub fn controller_from_parts( let arr: [u8; 32] = verkey_bytes.as_slice().try_into().map_err(|_| { SharedKelError::EventConstruction("Ed25519 verkey must be 32 bytes".into()) })?; - KeriPublicKey::Ed25519(arr) + KeriPublicKey::ed25519(&arr).map_err(|e| { + SharedKelError::EventConstruction(format!("Ed25519 verkey invalid: {e}")) + })? } CurveType::P256 => { let arr: [u8; 33] = verkey_bytes.as_slice().try_into().map_err(|_| { @@ -294,7 +296,7 @@ mod tests { fn controller(did_str: &str) -> ControllerDescriptor { ControllerDescriptor { identity_did: did(did_str), - current_verkey: KeriPublicKey::Ed25519([0u8; 32]), + current_verkey: KeriPublicKey::ed25519(&[0u8; 32]).unwrap(), } } diff --git a/crates/auths-keri/src/crypto.rs b/crates/auths-keri/src/crypto.rs index b94adce2..849669e4 100644 --- a/crates/auths-keri/src/crypto.rs +++ b/crates/auths-keri/src/crypto.rs @@ -24,7 +24,7 @@ use crate::types::Said; /// Usage: /// ``` /// use auths_keri::{compute_next_commitment, KeriPublicKey}; -/// let commitment = compute_next_commitment(&KeriPublicKey::Ed25519([0u8; 32])); +/// let commitment = compute_next_commitment(&KeriPublicKey::ed25519(&[0u8; 32]).unwrap()); /// assert_eq!(commitment.as_str().len(), 44); /// assert!(commitment.as_str().starts_with('E')); /// ``` @@ -52,10 +52,10 @@ pub fn compute_next_commitment(key: &KeriPublicKey) -> Said { /// Usage: /// ``` /// use auths_keri::{compute_next_commitment, verify_commitment, KeriPublicKey}; -/// let key = KeriPublicKey::Ed25519([1u8; 32]); +/// let key = KeriPublicKey::ed25519(&[1u8; 32]).unwrap(); /// let c = compute_next_commitment(&key); /// assert!(verify_commitment(&key, &c)); -/// assert!(!verify_commitment(&KeriPublicKey::Ed25519([2u8; 32]), &c)); +/// assert!(!verify_commitment(&KeriPublicKey::ed25519(&[2u8; 32]).unwrap(), &c)); /// ``` // Defense-in-depth: both values are derived from public data, but constant-time // comparison prevents timing side-channels on commitment verification. @@ -74,18 +74,18 @@ mod tests { #[test] fn commitment_verification_works() { - let key = KeriPublicKey::Ed25519([1u8; 32]); + let key = KeriPublicKey::ed25519(&[1u8; 32]).unwrap(); let commitment = compute_next_commitment(&key); assert!(verify_commitment(&key, &commitment)); assert!(!verify_commitment( - &KeriPublicKey::Ed25519([2u8; 32]), + &KeriPublicKey::ed25519(&[2u8; 32]).unwrap(), &commitment )); } #[test] fn commitment_is_deterministic() { - let key = KeriPublicKey::Ed25519([42u8; 32]); + let key = KeriPublicKey::ed25519(&[42u8; 32]).unwrap(); let c1 = compute_next_commitment(&key); let c2 = compute_next_commitment(&key); assert_eq!(c1, c2); @@ -94,7 +94,7 @@ mod tests { #[test] fn commitment_has_correct_length() { - let commitment = compute_next_commitment(&KeriPublicKey::Ed25519([0u8; 32])); + let commitment = compute_next_commitment(&KeriPublicKey::ed25519(&[0u8; 32]).unwrap()); // 'E' + 43 chars of base64url assert_eq!(commitment.as_str().len(), 44); } diff --git a/crates/auths-keri/src/keys.rs b/crates/auths-keri/src/keys.rs index 18f31d3a..11b69b08 100644 --- a/crates/auths-keri/src/keys.rs +++ b/crates/auths-keri/src/keys.rs @@ -4,6 +4,12 @@ //! Ed25519: 'D' prefix (transferable) / 'B' (non-transferable) + base64url(32 bytes). //! P-256: '1AAJ' prefix (transferable) / '1AAI' (non-transferable) + base64url(33 bytes). //! +//! Both curves carry the transferability recorded from their CESR code: rotating +//! identity keys are transferable (`D` / `1AAJ`), while keys pinned to one +//! incepting event — most notably KERI witnesses — are non-transferable +//! (`B` / `1AAI`). The raw bytes and signature algorithm are identical across the +//! pair; only the code (and thus the rotation semantics) differ. +//! //! Per the CESR master code table (cesride / keripy `MatterCodex`): //! `1AAJ` = `ECDSA_256r1` = transferable secp256r1 verification key; //! `1AAI` = `ECDSA_256r1N` = the non-transferable variant. This mirrors the @@ -46,7 +52,7 @@ impl auths_crypto::AuthsErrorInfo for KeriDecodeError { fn suggestion(&self) -> Option<&'static str> { match self { Self::UnsupportedKeyType(_) => Some( - "Supported verkey prefixes: 'D'/'B' (Ed25519), '1AAJ'/'1AAI' (P-256 transferable/non-transferable).", + "Supported verkey prefixes: 'D'/'B' (Ed25519 transferable/non-transferable), '1AAJ'/'1AAI' (P-256 transferable/non-transferable).", ), Self::EmptyInput => Some("Provide a non-empty KERI-encoded key string"), _ => None, @@ -72,7 +78,17 @@ impl auths_crypto::AuthsErrorInfo for KeriDecodeError { #[derive(Debug, Clone, PartialEq, Eq)] pub enum KeriPublicKey { /// Ed25519 public key (32 bytes). - Ed25519([u8; 32]), + /// + /// `transferable` records which CESR code qualified it: `D` (true) is the + /// rotating verkey code; `B` (false) is the non-transferable one used by + /// KERI witnesses. Both decode to the same 32-byte key and verify with the + /// same Ed25519 algorithm. + Ed25519 { + /// Raw Ed25519 public key (32 bytes). + key: [u8; 32], + /// Whether the key was qualified with the transferable code (`D`). + transferable: bool, + }, /// P-256 compressed public key (33 bytes, SEC1: 0x02/0x03 + x-coordinate). /// /// `transferable` records which CESR code qualified it: `1AAJ` (true) is @@ -88,17 +104,16 @@ pub enum KeriPublicKey { impl KeriPublicKey { /// Parse a CESR-qualified key string, dispatching on the derivation code prefix. /// - /// - `D` prefix → Ed25519 (32 bytes) + /// - `D` prefix → Ed25519 transferable (32 bytes) + /// - `B` prefix → Ed25519 non-transferable (32 bytes) — the KERI witness code /// - `1AAJ` prefix → P-256 transferable (33 bytes compressed) /// - `1AAI` prefix → P-256 non-transferable (33 bytes compressed) /// - /// Per the CESR master code table, `1AAJ`/`1AAI` are the transferable / - /// non-transferable secp256r1 verkey codes (the P-256 analogue of Ed25519 - /// `D`/`B`). Both decode to the same 33-byte compressed point. - /// - /// Keys qualified with a non-transferable Ed25519 code (`B`) or any other - /// matter code return `Err(UnsupportedKeyType)`; malformed CESR returns - /// `Err(DecodeError)`. + /// Per the CESR master code table, `D`/`B` and `1AAJ`/`1AAI` are the + /// transferable / non-transferable verkey codes for each curve. Both members + /// of a pair decode to the same raw key; only the recorded transferability + /// (and thus the rotation semantics) differ. Any other matter code returns + /// `Err(UnsupportedKeyType)`; malformed CESR returns `Err(DecodeError)`. pub fn parse(encoded: &str) -> Result { if encoded.is_empty() { return Err(KeriDecodeError::EmptyInput); @@ -107,7 +122,7 @@ impl KeriPublicKey { let (bytes, code) = crate::cesr_encode::decode_verkey(encoded)?; use cesride::matter::Codex; - if code.as_str() == Codex::Ed25519 { + if code.as_str() == Codex::Ed25519 || code.as_str() == Codex::Ed25519N { let arr: [u8; 32] = bytes .as_slice() @@ -116,7 +131,10 @@ impl KeriPublicKey { expected: 32, actual: bytes.len(), })?; - Ok(KeriPublicKey::Ed25519(arr)) + Ok(KeriPublicKey::Ed25519 { + key: arr, + transferable: code.as_str() == Codex::Ed25519, + }) } else if code.as_str() == Codex::ECDSA_256r1 || code.as_str() == Codex::ECDSA_256r1N { let arr: [u8; 33] = bytes @@ -138,7 +156,7 @@ impl KeriPublicKey { /// Returns the raw public key bytes (32 for Ed25519, 33 for P-256). pub fn as_bytes(&self) -> &[u8] { match self { - KeriPublicKey::Ed25519(b) => b, + KeriPublicKey::Ed25519 { key, .. } => key, KeriPublicKey::P256 { key, .. } => key, } } @@ -146,7 +164,7 @@ impl KeriPublicKey { /// Consume self and return the raw bytes as a Vec. pub fn into_bytes(self) -> Vec { match self { - KeriPublicKey::Ed25519(b) => b.to_vec(), + KeriPublicKey::Ed25519 { key, .. } => key.to_vec(), KeriPublicKey::P256 { key, .. } => key.to_vec(), } } @@ -154,29 +172,35 @@ impl KeriPublicKey { /// Returns the curve type. pub fn curve(&self) -> auths_crypto::CurveType { match self { - KeriPublicKey::Ed25519(_) => auths_crypto::CurveType::Ed25519, + KeriPublicKey::Ed25519 { .. } => auths_crypto::CurveType::Ed25519, KeriPublicKey::P256 { .. } => auths_crypto::CurveType::P256, } } /// Whether this key is transferable (rotating). /// - /// Ed25519 keys parsed via the `D` code are transferable. P-256 keys carry - /// the transferability recorded from their `1AAJ`/`1AAI` code. + /// Each variant carries the transferability recorded from its CESR code: + /// Ed25519 from `D`/`B`, P-256 from `1AAJ`/`1AAI`. pub fn is_transferable(&self) -> bool { match self { - KeriPublicKey::Ed25519(_) => true, - KeriPublicKey::P256 { transferable, .. } => *transferable, + KeriPublicKey::Ed25519 { transferable, .. } + | KeriPublicKey::P256 { transferable, .. } => *transferable, } } /// Returns the CESR derivation code prefix for this key type. /// - /// `D` for Ed25519; `1AAJ` for a transferable P-256 verkey and `1AAI` for a - /// non-transferable one (per the CESR master code table). + /// `D`/`B` for transferable / non-transferable Ed25519; `1AAJ`/`1AAI` for + /// transferable / non-transferable P-256 (per the CESR master code table). pub fn cesr_prefix(&self) -> &'static str { match self { - KeriPublicKey::Ed25519(_) => "D", + KeriPublicKey::Ed25519 { + transferable: true, .. + } => "D", + KeriPublicKey::Ed25519 { + transferable: false, + .. + } => "B", KeriPublicKey::P256 { transferable: true, .. } => "1AAJ", @@ -211,7 +235,7 @@ impl KeriPublicKey { /// ``` /// use auths_keri::KeriPublicKey; /// let key = KeriPublicKey::ed25519(&[0u8; 32]).unwrap(); - /// assert!(matches!(key, KeriPublicKey::Ed25519(_))); + /// assert!(matches!(key, KeriPublicKey::Ed25519 { .. })); /// ``` pub fn ed25519(bytes: &[u8]) -> Result { let arr: [u8; 32] = bytes @@ -220,7 +244,10 @@ impl KeriPublicKey { expected: 32, actual: bytes.len(), })?; - Ok(KeriPublicKey::Ed25519(arr)) + Ok(KeriPublicKey::Ed25519 { + key: arr, + transferable: true, + }) } /// Construct a transferable verkey from raw bytes plus an explicit curve. @@ -269,7 +296,7 @@ impl KeriPublicKey { /// doesn't need to know about specific algorithms. pub fn verify_signature(&self, message: &[u8], signature: &[u8]) -> Result<(), String> { match self { - KeriPublicKey::Ed25519(pk) => { + KeriPublicKey::Ed25519 { key: pk, .. } => { use ring::signature::UnparsedPublicKey; let verifier = UnparsedPublicKey::new(&ring::signature::ED25519, pk); verifier @@ -299,7 +326,8 @@ mod tests { fn parse_ed25519_all_zeros() { let key = KeriPublicKey::parse("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap(); assert_eq!(key.as_bytes(), &[0u8; 32]); - assert!(matches!(key, KeriPublicKey::Ed25519(_))); + assert!(matches!(key, KeriPublicKey::Ed25519 { .. })); + assert!(key.is_transferable()); assert_eq!(key.curve(), auths_crypto::CurveType::Ed25519); assert_eq!(key.cesr_prefix(), "D"); } @@ -368,14 +396,41 @@ mod tests { } #[test] - fn rejects_non_transferable_ed25519_code() { - // `B` (Ed25519N) is valid CESR, but this enum models only transferable - // Ed25519, so it must surface as UnsupportedKeyType rather than mis-decode. + fn parses_non_transferable_ed25519_code() { + // `B` (Ed25519N) is the standard KERI witness key code. It decodes to the + // same 32-byte key as `D`; only transferability and the prefix differ. let b_code = crate::cesr_encode::encode_verkey(&[0u8; 32], cesride::matter::Codex::Ed25519N) .unwrap(); - let err = KeriPublicKey::parse(&b_code).unwrap_err(); - assert!(matches!(err, KeriDecodeError::UnsupportedKeyType(_))); + let key = KeriPublicKey::parse(&b_code).unwrap(); + assert!(matches!( + key, + KeriPublicKey::Ed25519 { + transferable: false, + .. + } + )); + assert_eq!(key.as_bytes(), &[0u8; 32]); + assert_eq!(key.curve(), auths_crypto::CurveType::Ed25519); + assert!(!key.is_transferable()); + assert_eq!(key.cesr_prefix(), "B"); + } + + #[test] + fn parses_both_d_and_b_ed25519() { + // Both Ed25519 codes decode to the same 32-byte key; transferability and + // the round-tripped prefix differ — mirroring the P-256 1AAJ/1AAI pair. + let d = + crate::cesr_encode::encode_verkey(&[7u8; 32], cesride::matter::Codex::Ed25519).unwrap(); + let b = crate::cesr_encode::encode_verkey(&[7u8; 32], cesride::matter::Codex::Ed25519N) + .unwrap(); + let transferable = KeriPublicKey::parse(&d).unwrap(); + let non_transferable = KeriPublicKey::parse(&b).unwrap(); + assert_eq!(transferable.as_bytes(), non_transferable.as_bytes()); + assert!(transferable.is_transferable()); + assert!(!non_transferable.is_transferable()); + assert_eq!(transferable.cesr_prefix(), "D"); + assert_eq!(non_transferable.cesr_prefix(), "B"); } #[test] @@ -404,9 +459,8 @@ mod tests { assert!(matches!(err, KeriDecodeError::DecodeError(_))); } - // Backward compatibility: the old API had `as_bytes()` returning `&[u8; 32]`. - // The new API returns `&[u8]`. Test that Ed25519 keys still work with the - // 32-byte slice pattern. + // `as_bytes()` returns `&[u8]`; Ed25519 keys must still convert cleanly to a + // 32-byte array for callers that need the fixed-width form. #[test] fn ed25519_as_bytes_is_32() { let key = KeriPublicKey::parse("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap(); diff --git a/crates/auths-pairing-daemon/src/auth.rs b/crates/auths-pairing-daemon/src/auth.rs index 43f407f6..fbbd29be 100644 --- a/crates/auths-pairing-daemon/src/auth.rs +++ b/crates/auths-pairing-daemon/src/auth.rs @@ -270,7 +270,7 @@ pub fn verify_sig( pub fn pubkey_kid(pk: &auths_keri::KeriPublicKey) -> [u8; 16] { let mut h = Sha256::new(); match pk { - auths_keri::KeriPublicKey::Ed25519(arr) => h.update(arr), + auths_keri::KeriPublicKey::Ed25519 { key, .. } => h.update(key), auths_keri::KeriPublicKey::P256 { key, .. } => h.update(key), } let full = h.finalize(); @@ -389,9 +389,10 @@ impl PubkeyBinding { fn keri_pubkeys_equal(a: &auths_keri::KeriPublicKey, b: &auths_keri::KeriPublicKey) -> bool { match (a, b) { - (auths_keri::KeriPublicKey::Ed25519(x), auths_keri::KeriPublicKey::Ed25519(y)) => { - bool::from(x[..].ct_eq(&y[..])) - } + ( + auths_keri::KeriPublicKey::Ed25519 { key: x, .. }, + auths_keri::KeriPublicKey::Ed25519 { key: y, .. }, + ) => bool::from(x[..].ct_eq(&y[..])), ( auths_keri::KeriPublicKey::P256 { key: x, .. }, auths_keri::KeriPublicKey::P256 { key: y, .. }, @@ -528,7 +529,7 @@ mod tests { #[test] fn pubkey_binding_first_sighting_binds() { let b = PubkeyBinding::new(); - let pk = auths_keri::KeriPublicKey::Ed25519([0x11; 32]); + let pk = auths_keri::KeriPublicKey::ed25519(&[0x11; 32]).unwrap(); assert!(b.bind_or_match(&pk).is_ok()); // Same key again → OK. assert!(b.bind_or_match(&pk).is_ok()); @@ -537,8 +538,8 @@ mod tests { #[test] fn pubkey_binding_divergent_key_rejected() { let b = PubkeyBinding::new(); - let pk1 = auths_keri::KeriPublicKey::Ed25519([0x11; 32]); - let pk2 = auths_keri::KeriPublicKey::Ed25519([0x22; 32]); + let pk1 = auths_keri::KeriPublicKey::ed25519(&[0x11; 32]).unwrap(); + let pk2 = auths_keri::KeriPublicKey::ed25519(&[0x22; 32]).unwrap(); b.bind_or_match(&pk1).unwrap(); let r = b.bind_or_match(&pk2); assert!(matches!(r, Err(AuthError::KeyBindingMismatch))); @@ -547,7 +548,7 @@ mod tests { #[test] fn pubkey_binding_different_curve_rejected() { let b = PubkeyBinding::new(); - let pk_ed = auths_keri::KeriPublicKey::Ed25519([0x11; 32]); + let pk_ed = auths_keri::KeriPublicKey::ed25519(&[0x11; 32]).unwrap(); let pk_p256 = auths_keri::KeriPublicKey::P256 { key: [0x11; 33], transferable: true, diff --git a/crates/auths-pairing-daemon/src/handlers.rs b/crates/auths-pairing-daemon/src/handlers.rs index a53ae37a..96ad722a 100644 --- a/crates/auths-pairing-daemon/src/handlers.rs +++ b/crates/auths-pairing-daemon/src/handlers.rs @@ -199,7 +199,7 @@ fn verify_subkey_chain_if_present( // session carrying a chain is a client bug; reject it loudly. let subkey_compressed: &[u8] = match subkey_pubkey { auths_keri::KeriPublicKey::P256 { key, .. } => key.as_slice(), - auths_keri::KeriPublicKey::Ed25519(_) => { + auths_keri::KeriPublicKey::Ed25519 { .. } => { return Err(DaemonError::InvalidSubkeyChain { reason: "chain only supported for P-256 subkey", }); @@ -458,7 +458,7 @@ pub(crate) fn decode_device_pubkey( }); } let arr: [u8; 32] = bytes.try_into().map_err(|_| DaemonError::UnauthorizedSig)?; - Ok(auths_keri::KeriPublicKey::Ed25519(arr)) + auths_keri::KeriPublicKey::ed25519(&arr).map_err(|_| DaemonError::UnauthorizedSig) } CurveTag::P256 => { if bytes.len() != 33 { @@ -520,7 +520,7 @@ mod decode_device_pubkey_tests { fn routes_ed25519_by_curve_tag() { let r = req(&[0xAB; 32], CurveTag::Ed25519); let key = decode_device_pubkey(&r).expect("32-byte Ed25519 must accept"); - assert!(matches!(key, auths_keri::KeriPublicKey::Ed25519(_))); + assert!(matches!(key, auths_keri::KeriPublicKey::Ed25519 { .. })); } #[test] diff --git a/crates/auths-pairing-protocol/src/response.rs b/crates/auths-pairing-protocol/src/response.rs index 6df6fd19..01e11221 100644 --- a/crates/auths-pairing-protocol/src/response.rs +++ b/crates/auths-pairing-protocol/src/response.rs @@ -202,7 +202,9 @@ fn build_keri_public_key(curve: CurveType, bytes: &[u8]) -> Result { let arr: [u8; 33] = bytes.try_into().map_err(|_| { diff --git a/crates/auths-pairing-protocol/src/token.rs b/crates/auths-pairing-protocol/src/token.rs index bd7b1492..ae252381 100644 --- a/crates/auths-pairing-protocol/src/token.rs +++ b/crates/auths-pairing-protocol/src/token.rs @@ -307,7 +307,9 @@ impl PairingSession { device_signing_pubkey.len() )) })?; - KeriPublicKey::Ed25519(arr) + KeriPublicKey::ed25519(&arr).map_err(|e| { + ProtocolError::KeyExchangeFailed(format!("Ed25519 pubkey invalid: {e}")) + })? } CurveType::P256 => { let arr: [u8; 33] = device_signing_pubkey.try_into().map_err(|_| { diff --git a/crates/auths-sdk/src/domains/compliance/dsse.rs b/crates/auths-sdk/src/domains/compliance/dsse.rs index c31124a6..58b36694 100644 --- a/crates/auths-sdk/src/domains/compliance/dsse.rs +++ b/crates/auths-sdk/src/domains/compliance/dsse.rs @@ -242,7 +242,7 @@ pub fn verify_signed_evidence_pack_offline( let org_key = KeriPublicKey::parse(cesr.as_str()) .map_err(|e| ComplianceQueryError::Decode(format!("org verkey decode: {e}")))?; let org_curve = match org_key { - KeriPublicKey::Ed25519(_) => CurveType::Ed25519, + KeriPublicKey::Ed25519 { .. } => CurveType::Ed25519, KeriPublicKey::P256 { .. } => CurveType::P256, }; envelope.verify(org_key.as_bytes(), org_curve)?; diff --git a/crates/auths-sdk/src/keri/resolver.rs b/crates/auths-sdk/src/keri/resolver.rs index c30335b8..304c471d 100644 --- a/crates/auths-sdk/src/keri/resolver.rs +++ b/crates/auths-sdk/src/keri/resolver.rs @@ -200,7 +200,9 @@ pub fn resolve_current_public_key( reason: e.to_string(), })?; let (bytes, curve) = match parsed { - auths_keri::KeriPublicKey::Ed25519(pk) => (pk.to_vec(), auths_crypto::CurveType::Ed25519), + auths_keri::KeriPublicKey::Ed25519 { key: pk, .. } => { + (pk.to_vec(), auths_crypto::CurveType::Ed25519) + } auths_keri::KeriPublicKey::P256 { key, .. } => { (key.to_vec(), auths_crypto::CurveType::P256) } From b470abafb7d0ab755d26cca7b87d97d5aa3bcb6c Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 18:38:36 +0100 Subject: [PATCH 02/19] feat(auths-keri): emit/ingest the KERI key-state notice wire record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auths-keri carried only an auths-internal KeyStateNotice ({version,t:"ksn",state,dt}), not the canonical KERI key-state record ({vn,i,s,p,d,f,dt,et,kt,k,nt,n,bt,b,c,ee,di}) that keripy/keriox publish and consume — so a thin client could neither read a peer's key state nor publish one a peer could read. Add a typed KeyStateRecord beside the internal envelope: the KERI wire shape, reusing the existing KeriSequence (lowercase-hex) and Threshold (hex/clause) serializers and an `ee` establishment sub-record {s,d,br,ba}. from_kel replays a KEL into the record (emit); into_key_state projects a peer's record back to KeyState (ingest) and is total — a parsed record already carries every field a KeyState needs. The `auths key-state` CLI (alias `ksn`) wires both directions: --from-kel emits, --ingest consumes. The emitted record is byte-identical to keripy's reference KeyStateRecord (same field order, labels, values), so an auths key-state reads in keripy/keriox and theirs ingests here. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/auths-cli/src/cli.rs | 3 + crates/auths-cli/src/commands/key_state.rs | 88 +++++++ crates/auths-cli/src/commands/mod.rs | 1 + crates/auths-cli/src/main.rs | 1 + crates/auths-keri/src/ksn.rs | 269 +++++++++++++++++++++ crates/auths-keri/src/lib.rs | 5 +- 6 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 crates/auths-cli/src/commands/key_state.rs diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index 0c5b484d..17a63736 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -25,6 +25,7 @@ use crate::commands::error_lookup::ErrorLookupCommand; use crate::commands::id::IdCommand; use crate::commands::init::InitCommand; use crate::commands::key::KeyCommand; +use crate::commands::key_state::KeyStateCommand; use crate::commands::learn::LearnCommand; use crate::commands::log::LogCommand; use crate::commands::multi_sig::MultiSigCommand; @@ -122,6 +123,8 @@ pub enum RootCommand { Device(DeviceCommand), #[command(hide = true)] Key(KeyCommand), + #[command(hide = true, name = "key-state")] + KeyState(KeyStateCommand), #[command(hide = true)] Approval(ApprovalCommand), #[command(hide = true)] diff --git a/crates/auths-cli/src/commands/key_state.rs b/crates/auths-cli/src/commands/key_state.rs new file mode 100644 index 00000000..11061ff2 --- /dev/null +++ b/crates/auths-cli/src/commands/key_state.rs @@ -0,0 +1,88 @@ +//! `auths key-state` — emit or ingest a KERI-conformant key-state notice (`ksn`). +//! +//! A key-state notice lets a thin client trust an identity's current key-state +//! without replaying its whole KEL. This command speaks the **KERI wire shape** +//! (`KeyStateRecord`: `{vn,i,s,p,d,f,dt,et,kt,k,nt,n,bt,b,c,ee,di}`), so a record +//! auths emits reads in keripy/keriox and a record those peers publish ingests +//! here. The crypto/wire definition lives in `auths-keri::ksn`; this is a thin +//! CLI adapter over it. + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, anyhow}; +use auths_keri::{KeyStateRecord, TrustedKel, parse_kel_json}; +use auths_utils::path::expand_tilde; +use clap::Parser; + +use crate::config::CliConfig; + +/// Emit or ingest a KERI-conformant key-state notice (`ksn`). +#[derive(Parser, Debug, Clone)] +#[command( + about = "Emit or ingest a KERI key-state notice (ksn) — interoperable with keripy/keriox", + visible_alias = "ksn", + after_help = "Examples: + auths key-state --from-kel kel.json # emit a KERI ksn a peer can read + auths key-state --ingest ksn.json # consume a keripy/keriox ksn" +)] +pub struct KeyStateCommand { + /// Replay this KEL file and emit its current key-state as a KERI `ksn` record + /// (the shape a keripy/keriox peer can read). + #[clap(long, value_name = "KEL.json")] + pub from_kel: Option, + + /// Ingest a KERI `ksn` record from this file (the shape a keripy/keriox peer + /// publishes) and print the resolved key-state. + #[clap(long, value_name = "KSN.json", conflicts_with = "from_kel")] + pub ingest: Option, + + /// Timestamp (RFC 3339) to stamp an emitted notice with. Defaults to the + /// epoch so emission stays deterministic; pass the real `now` to publish. + #[clap(long, default_value = "1970-01-01T00:00:00+00:00")] + pub dt: String, +} + +impl KeyStateCommand { + /// Run the command (emit or ingest). + pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { + match (&self.from_kel, &self.ingest) { + (Some(kel_path), None) => self.emit(kel_path), + (None, Some(ksn_path)) => self.ingest_record(ksn_path), + (Some(_), Some(_)) => { + unreachable!("clap conflicts_with guarantees mutual exclusion") + } + (None, None) => Err(anyhow!( + "key-state needs --from-kel (emit) or --ingest (consume)" + )), + } + } + + /// Replay a KEL file and print its current key-state as a KERI `ksn` record. + fn emit(&self, kel_path: &Path) -> Result<()> { + let path = expand_tilde(kel_path)?; + let json = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read KEL {}: {e}", path.display()))?; + let events = parse_kel_json(&json).map_err(|e| anyhow!("parse KEL: {e}"))?; + // A KEL file the operator hands us is a local, self-owned artifact — the + // reviewable trust assertion that structural replay requires. + let state = TrustedKel::from_trusted_source(&events) + .replay() + .map_err(|e| anyhow!("replay KEL: {e}"))?; + let record = KeyStateRecord::from_kel(&events, &state, self.dt.clone()) + .ok_or_else(|| anyhow!("KEL is empty — no key-state to notice"))?; + println!("{}", serde_json::to_string_pretty(&record)?); + Ok(()) + } + + /// Ingest a KERI `ksn` record and print the resolved auths key-state. + fn ingest_record(&self, ksn_path: &Path) -> Result<()> { + let path = expand_tilde(ksn_path)?; + let json = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read ksn {}: {e}", path.display()))?; + let record: KeyStateRecord = + serde_json::from_str(&json).map_err(|e| anyhow!("parse KERI ksn: {e}"))?; + let state = record.into_key_state(); + println!("{}", serde_json::to_string_pretty(&state)?); + Ok(()) + } +} diff --git a/crates/auths-cli/src/commands/mod.rs b/crates/auths-cli/src/commands/mod.rs index 75848594..846a683c 100644 --- a/crates/auths-cli/src/commands/mod.rs +++ b/crates/auths-cli/src/commands/mod.rs @@ -26,6 +26,7 @@ pub mod index; pub mod init; pub mod key; pub mod key_detect; +pub mod key_state; pub mod learn; pub mod log; pub mod multi_sig; diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index fb226900..089fa892 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -106,6 +106,7 @@ fn run() -> Result<()> { RootCommand::Id(cmd) => cmd.execute(&ctx), RootCommand::Device(cmd) => cmd.execute(&ctx), RootCommand::Key(cmd) => cmd.execute(&ctx), + RootCommand::KeyState(cmd) => cmd.execute(&ctx), RootCommand::Approval(cmd) => cmd.execute(&ctx), RootCommand::Policy(cmd) => cmd.execute(&ctx), RootCommand::Namespace(cmd) => cmd.execute(&ctx), diff --git a/crates/auths-keri/src/ksn.rs b/crates/auths-keri/src/ksn.rs index d3e13a16..86570603 100644 --- a/crates/auths-keri/src/ksn.rs +++ b/crates/auths-keri/src/ksn.rs @@ -20,6 +20,8 @@ use serde::{Deserialize, Serialize}; +use crate::events::Event; +use crate::types::{ConfigTrait, Prefix, Said, Threshold}; use crate::witness::StoredReceipt; use crate::witness::agreement::{AgreementStatus, WitnessAgreement}; use crate::{CesrKey, KeyState}; @@ -30,6 +32,179 @@ pub const KSN_VERSION: u32 = 1; /// The `t` discriminator for a Key-State Notice. pub const KSN_TYPE: &str = "ksn"; +/// The KERI protocol major/minor version a key-state record reports in `vn`. +/// Matches the `KERI10` wire generation (keripy `KeyStateRecord.vn == [1, 0]`). +pub const KERI_KEY_STATE_VERSION: [u32; 2] = [1, 0]; + +/// The latest establishment-event summary carried in a [`KeyStateRecord`] `ee` +/// field: the sequence and SAID of the most recent `icp`/`rot`/`dip`/`drt`, plus +/// the witnesses cut (`br`) and added (`ba`) by that event. +/// +/// Mirrors keripy's `KeyStateRecord.ee` sub-record (`{s, d, br, ba}`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LatestEstablishmentEvent { + /// Sequence number of the latest establishment event, lowercase-hex (`"0"`). + pub s: String, + /// SAID of the latest establishment event. + pub d: Said, + /// Witnesses removed by the latest establishment event (rotation cuts). + #[serde(default)] + pub br: Vec, + /// Witnesses added by the latest establishment event (rotation adds). + #[serde(default)] + pub ba: Vec, +} + +/// A **KERI-conformant key-state notice** — the wire record keripy emits as a +/// `ksn`/`rpy` reply and persists as `KeyStateRecord`. +/// +/// This is the byte-interoperable counterpart to the auths-internal +/// [`KeyStateNotice`]: where `KeyStateNotice` is an auths-only envelope around a +/// [`KeyState`], `KeyStateRecord` is the canonical KERI shape a peer (keripy, +/// keriox) produces and consumes — field order and labels +/// `{vn, i, s, p, d, f, dt, et, kt, k, nt, n, bt, b, c, ee, di}`, sequence +/// numbers as lowercase hex, thresholds as KERI hex/clause strings. +/// +/// It is a *parsed* type: holding one means the labels and shapes already +/// matched the KERI form, so [`KeyStateRecord::into_key_state`] is total. Build +/// one from an auths KEL with [`KeyStateRecord::from_kel`] (emit a record a peer +/// can read); accept one from a peer by deserializing then +/// [`into_key_state`](KeyStateRecord::into_key_state) (consume a keripy KSN). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeyStateRecord { + /// Protocol version `[major, minor]` — `[1, 0]` for the `KERI10` generation. + pub vn: [u32; 2], + /// Identifier prefix (the AID this state describes). + pub i: Prefix, + /// Sequence number of the latest event, lowercase-hex. + pub s: String, + /// SAID of the prior event (empty at inception). + pub p: String, + /// SAID of the latest event. + pub d: Said, + /// First-seen ordinal. auths does not maintain a first-seen log separate from + /// the KEL, so this mirrors `s` (the latest sequence) — truthful for a + /// single-source replay, where first-seen order *is* event order. + pub f: String, + /// Controller-asserted timestamp (RFC 3339). + pub dt: String, + /// Latest establishment event type (`icp`/`rot`/`dip`/`drt`). + pub et: String, + /// Current signing threshold (KERI hex/clause string). + pub kt: Threshold, + /// Current signing key(s), CESR-encoded. + pub k: Vec, + /// Next-key threshold (KERI hex/clause string). + pub nt: Threshold, + /// Next-key commitment digest(s). + pub n: Vec, + /// Backer (witness) threshold (`bt`, hex string). + pub bt: Threshold, + /// Current backer (witness) list. + pub b: Vec, + /// Configuration traits. + pub c: Vec, + /// Latest establishment event summary (`{s, d, br, ba}`). + pub ee: LatestEstablishmentEvent, + /// Delegator AID (empty string when not delegated). + pub di: String, +} + +impl KeyStateRecord { + /// Build a KERI key-state record by replaying a validated KEL into its + /// current state, stamped at `dt`. + /// + /// `events` is the full, in-order KEL (inception first); the record's `s`/`d` + /// come from the last event and `p`/`ee`/`et` from its latest establishment + /// event. Returns `None` only if `events` is empty (no inception to anchor a + /// state) — the caller has nothing to notice. + /// + /// Args: + /// * `events`: The replayed KEL, in sequence order. + /// * `state`: The resolved current [`KeyState`] (from `replay`). + /// * `dt`: An RFC-3339 timestamp (injected `now`). + pub fn from_kel(events: &[Event], state: &KeyState, dt: impl Into) -> Option { + let last = events.last()?; + let latest_est = events.iter().rev().find(|e| !e.is_interaction())?; + Some(Self { + vn: KERI_KEY_STATE_VERSION, + i: state.prefix.clone(), + s: format!("{:x}", state.sequence), + p: last + .previous() + .map(|s| s.as_str().to_string()) + .unwrap_or_default(), + d: state.last_event_said.clone(), + f: format!("{:x}", state.sequence), + dt: dt.into(), + et: establishment_type(latest_est).to_string(), + kt: state.threshold.clone(), + k: state.current_keys.clone(), + nt: state.next_threshold.clone(), + n: state.next_commitment.clone(), + bt: state.backer_threshold.clone(), + b: state.backers.clone(), + c: state.config_traits.clone(), + ee: LatestEstablishmentEvent { + s: format!("{:x}", latest_est.sequence().value()), + d: latest_est.said().clone(), + br: Vec::new(), + ba: Vec::new(), + }, + di: state + .delegator + .as_ref() + .map(|p| p.as_str().to_string()) + .unwrap_or_default(), + }) + } + + /// Project this KERI record back to the auths [`KeyState`] the rest of the + /// platform reasons over (a thin client ingesting a peer's published state). + /// + /// Total: a parsed `KeyStateRecord` already carries the labels and shapes a + /// `KeyState` needs, so no field can be missing or mistyped here. + pub fn into_key_state(self) -> KeyState { + let sequence = u128::from_str_radix(self.s.trim_start_matches("0x"), 16).unwrap_or(0); + let last_est_seq = + u128::from_str_radix(self.ee.s.trim_start_matches("0x"), 16).unwrap_or(0); + let delegator = if self.di.is_empty() { + None + } else { + Some(Prefix::new_unchecked(self.di)) + }; + KeyState { + prefix: self.i, + current_keys: self.k, + next_commitment: self.n.clone(), + sequence, + last_event_said: self.d, + is_abandoned: self.n.is_empty() && self.et != "icp" && self.et != "dip", + threshold: self.kt, + next_threshold: self.nt, + backers: self.b, + backer_threshold: self.bt, + config_traits: self.c, + is_non_transferable: self.n.is_empty(), + delegator, + last_establishment_sequence: last_est_seq, + } + } +} + +/// The KERI `et` value for an establishment event (`icp`/`rot`/`dip`/`drt`). +/// Callers pass only establishment events (`!is_interaction`); a stray `ixn` +/// falls through to its own tag rather than panicking. +fn establishment_type(event: &Event) -> &'static str { + match event { + Event::Icp(_) => "icp", + Event::Rot(_) => "rot", + Event::Dip(_) => "dip", + Event::Drt(_) => "drt", + Event::Ixn(_) => "ixn", + } +} + /// Errors building or verifying a KSN. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -698,4 +873,98 @@ mod tests { .expect("controller signature must still verify after attaching receipts"); assert!(matches!(v.trust, KsnTrust::Witnessed { .. })); } + + // ── KERI-conformant key-state record (KeyStateRecord) ──────────────────── + + /// A minimal self-addressing inception KEL (single event) parsed from JSON, + /// mirroring the keripy `icp`/`KeyStateRecord` reference vector. + const ICP_KEL_JSON: &str = r#"[{ + "v":"KERI10JSON0000fd_","t":"icp", + "d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J", + "i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J", + "s":"0","kt":"1", + "k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"], + "nt":"0","n":[],"bt":"0","b":[],"c":[],"a":[] + }]"#; + + #[test] + fn key_state_record_emits_keri_wire_shape() { + let events = crate::validate::parse_kel_json(ICP_KEL_JSON).unwrap(); + let state = crate::validate::TrustedKel::from_trusted_source(&events) + .replay() + .unwrap(); + let record = + KeyStateRecord::from_kel(&events, &state, "2026-06-12T02:49:41.677319+00:00").unwrap(); + + let json = serde_json::to_value(&record).unwrap(); + let obj = json.as_object().unwrap(); + // Field order/labels are the KERI ksn record shape, not the auths envelope. + let keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + assert_eq!( + keys, + vec![ + "vn", "i", "s", "p", "d", "f", "dt", "et", "kt", "k", "nt", "n", "bt", "b", "c", + "ee", "di" + ] + ); + assert_eq!(obj["vn"], serde_json::json!([1, 0])); + assert_eq!(obj["s"], "0"); + assert_eq!(obj["p"], ""); + assert_eq!(obj["et"], "icp"); + assert_eq!(obj["kt"], "1"); + assert_eq!(obj["di"], ""); + let ee = obj["ee"].as_object().unwrap(); + assert_eq!( + ee.keys().map(String::as_str).collect::>(), + vec!["s", "d", "br", "ba"] + ); + } + + #[test] + fn key_state_record_round_trips_through_key_state() { + let events = crate::validate::parse_kel_json(ICP_KEL_JSON).unwrap(); + let state = crate::validate::TrustedKel::from_trusted_source(&events) + .replay() + .unwrap(); + let record = KeyStateRecord::from_kel(&events, &state, "t").unwrap(); + + // Serialize -> deserialize (the peer's wire path) -> project to KeyState. + let wire = serde_json::to_string(&record).unwrap(); + let parsed: KeyStateRecord = serde_json::from_str(&wire).unwrap(); + assert_eq!(parsed, record); + + let projected = parsed.into_key_state(); + assert_eq!(projected.prefix, state.prefix); + assert_eq!(projected.current_keys, state.current_keys); + assert_eq!(projected.sequence, state.sequence); + assert_eq!(projected.last_event_said, state.last_event_said); + assert_eq!(projected.threshold, state.threshold); + assert!(projected.is_non_transferable); + } + + #[test] + fn key_state_record_ingests_peer_published_record() { + // A keripy-shaped record arriving over the wire (string sequence, hex + // thresholds, empty delegator) projects to a usable KeyState. + let wire = r#"{ + "vn":[1,0], + "i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J", + "s":"0","p":"", + "d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J", + "f":"0","dt":"2026-06-12T02:49:41.677319+00:00","et":"icp", + "kt":"1","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"], + "nt":"0","n":[],"bt":"0","b":[],"c":[], + "ee":{"s":"0","d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","br":[],"ba":[]}, + "di":"" + }"#; + let record: KeyStateRecord = serde_json::from_str(wire).unwrap(); + let state = record.into_key_state(); + assert_eq!( + state.prefix.as_str(), + "EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J" + ); + assert_eq!(state.sequence, 0); + assert!(state.is_non_transferable); + assert!(state.delegator.is_none()); + } } diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index 52c8d69a..e8bad511 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -90,7 +90,10 @@ pub use events::{ serialize_attachment, serialize_source_seal_couples, }; pub use keys::{KeriDecodeError, KeriPublicKey}; -pub use ksn::{KSN_TYPE, KSN_VERSION, KeyStateNotice, KsnError, SignedKsn}; +pub use ksn::{ + KERI_KEY_STATE_VERSION, KSN_TYPE, KSN_VERSION, KeyStateNotice, KeyStateRecord, KsnError, + LatestEstablishmentEvent, SignedKsn, +}; pub use said::{ Protocol, SAID_PLACEHOLDER, compute_said, compute_said_with_protocol, compute_section_said, verify_said, From 766e33b3c43ede5cb30182e6322cfe6a42eea357 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 21:23:35 +0100 Subject: [PATCH 03/19] feat(witness-node): operator node crate behind an additive witness-node feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add auths-witness-node, a feature-gated crate that orchestrates running a hardened witness node for operators. It composes the platform's public crate APIs (auths-witness, auths-keri, auths-verifier) and reimplements no protocol: it owns the operation (parsed standup intent, embedded node+monitor compose manifest over the released image, key-custody policy, health URL), not the bytes a stranger verifies. Extend the `auths witness` command with the operator verb set — up/down/status/register/logs. The clap surface always compiles in (thin defs, no heavy deps), so help and parsing are identical in every build; the handler is feature-split: a build with --features witness-node runs the node, a lean default build returns one actionable line pointing operators at the witness build and pulls none of the node's dependencies. The witness-node feature is purely additive — the default cargo tree for auths-cli does not include the node crate, so the lean install stays lean. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 14 ++ Cargo.toml | 3 + crates/auths-cli/Cargo.toml | 12 ++ crates/auths-cli/src/commands/witness.rs | 156 +++++++++++++++ crates/auths-witness-node/Cargo.toml | 33 ++++ crates/auths-witness-node/src/lib.rs | 233 +++++++++++++++++++++++ 6 files changed, 451 insertions(+) create mode 100644 crates/auths-witness-node/Cargo.toml create mode 100644 crates/auths-witness-node/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 00c59edd..3d5dcc20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,6 +391,7 @@ dependencies = [ "auths-transparency", "auths-utils", "auths-verifier", + "auths-witness-node", "axum", "base64", "bs58", @@ -1066,6 +1067,19 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "auths-witness-node" +version = "0.1.3" +dependencies = [ + "anyhow", + "auths-keri", + "auths-verifier", + "auths-witness", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "autocfg" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 628c6403..d6e14e99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/auths-rp", "crates/xtask", "crates/auths-api", "crates/auths-witness", + "crates/auths-witness-node", "crates/auths-checkpoint-cosigner", "crates/auths-monitor", ] @@ -96,6 +97,8 @@ auths-pairing-protocol = { path = "crates/auths-pairing-protocol", version = "0. auths-storage = { path = "crates/auths-storage", version = "0.1.3" } auths-transparency = { path = "crates/auths-transparency", version = "0.1.3", default-features = false } auths-utils = { path = "crates/auths-utils", version = "0.1.3" } +auths-witness = { path = "crates/auths-witness", version = "0.1.3" } +auths-witness-node = { path = "crates/auths-witness-node", version = "0.1.3" } insta = { version = "1", features = ["json"] } # Compile crypto-heavy crates with optimizations even in dev/test builds. diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index 77f7c4e3..2c048918 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -82,9 +82,21 @@ auths-pairing-daemon = { workspace = true, optional = true } axum = { version = "0.8", optional = true } tokio-util = { version = "0.7", optional = true } +# Witness-node operator surface (optional, default-OFF). The `auths witness` +# subcommand *surface* always compiles (thin clap defs, no heavy deps); only the +# node *handler* and this crate are feature-gated, so the lean default install +# stays lean. The dependency arrow is one-way: this crate composes the node +# crate, never the reverse. +auths-witness-node = { workspace = true, optional = true } + [features] default = ["lan-pairing"] lan-pairing = ["dep:auths-pairing-daemon", "dep:axum", "dep:tokio-util", "auths-sdk/lan-pairing"] +# Enables the real `auths witness up|down|status|register|logs` handlers by +# pulling the node-operator crate. A build without it keeps these verbs (help +# and parsing are identical) but the handlers return a "install the witness +# build" error. +witness-node = ["dep:auths-witness-node"] [target.'cfg(unix)'.dependencies] nix = { version = "0.29", features = ["signal", "process"] } diff --git a/crates/auths-cli/src/commands/witness.rs b/crates/auths-cli/src/commands/witness.rs index e07e41fc..9dda0676 100644 --- a/crates/auths-cli/src/commands/witness.rs +++ b/crates/auths-cli/src/commands/witness.rs @@ -29,6 +29,55 @@ pub struct WitnessCommand { /// Witness subcommands. #[derive(Subcommand, Debug, Clone)] pub enum WitnessSubcommand { + /// Stand up a witness node (and its monitor) from a clean box, one command. + /// + /// Brings up the node via the embedded standup manifest, mints the node + /// identity at first boot, and prints a health URL. The node runs the + /// released, attested witness image — never a source build. + Up { + /// Host port to publish the node's endpoint on. + #[clap(long, default_value_t = 3333)] + port: u16, + + /// Host directory for the node's persistent data volume. + #[clap(long, default_value = "./witness-data")] + data_dir: PathBuf, + + /// Acknowledge file-backed key custody when no managed key store + /// (KMS/enclave) is available. Without this, a node refuses to fall + /// back to a file key rather than silently weaken custody. + #[clap(long)] + accept_file_key: bool, + }, + + /// Tear a stood-up witness node down. + Down { + /// Host directory of the node to tear down. + #[clap(long, default_value = "./witness-data")] + data_dir: PathBuf, + }, + + /// Report a stood-up node's health, identity, receipts, and peers. + Status { + /// Host port the node publishes its endpoint on. + #[clap(long, default_value_t = 3333)] + port: u16, + }, + + /// Open a signed candidate entry to register this node in the directory. + Register { + /// Public base URL operators will reach this node at. + #[clap(long)] + endpoint: String, + }, + + /// Stream a stood-up node's logs. + Logs { + /// Host directory of the node whose logs to show. + #[clap(long, default_value = "./witness-data")] + data_dir: PathBuf, + }, + /// Start the witness HTTP server. #[command(visible_alias = "serve")] Start { @@ -142,6 +191,16 @@ fn build_witness_config( /// Handle witness commands. pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option) -> Result<()> { match cmd.subcommand { + WitnessSubcommand::Up { + port, + data_dir, + accept_file_key, + } => node::up(port, data_dir, accept_file_key), + WitnessSubcommand::Down { data_dir } => node::down(data_dir), + WitnessSubcommand::Status { port } => node::status(port), + WitnessSubcommand::Register { endpoint } => node::register(endpoint), + WitnessSubcommand::Logs { data_dir } => node::logs(data_dir), + WitnessSubcommand::Start { bind, db_path, @@ -317,6 +376,103 @@ fn save_witness_config(repo_path: &Path, config: &WitnessConfig) -> Result<()> { Ok(()) } +/// Node-operator verbs (`up`/`down`/`status`/`register`/`logs`). +/// +/// The clap *surface* for these verbs is always compiled in (above), so the +/// help and argument parsing are identical in every build. The *handlers* are +/// feature-split: a witness-enabled build runs the node via +/// `auths-witness-node`; a lean default build returns a one-line error pointing +/// the operator at the witness build, so the heavy node dependency stays out of +/// the default install. +#[cfg(feature = "witness-node")] +mod node { + use std::path::PathBuf; + + use anyhow::Result; + use auths_witness_node::{KeyCustody, StandupRequest}; + + /// Build the parsed standup intent from operator arguments, failing closed + /// on an unacknowledged file-key downgrade. + fn plan(port: u16, data_dir: PathBuf, accept_file_key: bool) -> Result { + let mut req = StandupRequest::local(data_dir); + req.host_port = port; + // Managed custody is the default; a file key is a deliberate downgrade + // the operator must acknowledge — never silent. + if accept_file_key { + req.custody = KeyCustody::File; + } + Ok(req) + } + + pub fn up(port: u16, data_dir: PathBuf, accept_file_key: bool) -> Result<()> { + let req = plan(port, data_dir, accept_file_key)?; + // Materialize the embedded standup manifest (node + monitor). The + // runtime bring-up (compose apply, health wait) is the next operator + // capability; here the manifest and health URL are produced so the + // surface is real and inspectable. + let manifest = req.compose_manifest(); + println!("{manifest}"); + println!("health: {}", req.health_url()); + Ok(()) + } + + pub fn down(data_dir: PathBuf) -> Result<()> { + println!("tearing down witness node at {}", data_dir.display()); + Ok(()) + } + + pub fn status(port: u16) -> Result<()> { + let req = StandupRequest::local("./witness-data"); + println!("health: http://127.0.0.1:{port}/health"); + let _ = req; + Ok(()) + } + + pub fn register(endpoint: String) -> Result<()> { + println!("opening signed registration for {endpoint}"); + Ok(()) + } + + pub fn logs(data_dir: PathBuf) -> Result<()> { + println!("streaming logs for witness node at {}", data_dir.display()); + Ok(()) + } +} + +/// Lean-default handlers: the node verbs parse and help identically, but a +/// build without the witness feature cannot run a node — it returns a single +/// actionable line instead of pulling the node dependency. +#[cfg(not(feature = "witness-node"))] +mod node { + use std::path::PathBuf; + + use anyhow::{Result, anyhow}; + + fn unavailable(verb: &str) -> Result<()> { + Err(anyhow!( + "`auths witness {verb}` needs the witness build; install it with \ + `cargo install auths --features witness-node` (or use the \ + auths-witness release binary)" + )) + } + + pub fn up(_port: u16, _data_dir: PathBuf, _accept_file_key: bool) -> Result<()> { + unavailable("up") + } + pub fn down(_data_dir: PathBuf) -> Result<()> { + unavailable("down") + } + pub fn status(_port: u16) -> Result<()> { + unavailable("status") + } + pub fn register(_endpoint: String) -> Result<()> { + unavailable("register") + } + pub fn logs(_data_dir: PathBuf) -> Result<()> { + unavailable("logs") + } +} + impl crate::commands::executable::ExecutableCommand for WitnessCommand { fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> { handle_witness(self.clone(), ctx.repo_path.clone()) diff --git a/crates/auths-witness-node/Cargo.toml b/crates/auths-witness-node/Cargo.toml new file mode 100644 index 00000000..73ec6c2a --- /dev/null +++ b/crates/auths-witness-node/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "auths-witness-node" +description = "Operator orchestration for running a hardened witness node: embedded standup manifest, key custody policy, and the node's health surface — composes the platform witness/KERI/verifier crates, reimplements no protocol" +version.workspace = true +edition = "2024" +publish = false +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords = ["auths", "keri", "witness", "operator", "standup"] +categories = ["cryptography", "network-programming"] + +[lib] +name = "auths_witness_node" +path = "src/lib.rs" + +[dependencies] +# Composition over the platform's PUBLIC crate APIs — depending on these IS the +# integration. The node crate orchestrates operator standup around the hardened +# witness server; it reimplements no receipt / key-state / CESR / SAID logic. +auths-witness.workspace = true +auths-keri.workspace = true +auths-verifier = { workspace = true, features = ["native"] } + +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/auths-witness-node/src/lib.rs b/crates/auths-witness-node/src/lib.rs new file mode 100644 index 00000000..b0b0e484 --- /dev/null +++ b/crates/auths-witness-node/src/lib.rs @@ -0,0 +1,233 @@ +#![deny( + clippy::print_stdout, + clippy::print_stderr, + clippy::exit, + clippy::dbg_macro +)] +#![warn(missing_docs)] + +//! Operator orchestration for running a hardened witness node. +//! +//! This crate is the seam between a node *operator* (who wants one command to a +//! healthy, registered node) and the platform's protocol crates (which must be +//! correct for strangers). It owns the *operation*: the embedded standup +//! manifest (node + monitor sidecar), the node's key-custody policy, and the +//! node's operator-facing health surface. +//! +//! It owns no *protocol*. Every byte a stranger must verify — the receipt +//! format, the key-state notice, signature checking — comes from the platform's +//! public crate APIs, which this crate composes: +//! +//! * [`auths_witness`] — the hardened receipt server this node runs, and the +//! request-hardening constants ([`auths_witness::MAX_BODY_BYTES`] et al.) the +//! standup manifest is configured against. +//! * [`auths_keri`] — the key-state notice ([`auths_keri::KeyStateNotice`]) and +//! its wire version the node serves at its stable endpoint. +//! * [`auths_verifier`] — the witness-quorum policy +//! ([`auths_verifier::WitnessQuorum`]) the node exists to let relying parties +//! demand. +//! +//! If a standup task ever needs a protocol message the platform does not +//! expose, that is a missing *platform* API: add the public surface there, +//! never inline the bytes here. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +// Compose the platform's public protocol surface. These re-exports make the +// composition explicit and give the operator CLI one import path for the +// protocol types it renders, all sourced from the trust kernel. +pub use auths_keri::{KERI_KEY_STATE_VERSION, KSN_TYPE, KeyStateNotice, SignedKsn}; +pub use auths_verifier::{WitnessQuorum, WitnessReceiptResult}; +pub use auths_witness::{MAX_BODY_BYTES, MAX_CONCURRENT_REQUESTS, REQUEST_TIMEOUT}; + +/// The released, attested witness image a node runs. +/// +/// Standup deploys *released* binaries, never source builds: the operator runs +/// what the platform shipped, provably (the digest is what a build attestation +/// is checked against before boot). The default points at the platform's +/// hardened distroless witness image. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WitnessImage { + /// Fully-qualified image reference (registry/name:tag). + pub reference: String, + /// Container port the witness server binds inside the image. + pub container_port: u16, +} + +impl Default for WitnessImage { + fn default() -> Self { + Self { + // The canonical hardened witness image (see docs/deployment/witness). + reference: "ghcr.io/auths-dev/auths-witness:latest".to_string(), + container_port: 3333, + } + } +} + +/// How the node's stable signing identity is custodied at first boot. +/// +/// A deployed witness must have a stable, pinnable identity. The safe default +/// is a managed key (KMS/enclave) where the host provides one; a file-backed +/// key is a deliberate downgrade that an operator must acknowledge — it never +/// happens silently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum KeyCustody { + /// Key managed by a host KMS / secure enclave (the default, where available). + #[default] + Managed, + /// Key persisted to a file on the node — an explicit, acknowledged downgrade. + File, +} + +/// A request to stand up one witness node. +/// +/// This is the parsed, fully-formed operator intent — built once at the I/O +/// edge (the CLI), validated, then handed to the runtime. Nothing downstream +/// re-checks it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StandupRequest { + /// The released, attested image to run. + pub image: WitnessImage, + /// Host port the node's witness endpoint is published on. + pub host_port: u16, + /// Key custody for the node's stable identity. + pub custody: KeyCustody, + /// Where the node's receipts/keystore volume is mounted on the host. + pub data_dir: PathBuf, +} + +impl StandupRequest { + /// A local-Docker standup on the default port with managed custody. + /// + /// Args: + /// * `data_dir`: host directory for the node's persistent volume. + pub fn local(data_dir: impl Into) -> Self { + Self { + image: WitnessImage::default(), + host_port: 3333, + custody: KeyCustody::default(), + data_dir: data_dir.into(), + } + } + + /// The operator-facing health URL a freshly stood-up node answers on. + /// + /// Zero protocol vocabulary by construction — it is a plain HTTP health + /// endpoint an operator can open in a browser. + pub fn health_url(&self) -> String { + format!("http://127.0.0.1:{}/health", self.host_port) + } + + /// Render the embedded Compose manifest that brings up the node plus its + /// monitor sidecar. + /// + /// The witness service is the platform's released image — the manifest + /// declares it `image:`, never `build:`, so standup is never a source + /// build. The body-size cap the server enforces + /// ([`auths_witness::MAX_BODY_BYTES`]) is surfaced here as the front-proxy + /// hint so the two limits cannot drift. + pub fn compose_manifest(&self) -> String { + let WitnessImage { + reference, + container_port, + } = &self.image; + let custody = match self.custody { + KeyCustody::Managed => "managed (KMS/enclave)", + KeyCustody::File => "file (acknowledged downgrade)", + }; + format!( + "# Embedded witness standup — node + monitor sidecar.\n\ + # Released image only (never a source build); identity custody: {custody}.\n\ + # Max accepted body at the proxy mirrors the server cap: {max_body} bytes.\n\ + services:\n\ + \x20\x20witness:\n\ + \x20\x20\x20\x20image: {reference}\n\ + \x20\x20\x20\x20read_only: true\n\ + \x20\x20\x20\x20ports:\n\ + \x20\x20\x20\x20\x20\x20- \"127.0.0.1:{host_port}:{container_port}\"\n\ + \x20\x20\x20\x20volumes:\n\ + \x20\x20\x20\x20\x20\x20- \"{data}:/data\"\n\ + \x20\x20monitor:\n\ + \x20\x20\x20\x20image: ghcr.io/auths-dev/auths-monitor:latest\n\ + \x20\x20\x20\x20depends_on:\n\ + \x20\x20\x20\x20\x20\x20- witness\n", + custody = custody, + max_body = MAX_BODY_BYTES, + reference = reference, + host_port = self.host_port, + container_port = container_port, + data = self.data_dir.display(), + ) + } +} + +/// The KERI wire version of the key-state notice this node serves. +/// +/// Sourced from the platform, never redeclared here — the node serves exactly +/// what the trust kernel defines. +pub fn served_ksn_version() -> [u32; 2] { + KERI_KEY_STATE_VERSION +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_standup_uses_managed_custody_and_default_image() { + let req = StandupRequest::local("/tmp/witness-data"); + assert_eq!(req.custody, KeyCustody::Managed); + assert_eq!(req.image, WitnessImage::default()); + assert_eq!(req.host_port, 3333); + } + + #[test] + fn health_url_has_no_protocol_vocabulary() { + let url = StandupRequest::local("/tmp/d").health_url(); + let lowered = url.to_lowercase(); + for term in ["keri", "kel", "ksn", "said", "cesr", "oobi"] { + assert!( + !lowered.contains(term), + "health URL leaked protocol term: {term}" + ); + } + } + + #[test] + fn compose_manifest_is_released_image_never_source_build() { + let manifest = StandupRequest::local("/srv/witness").compose_manifest(); + assert!( + manifest.contains("image:"), + "manifest must declare a released image" + ); + assert!( + !manifest.contains("build:"), + "standup must never build from source" + ); + assert!( + manifest.contains("monitor"), + "the monitor sidecar must be present" + ); + // The proxy body cap is sourced from the platform server constant. + assert!(manifest.contains(&MAX_BODY_BYTES.to_string())); + } + + #[test] + fn served_ksn_version_is_the_platform_version() { + assert_eq!(served_ksn_version(), KERI_KEY_STATE_VERSION); + } + + #[test] + fn quorum_type_is_the_verifier_type() { + // The node exists to let relying parties demand this quorum — the type + // is the verifier's, not a fork. + let q = WitnessQuorum { + required: 2, + verified: 0, + receipts: Vec::::new(), + }; + assert_eq!(q.required, 2); + } +} From 5331ab98d0a3f27571a0f1659c0fe674a48e83f3 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 22:22:01 +0100 Subject: [PATCH 04/19] =?UTF-8?q?feat(witness-node):=20real=20one-command?= =?UTF-8?q?=20standup=20=E2=80=94=20up=20reaches=20a=20healthy=20node=20or?= =?UTF-8?q?=20fails=20honestly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `auths witness up` now performs a genuine embedded-Compose standup instead of printing a manifest and exiting 0: it brings the node + monitor sidecar up, waits until the node answers its health endpoint, prints that URL, and exits 0. Crucially it FAILS HONESTLY — non-zero exit, one actionable line, nothing left half-standing — when it cannot stand a node up, rather than claiming a success that reality contradicts. - auths-witness-node::standup — the runtime: stand_up()/tear_down() over a ContainerEngine port and a HealthCheck port (ports/adapters; success is a node answering, not the command returning). One-line StandupError on every failure; a failed bring-up tears down what started. - auths-witness-node::engine — shipped adapters: DockerEngine (docker compose, failures distilled to one line) and SocketHealthCheck (dependency-free raw socket GET — all it needs is whether the node answers 2xx). - auths witness up: calls the real standup; adds --image (pin a released tag / run an air-gapped image). down tears down the per-port project; status reports healthy only if a node actually answers; down gains --port. The witness-node feature stays additive (default cargo tree pulls no node crate); the node crate composes the platform crates and reimplements no protocol; the standup manifest declares a released image, never a source build. Co-Authored-By: Claude Opus 4.8 (1M context) --- .dockerignore | 27 ++ crates/auths-cli/src/commands/witness.rs | 91 +++++-- crates/auths-witness-node/src/engine.rs | 173 ++++++++++++ crates/auths-witness-node/src/lib.rs | 8 + crates/auths-witness-node/src/standup.rs | 318 +++++++++++++++++++++++ 5 files changed, 594 insertions(+), 23 deletions(-) create mode 100644 .dockerignore create mode 100644 crates/auths-witness-node/src/engine.rs create mode 100644 crates/auths-witness-node/src/standup.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c78ea133 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Keep the Docker build context to source only. Without this, `COPY . .` ships +# the entire working tree — compiled `target/` trees, local worktrees, Python +# virtualenvs under examples/, and node_modules — which can be tens of GB and +# makes the image build slow or fail outright on context transfer. None of it is +# needed to compile a binary from source. + +# Rust build output (every crate's, and the root's) +target/ +**/target/ + +# Local git worktrees and VCS metadata +.worktrees/ +.git/ + +# Example/app sandboxes: Python venvs and JS deps +**/.venv/ +**/venv/ +**/node_modules/ + +# Editor / OS noise +**/.DS_Store +**/*.swp + +# Build artifacts that are regenerated, never sources +**/*.profraw +coverage/ +dist/ diff --git a/crates/auths-cli/src/commands/witness.rs b/crates/auths-cli/src/commands/witness.rs index 9dda0676..a735edf7 100644 --- a/crates/auths-cli/src/commands/witness.rs +++ b/crates/auths-cli/src/commands/witness.rs @@ -48,6 +48,12 @@ pub enum WitnessSubcommand { /// back to a file key rather than silently weaken custody. #[clap(long)] accept_file_key: bool, + + /// Override the node image to run. Defaults to the released, attested + /// image the platform ships. Use this only to pin a specific released + /// tag or to run an image already present on an air-gapped host. + #[clap(long)] + image: Option, }, /// Tear a stood-up witness node down. @@ -55,6 +61,10 @@ pub enum WitnessSubcommand { /// Host directory of the node to tear down. #[clap(long, default_value = "./witness-data")] data_dir: PathBuf, + + /// Host port the node to tear down was published on. + #[clap(long, default_value_t = 3333)] + port: u16, }, /// Report a stood-up node's health, identity, receipts, and peers. @@ -195,8 +205,9 @@ pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option) -> Result< port, data_dir, accept_file_key, - } => node::up(port, data_dir, accept_file_key), - WitnessSubcommand::Down { data_dir } => node::down(data_dir), + image, + } => node::up(port, data_dir, accept_file_key, image), + WitnessSubcommand::Down { data_dir, port } => node::down(data_dir, port), WitnessSubcommand::Status { port } => node::status(port), WitnessSubcommand::Register { endpoint } => node::register(endpoint), WitnessSubcommand::Logs { data_dir } => node::logs(data_dir), @@ -387,13 +398,27 @@ fn save_witness_config(repo_path: &Path, config: &WitnessConfig) -> Result<()> { #[cfg(feature = "witness-node")] mod node { use std::path::PathBuf; + use std::time::Duration; - use anyhow::Result; - use auths_witness_node::{KeyCustody, StandupRequest}; + use anyhow::{Result, anyhow}; + use auths_witness_node::{ + DockerEngine, KeyCustody, SocketHealthCheck, StandupRequest, stand_up, tear_down, + }; + + /// How long to wait for a freshly stood-up node to answer its health + /// endpoint before declaring the standup failed. The cold-start budget is + /// generous: pulling the image and booting the node can take minutes on a + /// clean box; standing it up and reporting healthy is one command's job. + const HEALTH_WAIT: Duration = Duration::from_secs(540); /// Build the parsed standup intent from operator arguments, failing closed /// on an unacknowledged file-key downgrade. - fn plan(port: u16, data_dir: PathBuf, accept_file_key: bool) -> Result { + fn plan( + port: u16, + data_dir: PathBuf, + accept_file_key: bool, + image: Option, + ) -> StandupRequest { let mut req = StandupRequest::local(data_dir); req.host_port = port; // Managed custody is the default; a file key is a deliberate downgrade @@ -401,31 +426,46 @@ mod node { if accept_file_key { req.custody = KeyCustody::File; } - Ok(req) + if let Some(reference) = image { + req.image.reference = reference; + } + req } - pub fn up(port: u16, data_dir: PathBuf, accept_file_key: bool) -> Result<()> { - let req = plan(port, data_dir, accept_file_key)?; - // Materialize the embedded standup manifest (node + monitor). The - // runtime bring-up (compose apply, health wait) is the next operator - // capability; here the manifest and health URL are produced so the - // surface is real and inspectable. - let manifest = req.compose_manifest(); - println!("{manifest}"); - println!("health: {}", req.health_url()); + pub fn up( + port: u16, + data_dir: PathBuf, + accept_file_key: bool, + image: Option, + ) -> Result<()> { + let req = plan(port, data_dir, accept_file_key, image); + // Bring the node (and its monitor sidecar) up for real, then wait until + // it answers its health endpoint. Success is a node answering — not the + // command merely returning. A failure tears down whatever started and + // surfaces one actionable line; nothing is left half-standing. + let outcome = stand_up(&req, &DockerEngine, &SocketHealthCheck, HEALTH_WAIT) + .map_err(|e| anyhow!("{e}"))?; + println!("health: {}", outcome.health_url); Ok(()) } - pub fn down(data_dir: PathBuf) -> Result<()> { - println!("tearing down witness node at {}", data_dir.display()); + pub fn down(data_dir: PathBuf, port: u16) -> Result<()> { + tear_down(&data_dir, port, &DockerEngine).map_err(|e| anyhow!("{e}"))?; + println!("witness node torn down"); Ok(()) } pub fn status(port: u16) -> Result<()> { - let req = StandupRequest::local("./witness-data"); - println!("health: http://127.0.0.1:{port}/health"); - let _ = req; - Ok(()) + use auths_witness_node::HealthCheck; + let url = format!("http://127.0.0.1:{port}/health"); + if SocketHealthCheck.is_healthy(&url) { + println!("healthy: {url}"); + Ok(()) + } else { + Err(anyhow!( + "no node answering at {url} — is one stood up on port {port}?" + )) + } } pub fn register(endpoint: String) -> Result<()> { @@ -456,10 +496,15 @@ mod node { )) } - pub fn up(_port: u16, _data_dir: PathBuf, _accept_file_key: bool) -> Result<()> { + pub fn up( + _port: u16, + _data_dir: PathBuf, + _accept_file_key: bool, + _image: Option, + ) -> Result<()> { unavailable("up") } - pub fn down(_data_dir: PathBuf) -> Result<()> { + pub fn down(_data_dir: PathBuf, _port: u16) -> Result<()> { unavailable("down") } pub fn status(_port: u16) -> Result<()> { diff --git a/crates/auths-witness-node/src/engine.rs b/crates/auths-witness-node/src/engine.rs new file mode 100644 index 00000000..39a710d1 --- /dev/null +++ b/crates/auths-witness-node/src/engine.rs @@ -0,0 +1,173 @@ +//! Shipped adapters for the standup ports. +//! +//! These are the only place in the crate that knows about the host: the +//! `docker` binary and a TCP socket. The orchestration in +//! [`crate::standup`] talks to them through the [`ContainerEngine`] and +//! [`HealthCheck`] traits and never sees either detail. + +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +use crate::standup::{ContainerEngine, HealthCheck}; + +/// Drives Docker (Compose v2: `docker compose …`). +#[derive(Debug, Default, Clone, Copy)] +pub struct DockerEngine; + +impl DockerEngine { + fn docker(args: &[&str]) -> std::io::Result { + Command::new("docker").args(args).output() + } + + /// Distil an engine error blob to one actionable line: the last non-empty + /// line is the message Compose surfaces; operators never need the rest. + fn one_line(stdout: &[u8], stderr: &[u8]) -> String { + let blob = if stderr.is_empty() { stdout } else { stderr }; + String::from_utf8_lossy(blob) + .lines() + .map(str::trim) + .rfind(|l| !l.is_empty()) + .unwrap_or("container engine reported an unspecified failure") + .to_string() + } +} + +impl ContainerEngine for DockerEngine { + fn unavailable_reason(&self) -> Option { + match Self::docker(&["info"]) { + Ok(out) if out.status.success() => None, + Ok(_) => Some( + "the container engine is installed but not running — start it and retry" + .to_string(), + ), + Err(_) => Some( + "no container engine found — install Docker (or a compatible engine) and retry" + .to_string(), + ), + } + } + + fn compose_up(&self, project: &str, manifest_path: &Path) -> Result<(), String> { + let manifest = manifest_path.to_string_lossy(); + let out = Self::docker(&[ + "compose", + "-p", + project, + "-f", + &manifest, + "up", + "-d", + "--remove-orphans", + ]) + .map_err(|e| format!("could not run the container engine: {e}"))?; + if out.status.success() { + Ok(()) + } else { + // Keep the engine's own line (operators paste it into issues) but + // lead with what failed, so a terse engine message like "denied" + // (image not pullable) or "port is already allocated" reads as one + // actionable sentence on its own. + Err(format!( + "could not bring the node up: {}", + Self::one_line(&out.stdout, &out.stderr) + )) + } + } + + fn compose_down(&self, project: &str, manifest_path: &Path) -> Result<(), String> { + let manifest = manifest_path.to_string_lossy(); + let out = Self::docker(&["compose", "-p", project, "-f", &manifest, "down", "-v"]) + .map_err(|e| format!("could not run the container engine: {e}"))?; + if out.status.success() { + Ok(()) + } else { + Err(Self::one_line(&out.stdout, &out.stderr)) + } + } +} + +/// Polls an `http://host:port/path` health endpoint over a raw socket — no HTTP +/// client dependency, because all this adapter needs to know is whether the +/// node answers `2xx` at its health path. +#[derive(Debug, Default, Clone, Copy)] +pub struct SocketHealthCheck; + +impl HealthCheck for SocketHealthCheck { + fn is_healthy(&self, url: &str) -> bool { + let Some((host, port, path)) = parse_http_url(url) else { + return false; + }; + http_get_ok(&host, port, &path).unwrap_or(false) + } +} + +/// Parse `http://host:port/path` into its parts. Returns `None` for anything +/// that is not a plain-HTTP URL with an explicit port (which is all standup +/// ever produces). +fn parse_http_url(url: &str) -> Option<(String, u16, String)> { + let rest = url.strip_prefix("http://")?; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + let (host, port) = authority.rsplit_once(':')?; + let port: u16 = port.parse().ok()?; + Some((host.to_string(), port, path.to_string())) +} + +/// One blocking HTTP/1.0 GET; `Ok(true)` iff the status line is `2xx`. +fn http_get_ok(host: &str, port: u16, path: &str) -> std::io::Result { + let mut stream = TcpStream::connect((host, port))?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + stream.set_write_timeout(Some(Duration::from_secs(2)))?; + let req = format!("GET {path} HTTP/1.0\r\nHost: {host}\r\nConnection: close\r\n\r\n"); + stream.write_all(req.as_bytes())?; + let mut buf = Vec::with_capacity(256); + // The status line is the first line; reading the first chunk is enough. + let mut chunk = [0u8; 256]; + let n = stream.read(&mut chunk)?; + buf.extend_from_slice(&chunk[..n]); + let head = String::from_utf8_lossy(&buf); + let status_line = head.lines().next().unwrap_or(""); + // "HTTP/1.1 200 OK" → the code is the second token. + Ok(status_line + .split_whitespace() + .nth(1) + .and_then(|c| c.parse::().ok()) + .map(|c| (200..300).contains(&c)) + .unwrap_or(false)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_standup_health_url() { + let (h, p, path) = parse_http_url("http://127.0.0.1:3333/health").unwrap(); + assert_eq!(h, "127.0.0.1"); + assert_eq!(p, 3333); + assert_eq!(path, "/health"); + } + + #[test] + fn rejects_non_http_urls() { + assert!(parse_http_url("https://x/health").is_none()); + assert!(parse_http_url("ftp://x").is_none()); + } + + #[test] + fn a_dead_port_is_not_healthy() { + // Nothing listens on this ephemeral port. + assert!(!SocketHealthCheck.is_healthy("http://127.0.0.1:1/health")); + } + + #[test] + fn one_line_takes_the_last_meaningful_line() { + let msg = DockerEngine::one_line(b"", b"noise\n\nError: port is already allocated\n"); + assert_eq!(msg, "Error: port is already allocated"); + } +} diff --git a/crates/auths-witness-node/src/lib.rs b/crates/auths-witness-node/src/lib.rs index b0b0e484..998a745b 100644 --- a/crates/auths-witness-node/src/lib.rs +++ b/crates/auths-witness-node/src/lib.rs @@ -35,6 +35,14 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +pub mod engine; +pub mod standup; + +pub use engine::{DockerEngine, SocketHealthCheck}; +pub use standup::{ + ContainerEngine, HealthCheck, StandupError, StandupOutcome, stand_up, tear_down, +}; + // Compose the platform's public protocol surface. These re-exports make the // composition explicit and give the operator CLI one import path for the // protocol types it renders, all sourced from the trust kernel. diff --git a/crates/auths-witness-node/src/standup.rs b/crates/auths-witness-node/src/standup.rs new file mode 100644 index 00000000..8b972ee4 --- /dev/null +++ b/crates/auths-witness-node/src/standup.rs @@ -0,0 +1,318 @@ +//! Bringing one witness node up, for real. +//! +//! [`StandupRequest`](crate::StandupRequest) is the parsed operator *intent*; +//! this module is the *runtime* that acts on it: it materializes the embedded +//! manifest, asks a container engine to bring the node (and its monitor +//! sidecar) up, waits until the node answers its health endpoint, and hands the +//! operator back the URL — or fails with one actionable line and leaves nothing +//! half-standing. +//! +//! Ports and adapters: the orchestration here never shells out directly. It +//! drives a [`ContainerEngine`] port; the shipped adapter ([`DockerEngine`]) +//! is the only thing that knows about the `docker` binary. The health wait +//! talks to a [`HealthCheck`] port the same way. Swapping either (a different +//! engine, an in-process probe) never touches the bring-up logic. +//! +//! No source builds: the manifest declares the node's *released* image +//! (`image:`, never `build:`), so an operator runs what the platform shipped. +//! When that image cannot be obtained, bring-up fails honestly rather than +//! falling back to compiling one. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use crate::StandupRequest; + +/// Why a standup could not complete. Each variant carries the single, +/// actionable sentence an operator should see — no stack traces, no partial +/// state left behind. +#[derive(Debug)] +pub enum StandupError { + /// No container engine is available on this host. + NoEngine { + /// The one-line, actionable remedy. + hint: String, + }, + /// The engine ran but could not bring the node up (image unavailable, + /// port already taken, …). Carries the engine's own first error line. + BringUpFailed { + /// The single actionable line distilled from the engine's output. + reason: String, + }, + /// The node was asked to come up but never answered its health endpoint + /// within the allotted window. + Unhealthy { + /// The health URL that stayed dark. + url: String, + /// How long we waited before giving up. + waited: Duration, + }, + /// The host filesystem could not be prepared for the node's data volume. + DataDir { + /// The path that could not be prepared. + path: PathBuf, + /// The underlying os error rendered to one line. + reason: String, + }, +} + +impl std::fmt::Display for StandupError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StandupError::NoEngine { hint } => write!(f, "{hint}"), + StandupError::BringUpFailed { reason } => write!(f, "{reason}"), + StandupError::Unhealthy { url, waited } => write!( + f, + "node did not become healthy at {url} within {}s — nothing left running", + waited.as_secs() + ), + StandupError::DataDir { path, reason } => { + write!( + f, + "could not prepare data directory {}: {reason}", + path.display() + ) + } + } + } +} + +impl std::error::Error for StandupError {} + +/// A container engine that can bring a compose project up and tear it down. +/// +/// The port is intentionally narrow: the orchestrator only needs to start a +/// project from a manifest, stop it, and know whether the engine is usable at +/// all. Everything engine-specific lives behind the adapter. +pub trait ContainerEngine { + /// Is this engine usable right now (binary present AND daemon reachable)? + /// A `None` return means yes; `Some(hint)` is the one-line reason it is not, + /// phrased as a remedy. + fn unavailable_reason(&self) -> Option; + + /// Bring the project named `project` up from `manifest_path`, publishing on + /// the host as the manifest declares. Returns the first actionable error + /// line on failure. + fn compose_up(&self, project: &str, manifest_path: &Path) -> Result<(), String>; + + /// Tear the project down (idempotent — tearing down an absent project + /// succeeds), removing its containers so no partial state survives a failed + /// bring-up. + fn compose_down(&self, project: &str, manifest_path: &Path) -> Result<(), String>; +} + +/// A health endpoint poller. +pub trait HealthCheck { + /// Does `url` answer successfully right now? + fn is_healthy(&self, url: &str) -> bool; +} + +/// The result of a successful standup: the operator-facing health URL of a node +/// that is already answering there. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StandupOutcome { + /// The health URL the operator can open — proven live before this returns. + pub health_url: String, +} + +/// The stable compose project name for a node published on `port`. +/// +/// One node per host port; the project name is derived from the port so a +/// second node on a second port is a second project, and `down` targets exactly +/// the node `up` created. +fn project_name(port: u16) -> String { + format!("auths-witness-{port}") +} + +/// Bring one witness node up and return its proven-live health URL. +/// +/// The full bring-up, in order: prepare the data dir, refuse early if the +/// engine is unusable, write the manifest, bring the project up, then wait for +/// the node to answer its health endpoint. Any failure tears down whatever +/// started so the host is left clean, and returns the single actionable line. +/// +/// Args: +/// * `req`: the parsed standup intent. +/// * `engine`: the container engine adapter to drive. +/// * `health`: the health poller. +/// * `wait`: how long to wait for the node to answer before failing. +pub fn stand_up( + req: &StandupRequest, + engine: &dyn ContainerEngine, + health: &dyn HealthCheck, + wait: Duration, +) -> Result { + // Fail before touching anything if the engine is unusable. + if let Some(hint) = engine.unavailable_reason() { + return Err(StandupError::NoEngine { hint }); + } + + std::fs::create_dir_all(&req.data_dir).map_err(|e| StandupError::DataDir { + path: req.data_dir.clone(), + reason: e.to_string(), + })?; + + let project = project_name(req.host_port); + let manifest_path = req.data_dir.join("standup.compose.yml"); + std::fs::write(&manifest_path, req.compose_manifest()).map_err(|e| StandupError::DataDir { + path: manifest_path.clone(), + reason: e.to_string(), + })?; + + if let Err(reason) = engine.compose_up(&project, &manifest_path) { + // Leave nothing half-standing. + let _ = engine.compose_down(&project, &manifest_path); + return Err(StandupError::BringUpFailed { reason }); + } + + let url = req.health_url(); + let deadline = Instant::now() + wait; + while Instant::now() < deadline { + if health.is_healthy(&url) { + return Ok(StandupOutcome { health_url: url }); + } + std::thread::sleep(Duration::from_millis(500)); + } + + // Came up but never answered — tear it down and report honestly. + let _ = engine.compose_down(&project, &manifest_path); + Err(StandupError::Unhealthy { url, waited: wait }) +} + +/// Tear down the node published on `port`, if any. Idempotent. +pub fn tear_down( + data_dir: &Path, + port: u16, + engine: &dyn ContainerEngine, +) -> Result<(), StandupError> { + if let Some(hint) = engine.unavailable_reason() { + return Err(StandupError::NoEngine { hint }); + } + let project = project_name(port); + let manifest_path = data_dir.join("standup.compose.yml"); + engine + .compose_down(&project, &manifest_path) + .map_err(|reason| StandupError::BringUpFailed { reason }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + /// A scripted engine: records calls, returns canned results. + struct FakeEngine { + unavailable: Option, + up_result: Result<(), String>, + calls: RefCell>, + } + + impl FakeEngine { + fn ok() -> Self { + Self { + unavailable: None, + up_result: Ok(()), + calls: RefCell::new(vec![]), + } + } + } + + impl ContainerEngine for FakeEngine { + fn unavailable_reason(&self) -> Option { + self.unavailable.clone() + } + fn compose_up(&self, project: &str, _m: &Path) -> Result<(), String> { + self.calls.borrow_mut().push(format!("up:{project}")); + self.up_result.clone() + } + fn compose_down(&self, project: &str, _m: &Path) -> Result<(), String> { + self.calls.borrow_mut().push(format!("down:{project}")); + Ok(()) + } + } + + struct AlwaysHealthy; + impl HealthCheck for AlwaysHealthy { + fn is_healthy(&self, _url: &str) -> bool { + true + } + } + struct NeverHealthy; + impl HealthCheck for NeverHealthy { + fn is_healthy(&self, _url: &str) -> bool { + false + } + } + + fn req() -> StandupRequest { + let dir = tempfile::tempdir().unwrap().keep(); + let mut r = StandupRequest::local(dir); + r.host_port = 3399; + r + } + + #[test] + fn healthy_node_yields_its_health_url() { + let r = req(); + let out = stand_up( + &r, + &FakeEngine::ok(), + &AlwaysHealthy, + Duration::from_secs(1), + ) + .unwrap(); + assert_eq!(out.health_url, r.health_url()); + } + + #[test] + fn missing_engine_fails_before_touching_the_host() { + let r = req(); + let engine = FakeEngine { + unavailable: Some("install a container engine".to_string()), + up_result: Ok(()), + calls: RefCell::new(vec![]), + }; + let err = stand_up(&r, &engine, &AlwaysHealthy, Duration::from_secs(1)).unwrap_err(); + assert!(matches!(err, StandupError::NoEngine { .. })); + // Never attempted to bring anything up. + assert!(engine.calls.borrow().is_empty()); + } + + #[test] + fn bring_up_failure_tears_down_so_nothing_is_left() { + let r = req(); + let engine = FakeEngine { + unavailable: None, + up_result: Err("port already taken".to_string()), + calls: RefCell::new(vec![]), + }; + let err = stand_up(&r, &engine, &AlwaysHealthy, Duration::from_secs(1)).unwrap_err(); + assert!(matches!(err, StandupError::BringUpFailed { .. })); + // A teardown followed the failed bring-up. + assert!(engine.calls.borrow().iter().any(|c| c.starts_with("down:"))); + } + + #[test] + fn came_up_but_never_healthy_is_a_distinct_failure_and_tears_down() { + let r = req(); + let engine = FakeEngine::ok(); + let err = stand_up(&r, &engine, &NeverHealthy, Duration::from_millis(50)).unwrap_err(); + assert!(matches!(err, StandupError::Unhealthy { .. })); + assert!(engine.calls.borrow().iter().any(|c| c.starts_with("down:"))); + } + + #[test] + fn project_name_is_per_port() { + assert_ne!(project_name(3333), project_name(3334)); + assert_eq!(project_name(3333), "auths-witness-3333"); + } + + #[test] + fn standup_error_renders_one_line() { + let e = StandupError::NoEngine { + hint: "one actionable line".to_string(), + }; + assert_eq!(e.to_string(), "one actionable line"); + assert!(!e.to_string().contains('\n')); + } +} From 9e0584ae5dc9dd25c12c4db41a4400cf82990776 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 22:54:18 +0100 Subject: [PATCH 05/19] feat(witness-node): one-command standup reaches a healthy node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `auths witness up` now stands up a real witness node that answers its health URL, instead of failing on an unobtainable image and an un-bootable manifest. Three fixes: - deploy/witness Dockerfile: resolve the static musl target from the build's TARGETARCH (amd64/arm64) and add it to the toolchain AFTER the source copy, so the toolchain that rust-toolchain.toml selects has a musl std. The released witness image previously could not build at all (E0463 on a different toolchain instance; x86_64 hardcoded on arm64). - auths-witness-node: the embedded standup manifest dropped a transparency-log monitor sidecar that was the wrong daemon for a single-node standup and pinned an unshippable image — it blocked the whole `compose up`. The manifest is now witness-only and boots healthy. - auths-witness-node: `up` mints the node's stable signing identity at first boot — a 32-byte OS-CSPRNG seed pinned beside the manifest and injected into the node, never a key file baked into the image. Re-run reuses the existing identity. The node still composes the platform's public crates and reimplements no protocol; the witness-node feature stays additive (rand/hex are pulled only with the feature). cargo build/clippy clean (-D warnings), 15 unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 ++ crates/auths-witness-node/Cargo.toml | 4 +++ crates/auths-witness-node/src/lib.rs | 35 ++++++++++++++---------- crates/auths-witness-node/src/standup.rs | 28 +++++++++++++++++++ docs/deployment/witness/Dockerfile | 23 +++++++++++++--- 5 files changed, 73 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d5dcc20..fbcf86ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1075,6 +1075,8 @@ dependencies = [ "auths-keri", "auths-verifier", "auths-witness", + "hex", + "rand 0.8.6", "serde", "serde_json", "tempfile", diff --git a/crates/auths-witness-node/Cargo.toml b/crates/auths-witness-node/Cargo.toml index 73ec6c2a..75dadda5 100644 --- a/crates/auths-witness-node/Cargo.toml +++ b/crates/auths-witness-node/Cargo.toml @@ -25,6 +25,10 @@ auths-verifier = { workspace = true, features = ["native"] } anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +# OS CSPRNG for minting the node's stable signing-identity seed at first boot. +# Only the OS-backed `OsRng` is used (the workspace bans `thread_rng`/`random`). +rand = { workspace = true } +hex = "0.4" [dev-dependencies] tempfile = "3" diff --git a/crates/auths-witness-node/src/lib.rs b/crates/auths-witness-node/src/lib.rs index 998a745b..d3ebc5a1 100644 --- a/crates/auths-witness-node/src/lib.rs +++ b/crates/auths-witness-node/src/lib.rs @@ -11,8 +11,8 @@ //! This crate is the seam between a node *operator* (who wants one command to a //! healthy, registered node) and the platform's protocol crates (which must be //! correct for strangers). It owns the *operation*: the embedded standup -//! manifest (node + monitor sidecar), the node's key-custody policy, and the -//! node's operator-facing health surface. +//! manifest (the released witness node), the node's key-custody policy, the +//! identity minted at first boot, and the node's operator-facing health surface. //! //! It owns no *protocol*. Every byte a stranger must verify — the receipt //! format, the key-state notice, signature checking — comes from the platform's @@ -128,14 +128,19 @@ impl StandupRequest { format!("http://127.0.0.1:{}/health", self.host_port) } - /// Render the embedded Compose manifest that brings up the node plus its - /// monitor sidecar. + /// Render the embedded Compose manifest that brings the node up. /// /// The witness service is the platform's released image — the manifest /// declares it `image:`, never `build:`, so standup is never a source /// build. The body-size cap the server enforces /// ([`auths_witness::MAX_BODY_BYTES`]) is surfaced here as the front-proxy /// hint so the two limits cannot drift. + /// + /// The node's stable signing identity is injected at first boot (the + /// `WITNESS_SEED` environment value), so the node advertises the same + /// identity across restarts without a key file baked into the image. A + /// file-backed custody downgrade is surfaced in the manifest header so an + /// operator reading it sees the posture they acknowledged. pub fn compose_manifest(&self) -> String { let WitnessImage { reference, @@ -146,8 +151,8 @@ impl StandupRequest { KeyCustody::File => "file (acknowledged downgrade)", }; format!( - "# Embedded witness standup — node + monitor sidecar.\n\ - # Released image only (never a source build); identity custody: {custody}.\n\ + "# Embedded witness standup — one node, released image only.\n\ + # Never a source build; identity custody: {custody}.\n\ # Max accepted body at the proxy mirrors the server cap: {max_body} bytes.\n\ services:\n\ \x20\x20witness:\n\ @@ -155,18 +160,16 @@ impl StandupRequest { \x20\x20\x20\x20read_only: true\n\ \x20\x20\x20\x20ports:\n\ \x20\x20\x20\x20\x20\x20- \"127.0.0.1:{host_port}:{container_port}\"\n\ - \x20\x20\x20\x20volumes:\n\ - \x20\x20\x20\x20\x20\x20- \"{data}:/data\"\n\ - \x20\x20monitor:\n\ - \x20\x20\x20\x20image: ghcr.io/auths-dev/auths-monitor:latest\n\ - \x20\x20\x20\x20depends_on:\n\ - \x20\x20\x20\x20\x20\x20- witness\n", + \x20\x20\x20\x20environment:\n\ + \x20\x20\x20\x20\x20\x20AUTHS_WITNESS_SEED: \"${{WITNESS_SEED}}\"\n\ + \x20\x20\x20\x20command: [\"--bind\", \"0.0.0.0:{container_port}\", \"--curve\", \"ed25519\", \"--persist\", \"/data/receipts.db\"]\n\ + \x20\x20\x20\x20tmpfs:\n\ + \x20\x20\x20\x20\x20\x20- /data\n", custody = custody, max_body = MAX_BODY_BYTES, reference = reference, host_port = self.host_port, container_port = container_port, - data = self.data_dir.display(), ) } } @@ -214,9 +217,11 @@ mod tests { !manifest.contains("build:"), "standup must never build from source" ); + // The node mints its identity at first boot via an injected seed, never + // a key file baked into the image. assert!( - manifest.contains("monitor"), - "the monitor sidecar must be present" + manifest.contains("AUTHS_WITNESS_SEED"), + "the node identity must be injected at boot" ); // The proxy body cap is sourced from the platform server constant. assert!(manifest.contains(&MAX_BODY_BYTES.to_string())); diff --git a/crates/auths-witness-node/src/standup.rs b/crates/auths-witness-node/src/standup.rs index 8b972ee4..d099e0cd 100644 --- a/crates/auths-witness-node/src/standup.rs +++ b/crates/auths-witness-node/src/standup.rs @@ -124,6 +124,18 @@ fn project_name(port: u16) -> String { format!("auths-witness-{port}") } +/// Mint a fresh 32-byte signing-identity seed from the OS CSPRNG, hex-encoded. +/// +/// This is the node's identity at first boot: 32 bytes is the witness signer's +/// seed width, and the hex form is what the node loads. Only the OS-backed +/// random source is used — never a thread-local or seeded PRNG. +fn mint_seed_hex() -> String { + use rand::RngCore; + let mut seed = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut seed); + hex::encode(seed) +} + /// Bring one witness node up and return its proven-live health URL. /// /// The full bring-up, in order: prepare the data dir, refuse early if the @@ -159,6 +171,22 @@ pub fn stand_up( reason: e.to_string(), })?; + // Mint the node's stable signing identity at first boot and pin it next to + // the manifest, so the node advertises the same identity across restarts + // (and an operator never has to think about a key file). Compose reads the + // value from this `.env` and injects it into the node — the manifest never + // carries the secret inline. A re-run reuses the existing identity rather + // than minting a second one. + let env_path = req.data_dir.join(".env"); + if !env_path.exists() { + std::fs::write(&env_path, format!("WITNESS_SEED={}\n", mint_seed_hex())).map_err(|e| { + StandupError::DataDir { + path: env_path.clone(), + reason: e.to_string(), + } + })?; + } + if let Err(reason) = engine.compose_up(&project, &manifest_path) { // Leave nothing half-standing. let _ = engine.compose_down(&project, &manifest_path); diff --git a/docs/deployment/witness/Dockerfile b/docs/deployment/witness/Dockerfile index eb00de02..9deadb80 100644 --- a/docs/deployment/witness/Dockerfile +++ b/docs/deployment/witness/Dockerfile @@ -6,21 +6,36 @@ # (secret/volume or AUTHS_WITNESS_SEED), never baked into the image. FROM rust:1.93 AS build +ARG TARGETARCH WORKDIR /src -RUN rustup target add x86_64-unknown-linux-musl && apt-get update \ +# Install musl tooling and the static target for THIS build's architecture. +# Resolving the Rust triple from the Docker TARGETARCH keeps a single image +# definition correct on both amd64 and arm64 build hosts (no emulated cross). +RUN case "$TARGETARCH" in \ + amd64) echo x86_64-unknown-linux-musl ;; \ + arm64) echo aarch64-unknown-linux-musl ;; \ + *) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \ + esac > /rust-target \ + && apt-get update \ && apt-get install -y --no-install-recommends musl-tools \ && rm -rf /var/lib/apt/lists/* +# Copy the source (including rust-toolchain.toml) FIRST, then add the static +# target to the toolchain the toolchain file actually selects — adding it to a +# different toolchain instance before the copy leaves the build without a musl +# std for the active toolchain (E0463: can't find crate for `core`). COPY . . +RUN rustup target add "$(cat /rust-target)" # Build only the slim witness binary; auths-core `default = []` keeps the # keychain / ssh-agent / pairing surface out of the dependency tree. RUN cargo build --release --locked \ - --target x86_64-unknown-linux-musl \ - -p auths-witness --bin auths-witness + --target "$(cat /rust-target)" \ + -p auths-witness --bin auths-witness \ + && cp "target/$(cat /rust-target)/release/auths-witness" /auths-witness FROM gcr.io/distroless/static-debian12:nonroot # Non-root (the distroless `nonroot` user is uid 65532). USER nonroot:nonroot -COPY --from=build /src/target/x86_64-unknown-linux-musl/release/auths-witness /usr/local/bin/auths-witness +COPY --from=build /auths-witness /usr/local/bin/auths-witness # Receipts DB + keystore live under a writable volume mounted at /data; the rest # of the rootfs is read-only (enforce with `--read-only` / k8s readOnlyRootFilesystem). From c4af2513257846608696159e974995d018fa532c Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 23:53:59 +0100 Subject: [PATCH 06/19] feat(verifier): offline self-contained witness-receipt verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A witness receipt is only corroboration if a third party who does not trust the node can check it alone — on a clean machine, with no network and no registry. The witness's published did:key identity embeds its verification key, so {receipt, signature, identity} is self-contained. - auths-verifier: verify_receipt_offline recovers the witness key from the published did:key and checks the signature over the canonical receipt bytes, returning a parsed Verified / SignatureFailed / UnreadableIdentity verdict (Verified is the only success arm). - auths-witness-node: ReceiptBundle composes that surface — it reimplements no protocol. - auths-cli: auths witness verify-receipt --receipt exposes it (handler feature-gated; the lean default returns the install hint). A tampered or foreign receipt fails closed with a distinct reason. The default build pulls no node crate; clippy clean (-D warnings). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/auths-cli/src/commands/witness.rs | 64 +++++++- crates/auths-verifier/src/lib.rs | 5 +- crates/auths-verifier/src/witness.rs | 193 ++++++++++++++++++++++- crates/auths-witness-node/src/lib.rs | 7 +- crates/auths-witness-node/src/receipt.rs | 111 +++++++++++++ 5 files changed, 376 insertions(+), 4 deletions(-) create mode 100644 crates/auths-witness-node/src/receipt.rs diff --git a/crates/auths-cli/src/commands/witness.rs b/crates/auths-cli/src/commands/witness.rs index a735edf7..42830e96 100644 --- a/crates/auths-cli/src/commands/witness.rs +++ b/crates/auths-cli/src/commands/witness.rs @@ -74,6 +74,21 @@ pub enum WitnessSubcommand { port: u16, }, + /// Verify a witness receipt offline, on this machine alone. + /// + /// Reads a receipt bundle (a witness's signed receipt paired with the + /// witness's published identity) and checks it with no network and no + /// registry — everything needed is in the bundle. Exits non-zero, with a + /// distinct reason, if the receipt does not verify (a tampered or foreign + /// receipt). This is how a third party who does not trust the node confirms + /// a receipt is genuine corroboration. + #[command(name = "verify-receipt")] + VerifyReceipt { + /// Path to the receipt bundle JSON file (`-` reads from stdin). + #[clap(long)] + receipt: PathBuf, + }, + /// Open a signed candidate entry to register this node in the directory. Register { /// Public base URL operators will reach this node at. @@ -209,6 +224,7 @@ pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option) -> Result< } => node::up(port, data_dir, accept_file_key, image), WitnessSubcommand::Down { data_dir, port } => node::down(data_dir, port), WitnessSubcommand::Status { port } => node::status(port), + WitnessSubcommand::VerifyReceipt { receipt } => node::verify_receipt(receipt), WitnessSubcommand::Register { endpoint } => node::register(endpoint), WitnessSubcommand::Logs { data_dir } => node::logs(data_dir), @@ -400,9 +416,12 @@ mod node { use std::path::PathBuf; use std::time::Duration; + use std::io::Read; + use anyhow::{Result, anyhow}; use auths_witness_node::{ - DockerEngine, KeyCustody, SocketHealthCheck, StandupRequest, stand_up, tear_down, + DockerEngine, KeyCustody, OfflineReceiptVerdict, ReceiptBundle, SocketHealthCheck, + StandupRequest, stand_up, tear_down, }; /// How long to wait for a freshly stood-up node to answer its health @@ -468,6 +487,46 @@ mod node { } } + /// Verify a receipt bundle offline — the third-party corroboration check. + /// + /// Reads the bundle (file path or `-` for stdin), then decides from its + /// bytes alone: no network, no registry, no node need be running. A receipt + /// that does not verify is a non-zero exit carrying the distinct reason, so + /// a tampered or foreign receipt is rejected loudly, never accepted as data. + pub fn verify_receipt(receipt: PathBuf) -> Result<()> { + let bytes = if receipt.as_os_str() == "-" { + let mut buf = Vec::new(); + std::io::stdin() + .read_to_end(&mut buf) + .map_err(|e| anyhow!("could not read the receipt bundle from stdin: {e}"))?; + buf + } else { + std::fs::read(&receipt).map_err(|e| { + anyhow!( + "could not read the receipt bundle at {}: {e}", + receipt.display() + ) + })? + }; + + let bundle = ReceiptBundle::from_json(&bytes) + .map_err(|e| anyhow!("the receipt bundle is not a readable receipt: {e}"))?; + + match bundle.verify_offline() { + OfflineReceiptVerdict::Verified { witness } => { + println!("verified: this receipt was issued by {witness}"); + Ok(()) + } + OfflineReceiptVerdict::SignatureFailed { witness } => Err(anyhow!( + "rejected: this receipt does not verify against {witness} — \ + it was altered or was not issued by that node" + )), + OfflineReceiptVerdict::UnreadableIdentity { reason } => Err(anyhow!( + "rejected: the witness identity in the bundle is unreadable: {reason}" + )), + } + } + pub fn register(endpoint: String) -> Result<()> { println!("opening signed registration for {endpoint}"); Ok(()) @@ -510,6 +569,9 @@ mod node { pub fn status(_port: u16) -> Result<()> { unavailable("status") } + pub fn verify_receipt(_receipt: PathBuf) -> Result<()> { + unavailable("verify-receipt") + } pub fn register(_endpoint: String) -> Result<()> { unavailable("register") } diff --git a/crates/auths-verifier/src/lib.rs b/crates/auths-verifier/src/lib.rs index 402c4fb7..a28be499 100644 --- a/crates/auths-verifier/src/lib.rs +++ b/crates/auths-verifier/src/lib.rs @@ -133,7 +133,10 @@ pub use verify::{ }; // Re-export witness types -pub use witness::{SignedReceipt, WitnessQuorum, WitnessReceiptResult, WitnessVerifyConfig}; +pub use witness::{ + OfflineReceiptVerdict, SignedReceipt, WitnessQuorum, WitnessReceiptResult, WitnessVerifyConfig, + verify_receipt_offline, +}; // Re-export KERI types directly from auths-keri pub use auths_keri::{ diff --git a/crates/auths-verifier/src/witness.rs b/crates/auths-verifier/src/witness.rs index f34b341f..783f8d6d 100644 --- a/crates/auths-verifier/src/witness.rs +++ b/crates/auths-verifier/src/witness.rs @@ -20,9 +20,124 @@ pub use auths_keri::witness::{Receipt, ReceiptTag, SignedReceipt}; use auths_crypto::CryptoProvider; -use auths_keri::Said; +use auths_crypto::did_key::{DecodedDidKey, did_key_decode}; +use auths_keri::{KeriPublicKey, Said}; use serde::{Deserialize, Serialize}; +/// The outcome of verifying a single receipt against a witness's published +/// identity, with NO network and NO registry — a stranger holding only the +/// receipt and the identity decides here. +/// +/// The verdict is a parsed sum type, not a boolean: the caller can render +/// exactly why a receipt failed (an unreadable identity vs. a signature that +/// did not check) without re-inspecting anything. `Verified` is the only +/// success arm, so a receipt that did not verify can never be mistaken for one +/// that did. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "result", rename_all = "kebab-case")] +pub enum OfflineReceiptVerdict { + /// The signature verifies against the key embedded in the published + /// identity. The receipt is genuine corroboration. + Verified { + /// The published witness identity the signature verified against. + witness: String, + }, + /// The published identity is not a readable `did:key` carrying a supported + /// verification key, so no key could be recovered to check against. + UnreadableIdentity { + /// The reason the identity could not be decoded into a key. + reason: String, + }, + /// A key was recovered from the identity, but the signature does not verify + /// over the canonical receipt bytes — a tampered or foreign receipt. + SignatureFailed { + /// The published witness identity the signature was checked against. + witness: String, + }, +} + +impl OfflineReceiptVerdict { + /// Whether the receipt is genuine corroboration (the only success arm). + pub fn is_verified(&self) -> bool { + matches!(self, OfflineReceiptVerdict::Verified { .. }) + } +} + +/// Verify a witness receipt offline against the witness's PUBLISHED identity +/// alone — no network, no registry, no separately-supplied key table. +/// +/// This is the self-contained corroboration check a third party runs on a +/// clean machine: the witness's published `did:key` identity *embeds* its +/// verification key, so `{receipt, signature, identity}` is everything needed +/// to decide. The published identity is the only trust input; the receipt body +/// carries the *controller* AID in `i`, never the witness, so it cannot +/// self-attest. +/// +/// The verdict distinguishes an unreadable identity (the wrong string was +/// carried) from a signature that did not check (a tampered or foreign +/// receipt). A single bit flipped anywhere in the signature, the receipt body, +/// or the identity moves the result off [`OfflineReceiptVerdict::Verified`]. +/// +/// The signed bytes are `serde_json::to_vec(&receipt)`, matching exactly what a +/// witness server signs when it issues the receipt. +/// +/// Args: +/// * `signed`: the receipt body paired with the witness's detached signature. +/// * `witness_identity`: the witness's published `did:key:z…` identity (as +/// advertised at its health endpoint). +/// +/// Usage: +/// ```ignore +/// let verdict = verify_receipt_offline(&signed, "did:key:z6Mk…"); +/// assert!(verdict.is_verified()); +/// ``` +pub fn verify_receipt_offline( + signed: &SignedReceipt, + witness_identity: &str, +) -> OfflineReceiptVerdict { + let decoded = match did_key_decode(witness_identity) { + Ok(d) => d, + Err(e) => { + return OfflineReceiptVerdict::UnreadableIdentity { + reason: e.to_string(), + }; + } + }; + + let key = match &decoded { + DecodedDidKey::Ed25519(bytes) => KeriPublicKey::from_verkey_bytes(bytes, decoded.curve()), + DecodedDidKey::P256(bytes) => KeriPublicKey::from_verkey_bytes(bytes, decoded.curve()), + }; + let key = match key { + Ok(k) => k, + Err(e) => { + return OfflineReceiptVerdict::UnreadableIdentity { + reason: e.to_string(), + }; + } + }; + + let payload = match serde_json::to_vec(&signed.receipt) { + Ok(p) => p, + Err(e) => { + // A receipt that cannot be re-serialized to its canonical bytes + // cannot be checked against any key — it is unusable, not verified. + return OfflineReceiptVerdict::UnreadableIdentity { + reason: format!("receipt is not serializable to its signing bytes: {e}"), + }; + } + }; + + match key.verify_signature(&payload, &signed.signature) { + Ok(()) => OfflineReceiptVerdict::Verified { + witness: witness_identity.to_string(), + }, + Err(_) => OfflineReceiptVerdict::SignatureFailed { + witness: witness_identity.to_string(), + }, + } +} + /// Result of witness quorum verification. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct WitnessQuorum { @@ -276,4 +391,80 @@ mod tests { let parsed: Receipt = serde_json::from_str(&json_out).unwrap(); assert_eq!(receipt, parsed); } + + /// A signed receipt and the witness's published `did:key` identity, where the + /// identity embeds the very key that signed — exactly the self-contained + /// pair a stranger carries to verify offline. + fn signed_with_published_identity(event_said: &str) -> (SignedReceipt, String) { + use auths_crypto::CurveType; + let (kp, pk) = create_test_keypair(&[42u8; 32]); + let signed = create_signed_receipt(&kp, "EController", event_said, 0); + let identity = auths_verifier_did_key(&pk, CurveType::Ed25519); + (signed, identity) + } + + fn auths_verifier_did_key(pk: &[u8], curve: auths_crypto::CurveType) -> String { + // Build the published did:key the same way a witness advertises it. + crate::types::CanonicalDid::from_public_key_did_key(pk, curve).into_inner() + } + + #[test] + fn offline_verify_accepts_a_genuine_receipt_with_no_key_table() { + let (signed, identity) = signed_with_published_identity("EEventOffline"); + let verdict = verify_receipt_offline(&signed, &identity); + assert!( + verdict.is_verified(), + "a genuine receipt must verify against the published identity alone: {verdict:?}" + ); + } + + #[test] + fn offline_verify_rejects_a_bit_flipped_signature() { + let (mut signed, identity) = signed_with_published_identity("EEventOffline"); + signed.signature[0] ^= 0x01; + let verdict = verify_receipt_offline(&signed, &identity); + assert_eq!( + verdict, + OfflineReceiptVerdict::SignatureFailed { + witness: identity.clone() + }, + "a tampered signature must be a distinct SignatureFailed verdict" + ); + assert!(!verdict.is_verified()); + } + + #[test] + fn offline_verify_rejects_a_tampered_receipt_body() { + let (mut signed, identity) = signed_with_published_identity("EEventOffline"); + // Mutate the receipted event SAID — the signature no longer covers it. + signed.receipt.d = Said::new_unchecked("EEventTampered".into()); + let verdict = verify_receipt_offline(&signed, &identity); + assert!(matches!( + verdict, + OfflineReceiptVerdict::SignatureFailed { .. } + )); + } + + #[test] + fn offline_verify_rejects_a_foreign_identity() { + // A genuine receipt, but carried with a DIFFERENT witness's identity. + let (signed, _genuine) = signed_with_published_identity("EEventOffline"); + let (_other_kp, other_pk) = create_test_keypair(&[7u8; 32]); + let foreign = auths_verifier_did_key(&other_pk, auths_crypto::CurveType::Ed25519); + let verdict = verify_receipt_offline(&signed, &foreign); + assert!(matches!( + verdict, + OfflineReceiptVerdict::SignatureFailed { .. } + )); + } + + #[test] + fn offline_verify_flags_an_unreadable_identity_distinctly() { + let (signed, _identity) = signed_with_published_identity("EEventOffline"); + let verdict = verify_receipt_offline(&signed, "not-a-did-key"); + assert!(matches!( + verdict, + OfflineReceiptVerdict::UnreadableIdentity { .. } + )); + } } diff --git a/crates/auths-witness-node/src/lib.rs b/crates/auths-witness-node/src/lib.rs index d3ebc5a1..676b129f 100644 --- a/crates/auths-witness-node/src/lib.rs +++ b/crates/auths-witness-node/src/lib.rs @@ -36,9 +36,11 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; pub mod engine; +pub mod receipt; pub mod standup; pub use engine::{DockerEngine, SocketHealthCheck}; +pub use receipt::ReceiptBundle; pub use standup::{ ContainerEngine, HealthCheck, StandupError, StandupOutcome, stand_up, tear_down, }; @@ -47,7 +49,10 @@ pub use standup::{ // composition explicit and give the operator CLI one import path for the // protocol types it renders, all sourced from the trust kernel. pub use auths_keri::{KERI_KEY_STATE_VERSION, KSN_TYPE, KeyStateNotice, SignedKsn}; -pub use auths_verifier::{WitnessQuorum, WitnessReceiptResult}; +pub use auths_verifier::{ + OfflineReceiptVerdict, SignedReceipt, WitnessQuorum, WitnessReceiptResult, + verify_receipt_offline, +}; pub use auths_witness::{MAX_BODY_BYTES, MAX_CONCURRENT_REQUESTS, REQUEST_TIMEOUT}; /// The released, attested witness image a node runs. diff --git a/crates/auths-witness-node/src/receipt.rs b/crates/auths-witness-node/src/receipt.rs new file mode 100644 index 00000000..e1bb80ba --- /dev/null +++ b/crates/auths-witness-node/src/receipt.rs @@ -0,0 +1,111 @@ +//! The self-contained receipt artifact a node hands a third party. +//! +//! A witness receipt is only *corroboration* if someone who does not trust the +//! node can check it alone — on a clean machine, with no network and no +//! registry. This module owns the operator-facing *bundle* that makes that +//! possible: the witness's signed receipt paired with the witness's own +//! published identity. Everything a stranger needs to decide is in one file. +//! +//! It owns no protocol. The decision — does this signature verify against the +//! key the published identity embeds? — is made by the platform verifier +//! ([`auths_verifier::verify_receipt_offline`]), composed here. The node crate +//! only frames the artifact and renders the verdict. + +use auths_verifier::{OfflineReceiptVerdict, SignedReceipt, verify_receipt_offline}; +use serde::{Deserialize, Serialize}; + +/// A witness receipt paired with the witness's published identity — the +/// complete, self-contained artifact a third party verifies offline. +/// +/// The two fields together are sufficient and self-describing: the published +/// `identity` is a `did:key` that *embeds* the witness's verification key, so a +/// holder needs no directory lookup and no network call to recover the key the +/// `receipt` was signed with. The receipt body itself names only the +/// *controller* it attests (`receipt.receipt.i`), never the witness — so the +/// bundle cannot self-attest, and the identity is the single trust input. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReceiptBundle { + /// The witness's signed receipt (spec `rct` body + detached signature). + pub receipt: SignedReceipt, + /// The witness's published `did:key:z…` identity (as advertised at its + /// health endpoint), embedding the key the receipt was signed with. Named + /// `witness` to match the platform's stored-receipt vocabulary — one word + /// for "who attested this". + pub witness: String, +} + +impl ReceiptBundle { + /// Parse a bundle from its JSON wire form, failing loudly on malformed input. + /// + /// Parse-don't-validate: a returned `ReceiptBundle` is fully formed; nothing + /// downstream re-checks its shape. + /// + /// Args: + /// * `json`: the bundle JSON bytes. + pub fn from_json(json: &[u8]) -> Result { + serde_json::from_slice(json) + } + + /// Verify this receipt offline against the published identity it carries. + /// + /// No network, no registry: the verdict is computed by the platform verifier + /// from the bundle's own bytes alone. The result is a parsed sum type, so a + /// caller renders exactly why a receipt failed without re-inspecting it. + pub fn verify_offline(&self) -> OfflineReceiptVerdict { + verify_receipt_offline(&self.receipt, &self.witness) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // A genuine bundle captured from a real witness node: a signed receipt and + // the node's published did:key identity, which embeds the signing key. This + // is data (a real artifact), not a re-implementation of any protocol. + const GENUINE_BUNDLE: &str = r#"{ + "receipt": { + "receipt": { + "v": "KERI10JSON000000_", + "t": "rct", + "d": "EPVOwKzgOeQ2rD5nv_fXzD036LBYcIgyaD3AgD0ghToU", + "i": "EProbeControllerWITN2000000000000000000000000", + "s": "0" + }, + "signature": "3828f4b8c9156f3603060e3c71f38324bc968ea6547fe3144ee059f3219879e9dd824d49db705478d71c0b597d5bc30ff53f79841266ded9275782259f5b270c" + }, + "witness": "did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S" + }"#; + + #[test] + fn a_genuine_bundle_verifies_offline() { + let bundle = ReceiptBundle::from_json(GENUINE_BUNDLE.as_bytes()).unwrap(); + let verdict = bundle.verify_offline(); + assert!( + verdict.is_verified(), + "a genuine receipt + published identity must verify alone: {verdict:?}" + ); + } + + #[test] + fn a_bit_flipped_signature_is_rejected() { + let mut bundle = ReceiptBundle::from_json(GENUINE_BUNDLE.as_bytes()).unwrap(); + bundle.receipt.signature[0] ^= 0x01; + assert!( + !bundle.verify_offline().is_verified(), + "a tampered signature must not verify" + ); + } + + #[test] + fn a_foreign_identity_is_rejected() { + // The genuine receipt, but carried with a different witness's identity + // (one of the fixture's other nodes) — must not verify. + let mut bundle = ReceiptBundle::from_json(GENUINE_BUNDLE.as_bytes()).unwrap(); + bundle.witness = "did:key:z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK".to_string(); + assert!( + !bundle.verify_offline().is_verified(), + "a receipt carried with the wrong witness identity must not verify" + ); + } +} From 13767be38f321ae72e6c42a810d1e7fe82a0e2f5 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 00:57:44 +0100 Subject: [PATCH 07/19] feat(witness): serve a conformant key-state notice from a running node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The witness server retained receipts and first-seen SAIDs but discarded the event bodies, so it could not recover an identity's current key-state (keys, thresholds, next-commitment) to serve a thin client. Retain each verified event body (a new auths-core witness storage events table, first-seen-wins) and serve the current key-state at a stable endpoint, GET /witness/{prefix}/key-state, as a KERI-conformant KeyStateRecord ({vn,i,s,p,d,f,dt,et,kt,k,nt,n,bt,b,c,ee,di}) — built only via the trust kernel's own auths_keri::KeyStateRecord::from_kel after a TrustedKel replay, never a hand-rolled serializer. The endpoint 404s when the witness has corroborated no events for the prefix (it cannot notice a key-state it never saw). Add KeyStateRecord::sequence()/check_not_stale(last_seen) (returning the existing KsnError::Stale) and an auths key-state --ingest --reject-stale-below flag that composes it, so a verifier already holding a newer state fails closed on a rewind to an older notice, with a distinct reason. The served record reconstructs byte-for-byte inside the keripy reference implementation, and a keripy-published notice ingests on the node — the wire shape is bidirectionally interoperable, on the running node. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/auths-cli/src/commands/key_state.rs | 20 ++ crates/auths-core/src/witness/server.rs | 248 ++++++++++++++++++++- crates/auths-core/src/witness/storage.rs | 117 +++++++++- crates/auths-keri/src/ksn.rs | 44 ++++ 4 files changed, 427 insertions(+), 2 deletions(-) diff --git a/crates/auths-cli/src/commands/key_state.rs b/crates/auths-cli/src/commands/key_state.rs index 11061ff2..3551a2d3 100644 --- a/crates/auths-cli/src/commands/key_state.rs +++ b/crates/auths-cli/src/commands/key_state.rs @@ -36,6 +36,13 @@ pub struct KeyStateCommand { #[clap(long, value_name = "KSN.json", conflicts_with = "from_kel")] pub ingest: Option, + /// Reject an ingested notice whose sequence is below this (lowercase-hex) + /// value — a verifier already holding a newer state refuses a stale or + /// replayed view. Fails closed with a distinct reason; only valid with + /// `--ingest`. + #[clap(long, value_name = "HEX_SEQ", requires = "ingest")] + pub reject_stale_below: Option, + /// Timestamp (RFC 3339) to stamp an emitted notice with. Defaults to the /// epoch so emission stays deterministic; pass the real `now` to publish. #[clap(long, default_value = "1970-01-01T00:00:00+00:00")] @@ -81,6 +88,19 @@ impl KeyStateCommand { .map_err(|e| anyhow!("read ksn {}: {e}", path.display()))?; let record: KeyStateRecord = serde_json::from_str(&json).map_err(|e| anyhow!("parse KERI ksn: {e}"))?; + + // Fail closed on a stale view if the verifier names the newest state it + // already trusts — a rewind below it is a stale or replayed notice. + if let Some(hex_seq) = &self.reject_stale_below { + let last_seen = + u128::from_str_radix(hex_seq.trim_start_matches("0x"), 16).map_err(|_| { + anyhow!("--reject-stale-below must be lowercase hex, got {hex_seq:?}") + })?; + record.check_not_stale(last_seen).map_err(|e| { + anyhow!("rejected: stale key-state notice — {e}; a verifier holding a newer state refuses to rewind") + })?; + } + let state = record.into_key_state(); println!("{}", serde_json::to_string_pretty(&state)?); Ok(()) diff --git a/crates/auths-core/src/witness/server.rs b/crates/auths-core/src/witness/server.rs index cf186c8e..82d929ab 100644 --- a/crates/auths-core/src/witness/server.rs +++ b/crates/auths-core/src/witness/server.rs @@ -31,7 +31,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Mutex; -use auths_keri::{KeriSequence, VersionString}; +use auths_keri::{KeriSequence, KeyStateRecord, TrustedKel, VersionString, parse_kel_json}; use super::error::{DuplicityEvidence, WitnessError}; use super::receipt::{Receipt, ReceiptTag, SignedReceipt}; @@ -357,6 +357,7 @@ pub fn router(state: WitnessServerState) -> Router { .route("/witness/{prefix}/head", get(get_head)) .route("/witness/{prefix}/said/{seq}", get(get_said_at_seq)) .route("/witness/{prefix}/receipt/{said}", get(get_receipt)) + .route("/witness/{prefix}/key-state", get(get_key_state)) .route("/health", get(health)) .with_state(state) } @@ -693,6 +694,28 @@ async fn submit_event( )); } + // Retain the verified event body so the witness can later replay this + // identity's KEL into the current key-state it serves. The body is + // canonical JSON of the event we just SAID- and signature-checked. + let event_json = serde_json::to_string(&event).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("failed to serialize event for retention: {e}"), + duplicity: None, + }), + ) + })?; + if let Err(e) = storage.store_event(now, &prefix, event_s, &event_json) { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("failed to store event: {}", e), + duplicity: None, + }), + )); + } + Ok(Json(signed)) } Ok(Some(existing_said)) => { @@ -821,6 +844,96 @@ async fn get_receipt( } } +/// `GET /witness/{prefix}/key-state` — the current key-state notice for `prefix`. +/// +/// Replays the KEL this witness has corroborated for `prefix` into its current +/// key-state and serves it as a **KERI-conformant key-state record** +/// (`{vn,i,s,p,d,f,dt,et,kt,k,nt,n,bt,b,c,ee,di}`) — the wire shape a keripy / +/// keriox peer reads. A thin client can trust this identity's current keys +/// without replaying the whole log itself. +/// +/// The notice describes exactly the history *this* witness saw: the record is +/// built only from retained, signature-verified events, never asserted. Returns +/// 404 when the witness has corroborated no events for the prefix (it cannot +/// notice a key-state it never observed), and 500 if a retained KEL fails to +/// replay (a corrupted store — surfaced, never papered over). +async fn get_key_state( + State(state): State, + AxumPath(prefix_str): AxumPath, +) -> Result, (StatusCode, Json)> { + let prefix = Prefix::new_unchecked(prefix_str); + + let kel_json = { + let storage = state.inner.storage.lock().map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "internal lock error".to_string(), + duplicity: None, + }), + ) + })?; + storage.get_kel(&prefix).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("storage error: {e}"), + duplicity: None, + }), + ) + })? + }; + + if kel_json.is_empty() { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!( + "no key-state for {} — this witness has corroborated no events for it", + prefix.as_str() + ), + duplicity: None, + }), + )); + } + + // Each row is one canonical event; assemble the in-order KEL as a JSON array + // and replay it through the platform's own validation, never a hand-rolled + // parser — the key-state and the notice wire shape are the trust kernel's. + let kel_array = format!("[{}]", kel_json.join(",")); + let events = parse_kel_json(&kel_array).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("retained KEL did not parse: {e}"), + duplicity: None, + }), + ) + })?; + let key_state = TrustedKel::from_trusted_source(&events) + .replay() + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("retained KEL did not replay: {e}"), + duplicity: None, + }), + ) + })?; + + let dt = (state.inner.clock)().to_rfc3339(); + let record = KeyStateRecord::from_kel(&events, &key_state, dt).ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "retained KEL is empty after replay".to_string(), + duplicity: None, + }), + ))?; + + Ok(Json(record)) +} + /// GET /health - Health check. async fn health(State(state): State) -> Json { let storage = state @@ -894,6 +1007,139 @@ mod tests { event } + /// Build a full, replay-able KERI inception event — every establishment field + /// present (`kt,k,nt,n,bt,b,c,a`), `s` as a lowercase-hex string, a valid + /// Ed25519 self-signature, and the auths-computed SAID. Unlike the minimal + /// [`make_valid_icp_event`], this is a complete KEL the witness can replay + /// into key-state — the shape a real controller submits. + fn make_full_icp_event() -> serde_json::Value { + let (pkcs8, _pk_hex) = test_keypair(); + let kp = Ed25519KeyPair::from_pkcs8(&pkcs8).unwrap(); + let k0 = auths_keri::KeriPublicKey::ed25519(kp.public_key().as_ref()) + .unwrap() + .to_qb64() + .unwrap(); + // A next-key commitment (a single-key kt=1/nt=1 inception). + let next = auths_keri::KeriPublicKey::ed25519(&[9u8; 32]).unwrap(); + let ncommit = auths_keri::compute_next_commitment(&next); + + // Self-addressing inception: prefix is blanked during SAID computation, + // then set equal to the SAID. Build with empty d/i and x. + let mut event = serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "icp", + "d": "", + "i": "", + "s": "0", + "kt": "1", + "k": [k0], + "nt": "1", + "n": [ncommit.as_str()], + "bt": "0", + "b": [], + "c": [], + "a": [], + "x": "" + }); + + // The SAID self-addresses the inception (d and i both blanked during the + // digest), so set i = d = SAID first. + let said = crate::crypto::said::compute_said(&event).unwrap(); + let said_str = said.into_inner(); + event["d"] = serde_json::Value::String(said_str.clone()); + event["i"] = serde_json::Value::String(said_str); + + // Sign exactly what the witness verifies: the event with d and x blanked, + // i left at the self-addressed prefix. + let mut to_sign = event.clone(); + to_sign["d"] = serde_json::Value::String(String::new()); + to_sign["x"] = serde_json::Value::String(String::new()); + let payload = serde_json::to_vec(&to_sign).unwrap(); + let sig = kp.sign(&payload); + event["x"] = serde_json::Value::String(hex::encode(sig.as_ref())); + + event + } + + /// Submit `event` to a fresh server and return its (router, prefix). + async fn submit_to_fresh_server(event: &serde_json::Value) -> (Router, String) { + let prefix = event["i"].as_str().unwrap().to_string(); + let state = test_state(); + let app = router(state); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/witness/{prefix}/event")) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(event).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "full icp must be witnessed"); + (app, prefix) + } + + #[tokio::test(flavor = "multi_thread")] + async fn key_state_serves_keri_conformant_record() { + let event = make_full_icp_event(); + let (app, prefix) = submit_to_fresh_server(&event).await; + + let resp = app + .oneshot( + Request::builder() + .uri(format!("/witness/{prefix}/key-state")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let record: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let obj = record.as_object().unwrap(); + + // The KERI ksn wire shape — labels and field order — not the auths envelope. + let keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + assert_eq!( + keys, + vec![ + "vn", "i", "s", "p", "d", "f", "dt", "et", "kt", "k", "nt", "n", "bt", "b", "c", + "ee", "di" + ] + ); + assert_eq!(obj["vn"], serde_json::json!([1, 0])); + assert_eq!(obj["i"], serde_json::Value::String(prefix.clone())); + assert_eq!(obj["s"], "0"); + assert_eq!(obj["et"], "icp"); + assert_eq!(obj["d"], serde_json::Value::String(prefix)); + + // The served record projects back to a usable key-state (parse-don't-validate). + let parsed: auths_keri::KeyStateRecord = serde_json::from_slice(&body).unwrap(); + let state = parsed.into_key_state(); + assert_eq!(state.sequence, 0); + } + + #[tokio::test(flavor = "multi_thread")] + async fn key_state_unknown_prefix_is_404() { + let app = router(test_state()); + let resp = app + .oneshot( + Request::builder() + .uri("/witness/ENeverWitnessed/key-state") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + #[tokio::test(flavor = "multi_thread")] async fn health_endpoint() { let state = test_state(); diff --git a/crates/auths-core/src/witness/storage.rs b/crates/auths-core/src/witness/storage.rs index cff5b91f..e14ee10e 100644 --- a/crates/auths-core/src/witness/storage.rs +++ b/crates/auths-core/src/witness/storage.rs @@ -5,9 +5,12 @@ //! //! # Schema //! -//! Two tables are used: +//! Three tables are used: //! - `first_seen`: Records (prefix, seq) → SAID mappings //! - `receipts`: Stores issued receipts by (prefix, event_said) +//! - `events`: Retains the verified key-event body by (prefix, seq) so the +//! witness can replay an identity's KEL into current key-state (the key-state +//! notice it serves), not just the SAID it first saw //! //! # Feature Gate //! @@ -80,6 +83,14 @@ impl WitnessStorage { PRIMARY KEY (prefix, event_said) ); + CREATE TABLE IF NOT EXISTS events ( + prefix TEXT NOT NULL, + seq INTEGER NOT NULL, + event_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (prefix, seq) + ); + CREATE INDEX IF NOT EXISTS idx_receipts_prefix ON receipts(prefix); "#, ) @@ -144,6 +155,74 @@ impl WitnessStorage { } } + /// Retain a verified key-event body at `(prefix, seq)`. + /// + /// First-seen-wins: a body is recorded only the first time the witness + /// accepts an event at `(prefix, seq)`. A later submission at the same + /// position with the same SAID is a no-op; a different SAID is rejected + /// upstream as duplicity before reaching here. The retained KEL is what the + /// witness replays into the current key-state it serves — so the served + /// notice describes exactly the history this witness corroborated. + /// + /// `event_json` is the canonical JSON of the accepted event (already SAID- + /// and signature-verified by the caller). + pub fn store_event( + &self, + now: DateTime, + prefix: &Prefix, + seq: u128, + event_json: &str, + ) -> Result<(), WitnessError> { + let now = now.to_rfc3339(); + + let mut stmt = self + .conn + .prepare( + "INSERT OR IGNORE INTO events (prefix, seq, event_json, created_at) \ + VALUES (?, ?, ?, ?)", + ) + .map_err(|e| WitnessError::Storage(format!("failed to prepare store_event: {}", e)))?; + + stmt.bind((1, prefix.as_str())) + .map_err(|e| WitnessError::Storage(format!("failed to bind prefix: {}", e)))?; + stmt.bind((2, seq as i64)) + .map_err(|e| WitnessError::Storage(format!("failed to bind seq: {}", e)))?; + stmt.bind((3, event_json)) + .map_err(|e| WitnessError::Storage(format!("failed to bind event_json: {}", e)))?; + stmt.bind((4, now.as_str())) + .map_err(|e| WitnessError::Storage(format!("failed to bind now: {}", e)))?; + + stmt.next() + .map_err(|e| WitnessError::Storage(format!("failed to execute store_event: {}", e)))?; + + Ok(()) + } + + /// Retrieve a prefix's retained KEL, in sequence order (inception first). + /// + /// Returns the verified event bodies this witness has accepted for `prefix`, + /// ordered by sequence — the in-order replay input for building a key-state + /// notice. An empty vector means the witness has corroborated no events for + /// this prefix (it cannot speak to a key-state it never saw). + pub fn get_kel(&self, prefix: &Prefix) -> Result, WitnessError> { + let mut stmt = self + .conn + .prepare("SELECT event_json FROM events WHERE prefix = ? ORDER BY seq ASC") + .map_err(|e| WitnessError::Storage(format!("failed to prepare get_kel: {}", e)))?; + + stmt.bind((1, prefix.as_str())) + .map_err(|e| WitnessError::Storage(format!("failed to bind prefix: {}", e)))?; + + let mut kel = Vec::new(); + while let Ok(sqlite::State::Row) = stmt.next() { + let json: String = stmt + .read(0) + .map_err(|e| WitnessError::Storage(format!("failed to read event_json: {}", e)))?; + kel.push(json); + } + Ok(kel) + } + /// Check for duplicity: same (prefix, seq) with different SAID. /// /// Returns: @@ -429,6 +508,42 @@ mod tests { assert!(result.is_none()); } + #[test] + fn store_event_and_get_kel_in_order() { + let storage = WitnessStorage::in_memory().unwrap(); + let p = prefix("EPrefix"); + + // Inserted out of order; retrieval must be sequence-ordered. + storage.store_event(now(), &p, 1, r#"{"s":"1"}"#).unwrap(); + storage.store_event(now(), &p, 0, r#"{"s":"0"}"#).unwrap(); + + let kel = storage.get_kel(&p).unwrap(); + assert_eq!(kel, vec![r#"{"s":"0"}"#, r#"{"s":"1"}"#]); + } + + #[test] + fn store_event_first_seen_wins() { + let storage = WitnessStorage::in_memory().unwrap(); + let p = prefix("EPrefix"); + + storage + .store_event(now(), &p, 0, r#"{"first":true}"#) + .unwrap(); + // A second write at the same position is ignored — the witness retains + // the body it first corroborated. + storage + .store_event(now(), &p, 0, r#"{"second":true}"#) + .unwrap(); + + assert_eq!(storage.get_kel(&p).unwrap(), vec![r#"{"first":true}"#]); + } + + #[test] + fn get_kel_unknown_prefix_is_empty() { + let storage = WitnessStorage::in_memory().unwrap(); + assert!(storage.get_kel(&prefix("ENeverSeen")).unwrap().is_empty()); + } + #[test] fn get_latest_seq() { let storage = WitnessStorage::in_memory().unwrap(); diff --git a/crates/auths-keri/src/ksn.rs b/crates/auths-keri/src/ksn.rs index 86570603..fa7f051e 100644 --- a/crates/auths-keri/src/ksn.rs +++ b/crates/auths-keri/src/ksn.rs @@ -159,6 +159,33 @@ impl KeyStateRecord { }) } + /// The sequence number this record notices (the latest event's `s`, decoded + /// from its lowercase-hex wire form). + pub fn sequence(&self) -> u128 { + u128::from_str_radix(self.s.trim_start_matches("0x"), 16).unwrap_or(0) + } + + /// Reject this notice if it is older than a state the verifier already trusts. + /// + /// A key-state notice is a snapshot; a thin client that has already seen + /// sequence `last_seen_seq` (e.g. it holds a fresher witness receipt) must not + /// accept a notice that rewinds below it — that is a stale or replayed view of + /// the identity. Returns [`KsnError::Stale`] when `self.sequence() < + /// last_seen_seq`; equal-or-newer is fine. + /// + /// Args: + /// * `last_seen_seq`: The highest sequence the verifier already trusts. + pub fn check_not_stale(&self, last_seen_seq: u128) -> Result<(), KsnError> { + let got = self.sequence(); + if got < last_seen_seq { + return Err(KsnError::Stale { + got, + seen: last_seen_seq, + }); + } + Ok(()) + } + /// Project this KERI record back to the auths [`KeyState`] the rest of the /// platform reasons over (a thin client ingesting a peer's published state). /// @@ -942,6 +969,23 @@ mod tests { assert!(projected.is_non_transferable); } + #[test] + fn key_state_record_check_not_stale() { + let events = crate::validate::parse_kel_json(ICP_KEL_JSON).unwrap(); + let state = crate::validate::TrustedKel::from_trusted_source(&events) + .replay() + .unwrap(); + let record = KeyStateRecord::from_kel(&events, &state, "t").unwrap(); + assert_eq!(record.sequence(), 0); + // A verifier that has seen seq 0 accepts a seq-0 notice; one holding a + // newer (seq 1) receipt rejects this stale seq-0 view. + assert!(record.check_not_stale(0).is_ok()); + assert!(matches!( + record.check_not_stale(1), + Err(KsnError::Stale { got: 0, seen: 1 }) + )); + } + #[test] fn key_state_record_ingests_peer_published_record() { // A keripy-shaped record arriving over the wire (string sequence, hex From aa5eeb46ed8932e07906bea297f78a1fb0dfaccf Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 02:25:10 +0100 Subject: [PATCH 08/19] feat(witness): a node proves which binary it runs (signed build attestation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A witness operator vouches for the network; the operator must in turn be vouchable. A running witness node now exposes a signed version+digest build attestation and `auths witness status` verifies it — a forged attestation (validly signed, but over a different binary) is rejected. - auths-core witness server: the node measures its own running binary (sha256 of /proc/self/exe at startup) and serves a BuildProof at GET /build pairing that self-measurement with the signed `auths artifact sign` attestation. 404 when no attestation was configured — a node that cannot prove its binary says so plainly; the server interprets nothing. - auths-verifier: verify_build_attestation_offline — a two-leg, fail-closed verdict: the signature verifies against the key the self-describing did:key issuer embeds, AND the attested digest equals the running digest. A valid signature over the wrong binary lands on DigestMismatch, never Verified. - auths-witness-node: BuildAttestation::verify -> NodeBuildVerdict composes the verifier; a SocketHttpFetch port reads /build. `auths witness status` renders the verdict and fails closed on a forged or absent build. `auths witness up --build-attestation` mounts the released image's attestation into standup. - auths-witness binary: reads AUTHS_WITNESS_BUILD_ATTESTATION at boot and attaches the self-measured proof. The witness-node feature stays additive (no core crate depends on the node crate; the default build pulls none of it). No protocol is hand-rolled — the node composes the platform's public verifier. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 + crates/auths-cli/src/commands/witness.rs | 63 +++++-- crates/auths-core/src/witness/mod.rs | 2 +- crates/auths-core/src/witness/server.rs | 161 ++++++++++++++++ crates/auths-sdk/src/witness.rs | 2 +- crates/auths-verifier/src/lib.rs | 6 +- crates/auths-verifier/src/witness.rs | 184 ++++++++++++++++++ crates/auths-witness-node/Cargo.toml | 3 + crates/auths-witness-node/src/build.rs | 228 +++++++++++++++++++++++ crates/auths-witness-node/src/engine.rs | 53 ++++++ crates/auths-witness-node/src/lib.rs | 62 +++++- crates/auths-witness-node/src/standup.rs | 33 ++++ crates/auths-witness/Cargo.toml | 3 + crates/auths-witness/src/main.rs | 46 ++++- 14 files changed, 826 insertions(+), 22 deletions(-) create mode 100644 crates/auths-witness-node/src/build.rs diff --git a/Cargo.lock b/Cargo.lock index fbcf86ec..a1f53d81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1060,6 +1060,7 @@ dependencies = [ "axum", "clap", "http-body-util", + "serde_json", "tokio", "tower", "tower-http", @@ -1080,6 +1081,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "tokio", ] [[package]] diff --git a/crates/auths-cli/src/commands/witness.rs b/crates/auths-cli/src/commands/witness.rs index 42830e96..3f3b4e49 100644 --- a/crates/auths-cli/src/commands/witness.rs +++ b/crates/auths-cli/src/commands/witness.rs @@ -54,6 +54,13 @@ pub enum WitnessSubcommand { /// tag or to run an image already present on an air-gapped host. #[clap(long)] image: Option, + + /// Path to the released image's signed build attestation (`auths + /// artifact sign` output). When supplied, the node serves a proof of + /// which binary it runs, and `status` verifies it. Operators pin the + /// attestation that ships with the released image. + #[clap(long)] + build_attestation: Option, }, /// Tear a stood-up witness node down. @@ -221,7 +228,8 @@ pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option) -> Result< data_dir, accept_file_key, image, - } => node::up(port, data_dir, accept_file_key, image), + build_attestation, + } => node::up(port, data_dir, accept_file_key, image, build_attestation), WitnessSubcommand::Down { data_dir, port } => node::down(data_dir, port), WitnessSubcommand::Status { port } => node::status(port), WitnessSubcommand::VerifyReceipt { receipt } => node::verify_receipt(receipt), @@ -420,8 +428,9 @@ mod node { use anyhow::{Result, anyhow}; use auths_witness_node::{ - DockerEngine, KeyCustody, OfflineReceiptVerdict, ReceiptBundle, SocketHealthCheck, - StandupRequest, stand_up, tear_down, + BuildAttestation, DockerEngine, HttpFetch, KeyCustody, NodeBuildVerdict, + OfflineReceiptVerdict, ReceiptBundle, SocketHealthCheck, SocketHttpFetch, StandupRequest, + stand_up, tear_down, }; /// How long to wait for a freshly stood-up node to answer its health @@ -437,6 +446,7 @@ mod node { data_dir: PathBuf, accept_file_key: bool, image: Option, + build_attestation: Option, ) -> StandupRequest { let mut req = StandupRequest::local(data_dir); req.host_port = port; @@ -448,6 +458,7 @@ mod node { if let Some(reference) = image { req.image.reference = reference; } + req.build_attestation = build_attestation; req } @@ -456,8 +467,9 @@ mod node { data_dir: PathBuf, accept_file_key: bool, image: Option, + build_attestation: Option, ) -> Result<()> { - let req = plan(port, data_dir, accept_file_key, image); + let req = plan(port, data_dir, accept_file_key, image, build_attestation); // Bring the node (and its monitor sidecar) up for real, then wait until // it answers its health endpoint. Success is a node answering — not the // command merely returning. A failure tears down whatever started and @@ -476,14 +488,40 @@ mod node { pub fn status(port: u16) -> Result<()> { use auths_witness_node::HealthCheck; - let url = format!("http://127.0.0.1:{port}/health"); - if SocketHealthCheck.is_healthy(&url) { - println!("healthy: {url}"); - Ok(()) - } else { - Err(anyhow!( - "no node answering at {url} — is one stood up on port {port}?" - )) + let health_url = format!("http://127.0.0.1:{port}/health"); + if !SocketHealthCheck.is_healthy(&health_url) { + return Err(anyhow!( + "no node answering at {health_url} — is one stood up on port {port}?" + )); + } + println!("healthy: {health_url}"); + + // Prove which binary the node runs. The node serves a signed build + // attestation paired with its own self-measurement; `status` confirms + // the signature holds AND attests the digest the node measured of + // itself. A node that cannot prove its binary, or whose attestation is + // for a different binary, fails closed here — an operator vouching for + // the network must itself be vouchable. + let build_url = format!("http://127.0.0.1:{port}/build"); + let response = SocketHttpFetch + .get(&build_url) + .map_err(|e| anyhow!("could not read the node's build proof: {e}"))?; + if !response.ok { + return Err(anyhow!( + "this node does not prove which binary it runs (no build attestation at \ + {build_url}) — refuse to trust a node that cannot be vouched for" + )); + } + let build = BuildAttestation::from_json(&response.body) + .map_err(|e| anyhow!("the node's build proof is unreadable: {e}"))?; + + let rt = tokio::runtime::Runtime::new()?; + match rt.block_on(build.verify()) { + verdict @ NodeBuildVerdict::Trusted { .. } => { + println!("{}", verdict.summary()); + Ok(()) + } + verdict => Err(anyhow!("{}", verdict.summary())), } } @@ -560,6 +598,7 @@ mod node { _data_dir: PathBuf, _accept_file_key: bool, _image: Option, + _build_attestation: Option, ) -> Result<()> { unavailable("up") } diff --git a/crates/auths-core/src/witness/mod.rs b/crates/auths-core/src/witness/mod.rs index 997eca83..602c4a8b 100644 --- a/crates/auths-core/src/witness/mod.rs +++ b/crates/auths-core/src/witness/mod.rs @@ -112,7 +112,7 @@ pub use identity::{ }; #[cfg(feature = "witness-server")] pub use server::{ - ErrorResponse, HeadResponse, HealthResponse, SaidAtSeqResponse, SubmitEventRequest, + BuildProof, ErrorResponse, HeadResponse, HealthResponse, SaidAtSeqResponse, SubmitEventRequest, WitnessServerConfig, WitnessServerState, router as witness_router, run_server, }; #[cfg(feature = "witness-server")] diff --git a/crates/auths-core/src/witness/server.rs b/crates/auths-core/src/witness/server.rs index 82d929ab..1f59eb22 100644 --- a/crates/auths-core/src/witness/server.rs +++ b/crates/auths-core/src/witness/server.rs @@ -55,6 +55,67 @@ struct WitnessServerInner { storage: Mutex, /// Clock function for getting current time clock: Box DateTime + Send + Sync>, + /// The proof of which binary this node runs (version, self-measured digest, + /// and the signed build attestation). `None` when the binary was started + /// without a build attestation configured, in which case the `/build` + /// surface 404s — a node that cannot prove its binary says so plainly. + build_proof: Option, +} + +/// The proof a node serves of which binary it is running. +/// +/// Three facts, none of which the server interprets: the running binary's +/// `version`, the SHA-256 the binary measured of *itself* at startup +/// (`running_digest`), and the signed build attestation (`attestation`, the raw +/// `auths artifact sign` document). The server is a serving surface — it pairs +/// the self-measurement with the signed claim and hands both to whoever asks. +/// The *verification* (does the attestation's signature hold, and does it attest +/// THIS running digest?) is a relying party's job, done from these bytes alone. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildProof { + /// Version string of the running binary. + pub version: String, + /// SHA-256 (hex) the binary measured of its own on-disk image at startup. + pub running_digest: String, + /// The signed build attestation document (`auths artifact sign` output), + /// carried verbatim so a relying party verifies the original signed bytes. + pub attestation: serde_json::Value, +} + +impl BuildProof { + /// Measure the running binary's own on-disk image and pair the digest with + /// its version and a signed build attestation. + /// + /// The digest is computed over the bytes of the executable this process is + /// running (`current_exe`), so `running_digest` is the node's measurement of + /// *itself* — not a number it was handed. A relying party then checks that + /// the signed `attestation` attests this exact digest. + /// + /// Args: + /// * `version`: the running binary's version string. + /// * `attestation`: the parsed `auths artifact sign` document to serve verbatim. + pub fn measure_self( + version: impl Into, + attestation: serde_json::Value, + ) -> std::io::Result { + let exe = std::env::current_exe()?; + let running_digest = sha256_file_hex(&exe)?; + Ok(Self { + version: version.into(), + running_digest, + attestation, + }) + } +} + +/// SHA-256 (hex) of a file's bytes. +/// +/// Reads the file whole (the witness binary is a few MB) through `std::fs::read` +/// — the sans-IO crate's approved file read — then hashes it. +fn sha256_file_hex(path: &std::path::Path) -> std::io::Result { + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(path)?; + Ok(hex::encode(Sha256::digest(&bytes))) } /// Configuration for the witness server. @@ -69,6 +130,10 @@ pub struct WitnessServerConfig { pub tls_cert_path: Option, /// Path to TLS private key (PEM format). Used by `run_server_tls()` when the `tls` feature is enabled. pub tls_key_path: Option, + /// Proof of which binary the node runs, served at `/build`. `None` leaves + /// the build surface absent (404) — a node that was not given a build + /// attestation does not pretend to have one. + pub build_proof: Option, } impl WitnessServerConfig { @@ -115,8 +180,20 @@ impl WitnessServerConfig { db_path, tls_cert_path: None, tls_key_path: None, + build_proof: None, }) } + + /// Attach the proof of which binary this node runs (served at `/build`). + /// + /// Consuming-builder so a deployed binary that knows its own version, + /// self-measured digest, and signed attestation threads them into the + /// server in one call; a binary started without one simply never calls this + /// and the build surface stays absent. + pub fn with_build_proof(mut self, proof: BuildProof) -> Self { + self.build_proof = Some(proof); + self + } } /// Generate a keypair for the given curve; returns (seed_bytes, pubkey_bytes). @@ -225,6 +302,7 @@ impl WitnessServerState { signer: config.signer, storage: Mutex::new(storage), clock: Box::new(Utc::now), + build_proof: config.build_proof, }), }) } @@ -243,6 +321,7 @@ impl WitnessServerState { signer, storage: Mutex::new(storage), clock: Box::new(Utc::now), + build_proof: None, }), }) } @@ -285,6 +364,7 @@ impl WitnessServerState { signer, storage: Mutex::new(storage), clock: Box::new(Utc::now), + build_proof: None, }), }) } @@ -358,6 +438,7 @@ pub fn router(state: WitnessServerState) -> Router { .route("/witness/{prefix}/said/{seq}", get(get_said_at_seq)) .route("/witness/{prefix}/receipt/{said}", get(get_receipt)) .route("/witness/{prefix}/key-state", get(get_key_state)) + .route("/build", get(get_build)) .route("/health", get(health)) .with_state(state) } @@ -934,6 +1015,32 @@ async fn get_key_state( Ok(Json(record)) } +/// `GET /build` — the node's proof of which binary it runs. +/// +/// Serves the [`BuildProof`] the binary measured of itself and was signed +/// against: version, self-measured running digest, and the verbatim signed +/// attestation. The server interprets none of it — a relying party decides, +/// from these bytes alone, whether the attestation's signature holds AND +/// attests this exact running digest. 404 when the node was started without a +/// build attestation: a node that cannot prove its binary says so plainly, +/// rather than serving an unprovable green. +async fn get_build( + State(state): State, +) -> Result, (StatusCode, Json)> { + match &state.inner.build_proof { + Some(proof) => Ok(Json(proof.clone())), + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "this node was started without a build attestation — \ + it cannot prove which binary it runs" + .to_string(), + duplicity: None, + }), + )), + } +} + /// GET /health - Health check. async fn health(State(state): State) -> Json { let storage = state @@ -1158,6 +1265,60 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[tokio::test(flavor = "multi_thread")] + async fn build_endpoint_absent_without_a_proof_is_404() { + // A node started without a build attestation must NOT serve a `/build` + // surface — it says plainly it cannot prove its binary (404), never an + // unprovable green. + let state = test_state(); + let app = router(state); + let response = app + .oneshot( + Request::builder() + .uri("/build") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test(flavor = "multi_thread")] + async fn build_endpoint_serves_the_configured_proof() { + // With a proof configured, `/build` serves exactly the self-measurement + // and signed attestation the binary handed in — verbatim, uninterpreted. + let mut state = test_state(); + let proof = BuildProof { + version: "1.2.3".to_string(), + running_digest: "abc123".to_string(), + attestation: serde_json::json!({"issuer": "did:key:zTest"}), + }; + // Replace the inner with one carrying the proof (the only field that + // differs from `test_state`'s default). + let inner = Arc::get_mut(&mut state.inner).expect("sole owner in test"); + inner.build_proof = Some(proof.clone()); + + let app = router(state); + let response = app + .oneshot( + Request::builder() + .uri("/build") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let served: BuildProof = serde_json::from_slice(&body).unwrap(); + assert_eq!(served.version, "1.2.3"); + assert_eq!(served.running_digest, "abc123"); + } + #[tokio::test(flavor = "multi_thread")] async fn submit_valid_icp_event_success() { let state = test_state(); diff --git a/crates/auths-sdk/src/witness.rs b/crates/auths-sdk/src/witness.rs index 0691f775..49023206 100644 --- a/crates/auths-sdk/src/witness.rs +++ b/crates/auths-sdk/src/witness.rs @@ -10,7 +10,7 @@ pub use auths_keri::witness::independence::{ #[cfg(feature = "witness-server")] pub use auths_core::witness::{ - WitnessIdentityError, WitnessServerConfig, WitnessServerState, + BuildProof, WitnessIdentityError, WitnessServerConfig, WitnessServerState, generate_and_persist_witness_signer, load_witness_signer, run_server, witness_signer_from_seed_hex, }; diff --git a/crates/auths-verifier/src/lib.rs b/crates/auths-verifier/src/lib.rs index a28be499..073e3ac5 100644 --- a/crates/auths-verifier/src/lib.rs +++ b/crates/auths-verifier/src/lib.rs @@ -133,9 +133,11 @@ pub use verify::{ }; // Re-export witness types +#[cfg(feature = "native")] +pub use witness::verify_build_attestation_offline; pub use witness::{ - OfflineReceiptVerdict, SignedReceipt, WitnessQuorum, WitnessReceiptResult, WitnessVerifyConfig, - verify_receipt_offline, + OfflineBuildVerdict, OfflineReceiptVerdict, SignedReceipt, WitnessQuorum, WitnessReceiptResult, + WitnessVerifyConfig, verify_receipt_offline, }; // Re-export KERI types directly from auths-keri diff --git a/crates/auths-verifier/src/witness.rs b/crates/auths-verifier/src/witness.rs index 783f8d6d..7173df20 100644 --- a/crates/auths-verifier/src/witness.rs +++ b/crates/auths-verifier/src/witness.rs @@ -138,6 +138,140 @@ pub fn verify_receipt_offline( } } +/// The outcome of verifying a node's build attestation offline — does the node +/// prove which binary it runs? +/// +/// Two facts must both hold: the attestation's signature verifies against the +/// key its self-describing `did:key` issuer embeds, AND the digest it attests is +/// the digest of the binary actually running (the node's own self-measurement). +/// A forged attestation — one whose attested digest differs from the running +/// binary — fails on the second even when its signature is perfectly valid, so a +/// swapped attestation cannot pass. `Verified` is the only success arm. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "result", rename_all = "kebab-case")] +pub enum OfflineBuildVerdict { + /// The attestation's signature verifies AND it attests the running digest: + /// the node provably runs the binary the attestation was signed over. + Verified { + /// The digest both attested and measured (they agree). + digest: String, + }, + /// The attestation could not be read as a signed artifact attestation (no + /// payload, no digest, malformed issuer) — there is nothing to check. + Unreadable { + /// Why the attestation could not be interpreted. + reason: String, + }, + /// The attestation's signature does not verify against the key its issuer + /// embeds — it was altered or was not produced by the claimed signer. + SignatureFailed { + /// The issuer the signature was checked against. + issuer: String, + }, + /// The signature is valid, but the attestation attests a DIFFERENT digest + /// than the binary actually running — a forged or stale attestation, paired + /// with a binary it does not describe. + DigestMismatch { + /// The digest the attestation attests. + attested: String, + /// The digest the node measured of its running binary. + running: String, + }, +} + +impl OfflineBuildVerdict { + /// Whether the node provably runs the attested binary (the only success arm). + pub fn is_verified(&self) -> bool { + matches!(self, OfflineBuildVerdict::Verified { .. }) + } +} + +/// Verify a node's build attestation offline against the digest of the binary +/// it is actually running — the "which binary does this node run?" check. +/// +/// The attestation is the `auths artifact sign` document the operator produced +/// over the released binary; its self-describing `did:key` issuer embeds the +/// verification key, so `{attestation, running_digest}` is everything a relying +/// party needs. The check is deliberately two-legged and fail-closed: +/// +/// 1. the signature verifies against the issuer's embedded key +/// ([`verify_with_keys`](crate::verify_with_keys)); and +/// 2. the digest the attestation attests equals `running_digest` — the digest +/// the node measured of its own executable. +/// +/// A valid signature over the WRONG binary fails on leg 2 +/// ([`OfflineBuildVerdict::DigestMismatch`]): an operator cannot vouch for a +/// binary they are not running by attaching a correctly-signed attestation for a +/// different one. This is the dogfood of `auths artifact verify --signature-only` +/// bound to a live self-measurement. +/// +/// Args: +/// * `attestation`: the parsed signed build attestation (`auths artifact sign`). +/// * `running_digest`: the SHA-256 (hex) the node measured of its own binary. +#[cfg(feature = "native")] +pub async fn verify_build_attestation_offline( + attestation: &crate::core::Attestation, + running_digest: &str, +) -> OfflineBuildVerdict { + // The attested digest lives in the artifact payload the signer covered. + let attested = match attestation + .payload + .as_ref() + .and_then(|p| p.get("digest")) + .and_then(|d| d.get("hex")) + .and_then(|h| h.as_str()) + { + Some(h) => h.to_string(), + None => { + return OfflineBuildVerdict::Unreadable { + reason: + "attestation carries no artifact digest to check against the running binary" + .to_string(), + }; + } + }; + + // The issuer is a self-describing `did:key` that embeds the signing key — + // recover it so the signature can be checked from the attestation alone. + let issuer = attestation.issuer.as_str(); + let decoded = match did_key_decode(issuer) { + Ok(d) => d, + Err(e) => { + return OfflineBuildVerdict::Unreadable { + reason: format!("attestation issuer is not a readable did:key: {e}"), + }; + } + }; + let issuer_pk = match crate::core::DevicePublicKey::try_new(decoded.curve(), decoded.bytes()) { + Ok(pk) => pk, + Err(e) => { + return OfflineBuildVerdict::Unreadable { + reason: format!("attestation issuer key is unusable: {e}"), + }; + } + }; + + // Leg 1: the signature must verify against the issuer's embedded key. + if crate::verify_with_keys(attestation, &issuer_pk) + .await + .is_err() + { + return OfflineBuildVerdict::SignatureFailed { + issuer: issuer.to_string(), + }; + } + + // Leg 2: the attested digest must be the digest of the running binary. + if attested != running_digest { + return OfflineBuildVerdict::DigestMismatch { + attested, + running: running_digest.to_string(), + }; + } + + OfflineBuildVerdict::Verified { digest: attested } +} + /// Result of witness quorum verification. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct WitnessQuorum { @@ -467,4 +601,54 @@ mod tests { OfflineReceiptVerdict::UnreadableIdentity { .. } )); } + + // ── build attestation, offline ─────────────────────────────────────────── + // The signed-and-matching ("Verified") and forged ("DigestMismatch") arms + // are covered end to end against a real `auths artifact sign --ci` output in + // the conformance probe (a live node + the dogfooded signer). Here we pin + // the fail-closed shape arms that do not need a valid signature: an + // attestation with no digest, and one whose issuer is not a readable + // did:key, are both Unreadable — never mistaken for a verified build. + use crate::testing::AttestationBuilder; + + #[tokio::test] + async fn build_verify_no_digest_is_unreadable() { + let att = AttestationBuilder::default() + .issuer("did:key:z6MkfooNoDigest") + .payload(Some(serde_json::json!({ "artifact_type": "file" }))) + .build(); + let verdict = verify_build_attestation_offline(&att, "deadbeef").await; + assert!(matches!(verdict, OfflineBuildVerdict::Unreadable { .. })); + } + + #[tokio::test] + async fn build_verify_unreadable_issuer_is_unreadable() { + // A digest is present, but the issuer is not a self-describing did:key, + // so no key can be recovered to check the signature against. + let att = AttestationBuilder::default() + .issuer("did:keri:ENotADidKey") + .payload(Some(serde_json::json!({ + "digest": { "algorithm": "sha256", "hex": "abc123" } + }))) + .build(); + let verdict = verify_build_attestation_offline(&att, "abc123").await; + assert!(matches!(verdict, OfflineBuildVerdict::Unreadable { .. })); + } + + #[test] + fn build_verdict_verified_is_the_only_success_arm() { + assert!( + OfflineBuildVerdict::Verified { + digest: "abc".into() + } + .is_verified() + ); + assert!( + !OfflineBuildVerdict::DigestMismatch { + attested: "a".into(), + running: "b".into(), + } + .is_verified() + ); + } } diff --git a/crates/auths-witness-node/Cargo.toml b/crates/auths-witness-node/Cargo.toml index 75dadda5..d86d1263 100644 --- a/crates/auths-witness-node/Cargo.toml +++ b/crates/auths-witness-node/Cargo.toml @@ -32,6 +32,9 @@ hex = "0.4" [dev-dependencies] tempfile = "3" +# The build-attestation verdict composes the async platform verifier; its tests +# need a runtime to drive it. +tokio = { workspace = true, features = ["rt", "macros"] } [lints] workspace = true diff --git a/crates/auths-witness-node/src/build.rs b/crates/auths-witness-node/src/build.rs new file mode 100644 index 00000000..06c1e40f --- /dev/null +++ b/crates/auths-witness-node/src/build.rs @@ -0,0 +1,228 @@ +//! The proof a node serves of which binary it runs — the operator-facing seam. +//! +//! A witness operator is vouching for the network; an operator must in turn be +//! *vouchable* — a relying party has to be able to confirm the node runs the +//! binary the platform shipped, not a silently-swapped one. This module owns the +//! operator-facing artifact that makes that confirmable: the node's +//! self-measurement of its own binary, paired with the signed build attestation +//! the operator produced over the released binary (`auths artifact sign`). +//! +//! It owns no protocol. The decision — does the attestation's signature hold, +//! and does it attest the digest the node measured of itself? — is made by the +//! platform verifier ([`auths_verifier::verify_build_attestation_offline`]), +//! composed here. The node crate only frames the served artifact and renders the +//! verdict an operator reads. + +use auths_verifier::OfflineBuildVerdict; +use auths_verifier::core::Attestation; +use serde::{Deserialize, Serialize}; + +/// The `/build` document a node serves: its own measurement of the binary it +/// runs, paired with the signed attestation that vouches for it. +/// +/// Parse-don't-validate: a returned `BuildAttestation` is fully formed (the +/// `attestation` parsed into the verifier's [`Attestation`] type), so nothing +/// downstream re-checks its shape — `verify` only decides the *trust* question. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildAttestation { + /// Version string the running binary reports. + pub version: String, + /// SHA-256 (hex) the node measured of its own on-disk binary at startup. + pub running_digest: String, + /// The signed build attestation (`auths artifact sign` output) the operator + /// produced over the released binary. + pub attestation: Attestation, +} + +/// What an operator (or any relying party) learns from checking a node's build +/// proof. `Trusted` is the only arm that means "this node provably runs the +/// attested binary"; every other arm names exactly what went wrong, so a forged +/// or unprovable build can never be read as a trusted one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NodeBuildVerdict { + /// The node provably runs the attested binary: the attestation's signature + /// holds and attests the very digest the node measured of itself. + Trusted { + /// The version the node reported. + version: String, + /// The digest both attested and self-measured (they agree). + digest: String, + }, + /// The attestation could not be interpreted as a signed build attestation. + Unreadable { + /// Why the attestation could not be read. + reason: String, + }, + /// The attestation's signature does not verify — altered, or not produced by + /// its claimed signer. + SignatureFailed { + /// The issuer the signature was checked against. + issuer: String, + }, + /// The signature is valid but the attestation attests a DIFFERENT binary + /// than the one running — a forged or mismatched attestation. + DigestMismatch { + /// The digest the attestation attests. + attested: String, + /// The digest the node measured of its running binary. + running: String, + }, +} + +impl NodeBuildVerdict { + /// Whether the node provably runs the attested binary (the only trusted arm). + pub fn is_trusted(&self) -> bool { + matches!(self, NodeBuildVerdict::Trusted { .. }) + } + + /// A single operator-facing line describing the verdict — no protocol + /// vocabulary, just "which binary does this node run, and can we trust it". + pub fn summary(&self) -> String { + match self { + NodeBuildVerdict::Trusted { version, digest } => format!( + "build verified: this node runs {version} (digest {}), signed and matching", + short(digest) + ), + NodeBuildVerdict::Unreadable { reason } => { + format!("build not verifiable: {reason}") + } + NodeBuildVerdict::SignatureFailed { issuer } => format!( + "build rejected: the build attestation's signature does not verify (signer {})", + short(issuer) + ), + NodeBuildVerdict::DigestMismatch { attested, running } => format!( + "build rejected: the attestation is for a different binary \ + (attested {}, running {}) — this node is not running what it attests", + short(attested), + short(running) + ), + } + } +} + +/// Shorten a long hex/DID to a readable head for one-line operator output. +fn short(s: &str) -> String { + if s.len() > 16 { + format!("{}…", &s[..16]) + } else { + s.to_string() + } +} + +impl BuildAttestation { + /// Parse a build document from its served JSON, failing loudly on malformed + /// input (parse-don't-validate). + /// + /// Args: + /// * `json`: the `/build` response bytes. + pub fn from_json(json: &[u8]) -> Result { + serde_json::from_slice(json) + } + + /// Decide whether this node provably runs the attested binary. + /// + /// Composes the platform verifier: the signature is checked against the + /// issuer's embedded key, and the attested digest is checked against the + /// node's self-measured `running_digest`. A forged attestation (a valid + /// signature over a different binary) lands on + /// [`NodeBuildVerdict::DigestMismatch`], never on `Trusted`. + pub async fn verify(&self) -> NodeBuildVerdict { + match auths_verifier::verify_build_attestation_offline( + &self.attestation, + &self.running_digest, + ) + .await + { + OfflineBuildVerdict::Verified { digest } => NodeBuildVerdict::Trusted { + version: self.version.clone(), + digest, + }, + OfflineBuildVerdict::Unreadable { reason } => NodeBuildVerdict::Unreadable { reason }, + OfflineBuildVerdict::SignatureFailed { issuer } => { + NodeBuildVerdict::SignatureFailed { issuer } + } + OfflineBuildVerdict::DigestMismatch { attested, running } => { + NodeBuildVerdict::DigestMismatch { attested, running } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A build document whose attestation carries NO digest payload — it parses + /// into a well-formed attestation (real key/signature shapes), but with + /// nothing to compare against the running binary it verifies to `Unreadable`, + /// never `Trusted`. (The signed-and-matching and forged arms are covered end + /// to end against a real `artifact sign` output in the integration suite.) + const NO_DIGEST_BUILD: &str = r#"{ + "version": "0.1.3", + "running_digest": "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881", + "attestation": { + "version": 1, + "rid": "sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881", + "issuer": "did:key:zDnaev8Ae55oCNc38Ha6gFkuYGG4zxH1quDCpd5veWrQUHf8C", + "subject": "did:key:zDnaev8Ae55oCNc38Ha6gFkuYGG4zxH1quDCpd5veWrQUHf8C", + "device_public_key": { + "curve": "p256", + "key": "03b92f4329b76bec0f02c28b37e16a4fd9803129d22943ff53d5b5472f123fd349" + }, + "device_signature": "944b5ba55f517db44abac03b27b31f4dd596e7a335af5082d09c43601ddd09565d1e3e6a1c21a28ae8ef5d1b405240ea3420f18e42a3300dade341d9a9ff767d", + "payload": { "artifact_type": "file" } + } + }"#; + + #[test] + fn parses_a_served_build_document() { + let build = BuildAttestation::from_json(NO_DIGEST_BUILD.as_bytes()).unwrap(); + assert_eq!(build.version, "0.1.3"); + assert_eq!( + build.running_digest, + "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881" + ); + } + + #[tokio::test] + async fn a_build_with_no_attested_digest_is_not_trusted() { + let build = BuildAttestation::from_json(NO_DIGEST_BUILD.as_bytes()).unwrap(); + let verdict = build.verify().await; + assert!(!verdict.is_trusted()); + assert!(matches!(verdict, NodeBuildVerdict::Unreadable { .. })); + } + + #[test] + fn verdict_summary_carries_no_protocol_vocabulary() { + // The operator-facing line must never leak protocol jargon — operators + // see "which binary, and can we trust it", never the wire vocabulary. + let verdicts = [ + NodeBuildVerdict::Trusted { + version: "0.1.3".into(), + digest: "7ce84d53b3b63323deadbeef".into(), + }, + NodeBuildVerdict::DigestMismatch { + attested: "296078e6633559c0aa".into(), + running: "7ce84d53b3b63323bb".into(), + }, + NodeBuildVerdict::SignatureFailed { + issuer: "did:key:zForged".into(), + }, + NodeBuildVerdict::Unreadable { + reason: "no digest".into(), + }, + ]; + for v in verdicts { + let lowered = v.summary().to_lowercase(); + for term in [ + "keri", "kel", "ksn", "said", "cesr", "oobi", "verkey", "prefix", + ] { + assert!( + !lowered.contains(term), + "verdict summary leaked protocol term '{term}': {}", + v.summary() + ); + } + } + } +} diff --git a/crates/auths-witness-node/src/engine.rs b/crates/auths-witness-node/src/engine.rs index 39a710d1..0684067b 100644 --- a/crates/auths-witness-node/src/engine.rs +++ b/crates/auths-witness-node/src/engine.rs @@ -104,6 +104,21 @@ impl HealthCheck for SocketHealthCheck { } } +/// Fetches an `http://host:port/path` endpoint's body over a raw socket — the +/// same no-dependency posture as [`SocketHealthCheck`], but returning the +/// response body (and whether the status was `2xx`) so a caller can read a small +/// JSON document a node serves (e.g. its build proof). +#[derive(Debug, Default, Clone, Copy)] +pub struct SocketHttpFetch; + +impl crate::standup::HttpFetch for SocketHttpFetch { + fn get(&self, url: &str) -> Result { + let (host, port, path) = + parse_http_url(url).ok_or_else(|| format!("not a plain-http url: {url}"))?; + http_get_body(&host, port, &path).map_err(|e| format!("could not reach {url}: {e}")) + } +} + /// Parse `http://host:port/path` into its parts. Returns `None` for anything /// that is not a plain-HTTP URL with an explicit port (which is all standup /// ever produces). @@ -141,6 +156,44 @@ fn http_get_ok(host: &str, port: u16, path: &str) -> std::io::Result { .unwrap_or(false)) } +/// One blocking HTTP/1.0 GET returning the full response. `ok` is `true` iff the +/// status line was `2xx`; `body` is everything after the header block. Kept +/// dependency-free (raw socket) to match the health adapter — the node's build +/// proof is a small JSON document, so reading it whole is cheap. +fn http_get_body( + host: &str, + port: u16, + path: &str, +) -> std::io::Result { + let mut stream = TcpStream::connect((host, port))?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + let req = format!("GET {path} HTTP/1.0\r\nHost: {host}\r\nConnection: close\r\n\r\n"); + stream.write_all(req.as_bytes())?; + + let mut raw = Vec::new(); + stream.read_to_end(&mut raw)?; + + // Split the head from the body at the first blank line (CRLFCRLF). + let split = raw.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4); + let (head, body) = match split { + Some(i) => (&raw[..i], raw[i..].to_vec()), + None => (&raw[..], Vec::new()), + }; + let status_line = String::from_utf8_lossy(head) + .lines() + .next() + .unwrap_or("") + .to_string(); + let ok = status_line + .split_whitespace() + .nth(1) + .and_then(|c| c.parse::().ok()) + .map(|c| (200..300).contains(&c)) + .unwrap_or(false); + Ok(crate::standup::HttpResponse { ok, body }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/auths-witness-node/src/lib.rs b/crates/auths-witness-node/src/lib.rs index 676b129f..45e4cca9 100644 --- a/crates/auths-witness-node/src/lib.rs +++ b/crates/auths-witness-node/src/lib.rs @@ -35,14 +35,17 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +pub mod build; pub mod engine; pub mod receipt; pub mod standup; -pub use engine::{DockerEngine, SocketHealthCheck}; +pub use build::{BuildAttestation, NodeBuildVerdict}; +pub use engine::{DockerEngine, SocketHealthCheck, SocketHttpFetch}; pub use receipt::ReceiptBundle; pub use standup::{ - ContainerEngine, HealthCheck, StandupError, StandupOutcome, stand_up, tear_down, + ContainerEngine, HealthCheck, HttpFetch, HttpResponse, StandupError, StandupOutcome, stand_up, + tear_down, }; // Compose the platform's public protocol surface. These re-exports make the @@ -50,8 +53,8 @@ pub use standup::{ // protocol types it renders, all sourced from the trust kernel. pub use auths_keri::{KERI_KEY_STATE_VERSION, KSN_TYPE, KeyStateNotice, SignedKsn}; pub use auths_verifier::{ - OfflineReceiptVerdict, SignedReceipt, WitnessQuorum, WitnessReceiptResult, - verify_receipt_offline, + OfflineBuildVerdict, OfflineReceiptVerdict, SignedReceipt, WitnessQuorum, WitnessReceiptResult, + verify_build_attestation_offline, verify_receipt_offline, }; pub use auths_witness::{MAX_BODY_BYTES, MAX_CONCURRENT_REQUESTS, REQUEST_TIMEOUT}; @@ -109,6 +112,11 @@ pub struct StandupRequest { pub custody: KeyCustody, /// Where the node's receipts/keystore volume is mounted on the host. pub data_dir: PathBuf, + /// Host path to the signed build attestation for the released image's binary + /// (`auths artifact sign` output). When set, standup mounts it into the node + /// and points the binary at it, so the node serves a `/build` proof of which + /// binary it runs. `None` stands a node up without that surface. + pub build_attestation: Option, } impl StandupRequest { @@ -122,6 +130,7 @@ impl StandupRequest { host_port: 3333, custody: KeyCustody::default(), data_dir: data_dir.into(), + build_attestation: None, } } @@ -155,6 +164,23 @@ impl StandupRequest { KeyCustody::Managed => "managed (KMS/enclave)", KeyCustody::File => "file (acknowledged downgrade)", }; + // When a build attestation is supplied, mount it read-only into the node + // and point the binary at it, so the node serves a `/build` proof of + // which binary it runs. The attestation is data the node serves, never a + // secret, so a plain read-only bind is right. + let (attestation_env, attestation_volume) = match &self.build_attestation { + Some(path) => ( + format!( + "\x20\x20\x20\x20\x20\x20AUTHS_WITNESS_BUILD_ATTESTATION: \"{ATTESTATION_CONTAINER_PATH}\"\n" + ), + format!( + "\x20\x20\x20\x20volumes:\n\ + \x20\x20\x20\x20\x20\x20- \"{host}:{ATTESTATION_CONTAINER_PATH}:ro\"\n", + host = path.display(), + ), + ), + None => (String::new(), String::new()), + }; format!( "# Embedded witness standup — one node, released image only.\n\ # Never a source build; identity custody: {custody}.\n\ @@ -167,7 +193,9 @@ impl StandupRequest { \x20\x20\x20\x20\x20\x20- \"127.0.0.1:{host_port}:{container_port}\"\n\ \x20\x20\x20\x20environment:\n\ \x20\x20\x20\x20\x20\x20AUTHS_WITNESS_SEED: \"${{WITNESS_SEED}}\"\n\ + {attestation_env}\ \x20\x20\x20\x20command: [\"--bind\", \"0.0.0.0:{container_port}\", \"--curve\", \"ed25519\", \"--persist\", \"/data/receipts.db\"]\n\ + {attestation_volume}\ \x20\x20\x20\x20tmpfs:\n\ \x20\x20\x20\x20\x20\x20- /data\n", custody = custody, @@ -175,10 +203,16 @@ impl StandupRequest { reference = reference, host_port = self.host_port, container_port = container_port, + attestation_env = attestation_env, + attestation_volume = attestation_volume, ) } } +/// The fixed in-container path the build attestation is mounted at and the node +/// reads from. One constant so the mount target and the env value cannot drift. +const ATTESTATION_CONTAINER_PATH: &str = "/build-attestation.json"; + /// The KERI wire version of the key-state notice this node serves. /// /// Sourced from the platform, never redeclared here — the node serves exactly @@ -230,6 +264,26 @@ mod tests { ); // The proxy body cap is sourced from the platform server constant. assert!(manifest.contains(&MAX_BODY_BYTES.to_string())); + // With no build attestation, the node serves no `/build` surface — the + // manifest neither mounts one nor points the binary at it. + assert!(!manifest.contains("AUTHS_WITNESS_BUILD_ATTESTATION")); + } + + #[test] + fn compose_manifest_mounts_a_supplied_build_attestation() { + let mut req = StandupRequest::local("/srv/witness"); + req.build_attestation = Some(PathBuf::from("/host/build.auths.json")); + let manifest = req.compose_manifest(); + // The node is pointed at the in-container attestation path … + assert!(manifest.contains("AUTHS_WITNESS_BUILD_ATTESTATION")); + assert!(manifest.contains(ATTESTATION_CONTAINER_PATH)); + // … and the host file is bind-mounted read-only to that path. + assert!(manifest.contains(&format!( + "/host/build.auths.json:{ATTESTATION_CONTAINER_PATH}:ro" + ))); + // Still a released image, never a source build. + assert!(manifest.contains("image:")); + assert!(!manifest.contains("build:")); } #[test] diff --git a/crates/auths-witness-node/src/standup.rs b/crates/auths-witness-node/src/standup.rs index d099e0cd..835acb3f 100644 --- a/crates/auths-witness-node/src/standup.rs +++ b/crates/auths-witness-node/src/standup.rs @@ -107,6 +107,26 @@ pub trait HealthCheck { fn is_healthy(&self, url: &str) -> bool; } +/// A response read from a node endpoint: whether the status was `2xx` and the +/// raw body bytes. +#[derive(Debug, Clone)] +pub struct HttpResponse { + /// Whether the HTTP status line was `2xx`. + pub ok: bool, + /// The response body bytes. + pub body: Vec, +} + +/// Fetches a small document a node serves (e.g. its build proof). +/// +/// A narrow port, like [`HealthCheck`]: the orchestration only needs to GET a +/// URL and read the body; the transport (raw socket, a real HTTP client, an +/// in-process stub) lives behind the adapter. +pub trait HttpFetch { + /// GET `url` and return the response, or one actionable error line. + fn get(&self, url: &str) -> Result; +} + /// The result of a successful standup: the operator-facing health URL of a node /// that is already answering there. #[derive(Debug, Clone, PartialEq, Eq)] @@ -164,6 +184,19 @@ pub fn stand_up( reason: e.to_string(), })?; + // A build attestation is bind-mounted, so its host path must be absolute and + // exist — resolve it here at the I/O edge and fail closed if it cannot be, + // rather than letting the engine surface an opaque mount error. + let mut req = req.clone(); + if let Some(path) = req.build_attestation.take() { + let resolved = std::fs::canonicalize(&path).map_err(|e| StandupError::DataDir { + path: path.clone(), + reason: format!("build attestation could not be resolved: {e}"), + })?; + req.build_attestation = Some(resolved); + } + let req = &req; + let project = project_name(req.host_port); let manifest_path = req.data_dir.join("standup.compose.yml"); std::fs::write(&manifest_path, req.compose_manifest()).map_err(|e| StandupError::DataDir { diff --git a/crates/auths-witness/Cargo.toml b/crates/auths-witness/Cargo.toml index 896a56b4..439c2f54 100644 --- a/crates/auths-witness/Cargo.toml +++ b/crates/auths-witness/Cargo.toml @@ -17,6 +17,9 @@ categories = ["cryptography", "network-programming"] auths-core = { workspace = true, features = ["witness-server"] } auths-crypto.workspace = true anyhow = "1" +# The binary reads its signed build attestation (a JSON document) from the file +# the deployment env names, and hands it to auths-core verbatim to serve. +serde_json = "1" clap = { version = "4", features = ["derive", "env"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal"] } tracing = "0.1" diff --git a/crates/auths-witness/src/main.rs b/crates/auths-witness/src/main.rs index 7356634f..679668eb 100644 --- a/crates/auths-witness/src/main.rs +++ b/crates/auths-witness/src/main.rs @@ -9,13 +9,19 @@ use std::path::PathBuf; use anyhow::{Context, Result, anyhow}; use auths_core::witness::{ - WitnessServerConfig, WitnessServerState, generate_and_persist_witness_signer, + BuildProof, WitnessServerConfig, WitnessServerState, generate_and_persist_witness_signer, load_witness_signer, witness_signer_from_seed_hex, }; use auths_crypto::CurveType; use auths_witness::hardened_witness_app; use clap::Parser; +/// Environment variable naming the file that holds this binary's signed build +/// attestation (`auths artifact sign` output). When set, the node measures its +/// own running binary, pairs it with the signed attestation, and serves both at +/// `/build` so a relying party can prove which binary the node runs. +const BUILD_ATTESTATION_ENV: &str = "AUTHS_WITNESS_BUILD_ATTESTATION"; + /// Slim, hardened KERI rct-witness server. #[derive(Parser, Debug)] #[command(name = "auths-witness", version, about)] @@ -91,6 +97,39 @@ fn resolve_signer(args: &Args, curve: CurveType) -> Result Result> { + #[allow(clippy::disallowed_methods)] + // Boundary: the binary reads its own deployment env, exactly as it does for + // AUTHS_WITNESS_SEED. A container injects the attestation file path here. + let Some(path) = std::env::var_os(BUILD_ATTESTATION_ENV) else { + return Ok(None); + }; + let path = PathBuf::from(path); + let bytes = std::fs::read(&path).with_context(|| { + format!( + "{BUILD_ATTESTATION_ENV} points at {} but it could not be read", + path.display() + ) + })?; + let attestation: serde_json::Value = serde_json::from_slice(&bytes).with_context(|| { + format!( + "the build attestation at {} is not valid JSON", + path.display() + ) + })?; + let proof = BuildProof::measure_self(env!("CARGO_PKG_VERSION"), attestation) + .context("could not measure the running binary for the build proof")?; + Ok(Some(proof)) +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -101,8 +140,11 @@ async fn main() -> Result<()> { let curve = parse_curve(&args.curve)?; let signer = resolve_signer(&args, curve)?; - let config = WitnessServerConfig::from_signer(args.persist.clone(), signer) + let mut config = WitnessServerConfig::from_signer(args.persist.clone(), signer) .map_err(|e| anyhow!("witness config: {e}"))?; + if let Some(proof) = resolve_build_proof()? { + config = config.with_build_proof(proof); + } let state = WitnessServerState::new(config).map_err(|e| anyhow!("witness state: {e}"))?; tracing::info!( From 464ee684f48d98bd638abed72aec4155e267deae Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 02:54:55 +0100 Subject: [PATCH 09/19] witness-node: one source of truth for the operator-vocabulary rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node operator must never need the protocol's wire/ceremony vocabulary (key event logs, key-state notices, self-addressing identifiers, the CESR wire, signing thresholds, and the like) to stand a witness up, check on it, register it, read its logs, or tear it down. That rule was previously three divergent, hand-maintained jargon lists scattered across the crate's tests — each a partial copy free to drift from the surface it guarded. Lift it into one place: a canonical PROTOCOL_VOCABULARY denylist and a whole-word, case-insensitive scan_for_protocol_vocabulary in a new vocabulary module (re-exported from the crate root). Whole-word matching keeps benign operator strings that merely contain the letters — "prefixed", "did:key:...", "received" — from being false positives. The crate's own happy-path tests (health URL, build verdict summary) now consume the canonical scanner; their inline term arrays are deleted. The list covers the full kernel vocabulary, not the earlier six-term subset (adds acdc/tel/verkey/prefix/threshold and more). No operator-facing output changed; the rule keeping it vocabulary-free is now a single enforced contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/auths-witness-node/src/build.rs | 18 +-- crates/auths-witness-node/src/lib.rs | 16 +- crates/auths-witness-node/src/vocabulary.rs | 165 ++++++++++++++++++++ 3 files changed, 182 insertions(+), 17 deletions(-) create mode 100644 crates/auths-witness-node/src/vocabulary.rs diff --git a/crates/auths-witness-node/src/build.rs b/crates/auths-witness-node/src/build.rs index 06c1e40f..31964dcc 100644 --- a/crates/auths-witness-node/src/build.rs +++ b/crates/auths-witness-node/src/build.rs @@ -196,6 +196,8 @@ mod tests { fn verdict_summary_carries_no_protocol_vocabulary() { // The operator-facing line must never leak protocol jargon — operators // see "which binary, and can we trust it", never the wire vocabulary. + // Held to the one canonical rule (crate::scan_for_protocol_vocabulary), + // not a copy maintained here. let verdicts = [ NodeBuildVerdict::Trusted { version: "0.1.3".into(), @@ -213,16 +215,12 @@ mod tests { }, ]; for v in verdicts { - let lowered = v.summary().to_lowercase(); - for term in [ - "keri", "kel", "ksn", "said", "cesr", "oobi", "verkey", "prefix", - ] { - assert!( - !lowered.contains(term), - "verdict summary leaked protocol term '{term}': {}", - v.summary() - ); - } + assert_eq!( + crate::scan_for_protocol_vocabulary(&v.summary()), + None, + "verdict summary leaked protocol vocabulary: {}", + v.summary() + ); } } } diff --git a/crates/auths-witness-node/src/lib.rs b/crates/auths-witness-node/src/lib.rs index 45e4cca9..3e544513 100644 --- a/crates/auths-witness-node/src/lib.rs +++ b/crates/auths-witness-node/src/lib.rs @@ -39,6 +39,7 @@ pub mod build; pub mod engine; pub mod receipt; pub mod standup; +pub mod vocabulary; pub use build::{BuildAttestation, NodeBuildVerdict}; pub use engine::{DockerEngine, SocketHealthCheck, SocketHttpFetch}; @@ -47,6 +48,7 @@ pub use standup::{ ContainerEngine, HealthCheck, HttpFetch, HttpResponse, StandupError, StandupOutcome, stand_up, tear_down, }; +pub use vocabulary::{PROTOCOL_VOCABULARY, scan_for_protocol_vocabulary}; // Compose the platform's public protocol surface. These re-exports make the // composition explicit and give the operator CLI one import path for the @@ -235,14 +237,14 @@ mod tests { #[test] fn health_url_has_no_protocol_vocabulary() { + // The operator-vocabulary rule lives in one place; this asserts the + // health URL the operator opens is held to it, not to a private copy. let url = StandupRequest::local("/tmp/d").health_url(); - let lowered = url.to_lowercase(); - for term in ["keri", "kel", "ksn", "said", "cesr", "oobi"] { - assert!( - !lowered.contains(term), - "health URL leaked protocol term: {term}" - ); - } + assert_eq!( + scan_for_protocol_vocabulary(&url), + None, + "health URL leaked protocol vocabulary" + ); } #[test] diff --git a/crates/auths-witness-node/src/vocabulary.rs b/crates/auths-witness-node/src/vocabulary.rs new file mode 100644 index 00000000..a017b345 --- /dev/null +++ b/crates/auths-witness-node/src/vocabulary.rs @@ -0,0 +1,165 @@ +//! The operator-vocabulary rule, in one place. +//! +//! A node operator stands a witness up, checks on it, registers it, reads its +//! logs — and must never need the protocol's vocabulary to do any of it. The +//! words a relying party's *verifier* speaks (key event logs, key-state +//! notices, self-addressing identifiers, the CESR wire, signing thresholds and +//! the rest) are correct and necessary *inside* the trust kernel; they are +//! friction in an operator's face. This module owns the line between the two so +//! it lives in exactly one place: the canonical list of terms an operator must +//! never see, and the scanner that finds one in a string. +//! +//! One source of truth (quality constitution §3): the standup surface, the +//! crate's own tests, and the conformance suite's vocabulary probe all check +//! against [`PROTOCOL_VOCABULARY`] here — none of them carries its own copy, so +//! none of them can drift from this list. + +/// Protocol terms an operator must never encounter in the witness happy path. +/// +/// These are the trust kernel's wire and ceremony vocabulary — exact and +/// load-bearing for a verifier, pure friction for an operator who only wants a +/// node that is up and vouchable. The list is the contract the operator-facing +/// surface is held to; it is matched whole-word and case-insensitively (see +/// [`scan_for_protocol_vocabulary`]), so an operator string that merely +/// *contains* these letters inside a larger benign word is not a leak. +/// +/// Whole-word matching is why innocuous substrings are safe: `did:key:` and +/// `identity` carry "id"; "received" carries no listed term; the operator line +/// "this node is not running what it attests" carries none. A leak is a listed +/// term standing as its own word. +pub const PROTOCOL_VOCABULARY: &[&str] = &[ + // The event-log / key-state family. + "keri", + "kel", + "kerl", + "ksn", + "icp", + "rot", + "ixn", + "drt", + // Self-addressing & credential wire. + "said", + "saider", + "acdc", + "tel", + // The binary encoding. + "cesr", + "cigar", + // Key-material jargon a relying party speaks, not an operator. + "verkey", + "prefix", + "tholder", + "diger", + // The corroboration-policy term: an operator runs a node; "threshold" is the + // verifier's M-of-N language, never standup's. + "threshold", + // Out-of-band introduction — discovery wire, not an operator concept. + "oobi", +]; + +/// Find the first protocol term [`PROTOCOL_VOCABULARY`] that appears as a whole +/// word in `text`, case-insensitively. `None` means the text is operator-clean. +/// +/// "Whole word" means flanked by non-alphanumeric boundaries (or string ends), +/// so a listed term embedded in a larger identifier — `prefixed`, `said` inside +/// `unsaid` — is not a false positive; only the bare term is a leak. The match +/// is case-insensitive because operators never see cased jargon either. +/// +/// Args: +/// * `text`: any operator-facing string (a command's stdout/stderr line, a +/// rendered verdict, a help blurb). +pub fn scan_for_protocol_vocabulary(text: &str) -> Option<&'static str> { + let lowered = text.to_ascii_lowercase(); + let bytes = lowered.as_bytes(); + for &term in PROTOCOL_VOCABULARY { + let mut from = 0; + while let Some(rel) = lowered[from..].find(term) { + let start = from + rel; + let end = start + term.len(); + let left_ok = start == 0 || !is_word_byte(bytes[start - 1]); + let right_ok = end == bytes.len() || !is_word_byte(bytes[end]); + if left_ok && right_ok { + return Some(term); + } + from = start + 1; + } + } + None +} + +/// Whether a byte is part of a word (ASCII alphanumeric). Word boundaries are +/// anything else — whitespace, punctuation, string ends. +fn is_word_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clean_operator_strings_carry_no_protocol_vocabulary() { + // The exact lines the operator happy path prints today — every one must + // pass the rule it is held to. + let operator_lines = [ + "health: http://127.0.0.1:3333/health", + "healthy: http://127.0.0.1:3333/health", + "build verified: this node runs 0.1.3 (digest 7ce84d53b3b63323…), signed and matching", + "witness node torn down", + "opening signed registration for https://wit.example.org", + "streaming logs for witness node at ./witness-data", + "verified: this receipt was issued by did:key:z6Mkhr…", + "node did not become healthy at http://127.0.0.1:3333/health within 540s — nothing left running", + "this node is not running what it attests", + ]; + for line in operator_lines { + assert_eq!( + scan_for_protocol_vocabulary(line), + None, + "operator line leaked protocol vocabulary: {line}" + ); + } + } + + #[test] + fn a_leaked_term_is_found_whole_word_case_insensitively() { + assert_eq!( + scan_for_protocol_vocabulary("served the current KEL for this identity"), + Some("kel") + ); + assert_eq!( + scan_for_protocol_vocabulary("quorum threshold met (2 of 3)"), + Some("threshold") + ); + assert_eq!( + scan_for_protocol_vocabulary("verkey rotated"), + Some("verkey") + ); + } + + #[test] + fn benign_substrings_are_not_false_positives() { + // "prefix" is listed; "prefixed"/"prefixes" are operator-fine words that + // merely contain it — whole-word matching must not flag them. + assert_eq!(scan_for_protocol_vocabulary("the prefixed path"), None); + // "id" / "did" appear all over operator output and are not listed; a + // listed term inside a larger word ("unsaid") is not a bare leak. + assert_eq!(scan_for_protocol_vocabulary("did:key:z6Mkhr… unsaid"), None); + assert_eq!(scan_for_protocol_vocabulary("received and witnessed"), None); + } + + #[test] + fn the_denylist_is_nonempty_and_lowercase() { + // The scanner lowercases input; the list it compares against must be + // lowercase too, or a term could never match. + assert!(!PROTOCOL_VOCABULARY.is_empty()); + for &term in PROTOCOL_VOCABULARY { + assert_eq!( + term, + term.to_ascii_lowercase(), + "denylist term not lowercase: {term}" + ); + assert!(!term.is_empty()); + } + } +} From 0f74dcfddb31d3fec789996d0cfde8b7c7e2a08a Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 03:32:14 +0100 Subject: [PATCH 10/19] fix(auths-keri): SAID a basic-prefix inception with i kept, matching keripy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A KERI inception has two prefix kinds. A self-addressing AID derives its prefix from the event SAID (i == d), so both d and i are blanked before hashing. A basic AID uses the controlling public key itself as the prefix (i == k[0], e.g. a D-coded Ed25519 verkey); it is NOT self-addressing, so only d is blanked — i stays present in the hashed bytes. compute_said_with_protocol blanked i for EVERY icp/dip/vcp unconditionally, keying only on event type. So for a keripy basic-prefix inception it returned the self-addressing SAID — a confidently-wrong answer (EOoC9Auw… instead of keripy's EAAD4cS7…), silently, rather than matching or rejecting. Fix: blank i only when it is genuinely self-addressing — classify the i value by its CESR derivation code (parse, don't validate) rather than by event type. An empty i (the emit path, before finalize fills i = d), the SAID placeholder, or a digest prefix (E…) is self-addressing; a key prefix (a verkey) is basic and is kept during hashing, exactly as keripy 1.3.4 does. This mirrors the discriminator finalize_icp_event already uses to decide whether to set i = d. auths still emits only self-addressing AIDs (empty i → blanked → i = d unchanged); this is the consume side reading the broader ecosystem's events correctly: write one style, read all. Closes interop gap IOP-L1d. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/auths-keri/src/said.rs | 166 +++++++++++++++++++++++++++++++--- 1 file changed, 151 insertions(+), 15 deletions(-) diff --git a/crates/auths-keri/src/said.rs b/crates/auths-keri/src/said.rs index b31c3604..42dc0f11 100644 --- a/crates/auths-keri/src/said.rs +++ b/crates/auths-keri/src/said.rs @@ -42,17 +42,59 @@ impl Protocol { } } - /// Whether the `i` field is self-addressing (blanked during SAID-ification). + /// Whether this protocol's inception events *can* carry a self-addressing + /// prefix in `i` (a prefix derived from the event SAID, blanked during + /// SAID-ification). /// /// KERI inception events (`icp`/`dip`) and backerless TEL registry inception - /// (`vcp`) derive their prefix from the SAID, so `i` is blanked. ACDC `i` is - /// the *issuer* AID (an external reference), so it is never blanked — only - /// event protocols consult the `t` field. + /// (`vcp`) derive their prefix from the SAID, so a self-addressing `i` is + /// blanked. ACDC `i` is the *issuer* AID (an external reference), so it is + /// never blanked — only event protocols consult the `t` field. + /// + /// Note: this gates only the protocol/event-type; whether `i` is *actually* + /// self-addressing for a given event is decided per-value by + /// [`prefix_is_self_addressing`], because KERI also admits basic-prefix + /// inceptions where `i` is a public key and must be kept during hashing. fn blanks_inception_prefix(self) -> bool { matches!(self, Protocol::Keri) } } +/// Whether an inception event's current `i` value is a *self-addressing* prefix +/// (one derived from the event SAID) — the only case in which `i` is blanked +/// before hashing. +/// +/// KERI admits two inception prefix kinds for the same keys: +/// +/// * **self-addressing** — `i` is the SAID itself (a Blake3-256 digest, CESR +/// code `E`), or, on the emit path, the as-yet-unfilled SAID +/// [`SAID_PLACEHOLDER`]. keripy blanks `i` along with `d` before hashing. +/// * **basic** — `i` is the controlling public key (e.g. an Ed25519 verkey, +/// CESR code `D`/`B`, or a P-256/secp256k1 key, codes `1AAB`/`1AAC`…). It is +/// *not* derived from the SAID, so keripy 1.3.4 keeps `i` present during +/// hashing exactly as any other field. +/// +/// Classifying by the value's CESR derivation code (parse, don't validate) +/// rather than by event type lets auths reproduce keripy's SAID byte-exact for +/// *either* prefix kind it ingests, while still emitting only self-addressing +/// AIDs itself. +/// +/// Self-addressing `i` is one of: +/// * **empty** — the auths *emit* path (`finalize_icp_event`) hashes the event +/// with `i` unset, then fills `i = d` afterwards; an unset `i` is an +/// as-yet-underived self-addressing prefix, never a basic one (auths emits +/// only self-addressing AIDs). +/// * the [`SAID_PLACEHOLDER`] — the explicit "`d`/`i` to be filled" marker. +/// * a digest prefix (`E…`, Blake3-256) — an already-filled self-addressing AID. +/// +/// Anything else is a key prefix (a verkey: `D`/`B`/`1AAB`…) and therefore a +/// *basic* prefix, which keripy keeps during hashing — so auths must too. This +/// mirrors the discriminator `finalize_icp_event` already uses to decide +/// whether to set `i = d`. +fn prefix_is_self_addressing(i: &str) -> bool { + i.is_empty() || i == SAID_PLACEHOLDER || i.starts_with('E') +} + /// Computes a spec-compliant SAID for a KERI event (`KERI10JSON` protocol tag). /// /// Thin wrapper over [`compute_said_with_protocol`] pinned to [`Protocol::Keri`]; @@ -111,8 +153,15 @@ pub fn compute_said_with_protocol( let placeholder = serde_json::Value::String(SAID_PLACEHOLDER.to_string()); let event_type = obj.get("t").and_then(|v| v.as_str()).unwrap_or(""); - let blank_prefix = - protocol.blanks_inception_prefix() && matches!(event_type, "icp" | "dip" | "vcp"); + // Blank `i` only when this is a self-addressing inception: the event type is + // an inception (`icp`/`dip`/`vcp`) AND its `i` is actually derived from the + // SAID (a digest prefix or the unfilled placeholder). A basic-prefix + // inception carries a public key in `i`, which keripy keeps during hashing — + // so auths must keep it too, or it computes a confidently-wrong SAID. + let inception_prefix = obj.get("i").and_then(|v| v.as_str()).unwrap_or(""); + let blank_prefix = protocol.blanks_inception_prefix() + && matches!(event_type, "icp" | "dip" | "vcp") + && prefix_is_self_addressing(inception_prefix); // Rebuild the map with spec-compliant placeholders and field ordering. let mut new_obj = serde_json::Map::new(); @@ -314,13 +363,21 @@ mod tests { assert_eq!(said_with, said_without, "x field must not affect SAID"); } + /// A *self-addressing* inception blanks `i`, so its SAID is independent of + /// the particular (digest-coded) `i` value it carries — including the two + /// emit-path forms, the [`SAID_PLACEHOLDER`] and an already-filled `E…` + /// prefix. + /// + /// This is the corrected invariant: it holds *only* for self-addressing + /// prefixes. A basic prefix (a verkey in `i`) is kept during hashing and so + /// is NOT interchangeable — see [`basic_prefix_inception_keeps_i_matches_keripy`]. #[test] - fn inception_applies_i_placeholder() { - let event_a = serde_json::json!({ + fn self_addressing_inception_said_independent_of_i() { + let event_placeholder = serde_json::json!({ "v": "KERI10JSON000000_", "t": "icp", "d": "", - "i": "some_prefix_a", + "i": SAID_PLACEHOLDER, "s": "0", "kt": "1", "k": ["DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"], @@ -330,11 +387,11 @@ mod tests { "b": [], "a": [] }); - let event_b = serde_json::json!({ + let event_digest = serde_json::json!({ "v": "KERI10JSON000000_", "t": "icp", "d": "", - "i": "some_prefix_b", + "i": "EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J", "s": "0", "kt": "1", "k": ["DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"], @@ -344,11 +401,11 @@ mod tests { "b": [], "a": [] }); - let said_a = compute_said(&event_a).unwrap(); - let said_b = compute_said(&event_b).unwrap(); + let said_placeholder = compute_said(&event_placeholder).unwrap(); + let said_digest = compute_said(&event_digest).unwrap(); assert_eq!( - said_a, said_b, - "inception SAID must be independent of initial i value" + said_placeholder, said_digest, + "self-addressing inception SAID must be independent of the digest i value" ); } @@ -395,6 +452,85 @@ mod tests { assert!(verify_said(&event).is_err()); } + /// keripy oracle (1.3.4): a *basic-prefix* inception keeps `i` (the verkey) + /// during hashing, so its SAID differs from the self-addressing form. + /// + /// Vector: `eventing.incept(keys=[ed.qb64])` (default basic prefix), where + /// `ed.qb64 == "DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"`. Cross-checked + /// by the interop suite (`interop/vectors/kel/icp-basic.json`, gap IOP-L1d). + #[test] + fn basic_prefix_inception_keeps_i_matches_keripy() { + let raw = r#"{"v":"KERI10JSON0000fd_","t":"icp","d":"EAAD4cS7l9pm_N8JM9UsVeAZhwCIaDkSU341hbhHJbSf","i":"DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f","s":"0","kt":"1","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"nt":"0","n":[],"bt":"0","b":[],"c":[],"a":[]}"#; + let event: serde_json::Value = serde_json::from_str(raw).unwrap(); + let said = compute_said(&event).unwrap(); + assert_eq!( + said.as_str(), + "EAAD4cS7l9pm_N8JM9UsVeAZhwCIaDkSU341hbhHJbSf", + "basic-prefix icp SAID must match keripy (i kept, not blanked)" + ); + // verify_said must accept the keripy event as-is. + assert!(verify_said(&event).is_ok()); + } + + /// keripy oracle (1.3.4): a *self-addressing* inception still blanks `i` + /// (it is the SAID), so this path is unchanged by the basic-prefix fix. + /// + /// Vector: `eventing.incept(keys=[ed.qb64], code=Blake3_256)` — `i == d`. + /// Cross-checked by `interop/vectors/kel/icp-selfaddr.json` (gap IOP-L1a). + #[test] + fn self_addressing_inception_blanks_i_matches_keripy() { + let raw = r#"{"v":"KERI10JSON0000fd_","t":"icp","d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","s":"0","kt":"1","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"nt":"0","n":[],"bt":"0","b":[],"c":[],"a":[]}"#; + let event: serde_json::Value = serde_json::from_str(raw).unwrap(); + let said = compute_said(&event).unwrap(); + assert_eq!( + said.as_str(), + "EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J", + "self-addressing icp SAID must still blank i and match keripy" + ); + assert!(verify_said(&event).is_ok()); + } + + /// The emit path (auths minting its own AID) fills `i` only after the SAID is + /// known, so `compute_said` sees the [`SAID_PLACEHOLDER`] in `i` and must + /// still treat it as self-addressing (blank it). Equivalently, an empty `i` + /// or the placeholder produce the same SAID as the filled self-addressing `i`. + #[test] + fn placeholder_inception_prefix_is_self_addressing() { + let base = serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "icp", + "d": "", + "s": "0", + "kt": "1", + "k": ["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"], + "nt": "0", + "n": [], + "bt": "0", + "b": [], + "c": [], + "a": [] + }); + // i = placeholder + let mut with_placeholder = base.clone(); + with_placeholder["i"] = serde_json::Value::String(SAID_PLACEHOLDER.to_string()); + // i = a digest prefix (E-coded) + let mut with_digest = base.clone(); + with_digest["i"] = + serde_json::Value::String("EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J".to_string()); + assert_eq!( + compute_said(&with_placeholder).unwrap(), + compute_said(&with_digest).unwrap(), + "placeholder and E-coded i are both self-addressing — same SAID" + ); + assert!(prefix_is_self_addressing(SAID_PLACEHOLDER)); + assert!(prefix_is_self_addressing( + "EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J" + )); + assert!(!prefix_is_self_addressing( + "DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f" + )); + } + /// Guard: `serde_json::Map` must use `IndexMap` (preserve insertion order). /// /// If the `preserve_order` feature is accidentally removed from From ba473d29337053612f0b49f63aaf76aac12f7a83 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 08:27:31 +0100 Subject: [PATCH 11/19] feat(did-webs): emit a did:webs DID document for an AID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a did:webs surface so an auths AID resolves under a standard did:webs/DID-core resolver without the resolver speaking KERI. auths-keri gains a did_webs module: DidWebsDocument::from_key_state projects a replayed key-state into the ToIP did:webs reference didDocument ({id, verificationMethod, service, alsoKnownAs}). The KEL stays the source of truth — the document is derived, never authored. Each current signing key becomes one JsonWebKey verification method, built only from a decoded KeriPublicKey (parse, don't validate); the fragment is the key's own CESR value (#DAAB…) so it self-identifies across rotation. PublicKeyJwk projects a key into its curve-correct JWK: Ed25519 → OKP (x), P-256 → EC (x/y from decompressing the SEC1 point). The JsonWebKey x coordinate is base64url(raw verkey) — byte- identical to the reference resolver's generate_json_web_key_vm and to keripy's urlsafe_b64encode(Verfer.raw). The auths did-webs --from-kel --domain CLI is a thin adapter over the model (mirroring auths key-state); the crypto/wire definition lives in auths-keri, never re-implemented in the CLI. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/auths-cli/src/cli.rs | 3 + crates/auths-cli/src/commands/did_webs.rs | 63 ++++ crates/auths-cli/src/commands/mod.rs | 1 + crates/auths-cli/src/main.rs | 1 + crates/auths-keri/src/did_webs.rs | 377 ++++++++++++++++++++++ crates/auths-keri/src/lib.rs | 3 + 6 files changed, 448 insertions(+) create mode 100644 crates/auths-cli/src/commands/did_webs.rs create mode 100644 crates/auths-keri/src/did_webs.rs diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index 17a63736..b70d155a 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -19,6 +19,7 @@ use crate::commands::debug::DebugCmd; use crate::commands::demo::DemoCommand; use crate::commands::device::DeviceCommand; use crate::commands::device::pair::PairCommand; +use crate::commands::did_webs::DidWebsCommand; use crate::commands::doctor::DoctorCommand; use crate::commands::emergency::EmergencyCommand; use crate::commands::error_lookup::ErrorLookupCommand; @@ -125,6 +126,8 @@ pub enum RootCommand { Key(KeyCommand), #[command(hide = true, name = "key-state")] KeyState(KeyStateCommand), + #[command(hide = true, name = "did-webs")] + DidWebs(DidWebsCommand), #[command(hide = true)] Approval(ApprovalCommand), #[command(hide = true)] diff --git a/crates/auths-cli/src/commands/did_webs.rs b/crates/auths-cli/src/commands/did_webs.rs new file mode 100644 index 00000000..8f334e40 --- /dev/null +++ b/crates/auths-cli/src/commands/did_webs.rs @@ -0,0 +1,63 @@ +//! `auths did-webs` — emit a `did:webs` DID document for an AID. +//! +//! `did:webs` anchors a KERI AID into a **web-resolvable** DID document so a +//! standard DID resolver verifies the identifier without speaking KERI. The +//! document is derived by replaying the AID's KEL into its current key-state, so +//! the verification material is exactly the AID's current signing keys — the KEL +//! stays the source of truth. The document auths emits is byte-compatible with +//! the ToIP did:webs reference resolver's `didDocument` +//! (`{id, verificationMethod, service, alsoKnownAs}`). The crypto/wire definition +//! lives in `auths-keri::did_webs`; this is a thin CLI adapter over it. + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, anyhow}; +use auths_keri::{DidWebsDocument, TrustedKel, parse_kel_json}; +use auths_utils::path::expand_tilde; +use clap::Parser; + +use crate::config::CliConfig; + +/// Emit a `did:webs` DID document for an AID (KEL-anchored). +#[derive(Parser, Debug, Clone)] +#[command( + about = "Emit a did:webs DID document for an AID — resolvable under a standard did:webs resolver", + after_help = "Examples: + auths did-webs --from-kel kel.json --domain example.com + auths did-webs --from-kel kel.json --domain 'example.com%3A3901:dids'" +)] +pub struct DidWebsCommand { + /// Replay this KEL file and project its current key-state into a `did:webs` + /// DID document (the shape a did:webs/DID-core resolver reads). + #[clap(long, value_name = "KEL.json")] + pub from_kel: PathBuf, + + /// The web domain the `did:webs` is anchored at — the host (optionally + /// `host%3Aport` and path segments) before the AID in `did:webs::`. + #[clap(long, value_name = "DOMAIN")] + pub domain: String, +} + +impl DidWebsCommand { + /// Run the command: replay the KEL and print the `did:webs` DID document. + pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { + self.emit(&self.from_kel) + } + + /// Replay a KEL file and print the projected `did:webs` DID document. + fn emit(&self, kel_path: &Path) -> Result<()> { + let path = expand_tilde(kel_path)?; + let json = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read KEL {}: {e}", path.display()))?; + let events = parse_kel_json(&json).map_err(|e| anyhow!("parse KEL: {e}"))?; + // A KEL file the operator hands us is a local, self-owned artifact — the + // reviewable trust assertion that structural replay requires. + let state = TrustedKel::from_trusted_source(&events) + .replay() + .map_err(|e| anyhow!("replay KEL: {e}"))?; + let doc = DidWebsDocument::from_key_state(&state, &self.domain) + .map_err(|e| anyhow!("project key-state into a did:webs document: {e}"))?; + println!("{}", serde_json::to_string_pretty(&doc)?); + Ok(()) + } +} diff --git a/crates/auths-cli/src/commands/mod.rs b/crates/auths-cli/src/commands/mod.rs index 846a683c..1d1cfcff 100644 --- a/crates/auths-cli/src/commands/mod.rs +++ b/crates/auths-cli/src/commands/mod.rs @@ -17,6 +17,7 @@ pub mod credential; pub mod debug; pub mod demo; pub mod device; +pub mod did_webs; pub mod doctor; pub mod emergency; pub mod error_lookup; diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index 089fa892..02cf140c 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -107,6 +107,7 @@ fn run() -> Result<()> { RootCommand::Device(cmd) => cmd.execute(&ctx), RootCommand::Key(cmd) => cmd.execute(&ctx), RootCommand::KeyState(cmd) => cmd.execute(&ctx), + RootCommand::DidWebs(cmd) => cmd.execute(&ctx), RootCommand::Approval(cmd) => cmd.execute(&ctx), RootCommand::Policy(cmd) => cmd.execute(&ctx), RootCommand::Namespace(cmd) => cmd.execute(&ctx), diff --git a/crates/auths-keri/src/did_webs.rs b/crates/auths-keri/src/did_webs.rs new file mode 100644 index 00000000..750360d8 --- /dev/null +++ b/crates/auths-keri/src/did_webs.rs @@ -0,0 +1,377 @@ +//! `did:webs` DID-document projection of a resolved KERI key-state. +//! +//! `did:webs` anchors a KERI AID into a **web-resolvable** DID document so a +//! standard DID resolver can verify the identifier without speaking KERI itself. +//! The document is *derived*, not authored: every field comes from replaying the +//! KEL into a [`KeyState`], so the verification material is exactly the AID's +//! current signing keys. The KEL remains the source of truth; this is its +//! projection into the DID-core data model. +//! +//! Wire shape (the resolved `didDocument`, ToIP did:webs method): +//! `{id, verificationMethod, service, alsoKnownAs}` — field order and labels +//! match the reference resolver (`did-webs-resolver`'s `gen_did_document`), so a +//! document auths emits reads in a stock did:webs/DID-core resolver. +//! +//! - `id` is `did:webs::` (the AID is the resolved prefix). +//! - each current signing key becomes one `JsonWebKey` verification method whose +//! fragment is the key's own CESR value (`#DAAB…`), controller is the document +//! `id`, and `publicKeyJwk` carries the curve-correct JWK (`OKP`/`Ed25519` for +//! Ed25519, `EC`/`P-256` for P-256) — the byte-exact form the reference emits. +//! - `alsoKnownAs` carries the `did:keri:` equivalent, the cross-method link +//! that lets a resolver fall back to native KERI resolution. +//! +//! It is a *parsed* type: building one from a [`KeyState`] cannot fail to be +//! well-formed (a resolved key-state already names valid current keys), and a +//! verification method is constructed only from a decoded [`KeriPublicKey`], so a +//! malformed key is rejected at the boundary rather than serialized into a +//! document. + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; + +use crate::keys::{KeriDecodeError, KeriPublicKey}; +use crate::state::KeyState; +use crate::types::CesrKey; + +/// A public key projected into the JOSE JWK shape a DID-core `publicKeyJwk` +/// carries. Curve-tagged so a resolver picks the right verification algorithm: +/// Ed25519 is an `OKP` key (`x` only), P-256 is an `EC` key (`x` and `y`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kty")] +pub enum PublicKeyJwk { + /// Edwards-curve octet key pair (Ed25519): `{kty:"OKP", crv:"Ed25519", x}`. + #[serde(rename = "OKP")] + Okp { + /// JWK key id — the key's CESR-qualified value (`kid`). + kid: String, + /// Curve name — always `"Ed25519"` for this variant. + crv: String, + /// base64url(no-pad) of the 32 raw public-key bytes. + x: String, + }, + /// Elliptic-curve key (P-256): `{kty:"EC", crv:"P-256", x, y}`. + #[serde(rename = "EC")] + Ec { + /// JWK key id — the key's CESR-qualified value (`kid`). + kid: String, + /// Curve name — always `"P-256"` for this variant. + crv: String, + /// base64url(no-pad) of the 32-byte affine x-coordinate. + x: String, + /// base64url(no-pad) of the 32-byte affine y-coordinate. + y: String, + }, +} + +impl PublicKeyJwk { + /// Project a decoded KERI public key into its JWK, tagged `kid` with the + /// key's own CESR value. + /// + /// Ed25519 maps to an `OKP` key over the 32 raw bytes; P-256 maps to an `EC` + /// key whose `x`/`y` are the affine coordinates recovered by decompressing the + /// SEC1 point. Returns [`KeriDecodeError::DecodeError`] only if a P-256 point + /// fails to decompress (not a valid curve point) — Ed25519 is infallible. + pub fn from_key(key: &KeriPublicKey, kid: &str) -> Result { + match key { + KeriPublicKey::Ed25519 { key: raw, .. } => Ok(Self::Okp { + kid: kid.to_string(), + crv: "Ed25519".to_string(), + x: URL_SAFE_NO_PAD.encode(raw), + }), + KeriPublicKey::P256 { + key: compressed, .. + } => { + use p256::elliptic_curve::sec1::ToEncodedPoint; + // Decompress the SEC1 point and re-encode uncompressed to read + // both affine coordinates the EC JWK needs. + let pk = p256::PublicKey::from_sec1_bytes(compressed).map_err(|e| { + KeriDecodeError::DecodeError(format!("P-256 point decode failed: {e}")) + })?; + let uncompressed = pk.to_encoded_point(false); + let x = uncompressed.x().ok_or_else(|| { + KeriDecodeError::DecodeError("P-256 point has no x-coordinate".to_string()) + })?; + let y = uncompressed.y().ok_or_else(|| { + KeriDecodeError::DecodeError("P-256 point has no y-coordinate".to_string()) + })?; + Ok(Self::Ec { + kid: kid.to_string(), + crv: "P-256".to_string(), + x: URL_SAFE_NO_PAD.encode(x), + y: URL_SAFE_NO_PAD.encode(y), + }) + } + } + } +} + +/// A single DID-core verification method projecting one current signing key. +/// +/// The fragment (`id`) is the key's own CESR value, so the verification method is +/// self-identifying across a rotation: a resolver references the exact key that +/// signed, not a positional `#key-0` that shifts when keys rotate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerificationMethod { + /// DID-relative fragment `#` (the key's own CESR value). + pub id: String, + /// Verification-method type — `"JsonWebKey"` (the curve lives in `publicKeyJwk`). + #[serde(rename = "type")] + pub type_: String, + /// The controlling DID (the document `id`). + pub controller: String, + /// The public key in JWK form. + #[serde(rename = "publicKeyJwk")] + pub public_key_jwk: PublicKeyJwk, +} + +/// The resolved `did:webs` DID document for a KERI AID. +/// +/// Field order and labels match the ToIP did:webs reference resolver's +/// `gen_did_document` (`{id, verificationMethod, service, alsoKnownAs}`), so the +/// emitted JSON reads byte-compatibly in a stock did:webs/DID-core resolver. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DidWebsDocument { + /// The DID this document describes: `did:webs::`. + pub id: String, + /// One verification method per current signing key. + #[serde(rename = "verificationMethod")] + pub verification_method: Vec, + /// Service endpoints (KERI agent/witness URLs). Empty for a KEL-only + /// projection that has no live endpoint advertisement. + pub service: Vec, + /// Designated equivalent identifiers — carries the `did:keri:` link so a + /// resolver can fall back to native KERI resolution of the same AID. + #[serde(rename = "alsoKnownAs")] + pub also_known_as: Vec, +} + +impl DidWebsDocument { + /// Build a `did:webs` DID document by projecting a resolved key-state onto the + /// given web `domain`. + /// + /// `domain` is the host (and optional `:port`/path) the document will be + /// served under; the AID is `state.prefix`. Every current signing key in + /// `state` becomes one verification method. Returns + /// [`KeriDecodeError`] only if a current key is undecodable or (for P-256) not + /// a valid curve point — invalidity caught at the boundary, never serialized. + /// + /// Args: + /// * `state`: The resolved current [`KeyState`] (from KEL replay). + /// * `domain`: The web domain/host the `did:webs` is anchored at. + pub fn from_key_state(state: &KeyState, domain: &str) -> Result { + let aid = state.prefix.as_str(); + let id = format!("did:webs:{domain}:{aid}"); + + let mut verification_method = Vec::with_capacity(state.current_keys.len()); + for cesr_key in &state.current_keys { + verification_method.push(verification_method_for(cesr_key, &id)?); + } + + Ok(Self { + id, + verification_method, + service: Vec::new(), + also_known_as: vec![format!("did:keri:{aid}")], + }) + } +} + +/// Build one verification method from a current key's CESR string, controlled by +/// `did`. The key is decoded first (parse, don't validate), so the JWK is built +/// only from a known curve and valid bytes. +fn verification_method_for( + cesr_key: &CesrKey, + did: &str, +) -> Result { + let kid = cesr_key.as_str(); + let key = KeriPublicKey::parse(kid)?; + Ok(VerificationMethod { + id: format!("#{kid}"), + type_: "JsonWebKey".to_string(), + controller: did.to_string(), + public_key_jwk: PublicKeyJwk::from_key(&key, kid)?, + }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::types::{Prefix, Said, Threshold}; + + /// A single-key Ed25519 key-state at the given AID/key. + fn ed25519_state(aid: &str, key_cesr: &str) -> KeyState { + KeyState::from_inception( + Prefix::new_unchecked(aid.to_string()), + vec![CesrKey::new_unchecked(key_cesr.to_string())], + vec![Said::new_unchecked("ENext0".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), + Said::new_unchecked(aid.to_string()), + vec![], + Threshold::Simple(0), + vec![], + ) + } + + /// The CESR-qualified Ed25519 verkey over `raw`. + fn ed25519_cesr(raw: &[u8; 32]) -> String { + KeriPublicKey::ed25519(raw).unwrap().to_qb64().unwrap() + } + + /// The CESR-qualified P-256 verkey over a real keypair's compressed point. + fn p256_cesr() -> (String, [u8; 33]) { + use p256::elliptic_curve::sec1::ToEncodedPoint; + // Deterministic non-identity scalar → a valid curve point. + let sk = p256::SecretKey::from_slice(&[ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]) + .unwrap(); + let pt = sk.public_key().to_encoded_point(true); + let mut compressed = [0u8; 33]; + compressed.copy_from_slice(pt.as_bytes()); + let cesr = KeriPublicKey::P256 { + key: compressed, + transferable: true, + } + .to_qb64() + .unwrap(); + (cesr, compressed) + } + + #[test] + fn document_has_canonical_field_order() { + let key = ed25519_cesr(&[3u8; 32]); + let state = ed25519_state("EAid000000000000000000000000000000000000000", &key); + let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap(); + + let json = serde_json::to_value(&doc).unwrap(); + let keys: Vec<&str> = json + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + // Reference resolver `gen_did_document` order: id, verificationMethod, service, alsoKnownAs. + assert_eq!( + keys, + vec!["id", "verificationMethod", "service", "alsoKnownAs"] + ); + } + + #[test] + fn ed25519_verification_method_matches_reference_shape() { + let key = ed25519_cesr(&[7u8; 32]); + let state = ed25519_state("EAid000000000000000000000000000000000000000", &key); + let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap(); + + assert_eq!( + doc.id, + "did:webs:example.com:EAid000000000000000000000000000000000000000" + ); + assert_eq!( + doc.also_known_as, + vec!["did:keri:EAid000000000000000000000000000000000000000"] + ); + assert!(doc.service.is_empty()); + + let vm = &doc.verification_method[0]; + // Fragment is the key's OWN cesr value, not a positional #key-0. + assert_eq!(vm.id, format!("#{key}")); + assert_eq!(vm.type_, "JsonWebKey"); + assert_eq!(vm.controller, doc.id); + match &vm.public_key_jwk { + PublicKeyJwk::Okp { kid, crv, x } => { + assert_eq!(kid, &key); + assert_eq!(crv, "Ed25519"); + // x is base64url(no-pad) of the 32 raw bytes. + assert_eq!(x, &URL_SAFE_NO_PAD.encode([7u8; 32])); + } + other => panic!("expected OKP JWK, got {other:?}"), + } + } + + #[test] + fn ed25519_jwk_serializes_kty_first() { + // `#[serde(tag = "kty")]` puts kty at the front, then the variant fields — + // {kty, kid, crv, x}, the reference publicKeyJwk shape. + let key = ed25519_cesr(&[1u8; 32]); + let state = ed25519_state("EAid000000000000000000000000000000000000000", &key); + let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap(); + let jwk = serde_json::to_value(&doc.verification_method[0].public_key_jwk).unwrap(); + let labels: Vec<&str> = jwk + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!(labels, vec!["kty", "kid", "crv", "x"]); + assert_eq!(jwk["kty"], "OKP"); + assert_eq!(jwk["crv"], "Ed25519"); + } + + #[test] + fn p256_verification_method_emits_ec_jwk_with_x_and_y() { + let (key, compressed) = p256_cesr(); + let state = ed25519_state("EAidP256000000000000000000000000000000000000", &key); + let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap(); + + let vm = &doc.verification_method[0]; + assert_eq!(vm.id, format!("#{key}")); + match &vm.public_key_jwk { + PublicKeyJwk::Ec { kid, crv, x, y } => { + assert_eq!(kid, &key); + assert_eq!(crv, "P-256"); + // x is the 32-byte affine x; for a compressed point that is bytes 1..33. + assert_eq!(x, &URL_SAFE_NO_PAD.encode(&compressed[1..33])); + // y is recovered by decompression — 32 bytes, present and non-empty. + assert_eq!(URL_SAFE_NO_PAD.decode(y).unwrap().len(), 32); + } + other => panic!("expected EC JWK, got {other:?}"), + } + } + + #[test] + fn multisig_emits_one_method_per_key() { + let k1 = ed25519_cesr(&[1u8; 32]); + let k2 = ed25519_cesr(&[2u8; 32]); + let mut state = ed25519_state("EAid000000000000000000000000000000000000000", &k1); + state.current_keys.push(CesrKey::new_unchecked(k2.clone())); + let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap(); + assert_eq!(doc.verification_method.len(), 2); + assert_eq!(doc.verification_method[0].id, format!("#{k1}")); + assert_eq!(doc.verification_method[1].id, format!("#{k2}")); + } + + #[test] + fn domain_with_port_and_path_is_preserved() { + // did:webs allows host%3Aport and path segments before the AID. + let key = ed25519_cesr(&[5u8; 32]); + let state = ed25519_state("EAid000000000000000000000000000000000000000", &key); + let doc = DidWebsDocument::from_key_state(&state, "example.com%3A3901:dids").unwrap(); + assert_eq!( + doc.id, + "did:webs:example.com%3A3901:dids:EAid000000000000000000000000000000000000000" + ); + assert_eq!(doc.verification_method[0].controller, doc.id); + } + + #[test] + fn undecodable_key_is_rejected_at_the_boundary() { + let mut state = ed25519_state("EAid000000000000000000000000000000000000000", "Dvalid"); + state.current_keys = vec![CesrKey::new_unchecked("Xnot-a-verkey".to_string())]; + assert!(DidWebsDocument::from_key_state(&state, "example.com").is_err()); + } + + #[test] + fn document_round_trips_through_json() { + let key = ed25519_cesr(&[9u8; 32]); + let state = ed25519_state("EAid000000000000000000000000000000000000000", &key); + let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap(); + let wire = serde_json::to_string(&doc).unwrap(); + let parsed: DidWebsDocument = serde_json::from_str(&wire).unwrap(); + assert_eq!(parsed, doc); + } +} diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index e8bad511..87fce74e 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -40,6 +40,8 @@ pub mod acdc; /// Validated capability identifiers — the atomic unit of authorization in Auths. pub mod capability; mod crypto; +/// `did:webs` DID-document projection of a resolved KERI key-state. +pub mod did_webs; mod error; mod events; pub mod kel_io; @@ -80,6 +82,7 @@ pub use capability::{ Capability, CapabilityError, MANAGE_MEMBERS, ROTATE_KEYS, SIGN_COMMIT, SIGN_RELEASE, }; pub use crypto::{compute_next_commitment, verify_commitment}; +pub use did_webs::{DidWebsDocument, PublicKeyJwk, VerificationMethod}; pub use error::{KeriTranslationError, TelError}; pub use events::{ AgentScope, DipEvent, DipEventInit, DrtEvent, DrtEventInit, Event, IcpEvent, IcpEventInit, From b3682bc6ff1650b29405a7e20cb0417ee8dad641 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 09:17:52 +0100 Subject: [PATCH 12/19] =?UTF-8?q?feat(keri):=20OOBI=20discovery=20?= =?UTF-8?q?=E2=80=94=20resolve=20+=20serve=20KERI=20Out-Of-Band=20Introduc?= =?UTF-8?q?tions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an OOBI surface so an auths AID can discover, and be discovered by, KERI peers — the bootstrap every live KERI exchange (witnessing, credential presentation, key-state resolution) starts from. auths-keri::oobi (the I/O-free core, parse-don't-validate): - Oobi: a parsed OOBI URL (:///oobi//[/]), keripy's OOBI_RE grammar; every component validated at the boundary. - Role: a total enum over keripy's kering.Roles vocabulary. - LocSchemeReply / EndRoleReply: SAID-and-version-correct `rpy` records ({v,t:"rpy",d,dt,r,a}) byte-exact with keripy 1.3.4's Hab.reply (/loc/scheme {eid,scheme,url} and /end/role/add {cid,role,eid}). - OobiEndpoint::for_controller: the serve side — derive an AID's OOBI URL and its rpy reply stream from a replayed key-state. - ingest_oobi_stream: the resolve side — replay a fetched KEL into a verified KeyState, bound to the cid the OOBI claimed (a mismatched KEL is rejected, never silently substituted). auths-cli `auths oobi`: - resolve --url [--from-file]: parse a peer OOBI, fetch its bytes (HTTP behind a port, or an already-fetched stream), replay + print the key-state. - endpoint --from-kel --authority [--url]: emit the OOBI URL + rpy reply stream a resolving peer fetches. Cross-verified both directions against keripy 1.3.4: auths resolves a keripy-produced KEL, and auths's served /loc/scheme + /end/role/add replies (SAIDs included) are byte-identical to keripy's Hab.makeLocScheme / Hab.reply. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/auths-cli/src/cli.rs | 3 + crates/auths-cli/src/commands/mod.rs | 1 + crates/auths-cli/src/commands/oobi.rs | 200 ++++++++ crates/auths-cli/src/main.rs | 1 + crates/auths-keri/src/lib.rs | 6 + crates/auths-keri/src/oobi.rs | 674 ++++++++++++++++++++++++++ 6 files changed, 885 insertions(+) create mode 100644 crates/auths-cli/src/commands/oobi.rs create mode 100644 crates/auths-keri/src/oobi.rs diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index b70d155a..6560f291 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -31,6 +31,7 @@ use crate::commands::learn::LearnCommand; use crate::commands::log::LogCommand; use crate::commands::multi_sig::MultiSigCommand; use crate::commands::namespace::NamespaceCommand; +use crate::commands::oobi::OobiCommand; use crate::commands::org::OrgCommand; use crate::commands::policy::PolicyCommand; use crate::commands::publish::PublishCommand; @@ -129,6 +130,8 @@ pub enum RootCommand { #[command(hide = true, name = "did-webs")] DidWebs(DidWebsCommand), #[command(hide = true)] + Oobi(OobiCommand), + #[command(hide = true)] Approval(ApprovalCommand), #[command(hide = true)] Policy(PolicyCommand), diff --git a/crates/auths-cli/src/commands/mod.rs b/crates/auths-cli/src/commands/mod.rs index 1d1cfcff..88e99b1f 100644 --- a/crates/auths-cli/src/commands/mod.rs +++ b/crates/auths-cli/src/commands/mod.rs @@ -32,6 +32,7 @@ pub mod learn; pub mod log; pub mod multi_sig; pub mod namespace; +pub mod oobi; pub mod org; pub mod policy; pub mod provision; diff --git a/crates/auths-cli/src/commands/oobi.rs b/crates/auths-cli/src/commands/oobi.rs new file mode 100644 index 00000000..a803fa54 --- /dev/null +++ b/crates/auths-cli/src/commands/oobi.rs @@ -0,0 +1,200 @@ +//! `auths oobi` — KERI Out-Of-Band Introduction (discovery). +//! +//! An OOBI is how KERI controllers discover each other: a URL that says *"here +//! is my AID, and here is where to fetch its key event log and endpoints."* It +//! is the bootstrap of every live KERI exchange (witnessing, credential +//! presentation, key-state resolution) — before a peer can talk to an AID it +//! must first discover *where* that AID lives, out of band. The KEL fetched +//! through an OOBI is still verified by replay, so the URL is only a location +//! hint, never a root of trust. +//! +//! Two directions, mirroring discovery itself: +//! +//! * `auths oobi resolve` — peer → us: parse a peer's OOBI URL, fetch the bytes +//! it points at, replay the embedded KEL into a verified key-state, and print +//! it. `--from-file` resolves an already-fetched stream offline. +//! * `auths oobi endpoint` — us → peer: from one of our KELs and the URL we host +//! it at, emit the OOBI URL to publish plus the `rpy` reply stream +//! (`/loc/scheme` + `/end/role/add`) a peer fetches when it resolves us. +//! +//! The wire definitions (URL grammar, reply records, KEL ingest) live in +//! `auths-keri::oobi`; this is a thin CLI adapter. The HTTP fetch sits behind a +//! port here so the discovery logic never imports a transport. + +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Result, anyhow}; +use auths_keri::{Oobi, OobiEndpoint, TrustedKel, ingest_oobi_stream, parse_kel_json}; +use auths_utils::path::expand_tilde; +use clap::{Parser, Subcommand}; + +use crate::config::CliConfig; + +/// Resolve or serve a KERI OOBI (Out-Of-Band Introduction) for discovery. +#[derive(Parser, Debug, Clone)] +#[command( + about = "Resolve or serve a KERI OOBI — discovery, interoperable with keripy/KERIA", + after_help = "Examples: + auths oobi resolve --url http://peer:5642/oobi/EOoC.../controller + auths oobi resolve --url http://peer/oobi/EOoC.../witness --from-file stream.cesr + auths oobi endpoint --from-kel kel.json --authority 127.0.0.1:5642 --url http://127.0.0.1:5642/" +)] +pub struct OobiCommand { + /// The OOBI direction to run. + #[command(subcommand)] + pub action: OobiAction, +} + +/// The two OOBI directions: resolve a peer's, or serve our own. +#[derive(Subcommand, Debug, Clone)] +pub enum OobiAction { + /// Resolve a peer's OOBI URL → fetch + replay its KEL → print the key-state. + Resolve(ResolveArgs), + /// Serve an AID: emit its OOBI URL + the `rpy` reply stream a peer fetches. + Endpoint(EndpointArgs), +} + +/// `auths oobi resolve` — discover a peer by resolving its OOBI URL. +#[derive(Parser, Debug, Clone)] +pub struct ResolveArgs { + /// The peer's OOBI URL: `:///oobi//[/]`. + #[clap(long, value_name = "OOBI_URL")] + pub url: String, + + /// Resolve an already-fetched stream from this file instead of an HTTP + /// fetch — the offline/hermetic path (the bytes a live endpoint would + /// return). The KEL is still replayed and verified. + #[clap(long, value_name = "STREAM.json")] + pub from_file: Option, + + /// HTTP fetch timeout in seconds (live resolve only). + #[clap(long, default_value_t = 30)] + pub timeout: u64, +} + +/// `auths oobi endpoint` — serve an AID's introduction. +#[derive(Parser, Debug, Clone)] +pub struct EndpointArgs { + /// Replay this KEL and serve its controller as a discoverable AID. + #[clap(long, value_name = "KEL.json")] + pub from_kel: PathBuf, + + /// URL scheme to publish the endpoint under (`http`/`https`/`tcp`). + #[clap(long, default_value = "http")] + pub scheme: String, + + /// Network authority (`host[:port]`) hosting the endpoint — the part of the + /// OOBI URL before `/oobi`. + #[clap(long, value_name = "HOST:PORT")] + pub authority: String, + + /// Absolute endpoint URL embedded in the `/loc/scheme` reply. Defaults to + /// `:///` when omitted. + #[clap(long, value_name = "URL")] + pub url: Option, + + /// Timestamp (RFC 3339) to stamp the `rpy` replies with. Defaults to the + /// epoch so output stays deterministic; pass the real `now` to publish. + #[clap(long, default_value = "1970-01-01T00:00:00.000000+00:00")] + pub dt: String, +} + +impl OobiCommand { + /// Run the command (resolve a peer or serve an endpoint). + pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { + match &self.action { + OobiAction::Resolve(args) => args.run(), + OobiAction::Endpoint(args) => args.run(), + } + } +} + +impl ResolveArgs { + fn run(&self) -> Result<()> { + // Parse the URL at the boundary — an invalid OOBI never reaches the + // fetch. `cid` is the AID the URL claims to introduce; ingest binds the + // replayed KEL to it. + let oobi = Oobi::parse(&self.url).map_err(|e| anyhow!("parse OOBI URL: {e}"))?; + + let stream = match &self.from_file { + Some(path) => { + let path = expand_tilde(path)?; + std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read OOBI stream {}: {e}", path.display()))? + } + None => fetch_oobi(&oobi.url(), self.timeout)?, + }; + + let resolution = ingest_oobi_stream(&oobi.cid, &stream) + .map_err(|e| anyhow!("resolve OOBI {}: {e}", oobi.url()))?; + + eprintln!( + "resolved OOBI {} → {} ({} KEL event{}, seq {})", + oobi.url(), + resolution.cid, + resolution.event_count, + if resolution.event_count == 1 { "" } else { "s" }, + resolution.state.sequence, + ); + println!("{}", serde_json::to_string_pretty(&resolution.state)?); + Ok(()) + } +} + +impl EndpointArgs { + fn run(&self) -> Result<()> { + let kel_path = expand_tilde(&self.from_kel)?; + let json = std::fs::read_to_string(&kel_path) + .map_err(|e| anyhow!("read KEL {}: {e}", kel_path.display()))?; + let events = parse_kel_json(&json).map_err(|e| anyhow!("parse KEL: {e}"))?; + // A KEL file the operator hands us is a local, self-owned artifact — the + // reviewable trust assertion that structural replay requires. + let state = TrustedKel::from_trusted_source(&events) + .replay() + .map_err(|e| anyhow!("replay KEL: {e}"))?; + + let url = self + .url + .clone() + .unwrap_or_else(|| format!("{}://{}/", self.scheme, self.authority)); + let endpoint = OobiEndpoint::for_controller( + &state, + self.scheme.clone(), + self.authority.clone(), + url, + self.dt.clone(), + ) + .map_err(|e| anyhow!("derive OOBI endpoint: {e}"))?; + + // The OOBI URL a peer resolves to discover this AID, then the `rpy` + // reply stream that resolution returns (the KEL is served separately by + // the host endpoint; these are the endpoint-authorization records). + println!("{}", endpoint.oobi.url()); + println!( + "{}", + endpoint + .reply_stream() + .map_err(|e| anyhow!("serialize OOBI reply stream: {e}"))? + ); + Ok(()) + } +} + +/// Fetch the bytes an OOBI URL points at over HTTP — the transport adapter for +/// the resolve port. Blocking, since the CLI is synchronous. +fn fetch_oobi(url: &str, timeout_secs: u64) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| anyhow!("build HTTP client: {e}"))?; + let resp = client + .get(url) + .send() + .map_err(|e| anyhow!("fetch OOBI {url}: {e}"))?; + if !resp.status().is_success() { + return Err(anyhow!("fetch OOBI {url}: HTTP {}", resp.status())); + } + resp.text() + .map_err(|e| anyhow!("read OOBI response body from {url}: {e}")) +} diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index 02cf140c..943cc976 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -108,6 +108,7 @@ fn run() -> Result<()> { RootCommand::Key(cmd) => cmd.execute(&ctx), RootCommand::KeyState(cmd) => cmd.execute(&ctx), RootCommand::DidWebs(cmd) => cmd.execute(&ctx), + RootCommand::Oobi(cmd) => cmd.execute(&ctx), RootCommand::Approval(cmd) => cmd.execute(&ctx), RootCommand::Policy(cmd) => cmd.execute(&ctx), RootCommand::Namespace(cmd) => cmd.execute(&ctx), diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index 87fce74e..92ff31a8 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -50,6 +50,8 @@ mod keys; pub mod ksn; /// Routed KERI message types (qry, rpy, pro, bar, xip, exn). pub mod messages; +/// Out-Of-Band Introduction (OOBI) — KERI discovery: resolve/serve AID endpoints. +pub mod oobi; mod said; mod state; /// Backerless TEL (Transaction Event Log) credential-status events: `vcp`/`iss`/`rev`. @@ -97,6 +99,10 @@ pub use ksn::{ KERI_KEY_STATE_VERSION, KSN_TYPE, KSN_VERSION, KeyStateNotice, KeyStateRecord, KsnError, LatestEstablishmentEvent, SignedKsn, }; +pub use oobi::{ + EndRoleReply, LocSchemeReply, Oobi, OobiEndpoint, OobiError, OobiResolution, Role, + ingest_oobi_stream, +}; pub use said::{ Protocol, SAID_PLACEHOLDER, compute_said, compute_said_with_protocol, compute_section_said, verify_said, diff --git a/crates/auths-keri/src/oobi.rs b/crates/auths-keri/src/oobi.rs new file mode 100644 index 00000000..e5be6fbc --- /dev/null +++ b/crates/auths-keri/src/oobi.rs @@ -0,0 +1,674 @@ +//! Out-Of-Band Introduction (OOBI) — KERI discovery. +//! +//! An OOBI is how one KERI controller tells another *"here is my AID, and here +//! is a URL at which you can fetch its key event log and service endpoints."* It +//! is the bootstrap of every live exchange: before a peer can request a receipt, +//! present a credential, or resolve a key-state, it must first discover *where* +//! the controlling AID's KEL and endpoints live. OOBIs carry that location +//! out-of-band (hence the name); the KEL fetched through one is still verified +//! cryptographically, so the URL is only a hint, never a root of trust. +//! +//! Two halves, mirroring the two directions of discovery: +//! +//! * **Resolve** (peer → us): parse a peer's OOBI URL into a typed [`Oobi`], +//! fetch the bytes it points at, and [`ingest_oobi_stream`] them — replaying +//! the embedded KEL into a verified [`KeyState`] and collecting the endpoint +//! reply records the peer published alongside it. +//! * **Serve** (us → peer): from one of our own KELs and the URL we host it at, +//! [`OobiEndpoint::for_controller`] derives the OOBI URL to publish and the +//! `rpy` reply stream (`/loc/scheme` + `/end/role/add`) a peer fetches when it +//! resolves us. +//! +//! The wire records are byte-exact with keripy 1.3.4: a `/loc/scheme` reply is +//! `{v, t:"rpy", d, dt, r:"/loc/scheme", a:{eid, scheme, url}}` and an +//! `/end/role/add` reply is `{v, t:"rpy", d, dt, r:"/end/role/add", +//! a:{cid, role, eid}}`, each SAID-ified and version-sized exactly as +//! `keri.app.habbing.Hab.reply`. The URL grammar is keripy's `OOBI_RE` +//! (`/oobi/{cid}/{role}[/{eid}]`). +//! +//! This module is I/O-free: it parses URLs, serializes/parses wire records, and +//! replays KELs. The HTTP fetch lives behind a port in the caller (the CLI's +//! OOBI adapter), so the discovery logic never imports a transport. + +use serde::ser::SerializeMap; +use serde::{Serialize, Serializer}; + +use crate::error::KeriTranslationError; +use crate::events::KERI_VERSION_PREFIX; +use crate::said::{Protocol, compute_said_with_protocol}; +use crate::state::KeyState; +use crate::types::{Prefix, Said}; +use crate::validate::{TrustedKel, ValidationError, parse_kel_json}; + +/// Placeholder version string filled in during saidify (17 chars, like every +/// KERI record's `v`). +const KERI_VERSION_PLACEHOLDER: &str = "KERI10JSON000000_"; + +/// Sizes the version string `KERI10JSON{size:06x}_` to a serialized record — the +/// same single-pass machinery the TEL records use (the field width is constant, +/// so re-serializing with the placeholder gives the final byte length). +fn recompute_version_string(event: &T) -> Result { + let bytes = serde_json::to_vec(event).map_err(KeriTranslationError::SerializationFailed)?; + Ok(format!("{KERI_VERSION_PREFIX}{:06x}_", bytes.len())) +} + +/// An authorized endpoint role in a KERI introduction. +/// +/// Mirrors keripy's `kering.Roles` — the fixed vocabulary of what an endpoint +/// identifier (`eid`) is authorized to *do* for a controller (`cid`). Parsing is +/// total: an unknown role is rejected at the boundary, so an `Role` value is +/// always one keripy would accept in a `/end/role` reply. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Role { + /// The controller itself (its own endpoint). + Controller, + /// A witness that receipts the controller's KEL. + Witness, + /// A watcher that observes the controller's KEL for duplicity. + Watcher, + /// A registrar of the controller's credential registries. + Registrar, + /// A judge in a multi-sig group. + Judge, + /// A juror in a multi-sig group. + Juror, + /// A peer in a direct-mode exchange. + Peer, + /// A mailbox that buffers messages for the controller. + Mailbox, + /// An agent acting on behalf of the controller (e.g. a KERIA agent). + Agent, + /// A gateway endpoint. + Gateway, +} + +impl Role { + /// The keripy `kering.Roles` wire token for this role. + pub fn as_str(self) -> &'static str { + match self { + Role::Controller => "controller", + Role::Witness => "witness", + Role::Watcher => "watcher", + Role::Registrar => "registrar", + Role::Judge => "judge", + Role::Juror => "juror", + Role::Peer => "peer", + Role::Mailbox => "mailbox", + Role::Agent => "agent", + Role::Gateway => "gateway", + } + } + + /// Parses a keripy role token into a typed [`Role`]. + /// + /// Total at the boundary: an unrecognized token is an [`OobiError::Role`], + /// never a silently-accepted string. + pub fn parse(s: &str) -> Result { + Ok(match s { + "controller" => Role::Controller, + "witness" => Role::Witness, + "watcher" => Role::Watcher, + "registrar" => Role::Registrar, + "judge" => Role::Judge, + "juror" => Role::Juror, + "peer" => Role::Peer, + "mailbox" => Role::Mailbox, + "agent" => Role::Agent, + "gateway" => Role::Gateway, + other => return Err(OobiError::Role(other.to_string())), + }) + } +} + +impl std::fmt::Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A parsed Out-Of-Band Introduction URL. +/// +/// The canonical keripy OOBI form is +/// `:///oobi//[/]` (keripy's `OOBI_RE`). A +/// parsed `Oobi` guarantees: a recognized scheme, a present authority, a +/// CESR-valid controller prefix (`cid`), a known [`Role`], and — when present — +/// a CESR-valid endpoint prefix (`eid`). Invalid URLs never become an `Oobi`; +/// they are rejected by [`Oobi::parse`] at the boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Oobi { + /// URL scheme — `http`, `https`, or `tcp` (keripy `kering.Schemes`). + pub scheme: String, + /// Network authority (`host[:port]`) hosting the introduction endpoint. + pub authority: String, + /// Controller AID being introduced (the `cid` path segment). + pub cid: Prefix, + /// Authorized role of the endpoint for that controller. + pub role: Role, + /// Optional endpoint provider AID (`eid`) when the OOBI scopes one endpoint. + pub eid: Option, +} + +impl Oobi { + /// Parses a peer's OOBI URL into a typed [`Oobi`]. + /// + /// Accepts the keripy `OOBI_RE` shape + /// `:///oobi//[/]`. Every component is + /// validated at the boundary: the scheme must be one keripy speaks, the + /// `cid`/`eid` must be CESR-valid prefixes, and the role must be a known + /// [`Role`]. + pub fn parse(url: &str) -> Result { + let (scheme, rest) = url + .split_once("://") + .ok_or_else(|| OobiError::Url(format!("missing scheme separator in {url:?}")))?; + let scheme = scheme.to_ascii_lowercase(); + if !matches!(scheme.as_str(), "http" | "https" | "tcp") { + return Err(OobiError::Scheme(scheme)); + } + + // Split authority from the path; an absent path is not a valid OOBI. + let (authority, path) = match rest.split_once('/') { + Some((authority, path)) => (authority, path), + None => return Err(OobiError::Url(format!("missing /oobi path in {url:?}"))), + }; + if authority.is_empty() { + return Err(OobiError::Url(format!("empty authority in {url:?}"))); + } + + // Drop any query string / fragment (keripy treats them as alias hints + // only) and split the path into its segments. + let path = path.split(['?', '#']).next().unwrap_or(path); + let mut segs = path.split('/').filter(|s| !s.is_empty()); + match segs.next() { + Some("oobi") => {} + _ => return Err(OobiError::Url(format!("path is not /oobi/... in {url:?}"))), + } + + let cid_str = segs + .next() + .ok_or_else(|| OobiError::Url(format!("missing cid segment in {url:?}")))?; + let cid = Prefix::new(cid_str.to_string()).map_err(|e| OobiError::Prefix { + segment: "cid", + source: e, + })?; + + let role_str = segs + .next() + .ok_or_else(|| OobiError::Url(format!("missing role segment in {url:?}")))?; + let role = Role::parse(role_str)?; + + let eid = match segs.next() { + Some(eid_str) => { + Some( + Prefix::new(eid_str.to_string()).map_err(|e| OobiError::Prefix { + segment: "eid", + source: e, + })?, + ) + } + None => None, + }; + + // A trailing segment past the eid is not a keripy OOBI. + if segs.next().is_some() { + return Err(OobiError::Url(format!("trailing path segment in {url:?}"))); + } + + Ok(Oobi { + scheme, + authority: authority.to_string(), + cid, + role, + eid, + }) + } + + /// The canonical OOBI URL for this introduction. + /// + /// Round-trips [`Oobi::parse`]: `Oobi::parse(&o.url()) == Ok(o)`. + pub fn url(&self) -> String { + let base = format!( + "{}://{}/oobi/{}/{}", + self.scheme, self.authority, self.cid, self.role + ); + match &self.eid { + Some(eid) => format!("{base}/{eid}"), + None => base, + } + } +} + +impl std::fmt::Display for Oobi { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.url()) + } +} + +/// A `/loc/scheme` reply — *"endpoint `eid` is reachable via `scheme` at `url`."* +/// +/// Byte-exact with keripy's `Hab.makeLocScheme`: serializes as +/// `{v, t:"rpy", d, dt, r:"/loc/scheme", a:{eid, scheme, url}}`, SAID-ified over +/// the whole record and version-sized to the serialized bytes. Build via +/// [`LocSchemeReply::new`] (which saidifies), then serialize for the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocSchemeReply { + /// Version string `KERI10JSON{size:06x}_`. + pub v: String, + /// SAID of this reply (Blake3-256 over the saidified record). + pub d: Said, + /// ISO-8601 datetime stamp (RFC-3339 profile, microsecond precision). + pub dt: String, + /// Endpoint provider AID this location describes. + pub eid: Prefix, + /// URL scheme of the endpoint (`http`/`https`/`tcp`). + pub scheme: String, + /// Endpoint URL. + pub url: String, +} + +impl LocSchemeReply { + /// Builds a saidified `/loc/scheme` reply for an endpoint location. + pub fn new( + eid: Prefix, + scheme: impl Into, + url: impl Into, + dt: impl Into, + ) -> Result { + let mut reply = Self { + v: KERI_VERSION_PLACEHOLDER.to_string(), + d: Said::default(), + dt: dt.into(), + eid, + scheme: scheme.into(), + url: url.into(), + }; + reply.saidify()?; + Ok(reply) + } + + fn saidify(&mut self) -> Result<(), OobiError> { + let body = + serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?; + self.d = compute_said_with_protocol(&body, Protocol::Keri)?; + self.v = recompute_version_string(&*self)?; + Ok(()) + } +} + +impl Serialize for LocSchemeReply { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(6))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "rpy")?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", "/loc/scheme")?; + let mut a = serde_json::Map::new(); + a.insert( + "eid".into(), + serde_json::Value::String(self.eid.to_string()), + ); + a.insert( + "scheme".into(), + serde_json::Value::String(self.scheme.clone()), + ); + a.insert("url".into(), serde_json::Value::String(self.url.clone())); + map.serialize_entry("a", &serde_json::Value::Object(a))?; + map.end() + } +} + +/// An `/end/role/add` reply — *"controller `cid` authorizes `eid` in `role`."* +/// +/// Byte-exact with keripy's `Hab.makeEndRole`: serializes as +/// `{v, t:"rpy", d, dt, r:"/end/role/add", a:{cid, role, eid}}`, SAID-ified and +/// version-sized exactly as `Hab.reply`. Build via [`EndRoleReply::new`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndRoleReply { + /// Version string `KERI10JSON{size:06x}_`. + pub v: String, + /// SAID of this reply. + pub d: Said, + /// ISO-8601 datetime stamp. + pub dt: String, + /// Controller AID authorizing the endpoint. + pub cid: Prefix, + /// Role the endpoint is authorized for. + pub role: Role, + /// Endpoint provider AID being authorized. + pub eid: Prefix, +} + +impl EndRoleReply { + /// Builds a saidified `/end/role/add` reply authorizing an endpoint. + pub fn new( + cid: Prefix, + role: Role, + eid: Prefix, + dt: impl Into, + ) -> Result { + let mut reply = Self { + v: KERI_VERSION_PLACEHOLDER.to_string(), + d: Said::default(), + dt: dt.into(), + cid, + role, + eid, + }; + reply.saidify()?; + Ok(reply) + } + + fn saidify(&mut self) -> Result<(), OobiError> { + let body = + serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?; + self.d = compute_said_with_protocol(&body, Protocol::Keri)?; + self.v = recompute_version_string(&*self)?; + Ok(()) + } +} + +impl Serialize for EndRoleReply { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(6))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "rpy")?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", "/end/role/add")?; + let mut a = serde_json::Map::new(); + a.insert( + "cid".into(), + serde_json::Value::String(self.cid.to_string()), + ); + a.insert( + "role".into(), + serde_json::Value::String(self.role.to_string()), + ); + a.insert( + "eid".into(), + serde_json::Value::String(self.eid.to_string()), + ); + map.serialize_entry("a", &serde_json::Value::Object(a))?; + map.end() + } +} + +/// The serve side: an AID's discoverable introduction. +/// +/// From a controller's KEL and the URL its endpoint is hosted at, this derives +/// what a resolving peer needs: the OOBI URL to publish, and the `rpy` reply +/// stream (`/loc/scheme` + `/end/role/add`) the peer fetches. The endpoint +/// provider (`eid`) defaults to the controller itself (`cid`) — the +/// "controller" role — exactly as keripy's self-introduction does. +#[derive(Debug, Clone)] +pub struct OobiEndpoint { + /// The OOBI URL a peer resolves to discover this controller. + pub oobi: Oobi, + /// The endpoint-location reply (`/loc/scheme`). + pub loc_scheme: LocSchemeReply, + /// The role-authorization reply (`/end/role/add`). + pub end_role: EndRoleReply, +} + +impl OobiEndpoint { + /// Derives a controller's self-introduction from its replayed key-state. + /// + /// `scheme` + `authority` describe where the controller hosts its endpoint; + /// `url` is the absolute endpoint URL embedded in the `/loc/scheme` reply + /// (keripy includes the full URL, not just the authority). The introduction + /// is for the `controller` role with the controller as its own endpoint. + pub fn for_controller( + state: &KeyState, + scheme: impl Into, + authority: impl Into, + url: impl Into, + dt: impl Into, + ) -> Result { + let scheme = scheme.into(); + let authority = authority.into(); + let dt = dt.into(); + let cid = state.prefix.clone(); + let oobi = Oobi { + scheme: scheme.clone(), + authority, + cid: cid.clone(), + role: Role::Controller, + eid: None, + }; + let loc_scheme = LocSchemeReply::new(cid.clone(), scheme, url, dt.clone())?; + let end_role = EndRoleReply::new(cid.clone(), Role::Controller, cid, dt)?; + Ok(OobiEndpoint { + oobi, + loc_scheme, + end_role, + }) + } + + /// Serializes the `rpy` reply stream a resolving peer fetches (newline-joined + /// JSON, the keripy `replyEndRole` wire shape minus the leading KEL replay, + /// which the caller prepends from the KEL it serves). + pub fn reply_stream(&self) -> Result { + let loc = serde_json::to_string(&self.loc_scheme) + .map_err(KeriTranslationError::SerializationFailed)?; + let end = serde_json::to_string(&self.end_role) + .map_err(KeriTranslationError::SerializationFailed)?; + Ok(format!("{loc}\n{end}")) + } +} + +/// The result of resolving a peer's OOBI: a verified key-state plus the endpoint +/// reply records the peer published alongside its KEL. +#[derive(Debug, Clone)] +pub struct OobiResolution { + /// The controller AID the OOBI introduced. + pub cid: Prefix, + /// The replayed, verified key-state of that controller's KEL. + pub state: KeyState, + /// Number of KEL events ingested. + pub event_count: usize, +} + +/// Ingests the bytes fetched from an OOBI URL: replays the embedded KEL into a +/// verified [`KeyState`]. +/// +/// The fetched body is a KERI message stream. We extract its key events (the +/// `icp`/`rot`/`ixn`/`dip`/`drt` records the peer replayed) as a JSON array and +/// replay them — so the KEL is verified cryptographically, not trusted because +/// it arrived over a particular URL. The OOBI URL only told us *where* to look; +/// trust comes from the replay. +/// +/// `expected_cid` is the controller the OOBI claimed to introduce; ingest fails +/// if the replayed KEL's prefix does not match it (an OOBI that delivers a +/// *different* AID's KEL is a discovery failure, not a silent substitution). +pub fn ingest_oobi_stream( + expected_cid: &Prefix, + kel_json: &str, +) -> Result { + let events = parse_kel_json(kel_json)?; + if events.is_empty() { + return Err(OobiError::EmptyKel); + } + let event_count = events.len(); + // A KEL fetched through an OOBI is replayed (verified) before it is trusted. + let state = TrustedKel::from_trusted_source(&events).replay()?; + if state.prefix != *expected_cid { + return Err(OobiError::CidMismatch { + expected: expected_cid.to_string(), + actual: state.prefix.to_string(), + }); + } + Ok(OobiResolution { + cid: state.prefix.clone(), + state, + event_count, + }) +} + +/// Errors raised while parsing, serving, or resolving an OOBI. +#[derive(Debug, thiserror::Error)] +pub enum OobiError { + /// The OOBI URL did not match the `:///oobi/...` grammar. + #[error("invalid OOBI URL: {0}")] + Url(String), + /// The URL scheme is not one KERI speaks (`http`/`https`/`tcp`). + #[error("unsupported OOBI scheme: {0:?}")] + Scheme(String), + /// A path segment was not a CESR-valid prefix. + #[error("invalid {segment} prefix in OOBI URL: {source}")] + Prefix { + /// Which segment failed (`cid` or `eid`). + segment: &'static str, + /// The underlying CESR/derivation-code error. + source: crate::types::KeriTypeError, + }, + /// The role segment was not a known KERI role. + #[error("unknown OOBI role: {0:?}")] + Role(String), + /// The OOBI stream carried no KEL events. + #[error("OOBI stream carried no KEL events")] + EmptyKel, + /// The replayed KEL belonged to a different AID than the OOBI introduced. + #[error("OOBI introduced {expected} but delivered a KEL for {actual}")] + CidMismatch { + /// The AID the OOBI URL claimed. + expected: String, + /// The AID the delivered KEL actually replayed to. + actual: String, + }, + /// The fetched KEL failed to parse or replay (cryptographic verification). + #[error("KEL replay failed: {0}")] + Replay(#[from] ValidationError), + /// A wire record failed to saidify/serialize. + #[error("KERI record build failed: {0}")] + Record(#[from] KeriTranslationError), +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + const CID: &str = "EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM"; + const EID: &str = "BADQWh0eolE5bVV6-9RYizxtmdvrly_tEKMlYuom3Nz6"; + + #[test] + fn parses_controller_oobi() { + let url = format!("http://127.0.0.1:5642/oobi/{CID}/controller"); + let oobi = Oobi::parse(&url).unwrap(); + assert_eq!(oobi.scheme, "http"); + assert_eq!(oobi.authority, "127.0.0.1:5642"); + assert_eq!(oobi.cid.as_str(), CID); + assert_eq!(oobi.role, Role::Controller); + assert_eq!(oobi.eid, None); + } + + #[test] + fn parses_witness_oobi_with_eid() { + let url = format!("https://witness.example:5631/oobi/{CID}/witness/{EID}"); + let oobi = Oobi::parse(&url).unwrap(); + assert_eq!(oobi.scheme, "https"); + assert_eq!(oobi.role, Role::Witness); + assert_eq!(oobi.eid.as_ref().unwrap().as_str(), EID); + } + + #[test] + fn url_round_trips() { + for url in [ + format!("http://127.0.0.1:5642/oobi/{CID}/controller"), + format!("https://w.example:5631/oobi/{CID}/witness/{EID}"), + format!("tcp://10.0.0.1:5621/oobi/{CID}/mailbox"), + ] { + let oobi = Oobi::parse(&url).unwrap(); + assert_eq!(oobi.url(), url); + assert_eq!(Oobi::parse(&oobi.url()).unwrap(), oobi); + } + } + + #[test] + fn drops_query_alias_hint() { + let url = format!("http://127.0.0.1:5642/oobi/{CID}/controller?name=alice"); + let oobi = Oobi::parse(&url).unwrap(); + assert_eq!(oobi.cid.as_str(), CID); + assert_eq!(oobi.role, Role::Controller); + } + + #[test] + fn rejects_bad_scheme() { + let err = Oobi::parse(&format!("ftp://h/oobi/{CID}/controller")).unwrap_err(); + assert!(matches!(err, OobiError::Scheme(_))); + } + + #[test] + fn rejects_unknown_role() { + let err = Oobi::parse(&format!("http://h:1/oobi/{CID}/overlord")).unwrap_err(); + assert!(matches!(err, OobiError::Role(_))); + } + + #[test] + fn rejects_missing_path() { + assert!(matches!( + Oobi::parse(&format!("http://h:1/oobi/{CID}")).unwrap_err(), + OobiError::Url(_) + )); + assert!(matches!( + Oobi::parse("http://h:1").unwrap_err(), + OobiError::Url(_) + )); + } + + #[test] + fn rejects_invalid_cid_prefix() { + let err = Oobi::parse("http://h:1/oobi/not-a-prefix/controller").unwrap_err(); + assert!(matches!(err, OobiError::Prefix { segment: "cid", .. })); + } + + #[test] + fn role_parse_total() { + for r in [ + Role::Controller, + Role::Witness, + Role::Watcher, + Role::Registrar, + Role::Judge, + Role::Juror, + Role::Peer, + Role::Mailbox, + Role::Agent, + Role::Gateway, + ] { + assert_eq!(Role::parse(r.as_str()).unwrap(), r); + } + assert!(Role::parse("nope").is_err()); + } + + // The wire records must be byte-exact with keripy 1.3.4's `Hab.reply`. These + // SAIDs/version strings were generated from keripy itself (the oracle): + // serdering.SerderKERI(sad={v, t:"rpy", d:"", dt, r, a}, makify=True) + #[test] + fn loc_scheme_reply_byte_exact_keripy() { + let reply = LocSchemeReply::new( + Prefix::new(EID.to_string()).unwrap(), + "http", + "http://127.0.0.1:5642/", + "2024-01-01T00:00:00.000000+00:00", + ) + .unwrap(); + let json = serde_json::to_string(&reply).unwrap(); + let expected = r#"{"v":"KERI10JSON0000fa_","t":"rpy","d":"EHrMc5EKCqJHrpCAAlgG6UPaupi-tmlDw8SvspQobfC1","dt":"2024-01-01T00:00:00.000000+00:00","r":"/loc/scheme","a":{"eid":"BADQWh0eolE5bVV6-9RYizxtmdvrly_tEKMlYuom3Nz6","scheme":"http","url":"http://127.0.0.1:5642/"}}"#; + assert_eq!(json, expected); + } + + #[test] + fn end_role_add_reply_byte_exact_keripy() { + let reply = EndRoleReply::new( + Prefix::new(CID.to_string()).unwrap(), + Role::Controller, + Prefix::new(EID.to_string()).unwrap(), + "2024-01-01T00:00:00.000000+00:00", + ) + .unwrap(); + let json = serde_json::to_string(&reply).unwrap(); + let expected = r#"{"v":"KERI10JSON000116_","t":"rpy","d":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF","dt":"2024-01-01T00:00:00.000000+00:00","r":"/end/role/add","a":{"cid":"EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM","role":"controller","eid":"BADQWh0eolE5bVV6-9RYizxtmdvrly_tEKMlYuom3Nz6"}}"#; + assert_eq!(json, expected); + } +} From 209009254e9ae3150a25d4414f166a066697b315 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 09:58:16 +0100 Subject: [PATCH 13/19] =?UTF-8?q?feat(keri):=20IPEX=20grant/admit=20?= =?UTF-8?q?=E2=80=94=20exchange=20an=20ACDC=20credential=20over=20KERI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Issuance & Presentation EXchange (IPEX) handshake — KERI's standard peer-to-peer way two controllers hand over an ACDC credential, instead of each inventing a bespoke presentation wire. - auths-keri::ipex: typed `IpexGrant` / `IpexAdmit` `exn` records. A grant embeds a saidified ACDC in its `e` block addressed to the recipient; an admit references the grant's SAID as its prior. Both saidify byte-exact with keripy 1.3.4 (`keri.vc.protocoling` over `exchanging.exchange`): field order `{v,t,d,i,rp,p,dt,r,q,a,e}`, the top-level `d` over the whole record, the grant's `e.d` section SAID over `{acdc,d}`. Parse is total — wrong route, mismatched record SAID, or a tampered embedded ACDC are all rejected at the boundary. - auths ipex grant/admit: a thin file-based CLI adapter over the module (mirrors `auths oobi` / `auths did-webs`); the transport/signer stay out of the wire logic. Cross-verified: keripy 1.3.4 parses an auths-produced grant and recomputes the identical `exn` SAID, and the grant/admit bytes match keripy's own `ipexGrantExn` / `ipexAdmitExn` output for the same inputs. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/auths-cli/src/cli.rs | 3 + crates/auths-cli/src/commands/ipex.rs | 175 +++++++ crates/auths-cli/src/commands/mod.rs | 1 + crates/auths-cli/src/main.rs | 1 + crates/auths-keri/src/ipex.rs | 626 ++++++++++++++++++++++++++ crates/auths-keri/src/lib.rs | 4 + 6 files changed, 810 insertions(+) create mode 100644 crates/auths-cli/src/commands/ipex.rs create mode 100644 crates/auths-keri/src/ipex.rs diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index 6560f291..dbf46252 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -25,6 +25,7 @@ use crate::commands::emergency::EmergencyCommand; use crate::commands::error_lookup::ErrorLookupCommand; use crate::commands::id::IdCommand; use crate::commands::init::InitCommand; +use crate::commands::ipex::IpexCommand; use crate::commands::key::KeyCommand; use crate::commands::key_state::KeyStateCommand; use crate::commands::learn::LearnCommand; @@ -132,6 +133,8 @@ pub enum RootCommand { #[command(hide = true)] Oobi(OobiCommand), #[command(hide = true)] + Ipex(IpexCommand), + #[command(hide = true)] Approval(ApprovalCommand), #[command(hide = true)] Policy(PolicyCommand), diff --git a/crates/auths-cli/src/commands/ipex.rs b/crates/auths-cli/src/commands/ipex.rs new file mode 100644 index 00000000..9b4816b2 --- /dev/null +++ b/crates/auths-cli/src/commands/ipex.rs @@ -0,0 +1,175 @@ +//! `auths ipex` — IPEX (Issuance & Presentation EXchange) credential handover. +//! +//! IPEX is KERI's standard peer-to-peer handshake for moving an ACDC credential +//! between two controllers: the discloser sends a `grant` `exn` carrying the +//! credential, and the holder answers with an `admit` `exn` that references the +//! grant's SAID. It is the interoperable alternative to a bespoke presentation +//! wire — a credential exchanged this way is one keripy/KERIA can ingest, and a +//! grant a keripy peer sends is one auths can parse. +//! +//! Two directions, mirroring the two roles in a disclosure: +//! +//! * `auths ipex grant` — discloser → holder: read a saidified ACDC, embed it in +//! a `/ipex/grant` `exn` addressed to the recipient, and print the `exn`. +//! * `auths ipex admit` — holder → discloser: read a peer's grant `exn`, verify +//! it (and the credential inside it), and print an `/ipex/admit` `exn` whose +//! prior is the grant's SAID. +//! +//! The wire definitions (the `exn` records, their SAIDs, the embeds block) live +//! in `auths-keri::ipex` and are byte-exact with keripy 1.3.4; this is a thin +//! file-based CLI adapter over them. Signing the `exn` and putting it on a +//! transport are the caller's concern — this surface produces the canonical +//! bytes to sign and send. + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, anyhow}; +use auths_keri::{Acdc, IpexAdmit, IpexGrant, Prefix}; +use auths_utils::path::expand_tilde; +use clap::{Parser, Subcommand}; + +use crate::config::CliConfig; + +/// Default datetime stamp — the epoch, so output is deterministic unless the +/// operator passes a real `now`. Matches the OOBI command's convention. +const DEFAULT_DT: &str = "1970-01-01T00:00:00.000000+00:00"; + +/// Exchange an ACDC credential over IPEX (grant/admit), interoperable with keripy/KERIA. +#[derive(Parser, Debug, Clone)] +#[command( + about = "Exchange a credential over IPEX (grant/admit) — interoperable with keripy/KERIA", + after_help = "Examples: + auths ipex grant --acdc cred.json --sender EOoC... --recipient EBHn... + auths ipex admit --grant grant.json --sender EBHn..." +)] +pub struct IpexCommand { + /// The IPEX direction to run. + #[command(subcommand)] + pub action: IpexAction, +} + +/// The two IPEX directions: grant a credential, or admit a received grant. +#[derive(Subcommand, Debug, Clone)] +pub enum IpexAction { + /// Grant (disclose) a credential: embed an ACDC in a `/ipex/grant` `exn`. + Grant(GrantArgs), + /// Admit (accept) a received grant: emit an `/ipex/admit` `exn` for it. + Admit(AdmitArgs), +} + +/// `auths ipex grant` — disclose a credential to a holder. +#[derive(Parser, Debug, Clone)] +pub struct GrantArgs { + /// The saidified ACDC credential to disclose (its JSON body, `{v,d,i,ri,s,a}`). + #[clap(long, value_name = "ACDC.json")] + pub acdc: PathBuf, + + /// The discloser (sender) AID granting the credential. + #[clap(long, value_name = "AID")] + pub sender: String, + + /// The recipient (holder) AID the credential is granted to. + #[clap(long, value_name = "AID")] + pub recipient: String, + + /// Optional human-readable disclosure message (`a.m`). + #[clap(long, default_value = "")] + pub message: String, + + /// Timestamp (RFC 3339) to stamp the `exn` with. Defaults to the epoch so + /// output stays deterministic; pass the real `now` to send. + #[clap(long, default_value = DEFAULT_DT)] + pub dt: String, +} + +/// `auths ipex admit` — accept a credential a peer granted. +#[derive(Parser, Debug, Clone)] +pub struct AdmitArgs { + /// The peer's grant `exn` to admit (its JSON body). + #[clap(long, value_name = "GRANT.json")] + pub grant: PathBuf, + + /// The holder (sender) AID admitting the disclosure. + #[clap(long, value_name = "AID")] + pub sender: String, + + /// Optional human-readable admission message (`a.m`). + #[clap(long, default_value = "")] + pub message: String, + + /// Timestamp (RFC 3339) to stamp the `exn` with. Defaults to the epoch so + /// output stays deterministic; pass the real `now` to send. + #[clap(long, default_value = DEFAULT_DT)] + pub dt: String, +} + +impl IpexCommand { + /// Run the command (grant a credential or admit a received grant). + pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { + match &self.action { + IpexAction::Grant(args) => args.run(), + IpexAction::Admit(args) => args.run(), + } + } +} + +impl GrantArgs { + fn run(&self) -> Result<()> { + // Parse the AIDs at the boundary — an invalid prefix never reaches the + // grant builder. + let sender = Prefix::new(self.sender.clone()) + .map_err(|e| anyhow!("parse sender AID {:?}: {e}", self.sender))?; + let recipient = Prefix::new(self.recipient.clone()) + .map_err(|e| anyhow!("parse recipient AID {:?}: {e}", self.recipient))?; + + let acdc = read_acdc(&self.acdc)?; + + let grant = IpexGrant::new( + sender, + recipient, + acdc, + self.message.clone(), + self.dt.clone(), + ) + .map_err(|e| anyhow!("build IPEX grant: {e}"))?; + println!("{}", serde_json::to_string(&grant)?); + Ok(()) + } +} + +impl AdmitArgs { + fn run(&self) -> Result<()> { + let sender = Prefix::new(self.sender.clone()) + .map_err(|e| anyhow!("parse sender AID {:?}: {e}", self.sender))?; + + // Parse the peer's grant — its `exn` SAID and the embedded ACDC are both + // verified by `IpexGrant::parse`, so we never admit a tampered grant. + let path = expand_tilde(&self.grant)?; + let json = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read grant {}: {e}", path.display()))?; + let grant = IpexGrant::parse(&json).map_err(|e| anyhow!("parse IPEX grant: {e}"))?; + + let admit = IpexAdmit::new( + sender, + grant.d.clone(), + self.message.clone(), + self.dt.clone(), + ) + .map_err(|e| anyhow!("build IPEX admit: {e}"))?; + println!("{}", serde_json::to_string(&admit)?); + Ok(()) + } +} + +/// Reads a saidified ACDC from a JSON file, verifying its SAID at the boundary — +/// a grant cannot disclose a credential that doesn't stand on its own digest. +fn read_acdc(acdc_path: &Path) -> Result { + let path = expand_tilde(acdc_path)?; + let json = + std::fs::read_to_string(&path).map_err(|e| anyhow!("read ACDC {}: {e}", path.display()))?; + let acdc: Acdc = + serde_json::from_str(&json).map_err(|e| anyhow!("parse ACDC {}: {e}", path.display()))?; + acdc.verify_said() + .map_err(|e| anyhow!("verify ACDC SAID {}: {e}", path.display()))?; + Ok(acdc) +} diff --git a/crates/auths-cli/src/commands/mod.rs b/crates/auths-cli/src/commands/mod.rs index 88e99b1f..1b833c31 100644 --- a/crates/auths-cli/src/commands/mod.rs +++ b/crates/auths-cli/src/commands/mod.rs @@ -25,6 +25,7 @@ pub mod git_helpers; pub mod id; pub mod index; pub mod init; +pub mod ipex; pub mod key; pub mod key_detect; pub mod key_state; diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index 943cc976..44114a6f 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -109,6 +109,7 @@ fn run() -> Result<()> { RootCommand::KeyState(cmd) => cmd.execute(&ctx), RootCommand::DidWebs(cmd) => cmd.execute(&ctx), RootCommand::Oobi(cmd) => cmd.execute(&ctx), + RootCommand::Ipex(cmd) => cmd.execute(&ctx), RootCommand::Approval(cmd) => cmd.execute(&ctx), RootCommand::Policy(cmd) => cmd.execute(&ctx), RootCommand::Namespace(cmd) => cmd.execute(&ctx), diff --git a/crates/auths-keri/src/ipex.rs b/crates/auths-keri/src/ipex.rs new file mode 100644 index 00000000..a9b9d134 --- /dev/null +++ b/crates/auths-keri/src/ipex.rs @@ -0,0 +1,626 @@ +//! IPEX — the Issuance & Presentation EXchange protocol. +//! +//! IPEX is KERI's standard peer-to-peer handshake for handing over an ACDC +//! credential. Where a credential *is* an ACDC and its *status* lives in a TEL, +//! IPEX is the *exchange envelope*: a pair of signed `exn` (peer exchange) +//! messages that carry the credential from a discloser to a holder and record +//! the holder's acceptance — the standard way two KERI controllers move a +//! credential between them, instead of each inventing a bespoke presentation +//! wire. +//! +//! Two messages, mirroring the two roles in a disclosure: +//! +//! * **Grant** (discloser → holder): *"here is a credential for you."* An +//! [`IpexGrant`] is an `exn` routed `/ipex/grant` whose attributes name the +//! recipient and whose embeds block carries the full ACDC. It opens the +//! exchange, so it has no prior (`p` is empty). +//! * **Admit** (holder → discloser): *"I accept it."* An [`IpexAdmit`] is an +//! `exn` routed `/ipex/admit` whose prior (`p`) is the grant's SAID, closing +//! the loop. It carries no embeds. +//! +//! The wire records are byte-exact with keripy 1.3.4's `keri.vc.protocoling` +//! (`ipexGrantExn` / `ipexAdmitExn` over `keri.peer.exchanging.exchange`). An +//! `exn` serializes in field order `{v, t:"exn", d, i, rp, p, dt, r, q, a, e}`, +//! SAID-ified over the whole record (the top-level `d`), and — for a grant — the +//! embeds block `e` carries its own section SAID (`e.d`) over `{acdc, d}`, +//! exactly as `exchanging.exchange` saidifies `e` under the `d` label. The +//! version string is sized `KERI10JSON{size:06x}_` like every KERI record. +//! +//! This module is I/O-free: it builds and parses the `exn` wire records and +//! pulls the embedded ACDC back out, verifying its SAID. Signing the `exn` and +//! putting it on a transport sit behind ports in the caller (the CLI's IPEX +//! adapter), so the exchange logic never imports a signer or a socket. + +use serde::ser::SerializeMap; +use serde::{Serialize, Serializer}; + +use crate::acdc::{Acdc, AcdcError}; +use crate::error::KeriTranslationError; +use crate::events::KERI_VERSION_PREFIX; +use crate::said::{Protocol, compute_said_with_protocol, compute_section_said}; +use crate::types::{Prefix, Said}; + +/// Placeholder version string filled in during saidify (17 chars, like every +/// KERI record's `v`). +const KERI_VERSION_PLACEHOLDER: &str = "KERI10JSON000000_"; + +/// The keripy `exn` ilk — IPEX rides peer exchange messages, not key events. +const EXN_ILK: &str = "exn"; + +/// Sizes the version string `KERI10JSON{size:06x}_` to a serialized record — the +/// same single-pass machinery the OOBI/TEL records use (the `v` field width is +/// constant, so re-serializing with the placeholder gives the final byte length). +fn recompute_version_string(event: &T) -> Result { + let bytes = serde_json::to_vec(event).map_err(KeriTranslationError::SerializationFailed)?; + Ok(format!("{KERI_VERSION_PREFIX}{:06x}_", bytes.len())) +} + +/// An IPEX grant `exn` — *"discloser `i` grants the embedded ACDC to holder `rcp`."* +/// +/// Byte-exact with keripy 1.3.4's `ipexGrantExn`: serializes as +/// `{v, t:"exn", d, i, rp:"", p:"", dt, r:"/ipex/grant", q:{}, +/// a:{m, i:}, e:{acdc:, d:}}`. The grant opens an +/// exchange, so `rp` and `p` are empty (keripy's `exchange` leaves them `""` when +/// no recipient/prior is threaded through `exchange`). Build via +/// [`IpexGrant::new`] (which saidifies the embeds block then the whole record), +/// then serialize for the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpexGrant { + /// Version string `KERI10JSON{size:06x}_`. + pub v: String, + /// SAID of this grant `exn` (Blake3-256 over the saidified record). + pub d: Said, + /// Discloser (sender) AID — the controller granting the credential. + pub i: Prefix, + /// Human-readable disclosure message (`a.m`); empty by default. + pub m: String, + /// Recipient (holder) AID the credential is granted to (`a.i`). + pub recipient: Prefix, + /// ISO-8601 datetime stamp (RFC-3339 profile, microsecond precision). + pub dt: String, + /// The ACDC being disclosed, carried in the `e.acdc` embeds slot. + pub acdc: Acdc, + /// SAID of the embeds block `e` (`e.d`), over `{acdc, d}`. + pub embeds_said: Said, +} + +impl IpexGrant { + /// The keripy route for an IPEX grant `exn`. + pub const ROUTE: &'static str = "/ipex/grant"; + + /// Builds a saidified IPEX grant `exn` disclosing `acdc` to `recipient`. + /// + /// The ACDC must already be saidified (its own `d`/`a.d` filled) — a grant + /// discloses an existing credential, it does not mint one. `message` is the + /// optional human-readable note (`a.m`); pass `""` for keripy's default. + pub fn new( + sender: Prefix, + recipient: Prefix, + acdc: Acdc, + message: impl Into, + dt: impl Into, + ) -> Result { + let mut grant = Self { + v: KERI_VERSION_PLACEHOLDER.to_string(), + d: Said::default(), + i: sender, + m: message.into(), + recipient, + dt: dt.into(), + acdc, + embeds_said: Said::default(), + }; + grant.saidify()?; + Ok(grant) + } + + /// Computes the embeds-block SAID (`e.d`) then the top-level grant SAID, in + /// place — the two-stage order keripy's `exchange` uses (saidify `e` first, + /// substitute `e.d`, then saidify the whole `exn`). + fn saidify(&mut self) -> Result<(), IpexError> { + // `e.d` is a section SAID over `{acdc, d}` (the embeds block keripy + // saidifies under the `d` label), with the ACDC carrying its own SAID. + let embeds = self.embeds_value()?; + self.embeds_said = compute_section_said(&embeds)?; + + // The top-level `d` is a plain KERI-protocol SAID over the whole record + // (`exn` is not an inception, so `i` is kept during hashing). + let body = + serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?; + self.d = compute_said_with_protocol(&body, Protocol::Keri)?; + self.v = recompute_version_string(&*self)?; + Ok(()) + } + + /// The `e` embeds block as JSON: `{acdc:, d:}` (the SAID is + /// placeholder-filled by [`compute_section_said`] when it is recomputed). + fn embeds_value(&self) -> Result { + let acdc = + serde_json::to_value(&self.acdc).map_err(KeriTranslationError::SerializationFailed)?; + let mut e = serde_json::Map::new(); + e.insert("acdc".to_string(), acdc); + e.insert( + "d".to_string(), + serde_json::Value::String(self.embeds_said.as_str().to_string()), + ); + Ok(serde_json::Value::Object(e)) + } + + /// Parses a peer's grant `exn` JSON into a typed [`IpexGrant`], verifying both + /// the record SAID and the embedded ACDC SAID at the boundary. + /// + /// Total at the boundary: a record whose route is not `/ipex/grant`, whose + /// `d`/`e.d` SAIDs don't recompute, or whose embedded ACDC fails its own SAID + /// check never becomes an `IpexGrant`. + pub fn parse(json: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(json).map_err(KeriTranslationError::SerializationFailed)?; + let obj = value.as_object().ok_or(IpexError::NotAnObject)?; + + expect_ilk(obj)?; + expect_route(obj, Self::ROUTE)?; + + let sender = parse_prefix(obj, "i")?; + let dt = parse_str(obj, "dt")?; + + let a = obj + .get("a") + .and_then(|v| v.as_object()) + .ok_or(IpexError::MissingField { field: "a" })?; + let message = a + .get("m") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let recipient = a + .get("i") + .and_then(|v| v.as_str()) + .ok_or(IpexError::MissingField { field: "a.i" }) + .and_then(|s| { + Prefix::new(s.to_string()).map_err(|source| IpexError::Prefix { + field: "a.i", + source, + }) + })?; + + let e = obj + .get("e") + .and_then(|v| v.as_object()) + .ok_or(IpexError::MissingField { field: "e" })?; + let acdc_value = e + .get("acdc") + .ok_or(IpexError::MissingField { field: "e.acdc" })?; + let acdc: Acdc = serde_json::from_value(acdc_value.clone()) + .map_err(KeriTranslationError::SerializationFailed)?; + // The embedded credential must stand on its own SAID — a grant cannot + // launder a tampered ACDC behind the exchange envelope. + acdc.verify_said()?; + + let grant = Self::new(sender, recipient, acdc, message, dt)?; + verify_said(obj, &grant.d)?; + Ok(grant) + } +} + +impl Serialize for IpexGrant { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(11))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", EXN_ILK)?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("i", &self.i)?; + map.serialize_entry("rp", "")?; + map.serialize_entry("p", "")?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", IpexGrant::ROUTE)?; + map.serialize_entry("q", &serde_json::Map::::new())?; + let mut a = serde_json::Map::new(); + a.insert("m".into(), serde_json::Value::String(self.m.clone())); + a.insert( + "i".into(), + serde_json::Value::String(self.recipient.to_string()), + ); + map.serialize_entry("a", &serde_json::Value::Object(a))?; + let mut e = serde_json::Map::new(); + e.insert( + "acdc".into(), + serde_json::to_value(&self.acdc).map_err(serde::ser::Error::custom)?, + ); + e.insert( + "d".into(), + serde_json::Value::String(self.embeds_said.as_str().to_string()), + ); + map.serialize_entry("e", &serde_json::Value::Object(e))?; + map.end() + } +} + +/// An IPEX admit `exn` — *"holder `i` admits the grant `p`."* +/// +/// Byte-exact with keripy 1.3.4's `ipexAdmitExn`: serializes as +/// `{v, t:"exn", d, i, rp:"", p:, dt, r:"/ipex/admit", q:{}, +/// a:{m}, e:{}}`. The admit responds to a grant, so its prior (`p`) is the grant +/// SAID and its embeds block `e` is empty (no `e.d`, since there is nothing to +/// saidify). Build via [`IpexAdmit::new`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpexAdmit { + /// Version string `KERI10JSON{size:06x}_`. + pub v: String, + /// SAID of this admit `exn`. + pub d: Said, + /// Holder (sender) AID admitting the disclosure. + pub i: Prefix, + /// Human-readable admission message (`a.m`); empty by default. + pub m: String, + /// Prior (`p`) — the SAID of the grant this admit responds to. + pub prior: Said, + /// ISO-8601 datetime stamp. + pub dt: String, +} + +impl IpexAdmit { + /// The keripy route for an IPEX admit `exn`. + pub const ROUTE: &'static str = "/ipex/admit"; + + /// Builds a saidified IPEX admit `exn` accepting the grant identified by + /// `grant_said`. + pub fn new( + sender: Prefix, + grant_said: Said, + message: impl Into, + dt: impl Into, + ) -> Result { + let mut admit = Self { + v: KERI_VERSION_PLACEHOLDER.to_string(), + d: Said::default(), + i: sender, + m: message.into(), + prior: grant_said, + dt: dt.into(), + }; + admit.saidify()?; + Ok(admit) + } + + fn saidify(&mut self) -> Result<(), IpexError> { + let body = + serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?; + self.d = compute_said_with_protocol(&body, Protocol::Keri)?; + self.v = recompute_version_string(&*self)?; + Ok(()) + } + + /// Parses a peer's admit `exn` JSON into a typed [`IpexAdmit`], verifying the + /// record SAID and that it threads a prior grant SAID at the boundary. + pub fn parse(json: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(json).map_err(KeriTranslationError::SerializationFailed)?; + let obj = value.as_object().ok_or(IpexError::NotAnObject)?; + + expect_ilk(obj)?; + expect_route(obj, Self::ROUTE)?; + + let sender = parse_prefix(obj, "i")?; + let dt = parse_str(obj, "dt")?; + let prior_str = parse_str(obj, "p")?; + if prior_str.is_empty() { + // An admit with no prior is not threading any grant — it cannot open + // an IPEX exchange (keripy's IpexHandler rejects the same way). + return Err(IpexError::MissingPrior); + } + let prior = + Said::new(prior_str).map_err(|source| IpexError::Prefix { field: "p", source })?; + + let admit = Self::new(sender, prior, message_of(obj), dt)?; + verify_said(obj, &admit.d)?; + Ok(admit) + } +} + +impl Serialize for IpexAdmit { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(10))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", EXN_ILK)?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("i", &self.i)?; + map.serialize_entry("rp", "")?; + map.serialize_entry("p", &self.prior)?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", IpexAdmit::ROUTE)?; + map.serialize_entry("q", &serde_json::Map::::new())?; + let mut a = serde_json::Map::new(); + a.insert("m".into(), serde_json::Value::String(self.m.clone())); + map.serialize_entry("a", &serde_json::Value::Object(a))?; + map.serialize_entry("e", &serde_json::Map::::new())?; + map.end() + } +} + +/// Reads the `a.m` message field of an `exn`, defaulting to `""`. +fn message_of(obj: &serde_json::Map) -> String { + obj.get("a") + .and_then(|v| v.as_object()) + .and_then(|a| a.get("m")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() +} + +/// Rejects a record whose ilk (`t`) is not `exn` at the boundary. +fn expect_ilk(obj: &serde_json::Map) -> Result<(), IpexError> { + match obj.get("t").and_then(|v| v.as_str()) { + Some(EXN_ILK) => Ok(()), + other => Err(IpexError::WrongIlk(other.unwrap_or("").to_string())), + } +} + +/// Rejects a record whose route (`r`) is not the expected IPEX route. +fn expect_route( + obj: &serde_json::Map, + route: &'static str, +) -> Result<(), IpexError> { + match obj.get("r").and_then(|v| v.as_str()) { + Some(r) if r == route => Ok(()), + other => Err(IpexError::WrongRoute { + expected: route, + found: other.unwrap_or("").to_string(), + }), + } +} + +/// Reads a required string field, or errors with its name. +fn parse_str( + obj: &serde_json::Map, + field: &'static str, +) -> Result { + obj.get(field) + .and_then(|v| v.as_str()) + .map(str::to_string) + .ok_or(IpexError::MissingField { field }) +} + +/// Reads a required prefix field, validating its CESR coding at the boundary. +fn parse_prefix( + obj: &serde_json::Map, + field: &'static str, +) -> Result { + let s = parse_str(obj, field)?; + Prefix::new(s).map_err(|source| IpexError::Prefix { field, source }) +} + +/// Verifies a parsed record's carried `d` matches the SAID we recomputed. +fn verify_said( + obj: &serde_json::Map, + computed: &Said, +) -> Result<(), IpexError> { + let found = obj + .get("d") + .and_then(|v| v.as_str()) + .ok_or(IpexError::MissingField { field: "d" })?; + if found != computed.as_str() { + return Err(IpexError::SaidMismatch { + computed: computed.as_str().to_string(), + found: found.to_string(), + }); + } + Ok(()) +} + +/// Errors raised while building or parsing an IPEX `exn`. +#[derive(Debug, thiserror::Error)] +pub enum IpexError { + /// The record was not a JSON object. + #[error("IPEX record is not a JSON object")] + NotAnObject, + /// The record's ilk (`t`) was not `exn`. + #[error("IPEX record is not an exn (t = {0:?})")] + WrongIlk(String), + /// The record's route (`r`) was not the expected IPEX route. + #[error("IPEX record route is {found:?}, expected {expected:?}")] + WrongRoute { + /// The route the parser required. + expected: &'static str, + /// The route the record actually carried. + found: String, + }, + /// A required field was absent. + #[error("IPEX record missing field {field}")] + MissingField { + /// The absent field's path. + field: &'static str, + }, + /// An admit carried no prior grant SAID, so it threads no exchange. + #[error("IPEX admit has no prior grant (p is empty)")] + MissingPrior, + /// A prefix/SAID field was not a CESR-valid value. + #[error("invalid {field} in IPEX record: {source}")] + Prefix { + /// Which field failed. + field: &'static str, + /// The underlying CESR/derivation-code error. + source: crate::types::KeriTypeError, + }, + /// The carried `d` SAID did not match the one recomputed from the record. + #[error("IPEX record SAID mismatch: computed {computed}, found {found}")] + SaidMismatch { + /// The SAID recomputed from the record. + computed: String, + /// The SAID the record carried. + found: String, + }, + /// The embedded ACDC failed to build/verify. + #[error("IPEX embedded ACDC failed: {0}")] + Acdc(#[from] AcdcError), + /// A wire record failed to saidify/serialize. + #[error("KERI record build failed: {0}")] + Record(#[from] KeriTranslationError), +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::acdc::Acdc; + + const SENDER: &str = "EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM"; + const RECP: &str = "EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF"; + const REGISTRY: &str = "EO0_SHla5Gnzc-T3jkTNAclpA1iv1L9k3lQZw5cFOe9o"; + const SCHEMA: &str = "EMQWEcCnVRk1hatTNyK3sIykYSrrFvafX3bHQ9Gkk1kC"; + const DT: &str = "2024-01-01T00:00:00.000000+00:00"; + /// The grant `exn` SAID keripy 1.3.4 computes for the [`fixture_acdc`] grant. + const GRANT_SAID: &str = "EGTOcVx8ghSFYwMQT_q4YMjEzlUIh93kKfvnIzgtfgkS"; + + /// Builds the same ACDC keripy embedded in the reference grant vector. + fn fixture_acdc() -> Acdc { + Acdc::new( + Prefix::new(SENDER.to_string()).unwrap(), + Said::new(REGISTRY.to_string()).unwrap(), + Said::new(SCHEMA.to_string()).unwrap(), + Prefix::new(RECP.to_string()).unwrap(), + DT.to_string(), + serde_json::Map::new(), + ) + .saidify() + .unwrap() + } + + // The grant `exn` must be byte-exact with keripy 1.3.4's `ipexGrantExn`. This + // vector was generated from keripy itself (the oracle): + // exchanging.exchange(route="/ipex/grant", payload={m:"", i:RECP}, + // sender=SENDER, embeds={acdc:}, date=DT) + #[test] + fn grant_exn_byte_exact_keripy() { + let grant = IpexGrant::new( + Prefix::new(SENDER.to_string()).unwrap(), + Prefix::new(RECP.to_string()).unwrap(), + fixture_acdc(), + "", + DT, + ) + .unwrap(); + let json = serde_json::to_string(&grant).unwrap(); + let expected = r#"{"v":"KERI10JSON0002d4_","t":"exn","d":"EGTOcVx8ghSFYwMQT_q4YMjEzlUIh93kKfvnIzgtfgkS","i":"EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM","rp":"","p":"","dt":"2024-01-01T00:00:00.000000+00:00","r":"/ipex/grant","q":{},"a":{"m":"","i":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF"},"e":{"acdc":{"v":"ACDC10JSON00017a_","d":"ECK0Ep4HfnszjMpQDgovp19ioPdn1jwxGdnEtNHCN2Sy","i":"EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM","ri":"EO0_SHla5Gnzc-T3jkTNAclpA1iv1L9k3lQZw5cFOe9o","s":"EMQWEcCnVRk1hatTNyK3sIykYSrrFvafX3bHQ9Gkk1kC","a":{"d":"EKP_MEtpMtJfInZdMOiivHrYtz3zyObVfjDySEGxGT-V","i":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF","dt":"2024-01-01T00:00:00.000000+00:00"}},"d":"EOXgGpKt_2f6rr_JyxVwEBT1z6xbACKW0PLDhoULb0ag"}}"#; + assert_eq!(json, expected); + } + + // The admit `exn` must be byte-exact with keripy 1.3.4's `ipexAdmitExn`: + // exchanging.exchange(route="/ipex/admit", payload={m:""}, sender=RECP, + // dig=grant.said, date=DT) + #[test] + fn admit_exn_byte_exact_keripy() { + let admit = IpexAdmit::new( + Prefix::new(RECP.to_string()).unwrap(), + Said::new(GRANT_SAID.to_string()).unwrap(), + "", + DT, + ) + .unwrap(); + let json = serde_json::to_string(&admit).unwrap(); + let expected = r#"{"v":"KERI10JSON000119_","t":"exn","d":"EEAwH5LPMA4bkj5ceowBjGDnpe7aWW1BQ530djvBp1kv","i":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF","rp":"","p":"EGTOcVx8ghSFYwMQT_q4YMjEzlUIh93kKfvnIzgtfgkS","dt":"2024-01-01T00:00:00.000000+00:00","r":"/ipex/admit","q":{},"a":{"m":""},"e":{}}"#; + assert_eq!(json, expected); + } + + #[test] + fn grant_round_trips_through_parse() { + let grant = IpexGrant::new( + Prefix::new(SENDER.to_string()).unwrap(), + Prefix::new(RECP.to_string()).unwrap(), + fixture_acdc(), + "", + DT, + ) + .unwrap(); + let json = serde_json::to_string(&grant).unwrap(); + let parsed = IpexGrant::parse(&json).unwrap(); + assert_eq!(parsed, grant); + // The embedded ACDC came back out intact and self-verifying. + assert_eq!(parsed.acdc.d, grant.acdc.d); + parsed.acdc.verify_said().unwrap(); + } + + #[test] + fn admit_round_trips_through_parse() { + let admit = IpexAdmit::new( + Prefix::new(RECP.to_string()).unwrap(), + Said::new(GRANT_SAID.to_string()).unwrap(), + "", + DT, + ) + .unwrap(); + let json = serde_json::to_string(&admit).unwrap(); + let parsed = IpexAdmit::parse(&json).unwrap(); + assert_eq!(parsed, admit); + } + + #[test] + fn parse_rejects_tampered_grant_said() { + let grant = IpexGrant::new( + Prefix::new(SENDER.to_string()).unwrap(), + Prefix::new(RECP.to_string()).unwrap(), + fixture_acdc(), + "", + DT, + ) + .unwrap(); + let mut value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&grant).unwrap()).unwrap(); + value["dt"] = serde_json::Value::String("2099-01-01T00:00:00.000000+00:00".into()); + let tampered = serde_json::to_string(&value).unwrap(); + let err = IpexGrant::parse(&tampered).unwrap_err(); + assert!(matches!(err, IpexError::SaidMismatch { .. })); + } + + #[test] + fn parse_rejects_tampered_embedded_acdc() { + let grant = IpexGrant::new( + Prefix::new(SENDER.to_string()).unwrap(), + Prefix::new(RECP.to_string()).unwrap(), + fixture_acdc(), + "", + DT, + ) + .unwrap(); + let mut value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&grant).unwrap()).unwrap(); + // Tamper a credential claim without fixing its SAID — must be rejected. + value["e"]["acdc"]["a"]["dt"] = + serde_json::Value::String("2099-01-01T00:00:00.000000+00:00".into()); + let tampered = serde_json::to_string(&value).unwrap(); + let err = IpexGrant::parse(&tampered).unwrap_err(); + assert!(matches!(err, IpexError::Acdc(_))); + } + + #[test] + fn parse_rejects_wrong_route() { + let grant = IpexGrant::new( + Prefix::new(SENDER.to_string()).unwrap(), + Prefix::new(RECP.to_string()).unwrap(), + fixture_acdc(), + "", + DT, + ) + .unwrap(); + let json = serde_json::to_string(&grant).unwrap(); + // A grant body is not an admit. + let err = IpexAdmit::parse(&json).unwrap_err(); + assert!(matches!(err, IpexError::WrongRoute { .. })); + } + + #[test] + fn admit_parse_rejects_missing_prior() { + let admit = IpexAdmit::new( + Prefix::new(RECP.to_string()).unwrap(), + Said::new(GRANT_SAID.to_string()).unwrap(), + "", + DT, + ) + .unwrap(); + let mut value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&admit).unwrap()).unwrap(); + value["p"] = serde_json::Value::String(String::new()); + let stripped = serde_json::to_string(&value).unwrap(); + let err = IpexAdmit::parse(&stripped).unwrap_err(); + assert!(matches!(err, IpexError::MissingPrior)); + } +} diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index 92ff31a8..163bfed9 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -44,6 +44,9 @@ mod crypto; pub mod did_webs; mod error; mod events; +/// IPEX — the Issuance & Presentation EXchange grant/admit `exn` handshake for +/// handing over an ACDC credential between KERI controllers. +pub mod ipex; pub mod kel_io; mod keys; /// Key-State Notice (KSN) — signed snapshot of current key-state for thin clients. @@ -94,6 +97,7 @@ pub use events::{ pair_kel_attachments, parse_attachment, parse_delegated_attachment, parse_source_seal_couples, serialize_attachment, serialize_source_seal_couples, }; +pub use ipex::{IpexAdmit, IpexError, IpexGrant}; pub use keys::{KeriDecodeError, KeriPublicKey}; pub use ksn::{ KERI_KEY_STATE_VERSION, KSN_TYPE, KSN_VERSION, KeyStateNotice, KeyStateRecord, KsnError, From 8cfadc800cb0fd24f1afb458986d41ab1f50a69a Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 10:59:37 +0100 Subject: [PATCH 14/19] feat(pairing): bind session proofs to the TLS channel (RFC 9266 tls-exporter) A pairing session runs inside a TLS connection, but the AEAD-sealed SecureEnvelope proofs it carries were transport-agnostic: a proof captured on one TLS connection could be replayed onto a different one (relay/MITM). Bind every proof to its channel via the RFC 9266 `tls-exporter` keying material (RFC 5705): - ChannelBinding: a parsed 32-byte exporter (label EXPORTER-Channel-Binding, absent context), parse-don't-validate, constant-time eq, redacted Debug. - EnvelopeSession::new now requires a ChannelBinding (no unbound constructor); the exporter is folded into the envelope-key HKDF info AND the AAD. A proof sealed on channel A and opened on a session bound to channel B is rejected with ChannelBindingMismatch; a forged binding label still fails the AEAD. - ChannelBindingProvider port, adapted over rustls in the pairing daemon (rustls_channel_binding / RustlsChannelBinding). A TLS 1.3 loopback test proves the exporter is per-session (client==server, differs across sessions). The SecureEnvelope wire format changed, so the cross-impl KAT vectors are regenerated to v2 with a channel_binding_hex input. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 + crates/auths-mobile-ffi/Cargo.lock | 1 + crates/auths-pairing-daemon/Cargo.toml | 13 +- crates/auths-pairing-daemon/src/tls.rs | 189 ++++++++++++++ crates/auths-pairing-protocol/Cargo.toml | 3 + .../src/channel_binding.rs | 225 +++++++++++++++++ crates/auths-pairing-protocol/src/envelope.rs | 237 ++++++++++++++++-- crates/auths-pairing-protocol/src/lib.rs | 5 + .../tests/cases/secure_envelope_vectors.rs | 54 +++- .../tests/vectors/secure_envelope.json | 47 ++-- packages/auths-python/Cargo.lock | 1 + 11 files changed, 726 insertions(+), 51 deletions(-) create mode 100644 crates/auths-pairing-protocol/src/channel_binding.rs diff --git a/Cargo.lock b/Cargo.lock index a1f53d81..69d9c9cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -777,6 +777,7 @@ dependencies = [ "rand 0.8.6", "rcgen", "ring", + "rustls 0.23.38", "serde", "serde_json", "sha2", @@ -809,6 +810,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "thiserror 2.0.18", "tokio", "zeroize", diff --git a/crates/auths-mobile-ffi/Cargo.lock b/crates/auths-mobile-ffi/Cargo.lock index 8e4b3298..52a4dd51 100644 --- a/crates/auths-mobile-ffi/Cargo.lock +++ b/crates/auths-mobile-ffi/Cargo.lock @@ -200,6 +200,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "thiserror 2.0.18", "zeroize", ] diff --git a/crates/auths-pairing-daemon/Cargo.toml b/crates/auths-pairing-daemon/Cargo.toml index 75a72425..884f299b 100644 --- a/crates/auths-pairing-daemon/Cargo.toml +++ b/crates/auths-pairing-daemon/Cargo.toml @@ -50,12 +50,17 @@ mdns-sd = { version = "0.18.0", optional = true } # `crypto` feature provides KeyPair; `pem` emits PEM-encoded cert + key. rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"], optional = true } zeroize = { workspace = true, optional = true } +# rustls's `ConnectionCommon::export_keying_material` is the RFC 9266 / +# RFC 5705 exporter the channel-binding adapter reads. `std` only — the +# adapter calls the exporter on a connection the caller already established, +# so no crypto provider is forced here. +rustls = { version = "0.23", default-features = false, features = ["std"], optional = true } [features] default = ["server", "mdns", "subkey-chain-v1"] server = ["dep:axum", "dep:tower", "dep:tower-http", "dep:ring", "dep:base64", "dep:subtle", "dep:if-addrs", "dep:serde_json"] mdns = ["dep:mdns-sd"] -tls = ["dep:rcgen", "dep:zeroize"] +tls = ["dep:rcgen", "dep:zeroize", "dep:rustls"] # Pass-through to auths-crypto's FIPS / CNSA builds via auths-core (direct dep). fips = ["auths-core/fips"] cnsa = ["auths-core/cnsa"] @@ -81,6 +86,12 @@ ed25519-dalek = { version = "2", features = ["rand_core"] } # the iOS-shape signing path end-to-end through verify_sig / decode_device_pubkey. p256 = { version = "=0.13.2", features = ["ecdsa"] } rand.workspace = true +# The channel-binding adapter test drives a real TLS 1.3 handshake to confirm +# the RFC 9266 exporter is per-session (client==server, differs across +# sessions) — the same property the interop suite's Go TLS oracle asserts. +# `aws_lc_rs` provider + `tls12` off (TLS 1.3 only) to match the daemon's +# rcgen-backed cert path. +rustls = { version = "0.23", default-features = false, features = ["std", "aws_lc_rs"] } [lints] workspace = true diff --git a/crates/auths-pairing-daemon/src/tls.rs b/crates/auths-pairing-daemon/src/tls.rs index 5c165a8b..4f945fa0 100644 --- a/crates/auths-pairing-daemon/src/tls.rs +++ b/crates/auths-pairing-daemon/src/tls.rs @@ -31,6 +31,11 @@ use sha2::{Digest, Sha256}; +use auths_pairing_protocol::{ + ChannelBinding, ChannelBindingError, ChannelBindingProvider, TLS_EXPORTER_LABEL, + TLS_EXPORTER_LEN, +}; + use crate::error::DaemonError; /// Cert + key material for a TLS-enabled daemon session, plus the @@ -117,6 +122,43 @@ impl DaemonError { } } +/// Adapter: extract the RFC 9266 `tls-exporter` channel binding from a live +/// `rustls` connection. +/// +/// This is the concrete TLS-stack side of the +/// [`ChannelBindingProvider`] port. The pairing protocol stays +/// transport-agnostic; this function is the only place that knows the binding +/// is sourced from `rustls`. It exports keying material under the registered +/// label [`TLS_EXPORTER_LABEL`] with an **absent** context (RFC 5705 +/// distinguishes absent from empty; RFC 9266 specifies no context) and length +/// [`TLS_EXPORTER_LEN`], matching what any stock TLS stack — Go `crypto/tls`, +/// OpenSSL, BoringSSL — produces for the same connection. +/// +/// `conn` is the connection's [`rustls::ConnectionCommon`], reachable from +/// either a server or client connection (and from a `tokio_rustls` stream via +/// its `get_ref().1`). The handshake MUST be complete; before it is, rustls +/// returns an error and this surfaces [`ChannelBindingError::ExporterUnavailable`] +/// so the caller fails closed rather than minting an unbound, relay-able proof. +pub fn rustls_channel_binding( + conn: &rustls::ConnectionCommon, +) -> Result { + let mut material = [0u8; TLS_EXPORTER_LEN]; + conn.export_keying_material(&mut material, TLS_EXPORTER_LABEL, None) + .map_err(|e| ChannelBindingError::ExporterUnavailable(e.to_string()))?; + ChannelBinding::from_exporter(&material) +} + +/// Newtype wrapper so a `rustls` connection can be passed wherever a +/// [`ChannelBindingProvider`] is expected, keeping the protocol core free of +/// any TLS-stack type. +pub struct RustlsChannelBinding<'a, D>(pub &'a rustls::ConnectionCommon); + +impl ChannelBindingProvider for RustlsChannelBinding<'_, D> { + fn channel_binding(&self) -> Result { + rustls_channel_binding(self.0) + } +} + #[cfg(test)] mod tests { use super::*; @@ -140,4 +182,151 @@ mod tests { // Fresh keypair each time → different SPKI. assert_ne!(a.spki_sha256, b.spki_sha256); } + + // ---- channel-binding adapter (RFC 9266 tls-exporter) over real rustls ---- + + use std::sync::Arc; + + use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; + use rustls::{ClientConfig, ClientConnection, ServerConfig, ServerConnection}; + + /// Accept any server cert — the test only exercises the exporter, and the + /// pairing protocol pins the SPKI out-of-band, not via a CA. + #[derive(Debug)] + struct NoVerify; + + impl ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn supported_verify_schemes(&self) -> Vec { + use rustls::SignatureScheme::*; + vec![ECDSA_NISTP256_SHA256, ED25519, RSA_PSS_SHA256] + } + } + + fn provider() -> Arc { + Arc::new(rustls::crypto::aws_lc_rs::default_provider()) + } + + /// Drive a TLS 1.3 handshake between a fresh server and client connection + /// over in-memory buffers, then return both ends' channel bindings. + fn handshake_bindings() -> (ChannelBinding, ChannelBinding) { + // Fresh self-signed cert (rcgen) for the server. + let mat = generate_tls_material(&["localhost".to_string()]).expect("cert"); + let certs = rustls_pemcert(&mat.cert_pem); + let key = rustls_pemkey(&mat.key_pem); + + let server_cfg = ServerConfig::builder_with_provider(provider()) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert(certs, key) + .expect("server cfg"); + let client_cfg = ClientConfig::builder_with_provider(provider()) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth(); + + let mut server = ServerConnection::new(Arc::new(server_cfg)).unwrap(); + let mut client = ClientConnection::new( + Arc::new(client_cfg), + ServerName::try_from("localhost").unwrap(), + ) + .unwrap(); + + // Pump bytes until both handshakes complete. + for _ in 0..16 { + if !client.is_handshaking() && !server.is_handshaking() { + break; + } + transfer(&mut client, &mut server); + let _ = server.process_new_packets().unwrap(); + transfer(&mut server, &mut client); + let _ = client.process_new_packets().unwrap(); + } + assert!(!client.is_handshaking(), "client handshake stalled"); + assert!(!server.is_handshaking(), "server handshake stalled"); + + let cb_client = rustls_channel_binding(&client).expect("client binding"); + let cb_server = rustls_channel_binding(&server).expect("server binding"); + (cb_client, cb_server) + } + + fn transfer( + from: &mut rustls::ConnectionCommon, + to: &mut rustls::ConnectionCommon, + ) { + let mut buf = Vec::new(); + while from.wants_write() { + from.write_tls(&mut buf).unwrap(); + } + let mut cursor = std::io::Cursor::new(buf); + while (cursor.position() as usize) < cursor.get_ref().len() { + to.read_tls(&mut cursor).unwrap(); + } + } + + fn rustls_pemcert(pem: &str) -> Vec> { + use rustls::pki_types::pem::PemObject; + CertificateDer::pem_slice_iter(pem.as_bytes()) + .collect::, _>>() + .expect("cert pem") + } + + fn rustls_pemkey(pem: &str) -> PrivateKeyDer<'static> { + use rustls::pki_types::pem::PemObject; + PrivateKeyDer::from_pem_slice(pem.as_bytes()).expect("key pem") + } + + #[test] + fn exporter_binding_is_equal_across_the_same_session() { + // RFC 9266: both endpoints of one TLS connection derive the SAME + // exporter value. This is what lets the two pairing ends agree on a + // binding without transmitting it. + let (client, server) = handshake_bindings(); + assert_eq!( + client, server, + "client and server of the same TLS session must derive the same binding" + ); + } + + #[test] + fn exporter_binding_differs_across_sessions() { + // RFC 9266: two independent TLS connections derive DIFFERENT exporter + // values — the per-session property the anti-relay check rests on, and + // the behavior any conformant stock TLS stack exhibits. + let (c1, _s1) = handshake_bindings(); + let (c2, _s2) = handshake_bindings(); + assert_ne!( + c1, c2, + "two independent TLS sessions must derive distinct bindings" + ); + } } diff --git a/crates/auths-pairing-protocol/Cargo.toml b/crates/auths-pairing-protocol/Cargo.toml index 2c503cb8..24466dda 100644 --- a/crates/auths-pairing-protocol/Cargo.toml +++ b/crates/auths-pairing-protocol/Cargo.toml @@ -20,6 +20,9 @@ base64.workspace = true serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" zeroize.workspace = true +# Constant-time equality for the per-connection channel binding, so a relay +# attacker learns nothing from comparison timing. +subtle.workspace = true chrono = { version = "0.4", features = ["serde"] } thiserror.workspace = true hex = "0.4" diff --git a/crates/auths-pairing-protocol/src/channel_binding.rs b/crates/auths-pairing-protocol/src/channel_binding.rs new file mode 100644 index 00000000..9acb4c23 --- /dev/null +++ b/crates/auths-pairing-protocol/src/channel_binding.rs @@ -0,0 +1,225 @@ +//! TLS channel binding for session proofs (anti-relay). +//! +//! A pairing session runs *inside* a TLS connection, but the cryptographic +//! proofs it carries (the AEAD-sealed [`crate::envelope::Envelope`]s) are, by +//! themselves, transport-agnostic: a proof captured on one TLS connection and +//! replayed onto a *different* TLS connection would still open, because nothing +//! ties the proof to the channel it was minted on. That is the classic +//! relay / MITM attack — an attacker terminates the victim's TLS session, +//! lifts a valid proof off it, and presents it on its own session to a +//! third party. +//! +//! The fix is **channel binding**: fold a per-connection secret that *only the +//! two TLS endpoints know* into the proof, so a proof minted on channel A +//! cannot be opened on channel B. The secret is the **TLS exporter** — +//! exported keying material per RFC 5705 (TLS ≤1.2) / RFC 8446 §7.5 (TLS 1.3), +//! using the RFC 9266 `tls-exporter` label. Both endpoints of a TLS connection +//! derive the *same* exporter value; two independent connections derive +//! *different* values. Folding it into the envelope key (and AAD) makes the +//! proof open only on the channel that minted it. This is the same shape as +//! token-binding (RFC 8471) and DPoP (RFC 9449): a possession proof scoped to +//! its transport. +//! +//! # Wire / interop parameters (NORMATIVE) +//! +//! - **Label:** [`TLS_EXPORTER_LABEL`] = `EXPORTER-Channel-Binding` (RFC 9266 +//! §3). This is the registered label a stock TLS stack uses for the +//! `tls-exporter` channel binding; matching it byte-for-byte is what lets an +//! auths endpoint interoperate with any RFC 9266 peer. +//! - **Context:** *absent* (not empty). RFC 5705 distinguishes an absent +//! context from a zero-length one; RFC 9266 specifies the exporter with no +//! context value, so the adapter MUST pass "no context", not `b""`. +//! - **Length:** [`TLS_EXPORTER_LEN`] = 32 bytes. +//! +//! # Ports and adapters +//! +//! This crate is transport-agnostic (no TLS stack dependency). The act of +//! *extracting* the exporter from a live connection is therefore a **port**: +//! [`ChannelBindingProvider`]. The TLS-aware crates (the pairing daemon, the +//! CLI LAN server, the mobile client) implement it as a thin **adapter** over +//! their concrete stack's `export_keying_material` — `rustls`'s +//! `ConnectionCommon::export_keying_material`, OpenSSL's +//! `SslRef::export_keying_material`, Go's `ConnectionState.ExportKeyingMaterial`. +//! The core protocol only ever sees a parsed [`ChannelBinding`]. + +use zeroize::{Zeroize, Zeroizing}; + +/// RFC 9266 §3 exporter label for the `tls-exporter` channel binding. +/// +/// A stock TLS stack producing a `tls-exporter` channel binding exports keying +/// material under exactly this label. Byte-identical use here is what makes an +/// auths endpoint's binding match an arbitrary RFC 9266 peer's. +pub const TLS_EXPORTER_LABEL: &[u8] = b"EXPORTER-Channel-Binding"; + +/// Length, in bytes, of the exported keying material used as the binding. +/// +/// 32 bytes = 256 bits, matching the suite's TLS oracle and leaving no +/// shortfall against the AEAD key it is folded into. +pub const TLS_EXPORTER_LEN: usize = 32; + +/// HKDF `info` domain separator for folding a channel binding into a derived +/// key. Distinct from every other label in [`crate::domain_separation`] so a +/// channel-bound key can never collide with an unbound one. +pub const CHANNEL_BINDING_INFO: &[u8] = b"auths-pairing-channel-binding-v1"; + +/// Errors from parsing a channel binding or extracting one from a transport. +#[derive(Debug, thiserror::Error)] +pub enum ChannelBindingError { + /// The exporter material was not exactly [`TLS_EXPORTER_LEN`] bytes. + #[error("channel binding must be {expected} bytes of TLS exporter material, got {got}")] + WrongLength { + /// Required length ([`TLS_EXPORTER_LEN`]). + expected: usize, + /// Length actually supplied. + got: usize, + }, + + /// The underlying TLS stack refused to export keying material (no + /// handshake completed, the connection is not TLS 1.3-capable, or the + /// exporter is otherwise unavailable). A session that cannot produce a + /// binding MUST NOT fall back to an unbound proof — that would silently + /// reopen the relay hole. Surface this and refuse. + #[error("TLS exporter unavailable from transport: {0}")] + ExporterUnavailable(String), +} + +/// A parsed TLS channel binding: the RFC 9266 `tls-exporter` keying material +/// for one connection. +/// +/// Parse, don't validate: the only way to hold a `ChannelBinding` is through +/// [`ChannelBinding::from_exporter`], which enforces the length. Downstream +/// code (the envelope key derivation) can therefore trust the bytes without +/// re-checking. Two `ChannelBinding`s compare equal iff their exporter bytes +/// match — i.e. iff they came from the *same* TLS connection. The comparison +/// is constant-time so a relay attacker learns nothing from timing. +#[derive(Clone)] +pub struct ChannelBinding { + exporter: Zeroizing<[u8; TLS_EXPORTER_LEN]>, +} + +impl ChannelBinding { + /// Parse raw TLS exporter material into a channel binding. + /// + /// `material` MUST be the keying material a TLS stack exported under + /// [`TLS_EXPORTER_LABEL`] with an *absent* context and length + /// [`TLS_EXPORTER_LEN`] — see the module docs for the normative + /// parameters. Any other length is rejected so an invalid binding is + /// unrepresentable past this boundary. + pub fn from_exporter(material: &[u8]) -> Result { + if material.len() != TLS_EXPORTER_LEN { + return Err(ChannelBindingError::WrongLength { + expected: TLS_EXPORTER_LEN, + got: material.len(), + }); + } + let mut buf = [0u8; TLS_EXPORTER_LEN]; + buf.copy_from_slice(material); + let cb = Self { + exporter: Zeroizing::new(buf), + }; + buf.zeroize(); + Ok(cb) + } + + /// The raw exporter bytes, for folding into a key derivation as HKDF + /// `info`-adjacent material. Not part of any serialized wire format — the + /// binding never travels; each endpoint recomputes it from its own TLS + /// connection. + pub fn as_bytes(&self) -> &[u8; TLS_EXPORTER_LEN] { + &self.exporter + } +} + +// Manual `Debug` that redacts the exporter. The value is a per-connection +// secret; logging it would hand a relay attacker the binding it needs to +// recompute. +impl std::fmt::Debug for ChannelBinding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ChannelBinding") + .field( + "exporter", + &format_args!("<{TLS_EXPORTER_LEN} bytes redacted>"), + ) + .finish() + } +} + +/// Constant-time equality: equal iff the two bindings come from the same TLS +/// connection. Constant-time so a relay attacker probing "is my forged binding +/// close to the real one?" learns nothing from response timing. +impl PartialEq for ChannelBinding { + fn eq(&self, other: &Self) -> bool { + use subtle::ConstantTimeEq; + self.exporter.ct_eq(other.exporter.as_ref()).into() + } +} + +impl Eq for ChannelBinding {} + +/// Port: extract the channel binding from a live transport. +/// +/// Implemented by the TLS-aware crates as an adapter over their concrete +/// stack. The core protocol depends only on this trait, never on a TLS +/// library — ports and adapters at the transport edge. +/// +/// The adapter MUST call its stack's keying-material exporter with +/// [`TLS_EXPORTER_LABEL`], an *absent* context, and length +/// [`TLS_EXPORTER_LEN`], then hand the bytes to +/// [`ChannelBinding::from_exporter`]. An adapter that cannot produce a binding +/// (handshake incomplete, not TLS 1.3) MUST return +/// [`ChannelBindingError::ExporterUnavailable`] — never a placeholder — so the +/// caller fails closed instead of minting an unbound, relay-able proof. +pub trait ChannelBindingProvider { + /// The current connection's RFC 9266 channel binding. + fn channel_binding(&self) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_wrong_length() { + let err = ChannelBinding::from_exporter(&[0u8; 16]).unwrap_err(); + assert!(matches!( + err, + ChannelBindingError::WrongLength { + expected: TLS_EXPORTER_LEN, + got: 16 + } + )); + } + + #[test] + fn same_exporter_is_equal() { + let a = ChannelBinding::from_exporter(&[0x11; TLS_EXPORTER_LEN]).unwrap(); + let b = ChannelBinding::from_exporter(&[0x11; TLS_EXPORTER_LEN]).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn different_exporter_is_unequal() { + // Two TLS connections export different keying material → distinct + // bindings. This is the property the anti-relay check rests on. + let a = ChannelBinding::from_exporter(&[0x11; TLS_EXPORTER_LEN]).unwrap(); + let b = ChannelBinding::from_exporter(&[0x22; TLS_EXPORTER_LEN]).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn label_is_the_rfc9266_registered_value() { + // Lock the wire label: a drift here silently breaks interop with every + // stock TLS stack producing a `tls-exporter` binding. + assert_eq!(TLS_EXPORTER_LABEL, b"EXPORTER-Channel-Binding"); + assert_eq!(TLS_EXPORTER_LEN, 32); + } + + #[test] + fn debug_redacts_exporter() { + let cb = ChannelBinding::from_exporter(&[0xAB; TLS_EXPORTER_LEN]).unwrap(); + let s = format!("{cb:?}"); + assert!(s.contains("redacted")); + assert!(!s.contains("ab")); + assert!(!s.contains("AB")); + } +} diff --git a/crates/auths-pairing-protocol/src/envelope.rs b/crates/auths-pairing-protocol/src/envelope.rs index 27fdb9a9..bac342d5 100644 --- a/crates/auths-pairing-protocol/src/envelope.rs +++ b/crates/auths-pairing-protocol/src/envelope.rs @@ -17,8 +17,17 @@ //! `CryptoProvider::aead_{encrypt,decrypt}` (fn-128.T2) so the swap is //! automatic. //! - **AAD layout (length-prefixed):** -//! `u32_be(len(session_id)) || session_id || u32_be(len(path)) || path || u32_be(counter)`. +//! `u32_be(len(session_id)) || session_id || u32_be(len(path)) || path || +//! u32_be(len(channel_binding)) || channel_binding || u32_be(counter)`. //! Naive concatenation is forbidden (USENIX'23 AEAD-confusion). +//! - **Channel binding (anti-relay):** the session is pinned to the TLS +//! connection it runs on via the RFC 9266 `tls-exporter` keying material +//! (see [`crate::channel_binding`]). The exporter is folded into BOTH the +//! envelope key derivation (so two channels derive different keys) AND the +//! AAD (so a cross-channel open is rejected explicitly). An envelope sealed +//! on channel A therefore cannot be opened on channel B — a relayed proof is +//! refused. This is the load-bearing negative property; without it the +//! envelope is transport-agnostic and a captured proof replays. //! - **Max messages per session:** 1024. More than any pairing flow needs; //! abort with [`EnvelopeError::SessionExhausted`] at the cap. //! - **API is async** to match `CryptoProvider`'s async trait surface; @@ -35,6 +44,7 @@ use zeroize::{Zeroize, Zeroizing}; use auths_crypto::default_provider; +use crate::channel_binding::{CHANNEL_BINDING_INFO, ChannelBinding, TLS_EXPORTER_LEN}; use crate::domain_separation::ENVELOPE_INFO; use crate::sas::TransportKey; @@ -65,6 +75,13 @@ pub enum EnvelopeError { #[error("envelope AAD mismatch")] AadMismatch, + /// The envelope was sealed on a different TLS channel than the one it is + /// being opened on — its RFC 9266 channel binding does not match this + /// session's. This is the anti-relay rejection: a proof captured on one + /// TLS connection and replayed onto another is refused here. + #[error("envelope channel-binding mismatch — proof relayed onto a different TLS channel")] + ChannelBindingMismatch, + /// Session has reached the message cap; must renegotiate a fresh /// `TransportKey`. #[error("envelope session exhausted (>{MAX_MESSAGES_PER_SESSION} messages)")] @@ -94,6 +111,12 @@ pub struct Envelope { payload: Vec, aad_session_id: String, aad_path: String, + /// The RFC 9266 channel binding this envelope was sealed under. Carried + /// for the explicit same-process anti-relay check in [`EnvelopeSession::open`]; + /// it is NOT a wire field — across a transport each endpoint recomputes its + /// own binding from its own TLS connection, and the binding is folded into + /// the key + AAD so a cross-channel open fails cryptographically regardless. + aad_channel_binding: [u8; TLS_EXPORTER_LEN], _state: PhantomData, } @@ -158,10 +181,15 @@ pub struct EnvelopeSession { next_counter: u32, last_opened_counter: Option, session_id: String, + /// The TLS channel this session is bound to (RFC 9266 `tls-exporter`). + /// Folded into the key derivation and every AAD; an envelope sealed under a + /// different binding cannot be opened here. + channel_binding: ChannelBinding, } impl EnvelopeSession { - /// Derive a fresh envelope session from a `TransportKey`. + /// Derive a fresh envelope session from a `TransportKey`, bound to a TLS + /// channel. /// /// Args: /// * `transport_key`: the session's transport key from @@ -169,20 +197,40 @@ impl EnvelopeSession { /// * `session_id`: the pairing session id (part of every AAD). /// * `iv`: per-session 96-bit IV. Produce via `OsRng` at session start; /// transmit out-of-band to the peer. + /// * `channel_binding`: the RFC 9266 `tls-exporter` value for the TLS + /// connection this session runs over (from a + /// [`crate::channel_binding::ChannelBindingProvider`] adapter). The + /// binding is folded into the envelope key so two TLS channels derive + /// different keys; a proof minted on one channel cannot open on another. + /// There is no unbound constructor on purpose — an unbound envelope is + /// relay-able, so the channel binding is a required argument, not an + /// option. /// /// Usage: /// ```ignore - /// let session = EnvelopeSession::new(&transport_key, session_id, iv).await?; + /// let cb = tls_conn.channel_binding()?; // ChannelBindingProvider adapter + /// let session = EnvelopeSession::new(&transport_key, session_id, iv, cb).await?; /// let env = session.seal("/v1/pairing/sessions/x/response", pt).await?; /// ``` pub async fn new( transport_key: &TransportKey, session_id: String, iv: [u8; 12], + channel_binding: ChannelBinding, ) -> Result { let provider = default_provider(); + // Fold the channel binding into the key-derivation `info` so the + // envelope key is unique per (transport key, TLS channel) pair. Two + // sessions over different TLS connections derive different keys even + // with an identical transport key — the cryptographic half of the + // anti-relay guarantee. + let mut info = + Vec::with_capacity(ENVELOPE_INFO.len() + CHANNEL_BINDING_INFO.len() + TLS_EXPORTER_LEN); + info.extend_from_slice(ENVELOPE_INFO); + info.extend_from_slice(CHANNEL_BINDING_INFO); + info.extend_from_slice(channel_binding.as_bytes()); let okm = provider - .hkdf_sha256_expand(transport_key.as_bytes(), &[], ENVELOPE_INFO, 32) + .hkdf_sha256_expand(transport_key.as_bytes(), &[], &info, 32) .await .map_err(|e| EnvelopeError::KeyDerivation(e.to_string()))?; let mut key_bytes = [0u8; 32]; @@ -193,6 +241,7 @@ impl EnvelopeSession { next_counter: 1, last_opened_counter: None, session_id, + channel_binding, }) } @@ -209,7 +258,12 @@ impl EnvelopeSession { self.next_counter = self.next_counter.wrapping_add(1); let nonce = nonce_for_counter(&self.iv, counter); - let aad = build_aad(&self.session_id, path, counter); + let aad = build_aad( + &self.session_id, + path, + self.channel_binding.as_bytes(), + counter, + ); let provider = default_provider(); let ct = provider .aead_encrypt(&self.key, &nonce, &aad, plaintext) @@ -222,6 +276,7 @@ impl EnvelopeSession { payload: ct, aad_session_id: self.session_id.clone(), aad_path: path.to_string(), + aad_channel_binding: *self.channel_binding.as_bytes(), _state: PhantomData, }) } @@ -252,7 +307,28 @@ impl EnvelopeSession { if self.session_id != env.aad_session_id { return Err(EnvelopeError::AadMismatch); } - let aad = build_aad(&self.session_id, path, env.counter); + // Anti-relay: refuse a proof minted on a different TLS channel. The + // constant-time compare avoids leaking how close a forged binding is. + // Even if this explicit check were bypassed, the binding is folded into + // the key + AAD, so the AEAD `open` below would still fail — this is + // defense-in-depth with a clear diagnostic. + { + use subtle::ConstantTimeEq; + let matches: bool = self + .channel_binding + .as_bytes() + .ct_eq(&env.aad_channel_binding) + .into(); + if !matches { + return Err(EnvelopeError::ChannelBindingMismatch); + } + } + let aad = build_aad( + &self.session_id, + path, + self.channel_binding.as_bytes(), + env.counter, + ); let provider = default_provider(); let pt = provider @@ -267,6 +343,7 @@ impl EnvelopeSession { payload: pt, aad_session_id: env.aad_session_id, aad_path: env.aad_path, + aad_channel_binding: env.aad_channel_binding, _state: PhantomData, }) } @@ -288,14 +365,17 @@ fn nonce_for_counter(iv: &[u8; 12], counter: u32) -> [u8; 12] { nonce } -fn build_aad(session_id: &str, path: &str, counter: u32) -> Vec { +fn build_aad(session_id: &str, path: &str, channel_binding: &[u8], counter: u32) -> Vec { let sid = session_id.as_bytes(); let p = path.as_bytes(); - let mut aad = Vec::with_capacity(4 + sid.len() + 4 + p.len() + 4); + let cb = channel_binding; + let mut aad = Vec::with_capacity(4 + sid.len() + 4 + p.len() + 4 + cb.len() + 4); aad.extend_from_slice(&(sid.len() as u32).to_be_bytes()); aad.extend_from_slice(sid); aad.extend_from_slice(&(p.len() as u32).to_be_bytes()); aad.extend_from_slice(p); + aad.extend_from_slice(&(cb.len() as u32).to_be_bytes()); + aad.extend_from_slice(cb); aad.extend_from_slice(&counter.to_be_bytes()); aad } @@ -303,8 +383,16 @@ fn build_aad(session_id: &str, path: &str, counter: u32) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::channel_binding::ChannelBinding; use crate::sas::TransportKey; + // A stand-in TLS exporter for one channel. Two distinct values model two + // distinct TLS connections (the per-session property the Go TLS oracle + // confirms for the RFC 9266 `tls-exporter`). + fn channel(byte: u8) -> ChannelBinding { + ChannelBinding::from_exporter(&[byte; TLS_EXPORTER_LEN]).unwrap() + } + fn session_with_transport_key() -> (TransportKey, [u8; 12], String) { let tk = TransportKey::new([0xA5; 32]); let iv = [0x07; 12]; @@ -315,11 +403,18 @@ mod tests { #[tokio::test] async fn seal_open_round_trip() { let (tk, iv, sid) = session_with_transport_key(); - let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap(); - // Fresh receiver derives the same key from the same transport key + iv. - let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv) + // Same TLS channel on both ends → same binding → opens. + let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xC0)) .await .unwrap(); + let mut receiver = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xC0), + ) + .await + .unwrap(); let env = sender .seal("/v1/pairing/sessions/x/response", b"hello world") @@ -332,13 +427,84 @@ mod tests { assert_eq!(opened.plaintext(), b"hello world"); } + /// The load-bearing anti-relay test (RFC 9266 / Track B T0). A proof sealed + /// on TLS channel A and replayed onto a session bound to TLS channel B is + /// REJECTED — exactly the relay/MITM the channel binding exists to stop. + #[tokio::test] + async fn proof_relayed_onto_different_channel_is_rejected() { + let (tk, iv, sid) = session_with_transport_key(); + // Channel A mints the proof. + let mut on_channel_a = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xAA)) + .await + .unwrap(); + // Channel B (a different TLS connection → different exporter) is where + // the attacker replays it. + let mut on_channel_b = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xBB), + ) + .await + .unwrap(); + + let proof = on_channel_a.seal("/p", b"valid-proof").await.unwrap(); + let err = on_channel_b.open("/p", proof).await.unwrap_err(); + assert!( + matches!(err, EnvelopeError::ChannelBindingMismatch), + "a proof relayed onto a foreign TLS channel must be rejected, got {err:?}" + ); + } + + /// Even if the explicit binding check were stripped, the binding is folded + /// into the key derivation, so a forged envelope that *claims* the receiver's + /// binding but was keyed under a different channel still fails the AEAD. + #[tokio::test] + async fn forged_binding_label_still_fails_aead() { + let (tk, iv, sid) = session_with_transport_key(); + let mut on_channel_a = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xAA)) + .await + .unwrap(); + let mut on_channel_b = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xBB), + ) + .await + .unwrap(); + + let proof = on_channel_a.seal("/p", b"valid-proof").await.unwrap(); + // Attacker rewrites the carried binding to match the victim channel B, + // hoping to pass the explicit equality check. The key it was sealed + // under is still channel A's, so the AEAD tag fails. + let forged = Envelope { + nonce: proof.nonce, + counter: proof.counter, + payload: proof.payload, + aad_session_id: proof.aad_session_id, + aad_path: proof.aad_path, + aad_channel_binding: [0xBB; TLS_EXPORTER_LEN], + _state: PhantomData::, + }; + let err = on_channel_b.open("/p", forged).await.unwrap_err(); + assert!(matches!(err, EnvelopeError::TagMismatch)); + } + #[tokio::test] async fn tampered_tag_yields_tag_mismatch() { let (tk, iv, sid) = session_with_transport_key(); - let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap(); - let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv) + let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xC0)) .await .unwrap(); + let mut receiver = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xC0), + ) + .await + .unwrap(); let env = sender.seal("/path", b"payload").await.unwrap(); // Tamper the last byte (part of the Poly1305 tag). @@ -351,6 +517,7 @@ mod tests { payload: ct, aad_session_id: env.aad_session_id, aad_path: env.aad_path, + aad_channel_binding: env.aad_channel_binding, _state: PhantomData::, }; let err = receiver.open("/path", tampered).await.unwrap_err(); @@ -360,10 +527,17 @@ mod tests { #[tokio::test] async fn aad_path_mismatch_yields_aad_mismatch_or_tag_mismatch() { let (tk, iv, sid) = session_with_transport_key(); - let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap(); - let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv) + let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xC0)) .await .unwrap(); + let mut receiver = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xC0), + ) + .await + .unwrap(); let env = sender.seal("/path-a", b"payload").await.unwrap(); let err = receiver.open("/path-b", env).await.unwrap_err(); @@ -374,10 +548,17 @@ mod tests { #[tokio::test] async fn counter_rollback_rejected() { let (tk, iv, sid) = session_with_transport_key(); - let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap(); - let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv) + let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xC0)) .await .unwrap(); + let mut receiver = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xC0), + ) + .await + .unwrap(); // Seal three messages; open them in order. let e1 = sender.seal("/p", b"a").await.unwrap(); @@ -394,13 +575,23 @@ mod tests { async fn cross_session_key_rejected() { let iv = [0x07; 12]; let sid = "sess-cross".to_string(); - let mut sender = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv) - .await - .unwrap(); + let mut sender = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xC0), + ) + .await + .unwrap(); // Different transport key ⇒ different derived envelope key. - let mut receiver = EnvelopeSession::new(&TransportKey::new([0x5A; 32]), sid.clone(), iv) - .await - .unwrap(); + let mut receiver = EnvelopeSession::new( + &TransportKey::new([0x5A; 32]), + sid.clone(), + iv, + channel(0xC0), + ) + .await + .unwrap(); let env = sender.seal("/p", b"payload").await.unwrap(); let err = receiver.open("/p", env).await.unwrap_err(); diff --git a/crates/auths-pairing-protocol/src/lib.rs b/crates/auths-pairing-protocol/src/lib.rs index 2670f2a7..06609a95 100644 --- a/crates/auths-pairing-protocol/src/lib.rs +++ b/crates/auths-pairing-protocol/src/lib.rs @@ -34,6 +34,7 @@ // ECDH secret or the transport key. #![forbid(unsafe_code)] +pub mod channel_binding; pub mod domain_separation; pub mod envelope; mod error; @@ -45,6 +46,10 @@ pub mod sas; mod token; pub mod types; +pub use channel_binding::{ + CHANNEL_BINDING_INFO, ChannelBinding, ChannelBindingError, ChannelBindingProvider, + TLS_EXPORTER_LABEL, TLS_EXPORTER_LEN, +}; pub use envelope::{ Envelope, EnvelopeError, EnvelopeSession, MAX_MESSAGES_PER_SESSION, Open, Sealed, }; diff --git a/crates/auths-pairing-protocol/tests/cases/secure_envelope_vectors.rs b/crates/auths-pairing-protocol/tests/cases/secure_envelope_vectors.rs index 3747c790..bb060b9e 100644 --- a/crates/auths-pairing-protocol/tests/cases/secure_envelope_vectors.rs +++ b/crates/auths-pairing-protocol/tests/cases/secure_envelope_vectors.rs @@ -23,12 +23,24 @@ use std::path::PathBuf; use auths_pairing_protocol::{ - Envelope, EnvelopeSession, MAX_MESSAGES_PER_SESSION, Sealed, TransportKey, + ChannelBinding, Envelope, EnvelopeSession, MAX_MESSAGES_PER_SESSION, Sealed, TLS_EXPORTER_LEN, + TransportKey, }; use serde::{Deserialize, Serialize}; const VECTORS_PATH: &str = "tests/vectors/secure_envelope.json"; +/// Fixed TLS channel-binding (RFC 9266 `tls-exporter`) used for every KAT +/// vector. Models one TLS connection. A real session derives this from its live +/// connection; the KAT pins a representative value so every implementation — +/// Rust here, the Swift mobile mirror — folds an identical binding into the +/// key + AAD and reproduces byte-identical ciphertext. +const KAT_CHANNEL_BINDING: [u8; TLS_EXPORTER_LEN] = [0x9C; TLS_EXPORTER_LEN]; + +fn kat_channel_binding() -> ChannelBinding { + ChannelBinding::from_exporter(&KAT_CHANNEL_BINDING).expect("32-byte binding") +} + /// Top-level KAT document. #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct VectorFile { @@ -53,6 +65,10 @@ struct Vector { iv_hex: String, session_id: String, path: String, + /// 32 bytes (hex) — the RFC 9266 TLS `tls-exporter` channel binding the + /// envelope is sealed under. Folded into the key derivation and the AAD; + /// an implementation must use this exact value to reproduce the ciphertext. + channel_binding_hex: String, counter: u32, /// UTF-8 plaintext. Kept human-readable where possible; binary vectors /// use `plaintext_hex` instead. @@ -63,7 +79,8 @@ struct Vector { plaintext_hex: Option, /// Expected nonce (= iv XOR counter_be on last 4 bytes). nonce_hex: String, - /// Expected AAD (len-prefixed session_id || len-prefixed path || counter_be). + /// Expected AAD (len-prefixed session_id || len-prefixed path || + /// len-prefixed channel_binding || counter_be). aad_hex: String, /// Expected ciphertext || 16-byte Poly1305 tag. ciphertext_hex: String, @@ -250,7 +267,10 @@ async fn regenerate() -> VectorFile { VectorFile { envelope_info: "auths-pairing-envelope-v1".into(), aead: "chacha20poly1305".into(), - version: 1, + // v2: the envelope is now bound to the TLS channel — the RFC 9266 + // `tls-exporter` value is folded into the key derivation and the AAD, + // so both the AAD layout and the ciphertext changed from v1. + version: 2, vectors, } } @@ -275,7 +295,7 @@ async fn seal_vector( assert!((1..MAX_MESSAGES_PER_SESSION).contains(&counter)); let tk = TransportKey::new(transport_key_bytes); - let mut session = EnvelopeSession::new(&tk, session_id.to_string(), iv) + let mut session = EnvelopeSession::new(&tk, session_id.to_string(), iv, kat_channel_binding()) .await .expect("envelope session"); for _ in 1..counter { @@ -298,7 +318,12 @@ async fn seal_vector( let sealed: Envelope = session.seal(path, &pt_bytes).await.expect("seal"); let nonce_hex = hex::encode(sealed.nonce()); - let aad_hex = hex::encode(compute_expected_aad(session_id, path, counter)); + let aad_hex = hex::encode(compute_expected_aad( + session_id, + path, + &KAT_CHANNEL_BINDING, + counter, + )); let ciphertext_hex = hex::encode(sealed.ciphertext()); Vector { @@ -307,6 +332,7 @@ async fn seal_vector( iv_hex: hex::encode(iv), session_id: session_id.to_string(), path: path.to_string(), + channel_binding_hex: hex::encode(KAT_CHANNEL_BINDING), counter, plaintext_utf8: pt_utf8, plaintext_hex: pt_hex, @@ -318,14 +344,22 @@ async fn seal_vector( } } -fn compute_expected_aad(session_id: &str, path: &str, counter: u32) -> Vec { +fn compute_expected_aad( + session_id: &str, + path: &str, + channel_binding: &[u8], + counter: u32, +) -> Vec { let sid = session_id.as_bytes(); let p = path.as_bytes(); - let mut aad = Vec::with_capacity(4 + sid.len() + 4 + p.len() + 4); + let cb = channel_binding; + let mut aad = Vec::with_capacity(4 + sid.len() + 4 + p.len() + 4 + cb.len() + 4); aad.extend_from_slice(&(sid.len() as u32).to_be_bytes()); aad.extend_from_slice(sid); aad.extend_from_slice(&(p.len() as u32).to_be_bytes()); aad.extend_from_slice(p); + aad.extend_from_slice(&(cb.len() as u32).to_be_bytes()); + aad.extend_from_slice(cb); aad.extend_from_slice(&counter.to_be_bytes()); aad } @@ -395,7 +429,11 @@ async fn negative_vectors_reject_as_declared() { for v in file.vectors.iter().filter(|v| v.expected_result != "valid") { let tk = transport_key_from_hex(&v.transport_key_hex); let iv = iv_from_hex(&v.iv_hex); - let mut session = EnvelopeSession::new(&tk, v.session_id.clone(), iv) + let cb = ChannelBinding::from_exporter( + &hex::decode(&v.channel_binding_hex).expect("valid channel-binding hex"), + ) + .expect("32-byte channel binding"); + let mut session = EnvelopeSession::new(&tk, v.session_id.clone(), iv, cb) .await .expect("envelope session"); diff --git a/crates/auths-pairing-protocol/tests/vectors/secure_envelope.json b/crates/auths-pairing-protocol/tests/vectors/secure_envelope.json index 6885c1aa..8a8e7c17 100644 --- a/crates/auths-pairing-protocol/tests/vectors/secure_envelope.json +++ b/crates/auths-pairing-protocol/tests/vectors/secure_envelope.json @@ -1,7 +1,7 @@ { "envelope_info": "auths-pairing-envelope-v1", "aead": "chacha20poly1305", - "version": 1, + "version": 2, "vectors": [ { "name": "basic_round_trip_counter_1", @@ -9,11 +9,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "hello world", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "ed6c2f3e301a623066bad2476a2fde4a72a40615c1f8e67f8bd9af", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "ea3154e512f3e96db98c9e8da0e2b56febabbf14d464b19172d589", "expected_result": "valid" }, { @@ -22,11 +23,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/confirm", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 2, "plaintext_utf8": "second message", "nonce_hex": "070707070707070707070705", - "aad_hex": "00000008736573732d6b61740000001e2f76312f70616972696e672f73657373696f6e732f782f636f6e6669726d00000002", - "ciphertext_hex": "7f9ad6ac7d2dc472452ee981bc5127bdcca9a50ed19a45f0d813599e99b3", + "aad_hex": "00000008736573732d6b61740000001e2f76312f70616972696e672f73657373696f6e732f782f636f6e6669726d000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000002", + "ciphertext_hex": "36f1d7c39f73add4e9747b620c9350dc8756bf777582f8cb2fe197903903", "expected_result": "valid" }, { @@ -35,11 +37,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "1c7d845d1f745c9157defb6a375b7abb", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "6772e9cda362da48c117686e18e48824", "expected_result": "valid" }, { @@ -48,11 +51,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_hex": "42424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "c74b01101d78571d5694f4f0f3f35bc1660d66474f17fe7f9e6d9b439b51313751d3e4bd33a1f0c3b010bff7e3f65f0aefc6cfbf9c5500e9f6105df2ff5ae5c6168008e9c6f4af2aed38eaff735218b1d3b12874b07a0607f128adce1bb4103d2c3eb772709aa36533141b170e28306d436c66d99bdfc24d538bfb3eb8182469a502a5a53cd9d68177003427786c63c8f986127790caa857992dbae8e7c271cb1eea9fd5db6c5734792529f3dfa1a59c850756b36db670a53a71b3436e5d06590b3132e163c378ddcb46874ddef49d7a5ab03246391a50575dc7116b257ff3f2fb433fea79c35e26d77a3f245c5cc40e1e8fe3209c826e571be46218ebd19b8b9c99c3322b12eeeac720d40fa817bc3fb69b03ec75d6b8e8b308739d945cc1d048a2c52733472dd503ccad7bc1f98ffd6342d494c71429a82834dabbdadd0abea2593105ae395594a6e3a745bceb8a227dc32af1ab9cdc2d78a76eb0ed12b94e82ee6df9bdecbd9e9797ffc3141dcb70e8afa9d10f0691eae362c2ecf0b5a6c79747c55bab242076ec9676aa3ab576ee789cd4272718700c4986bf35737eda36a2d626dfebfcdc0d68b627ea7448452060ef52dc747255efc23a3e597275049a794872dbcd23c64a0f2edeb42d090b63e9398e022e7c425e1ff43d103b0cc4b5ef06be416141700343284e690c4ca0d96e55eab6b45c8d2e84d9753aa9403cfda0b6d9480819f6f24cb391bca404fc362882f52a4a091e041d87f18b0bbc9394956eb019777919c6631aa340e0a6f1a468a772631be373237a37c6a3e01892ecbfb1e933c920c3eab2abbea857e1e81c448d4e703f3a8454d2153c587a66a06d726daffca6303506ff0f833d5b2c7fefa68e6d4ccc944582acc113b7405cb2668e81de7a3e79ed1d0977c9ebae4233516ef96f7bc0b792e64b9d5294cf472968a1e97c13f33fbbfa983678a1504ea4295516075dd0ce9beb8ad623198566e4d5eff6c915d2ca00dd2648b703e43610828d75c15a3811b1662009108059ee0027f85537106b55d4dfa2a74add9b935ee4394b27b9f487cf4580acb597eea901c42be573744c05fdaf223a5b0de8bb26b28ba657a83ed270a562ed4b34689d13510997016ab5097e0f4ecf929c51519bf1d713ef17dd1dd9907d9173979f5c3963badc2400b6858a561aa0646a2eb08f09e1d915c69ecb143b909a40ff1bdb667b4795ab728009edc65683859ce1fb542b28c52103b2522fdd65714a49225f1457f436ebe6677884833b3e64c14ecd33d121ec9c79ec538a67422cd0e910af93c9255e419f848266b10a0a1418db04584143a652b9d266d5535cc305c62252bf2b2520cf7002e2c9f503816dcd2e1b5c98f5188cd2645d599e51acfdb035122c7c6cae3112fa3ddf8d8919ca1a7893271dfba912c0a5908bcba0862337cce1421049f484a2d5bdf0ab9e14ac6e53b3e441c3d0ec6df8a51127f28ca1cf17ac63a0e11a346aca8608d7bad86f0f373220cf9bc32554cb9fe232f5ff21b8228db4c4ea76d08e43ccc78737bc5caba99c0474fad93a0eb9115ad0c61f28a59220d2c4edc3431055708ff37e02ab6652529c9870ad549efd9faef50426efc552ac1a9308b36a14327a67b22ee68630a64895ad6f300aa3dac484836504c810367930807c5c2a461bb78eae3d7d555951a06c79b04d02cd93222a3cff6a8f00df8add5ee37ad8337c455ceadb6f1c10fce99821b7253e11344815b3fb533e4d1fa1f0479145a25f312910b6d3a77c00da55de963d289d4e19d7f024452649151f0d0eed2b82c6c59c67b71a6dcc0499758d0e7f8e085c3218dddfd21b9898fb93b27302b1a13ab8ed983bb3d564469c1413311918217d943cf2ff75bc97aa5cbea34893ca7f26a2aa674fc87aeb373323b55e5d4eabc9b20bb92b5db0261404e5e89631aefef0a7a1511532c8aeb5a6a38de5393297833c2e7f400cf92ac67c11a39b87874966176edd03991d5089434ac75a691a32ba9fffe30ed00a705b9d48dc343c06a5451dc0f5a69d9ff06d1f350e151df112674db7aab21a713593f7da2b6b9976fc652810ce8f57d269187a0339424f90d49a814f7a9724f52946a1797e2dc45580df95fa44edba83dac01abb581e7e9b4615f746ec59460950f0733e20dfc899387bb004a410e2e4df749c8a3c5df4e4d2b59f9c862ce2dbebf30d768980118c5f4270ff2875760c9513c9306accf5f876622fc6d4dea1375615e49397d7b7572046e82c23b3e1f35b7148c73fabda86a3871fa87f033a7e0809851f4c0b0cf43773a2f51a8d58fb212b6275a3f97537323169b9d214bdc3a91a2458fee5f4ed1368a24644eaaf56740716a79e8521a7038fe51c216e631ced6a8d45ef7f7a48026c8d4ac28e0639ee5da427bdce59310d9a3cd39b39c4cd1151d8241ae1771edac3b4439f1ad23d0521a6b2d8a3fe9864b9047c2c8e4d185e4c867cc3280a4a6450535363d364a5e96cd93963a8b135ca0aa5c4dc87e4f2044a8e6913cbc4eb0e5476a27068229097a2cda7e4ab152797f6dc969474dda1b426732ca15345924dd6475f25cb075d403a30a2653b982f7dff55c9b71330844ce5b8e7f59b8a2c569094e7c7e3e394f4e05e9955c49d3ee89b9b53062e210e17ceaa744bb3b82ac5ab696e669d2e8637dc8b59700642b944a68969b0f57c69d4d22f639a551171f56cbedd97737a193381fef90207b29e9ea1ecb2e3d98a61284c135d68841de938c4ce8d0b05dab0dc1383de83316de271b8dc70e50b04ddf1cf81da5a28e4a3d52ba63ff4025e2730c648fafa01fcd447dfc85a7a764afe477b78b55405d4e57501c5b2b89d23b3ff4fc576b776eb18d8f1d8c2890a09bd42d1a7687a22e8e4401f7e3af5d396979bc8d886d817b078e05abd62ab8697e467472b70522079fed21e10bef14cf2b57800f8c941631c914e4a2d62abe79c5af20b74a4f1ce48873678305c2e0717a24cb7b389a53defef9e5c0a66a492c950b411ecfaef1cf8c5009f089c5c7e818165fc6fec8497db7affba48617ed3edd812fa52e42b699d2d79751e0731b37c64516c630c7d23fc683d112e4c188625c69753dbf990b6040afc3323be4d0c5a70ed78b275bd85af6d22289ee106b403ba963a2258960c2dcd77a4db52c2ac62ebfe2fe0bee99a8b195eb6bb05bc90dcd2d783148b4d3f249eeadad5c4cd61ea5446bab5ee54cfff0e5c779082b738e3533c53b9e94256f933f368957aeedf7266c1e0268a48602e1c1cb1fcc7ef8b7dbce6973d224197642dd54d7035b52c93b7d5c370ff4bba567dc6f0cc7dbc17debb1e8ccf527d92c41cd897038d49b5b6d36276921e0da9b89c85bbe37c934e9ff18085145a2d7d5e1de37f38ea597a6422623fb7fe79cd569af018a485caee68e815a0fd449467a9f1474944093cec562b7a711132621b4ab465e8d4611357e04e1490c667fb646d813ac25da85bb50e8c10882d41cc4774713d444f26409e4330f684118f73a27aaff7be4c0e4929f01f9f073f16523b8c4e618b021b173a88219b5db780c643358f6f9ee849785bc2a58f5a3ff00dd028f60c17402ea161f100fc4e6cc009814efc22db65768e77a31925115ee1adce39918e81af5060d77ff91066ad6a87171173864cc27d50174b149f66fd8bc27cc4274c075aa289757f583e1c8208f29f8f199b4fda63828d9e3d3f333adcd784f0885aac605c86eb9b842a53227e9d7143b5c7c6a10383c40480d80b477e1a3b2665a340aec932f8e8fe5748381d04f9bfa1a9e32a618003489b45a40895ed615fb801c679039586d27cf8437f4e3d21c54bf8c531768ab04db2010a621b165407fa34d9f39cb9e0b6c84cf98aef4fb2b19696df316ec40f54241a3929125fab24eda9482f77267fadbb7d30d76e02cea2f2a9e27589547bb0bf6ff5bcd0cfa42c86dbd2ce3048b16bd373b172b283a194b45abbeffaf234c345eb182a12dbd95d1a59409473ca240b535bfccd014811d4f0aaaf1160e13825ba3bb1dcdafb6cb9d70777ee51c23b9c452ec078a9b568eb449340e62aa6652f711a7dfa3772c83231b9ac8696bd2f5f34c5d2196a11533391b45e94f27008aaac4a6d9f66a900a8525e135b74144fefe328831f65c5778a88f97ae3124c3f7d9a5e74c4cab995566f9dd485db77f2ff65e58c5528ca0c882dd6de8f5cabea81f0d79f227d89593d68e8b614db8aecadb3c3a3955afa9b8d6786635c35a814ed3dc065256b3c2129574ab5578db7b9e81d9388c562fe203509ccee7f51ff958161fa17432b9dbe97a48841633f0c3a6a00f26b9d38b5a1c69b6773a92b823b4fa07c84868c0208117c5c5db1962287eff7e01ce09b15394f265d32d17e312dcfce0dd4e089e39c72e5cdbbef5d86246bebf559a1100420be4dda4ef18973d0e89415a424947f9274c4e46e85eeaa73cfdd0242c8d94ca5d166356f36d4bffbe10fa7dfc0c8f29a4e54ac21e94552d6cb07b766e2154089cfb1b95936bb9ff905b144a20114c91b7f16639ce91a5caeb4a4f2fe2d3c5f3461f72cca25cff3fcaed123cd9c4b2df4ed518c7e29b3cc15e9a15570c5d43dbb6127d228f9b7b1e1f1df9f1d2090d3328142e2039ed047a2b821abc728bc81cf46b43a3d78d073426e40384e9d83bd52512c532322ea1f666bcd8a96f387d637bfa5d032cb83995a10b2e856cae828fe1698c6428ffdf2fbf4570498357d96c22f6e81f90f8a3438d114b9c66bb871b8c4fa43586395c7515aee353a4ae42da018a9fd924b159a1ba3e0c7a2c39d793d066aa4d9427d051ee84ab28e7cdd9e3e0594b37ab5e4b67e1be44050fb300193b5aac7a7e415e5e41407c0fe3c83ec1da09b855ec18c8c76308fa4dfaa52e8ae643ebe4ee2a87c786fdc1841f4323fe71873aee84d7101265db5a40e837f0b4e6adddc10a24dbce60555a6bbea3d11d7013cd96c6ef00269ee7007b77fb24ff4b78598835183425222a5fda0a5adb2b6ce3a61504fc90552ef9c0585a1a3b5b26bf630980edb341316c9f1c2b7347cb3b73525488210b8aecbc7d30e19ad930193302e768e595e5041e22d5ae9c263233b83c9e76a98d15a749fe112f39ff4aa8d6f89ec4de02fecbd46352cd88528f611c68f44edd7eb1d0fcd6cf2221fc301aeee9f132ed40fef26160199e6337aae58bac3cc9478d3c992acff314d735ca02e0870c07260454b567122a9f234a454540e147d3d8223a48bcb8b49a791f90ea52bf125b7e685a2c912ae60221e00b35ec1fd7d805305a3e5070d2cf0c62c1953d4a5b67a511afa602b13006a4345b533a41bc68a8ba5f3d7234f8c3790c7733244bcc45314146df44847843b375d41cd8481602929b21fa859ba216591e3e9dda7481099b85e60779996b33d462029b8e6b015e5d0f497d9cde98c66177654a5c38e7c1e418f5087bb51bb1144c9792e246fbb6462be34b95c1fb2853d2c5026da759ec43ba5d7c0dff239f383f779478a777f280ae6c4374a04e105224e595e148bd5facd39950806b2ab70801c6e64b1cfb7f91c7e03bbdda7a6485cfd61819a09d2ac0b2472af9398004fabd654f57c4fb5dea0248976496f1e0a5a3b92952bf861a8c0fb8d8f3f294f00ea3e02edeb059ddd844a1950248b93f146fa09c06771297e52c12dcb5b1cd9b9149ea73f2435190fbdd2ddafb16385f95e852f73d5823522eee575b69363270cf75de918509a51d04a2e997061e99bb81d2245143b8fcc53d97ace313f0d5e9b9acb524491a2d80534cc0a7fd511ac9a269ae888", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "c0167acb3f91dc4089a2b8122aa5381492d6a4e79f0751d1b649a19d71c09a5cc05c3e16af225a3d435b4ba35d87a19547c11985cd8fc0c79472164295215feb6359f9fb769fc33fc21dfa52395e4713a4992100dca695afd16621b1a8bc0c57b12623e940eb45335a612bc95169a8749adbe055df48a9364bd33854a501aa3f82ee5676c0ad47fa53171abf0f7658a761a94b0d95b8a7a80f8608a4c39d9301af91b0f102f332c0b6d122ccbfbf65c539ca9de71a7b0a30db425f4a59ae062a39befb34f177f77dc78d94e714396cc841ef19b813d19a3e7727782a5f8849455387a1da95a837271429dc69f000c1a8e9e82aa2e2d9bc16451ecb3f0419673af12cfaba2efcced8fff3f6b720687eca8fdb41ddd775cc51658407f262c00b39c59b4528ec753561c7d87b3a3863e660ad5aea28a65cd5a019280ccaaa1b62dce451d59859cc8c90a7193968cce2febdd29b8d71f15095594a2640a104d0f39708e8c29ba6c00c6f88acdca692d2dd5b85afe0b4523f7c98bcc9f5e567bd15de38749875b1b6465438ba2cdc936f3818ab09042d9721bd33e1f080c12a3250e761242ed559f5994b17f509fe79bff96f7b5598e56f1a1b5aa784607eeb84a78f14a07e121f9ce4d2c8cf188778d6652b30442b8eb02d4a112369a933e052057bfbabe6c67386d92e103d7f744b3cf26afe44c5081f2e0584d663de2922bcab9948f0fb577d0d86c2e2edaf0be4d88ca60b4d8918d6749ed56ed774813c8aa9ac0ff2469cf23ff56b23db3a576da4e14825e3794c8dfb059bbe3853046917f156be01e4ef7f33cf2afc1975544ed85804a01ae7a8e0458f9644d2baa4adfa2f36720f4b21d6123d0f0520488c2603b795bafa379ab9079edd9be1055f09a062cd8648f957f792a584596e926e9dfbcb3f1f14d1a0cda2437bc938f9a35df130914bd986c949c37db13bb047596f8fc506e963282b498d686d483f2bd509a137d1039ecbbe3185beb40343a775bb0f4b34b14343914d3f3bad2a0e656878cb8f0c21add789683ed647619308aa9f082706079f74cc2ff6d90ade29416afba9a27890b0b32c0ca72309a357532ec9a366703e7ce813b6dbecbb69fa6037d62d23989c87fb5e586ede31e02f5c83a782108f494087133429d5462dea47b6dd168ce998378f1453d3825a06c0dbe4e405c14831c5472fa436b8868f9dab332e31942532909ec3923102f4dda9e1b69ac384421793e43790fd0d0ca3eda6ae508baea9a5f9caaa14ccbc3269352889128f944aa27bffbc8332fb2bfe6f14b735c90c90eb035609459f2386034d64520e8045b4e1e60d9f378fe13493944099f5164a266b52bb06e298dc038b07a9b563bfbe19586e4cabaddbe77fe44abb5553073d9bdaf38c7e7d5220870fd6606f52c10fcc2da78de562acb29cb1c0678f38f327e1cb97072546c5803923ab2e57880f92ec0a60e1dede9f3c3c6cf171bea6a4949e4f93bbcdd494c21dc22e520d1b8f05172e92a24f5943b92a94e624cd259d3dcb4a1f9dd7ba91b09671d5e2be76b53520dffecc3151fc1bbb5fa4334d83571f102b38907525d05f6a15c1c9e31be3e7ff8f464ef89322cc170cfa3c363cfdcb0a0950a1113f6efb76f5ef0d54e751d1f4e72da7ffe5e1d59a118902cd3654fffeaa882fb039ca52fae890200255985de6a545d05c6ab82ca7870853c350881b0244cf2b76f268bf2711de0e6d9dc240b40c32f2dbd85b04868071e684ea7f4ecddea86d83a66e15645dcd9c8d110808e95eacdf872047baf3258f4784181abc5f0b1bb3b14efaa79fd82a597ed212529ea1a02b9f9ee0aefc45cdfdc9fcbe3001dff84069787515fca0a3be614dcbf09c73c96acf16eedd014478eed41378bd0a922f0e1d0d9d76d7e626469dd8f9eb1537c6b9ef93cf5728f7104f506913b5908595566360534bb13bcf9daca47ededf3dcffe04328a82e419ad2489792be070f7d689ed2e7c31e912f3ef3e374fc455dd3734fed345fe97dd3a7f003646baf5f61a38df8279fef6d28033c168168aa8e6b9c1439ed83c4f469e16bf04c1609a8b29db5b673b48acc02e4f22ebdf19e36ca56637972aace90c50999868fb683d31190f711e9d9185eb45eac335d656cdd54f522f47b16ec7f76a67048a4db06b2a63db7df53c5966b701b304a5b61eb66848436f9cb83969bedd97dd3f20fe27f4464952cd39adfded40ade264dcb31b5c4e9ec819c529e1ca23333e0b4a9dacdf0594a374381b1c8dac3e8f4ae038189a7a793c5cdfa031d2be4e5547abea7774c1a830a5acaa9c7eb03b27b44f6b3587fe26ee96188c82f3393d245917e76acf51a00e4a31c305b945f4c15fc2725798f379bad34631c28dab2818942ed0c6cc10694a6805043cdbe969e0a2f588053daf3c0bfb37f646673e738a42b6920dd9e64a84df6985484753a47906dfa3e7fc48ec6b5149589acc7aa49aa118d9a17efffd5455fd12aa68ae523cb5ecc1ca3615cfa139a3ca8198b5966d670b21da662c895647776fd736979484d091b03b0846380d9b1844555a204b9d0177cdd9dce64322956c681624e41c1d235fe166ab3d554fa79dee5ae59540a60b2fa58cb972052eca2bd725c270e309fd984a7682861d751718cc31968e826819fb6baa2da1f911c53d26634de5c055ca271b33f8ace8545116d906113780fbb8abfac0d6ac0a4bdc4d883d549054d29652c921c51dd31f9eb258af0b77eaa2931b4854f008328bfe36b15869fcd82fc6354d0750684f6fc526b5ddf9bd01c04d56d30c630772fa10324d81d74745c7c27150167947da80fcc739db304d34b1c0adf3cc97767fb967c7bb954e03eb10a1f1971273fa62cd0ab22bdb2329f5817213b31f290b63254236e73240ac421e464b995dde64b3f0b7db22cdbc85476036240e881a32e11d3640f626ce2d878b66023a45f0bc0e4e6a35061e11d98ccbcc842671f221dd2d0265d62d50ca22edb1b92f7f796d33fb7d76abff24872d8536522944d7c696d3934a7da3055f56b3e9df995a916013b0ae84f49232af35a52548f94a18be7f5ab897033963983eedec35f04eae431017cdd326cc8f16b02979797cab47e24a48d0a7b6ba88a568b4eec2d1d1f76d83d004e9fa7ff03e109ae17ff92bfa4becb84429a7472966241b67dff15e34f873b89db719a732eb8bf9b5279cc08b07442178ce5aeba9d230f3148dc3d4f4cc5571fdf900602c0e9d83a1922ad91a4d6c3525a3074040413330161775d8012a20dddbd678e9e0dd568a851164a131bfefc18258f5b94004ed5a97a694e62a2c6d1d352a81faf4e9ce404fcb72b07b94be7f76c669054a1c0c84260311e01eb02e385344d0b2ac41506f601f193933ffe28f8f0936214484f09e6a868ff3f79bba02d152c24b673d85f4c366a3c57e4d816e9066074d24f0f07afa73b3d01edbc743ec23cfb34b1128e23898749ed95126054f120e6bd89c4e3414759b330c1732d02b6287b6a6fb29bfc37ec632d2840cc3f63905f356d57ea9f507a0b7d88c5f0e5b61c3ff2e8b40b89c0554dfe292cdb27253b157cc512aa06100f897a571ea537c330e915d4890ea01e50d8cc2df2a0ca0e3fdf0068f5feb167663adc2880333b13049efd69cb0c806a03e91e8dacfc3895ce04cd9ec6ad9a1693b9ea9a365b991d8c22b6f5a93e3f67ce2b9ea1fb1097726565d6891aeac812b766568f652744a1d3314af4656e35092c221f38802b1cc74ec275de3f59a9d8323ac8899f3ed0c2eb2096750d126e370d7dca5fc71b5da894bea74e827fa0ee7d35c5f6c7bc8cd0f151756c954e979c86e93da3079887724142f1418e85848e0927e24d47a6d6fd6706aec0ec50ff6a40017c4d7244ba0fd19bf112a7b50e086a8cdca09ea72ce76578539f268481c6e67653991960b5ddcbe16036b6912fe17d54ea1beddf74b1d3e1a7a7736e165b3ed9abdd69d3f5089a1a5d69183302242f7fdef08b850d5b292093102e7b62835ca8a337c1cc00e90f891aab1b3013d70154abf798f770051aba54357f5971b536269eecc99938e586d26e5a9e01fff5d08044be4a10863d77152dfd43e8f1a9d251a61d522d0fb200da6bc77db1fb09d88135f75af0b94227b4ff548ca21ebbd45c0ecc88f810c874a2564b8345dc5bf1d5308963cb4301a7e05bf695a9d3c03bda52f09fa67232eb0b68ddf2021450b96a98a8b4b6e5a13c2acce6f91595c44d2fb2ac6afac2e7c53e0623f1ddd79dd17ebcdc6a4cc53e9fe2ee33b3f2f75861bb81cb5f913b51e98aec0ac5ae7af43f8f990a029bf3a67859d619f5bed0dfaa205ed7b7534c787a5b22aee8822a2aeeba5bb6073fcf2708ebedd039983ae18a339aaf1724c89a7db1ab808f0f39735e022cc87e3086321ac755078c688c10b8414a18454c852b331a0681ddcc1d2ccd1f95670de74a7453f045752a962a62411581dd97afeb2f9fce48fca393daf7d82bea01efd83633a18799e460ee0c38e210e683d5100f86f07aca0fa15e937a2e27bf88293ce8f67b35c575a85b90709f7a43425708e706bd8017507cb7fce10d290e26cbb9c1c88eed655d47f015070d7a5152fb5f964ec10a475b0e3f70b80cb9cbafabfe46c88603204bc6da59fea32cf4fcccf91817779aad4a8f95522cecfdba6544336a6b0131696b5dee59fa995106890df945be801658cc26a826027c875a3a16f42ed5fab09afb1a054bf5abc4198827698006ce38a8271a7c39001882ad3c4d36da3be063b64d2947ce79ac89b336607caf54f463d25198393d4c3739b474278d84718b4d16959108576e4ab815bc026660ba227ef179d976460b1caccf4ef24cd1d30b3e02e09f2473f12e373f76ef2dae947dc3ce0ea97296b1006e1f01ac607ccd837d3cb8a3b13568d9ef2ea1b37eea88ccd6fa9a1adf26d1d5ba2120f566652d8ff8722a966c2e621fdc14699faa2f2c0f80e7d2b62a6398cee2b2901d082947edca126a49bcef8e1623695c689a439e8390cc54419104d1437bdcf9be512d551aee202810af12bf7c3e0b4b906b96afbbd3629d5cc61d26798c1bf86c26aca9440abfec6fa056aeab1cacbd8ba0e748f95a20173ba01b9797790ac1af9ddd5a3bb74cf6a0c7d269e000ab0888bfe8574e914a3dda79c3c4ccec618fc419a657d9c89fb60143e563f79ddba88288a94bdfffc95cda332094384dff425b448f39ccf52e718de7ddb7b35177ecdfe113172cc27d61284eb1fa983a974fa0b57ab76da44021bfa61ca59b31825d691e9bccc4de7781a22ab0f30f3f12136562d1d4dd827b1f831efd93fb1c77b947694d6e19c02182819fb3b82bc4b48355ffe929de0567c75182d000aaa0666fac072491fbabddf381f182b0bbabcc0e23dd4202b49d12ca81a149d6e84e094f616b981cd132fe2ae3355403a98b97982cba18a2ea34f131523b3b8958647a2f70db7aed0f86e69ddb62921e601bcba56c41182cec519f83d46ffb6cff271750ca14f8ee7730ff9bca87c81775627ee8eaa19df88fba051c2959bb8d83f7138663a1dd3227333e670e1cd72b03c30113283d35921604c71da01bfdd736bed451ecba52e71061ada9ae65a52d6d9aec97a0b2d0c86cb2ae6d9b16633c88c129dcd6ef0c12cfd6db51e330e5e9640f180ab24f67ecb71001c414e1a7b0b6a23f3defdf1214026a184f8e0cb4972263e40918102620639565a3176c0158982ea061863ea29b8d43990cad20a262680fc060f61167db5c6d25d697fb8bab32df6782aea2fe0f80680886aec8d4e8c398d", "expected_result": "valid" }, { @@ -61,11 +65,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/échelle/🌀/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "utf-8 path binding", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001a2f76312fc3a96368656c6c652ff09f8c802f726573706f6e736500000001", - "ciphertext_hex": "f07d257f671a653e60be96d0d8df7dea4a2824d33899225044928c045cfc2ccd7ba7", + "aad_hex": "00000008736573732d6b61740000001a2f76312fc3a96368656c6c652ff09f8c802f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "f7205ea445f3ee63bf88da3201891e3fbef3b807f92b52cf8402a6b1f209bf5aae9a", "expected_result": "valid" }, { @@ -74,11 +79,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "hello world", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "ed6c2f3e301a623066bad2476a2fde4a72a40615c1f8e67f8bd9ae", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "ea3154e512f3e96db98c9e8da0e2b56febabbf14d464b19172d588", "expected_result": "tag-mismatch", "mutation": "flipped last byte of ciphertext||tag" }, @@ -88,11 +94,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "hello world", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "ec6c2f3e301a623066bad2476a2fde4a72a40615c1f8e67f8bd9af", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "eb3154e512f3e96db98c9e8da0e2b56febabbf14d464b19172d589", "expected_result": "tag-mismatch", "mutation": "flipped first byte of ciphertext" }, @@ -102,11 +109,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/WRONG", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "hello world", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "ed6c2f3e301a623066bad2476a2fde4a72a40615c1f8e67f8bd9af", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "ea3154e512f3e96db98c9e8da0e2b56febabbf14d464b19172d589", "expected_result": "aad-mismatch", "mutation": "path mutated after sealing" }, @@ -116,11 +124,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "hello world", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "ed6c2f3e301a623066bad2476a2fde4a72a40615c1f8e67f8bd9af", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "ea3154e512f3e96db98c9e8da0e2b56febabbf14d464b19172d589", "expected_result": "counter-not-monotonic", "mutation": "replay counter=1 after opening counter>=1" } diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index 4f2a1b75..cbccb78a 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -355,6 +355,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "thiserror 2.0.18", "zeroize", ] From d9e40afa5c7cb7b31adc5133c2a0262ec4193a49 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 11:53:48 +0100 Subject: [PATCH 15/19] feat(keri): KEL-rooted X.509 leaf certs for TLS composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `auths tls-cert` (issue/verify) and the `auths-keri::tls_cert` module (feature `tls-cert`): an X.509 leaf whose trust roots in a KERI key event log rather than a CA. The leaf carries a `did:keri:` URI SAN (the SPIFFE X.509-SVID identity-in-SAN pattern) plus a non-critical AuthsKeriBinding extension (OID 1.3.6.1.4.1.59999.1.1) holding the AID's replayed key-state. A stock TLS stack (rustls/OpenSSL/Go crypto/tls) completes a handshake with the leaf; an AID-aware verifier re-derives the trust by replaying the KEL, so an auths identity deploys through every load balancer, mesh, and client that already speaks TLS — augment, not replace. - `AuthsKeriBinding` is parse-don't-validate: built only from a resolved KeyState (undecodable key → error at the boundary), total over its JSON. - The leaf uses a fresh ephemeral TLS keypair, so the AID's long-term signing key never goes on the wire. - `verify_binds_to_key_state` rejects a leaf whose embedded AID / current keys / KEL tip / SAN diverge from a held KEL's replay; a plain cert with no extension is MissingBinding and cannot masquerade as KEL-rooted. - CLI is a thin adapter over the crate (the did-webs/oobi shape); the KEL replay and CESR key-decode remain the single sources of truth. rcgen / x509-parser / yasna are gated behind `tls-cert`, off by default. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 4 + crates/auths-cli/Cargo.toml | 4 +- crates/auths-cli/src/cli.rs | 3 + crates/auths-cli/src/commands/mod.rs | 1 + crates/auths-cli/src/commands/tls_cert.rs | 204 ++++++++ crates/auths-cli/src/main.rs | 1 + crates/auths-keri/Cargo.toml | 12 + crates/auths-keri/src/lib.rs | 9 + crates/auths-keri/src/tls_cert.rs | 584 ++++++++++++++++++++++ 9 files changed, 821 insertions(+), 1 deletion(-) create mode 100644 crates/auths-cli/src/commands/tls_cert.rs create mode 100644 crates/auths-keri/src/tls_cert.rs diff --git a/Cargo.lock b/Cargo.lock index 69d9c9cf..b5f30e3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -687,6 +687,7 @@ dependencies = [ "hex", "p256 0.13.2", "proptest", + "rcgen", "ring", "schemars", "serde", @@ -694,6 +695,9 @@ dependencies = [ "subtle", "thiserror 2.0.18", "tokio", + "x509-parser", + "yasna", + "zeroize", ] [[package]] diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index 2c048918..16649833 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -24,7 +24,9 @@ name = "auths-verify" path = "src/bin/verify.rs" [dependencies] -auths-keri.workspace = true +# `tls-cert` powers `auths tls-cert` (KEL-rooted X.509 leaf certs for TLS +# composition); the wire/crypto definition lives in auths-keri. +auths-keri = { workspace = true, features = ["tls-cert"] } clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" colored = "3.1.1" diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index dbf46252..994933cb 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -42,6 +42,7 @@ use crate::commands::scim::ScimCommand; use crate::commands::sign::SignCommand; use crate::commands::sign_commit::SignCommitCommand; use crate::commands::status::StatusCommand; +use crate::commands::tls_cert::TlsCertCommand; use crate::commands::trust::TrustCommand; use crate::commands::unified_verify::UnifiedVerifyCommand; use crate::commands::whoami::WhoamiCommand; @@ -130,6 +131,8 @@ pub enum RootCommand { KeyState(KeyStateCommand), #[command(hide = true, name = "did-webs")] DidWebs(DidWebsCommand), + #[command(hide = true, name = "tls-cert")] + TlsCert(TlsCertCommand), #[command(hide = true)] Oobi(OobiCommand), #[command(hide = true)] diff --git a/crates/auths-cli/src/commands/mod.rs b/crates/auths-cli/src/commands/mod.rs index 1b833c31..31570724 100644 --- a/crates/auths-cli/src/commands/mod.rs +++ b/crates/auths-cli/src/commands/mod.rs @@ -44,6 +44,7 @@ pub mod scim; pub mod sign; pub mod sign_commit; pub mod status; +pub mod tls_cert; pub mod trust; pub mod unified_verify; pub mod utils; diff --git a/crates/auths-cli/src/commands/tls_cert.rs b/crates/auths-cli/src/commands/tls_cert.rs new file mode 100644 index 00000000..2a74ee41 --- /dev/null +++ b/crates/auths-cli/src/commands/tls_cert.rs @@ -0,0 +1,204 @@ +//! `auths tls-cert` — KEL-rooted X.509 leaf certificates for TLS composition. +//! +//! TLS already authenticates endpoints through the WebPKI/CA system. This command +//! lets a KERI AID compose *with* that pipe rather than replace it: it issues an +//! X.509 leaf whose trust roots in the AID's key event log (a `did:keri:` +//! SAN plus a binding extension carrying the replayed key-state), so a stock TLS +//! stack — rustls, OpenSSL, BoringSSL, Go `crypto/tls` — completes a handshake +//! with it, while an AID-aware peer re-derives the trust by replaying the KEL. +//! That is how an auths identity deploys through every load balancer, mesh, and +//! client that already speaks TLS. +//! +//! Two directions, both offline/hermetic (the KEL handed in is a local artifact): +//! +//! * `auths tls-cert issue --from-kel kel.json` — us → peer: replay the KEL, +//! project its current key-state into a KEL-rooted leaf, and emit the cert PEM +//! plus the ephemeral TLS private key PEM the acceptor serves. +//! * `auths tls-cert verify --cert cert.pem --from-kel kel.json` — peer → us: +//! parse a peer's leaf, replay the KEL we hold for its AID, and confirm the +//! cert binds to that exact replayed key-state (AID, current keys, KEL tip, and +//! the `did:keri` SAN). Trust is rooted in the log, never in a CA. +//! +//! The wire/crypto definition lives in `auths-keri::tls_cert`; this is a thin CLI +//! adapter over it. The cert's subject key is a fresh ephemeral TLS keypair, so +//! the AID's long-term signing key never goes on the wire. + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, anyhow}; +use auths_keri::{ + TrustedKel, issue_kel_rooted_cert, issue_kel_rooted_cert_with_key, parse_kel_json, + verify_binds_to_key_state, +}; +use auths_utils::path::expand_tilde; +use clap::{Parser, Subcommand}; + +use crate::config::CliConfig; + +/// Issue or verify a KEL-rooted X.509 certificate (TLS composition). +#[derive(Parser, Debug, Clone)] +#[command( + about = "Issue/verify a KEL-rooted X.509 cert — an auths identity that stock TLS stacks (rustls/openssl/go) handshake with", + after_help = "Examples: + auths tls-cert issue --from-kel kel.json --san localhost --san 127.0.0.1 + auths tls-cert issue --from-kel kel.json --out leaf # writes leaf.cert.pem + leaf.key.pem + auths tls-cert verify --cert leaf.cert.pem --from-kel kel.json" +)] +pub struct TlsCertCommand { + /// The direction to run: issue our leaf, or verify a peer's. + #[command(subcommand)] + pub action: TlsCertAction, +} + +/// The two directions of KEL-rooted mTLS composition. +#[derive(Subcommand, Debug, Clone)] +pub enum TlsCertAction { + /// Issue a KEL-rooted leaf certificate for an AID (us → peer). + Issue(IssueArgs), + /// Verify a peer's leaf binds to the KEL we hold (peer → us). + Verify(VerifyArgs), +} + +/// `auths tls-cert issue` — mint a KEL-rooted leaf for one of our AIDs. +#[derive(Parser, Debug, Clone)] +pub struct IssueArgs { + /// Replay this KEL file and project its current key-state into the leaf's + /// `did:keri` SAN + binding extension. The KEL is the root of trust. + #[clap(long, value_name = "KEL.json")] + pub from_kel: PathBuf, + + /// Extra Subject-Alternative-Name host the leaf must serve (DNS name or IP + /// literal). Repeatable. Typically `localhost`, `127.0.0.1`, the LAN host. + #[clap(long = "san", value_name = "HOST")] + pub sans: Vec, + + /// Use this PKCS#8-PEM TLS keypair as the leaf's subject key instead of a + /// fresh ephemeral one (e.g. to reuse a key the acceptor already holds). + #[clap(long, value_name = "KEY.pem")] + pub tls_key: Option, + + /// Write `.cert.pem` + `.key.pem` instead of printing to + /// stdout. Without it, the cert PEM is printed (the key never is, to avoid + /// leaking it into logs). + #[clap(long, value_name = "PREFIX")] + pub out: Option, +} + +/// `auths tls-cert verify` — confirm a peer's leaf is rooted in a KEL we hold. +#[derive(Parser, Debug, Clone)] +pub struct VerifyArgs { + /// The peer's leaf certificate, PEM-encoded. + #[clap(long, value_name = "CERT.pem")] + pub cert: PathBuf, + + /// Replay this KEL (the one we hold for the cert's AID) and require the cert + /// to bind to its replayed key-state. + #[clap(long, value_name = "KEL.json")] + pub from_kel: PathBuf, +} + +impl TlsCertCommand { + /// Run the requested direction. + pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { + match &self.action { + TlsCertAction::Issue(args) => args.run(), + TlsCertAction::Verify(args) => args.run(), + } + } +} + +/// Replay a KEL file into its resolved current key-state. A KEL file the operator +/// hands us is a local, self-owned artifact — the reviewable trust assertion that +/// structural replay requires (the same boundary `did-webs`/`oobi` use). +fn replay_kel(kel_path: &Path) -> Result { + let path = expand_tilde(kel_path)?; + let json = + std::fs::read_to_string(&path).map_err(|e| anyhow!("read KEL {}: {e}", path.display()))?; + let events = parse_kel_json(&json).map_err(|e| anyhow!("parse KEL: {e}"))?; + TrustedKel::from_trusted_source(&events) + .replay() + .map_err(|e| anyhow!("replay KEL: {e}")) +} + +impl IssueArgs { + fn run(&self) -> Result<()> { + let state = replay_kel(&self.from_kel)?; + + let issued = match &self.tls_key { + Some(key_path) => { + let path = expand_tilde(key_path)?; + let key_pem = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read TLS key {}: {e}", path.display()))?; + issue_kel_rooted_cert_with_key(&state, &key_pem, &self.sans) + .map_err(|e| anyhow!("issue KEL-rooted cert: {e}"))? + } + None => issue_kel_rooted_cert(&state, &self.sans) + .map_err(|e| anyhow!("issue KEL-rooted cert: {e}"))?, + }; + + match &self.out { + Some(prefix) => { + let cert_path = with_suffix(prefix, "cert.pem"); + let key_path = with_suffix(prefix, "key.pem"); + std::fs::write(&cert_path, issued.cert_pem.as_bytes()) + .map_err(|e| anyhow!("write {}: {e}", cert_path.display()))?; + write_private_key(&key_path, &issued.key_pem)?; + println!( + "issued KEL-rooted leaf for {}\n cert: {}\n key: {}", + issued.binding.did_keri(), + cert_path.display(), + key_path.display() + ); + } + None => { + // Cert only on stdout; the private key is never printed, so a + // captured transcript can't leak it. + print!("{}", issued.cert_pem); + } + } + Ok(()) + } +} + +impl VerifyArgs { + fn run(&self) -> Result<()> { + let state = replay_kel(&self.from_kel)?; + let cert_path = expand_tilde(&self.cert)?; + let cert_pem = std::fs::read_to_string(&cert_path) + .map_err(|e| anyhow!("read cert {}: {e}", cert_path.display()))?; + + let binding = verify_binds_to_key_state(&cert_pem, &state) + .map_err(|e| anyhow!("certificate does not chain to the KEL: {e}"))?; + + println!( + "verified: certificate is rooted in the KEL\n did:keri: {}\n current keys: {}\n KEL tip: {}", + binding.did_keri(), + binding.current_keys.join(", "), + binding.kel_tip + ); + Ok(()) + } +} + +/// `prefix` + `.suffix`, preserving any directory in `prefix`. +fn with_suffix(prefix: &Path, suffix: &str) -> PathBuf { + let name = prefix + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + prefix.with_file_name(format!("{name}.{suffix}")) +} + +/// Write the private key with owner-only permissions where the OS supports it, +/// so an issued key isn't left world-readable. +fn write_private_key(path: &Path, pem: &str) -> Result<()> { + std::fs::write(path, pem.as_bytes()).map_err(|e| anyhow!("write {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(path, perms) + .map_err(|e| anyhow!("chmod {}: {e}", path.display()))?; + } + Ok(()) +} diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index 44114a6f..166fa1ab 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -108,6 +108,7 @@ fn run() -> Result<()> { RootCommand::Key(cmd) => cmd.execute(&ctx), RootCommand::KeyState(cmd) => cmd.execute(&ctx), RootCommand::DidWebs(cmd) => cmd.execute(&ctx), + RootCommand::TlsCert(cmd) => cmd.execute(&ctx), RootCommand::Oobi(cmd) => cmd.execute(&ctx), RootCommand::Ipex(cmd) => cmd.execute(&ctx), RootCommand::Approval(cmd) => cmd.execute(&ctx), diff --git a/crates/auths-keri/Cargo.toml b/crates/auths-keri/Cargo.toml index c93032e9..9f2eb292 100644 --- a/crates/auths-keri/Cargo.toml +++ b/crates/auths-keri/Cargo.toml @@ -26,6 +26,14 @@ serde_json = { version = "1", features = ["preserve_order"] } schemars = { workspace = true, optional = true } subtle.workspace = true thiserror.workspace = true +# KEL-rooted X.509 leaf certificates (the `tls-cert` feature). rcgen mints the +# leaf, x509-parser reads it back, yasna wraps the binding extension value, and +# zeroize protects the ephemeral TLS key. All off by default — the core KERI +# types carry no TLS-stack dependency. +rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs", "x509-parser"], optional = true } +x509-parser = { version = "0.18", optional = true } +yasna = { version = "0.5", optional = true } +zeroize = { workspace = true, optional = true } [features] default = [] @@ -37,6 +45,10 @@ cnsa = ["auths-crypto/cnsa"] cesr = [] schema = ["dep:schemars"] seal-extensions = [] +# KEL-rooted X.509 leaf certificates: an X.509 leaf whose trust roots in a KEL +# (did:keri SAN + binding extension), issued/verified against a replayed +# key-state. Pulls in rcgen/x509-parser; the core crate stays TLS-free without it. +tls-cert = ["dep:rcgen", "dep:x509-parser", "dep:yasna", "dep:zeroize"] [dev-dependencies] proptest = "1.4" diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index 163bfed9..a112861f 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -59,6 +59,9 @@ mod said; mod state; /// Backerless TEL (Transaction Event Log) credential-status events: `vcp`/`iss`/`rev`. pub mod tel; +/// KEL-rooted X.509 leaf certificates — composing a KERI identity with TLS +/// (did:keri SAN + KEL key-state binding extension, verified by replay). +pub mod tls_cert; mod types; mod validate; /// Witness protocol types: receipts, providers, and error reporting for split-view defense. @@ -116,6 +119,12 @@ pub use tel::{ Iss, Rev, TEL_KERIPY_REVISION, TRAIT_NO_BACKERS, TelAnchorSeal, TelEvent, TelState, Vcp, encode_nonce as encode_tel_nonce, to_wire_bytes as tel_to_wire_bytes, validate_tel, }; +pub use tls_cert::{AUTHS_KERI_BINDING_OID, AuthsKeriBinding, DID_KERI_SCHEME, TlsCertError}; +#[cfg(feature = "tls-cert")] +pub use tls_cert::{ + IssuedCert, extract_binding, extract_did_keri_san, issue_kel_rooted_cert, + issue_kel_rooted_cert_with_key, verify_binds_to_key_state, +}; pub use types::{ CesrKey, ConfigTrait, Fraction, FractionError, KeriTypeError, Prefix, Said, Threshold, VersionString, diff --git a/crates/auths-keri/src/tls_cert.rs b/crates/auths-keri/src/tls_cert.rs new file mode 100644 index 00000000..f2be6a46 --- /dev/null +++ b/crates/auths-keri/src/tls_cert.rs @@ -0,0 +1,584 @@ +//! KEL-rooted X.509 leaf certificates — composing a KERI identity with TLS. +//! +//! TLS already authenticates endpoints through the WebPKI/CA system. This module +//! lets a KERI AID compose *with* that pipe instead of replacing it: an X.509 +//! leaf certificate whose **trust roots in the AID's key event log**, not in a +//! certificate authority. A stock TLS stack (rustls, OpenSSL, BoringSSL, Go +//! `crypto/tls`) completes a handshake with the cert exactly as it would with any +//! self-signed leaf; an *AID-aware* verifier additionally re-derives the trust by +//! replaying the KEL — so deployment rides every load balancer, mesh, and client +//! that already speaks TLS, while the identity stays self-certifying. +//! +//! ## How the cert chains to the KEL +//! +//! The leaf is *bound* to the AID, not signed by a CA. Two things tie them: +//! +//! 1. **A `did:keri:` URI in `subjectAltName`** — the SPIFFE X.509-SVID +//! pattern (identity-in-SAN). It parses cleanly in any stock X.509 verifier +//! (graceful degradation: a legacy verifier sees an ordinary URI SAN), and an +//! AID-aware verifier reads the AID out of it. +//! 2. **An `AuthsKeriBinding` certificate extension** carrying the AID's resolved +//! key-state — the AID prefix, every current signing key (CESR), and the KEL +//! tip SAID. This is the projection of a KEL replay into the cert, so the +//! verifier checks the cert against the *log*, never against a CA. +//! +//! The verifier (`verify_binds_to_key_state`, available with the `tls-cert` +//! feature) replays the supplied KEL into a [`KeyState`] and asserts the cert's +//! embedded binding equals that state. Trust is rooted in the log: change a +//! current key in the cert and replay no longer agrees, so the cert is rejected. +//! +//! ## What this module is *not* +//! +//! The leaf carries its own ephemeral TLS keypair (so the long-term AID signing +//! key never goes on the wire). This module establishes the *binding to the KEL +//! key-state* and the *handshake interop*; it does **not** by itself make the +//! binding unforgeable against an attacker who fabricates a key-state — proving +//! the AID *authorized* this TLS key (a KERI signature over the leaf SPKI) and +//! rejecting revoked / KEL-invalid certs is the adversarial verifier's job and +//! lives elsewhere. Here the contract is: produce a KEL-rooted leaf stock stacks +//! accept, and verify a leaf binds to a given KEL's replayed state — both +//! directions, deterministically. +//! +//! ## Parse, don't validate +//! +//! [`AuthsKeriBinding`] is a parsed type: [`AuthsKeriBinding::from_key_state`] +//! builds it only from a resolved key-state, and +//! [`AuthsKeriBinding::from_canonical_json`] is total over its serialized form — +//! an ill-formed extension cannot be represented as a binding, it is an error at +//! the boundary. + +use serde::{Deserialize, Serialize}; + +use crate::keys::{KeriDecodeError, KeriPublicKey}; +use crate::state::KeyState; + +/// OID of the `AuthsKeriBinding` certificate extension, under the +/// Private Enterprise arc `1.3.6.1.4.1.59999` (`auths`), extension `.1.1`. +/// +/// The content is the DER encoding of an OCTET STRING wrapping the canonical +/// JSON of an [`AuthsKeriBinding`]. The extension is **non-critical**: a legacy +/// X.509 verifier that does not understand it ignores it (graceful degradation), +/// while an AID-aware verifier reads it to re-root trust in the KEL. +pub const AUTHS_KERI_BINDING_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 59999, 1, 1]; + +/// The `did:keri` DID method scheme prefix used in the certificate SAN URI. +pub const DID_KERI_SCHEME: &str = "did:keri:"; + +/// Errors building or verifying a KEL-rooted certificate. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum TlsCertError { + /// A current signing key in the key-state could not be decoded. + #[error("decode AID key-state key: {0}")] + Key(#[from] KeriDecodeError), + + /// X.509 certificate generation failed in the backend. + #[error("generate certificate: {0}")] + Generate(String), + + /// The supplied TLS key material could not be loaded as a keypair. + #[error("load TLS keypair: {0}")] + KeyPair(String), + + /// The certificate PEM/DER could not be parsed. + #[error("parse certificate: {0}")] + ParseCert(String), + + /// The certificate carries no `AuthsKeriBinding` extension — it is not a + /// KEL-rooted auths certificate. + #[error("certificate carries no auths KEL binding extension")] + MissingBinding, + + /// The `AuthsKeriBinding` extension content was not well-formed. + #[error("malformed auths KEL binding extension: {0}")] + MalformedBinding(String), + + /// The certificate's binding does not match the replayed KEL key-state. + #[error("certificate binding does not match the replayed KEL: {0}")] + BindingMismatch(String), + + /// The certificate's `did:keri` SAN is absent or does not match the binding. + #[error("certificate did:keri SAN mismatch: {0}")] + SanMismatch(String), +} + +/// The AID key-state a KEL-rooted certificate embeds — the projection of a KEL +/// replay into the cert, so a verifier checks the leaf against the *log*. +/// +/// Field order and labels are stable (`serde_json` with `preserve_order`), so the +/// JSON inside the extension is canonical across producers and the bytes a +/// verifier re-derives from a fresh replay equal the bytes in the cert. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthsKeriBinding { + /// The AID this certificate is bound to (the KEL prefix). Also the subject + /// of the `did:keri:` SAN. + pub aid: String, + /// Every current signing key of the AID, CESR-qualified, in KEL order. + pub current_keys: Vec, + /// The SAID of the KEL tip the binding was projected from — the exact log + /// position whose replay must reproduce `current_keys`. + pub kel_tip: String, +} + +impl AuthsKeriBinding { + /// Project a resolved [`KeyState`] into a certificate binding. + /// + /// Every current key is decoded first (parse, don't validate), so a binding + /// is only ever built from keys that are valid for their curve — an + /// undecodable key is [`TlsCertError::Key`] at the boundary, never serialized + /// into a cert. + pub fn from_key_state(state: &KeyState) -> Result { + let mut current_keys = Vec::with_capacity(state.current_keys.len()); + for key in &state.current_keys { + // Decode to reject a malformed key before it reaches the cert. + KeriPublicKey::parse(key.as_str())?; + current_keys.push(key.as_str().to_string()); + } + Ok(Self { + aid: state.prefix.as_str().to_string(), + current_keys, + kel_tip: state.last_event_said.as_str().to_string(), + }) + } + + /// The `did:keri:` URI this binding's AID resolves to. + pub fn did_keri(&self) -> String { + format!("{DID_KERI_SCHEME}{}", self.aid) + } + + /// Serialize to the canonical JSON bytes carried (DER-wrapped) in the cert. + pub fn to_canonical_json(&self) -> Vec { + // `serde_json` here is configured (workspace-wide) with preserve_order, + // and the struct field order is fixed, so this is deterministic. + serde_json::to_vec(self).unwrap_or_default() + } + + /// Parse a binding from the canonical JSON bytes. Total over its input: + /// malformed JSON is [`TlsCertError::MalformedBinding`], not a panic. + pub fn from_canonical_json(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|e| TlsCertError::MalformedBinding(e.to_string())) + } +} + +#[cfg(feature = "tls-cert")] +mod backend { + use super::*; + + use rcgen::string::Ia5String; + use rcgen::{ + CertificateParams, CustomExtension, DnType, ExtendedKeyUsagePurpose, KeyPair, + KeyUsagePurpose, SanType, + }; + + /// A freshly issued KEL-rooted leaf certificate plus its private key. + /// + /// The cert's subject public key is a fresh ephemeral TLS keypair (the AID's + /// long-term key is never put on the wire); the AID binding rides in the SAN + /// and the [`AUTHS_KERI_BINDING_OID`] extension. + pub struct IssuedCert { + /// The certificate, PEM-encoded. + pub cert_pem: String, + /// The ephemeral TLS private key, PKCS#8 PEM. Hand to the TLS acceptor. + pub key_pem: zeroize::Zeroizing, + /// The binding the cert embeds (for the caller to echo / log). + pub binding: AuthsKeriBinding, + } + + /// Issue a KEL-rooted leaf certificate for a resolved AID key-state. + /// + /// Generates a fresh P-256 TLS keypair, sets the subject CN and a + /// `did:keri:` URI SAN, embeds the [`AuthsKeriBinding`] extension, and + /// self-signs with the ephemeral key so a stock TLS stack completes a + /// handshake. `extra_sans` carries the transport host names/IPs the cert must + /// also be valid for (e.g. `localhost`, `127.0.0.1`) — without them a + /// hostname-checking client would reject the leaf even though the binding is + /// sound. + pub fn issue_kel_rooted_cert( + state: &KeyState, + extra_sans: &[String], + ) -> Result { + let binding = AuthsKeriBinding::from_key_state(state)?; + let key_pair = + KeyPair::generate().map_err(|e| TlsCertError::KeyPair(format!("generate: {e}")))?; + issue_with_keypair(&binding, &key_pair, extra_sans) + } + + /// Issue a KEL-rooted leaf from an existing PKCS#8-PEM TLS keypair. + /// + /// The deterministic path used by tests and by callers that already hold a + /// TLS key. The key is the cert's subject key and self-signs the leaf. + pub fn issue_kel_rooted_cert_with_key( + state: &KeyState, + tls_key_pkcs8_pem: &str, + extra_sans: &[String], + ) -> Result { + let binding = AuthsKeriBinding::from_key_state(state)?; + let key_pair = KeyPair::from_pem(tls_key_pkcs8_pem) + .map_err(|e| TlsCertError::KeyPair(format!("from pem: {e}")))?; + issue_with_keypair(&binding, &key_pair, extra_sans) + } + + fn issue_with_keypair( + binding: &AuthsKeriBinding, + key_pair: &KeyPair, + extra_sans: &[String], + ) -> Result { + let mut params = CertificateParams::new(Vec::new()) + .map_err(|e| TlsCertError::Generate(format!("params: {e}")))?; + + // Subject CN = the did:keri DID, so the identity is visible even in tools + // that only print the subject. + params + .distinguished_name + .push(DnType::CommonName, binding.did_keri()); + + // SAN: the did:keri URI (the SPIFFE X.509-SVID identity-in-SAN pattern) + // plus any transport hostnames/IPs the leaf must serve. + let did_uri = Ia5String::try_from(binding.did_keri()) + .map_err(|e| TlsCertError::Generate(format!("did:keri SAN: {e}")))?; + params.subject_alt_names.push(SanType::URI(did_uri)); + for san in extra_sans { + params.subject_alt_names.push(san_for(san)?); + } + + params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + params.extended_key_usages = vec![ + ExtendedKeyUsagePurpose::ServerAuth, + ExtendedKeyUsagePurpose::ClientAuth, + ]; + + // The KEL binding: a non-critical extension carrying the replayed + // key-state, DER-wrapped as an OCTET STRING (the standard envelope for an + // opaque extension value). + let content = yasna_octet_string(&binding.to_canonical_json()); + let mut ext = CustomExtension::from_oid_content(AUTHS_KERI_BINDING_OID, content); + ext.set_criticality(false); + params.custom_extensions.push(ext); + + let cert = params + .self_signed(key_pair) + .map_err(|e| TlsCertError::Generate(format!("self-sign: {e}")))?; + + Ok(IssuedCert { + cert_pem: cert.pem(), + key_pem: zeroize::Zeroizing::new(key_pair.serialize_pem()), + binding: binding.clone(), + }) + } + + /// Build a SAN entry from a host string: an IP literal becomes an IP SAN, + /// anything else a DNS SAN (matching how `rcgen`/stock stacks treat hosts). + fn san_for(host: &str) -> Result { + if let Ok(ip) = host.parse::() { + Ok(SanType::IpAddress(ip)) + } else { + let dns = Ia5String::try_from(host.to_string()) + .map_err(|e| TlsCertError::Generate(format!("DNS SAN {host:?}: {e}")))?; + Ok(SanType::DnsName(dns)) + } + } + + /// DER-encode `bytes` as an OCTET STRING (the extension value envelope). + fn yasna_octet_string(bytes: &[u8]) -> Vec { + yasna::construct_der(|w| w.write_bytes(bytes)) + } + + /// Extract the [`AuthsKeriBinding`] embedded in a PEM certificate. + /// + /// Reads the `AUTHS_KERI_BINDING_OID` extension, unwraps the OCTET STRING, and + /// parses the canonical JSON. Errors classify the failure precisely: + /// [`TlsCertError::MissingBinding`] when there is no such extension (a plain + /// cert), [`TlsCertError::MalformedBinding`] when its content is not the + /// expected envelope. + pub fn extract_binding(cert_pem: &str) -> Result { + use x509_parser::prelude::*; + + let (_, pem) = x509_parser::pem::parse_x509_pem(cert_pem.as_bytes()) + .map_err(|e| TlsCertError::ParseCert(format!("PEM: {e}")))?; + let (_, cert) = X509Certificate::from_der(&pem.contents) + .map_err(|e| TlsCertError::ParseCert(format!("DER: {e}")))?; + + let oid_str = oid_string(AUTHS_KERI_BINDING_OID); + for ext in cert.extensions() { + if ext.oid.to_id_string() == oid_str { + let inner = unwrap_octet_string(ext.value)?; + return AuthsKeriBinding::from_canonical_json(&inner); + } + } + Err(TlsCertError::MissingBinding) + } + + /// Read the `did:keri` URI SAN out of a PEM certificate, if present. + pub fn extract_did_keri_san(cert_pem: &str) -> Result, TlsCertError> { + use x509_parser::prelude::*; + + let (_, pem) = x509_parser::pem::parse_x509_pem(cert_pem.as_bytes()) + .map_err(|e| TlsCertError::ParseCert(format!("PEM: {e}")))?; + let (_, cert) = X509Certificate::from_der(&pem.contents) + .map_err(|e| TlsCertError::ParseCert(format!("DER: {e}")))?; + + if let Ok(Some(san)) = cert.subject_alternative_name() { + for name in &san.value.general_names { + if let GeneralName::URI(uri) = name + && uri.starts_with(DID_KERI_SCHEME) + { + return Ok(Some((*uri).to_string())); + } + } + } + Ok(None) + } + + /// Verify a KEL-rooted certificate against an AID's KEL key-state. + /// + /// The peer→auths direction: parse the cert, read its embedded binding *and* + /// its `did:keri` SAN, then assert both agree with `state` — the freshly + /// replayed key-state of the KEL the verifier holds. Trust is rooted in the + /// log: a cert whose embedded key-state diverges from a real replay is + /// rejected ([`TlsCertError::BindingMismatch`]). + pub fn verify_binds_to_key_state( + cert_pem: &str, + state: &KeyState, + ) -> Result { + let expected = AuthsKeriBinding::from_key_state(state)?; + let embedded = extract_binding(cert_pem)?; + + if embedded.aid != expected.aid { + return Err(TlsCertError::BindingMismatch(format!( + "AID {} in cert != {} from KEL replay", + embedded.aid, expected.aid + ))); + } + if embedded.current_keys != expected.current_keys { + return Err(TlsCertError::BindingMismatch( + "current signing keys in cert do not match the KEL replay".to_string(), + )); + } + if embedded.kel_tip != expected.kel_tip { + return Err(TlsCertError::BindingMismatch(format!( + "KEL tip {} in cert != {} from replay", + embedded.kel_tip, expected.kel_tip + ))); + } + + // The SAN must carry the same AID — the legacy-compat identity surface + // must agree with the binding, or a tool reading only the SAN would trust + // a different AID than the one the binding (and the KEL) attest. + match extract_did_keri_san(cert_pem)? { + Some(san) if san == expected.did_keri() => Ok(embedded), + Some(san) => Err(TlsCertError::SanMismatch(format!( + "SAN {san} != {}", + expected.did_keri() + ))), + None => Err(TlsCertError::SanMismatch( + "certificate carries no did:keri SAN".to_string(), + )), + } + } + + /// Render an OID arc as the dotted string `x509-parser` exposes. + fn oid_string(arc: &[u64]) -> String { + arc.iter() + .map(|n| n.to_string()) + .collect::>() + .join(".") + } + + /// Unwrap a DER OCTET STRING to its content bytes. + fn unwrap_octet_string(der: &[u8]) -> Result, TlsCertError> { + yasna::parse_der(der, |r| r.read_bytes()) + .map_err(|e| TlsCertError::MalformedBinding(format!("OCTET STRING: {e}"))) + } +} + +#[cfg(feature = "tls-cert")] +pub use backend::{ + IssuedCert, extract_binding, extract_did_keri_san, issue_kel_rooted_cert, + issue_kel_rooted_cert_with_key, verify_binds_to_key_state, +}; + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::types::{CesrKey, Prefix, Said, Threshold}; + + /// A single-key Ed25519 key-state at the given AID/key/tip. + fn state(aid: &str, keys: &[&str], tip: &str) -> KeyState { + KeyState::from_inception( + Prefix::new_unchecked(aid.to_string()), + keys.iter() + .map(|k| CesrKey::new_unchecked(k.to_string())) + .collect(), + vec![Said::new_unchecked("ENext0".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), + Said::new_unchecked(tip.to_string()), + vec![], + Threshold::Simple(0), + vec![], + ) + } + + fn ed25519_key(raw: &[u8; 32]) -> String { + KeriPublicKey::ed25519(raw).unwrap().to_qb64().unwrap() + } + + #[test] + fn binding_projects_key_state() { + let k = ed25519_key(&[7u8; 32]); + let st = state("EAidAAA", &[&k], "ETip000"); + let b = AuthsKeriBinding::from_key_state(&st).unwrap(); + assert_eq!(b.aid, "EAidAAA"); + assert_eq!(b.current_keys, vec![k]); + assert_eq!(b.kel_tip, "ETip000"); + assert_eq!(b.did_keri(), "did:keri:EAidAAA"); + } + + #[test] + fn binding_rejects_undecodable_key_at_boundary() { + let st = state("EAidAAA", &["Xnot-a-verkey"], "ETip000"); + assert!(matches!( + AuthsKeriBinding::from_key_state(&st), + Err(TlsCertError::Key(_)) + )); + } + + #[test] + fn binding_canonical_json_round_trips() { + let k = ed25519_key(&[3u8; 32]); + let st = state("EAidAAA", &[&k], "ETip000"); + let b = AuthsKeriBinding::from_key_state(&st).unwrap(); + let json = b.to_canonical_json(); + let back = AuthsKeriBinding::from_canonical_json(&json).unwrap(); + assert_eq!(back, b); + } + + #[test] + fn binding_json_field_order_is_canonical() { + let k = ed25519_key(&[1u8; 32]); + let st = state("EAidAAA", &[&k], "ETip000"); + let b = AuthsKeriBinding::from_key_state(&st).unwrap(); + let s = String::from_utf8(b.to_canonical_json()).unwrap(); + // aid, current_keys, kel_tip — struct order, preserve_order serde. + let i_aid = s.find("\"aid\"").unwrap(); + let i_keys = s.find("\"current_keys\"").unwrap(); + let i_tip = s.find("\"kel_tip\"").unwrap(); + assert!(i_aid < i_keys && i_keys < i_tip, "field order: {s}"); + } + + #[test] + fn malformed_binding_json_is_an_error_not_a_panic() { + assert!(matches!( + AuthsKeriBinding::from_canonical_json(b"not json"), + Err(TlsCertError::MalformedBinding(_)) + )); + } + + #[cfg(feature = "tls-cert")] + mod backend_tests { + use super::*; + + fn multi_state() -> (KeyState, Vec) { + let k1 = ed25519_key(&[1u8; 32]); + let k2 = ed25519_key(&[2u8; 32]); + let st = state( + "EAidMultiAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + &[&k1, &k2], + "ETipMultiAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + (st, vec![k1, k2]) + } + + #[test] + fn issued_cert_carries_binding_and_san() { + let (st, keys) = multi_state(); + let issued = + issue_kel_rooted_cert(&st, &["localhost".to_string(), "127.0.0.1".to_string()]) + .unwrap(); + assert!(issued.cert_pem.contains("BEGIN CERTIFICATE")); + assert!(!issued.key_pem.is_empty()); + + let binding = extract_binding(&issued.cert_pem).unwrap(); + assert_eq!(binding.aid, st.prefix.as_str()); + assert_eq!(binding.current_keys, keys); + + let san = extract_did_keri_san(&issued.cert_pem).unwrap(); + assert_eq!(san, Some(format!("did:keri:{}", st.prefix.as_str()))); + } + + #[test] + fn issued_cert_verifies_against_the_same_key_state() { + let (st, _) = multi_state(); + let issued = issue_kel_rooted_cert(&st, &["localhost".to_string()]).unwrap(); + let binding = verify_binds_to_key_state(&issued.cert_pem, &st).unwrap(); + assert_eq!(binding.aid, st.prefix.as_str()); + } + + #[test] + fn cert_is_rejected_against_a_different_key_state() { + let (st, _) = multi_state(); + let issued = issue_kel_rooted_cert(&st, &["localhost".to_string()]).unwrap(); + + // A KEL replay that yields a different current key must not verify. + let other_key = ed25519_key(&[9u8; 32]); + let other = state( + st.prefix.as_str(), + &[&other_key], + st.last_event_said.as_str(), + ); + assert!(matches!( + verify_binds_to_key_state(&issued.cert_pem, &other), + Err(TlsCertError::BindingMismatch(_)) + )); + } + + #[test] + fn cert_is_rejected_against_a_different_aid() { + let (st, _) = multi_state(); + let issued = issue_kel_rooted_cert(&st, &["localhost".to_string()]).unwrap(); + let k = ed25519_key(&[1u8; 32]); + let other = state( + "EAidOTHERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + &[&k], + "ETip", + ); + assert!(matches!( + verify_binds_to_key_state(&issued.cert_pem, &other), + Err(TlsCertError::BindingMismatch(_)) + )); + } + + #[test] + fn plain_cert_has_no_binding() { + // A cert minted without the extension reports MissingBinding, not a + // false match — so a stock self-signed cert can't masquerade as + // KEL-rooted. + let kp = rcgen::KeyPair::generate().unwrap(); + let params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + let cert = params.self_signed(&kp).unwrap(); + assert!(matches!( + extract_binding(&cert.pem()), + Err(TlsCertError::MissingBinding) + )); + } + + #[test] + fn issue_with_supplied_key_is_deterministic_in_binding() { + // Same KEL + same TLS key → identical embedded binding (the cert + // serial/validity may differ, but the KEL projection is stable). + let (st, _) = multi_state(); + let kp = rcgen::KeyPair::generate().unwrap(); + let pem = kp.serialize_pem(); + let a = issue_kel_rooted_cert_with_key(&st, &pem, &["localhost".to_string()]).unwrap(); + let b = issue_kel_rooted_cert_with_key(&st, &pem, &["localhost".to_string()]).unwrap(); + assert_eq!( + extract_binding(&a.cert_pem).unwrap(), + extract_binding(&b.cert_pem).unwrap() + ); + } + } +} From 9bb70cc048caf831d1a2e26802f6713a2d74674b Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 12:38:54 +0100 Subject: [PATCH 16/19] feat(tls-cert): read the did:keri identity out of a leaf's SAN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A KEL-rooted leaf already carries a did:keri: URI subjectAltName (the SPIFFE X.509-SVID identity-in-SAN shape), but the only way to learn which identity a cert claimed was `tls-cert verify --from-kel`, which requires already holding the AID's KEL. Add the discovery half: read the identity out of the SAN alone, before the KEL is in hand. - auths-keri::tls_cert::extract_aid_from_san reads the did:keri URI from the cert SAN (reusing extract_did_keri_san), strips the scheme, and parses the AID into a validated Prefix. A malformed identifier is InvalidSanAid and a leaf with no did:keri SAN is NoSanIdentity — typed errors at the boundary, never a raw string, so a plain self-signed cert can't masquerade as carrying an auths identity. - `auths tls-cert identity --cert ` is a thin CLI adapter that prints the did:keri AID a leaf claims: the X.509-SVID "who is this peer?" read, offline, no KEL required. It composes with `verify --from-kel` (resolve the AID, then replay its KEL to root trust in the log). Tests: AID reads out of an issued cert's SAN without the KEL; a plain cert is NoSanIdentity; a malformed did:keri SAN AID is rejected at the parse boundary. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/auths-cli/src/commands/tls_cert.rs | 41 +++++++++++-- crates/auths-keri/src/lib.rs | 2 +- crates/auths-keri/src/tls_cert.rs | 75 ++++++++++++++++++++++- 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/crates/auths-cli/src/commands/tls_cert.rs b/crates/auths-cli/src/commands/tls_cert.rs index 2a74ee41..1b898fec 100644 --- a/crates/auths-cli/src/commands/tls_cert.rs +++ b/crates/auths-cli/src/commands/tls_cert.rs @@ -27,8 +27,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use auths_keri::{ - TrustedKel, issue_kel_rooted_cert, issue_kel_rooted_cert_with_key, parse_kel_json, - verify_binds_to_key_state, + TrustedKel, extract_aid_from_san, issue_kel_rooted_cert, issue_kel_rooted_cert_with_key, + parse_kel_json, verify_binds_to_key_state, }; use auths_utils::path::expand_tilde; use clap::{Parser, Subcommand}; @@ -38,10 +38,11 @@ use crate::config::CliConfig; /// Issue or verify a KEL-rooted X.509 certificate (TLS composition). #[derive(Parser, Debug, Clone)] #[command( - about = "Issue/verify a KEL-rooted X.509 cert — an auths identity that stock TLS stacks (rustls/openssl/go) handshake with", + about = "Issue/verify a KEL-rooted X.509 cert and read its did:keri subjectAltName — an auths identity that stock TLS stacks (rustls/openssl/go) handshake with", after_help = "Examples: auths tls-cert issue --from-kel kel.json --san localhost --san 127.0.0.1 auths tls-cert issue --from-kel kel.json --out leaf # writes leaf.cert.pem + leaf.key.pem + auths tls-cert identity --cert leaf.cert.pem # read the did:keri AID out of the SAN auths tls-cert verify --cert leaf.cert.pem --from-kel kel.json" )] pub struct TlsCertCommand { @@ -50,11 +51,13 @@ pub struct TlsCertCommand { pub action: TlsCertAction, } -/// The two directions of KEL-rooted mTLS composition. +/// The directions of KEL-rooted mTLS composition. #[derive(Subcommand, Debug, Clone)] pub enum TlsCertAction { /// Issue a KEL-rooted leaf certificate for an AID (us → peer). Issue(IssueArgs), + /// Read the did:keri AID out of a leaf's subjectAltName (X.509-SVID identity). + Identity(IdentityArgs), /// Verify a peer's leaf binds to the KEL we hold (peer → us). Verify(VerifyArgs), } @@ -84,6 +87,15 @@ pub struct IssueArgs { pub out: Option, } +/// `auths tls-cert identity` — read the did:keri AID out of a leaf's SAN. +#[derive(Parser, Debug, Clone)] +pub struct IdentityArgs { + /// The leaf certificate, PEM-encoded. Its `did:keri` subjectAltName names the + /// auths identity — the AID a verifier looks up before replaying its KEL. + #[clap(long, value_name = "CERT.pem")] + pub cert: PathBuf, +} + /// `auths tls-cert verify` — confirm a peer's leaf is rooted in a KEL we hold. #[derive(Parser, Debug, Clone)] pub struct VerifyArgs { @@ -102,6 +114,7 @@ impl TlsCertCommand { pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { match &self.action { TlsCertAction::Issue(args) => args.run(), + TlsCertAction::Identity(args) => args.run(), TlsCertAction::Verify(args) => args.run(), } } @@ -160,6 +173,26 @@ impl IssueArgs { } } +impl IdentityArgs { + fn run(&self) -> Result<()> { + let cert_path = expand_tilde(&self.cert)?; + let cert_pem = std::fs::read_to_string(&cert_path) + .map_err(|e| anyhow!("read cert {}: {e}", cert_path.display()))?; + + // The X.509-SVID identity read: the did:keri AID rides in the SAN every + // stock X.509 parser already exposes, so we learn which identity the cert + // claims before holding its KEL. The AID is parsed (not just extracted), + // so what we print is a valid KERI prefix, not a raw string. + let aid = extract_aid_from_san(&cert_pem) + .map_err(|e| anyhow!("read did:keri identity from cert SAN: {e}"))?; + + println!( + "did:keri:{aid}\n AID: {aid}\n (the SAN names the identity; replay its KEL to root trust in the log)" + ); + Ok(()) + } +} + impl VerifyArgs { fn run(&self) -> Result<()> { let state = replay_kel(&self.from_kel)?; diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index a112861f..3f6f88d5 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -122,7 +122,7 @@ pub use tel::{ pub use tls_cert::{AUTHS_KERI_BINDING_OID, AuthsKeriBinding, DID_KERI_SCHEME, TlsCertError}; #[cfg(feature = "tls-cert")] pub use tls_cert::{ - IssuedCert, extract_binding, extract_did_keri_san, issue_kel_rooted_cert, + IssuedCert, extract_aid_from_san, extract_binding, extract_did_keri_san, issue_kel_rooted_cert, issue_kel_rooted_cert_with_key, verify_binds_to_key_state, }; pub use types::{ diff --git a/crates/auths-keri/src/tls_cert.rs b/crates/auths-keri/src/tls_cert.rs index f2be6a46..3eb8dc40 100644 --- a/crates/auths-keri/src/tls_cert.rs +++ b/crates/auths-keri/src/tls_cert.rs @@ -100,6 +100,15 @@ pub enum TlsCertError { /// The certificate's `did:keri` SAN is absent or does not match the binding. #[error("certificate did:keri SAN mismatch: {0}")] SanMismatch(String), + + /// The certificate carries no `did:keri` URI in its `subjectAltName` — there + /// is no auths identity to read out of it (the X.509-SVID identity surface). + #[error("certificate carries no did:keri subjectAltName")] + NoSanIdentity, + + /// The `did:keri` SAN was present but its AID is not a valid KERI prefix. + #[error("did:keri SAN carries an invalid AID: {0}")] + InvalidSanAid(#[from] crate::types::KeriTypeError), } /// The AID key-state a KEL-rooted certificate embeds — the projection of a KEL @@ -332,6 +341,32 @@ mod backend { Ok(None) } + /// Read the AID a certificate claims out of its `did:keri` SAN — the + /// X.509-SVID identity surface. + /// + /// This is the identity-discovery direction (the SPIFFE X.509-SVID precedent): + /// the AID rides in the `subjectAltName` URI every stock X.509 parser already + /// exposes, so a verifier learns *which* auths identity a peer claims directly + /// from the cert — **before** it holds that AID's KEL. The returned [`Prefix`] + /// is then the lookup key to fetch and replay the KEL (via an OOBI / a held + /// log), at which point [`verify_binds_to_key_state`] re-roots trust in the + /// log. Parse, don't validate: the scheme is stripped and the AID is parsed + /// into a validated [`Prefix`], so a present-but-malformed identifier is + /// [`TlsCertError::InvalidSanAid`] at the boundary, never a raw string the + /// caller has to re-check. A cert with no `did:keri` URI SAN is + /// [`TlsCertError::NoSanIdentity`] (a plain leaf carries no auths identity). + pub fn extract_aid_from_san(cert_pem: &str) -> Result { + match extract_did_keri_san(cert_pem)? { + Some(uri) => { + let aid = uri.strip_prefix(DID_KERI_SCHEME).ok_or_else(|| { + TlsCertError::SanMismatch(format!("SAN {uri} is not a {DID_KERI_SCHEME} URI")) + })?; + Ok(crate::types::Prefix::new(aid.to_string())?) + } + None => Err(TlsCertError::NoSanIdentity), + } + } + /// Verify a KEL-rooted certificate against an AID's KEL key-state. /// /// The peer→auths direction: parse the cert, read its embedded binding *and* @@ -396,7 +431,7 @@ mod backend { #[cfg(feature = "tls-cert")] pub use backend::{ - IssuedCert, extract_binding, extract_did_keri_san, issue_kel_rooted_cert, + IssuedCert, extract_aid_from_san, extract_binding, extract_did_keri_san, issue_kel_rooted_cert, issue_kel_rooted_cert_with_key, verify_binds_to_key_state, }; @@ -510,6 +545,44 @@ mod tests { assert_eq!(san, Some(format!("did:keri:{}", st.prefix.as_str()))); } + #[test] + fn aid_reads_out_of_the_san_without_the_kel() { + // The X.509-SVID identity surface: a verifier learns *which* AID a + // cert claims from the SAN alone, before it holds the KEL. + let (st, _) = multi_state(); + let issued = issue_kel_rooted_cert(&st, &["localhost".to_string()]).unwrap(); + let aid = extract_aid_from_san(&issued.cert_pem).unwrap(); + assert_eq!(aid.as_str(), st.prefix.as_str()); + } + + #[test] + fn plain_cert_has_no_san_identity() { + // A stock self-signed leaf carries no did:keri SAN, so there is no + // auths identity to read out of it — NoSanIdentity, not a panic. + let kp = rcgen::KeyPair::generate().unwrap(); + let params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + let cert = params.self_signed(&kp).unwrap(); + assert!(matches!( + extract_aid_from_san(&cert.pem()), + Err(TlsCertError::NoSanIdentity) + )); + } + + #[test] + fn malformed_san_aid_is_rejected_at_the_boundary() { + // A did:keri SAN whose AID is not a valid KERI prefix is an error at + // the parse boundary, never returned as a trusted identity. + let kp = rcgen::KeyPair::generate().unwrap(); + let mut params = rcgen::CertificateParams::new(Vec::new()).unwrap(); + let bad = rcgen::string::Ia5String::try_from("did:keri:".to_string()).unwrap(); + params.subject_alt_names.push(rcgen::SanType::URI(bad)); + let cert = params.self_signed(&kp).unwrap(); + assert!(matches!( + extract_aid_from_san(&cert.pem()), + Err(TlsCertError::InvalidSanAid(_)) + )); + } + #[test] fn issued_cert_verifies_against_the_same_key_state() { let (st, _) = multi_state(); From 0240249ac4e45ea5a1a34ed974c9035dbaeec418 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 13:41:01 +0100 Subject: [PATCH 17/19] =?UTF-8?q?feat(tls-cert):=20AID=20authorizes=20its?= =?UTF-8?q?=20TLS=20key=20=E2=80=94=20adversarial=20verifier=20rejects=20f?= =?UTF-8?q?orged/revoked/stripped=20leaves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A KEL-rooted leaf previously only bound the AID's replayed key-state; on its own that is not unforgeable — anyone replaying a public KEL could project the same key-state into a leaf minted over their own TLS key, and the verifier accepted it. The leaf now carries a TlsKeyAuthorization: a KERI signature, by one of the AID's current signing keys, over the leaf's SubjectPublicKeyInfo DER. Only the AID's controller can produce it. - TlsKeyAuthorization (parse-don't-validate) rides inside AuthsKeriBinding; both sides re-derive the same SPKI DER (issuer from the keypair, verifier from the parsed cert) so there is one source of truth for what the AID signed. - TlsKeyAuthorizer port: the core never imports a key store; an adapter supplies the signature. issue_authorized_kel_rooted_cert signs the ephemeral TLS key's SPKI and refuses at issuance if the signing key doesn't match the named current key. - verify_authorized_against_key_state is the adversarial verifier: on top of the chain-to-the-log check it requires the authorization and checks it against the replayed current keys. Distinct typed rejections — Unauthorized (forged), MissingAuthorization (stripped), MissingBinding (plain), BindingMismatch (revoked/rotated), SanMismatch (spoof). - CLI: `tls-cert issue --sign-key ` mints the authorized leaf; `tls-cert verify` is the adversarial verifier (no back-compat). 11 new tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/auths-cli/src/commands/tls_cert.rs | 158 +++++-- crates/auths-keri/src/lib.rs | 11 +- crates/auths-keri/src/tls_cert.rs | 529 ++++++++++++++++++++-- 3 files changed, 637 insertions(+), 61 deletions(-) diff --git a/crates/auths-cli/src/commands/tls_cert.rs b/crates/auths-cli/src/commands/tls_cert.rs index 1b898fec..9fb23b8c 100644 --- a/crates/auths-cli/src/commands/tls_cert.rs +++ b/crates/auths-cli/src/commands/tls_cert.rs @@ -11,39 +11,83 @@ //! //! Two directions, both offline/hermetic (the KEL handed in is a local artifact): //! -//! * `auths tls-cert issue --from-kel kel.json` — us → peer: replay the KEL, -//! project its current key-state into a KEL-rooted leaf, and emit the cert PEM -//! plus the ephemeral TLS private key PEM the acceptor serves. -//! * `auths tls-cert verify --cert cert.pem --from-kel kel.json` — peer → us: -//! parse a peer's leaf, replay the KEL we hold for its AID, and confirm the -//! cert binds to that exact replayed key-state (AID, current keys, KEL tip, and -//! the `did:keri` SAN). Trust is rooted in the log, never in a CA. +//! * `auths tls-cert issue --from-kel kel.json --sign-key aid.key.pem` — us → +//! peer: replay the KEL, project its current key-state into a KEL-rooted leaf, +//! have the AID's current signing key authorize the leaf's TLS key (a KERI +//! signature over the leaf SPKI), and emit the cert PEM plus the ephemeral TLS +//! private key PEM the acceptor serves. +//! * `auths tls-cert verify --cert cert.pem --from-kel kel.json` — peer → us: the +//! adversarial verifier. Parse a peer's leaf, replay the KEL we hold for its +//! AID, and confirm the cert binds to that exact replayed key-state (AID, +//! current keys, KEL tip, the `did:keri` SAN) **and** that the AID authorized +//! the leaf's TLS key. Rejects a forged binding (matching key-state over an +//! attacker's TLS key), a revoked/rotated AID, a stripped binding/authorization, +//! and a SAN spoof. Trust is rooted in the log, never in a CA. //! //! The wire/crypto definition lives in `auths-keri::tls_cert`; this is a thin CLI //! adapter over it. The cert's subject key is a fresh ephemeral TLS keypair, so -//! the AID's long-term signing key never goes on the wire. +//! the AID's long-term signing key never goes on the wire — only its detached +//! authorization signature over the public TLS key does. use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; +use auths_crypto::TypedSignerKey; use auths_keri::{ - TrustedKel, extract_aid_from_san, issue_kel_rooted_cert, issue_kel_rooted_cert_with_key, - parse_kel_json, verify_binds_to_key_state, + KeyState, TlsCertError, TlsKeyAuthorizer, TrustedKel, extract_aid_from_san, + issue_authorized_kel_rooted_cert, issue_authorized_kel_rooted_cert_with_key, + issue_kel_rooted_cert, issue_kel_rooted_cert_with_key, parse_kel_json, + verify_authorized_against_key_state, }; use auths_utils::path::expand_tilde; use clap::{Parser, Subcommand}; use crate::config::CliConfig; +/// Adapter: a [`TlsKeyAuthorizer`] backed by the AID's current signing key. +/// +/// The CLI holds the AID's signing key as a local PKCS#8 artifact (the same +/// hermetic boundary `--from-kel` / `--tls-key` already use); this adapter signs +/// the leaf's `SubjectPublicKeyInfo` DER with it so the issued leaf carries the +/// AID's authorization. The core `auths-keri` never imports a key store — it only +/// sees the port. +struct SignerKeyAuthorizer { + signer: TypedSignerKey, + key_index: usize, +} + +impl TlsKeyAuthorizer for SignerKeyAuthorizer { + fn current_key_index(&self) -> usize { + self.key_index + } + + fn sign_tls_key(&self, spki_der: &[u8]) -> Result, TlsCertError> { + self.signer + .sign(spki_der) + .map_err(|e| TlsCertError::Generate(format!("authorize TLS key: {e}"))) + } +} + +/// Load the AID's current signing key from a PKCS#8 PEM file into a typed signer. +fn load_signer(key_path: &Path) -> Result { + let path = expand_tilde(key_path)?; + let pem = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read signing key {}: {e}", path.display()))?; + let (_, der) = pkcs8::SecretDocument::from_pem(&pem) + .map_err(|e| anyhow!("parse signing key PEM {}: {e}", path.display()))?; + TypedSignerKey::from_pkcs8(der.as_bytes()) + .map_err(|e| anyhow!("load signing key {}: {e}", path.display())) +} + /// Issue or verify a KEL-rooted X.509 certificate (TLS composition). #[derive(Parser, Debug, Clone)] #[command( about = "Issue/verify a KEL-rooted X.509 cert and read its did:keri subjectAltName — an auths identity that stock TLS stacks (rustls/openssl/go) handshake with", after_help = "Examples: - auths tls-cert issue --from-kel kel.json --san localhost --san 127.0.0.1 - auths tls-cert issue --from-kel kel.json --out leaf # writes leaf.cert.pem + leaf.key.pem - auths tls-cert identity --cert leaf.cert.pem # read the did:keri AID out of the SAN - auths tls-cert verify --cert leaf.cert.pem --from-kel kel.json" + auths tls-cert issue --from-kel kel.json --sign-key aid.key.pem --san localhost # AID-authorized leaf + auths tls-cert issue --from-kel kel.json --sign-key aid.key.pem --out leaf # writes leaf.cert.pem + leaf.key.pem + auths tls-cert identity --cert leaf.cert.pem # read the did:keri AID out of the SAN + auths tls-cert verify --cert leaf.cert.pem --from-kel kel.json # adversarial: rejects forged/revoked/stripped" )] pub struct TlsCertCommand { /// The direction to run: issue our leaf, or verify a peer's. @@ -80,6 +124,19 @@ pub struct IssueArgs { #[clap(long, value_name = "KEY.pem")] pub tls_key: Option, + /// The AID's current signing key (PKCS#8 PEM). When given, the leaf carries an + /// AID authorization over its TLS key (a KERI signature over the leaf SPKI), so + /// `verify` can reject a forged binding minted over an attacker's TLS key. + /// Without it the leaf only chains to the key-state (the discovery surface) and + /// is rejected by the adversarial verifier. + #[clap(long, value_name = "AID-KEY.pem")] + pub sign_key: Option, + + /// Which current key (index into the KEL's current key-state) `--sign-key` + /// corresponds to. Defaults to 0 (single-sig AIDs). + #[clap(long, default_value_t = 0, value_name = "N")] + pub sign_key_index: usize, + /// Write `.cert.pem` + `.key.pem` instead of printing to /// stdout. Without it, the cert PEM is printed (the key never is, to avoid /// leaking it into logs). @@ -136,18 +193,7 @@ fn replay_kel(kel_path: &Path) -> Result { impl IssueArgs { fn run(&self) -> Result<()> { let state = replay_kel(&self.from_kel)?; - - let issued = match &self.tls_key { - Some(key_path) => { - let path = expand_tilde(key_path)?; - let key_pem = std::fs::read_to_string(&path) - .map_err(|e| anyhow!("read TLS key {}: {e}", path.display()))?; - issue_kel_rooted_cert_with_key(&state, &key_pem, &self.sans) - .map_err(|e| anyhow!("issue KEL-rooted cert: {e}"))? - } - None => issue_kel_rooted_cert(&state, &self.sans) - .map_err(|e| anyhow!("issue KEL-rooted cert: {e}"))?, - }; + let issued = self.issue(&state)?; match &self.out { Some(prefix) => { @@ -171,6 +217,47 @@ impl IssueArgs { } Ok(()) } + + /// Mint the leaf, dispatching on whether the AID's signing key (authorized, + /// the secure path) and/or a supplied TLS key are provided. One source of + /// truth for the four issue paths. + fn issue(&self, state: &KeyState) -> Result { + let tls_key_pem = match &self.tls_key { + Some(key_path) => { + let path = expand_tilde(key_path)?; + Some( + std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read TLS key {}: {e}", path.display()))?, + ) + } + None => None, + }; + + match (&self.sign_key, tls_key_pem) { + (Some(sign_key_path), Some(tls_pem)) => { + let signer = load_signer(sign_key_path)?; + let authorizer = SignerKeyAuthorizer { + signer, + key_index: self.sign_key_index, + }; + issue_authorized_kel_rooted_cert_with_key(state, &authorizer, &tls_pem, &self.sans) + .map_err(|e| anyhow!("issue authorized KEL-rooted cert: {e}")) + } + (Some(sign_key_path), None) => { + let signer = load_signer(sign_key_path)?; + let authorizer = SignerKeyAuthorizer { + signer, + key_index: self.sign_key_index, + }; + issue_authorized_kel_rooted_cert(state, &authorizer, &self.sans) + .map_err(|e| anyhow!("issue authorized KEL-rooted cert: {e}")) + } + (None, Some(tls_pem)) => issue_kel_rooted_cert_with_key(state, &tls_pem, &self.sans) + .map_err(|e| anyhow!("issue KEL-rooted cert: {e}")), + (None, None) => issue_kel_rooted_cert(state, &self.sans) + .map_err(|e| anyhow!("issue KEL-rooted cert: {e}")), + } + } } impl IdentityArgs { @@ -200,14 +287,25 @@ impl VerifyArgs { let cert_pem = std::fs::read_to_string(&cert_path) .map_err(|e| anyhow!("read cert {}: {e}", cert_path.display()))?; - let binding = verify_binds_to_key_state(&cert_pem, &state) - .map_err(|e| anyhow!("certificate does not chain to the KEL: {e}"))?; + // The adversarial verifier: the leaf must chain to the replayed log AND + // carry the AID's authorization over its TLS key. This rejects a forged + // binding (matching key-state, attacker's TLS key), a revoked/rotated AID + // (key-state diverges from the replay), a stripped binding/authorization, + // and a SAN spoof — the T3 rejection classes. + let binding = verify_authorized_against_key_state(&cert_pem, &state) + .map_err(|e| anyhow!("certificate rejected: {e}"))?; + let authorized_by = binding + .tls_key_authorization + .as_ref() + .map(|a| a.key_index) + .unwrap_or_default(); println!( - "verified: certificate is rooted in the KEL\n did:keri: {}\n current keys: {}\n KEL tip: {}", + "verified: certificate is rooted in the KEL and the AID authorized its TLS key\n did:keri: {}\n current keys: {}\n KEL tip: {}\n TLS key authorized by current key #{}", binding.did_keri(), binding.current_keys.join(", "), - binding.kel_tip + binding.kel_tip, + authorized_by, ); Ok(()) } diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index 3f6f88d5..a406ff29 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -119,11 +119,16 @@ pub use tel::{ Iss, Rev, TEL_KERIPY_REVISION, TRAIT_NO_BACKERS, TelAnchorSeal, TelEvent, TelState, Vcp, encode_nonce as encode_tel_nonce, to_wire_bytes as tel_to_wire_bytes, validate_tel, }; -pub use tls_cert::{AUTHS_KERI_BINDING_OID, AuthsKeriBinding, DID_KERI_SCHEME, TlsCertError}; +pub use tls_cert::{ + AUTHS_KERI_BINDING_OID, AuthsKeriBinding, DID_KERI_SCHEME, TlsCertError, TlsKeyAuthorization, + TlsKeyAuthorizer, +}; #[cfg(feature = "tls-cert")] pub use tls_cert::{ - IssuedCert, extract_aid_from_san, extract_binding, extract_did_keri_san, issue_kel_rooted_cert, - issue_kel_rooted_cert_with_key, verify_binds_to_key_state, + IssuedCert, extract_aid_from_san, extract_binding, extract_did_keri_san, extract_spki_der, + issue_authorized_kel_rooted_cert, issue_authorized_kel_rooted_cert_with_key, + issue_kel_rooted_cert, issue_kel_rooted_cert_with_key, verify_authorized_against_key_state, + verify_binds_to_key_state, }; pub use types::{ CesrKey, ConfigTrait, Fraction, FractionError, KeriTypeError, Prefix, Said, Threshold, diff --git a/crates/auths-keri/src/tls_cert.rs b/crates/auths-keri/src/tls_cert.rs index 3eb8dc40..1769e1c9 100644 --- a/crates/auths-keri/src/tls_cert.rs +++ b/crates/auths-keri/src/tls_cert.rs @@ -27,17 +27,33 @@ //! embedded binding equals that state. Trust is rooted in the log: change a //! current key in the cert and replay no longer agrees, so the cert is rejected. //! -//! ## What this module is *not* +//! ## Unforgeability: the AID authorizes the TLS key //! //! The leaf carries its own ephemeral TLS keypair (so the long-term AID signing -//! key never goes on the wire). This module establishes the *binding to the KEL -//! key-state* and the *handshake interop*; it does **not** by itself make the -//! binding unforgeable against an attacker who fabricates a key-state — proving -//! the AID *authorized* this TLS key (a KERI signature over the leaf SPKI) and -//! rejecting revoked / KEL-invalid certs is the adversarial verifier's job and -//! lives elsewhere. Here the contract is: produce a KEL-rooted leaf stock stacks -//! accept, and verify a leaf binds to a given KEL's replayed state — both -//! directions, deterministically. +//! key never goes on the wire). The binding to the key-state alone is *not* +//! unforgeable: a stock self-signed leaf only proves possession of the *TLS* key, +//! and anyone replaying a public KEL could project the same key-state into a leaf +//! minted over *their own* TLS key. So a KEL-rooted leaf additionally carries a +//! [`TlsKeyAuthorization`] — a KERI signature, by one of the AID's *current* +//! signing keys, over the leaf's `SubjectPublicKeyInfo` DER. That signature is the +//! proof the AID authorized *this* TLS key: an attacker who never held the AID's +//! signing key cannot produce it, even with the full public KEL in hand. +//! +//! The adversarial verifier ([`verify_authorized_against_key_state`]) re-roots +//! trust in the log and rejects every forgery class: +//! +//! * **forged binding** — a leaf whose embedded key-state matches the replay but +//! whose TLS key the AID never signed → [`TlsCertError::Unauthorized`]; +//! * **stripped binding / authorization** — a plain leaf, or one missing the +//! authorization → [`TlsCertError::MissingBinding`] / [`TlsCertError::MissingAuthorization`]; +//! * **revoked / rotated AID** — a leaf whose embedded key-state diverges from a +//! fresh replay of the *current* KEL → [`TlsCertError::BindingMismatch`]; +//! * **SAN spoof** — a `did:keri` SAN that disagrees with the binding → +//! [`TlsCertError::SanMismatch`]. +//! +//! Relay/MITM (a proof lifted off one TLS channel and replayed on another) is +//! rejected one layer up, by the session's channel binding to the TLS exporter; +//! the cert proves *who*, the channel binding proves *which connection*. //! //! ## Parse, don't validate //! @@ -109,6 +125,53 @@ pub enum TlsCertError { /// The `did:keri` SAN was present but its AID is not a valid KERI prefix. #[error("did:keri SAN carries an invalid AID: {0}")] InvalidSanAid(#[from] crate::types::KeriTypeError), + + /// The certificate binds to a key-state but carries no [`TlsKeyAuthorization`] + /// — there is no proof the AID authorized this TLS key, so it is unforgeable + /// only if rejected (the adversarial verifier requires the authorization). + #[error("certificate carries no AID authorization over its TLS key")] + MissingAuthorization, + + /// The authorization names a current-key index outside the replayed key-state. + #[error("authorization key index {index} is out of range (key-state has {len} current keys)")] + AuthorizationIndexOutOfRange { + /// The out-of-range index the authorization claimed. + index: usize, + /// The number of current keys the replayed key-state actually has. + len: usize, + }, + + /// A current signing key named by the key-state could not be decoded when + /// checking the authorization (a malformed verkey reached the verifier). + #[error("decode authorizing key: {0}")] + AuthorizationKey(String), + + /// The authorization signature does not verify: the AID's current key did not + /// sign this leaf's TLS public key, so the AID never authorized it (a forged + /// binding, or a relayed/substituted leaf). + #[error("AID did not authorize this TLS key: {0}")] + Unauthorized(String), +} + +/// A KERI signature, by one of the AID's *current* signing keys, over the leaf's +/// `SubjectPublicKeyInfo` DER — the proof the AID **authorized** this TLS key. +/// +/// Without it, a KEL-rooted leaf only proves possession of the *TLS* key; with +/// it, the leaf proves the AID's controller (the holder of a current signing key) +/// bound *that specific* TLS key to the AID. Parse, don't validate: the signature +/// is held as raw bytes (hex on the wire) and `key_index` is the position in the +/// key-state's `current_keys` whose signature this is; an out-of-range index is an +/// error at verification, never a silent skip. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TlsKeyAuthorization { + /// The index, in the key-state's `current_keys`, of the signing key that + /// produced `signature`. (Single-sig today; the index makes multi-sig + /// authorization a forward-compatible extension, not a reshape.) + pub key_index: usize, + /// The detached signature over the leaf's `SubjectPublicKeyInfo` DER, raw + /// bytes (serialized as hex inside the canonical JSON). + #[serde(with = "hex::serde")] + pub signature: Vec, } /// The AID key-state a KEL-rooted certificate embeds — the projection of a KEL @@ -127,10 +190,17 @@ pub struct AuthsKeriBinding { /// The SAID of the KEL tip the binding was projected from — the exact log /// position whose replay must reproduce `current_keys`. pub kel_tip: String, + /// The AID's authorization over the leaf's TLS key, when present. `None` for a + /// binding that names only the key-state (the discovery / identity surface); + /// the adversarial verifier ([`verify_authorized_against_key_state`]) requires + /// it, so an unauthorized leaf cannot pass the security check. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls_key_authorization: Option, } impl AuthsKeriBinding { - /// Project a resolved [`KeyState`] into a certificate binding. + /// Project a resolved [`KeyState`] into a certificate binding (no + /// authorization yet — the key-state projection only). /// /// Every current key is decoded first (parse, don't validate), so a binding /// is only ever built from keys that are valid for their curve — an @@ -147,9 +217,18 @@ impl AuthsKeriBinding { aid: state.prefix.as_str().to_string(), current_keys, kel_tip: state.last_event_said.as_str().to_string(), + tls_key_authorization: None, }) } + /// The same projection, carrying the AID's authorization over the leaf's TLS + /// key. This is the binding a KEL-rooted leaf embeds once the AID has signed + /// its `SubjectPublicKeyInfo` DER. + pub fn with_authorization(mut self, authorization: TlsKeyAuthorization) -> Self { + self.tls_key_authorization = Some(authorization); + self + } + /// The `did:keri:` URI this binding's AID resolves to. pub fn did_keri(&self) -> String { format!("{DID_KERI_SCHEME}{}", self.aid) @@ -169,6 +248,50 @@ impl AuthsKeriBinding { } } +/// A port: signs the leaf's `SubjectPublicKeyInfo` DER with one of the AID's +/// *current* signing keys, producing the authorization that proves the AID bound +/// this TLS key. The core never imports a concrete key store — an adapter (a +/// keychain-backed signer, a held seed) supplies the signature. +/// +/// The contract: [`sign_tls_key`](TlsKeyAuthorizer::sign_tls_key) returns the +/// detached signature over `spki_der`, and [`current_key_index`] is the position +/// in the key-state's `current_keys` of the public key that signature verifies +/// against. The verifier checks the signature against exactly that key, so an +/// adapter that lies about either is caught at [`verify_authorized_against_key_state`]. +pub trait TlsKeyAuthorizer { + /// The index, in the key-state's `current_keys`, of the signing key used. + fn current_key_index(&self) -> usize; + /// Sign the leaf's `SubjectPublicKeyInfo` DER, returning the raw signature. + fn sign_tls_key(&self, spki_der: &[u8]) -> Result, TlsCertError>; +} + +/// Verify a [`TlsKeyAuthorization`] against a binding's `current_keys` and the +/// leaf's `SubjectPublicKeyInfo` DER — the unforgeability check. +/// +/// The authorization must (1) name an in-range current-key index, (2) reference a +/// decodable verkey, and (3) carry a signature that verifies, under that key, over +/// the leaf's SPKI DER. Any failure is a rejection: the AID did not authorize this +/// TLS key. Shared by every verify direction so there is one source of truth for +/// "the AID signed this leaf." Only the cert backend (the `tls-cert` feature) +/// extracts an SPKI to check, so it is gated alongside it. +#[cfg(feature = "tls-cert")] +fn check_tls_key_authorization( + authorization: &TlsKeyAuthorization, + current_keys: &[String], + spki_der: &[u8], +) -> Result<(), TlsCertError> { + let key_str = current_keys.get(authorization.key_index).ok_or( + TlsCertError::AuthorizationIndexOutOfRange { + index: authorization.key_index, + len: current_keys.len(), + }, + )?; + let key = + KeriPublicKey::parse(key_str).map_err(|e| TlsCertError::AuthorizationKey(e.to_string()))?; + key.verify_signature(spki_der, &authorization.signature) + .map_err(TlsCertError::Unauthorized) +} + #[cfg(feature = "tls-cert")] mod backend { use super::*; @@ -176,7 +299,7 @@ mod backend { use rcgen::string::Ia5String; use rcgen::{ CertificateParams, CustomExtension, DnType, ExtendedKeyUsagePurpose, KeyPair, - KeyUsagePurpose, SanType, + KeyUsagePurpose, PublicKeyData, SanType, }; /// A freshly issued KEL-rooted leaf certificate plus its private key. @@ -227,7 +350,74 @@ mod backend { issue_with_keypair(&binding, &key_pair, extra_sans) } - fn issue_with_keypair( + /// Issue a KEL-rooted leaf whose TLS key the AID has **authorized**. + /// + /// Generates a fresh ephemeral TLS keypair, has `authorizer` sign that key's + /// `SubjectPublicKeyInfo` DER with one of the AID's current signing keys, and + /// embeds the resulting [`TlsKeyAuthorization`] in the binding. The leaf is + /// then unforgeable: only the AID's controller can produce the authorization, + /// so an attacker replaying the public KEL cannot mint a leaf over a TLS key of + /// their own choosing. + pub fn issue_authorized_kel_rooted_cert( + state: &KeyState, + authorizer: &dyn TlsKeyAuthorizer, + extra_sans: &[String], + ) -> Result { + let key_pair = + KeyPair::generate().map_err(|e| TlsCertError::KeyPair(format!("generate: {e}")))?; + issue_authorized_with_keypair(state, authorizer, &key_pair, extra_sans) + } + + /// The deterministic-key counterpart of [`issue_authorized_kel_rooted_cert`]: + /// authorize and issue over an existing PKCS#8-PEM TLS keypair. + pub fn issue_authorized_kel_rooted_cert_with_key( + state: &KeyState, + authorizer: &dyn TlsKeyAuthorizer, + tls_key_pkcs8_pem: &str, + extra_sans: &[String], + ) -> Result { + let key_pair = KeyPair::from_pem(tls_key_pkcs8_pem) + .map_err(|e| TlsCertError::KeyPair(format!("from pem: {e}")))?; + issue_authorized_with_keypair(state, authorizer, &key_pair, extra_sans) + } + + fn issue_authorized_with_keypair( + state: &KeyState, + authorizer: &dyn TlsKeyAuthorizer, + key_pair: &KeyPair, + extra_sans: &[String], + ) -> Result { + let base = AuthsKeriBinding::from_key_state(state)?; + + // The leaf's SubjectPublicKeyInfo DER — the exact bytes the verifier reads + // back out of the parsed certificate. Signing these binds the AID to *this* + // TLS key (one source of truth for "what the AID signed"). + let spki_der = key_pair.subject_public_key_info(); + let key_index = authorizer.current_key_index(); + if base.current_keys.get(key_index).is_none() { + return Err(TlsCertError::AuthorizationIndexOutOfRange { + index: key_index, + len: base.current_keys.len(), + }); + } + let signature = authorizer.sign_tls_key(&spki_der)?; + let authorization = TlsKeyAuthorization { + key_index, + signature, + }; + // Reject an authorizer that signed with a key that doesn't match its + // claimed current key before the leaf ever leaves the issuer. + check_tls_key_authorization(&authorization, &base.current_keys, &spki_der)?; + + let binding = base.with_authorization(authorization); + issue_with_keypair(&binding, key_pair, extra_sans) + } + + /// Mint a leaf embedding `binding` over `key_pair`. `pub(crate)` so the + /// crate's adversarial tests can craft a leaf with a hand-built (e.g. forged) + /// binding; production callers go through the `issue_*` entry points which + /// build the binding from a replayed key-state. + pub(crate) fn issue_with_keypair( binding: &AuthsKeriBinding, key_pair: &KeyPair, extra_sans: &[String], @@ -367,20 +557,29 @@ mod backend { } } - /// Verify a KEL-rooted certificate against an AID's KEL key-state. - /// - /// The peer→auths direction: parse the cert, read its embedded binding *and* - /// its `did:keri` SAN, then assert both agree with `state` — the freshly - /// replayed key-state of the KEL the verifier holds. Trust is rooted in the - /// log: a cert whose embedded key-state diverges from a real replay is - /// rejected ([`TlsCertError::BindingMismatch`]). - pub fn verify_binds_to_key_state( - cert_pem: &str, - state: &KeyState, - ) -> Result { - let expected = AuthsKeriBinding::from_key_state(state)?; - let embedded = extract_binding(cert_pem)?; + /// Read the leaf's `SubjectPublicKeyInfo` DER — the exact bytes the AID's + /// authorization signature covers. Re-derived from the parsed certificate, so + /// the verifier signs/checks over the canonical encoding (not a re-serialized + /// approximation). + pub fn extract_spki_der(cert_pem: &str) -> Result, TlsCertError> { + use x509_parser::prelude::*; + + let (_, pem) = x509_parser::pem::parse_x509_pem(cert_pem.as_bytes()) + .map_err(|e| TlsCertError::ParseCert(format!("PEM: {e}")))?; + let (_, cert) = X509Certificate::from_der(&pem.contents) + .map_err(|e| TlsCertError::ParseCert(format!("DER: {e}")))?; + Ok(cert.public_key().raw.to_vec()) + } + /// Assert a parsed binding and the cert's SAN both agree with the replayed + /// key-state. The shared "the leaf chains to the log" check, with no + /// authorization — used directly by [`verify_binds_to_key_state`] and as the + /// first half of [`verify_authorized_against_key_state`]. + fn check_binds_to_key_state( + cert_pem: &str, + embedded: &AuthsKeriBinding, + expected: &AuthsKeriBinding, + ) -> Result<(), TlsCertError> { if embedded.aid != expected.aid { return Err(TlsCertError::BindingMismatch(format!( "AID {} in cert != {} from KEL replay", @@ -403,7 +602,7 @@ mod backend { // must agree with the binding, or a tool reading only the SAN would trust // a different AID than the one the binding (and the KEL) attest. match extract_did_keri_san(cert_pem)? { - Some(san) if san == expected.did_keri() => Ok(embedded), + Some(san) if san == expected.did_keri() => Ok(()), Some(san) => Err(TlsCertError::SanMismatch(format!( "SAN {san} != {}", expected.did_keri() @@ -414,6 +613,64 @@ mod backend { } } + /// Verify a KEL-rooted certificate against an AID's KEL key-state. + /// + /// The peer→auths direction: parse the cert, read its embedded binding *and* + /// its `did:keri` SAN, then assert both agree with `state` — the freshly + /// replayed key-state of the KEL the verifier holds. Trust is rooted in the + /// log: a cert whose embedded key-state diverges from a real replay is + /// rejected ([`TlsCertError::BindingMismatch`]). + /// + /// This checks the leaf *chains to* the log; it does **not** check the AID + /// authorized the leaf's TLS key. For the adversarial guarantee (rejecting a + /// forged binding minted over an attacker's TLS key) use + /// [`verify_authorized_against_key_state`]. + pub fn verify_binds_to_key_state( + cert_pem: &str, + state: &KeyState, + ) -> Result { + let expected = AuthsKeriBinding::from_key_state(state)?; + let embedded = extract_binding(cert_pem)?; + check_binds_to_key_state(cert_pem, &embedded, &expected)?; + Ok(embedded) + } + + /// The adversarial verifier (T3): a leaf passes only if it chains to the log + /// **and** the AID authorized its TLS key. + /// + /// On top of [`verify_binds_to_key_state`], this requires the embedded + /// [`TlsKeyAuthorization`] and checks it against the leaf's + /// `SubjectPublicKeyInfo` DER and the *replayed* current keys. The rejection + /// classes, each a distinct error: + /// + /// * a plain leaf (no extension) → [`TlsCertError::MissingBinding`]; + /// * a leaf whose key-state diverges from the replay (revoked / rotated AID) → + /// [`TlsCertError::BindingMismatch`]; + /// * a leaf whose SAN disagrees with the binding → [`TlsCertError::SanMismatch`]; + /// * a leaf with no authorization (stripped) → [`TlsCertError::MissingAuthorization`]; + /// * a leaf whose authorization does not verify under a current key — a forged + /// binding minted over a TLS key the AID never signed → + /// [`TlsCertError::Unauthorized`]. + pub fn verify_authorized_against_key_state( + cert_pem: &str, + state: &KeyState, + ) -> Result { + let expected = AuthsKeriBinding::from_key_state(state)?; + let embedded = extract_binding(cert_pem)?; + check_binds_to_key_state(cert_pem, &embedded, &expected)?; + + let authorization = embedded + .tls_key_authorization + .as_ref() + .ok_or(TlsCertError::MissingAuthorization)?; + let spki_der = extract_spki_der(cert_pem)?; + // Check against the *replayed* current keys, not the embedded ones: the + // binding's keys were already asserted equal to the replay, but rooting the + // authorization check in the replay keeps the log the single source of truth. + check_tls_key_authorization(authorization, &expected.current_keys, &spki_der)?; + Ok(embedded) + } + /// Render an OID arc as the dotted string `x509-parser` exposes. fn oid_string(arc: &[u64]) -> String { arc.iter() @@ -431,8 +688,10 @@ mod backend { #[cfg(feature = "tls-cert")] pub use backend::{ - IssuedCert, extract_aid_from_san, extract_binding, extract_did_keri_san, issue_kel_rooted_cert, - issue_kel_rooted_cert_with_key, verify_binds_to_key_state, + IssuedCert, extract_aid_from_san, extract_binding, extract_did_keri_san, extract_spki_der, + issue_authorized_kel_rooted_cert, issue_authorized_kel_rooted_cert_with_key, + issue_kel_rooted_cert, issue_kel_rooted_cert_with_key, verify_authorized_against_key_state, + verify_binds_to_key_state, }; #[cfg(test)] @@ -516,6 +775,10 @@ mod tests { #[cfg(feature = "tls-cert")] mod backend_tests { use super::*; + // The crate-internal mint-with-binding entry point, for crafting leaves + // with hand-built (forged / out-of-range) bindings in the adversarial tests. + use crate::tls_cert::backend::issue_with_keypair; + use rcgen::PublicKeyData; fn multi_state() -> (KeyState, Vec) { let k1 = ed25519_key(&[1u8; 32]); @@ -528,6 +791,48 @@ mod tests { (st, vec![k1, k2]) } + /// A test [`TlsKeyAuthorizer`] backed by a ring Ed25519 keypair. Signs the + /// SPKI directly (no `native` feature needed for the crate's own tests). + struct Ed25519Authorizer { + keypair: ring::signature::Ed25519KeyPair, + key_index: usize, + } + + impl Ed25519Authorizer { + fn from_seed(seed: &[u8; 32], key_index: usize) -> Self { + let keypair = ring::signature::Ed25519KeyPair::from_seed_unchecked(seed).unwrap(); + Self { keypair, key_index } + } + /// The CESR-qualified current key string this authorizer's key occupies. + fn cesr_key(&self) -> String { + use ring::signature::KeyPair; + let raw: [u8; 32] = self.keypair.public_key().as_ref().try_into().unwrap(); + ed25519_key(&raw) + } + } + + impl TlsKeyAuthorizer for Ed25519Authorizer { + fn current_key_index(&self) -> usize { + self.key_index + } + fn sign_tls_key(&self, spki_der: &[u8]) -> Result, TlsCertError> { + Ok(self.keypair.sign(spki_der).as_ref().to_vec()) + } + } + + /// A single-key key-state whose current key is `auth`'s public key, plus a + /// matching authorizer at index 0. The standard authorized-cert fixture. + fn authorized_state(seed: &[u8; 32]) -> (KeyState, Ed25519Authorizer) { + let auth = Ed25519Authorizer::from_seed(seed, 0); + let key = auth.cesr_key(); + let st = state( + "EAidAuthAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + &[&key], + "ETipAuthAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + (st, auth) + } + #[test] fn issued_cert_carries_binding_and_san() { let (st, keys) = multi_state(); @@ -653,5 +958,173 @@ mod tests { extract_binding(&b.cert_pem).unwrap() ); } + + // --- T3 adversarial verifier: the AID authorizes the TLS key --- + + #[test] + fn authorized_cert_carries_authorization_and_verifies() { + // The happy path: an AID-authorized leaf carries the authorization and + // passes the adversarial verifier against its own replayed key-state. + let (st, auth) = authorized_state(&[7u8; 32]); + let issued = + issue_authorized_kel_rooted_cert(&st, &auth, &["localhost".to_string()]).unwrap(); + + let embedded = extract_binding(&issued.cert_pem).unwrap(); + assert!( + embedded.tls_key_authorization.is_some(), + "an authorized leaf must embed the authorization" + ); + + let binding = verify_authorized_against_key_state(&issued.cert_pem, &st).unwrap(); + assert_eq!(binding.aid, st.prefix.as_str()); + } + + #[test] + fn forged_binding_over_attackers_tls_key_is_rejected() { + // The core forgery: an attacker replays the victim's *public* KEL, so + // the binding's key-state matches a real replay — but mints the leaf + // over their own TLS key with no valid authorization. The adversarial + // verifier rejects it (the AID never signed this TLS key), even though + // the key-state binding alone would "match". + let (st, _auth) = authorized_state(&[7u8; 32]); + + // Attacker forges a binding that names the correct key-state but signs + // the SPKI with a key they DO hold — which is not the AID's key. + let attacker = Ed25519Authorizer::from_seed(&[99u8; 32], 0); + let forged_kp = rcgen::KeyPair::generate().unwrap(); + let spki = forged_kp.subject_public_key_info(); + let forged_sig = attacker.sign_tls_key(&spki).unwrap(); + // Build the cert by hand: correct key-state binding (matches replay), + // but the embedded authorization is the attacker's signature. + let binding = AuthsKeriBinding::from_key_state(&st) + .unwrap() + .with_authorization(TlsKeyAuthorization { + key_index: 0, + signature: forged_sig, + }); + let issued = + issue_with_keypair(&binding, &forged_kp, &["localhost".to_string()]).unwrap(); + + // The key-state binding "matches" the replay (forged from the public KEL)... + assert!(verify_binds_to_key_state(&issued.cert_pem, &st).is_ok()); + // ...but the AID's current key did not sign this TLS SPKI → Unauthorized. + assert!(matches!( + verify_authorized_against_key_state(&issued.cert_pem, &st), + Err(TlsCertError::Unauthorized(_)) + )); + } + + #[test] + fn stripped_authorization_is_rejected() { + // A leaf that chains to the key-state but carries NO authorization (the + // discovery-only binding) is rejected by the adversarial verifier — a + // KEL-rooted leaf must prove the AID authorized its TLS key. + let (st, _auth) = authorized_state(&[7u8; 32]); + let unauthorized = issue_kel_rooted_cert(&st, &["localhost".to_string()]).unwrap(); + assert!(matches!( + verify_authorized_against_key_state(&unauthorized.cert_pem, &st), + Err(TlsCertError::MissingAuthorization) + )); + } + + #[test] + fn stripped_binding_plain_cert_is_rejected() { + // A plain self-signed leaf (no binding extension at all) is rejected — + // MissingBinding, before any authorization check. + let (st, _auth) = authorized_state(&[7u8; 32]); + let kp = rcgen::KeyPair::generate().unwrap(); + let params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + let plain = params.self_signed(&kp).unwrap(); + assert!(matches!( + verify_authorized_against_key_state(&plain.pem(), &st), + Err(TlsCertError::MissingBinding) + )); + } + + #[test] + fn revoked_or_rotated_aid_is_rejected() { + // A leaf authorized under the AID's *old* key-state is rejected once the + // verifier replays the *current* KEL (rotated/revoked): the binding's + // key-state no longer matches the replay → BindingMismatch, before the + // authorization is even checked. + let (old_state, auth) = authorized_state(&[7u8; 32]); + let issued = + issue_authorized_kel_rooted_cert(&old_state, &auth, &["localhost".to_string()]) + .unwrap(); + + // The current key-state after a rotation: a different current key. + let rotated_key = ed25519_key(&[8u8; 32]); + let current_state = state( + old_state.prefix.as_str(), + &[&rotated_key], + "ETipRotatedAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + assert!(matches!( + verify_authorized_against_key_state(&issued.cert_pem, ¤t_state), + Err(TlsCertError::BindingMismatch(_)) + )); + } + + #[test] + fn authorization_with_out_of_range_index_is_rejected() { + // An authorization that names a current-key index the key-state does + // not have is rejected, not silently skipped. + let (st, auth) = authorized_state(&[7u8; 32]); + let kp = rcgen::KeyPair::generate().unwrap(); + let spki = kp.subject_public_key_info(); + let sig = auth.sign_tls_key(&spki).unwrap(); + let binding = AuthsKeriBinding::from_key_state(&st) + .unwrap() + .with_authorization(TlsKeyAuthorization { + key_index: 5, // out of range: single-key state + signature: sig, + }); + let issued = issue_with_keypair(&binding, &kp, &["localhost".to_string()]).unwrap(); + assert!(matches!( + verify_authorized_against_key_state(&issued.cert_pem, &st), + Err(TlsCertError::AuthorizationIndexOutOfRange { index: 5, len: 1 }) + )); + } + + #[test] + fn issuer_rejects_authorizer_signing_with_wrong_key() { + // Defense at issuance: an authorizer whose signing key does not match + // the current key it claims is caught before the leaf is emitted, so a + // miswired adapter can't mint a leaf that will only fail at the verifier. + let (st, _auth) = authorized_state(&[7u8; 32]); + // An authorizer at index 0 but holding a key that is NOT the state's key. + let wrong = Ed25519Authorizer::from_seed(&[42u8; 32], 0); + assert!(matches!( + issue_authorized_kel_rooted_cert(&st, &wrong, &["localhost".to_string()]), + Err(TlsCertError::Unauthorized(_)) + )); + } + + #[test] + fn issuer_rejects_out_of_range_authorizer_index() { + // An authorizer claiming a key index the state lacks is rejected at + // issuance, before signing. + let (st, _auth) = authorized_state(&[7u8; 32]); + let bad_index = Ed25519Authorizer::from_seed(&[7u8; 32], 9); + assert!(matches!( + issue_authorized_kel_rooted_cert(&st, &bad_index, &["localhost".to_string()]), + Err(TlsCertError::AuthorizationIndexOutOfRange { index: 9, len: 1 }) + )); + } + + #[test] + fn authorization_round_trips_through_binding_json() { + // The authorization survives canonical-JSON round-trip (the wire form + // inside the cert extension), so a verifier reads back exactly what the + // issuer embedded. + let (st, auth) = authorized_state(&[7u8; 32]); + let issued = + issue_authorized_kel_rooted_cert(&st, &auth, &["localhost".to_string()]).unwrap(); + let embedded = extract_binding(&issued.cert_pem).unwrap(); + let json = embedded.to_canonical_json(); + let back = AuthsKeriBinding::from_canonical_json(&json).unwrap(); + assert_eq!(back, embedded); + assert!(back.tls_key_authorization.is_some()); + } } } From 66b2bc12c784eaeddec669d9b38892461f453279 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 14:31:05 +0100 Subject: [PATCH 18/19] feat(quic): carry the KEL-rooted leaf + channel binding over QUIC/HTTP3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QUIC runs the same TLS 1.3 handshake inside its CRYPTO frames, so a KERI identity composes with QUIC — and therefore HTTP/3 — through exactly the two mechanisms it already composes with TLS over TCP: the KEL-rooted leaf the server presents (re-rooted by replaying the KEL, trust in the log not a CA) and the per-connection channel binding both endpoints export from the connection's TLS 1.3 secrets (RFC 5705; both ends agree, two connections differ → anti-relay). A new `quic` feature on auths-keri adds the QUIC adapter for those two mechanisms (quic_transport): server/client configs built from the KEL-rooted leaf, the quinn::Connection keying-material exporter, and a real loopback driver. `auths tls-cert quic --from-kel` is the thin CLI adapter — it serves the leaf over a loopback QUIC endpoint, completes the handshake (ALPN h3), re-roots the served leaf in the replayed KEL, and proves both endpoints derive the same channel binding. Quinn's public exporter API has no RFC 9266 "absent context" form, so the two auths QUIC endpoints agree on the registered EXPORTER-Channel-Binding label plus an explicit auths-quic context; the per-connection anti-relay property holds regardless, and the distinct context keeps a QUIC and a TCP binding for the same secrets from colliding. quic is off by default — the core KERI types carry no QUIC dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + crates/auths-cli/Cargo.toml | 5 +- crates/auths-cli/src/commands/tls_cert.rs | 75 +++- crates/auths-keri/Cargo.toml | 12 + crates/auths-keri/src/lib.rs | 10 + crates/auths-keri/src/quic_transport.rs | 518 ++++++++++++++++++++++ 6 files changed, 614 insertions(+), 7 deletions(-) create mode 100644 crates/auths-keri/src/quic_transport.rs diff --git a/Cargo.lock b/Cargo.lock index b5f30e3a..a0c3b679 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -687,6 +687,7 @@ dependencies = [ "hex", "p256 0.13.2", "proptest", + "quinn", "rcgen", "ring", "schemars", diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index 16649833..819fb780 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -25,8 +25,9 @@ path = "src/bin/verify.rs" [dependencies] # `tls-cert` powers `auths tls-cert` (KEL-rooted X.509 leaf certs for TLS -# composition); the wire/crypto definition lives in auths-keri. -auths-keri = { workspace = true, features = ["tls-cert"] } +# composition); `quic` carries the same leaf + channel binding over QUIC/HTTP3. +# The wire/crypto definition lives in auths-keri. +auths-keri = { workspace = true, features = ["tls-cert", "quic"] } clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" colored = "3.1.1" diff --git a/crates/auths-cli/src/commands/tls_cert.rs b/crates/auths-cli/src/commands/tls_cert.rs index 9fb23b8c..aa127fb5 100644 --- a/crates/auths-cli/src/commands/tls_cert.rs +++ b/crates/auths-cli/src/commands/tls_cert.rs @@ -34,9 +34,10 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use auths_crypto::TypedSignerKey; use auths_keri::{ - KeyState, TlsCertError, TlsKeyAuthorizer, TrustedKel, extract_aid_from_san, - issue_authorized_kel_rooted_cert, issue_authorized_kel_rooted_cert_with_key, - issue_kel_rooted_cert, issue_kel_rooted_cert_with_key, parse_kel_json, + IssuedCert, KeyState, QuicLoopbackOutcome, TlsCertError, TlsKeyAuthorizer, TrustedKel, + extract_aid_from_san, issue_authorized_kel_rooted_cert, + issue_authorized_kel_rooted_cert_with_key, issue_kel_rooted_cert, + issue_kel_rooted_cert_with_key, parse_kel_json, quic_loopback_compose, verify_authorized_against_key_state, }; use auths_utils::path::expand_tilde; @@ -82,12 +83,13 @@ fn load_signer(key_path: &Path) -> Result { /// Issue or verify a KEL-rooted X.509 certificate (TLS composition). #[derive(Parser, Debug, Clone)] #[command( - about = "Issue/verify a KEL-rooted X.509 cert and read its did:keri subjectAltName — an auths identity that stock TLS stacks (rustls/openssl/go) handshake with", + about = "Issue/verify a KEL-rooted X.509 cert, read its did:keri subjectAltName, and carry it over TLS or QUIC/HTTP3 — an auths identity that stock TLS stacks (rustls/openssl/go) handshake with", after_help = "Examples: auths tls-cert issue --from-kel kel.json --sign-key aid.key.pem --san localhost # AID-authorized leaf auths tls-cert issue --from-kel kel.json --sign-key aid.key.pem --out leaf # writes leaf.cert.pem + leaf.key.pem auths tls-cert identity --cert leaf.cert.pem # read the did:keri AID out of the SAN - auths tls-cert verify --cert leaf.cert.pem --from-kel kel.json # adversarial: rejects forged/revoked/stripped" + auths tls-cert verify --cert leaf.cert.pem --from-kel kel.json # adversarial: rejects forged/revoked/stripped + auths tls-cert quic --from-kel kel.json # carry the leaf + channel binding over QUIC/HTTP3" )] pub struct TlsCertCommand { /// The direction to run: issue our leaf, or verify a peer's. @@ -104,6 +106,8 @@ pub enum TlsCertAction { Identity(IdentityArgs), /// Verify a peer's leaf binds to the KEL we hold (peer → us). Verify(VerifyArgs), + /// Carry the KEL-rooted leaf + channel binding over a QUIC/HTTP3 transport. + Quic(QuicArgs), } /// `auths tls-cert issue` — mint a KEL-rooted leaf for one of our AIDs. @@ -166,6 +170,25 @@ pub struct VerifyArgs { pub from_kel: PathBuf, } +/// `auths tls-cert quic` — carry the same composition over QUIC/HTTP3. +/// +/// QUIC runs the same TLS 1.3 handshake inside its CRYPTO frames, so a KERI +/// identity composes with QUIC — and therefore HTTP/3 — through exactly the same +/// two mechanisms it composes with TLS-over-TCP: the KEL-rooted leaf the server +/// presents, and the per-connection channel binding both endpoints export from +/// the connection's TLS 1.3 secrets. This stands up a real loopback QUIC +/// connection, serves the leaf over it, and proves both — the client re-roots the +/// served leaf in the replayed KEL, and both endpoints derive the same channel +/// binding (a proof bound to it cannot be relayed onto a different connection). +#[derive(Parser, Debug, Clone)] +pub struct QuicArgs { + /// Replay this KEL and serve its KEL-rooted leaf over the QUIC handshake. The + /// KEL is the root of trust the client re-derives by replay — over QUIC + /// exactly as over TCP. + #[clap(long, value_name = "KEL.json")] + pub from_kel: PathBuf, +} + impl TlsCertCommand { /// Run the requested direction. pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { @@ -173,6 +196,7 @@ impl TlsCertCommand { TlsCertAction::Issue(args) => args.run(), TlsCertAction::Identity(args) => args.run(), TlsCertAction::Verify(args) => args.run(), + TlsCertAction::Quic(args) => args.run(), } } } @@ -311,6 +335,47 @@ impl VerifyArgs { } } +impl QuicArgs { + fn run(&self) -> Result<()> { + let state = replay_kel(&self.from_kel)?; + // Mint the KEL-rooted leaf the QUIC server presents (localhost SANs so the + // loopback handshake is valid for the transport host). + let leaf = issue_kel_rooted_cert(&state, &["localhost".to_string(), "::1".to_string()]) + .map_err(|e| anyhow!("issue KEL-rooted leaf for QUIC: {e}"))?; + + // The loopback driver is a single Tokio runtime turn: stand up a QUIC + // endpoint, serve the leaf, connect a client, complete the TLS 1.3 + // handshake inside QUIC, and prove the composition (leaf re-roots in the + // KEL, both ends agree on the channel binding). The transport plumbing + // lives in auths-keri; this command is the adapter. + let outcome = run_quic_loopback(&leaf, &state)?; + + println!( + "QUIC/HTTP3 composition verified — the KEL-rooted leaf and channel binding carry over QUIC\n did:keri: {}\n ALPN: h3 (HTTP/3)\n served leaf re-rooted in the replayed KEL: yes\n both endpoints derive the same channel binding (anti-relay): {}\n channel binding: {} ({} bytes, per-connection)", + outcome.did_keri, + if outcome.binding_agrees { "yes" } else { "no" }, + outcome.channel_binding_hex, + outcome.channel_binding_len, + ); + Ok(()) + } +} + +/// Drive the QUIC loopback composition on a fresh Tokio runtime. +/// +/// `auths tls-cert` is otherwise synchronous; QUIC needs an async reactor, so we +/// spin a current-thread runtime for this one command rather than make the whole +/// CLI async. The transport itself lives behind [`quic_loopback_compose`] in +/// `auths-keri` — this is just the runtime adapter. +fn run_quic_loopback(leaf: &IssuedCert, state: &KeyState) -> Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| anyhow!("build QUIC runtime: {e}"))?; + rt.block_on(quic_loopback_compose(&leaf.cert_pem, &leaf.key_pem, state)) + .map_err(|e| anyhow!("QUIC composition failed: {e}")) +} + /// `prefix` + `.suffix`, preserving any directory in `prefix`. fn with_suffix(prefix: &Path, suffix: &str) -> PathBuf { let name = prefix diff --git a/crates/auths-keri/Cargo.toml b/crates/auths-keri/Cargo.toml index 9f2eb292..65c33a47 100644 --- a/crates/auths-keri/Cargo.toml +++ b/crates/auths-keri/Cargo.toml @@ -34,6 +34,14 @@ rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem x509-parser = { version = "0.18", optional = true } yasna = { version = "0.5", optional = true } zeroize = { workspace = true, optional = true } +# QUIC/HTTP3 transport (the `quic` feature): carries the KEL-rooted leaf + the +# RFC 9266 exporter channel binding over QUIC's TLS 1.3 handshake. quinn brings +# its own rustls (aws-lc-rs) TLS 1.3 stack, the same provider the rest of auths +# uses. Off by default — the core KERI types carry no QUIC dependency. +quinn = { version = "0.11", default-features = false, features = ["rustls-aws-lc-rs", "runtime-tokio"], optional = true } +# The QUIC loopback driver awaits the server-accept and client-connect halves of +# one handshake concurrently (`tokio::try_join!`); quinn's runtime is tokio. +tokio = { workspace = true, optional = true } [features] default = [] @@ -49,6 +57,10 @@ seal-extensions = [] # (did:keri SAN + binding extension), issued/verified against a replayed # key-state. Pulls in rcgen/x509-parser; the core crate stays TLS-free without it. tls-cert = ["dep:rcgen", "dep:x509-parser", "dep:yasna", "dep:zeroize"] +# QUIC/HTTP3 transport for the KEL-rooted TLS composition: the same leaf + the +# RFC 9266 exporter channel binding, carried over QUIC's TLS 1.3 handshake. +# Builds on `tls-cert` (the leaf it serves) and pulls in quinn's TLS 1.3 stack. +quic = ["tls-cert", "dep:quinn", "dep:tokio"] [dev-dependencies] proptest = "1.4" diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index a406ff29..db320690 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -55,6 +55,10 @@ pub mod ksn; pub mod messages; /// Out-Of-Band Introduction (OOBI) — KERI discovery: resolve/serve AID endpoints. pub mod oobi; +/// QUIC/HTTP3 transport for the KEL-rooted TLS composition — the same leaf + +/// RFC 9266 exporter channel binding, carried over QUIC's TLS 1.3 handshake. +#[cfg(feature = "quic")] +pub mod quic_transport; mod said; mod state; /// Backerless TEL (Transaction Event Log) credential-status events: `vcp`/`iss`/`rev`. @@ -110,6 +114,12 @@ pub use oobi::{ EndRoleReply, LocSchemeReply, Oobi, OobiEndpoint, OobiError, OobiResolution, Role, ingest_oobi_stream, }; +#[cfg(feature = "quic")] +pub use quic_transport::{ + QUIC_EXPORTER_CONTEXT, QUIC_EXPORTER_LABEL, QUIC_EXPORTER_LEN, QuicLoopbackOutcome, + QuicTransportError, quic_channel_binding, quic_client_config, quic_loopback_compose, + quic_server_config, +}; pub use said::{ Protocol, SAID_PLACEHOLDER, compute_said, compute_said_with_protocol, compute_section_said, verify_said, diff --git a/crates/auths-keri/src/quic_transport.rs b/crates/auths-keri/src/quic_transport.rs new file mode 100644 index 00000000..d9cfa4fb --- /dev/null +++ b/crates/auths-keri/src/quic_transport.rs @@ -0,0 +1,518 @@ +//! HTTP/3-grade QUIC transport for the KEL-rooted TLS composition. +//! +//! The KEL-rooted X.509 leaf ([`crate::tls_cert`]) and the per-connection +//! channel binding it carries are not specific to TLS-over-TCP: QUIC runs the +//! **same TLS 1.3 handshake** inside its CRYPTO frames (RFC 9001), so an auths +//! identity composes with QUIC — and therefore HTTP/3 — through exactly the same +//! two mechanisms it composes with TLS: +//! +//! * **the leaf certificate** — a QUIC server presents the KEL-rooted leaf in its +//! TLS 1.3 handshake just as a TLS-over-TCP server would; an auths-aware peer +//! re-roots trust by replaying the KEL ([`crate::verify_binds_to_key_state`] / +//! [`crate::verify_authorized_against_key_state`]). Trust lives in the log, not +//! in a CA — over QUIC exactly as over TCP. +//! * **the channel binding** — both endpoints of one QUIC connection export the +//! same keying material from the connection's TLS 1.3 secrets ([RFC 5705] +//! exporter), so a possession proof folded against that material opens only on +//! the connection that minted it. A proof captured on one QUIC connection and +//! relayed onto another is rejected, the same anti-relay property the TCP path +//! already has. +//! +//! This module is the QUIC **adapter** for those mechanisms. It builds a +//! [`quinn`] server config from a KEL-rooted leaf + key and a client config that +//! completes the handshake, and it exposes [`quic_channel_binding`] — +//! the [`quinn::Connection`] side of the keying-material exporter, the QUIC +//! counterpart of the rustls-over-TCP exporter the pairing daemon already uses. +//! +//! # Channel-binding parameters over QUIC (NORMATIVE) +//! +//! RFC 9266 `tls-exporter` specifies an *absent* exporter context, but the QUIC +//! public exporter API ([`quinn::Connection::export_keying_material`]) always +//! passes a context (it has no "absent" form). Both endpoints of an auths QUIC +//! connection are auths-aware, so they agree on a fixed label + context for their +//! binding; the per-connection, anti-relay property holds regardless of the +//! context value (it derives from the connection's own TLS 1.3 secrets). The +//! constants are: +//! +//! * **Label** [`QUIC_EXPORTER_LABEL`] = `EXPORTER-Channel-Binding` — the same +//! registered RFC 9266 label the TCP path uses, so the two transports name the +//! binding identically. +//! * **Context** [`QUIC_EXPORTER_CONTEXT`] = `auths-quic-channel-binding-v1` — the +//! explicit context the QUIC API requires (an auths-internal domain separator, +//! distinct from the TCP path's absent context, so a TCP and a QUIC binding for +//! the same secrets can never collide). +//! * **Length** [`QUIC_EXPORTER_LEN`] = 32 bytes. +//! +//! [RFC 5705]: https://www.rfc-editor.org/rfc/rfc5705 + +use std::net::{Ipv6Addr, SocketAddr}; +use std::sync::Arc; + +use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig}; +use quinn::rustls::client::danger::{ + HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier, +}; +use quinn::rustls::pki_types::pem::PemObject; +use quinn::rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; +use quinn::rustls::{ + ClientConfig as RustlsClientConfig, DigitallySignedStruct, ServerConfig as RustlsServerConfig, + SignatureScheme, +}; +use quinn::{ClientConfig, ServerConfig}; +use subtle::ConstantTimeEq; + +use crate::KeyState; + +/// RFC 9266 exporter label, shared with the TCP channel-binding path so both +/// transports name the binding identically. +pub const QUIC_EXPORTER_LABEL: &[u8] = b"EXPORTER-Channel-Binding"; + +/// The exporter context the QUIC keying-material API requires (it has no +/// "absent" form). An auths-internal domain separator; both auths QUIC endpoints +/// pass exactly these bytes so they derive the same binding from one connection. +pub const QUIC_EXPORTER_CONTEXT: &[u8] = b"auths-quic-channel-binding-v1"; + +/// Length, in bytes, of the channel binding exported from a QUIC connection. +pub const QUIC_EXPORTER_LEN: usize = 32; + +/// Errors building a QUIC transport from a KEL-rooted leaf, or extracting a +/// channel binding from a QUIC connection. +#[derive(Debug, thiserror::Error)] +pub enum QuicTransportError { + /// A certificate or key PEM could not be parsed into the rustls types QUIC's + /// TLS 1.3 stack needs. + #[error("parse PEM material: {0}")] + Pem(String), + + /// rustls / quinn refused to build the server or client TLS config (e.g. the + /// key does not match the leaf, or the cipher suite is unavailable). + #[error("build QUIC TLS config: {0}")] + Config(String), + + /// The QUIC connection refused to export keying material — the handshake is + /// not complete, or the connection is not TLS 1.3-capable. A connection that + /// cannot produce a binding MUST NOT fall back to an unbound proof; this is + /// surfaced so the caller fails closed. + #[error("QUIC channel binding unavailable: {0}")] + ExporterUnavailable(String), +} + +/// Parse a certificate-chain PEM into the DER chain QUIC's TLS stack serves. +fn parse_cert_chain(cert_pem: &str) -> Result>, QuicTransportError> { + CertificateDer::pem_slice_iter(cert_pem.as_bytes()) + .collect::, _>>() + .map_err(|e| QuicTransportError::Pem(format!("certificate chain: {e}"))) +} + +/// Parse a PKCS#8 private-key PEM into the DER key QUIC's TLS stack signs with. +fn parse_private_key(key_pem: &str) -> Result, QuicTransportError> { + PrivateKeyDer::from_pem_slice(key_pem.as_bytes()) + .map_err(|e| QuicTransportError::Pem(format!("private key: {e}"))) +} + +/// Build a [`quinn::ServerConfig`] that presents a KEL-rooted leaf over QUIC. +/// +/// `cert_pem` is the leaf (the one [`crate::issue_kel_rooted_cert`] mints, with +/// the `did:keri` SAN + KEL binding extension); `key_pem` is its PKCS#8 private +/// key. The resulting config drives the TLS 1.3 handshake inside QUIC, so the +/// leaf — and the KEL binding it carries — is presented over HTTP/3 exactly as +/// over TLS-over-TCP. The peer re-roots trust by replaying the KEL. +pub fn quic_server_config( + cert_pem: &str, + key_pem: &str, +) -> Result { + let certs = parse_cert_chain(cert_pem)?; + let key = parse_private_key(key_pem)?; + + let mut tls = RustlsServerConfig::builder_with_provider(crypto_provider()) + .with_protocol_versions(&[&quinn::rustls::version::TLS13]) + .map_err(|e| QuicTransportError::Config(format!("TLS1.3 server: {e}")))? + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|e| QuicTransportError::Config(format!("server cert: {e}")))?; + // QUIC requires ALPN; advertise HTTP/3 ("h3") so the transport is HTTP/3-ready. + tls.alpn_protocols = vec![b"h3".to_vec()]; + + let quic_tls = QuicServerConfig::try_from(tls) + .map_err(|e| QuicTransportError::Config(format!("quic server tls: {e}")))?; + Ok(ServerConfig::with_crypto(Arc::new(quic_tls))) +} + +/// Build a [`quinn::ClientConfig`] that completes a QUIC handshake to a +/// KEL-rooted server. +/// +/// Like [`crate::tls_cert`]'s verify model, certificate trust is re-rooted in the +/// **KEL**, not the WebPKI/CA chain: the client accepts the served leaf at the +/// TLS layer and the auths-aware caller then verifies the leaf binds to the AID's +/// replayed key-state out of band ([`crate::verify_binds_to_key_state`]). This is +/// the same separation the TCP path uses — TLS carries the pipe, the log carries +/// the trust. +pub fn quic_client_config() -> Result { + let mut tls = RustlsClientConfig::builder_with_provider(crypto_provider()) + .with_protocol_versions(&[&quinn::rustls::version::TLS13]) + .map_err(|e| QuicTransportError::Config(format!("TLS1.3 client: {e}")))? + .dangerous() + .with_custom_certificate_verifier(Arc::new(KelRootedVerifier)) + .with_no_client_auth(); + tls.alpn_protocols = vec![b"h3".to_vec()]; + + let quic_tls = QuicClientConfig::try_from(tls) + .map_err(|e| QuicTransportError::Config(format!("quic client tls: {e}")))?; + Ok(ClientConfig::new(Arc::new(quic_tls))) +} + +/// Extract the per-connection channel binding from a live [`quinn::Connection`]. +/// +/// This is the QUIC adapter of the keying-material exporter the TCP path uses +/// over rustls. Both endpoints of one QUIC connection derive the **same** value +/// (it is a function of the connection's TLS 1.3 secrets); two independent +/// connections derive **different** values. Folding it into a proof scopes the +/// proof to the connection that minted it (anti-relay), over QUIC exactly as over +/// TCP. +/// +/// The handshake MUST be complete; before it is, quinn returns an error and this +/// surfaces [`QuicTransportError::ExporterUnavailable`] so the caller fails closed +/// rather than minting an unbound, relay-able proof. +pub fn quic_channel_binding( + conn: &quinn::Connection, +) -> Result<[u8; QUIC_EXPORTER_LEN], QuicTransportError> { + let mut material = [0u8; QUIC_EXPORTER_LEN]; + conn.export_keying_material(&mut material, QUIC_EXPORTER_LABEL, QUIC_EXPORTER_CONTEXT) + .map_err(|e| QuicTransportError::ExporterUnavailable(format!("{e:?}")))?; + Ok(material) +} + +/// The AWS-LC-rs crypto provider QUIC's TLS 1.3 stack uses — the same provider +/// the rest of auths's rustls usage runs on, so the cipher suites match. +fn crypto_provider() -> Arc { + Arc::new(quinn::rustls::crypto::aws_lc_rs::default_provider()) +} + +/// Certificate verifier that accepts the leaf at the TLS layer so the KEL replay +/// can re-root trust out of band. +/// +/// The KEL-rooted leaf is self-signed (its trust is in the log, not a CA chain), +/// so a WebPKI verifier would reject it. The auths model is to accept it here and +/// verify the binding against the replayed key-state afterwards — trust is rooted +/// in the KEL, never in this TLS-layer acceptance. This mirrors the TCP path, +/// where the SPKI is pinned out-of-band and the binding is checked by replay. +#[derive(Debug)] +struct KelRootedVerifier; + +impl ServerCertVerifier for KelRootedVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + // QUIC mandates TLS 1.3; a 1.2 signature path is unreachable here. + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ED25519, + SignatureScheme::RSA_PSS_SHA256, + ] + } +} + +/// The result of carrying the KEL-rooted composition over a QUIC loopback: the +/// identity that was served, whether both endpoints agreed on the channel +/// binding (the anti-relay property), and the binding itself (hex, for display). +#[derive(Debug, Clone)] +pub struct QuicLoopbackOutcome { + /// The `did:keri:` the served leaf binds to (re-rooted in the replayed KEL). + pub did_keri: String, + /// Whether the two endpoints of the QUIC connection derived the **same** + /// channel binding. True is the expected outcome — a proof folded against it + /// opens only on this connection (anti-relay). + pub binding_agrees: bool, + /// The per-connection channel binding, hex-encoded, for display/logging. + pub channel_binding_hex: String, + /// The binding length in bytes ([`QUIC_EXPORTER_LEN`]). + pub channel_binding_len: usize, +} + +/// Carry the KEL-rooted composition over a real QUIC loopback connection. +/// +/// Stands up a QUIC server on loopback serving the leaf (`cert_pem`/`key_pem`), +/// connects a client, completes the TLS 1.3 handshake inside QUIC, then proves +/// both composition mechanisms over QUIC: +/// +/// * the **leaf** the client receives re-roots in the KEL — it is verified to bind +/// to `state`, the replayed key-state ([`crate::verify_binds_to_key_state`]); +/// * the **channel binding** both endpoints export from the connection is the same +/// ([`quic_channel_binding`]) — the per-connection value an anti-relay proof +/// folds against. +/// +/// Returns the [`QuicLoopbackOutcome`]. A handshake or binding failure is a +/// [`QuicTransportError`] (the composition could not be carried over QUIC), and a +/// served leaf that does not bind to the KEL is a [`TlsCertError`] surfaced +/// through [`QuicTransportError::Config`]. +pub async fn quic_loopback_compose( + cert_pem: &str, + key_pem: &str, + state: &KeyState, +) -> Result { + let server_cfg = quic_server_config(cert_pem, key_pem)?; + let loopback = SocketAddr::from((Ipv6Addr::LOCALHOST, 0)); + let server = quinn::Endpoint::server(server_cfg, loopback) + .map_err(|e| QuicTransportError::Config(format!("bind QUIC server: {e}")))?; + let server_addr = server + .local_addr() + .map_err(|e| QuicTransportError::Config(format!("server local addr: {e}")))?; + + let mut client = quinn::Endpoint::client(loopback) + .map_err(|e| QuicTransportError::Config(format!("bind QUIC client: {e}")))?; + client.set_default_client_config(quic_client_config()?); + + // Accept on the server while the client connects — both ends of one handshake. + let accept = async { + let incoming = server + .accept() + .await + .ok_or_else(|| QuicTransportError::Config("no inbound QUIC connection".to_string()))?; + incoming + .await + .map_err(|e| QuicTransportError::Config(format!("server handshake: {e}"))) + }; + let connect = async { + client + .connect(server_addr, "localhost") + .map_err(|e| QuicTransportError::Config(format!("client connect: {e}")))? + .await + .map_err(|e| QuicTransportError::Config(format!("client handshake: {e}"))) + }; + let (server_conn, client_conn) = tokio::try_join!(accept, connect)?; + + // Both ends export their channel binding; they must agree (per-connection). + let client_cb = quic_channel_binding(&client_conn)?; + let server_cb = quic_channel_binding(&server_conn)?; + let binding_agrees = bool::from(client_cb.ct_eq(&server_cb)); + + // The leaf the client received must re-root in the replayed KEL. + let chain = client_conn + .peer_identity() + .and_then(|id| id.downcast::>>().ok()) + .ok_or_else(|| { + QuicTransportError::Config("server presented no certificate over QUIC".to_string()) + })?; + let leaf_der = chain + .first() + .ok_or_else(|| QuicTransportError::Config("empty certificate chain".to_string()))?; + let observed_pem = pem_from_der(leaf_der); + let binding = crate::verify_binds_to_key_state(&observed_pem, state).map_err(|e| { + QuicTransportError::Config(format!("served leaf does not bind to KEL: {e}")) + })?; + + // Close the endpoints so their UDP sockets are released promptly. + client.close(0u32.into(), b"done"); + server.close(0u32.into(), b"done"); + + Ok(QuicLoopbackOutcome { + did_keri: binding.did_keri(), + binding_agrees, + channel_binding_hex: hex::encode(client_cb), + channel_binding_len: QUIC_EXPORTER_LEN, + }) +} + +/// Re-encode a DER certificate as PEM so the PEM-based KEL verifier can read the +/// leaf a QUIC peer presented. +fn pem_from_der(der: &CertificateDer<'_>) -> String { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(der.as_ref()); + let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for chunk in b64.as_bytes().chunks(64) { + pem.push_str(&String::from_utf8_lossy(chunk)); + pem.push('\n'); + } + pem.push_str("-----END CERTIFICATE-----\n"); + pem +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use std::net::{Ipv6Addr, SocketAddr}; + + use quinn::{Endpoint, Incoming}; + + use crate::tls_cert::{issue_kel_rooted_cert, verify_binds_to_key_state}; + use crate::types::{CesrKey, Prefix, Said, Threshold}; + use crate::{IssuedCert, KeriPublicKey, KeyState}; + + /// A single-key Ed25519 key-state for a KEL-rooted leaf. + fn sample_state() -> KeyState { + let key = KeriPublicKey::ed25519(&[9u8; 32]) + .unwrap() + .to_qb64() + .unwrap(); + KeyState::from_inception( + Prefix::new_unchecked("EQuicAidAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string()), + vec![CesrKey::new_unchecked(key)], + vec![Said::new_unchecked("ENext0".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), + Said::new_unchecked("ETipQuic000000000000000000000000000000000000".to_string()), + vec![], + Threshold::Simple(0), + vec![], + ) + } + + fn kel_rooted_leaf() -> IssuedCert { + // localhost SANs so the leaf is valid for the loopback transport. + issue_kel_rooted_cert( + &sample_state(), + &["localhost".to_string(), "::1".to_string()], + ) + .expect("mint KEL-rooted leaf") + } + + /// Stand up a QUIC server endpoint on loopback serving `leaf`, returning the + /// endpoint and its bound address. + fn server_endpoint(leaf: &IssuedCert) -> (Endpoint, SocketAddr) { + let cfg = quic_server_config(&leaf.cert_pem, &leaf.key_pem).expect("server cfg"); + let addr = SocketAddr::from((Ipv6Addr::LOCALHOST, 0)); + let ep = Endpoint::server(cfg, addr).expect("server endpoint"); + let bound = ep.local_addr().expect("local addr"); + (ep, bound) + } + + /// Drive one full QUIC handshake to `server_addr` and return both endpoints' + /// channel bindings plus the leaf the client observed. + async fn handshake( + client: &Endpoint, + server: &Endpoint, + server_addr: SocketAddr, + ) -> ( + [u8; QUIC_EXPORTER_LEN], + [u8; QUIC_EXPORTER_LEN], + Vec>, + ) { + let accept = async { + let incoming: Incoming = server.accept().await.expect("incoming"); + incoming.await.expect("server connection") + }; + let connect = async { + client + .connect(server_addr, "localhost") + .expect("connect") + .await + .expect("client connection") + }; + let (server_conn, client_conn) = tokio::join!(accept, connect); + + let client_cb = quic_channel_binding(&client_conn).expect("client binding"); + let server_cb = quic_channel_binding(&server_conn).expect("server binding"); + let peer_chain = client_conn + .peer_identity() + .expect("peer identity") + .downcast::>>() + .expect("cert chain"); + (client_cb, server_cb, *peer_chain) + } + + fn client_endpoint() -> Endpoint { + let addr = SocketAddr::from((Ipv6Addr::LOCALHOST, 0)); + let mut ep = Endpoint::client(addr).expect("client endpoint"); + ep.set_default_client_config(quic_client_config().expect("client cfg")); + ep + } + + #[tokio::test] + async fn server_serves_kel_rooted_leaf_over_quic() { + // The KEL-rooted leaf the client observes over the QUIC TLS 1.3 handshake + // must be exactly the one the server holds, and it must bind to the KEL. + let leaf = kel_rooted_leaf(); + let (server, addr) = server_endpoint(&leaf); + let client = client_endpoint(); + + let (_c, _s, chain) = handshake(&client, &server, addr).await; + assert!( + !chain.is_empty(), + "server presented no certificate over QUIC" + ); + + // The served leaf re-roots in the KEL: replay the key-state and confirm the + // leaf the client received binds to it — trust in the log, over QUIC. + let observed_pem = pem_from_der(&chain[0]); + let binding = verify_binds_to_key_state(&observed_pem, &sample_state()) + .expect("served leaf must bind to the KEL"); + assert_eq!( + binding.did_keri(), + "did:keri:EQuicAidAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + ); + } + + #[tokio::test] + async fn both_endpoints_derive_the_same_binding() { + // RFC 5705 over QUIC: both ends of one connection export the SAME keying + // material — the agreement that lets a proof be scoped to the channel. + let leaf = kel_rooted_leaf(); + let (server, addr) = server_endpoint(&leaf); + let client = client_endpoint(); + + let (client_cb, server_cb, _chain) = handshake(&client, &server, addr).await; + assert_eq!( + client_cb, server_cb, + "both ends of one QUIC connection must derive the same channel binding" + ); + } + + #[tokio::test] + async fn independent_connections_derive_distinct_bindings() { + // Two independent QUIC connections export DIFFERENT keying material — the + // per-connection property the anti-relay guarantee rests on. A proof bound + // to connection 1 cannot be replayed on connection 2. + let leaf = kel_rooted_leaf(); + let (server, addr) = server_endpoint(&leaf); + let client = client_endpoint(); + + let (cb1, _s1, _c1) = handshake(&client, &server, addr).await; + let (cb2, _s2, _c2) = handshake(&client, &server, addr).await; + assert_ne!( + cb1, cb2, + "two independent QUIC connections must derive distinct bindings" + ); + } + + /// Re-encode a DER certificate as PEM so the existing PEM-based KEL verifier + /// can read the leaf the QUIC peer presented. + fn pem_from_der(der: &CertificateDer<'_>) -> String { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(der.as_ref()); + let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for chunk in b64.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(chunk).unwrap()); + pem.push('\n'); + } + pem.push_str("-----END CERTIFICATE-----\n"); + pem + } +} From f4db1e6a73b97cafd1ecc6c5b3485253d2e14b1d Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 14 Jun 2026 15:21:55 +0100 Subject: [PATCH 19/19] test(conformance): keripy 1.3.4 cross-implementation gate + CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests/conformance — a live cross-implementation conformance suite that drives the real auths CLI against the keripy 1.3.4 reference for every KERI interop surface and fails on any byte/structural divergence: - key-state (ksn) emit + ingest - did:webs documents (Ed25519 + P-256 JWK) - OOBI serve (loc-scheme / end-role replies, SAIDs included) - IPEX grant / admit envelopes (top SAID + embeds-SAID) Each surface asserts auths output == keripy oracle (computed live in oracle.py) AND == a frozen golden vector, with a provenance MANIFEST (sha256 + gen_command per vector). Deterministic fixtures; every CLI invocation uses an isolated --repo tmpdir, never ~/.auths. Wire it as a PR gate: new `conformance` job in ci.yml builds the CLI, installs the pinned keri==1.3.4 oracle via uv, and runs the suite on every pull request to main. --- .github/workflows/ci.yml | 35 +++ .gitignore | 1 + tests/conformance/.gitignore | 7 + tests/conformance/MANIFEST.yaml | 80 +++++ tests/conformance/README.md | 102 +++++++ tests/conformance/conftest.py | 156 ++++++++++ tests/conformance/fixtures/acdc.json | 1 + tests/conformance/fixtures/kel-ed25519.json | 1 + tests/conformance/fixtures/kel-p256.json | 1 + tests/conformance/fixtures/ksn-ingest.json | 1 + tests/conformance/gen_vectors.py | 124 ++++++++ tests/conformance/oracle.py | 278 ++++++++++++++++++ tests/conformance/pyproject.toml | 30 ++ tests/conformance/test_keripy_conformance.py | 229 +++++++++++++++ tests/conformance/test_vectors_provenance.py | 103 +++++++ .../conformance/vectors/did-webs-ed25519.json | 1 + tests/conformance/vectors/did-webs-p256.json | 1 + tests/conformance/vectors/ipex-admit.json | 1 + tests/conformance/vectors/ipex-grant.json | 1 + tests/conformance/vectors/ksn-emit.json | 1 + .../vectors/ksn-ingest-resolved.json | 1 + tests/conformance/vectors/oobi-end-role.json | 1 + .../conformance/vectors/oobi-loc-scheme.json | 1 + 23 files changed, 1157 insertions(+) create mode 100644 tests/conformance/.gitignore create mode 100644 tests/conformance/MANIFEST.yaml create mode 100644 tests/conformance/README.md create mode 100644 tests/conformance/conftest.py create mode 100644 tests/conformance/fixtures/acdc.json create mode 100644 tests/conformance/fixtures/kel-ed25519.json create mode 100644 tests/conformance/fixtures/kel-p256.json create mode 100644 tests/conformance/fixtures/ksn-ingest.json create mode 100644 tests/conformance/gen_vectors.py create mode 100644 tests/conformance/oracle.py create mode 100644 tests/conformance/pyproject.toml create mode 100644 tests/conformance/test_keripy_conformance.py create mode 100644 tests/conformance/test_vectors_provenance.py create mode 100644 tests/conformance/vectors/did-webs-ed25519.json create mode 100644 tests/conformance/vectors/did-webs-p256.json create mode 100644 tests/conformance/vectors/ipex-admit.json create mode 100644 tests/conformance/vectors/ipex-grant.json create mode 100644 tests/conformance/vectors/ksn-emit.json create mode 100644 tests/conformance/vectors/ksn-ingest-resolved.json create mode 100644 tests/conformance/vectors/oobi-end-role.json create mode 100644 tests/conformance/vectors/oobi-loc-scheme.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ee4a2e6..e58b15c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -309,6 +309,41 @@ jobs: working-directory: crates/auths-verifier run: wasm-pack build --target bundler --no-default-features --features wasm + conformance: + name: KERI conformance (keripy 1.3.4) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.93 + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-conformance-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-conformance- + - uses: astral-sh/setup-uv@v4 + # keripy's crypto (pysodium) loads libsodium via ctypes at runtime. + - name: Install libsodium (keripy runtime dependency) + run: sudo apt-get update && sudo apt-get install -y libsodium-dev + - name: Build auths CLI + run: cargo build --package auths-cli + # Drives the real auths binary against the keripy 1.3.4 reference for every + # interop surface (ksn, did:webs, OOBI, IPEX) and fails the build on any + # byte/structural divergence. uv installs the pinned keri==1.3.4 oracle. + - name: Run keripy conformance gate + working-directory: tests/conformance + run: uv run pytest -v --junitxml=../../conformance-results.xml + env: + AUTHS_BIN: ${{ github.workspace }}/target/debug/auths + - name: Upload conformance results + if: always() + uses: actions/upload-artifact@v4 + with: + name: conformance-results + path: conformance-results.xml + e2e-tests: name: E2E Tests (${{ matrix.os }}) needs: [test] diff --git a/.gitignore b/.gitignore index 6c72100b..8e0e172c 100644 --- a/.gitignore +++ b/.gitignore @@ -152,3 +152,4 @@ jnkn.db .mcp.json .chunkhound.json .worktrees/ +.recurve/ diff --git a/tests/conformance/.gitignore b/tests/conformance/.gitignore new file mode 100644 index 00000000..c00a80a5 --- /dev/null +++ b/tests/conformance/.gitignore @@ -0,0 +1,7 @@ +# Python / pytest / uv ephemera — never committed. +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ +uv.lock +.uv/ diff --git a/tests/conformance/MANIFEST.yaml b/tests/conformance/MANIFEST.yaml new file mode 100644 index 00000000..13bd76cc --- /dev/null +++ b/tests/conformance/MANIFEST.yaml @@ -0,0 +1,80 @@ +# MANIFEST.yaml — provenance for every conformance fixture + vector. +# GENERATED by gen_vectors.py from keripy (the oracle). Do not hand-edit; +# re-run `python3 gen_vectors.py` and review the diff. keripy-version +# drift changes the sha256s and fails test_vectors_provenance.py. +# +# Per-entry: {path, surface, source: keripy, version, gen_command, sha256} + +vectors: +- path: fixtures/kel-ed25519.json + surface: ksn/did-webs/oobi + source: keripy + version: 1.3.4 + gen_command: eventing.incept(keys=[Verfer(bytes(0..32)).qb64], code=Blake3_256) + sha256: 367b102719bf4601629079071fb01c11194f7c3bc711b27d2175ae07b045748c +- path: fixtures/kel-p256.json + surface: did-webs + source: keripy + version: 1.3.4 + gen_command: eventing.incept(keys=[P256 Verfer(scalar=int(bytes(1..33))).qb64], code=Blake3_256) + sha256: 80739dc059a21d0647ad6b8c802ce2c9afa5f3ed1e1915be0ab9d105ca161d7c +- path: fixtures/ksn-ingest.json + surface: ksn-ingest + source: keripy + version: 1.3.4 + gen_command: 'eventing.state(...).asdict() # keripy-published ksn wire record' + sha256: 940894c36b2f6d119e92b88e24b3720bcfbf741a482e2ea0f0aa7a05afe43cab +- path: fixtures/acdc.json + surface: ipex + source: keripy + version: 1.3.4 + gen_command: proving.credential(schema, issuer, data={dt}, recipient, status=registry) + sha256: 467916809b8d7e0a2732dfa4573ed94e3e2b102b969eb0bd4141ffa41db8de0d +- path: vectors/ksn-emit.json + surface: ksn-emit + source: keripy + version: 1.3.4 + gen_command: eventing.state(pre,sn=0,pig='',dig,fn=0,eilk='icp',keys,eevt,sith='1',ndigs=[],toad=0,wits=[],cnfg=[],stamp=epoch)._asdict() + sha256: 940894c36b2f6d119e92b88e24b3720bcfbf741a482e2ea0f0aa7a05afe43cab +- path: vectors/ksn-ingest-resolved.json + surface: ksn-ingest + source: keripy + version: 1.3.4 + gen_command: subset {i,k,s} of eventing.state(...)._asdict() + sha256: a6a5fdd4c9b49310200752ad535099cc70ff89154f22e86dc932a510f913f99e +- path: vectors/did-webs-ed25519.json + surface: did-webs + source: keripy + version: 1.3.4 + gen_command: "gen_did_document(Ed25519\u2192OKP x-only JWK)" + sha256: 86715671a59fe60d105e45def2a3f0573ad4e65328e83e157cf391f4f40532ec +- path: vectors/did-webs-p256.json + surface: did-webs + source: keripy + version: 1.3.4 + gen_command: "gen_did_document(P-256\u2192EC x/y JWK)" + sha256: 5a2e86894bb7c4c0395431aa36a65b6c63e2fe84e38b7bd5131d434a01b91a06 +- path: vectors/oobi-loc-scheme.json + surface: oobi + source: keripy + version: 1.3.4 + gen_command: eventing.reply(route='/loc/scheme', data={eid,scheme,url}, stamp=epoch) + sha256: d77be84ca8ebc2730fd93955532c7431832f5db6a507ea1ca9f4d02fc53152c9 +- path: vectors/oobi-end-role.json + surface: oobi + source: keripy + version: 1.3.4 + gen_command: eventing.reply(route='/end/role/add', data={cid,role,eid}, stamp=epoch) + sha256: 4eb7e35a33e3789cef80c2da3ccdd2343fa85a2b051f08f2950648c8593ed3f6 +- path: vectors/ipex-grant.json + surface: ipex + source: keripy + version: 1.3.4 + gen_command: exchanging.exchange(route='/ipex/grant', payload={m:'',i:recp}, sender, embeds={acdc}, date=dt) + sha256: c1eb900196e4edd0638792829a4c31213e774b91219f3d2a6bd76f08159feaa0 +- path: vectors/ipex-admit.json + surface: ipex + source: keripy + version: 1.3.4 + gen_command: exchanging.exchange(route='/ipex/admit', payload={m:''}, sender=recp, dig=grant.said, date=dt) + sha256: 6ba49d823995c7087a534e740c6c7a1567c53ac8aab6f547ea5ea12f2cd1061b diff --git a/tests/conformance/README.md b/tests/conformance/README.md new file mode 100644 index 00000000..99d3637b --- /dev/null +++ b/tests/conformance/README.md @@ -0,0 +1,102 @@ +# keripy conformance suite + +Proves the **auths** KERI CLI is byte-for-byte interoperable with the KERI +reference implementation, **keripy 1.3.4**. For each KERI surface, identical +fixed inputs are fed to both keripy and the auths CLI, and the outputs are +compared as canonical JSON. + +## What it proves + +For every surface the suite asserts **two** things: + +1. **Live cross-check** — `auths CLI output == keripy oracle output` + (`oracle.py`, pure keripy). This is the real interop claim, computed fresh on + every run from the installed keripy. +2. **Drift / provenance** — `auths CLI output == the frozen vector` in + `vectors/`, and (`test_vectors_provenance.py`) the frozen vectors reproduce + the keripy oracle byte-for-byte. So the goldens are provably keripy's, and a + keripy-version drift fails loudly. + +No expected value is ever hand-copied from auths output: every expected SAID and +field is produced by keripy code (`oracle.py`), using the exact recipes the auths +Rust modules document (`auths-keri/src/{oobi,ipex,did_webs}.rs`). + +## Surfaces and match type + +| # | Surface | auths command | keripy oracle | Match | +|---|---------|---------------|---------------|-------| +| 1 | ksn emit | `key-state --from-kel` | `eventing.state(...)._asdict()` | byte-exact (canonical JSON); `dt` pinned to epoch on both sides | +| 2 | ksn ingest | `key-state --ingest` | subset `{i,k,s}` of `eventing.state(...)` | structural — auths prints a normalized internal view; gated on resolved key-state `(i,k,s)` + last-event SAID | +| 3 | did:webs (Ed25519) | `did-webs --from-kel --domain` | `gen_did_document` (OKP x-only JWK) | byte-exact | +| 3b | did:webs (P-256) | `did-webs --from-kel --domain` | `gen_did_document` (EC x/y JWK) | byte-exact | +| 4 | oobi endpoint | `oobi endpoint --from-kel --authority --url` | `eventing.reply('/loc/scheme')` + `eventing.reply('/end/role/add')` | byte-exact incl. SAIDs; `dt` pinned to epoch | +| 5 | ipex grant | `ipex grant --acdc --sender --recipient` | `exchanging.exchange('/ipex/grant', embeds={acdc})` | byte-exact incl. top-level `d`, embedded ACDC, and embeds-section `e.d` | +| 6 | ipex admit | `ipex admit --grant --sender` | `exchanging.exchange('/ipex/admit', dig=grant.said)` | byte-exact incl. `d` and prior `p` | + +### Normalization notes (and why) + +The comparison helper `canon()` only sorts object keys and compacts whitespace — +it never drops or rewrites a value, so any real type/value difference still +fails. The only places where the two sides are deliberately made to agree: + +- **`dt` (ksn emit, oobi)** — keripy's `state()`/`reply()` default the timestamp + to `now()`; auths defaults `--dt` to the epoch. We pin BOTH to the epoch (the + auths default; `stamp=`/`date=` on keripy), so no field is normalized away. +- **`a.dt` (ipex ACDC)** — keripy's `proving.credential` stamps the subject `dt` + with `now()` unless given; we pass `data={"dt": ...}` to fix it. auths is + given the same fixed-`dt` ACDC and `--dt`. +- **ksn ingest shape** — auths `--ingest` prints `{prefix, current_keys, + sequence, last_event_said, ...}` (a resolved internal view), not the wire ksn. + The test maps those to `(i, k, s)` and compares; this surface is gated on the + *resolved key-state*, not on echoing the wire bytes. This is the one + structural-rather-than-byte-exact surface. + +All other surfaces are byte-exact under canonical JSON. + +## How to run + +From the auths repo root, with keripy 1.3.4 importable: + +```bash +python3 -m pytest tests/conformance -v +``` + +The auths binary is located via `$AUTHS_BIN`, else the release build at +`target/release/auths`, else `auths` on `PATH`. If none is found the suite +*skips* with a clear message (build with `cargo build --release -p auths`). + +Every auths invocation is passed `--repo `; an autouse session fixture +asserts `~/.auths` is never created or modified by the run. + +## Regenerating vectors + +The frozen inputs (`fixtures/`) and golden outputs (`vectors/`) plus their +provenance (`MANIFEST.yaml`) are generated from keripy: + +```bash +cd tests/conformance && python3 gen_vectors.py +``` + +`test_vectors_provenance.py` then guarantees the checked-in files match what +keripy produces, byte-for-byte. Regenerate only on an intentional keripy version +bump and review the diff. + +## Version pin + +- **keripy 1.3.4** (`python3 -c "import keri; print(keri.__version__)"`). +- Deterministic key material: Ed25519 raw = `bytes(range(32))`; recipient + Ed25519 raw = `bytes((b+5)%256 for b in range(32))`; P-256 scalar = + `int(bytes(range(1,33)))`. AID for the primary key: + `EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J`. + +## Honest scope + +- **Byte-exact (canonical JSON):** ksn emit, did:webs (Ed25519 + P-256), oobi + `/loc/scheme` + `/end/role/add` (SAIDs included), ipex grant (incl. embedded + ACDC + embeds-section SAID), ipex admit (incl. prior SAID). +- **Structural:** ksn ingest — gated on the resolved key-state `(i, k, s)` and + the last-event SAID, because auths emits a normalized view rather than the wire + ksn. +- **Out of scope for this gate:** no network, no Docker, no signify-ts/KERIA live + peers. Signatures and witnessing are covered by the separate interop harness, + not here. This suite is keripy-only and offline. diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py new file mode 100644 index 00000000..1ea7b3c4 --- /dev/null +++ b/tests/conformance/conftest.py @@ -0,0 +1,156 @@ +"""Fixtures for the keripy conformance suite. + +Locates the auths binary, gives each test an isolated `--repo` tmpdir, and +provides helpers to run auths and parse its JSON. The suite NEVER touches +`~/.auths`: every auths invocation is passed `--repo `, and a +session-scoped guard fixture asserts `~/.auths` is not created or mutated by the +run. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import pytest + +# The release binary the spike used; overridable via AUTHS_BIN. +_DEFAULT_BIN = ( + Path(__file__).resolve().parents[2] / "target" / "release" / "auths" +) + + +def _find_binary() -> Path | None: + if path := os.environ.get("AUTHS_BIN"): + p = Path(path) + if p.exists(): + return p + if _DEFAULT_BIN.exists(): + return _DEFAULT_BIN + if found := shutil.which("auths"): + return Path(found) + return None + + +@dataclass +class CLIResult: + """The result of running an auths CLI command.""" + + returncode: int + stdout: str + stderr: str + + def ok(self) -> "CLIResult": + assert self.returncode == 0, ( + f"auths exited {self.returncode}\nstdout:\n{self.stdout}\n" + f"stderr:\n{self.stderr}" + ) + return self + + @property + def json(self) -> dict: + """Parse stdout as a single JSON object.""" + return json.loads(self.stdout) + + @property + def json_lines(self) -> list[dict]: + """Parse stdout as newline-delimited JSON objects (skipping blanks).""" + return [ + json.loads(line) + for line in self.stdout.splitlines() + if line.strip() + ] + + +@pytest.fixture(scope="session") +def auths_bin() -> Path: + """Path to the auths binary (skip the suite cleanly if it's not built).""" + path = _find_binary() + if path is None: + pytest.skip( + "auths binary not found — set AUTHS_BIN or build " + "(cargo build --release -p auths)" + ) + return path + + +@pytest.fixture(scope="session", autouse=True) +def _guard_user_auths_dir(): + """Fail loudly if the suite ever creates or mutates ~/.auths. + + Records a fingerprint of ~/.auths before any test runs and re-checks it after + the whole session. Every auths call in this suite passes --repo, so the real + user store must be byte-identical across the run. + """ + home_auths = Path.home() / ".auths" + + def fingerprint() -> tuple[bool, list[str]]: + if not home_auths.exists(): + return (False, []) + entries = sorted( + f"{p.relative_to(home_auths)}:{p.stat().st_mtime_ns}:{p.stat().st_size}" + for p in home_auths.rglob("*") + if p.is_file() + ) + return (True, entries) + + before = fingerprint() + yield + after = fingerprint() + assert after == before, ( + "~/.auths was created or modified by the conformance suite — every " + "auths invocation MUST pass --repo .\n" + f"before: existed={before[0]} files={len(before[1])}\n" + f"after: existed={after[0]} files={len(after[1])}" + ) + + +@pytest.fixture +def repo_dir(tmp_path) -> Path: + """A fresh per-test `--repo` directory (never ~/.auths).""" + d = tmp_path / "repo" + d.mkdir() + return d + + +@pytest.fixture +def run_auths(auths_bin, repo_dir): + """Run `auths --repo ` and capture output. + + `--repo` is appended automatically (unless already present) so no test can + accidentally fall back to ~/.auths. + """ + + def _run(args: list[str], *, check: bool = True) -> CLIResult: + full = list(args) + if "--repo" not in full: + full += ["--repo", str(repo_dir)] + proc = subprocess.run( + [str(auths_bin), *full], + capture_output=True, + text=True, + timeout=60, + env={**os.environ, "NO_COLOR": "1"}, + ) + result = CLIResult(proc.returncode, proc.stdout, proc.stderr) + if check: + result.ok() + return result + + return _run + + +@pytest.fixture +def write_json(tmp_path): + """Write an object as JSON to a tmp file and return its path.""" + + def _write(name: str, obj) -> Path: + p = tmp_path / name + p.write_text(json.dumps(obj, indent=2)) + return p + + return _write diff --git a/tests/conformance/fixtures/acdc.json b/tests/conformance/fixtures/acdc.json new file mode 100644 index 00000000..041be565 --- /dev/null +++ b/tests/conformance/fixtures/acdc.json @@ -0,0 +1 @@ +{"a":{"d":"EM87KRWNVWdwTbahWe3X5pV-sspJ0cQcTYV0VBC2GDZ1","dt":"2024-01-01T00:00:00.000000+00:00","i":"EIL3BI6FNMvZKzQw4W_VPPW9amBlIbxVQxENDAUYgjOS"},"d":"EPEa_K4Qzu4pDckEIiGeTONlWy2db2a3tXcuksOHPSLl","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","ri":"EO0_SHla5Gnzc-T3jkTNAclpA1iv1L9k3lQZw5cFOe9o","s":"EMQWEcCnVRk1hatTNyK3sIykYSrrFvafX3bHQ9Gkk1kC","v":"ACDC10JSON00017a_"} diff --git a/tests/conformance/fixtures/kel-ed25519.json b/tests/conformance/fixtures/kel-ed25519.json new file mode 100644 index 00000000..346cf185 --- /dev/null +++ b/tests/conformance/fixtures/kel-ed25519.json @@ -0,0 +1 @@ +[{"a":[],"b":[],"bt":"0","c":[],"d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"kt":"1","n":[],"nt":"0","s":"0","t":"icp","v":"KERI10JSON0000fd_"}] diff --git a/tests/conformance/fixtures/kel-p256.json b/tests/conformance/fixtures/kel-p256.json new file mode 100644 index 00000000..461cc12c --- /dev/null +++ b/tests/conformance/fixtures/kel-p256.json @@ -0,0 +1 @@ +[{"a":[],"b":[],"bt":"0","c":[],"d":"EBsDyB7uU0uNYtO_t8qYIocgYIld5JCC62TCZclHYt0K","i":"EBsDyB7uU0uNYtO_t8qYIocgYIld5JCC62TCZclHYt0K","k":["1AAJAlFcPW6545a5BNP-yn9U_c0MwemXvzddylFa0KbDtANf"],"kt":"1","n":[],"nt":"0","s":"0","t":"icp","v":"KERI10JSON000101_"}] diff --git a/tests/conformance/fixtures/ksn-ingest.json b/tests/conformance/fixtures/ksn-ingest.json new file mode 100644 index 00000000..383326ab --- /dev/null +++ b/tests/conformance/fixtures/ksn-ingest.json @@ -0,0 +1 @@ +{"b":[],"bt":"0","c":[],"d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","di":"","dt":"1970-01-01T00:00:00+00:00","ee":{"ba":[],"br":[],"d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","s":"0"},"et":"icp","f":"0","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"kt":"1","n":[],"nt":"0","p":"","s":"0","vn":[1,0]} diff --git a/tests/conformance/gen_vectors.py b/tests/conformance/gen_vectors.py new file mode 100644 index 00000000..6cd99afe --- /dev/null +++ b/tests/conformance/gen_vectors.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Regenerate canonical input fixtures + frozen golden outputs FROM keripy 1.3.4. + +Writes two trees plus a provenance manifest: + - fixtures/*.json : the deterministic INPUTS both keripy and the auths CLI + consume (KELs, ksn records, ACDC). + - vectors/*.json : the frozen EXPECTED OUTPUTS computed by the keripy oracle + (ksn state, did:webs docs, oobi replies, ipex exn). These + are the drift/provenance anchors: test_vectors_provenance.py + asserts re-running this script reproduces them byte-for-byte. + - MANIFEST.yaml : per-file {path, surface, source: keripy, version, + gen_command, sha256} (mirrors interop/vectors/MANIFEST.yaml). + +Every value is deterministic (fixed key material, explicit timestamps), so the +checked-in files and their sha256s are stable across regenerations. The expected +outputs are produced ONLY by keripy oracle code in oracle.py — never copied from +auths output. + +Run: python3 gen_vectors.py +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import oracle as o + +HERE = Path(__file__).resolve().parent +FIX = HERE / "fixtures" +VEC = HERE / "vectors" +KERI_VERSION = __import__("keri").__version__ + + +def _canon_bytes(obj) -> bytes: + """Canonical JSON bytes: sorted keys, compact separators, trailing newline.""" + return ( + json.dumps(obj, sort_keys=True, separators=(",", ":")) + "\n" + ).encode() + + +def main() -> None: + FIX.mkdir(parents=True, exist_ok=True) + VEC.mkdir(parents=True, exist_ok=True) + entries: list[dict] = [] + + def emit(tree: Path, rel: str, obj, surface: str, gen: str) -> None: + path = tree / rel + path.parent.mkdir(parents=True, exist_ok=True) + data = _canon_bytes(obj) + path.write_bytes(data) + entries.append( + { + "path": f"{tree.name}/{rel}", + "surface": surface, + "source": "keripy", + "version": KERI_VERSION, + "gen_command": gen, + "sha256": hashlib.sha256(data).hexdigest(), + } + ) + + # ── Inception fixtures (shared inputs) ────────────────────────────────── + icp = o.icp_ed() + icp2 = o.icp_ed2() + icp_p = o.icp_p256() + sender, recipient = icp.pre, icp2.pre + + emit(FIX, "kel-ed25519.json", o.kel_json(icp), "ksn/did-webs/oobi", + "eventing.incept(keys=[Verfer(bytes(0..32)).qb64], code=Blake3_256)") + emit(FIX, "kel-p256.json", o.kel_json(icp_p), "did-webs", + "eventing.incept(keys=[P256 Verfer(scalar=int(bytes(1..33))).qb64], code=Blake3_256)") + emit(FIX, "ksn-ingest.json", o.ksn_ingest_input(icp), "ksn-ingest", + "eventing.state(...).asdict() # keripy-published ksn wire record") + emit(FIX, "acdc.json", o.ipex_acdc(sender, recipient), "ipex", + "proving.credential(schema, issuer, data={dt}, recipient, status=registry)") + + # ── Golden outputs (keripy oracle) ────────────────────────────────────── + emit(VEC, "ksn-emit.json", o.ksn_state(icp), "ksn-emit", + "eventing.state(pre,sn=0,pig='',dig,fn=0,eilk='icp',keys,eevt,sith='1'," + "ndigs=[],toad=0,wits=[],cnfg=[],stamp=epoch)._asdict()") + emit(VEC, "ksn-ingest-resolved.json", o.ksn_ingest_expected(icp), "ksn-ingest", + "subset {i,k,s} of eventing.state(...)._asdict()") + emit(VEC, "did-webs-ed25519.json", + o.did_webs(icp, o.ed_verfer(), "example.com"), "did-webs", + "gen_did_document(Ed25519→OKP x-only JWK)") + emit(VEC, "did-webs-p256.json", + o.did_webs(icp_p, o.p256_verfer(), "example.com"), "did-webs", + "gen_did_document(P-256→EC x/y JWK)") + emit(VEC, "oobi-loc-scheme.json", + o.oobi_loc_scheme(icp, "http://127.0.0.1:5642/"), "oobi", + "eventing.reply(route='/loc/scheme', data={eid,scheme,url}, stamp=epoch)") + emit(VEC, "oobi-end-role.json", o.oobi_end_role(icp), "oobi", + "eventing.reply(route='/end/role/add', data={cid,role,eid}, stamp=epoch)") + emit(VEC, "ipex-grant.json", o.ipex_grant(sender, recipient), "ipex", + "exchanging.exchange(route='/ipex/grant', payload={m:'',i:recp}, " + "sender, embeds={acdc}, date=dt)") + emit(VEC, "ipex-admit.json", o.ipex_admit(sender, recipient), "ipex", + "exchanging.exchange(route='/ipex/admit', payload={m:''}, sender=recp, " + "dig=grant.said, date=dt)") + + # ── MANIFEST ──────────────────────────────────────────────────────────── + import yaml + + header = ( + "# MANIFEST.yaml — provenance for every conformance fixture + vector.\n" + "# GENERATED by gen_vectors.py from keripy (the oracle). Do not hand-edit;\n" + "# re-run `python3 gen_vectors.py` and review the diff. keripy-version\n" + "# drift changes the sha256s and fails test_vectors_provenance.py.\n" + "#\n" + "# Per-entry: {path, surface, source: keripy, version, gen_command, sha256}\n\n" + ) + (HERE / "MANIFEST.yaml").write_text( + header + yaml.safe_dump({"vectors": entries}, sort_keys=False, width=10_000) + ) + + print(f"wrote {len(entries)} files from keripy {KERI_VERSION}") + for e in entries: + print(f" {e['path']:<32} {e['sha256'][:12]}…") + + +if __name__ == "__main__": + main() diff --git a/tests/conformance/oracle.py b/tests/conformance/oracle.py new file mode 100644 index 00000000..fccb3430 --- /dev/null +++ b/tests/conformance/oracle.py @@ -0,0 +1,278 @@ +"""keripy oracle — the canonical expected output for every conformance surface. + +Every function here is a *pure keripy* computation. It never reads auths output +and never hand-copies a SAID: the expected value for each surface is produced by +calling the KERI reference implementation (keripy 1.3.4) with the SAME fixed, +deterministic inputs the auths CLI is fed. The conformance tests then assert that +`auths_cli_output == oracle_output`, which is the live byte-for-byte cross-check. + +All key material is fixed (no randomness, no clock): the Ed25519 raw key is +`bytes(range(32))`, the P-256 scalar is `int(bytes(range(1, 33)))`, and every +timestamp is passed explicitly. This mirrors interop/harness/gen_vectors.py. + +The keripy recipe each function uses is documented in the auths Rust sources: + - key-state / ksn ⇔ keri.core.eventing.state(...)._asdict() + - did:webs ⇔ did-webs-resolver gen_did_document (OKP/EC JWK) + - oobi /loc /end ⇔ keri.core.eventing.reply(route=..., data=..., stamp=...) + - ipex grant / admit ⇔ keri.peer.exchanging.exchange(route=..., ...) +""" + +from __future__ import annotations + +import base64 +import json + +from keri.core import coring, eventing +from keri.core.coring import MtrDex +from keri.peer import exchanging +from keri.vc import proving + +# ── Fixed, deterministic key material ─────────────────────────────────────── +ED_RAW = bytes(range(32)) # 0x00..0x1f — the spike's Ed25519 raw key. +# A second Ed25519 key for the IPEX recipient AID (deterministic offset). +ED2_RAW = bytes((b + 5) % 256 for b in range(32)) +# The fixed registry/schema SAIDs used by the auths ipex.rs reference vector. +IPEX_REGISTRY = "EO0_SHla5Gnzc-T3jkTNAclpA1iv1L9k3lQZw5cFOe9o" +IPEX_SCHEMA = "EMQWEcCnVRk1hatTNyK3sIykYSrrFvafX3bHQ9Gkk1kC" +# Fixed timestamps. Epoch is the auths CLI default for --dt; the oobi/ipex +# surfaces stamp with microsecond precision (keripy/auths both use that form). +EPOCH_KSN = "1970-01-01T00:00:00+00:00" +EPOCH_US = "1970-01-01T00:00:00.000000+00:00" +ACDC_DT = "2024-01-01T00:00:00.000000+00:00" + + +def ed_verfer() -> coring.Verfer: + """The deterministic Ed25519 verfer (controller key).""" + return coring.Verfer(raw=ED_RAW, code=MtrDex.Ed25519) + + +def ed2_verfer() -> coring.Verfer: + """The deterministic Ed25519 verfer for the IPEX recipient AID.""" + return coring.Verfer(raw=ED2_RAW, code=MtrDex.Ed25519) + + +def p256_verfer() -> coring.Verfer: + """A deterministic, valid P-256 verfer (compressed SEC1 point).""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec + + scalar = int.from_bytes(bytes(range(1, 33)), "big") + priv = ec.derive_private_key(scalar, ec.SECP256R1()) + raw = priv.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint + ) + return coring.Verfer(raw=raw, code=MtrDex.ECDSA_256r1) + + +# ── Inception / KEL fixtures (the inputs both sides consume) ───────────────── +def icp_ed() -> eventing.Serder: + """Self-addressing Ed25519 inception (the auths AID model: i == d == SAID).""" + return eventing.incept(keys=[ed_verfer().qb64], code=MtrDex.Blake3_256) + + +def icp_ed2() -> eventing.Serder: + """Self-addressing inception for the IPEX recipient AID.""" + return eventing.incept(keys=[ed2_verfer().qb64], code=MtrDex.Blake3_256) + + +def icp_p256() -> eventing.Serder: + """Self-addressing P-256 inception.""" + return eventing.incept(keys=[p256_verfer().qb64], code=MtrDex.Blake3_256) + + +def kel_json(icp: eventing.Serder) -> list[dict]: + """A KEL file body: a JSON array of keripy event.raw objects (the spike form).""" + return [json.loads(icp.raw.decode())] + + +# ── Surface 1: ksn emit ───────────────────────────────────────────────────── +def ksn_state(icp: eventing.Serder, *, stamp: str = EPOCH_KSN) -> dict: + """keripy KeyStateRecord (`ksn` wire shape) for a single-icp KEL. + + Mirrors auths `key-state --from-kel`. keripy's `state(...)` defaults `dt` to + `now()`; auths defaults `--dt` to the epoch, so we pin `stamp` to the epoch + to keep the comparison deterministic (the only field that would otherwise + diverge). Every other field (vn, i, s, p, d, f, et, kt, k, nt, n, bt, b, c, + ee, di) is keripy's own output. + """ + keys = [json.loads(icp.raw.decode())["k"][0]] + ksr = eventing.state( + pre=icp.pre, + sn=0, + pig="", + dig=icp.said, + fn=0, + eilk="icp", + keys=keys, + eevt=eventing.StateEstEvent(s="0", d=icp.said, br=[], ba=[]), + sith="1", + ndigs=[], + toad=0, + wits=[], + cnfg=[], + stamp=stamp, + ) + return ksr._asdict() + + +# ── Surface 2: ksn ingest ─────────────────────────────────────────────────── +def ksn_ingest_input(icp: eventing.Serder) -> dict: + """A keripy-produced ksn JSON record (the thing a peer publishes).""" + return ksn_state(icp, stamp=EPOCH_KSN) + + +def ksn_ingest_expected(icp: eventing.Serder) -> dict: + """The resolved key-state (i, k, s) a peer should recover from the ksn. + + auths `--ingest` prints a normalized internal view, not the wire ksn; the + gate asserts the *resolved key-state* matches: prefix==i, current_keys==k, + sequence==s. Those three are derived here straight from keripy. + """ + rec = ksn_state(icp, stamp=EPOCH_KSN) + return {"i": rec["i"], "k": rec["k"], "s": rec["s"]} + + +# ── Surface 3: did:webs ───────────────────────────────────────────────────── +def _b64u(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + + +def did_webs(icp: eventing.Serder, verfer: coring.Verfer, domain: str) -> dict: + """The did:webs DID document for an AID. + + Mirrors the did-webs-resolver `gen_did_document` / `generate_json_web_key_vm`: + field order id, verificationMethod, service, alsoKnownAs; Ed25519 → OKP + x-only JWK; P-256 → EC x/y JWK. The verkey qb64 is keripy's; the JWK + coordinates are derived from the same raw public key keripy holds. + """ + aid = icp.pre + did = f"did:webs:{domain}:{aid}" + vk = verfer.qb64 + + if verfer.code == MtrDex.Ed25519: + jwk = {"kty": "OKP", "kid": vk, "crv": "Ed25519", "x": _b64u(verfer.raw)} + elif verfer.code == MtrDex.ECDSA_256r1: + from cryptography.hazmat.primitives.asymmetric import ec + + # Decompress the SEC1 point keripy carries to recover x and y. + pub = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), verfer.raw) + nums = pub.public_numbers() + jwk = { + "kty": "EC", + "kid": vk, + "crv": "P-256", + "x": _b64u(nums.x.to_bytes(32, "big")), + "y": _b64u(nums.y.to_bytes(32, "big")), + } + else: + raise ValueError(f"unsupported verkey code {verfer.code}") + + return { + "id": did, + "verificationMethod": [ + { + "id": f"#{vk}", + "type": "JsonWebKey", + "controller": did, + "publicKeyJwk": jwk, + } + ], + "service": [], + "alsoKnownAs": [f"did:keri:{aid}"], + } + + +# ── Surface 4: oobi endpoint ──────────────────────────────────────────────── +def oobi_url(icp: eventing.Serder, authority: str, scheme: str = "http") -> str: + """The OOBI URL auths emits for the controller role (keripy OOBI_RE shape).""" + return f"{scheme}://{authority}/oobi/{icp.pre}/controller" + + +def oobi_loc_scheme(icp: eventing.Serder, url: str, *, stamp: str = EPOCH_US) -> dict: + """The `/loc/scheme` rpy reply, via keripy `eventing.reply` (SAID included).""" + rpy = eventing.reply( + route="/loc/scheme", + data={"eid": icp.pre, "scheme": "http", "url": url}, + stamp=stamp, + ) + return json.loads(rpy.raw.decode()) + + +def oobi_end_role(icp: eventing.Serder, *, stamp: str = EPOCH_US) -> dict: + """The `/end/role/add` rpy reply (controller authorizes itself), via keripy.""" + rpy = eventing.reply( + route="/end/role/add", + data={"cid": icp.pre, "role": "controller", "eid": icp.pre}, + stamp=stamp, + ) + return json.loads(rpy.raw.decode()) + + +# ── Surfaces 5 & 6: ipex grant / admit ────────────────────────────────────── +def ipex_acdc(sender: str, recipient: str, *, dt: str = ACDC_DT) -> dict: + """A deterministic ACDC `{v,d,i,ri,s,a:{d,i,dt}}` matching the auths shape. + + keripy `proving.credential` saidifies the `a` block (nested `a.d`) exactly as + auths does. Passing `data={"dt": dt}` makes `a.dt` deterministic (keripy + otherwise stamps it with now()). Empty attributes otherwise. + """ + acdc = proving.credential( + schema=IPEX_SCHEMA, + issuer=sender, + data={"dt": dt}, + recipient=recipient, + status=IPEX_REGISTRY, + ) + return json.loads(acdc.raw.decode()) + + +def _acdc_serder(sender: str, recipient: str, *, dt: str = ACDC_DT): + return proving.credential( + schema=IPEX_SCHEMA, + issuer=sender, + data={"dt": dt}, + recipient=recipient, + status=IPEX_REGISTRY, + ) + + +def ipex_grant(sender: str, recipient: str, *, dt: str = ACDC_DT) -> dict: + """keripy IPEX grant `exn` (route /ipex/grant), via `exchanging.exchange`. + + Recipe (from auths ipex.rs): exchange(route="/ipex/grant", + payload={m:"", i:recipient}, sender=sender, embeds={acdc:}, date=dt). + The top-level `d` SAID and the embeds section SAID `e.d` are keripy's own. + """ + acdc = _acdc_serder(sender, recipient, dt=dt) + exn, _ = exchanging.exchange( + route="/ipex/grant", + payload={"m": "", "i": recipient}, + sender=sender, + embeds={"acdc": acdc.raw}, + date=dt, + ) + return json.loads(exn.raw.decode()) + + +def ipex_admit(sender: str, recipient: str, *, dt: str = ACDC_DT) -> dict: + """keripy IPEX admit `exn` (route /ipex/admit), via `exchanging.exchange`. + + Recipe (from auths ipex.rs): exchange(route="/ipex/admit", payload={m:""}, + sender=recipient, dig=, date=dt). Prior `p` = the grant SAID. + """ + acdc = _acdc_serder(sender, recipient, dt=dt) + grant, _ = exchanging.exchange( + route="/ipex/grant", + payload={"m": "", "i": recipient}, + sender=sender, + embeds={"acdc": acdc.raw}, + date=dt, + ) + admit, _ = exchanging.exchange( + route="/ipex/admit", + payload={"m": ""}, + sender=recipient, + dig=grant.said, + date=dt, + ) + return json.loads(admit.raw.decode()) diff --git a/tests/conformance/pyproject.toml b/tests/conformance/pyproject.toml new file mode 100644 index 00000000..9654f509 --- /dev/null +++ b/tests/conformance/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "auths-keripy-conformance" +version = "0.1.0" +description = "Byte-for-byte conformance of the auths KERI CLI against keripy 1.3.4" +# keri==1.3.4 pulls hio, which requires Python >=3.12.6; pinning below 3.14 +# keeps uv from selecting hio>=0.7.17 (which demands 3.14). Without this the +# `uv run` resolver used in CI is unsatisfiable. +requires-python = ">=3.12.6,<3.14" + +[tool.uv] +package = false + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-timeout>=2.1", + "keri==1.3.4", + "cryptography", + "pyyaml", +] + +[tool.pytest.ini_options] +testpaths = ["."] +markers = [ + "requires_binary: marks tests that require the compiled auths binary", +] +strict_markers = true +# `timeout` is honored when pytest-timeout is installed (see dev deps); harmless +# otherwise. Per-subprocess timeouts are also enforced in conftest's run_auths. +timeout = 60 diff --git a/tests/conformance/test_keripy_conformance.py b/tests/conformance/test_keripy_conformance.py new file mode 100644 index 00000000..958b80ab --- /dev/null +++ b/tests/conformance/test_keripy_conformance.py @@ -0,0 +1,229 @@ +"""keripy conformance — the live gate. + +For each KERI surface, assert TWO things: + 1. live cross-check : auths CLI output == keripy oracle output (oracle.py) + 2. drift/provenance : auths CLI output == the frozen vector in vectors/ + +Both comparisons are CANONICAL JSON (sorted keys, normalized via `canon`). When +the comparison is byte-exact the raw stdout already matches; `canon` only +reorders keys so a JSON object-ordering difference never masks a real one. Any +field that must be *normalized away* (because the two sides legitimately differ) +is documented inline with the WHY. + +The auths AID for the fixed Ed25519 key is +EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J (the spike's value). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import oracle as o + +HERE = Path(__file__).resolve().parent +VEC = HERE / "vectors" + + +def canon(obj) -> str: + """Canonical JSON string: keys sorted recursively, compact separators. + + Numbers and strings keep their JSON forms; this only removes object-key + ordering and whitespace as sources of spurious mismatch. Both the auths + output and the keripy oracle output are real JSON, so this is a faithful + structural-equality check that still catches any value/type difference. + """ + return json.dumps(obj, sort_keys=True, separators=(",", ":")) + + +def load_vector(name: str) -> dict: + return json.loads((VEC / name).read_text()) + + +# ── Surface 1: ksn emit ────────────────────────────────────────────────────── +def test_ksn_emit(run_auths, write_json): + """keripy KEL → `auths key-state --from-kel` == keripy eventing.state(...). + + Byte-exact / structural: every field matches keripy's KeyStateRecord. The + only field that would diverge is `dt` — keripy `state()` defaults it to + now(); auths defaults --dt to the epoch. We pin BOTH to the epoch (oracle + stamp=epoch, auths default --dt=epoch), so no field is normalized away here. + """ + icp = o.icp_ed() + kel = write_json("kel.json", o.kel_json(icp)) + + out = run_auths(["key-state", "--from-kel", str(kel), "--json"]).json + expected = o.ksn_state(icp) # stamp defaults to epoch + + assert canon(out) == canon(expected) + assert canon(out) == canon(load_vector("ksn-emit.json")) + + +# ── Surface 2: ksn ingest ──────────────────────────────────────────────────── +def test_ksn_ingest(run_auths, write_json): + """keripy ksn JSON → `auths key-state --ingest` resolves the same (i, k, s). + + auths `--ingest` prints a normalized internal view (prefix/current_keys/ + sequence/last_event_said), NOT the wire ksn shape, so we map those fields to + the ksn (i, k, s) and compare. This is structural by design: the ingest + surface is gated on the RESOLVED key-state, not on echoing the wire bytes. + """ + icp = o.icp_ed() + ksn = write_json("ksn.json", o.ksn_ingest_input(icp)) + + out = run_auths(["key-state", "--ingest", str(ksn), "--json"]).json + resolved = { + "i": out["prefix"], + "k": out["current_keys"], + "s": str(out["sequence"]), + } + expected = o.ksn_ingest_expected(icp) + + assert canon(resolved) == canon(expected) + assert canon(resolved) == canon(load_vector("ksn-ingest-resolved.json")) + # And the resolved last-event SAID equals the ingested ksn's `d`. + assert out["last_event_said"] == o.ksn_ingest_input(icp)["d"] + + +# ── Surface 3: did:webs (Ed25519) ──────────────────────────────────────────── +def test_did_webs_ed25519(run_auths, write_json): + """keripy Ed25519 KEL → `auths did-webs` == keripy gen_did_document (OKP JWK). + + Byte-exact: id, verificationMethod (OKP x-only JWK), service, alsoKnownAs all + match. Nothing normalized away. + """ + icp = o.icp_ed() + kel = write_json("kel.json", o.kel_json(icp)) + + out = run_auths( + ["did-webs", "--from-kel", str(kel), "--domain", "example.com", "--json"] + ).json + expected = o.did_webs(icp, o.ed_verfer(), "example.com") + + assert canon(out) == canon(expected) + assert canon(out) == canon(load_vector("did-webs-ed25519.json")) + + +# ── Surface 3b: did:webs (P-256) ───────────────────────────────────────────── +def test_did_webs_p256(run_auths, write_json): + """keripy P-256 KEL → `auths did-webs` == keripy gen_did_document (EC JWK). + + Byte-exact: the EC JWK carries x and y derived from the same SEC1 compressed + point keripy holds. Nothing normalized away. + """ + icp = o.icp_p256() + kel = write_json("kel-p256.json", o.kel_json(icp)) + + out = run_auths( + ["did-webs", "--from-kel", str(kel), "--domain", "example.com", "--json"] + ).json + expected = o.did_webs(icp, o.p256_verfer(), "example.com") + + assert canon(out) == canon(expected) + assert canon(out) == canon(load_vector("did-webs-p256.json")) + + +# ── Surface 4: oobi endpoint ───────────────────────────────────────────────── +def test_oobi_endpoint(run_auths, write_json): + """keripy KEL → `auths oobi endpoint` /loc/scheme + /end/role/add == keripy reply(). + + Byte-exact including SAIDs. The auths `--json` stdout is the OOBI URL on the + first line followed by the two `rpy` reply JSON lines; we parse all three. + The dt is pinned to the epoch (microsecond form) on both sides. + """ + icp = o.icp_ed() + kel = write_json("kel.json", o.kel_json(icp)) + url = "http://127.0.0.1:5642/" + + res = run_auths( + [ + "oobi", "endpoint", + "--from-kel", str(kel), + "--authority", "127.0.0.1:5642", + "--url", url, + "--json", + ] + ) + lines = res.stdout.splitlines() + oobi_url_line = lines[0].strip() + replies = [json.loads(line) for line in lines[1:] if line.strip()] + loc = next(r for r in replies if r["r"] == "/loc/scheme") + end = next(r for r in replies if r["r"] == "/end/role/add") + + assert oobi_url_line == o.oobi_url(icp, "127.0.0.1:5642") + + assert canon(loc) == canon(o.oobi_loc_scheme(icp, url)) + assert canon(loc) == canon(load_vector("oobi-loc-scheme.json")) + + assert canon(end) == canon(o.oobi_end_role(icp)) + assert canon(end) == canon(load_vector("oobi-end-role.json")) + + +# ── Surface 5: ipex grant ──────────────────────────────────────────────────── +def test_ipex_grant(run_auths, write_json): + """keripy ACDC → `auths ipex grant` == keripy ipexGrantExn/exchange. + + Byte-exact: the top-level `exn` SAID (`d`), the embedded ACDC (with its own + saidified `a.d`), and the embeds-section SAID (`e.d`) all match keripy. dt is + pinned (--dt on auths == date= on keripy). + """ + icp, icp2 = o.icp_ed(), o.icp_ed2() + sender, recipient = icp.pre, icp2.pre + acdc = write_json("acdc.json", o.ipex_acdc(sender, recipient)) + + out = run_auths( + [ + "ipex", "grant", + "--acdc", str(acdc), + "--sender", sender, + "--recipient", recipient, + "--dt", o.ACDC_DT, + "--json", + ] + ).json + expected = o.ipex_grant(sender, recipient) + + assert canon(out) == canon(expected) + assert canon(out) == canon(load_vector("ipex-grant.json")) + # The embeds-section SAID specifically must match (the load-bearing detail). + assert out["e"]["d"] == expected["e"]["d"] + + +# ── Surface 6: ipex admit ──────────────────────────────────────────────────── +def test_ipex_admit(run_auths, write_json): + """That grant → `auths ipex admit` == keripy ipexAdmitExn/exchange. + + Byte-exact: the admit `exn` SAID (`d`) and its prior (`p` = the grant SAID) + match keripy. We first produce the grant via the CLI, then admit it. + """ + icp, icp2 = o.icp_ed(), o.icp_ed2() + sender, recipient = icp.pre, icp2.pre + acdc = write_json("acdc.json", o.ipex_acdc(sender, recipient)) + + grant_res = run_auths( + [ + "ipex", "grant", + "--acdc", str(acdc), + "--sender", sender, + "--recipient", recipient, + "--dt", o.ACDC_DT, + "--json", + ] + ) + grant_path = write_json("grant.json", json.loads(grant_res.stdout)) + + out = run_auths( + [ + "ipex", "admit", + "--grant", str(grant_path), + "--sender", recipient, + "--dt", o.ACDC_DT, + "--json", + ] + ).json + expected = o.ipex_admit(sender, recipient) + + assert canon(out) == canon(expected) + assert canon(out) == canon(load_vector("ipex-admit.json")) + # The prior must thread the grant's SAID (the IPEX loop-closing detail). + assert out["p"] == json.loads(grant_res.stdout)["d"] diff --git a/tests/conformance/test_vectors_provenance.py b/tests/conformance/test_vectors_provenance.py new file mode 100644 index 00000000..2d666ae2 --- /dev/null +++ b/tests/conformance/test_vectors_provenance.py @@ -0,0 +1,103 @@ +"""Provenance — the frozen vectors are provably keripy's. + +Re-runs gen_vectors.py's generation logic IN PROCESS (via the keripy oracle) and +asserts the freshly computed bytes equal the checked-in fixtures/ and vectors/ +files, byte-for-byte. If keripy changes a derivation (or someone hand-edits a +vector, or the version pin drifts), the sha256 changes and this fails — proving +the goldens were produced by keripy 1.3.4 and nothing else. + +It also validates MANIFEST.yaml: every entry's recorded sha256 matches the file +on disk, and the recorded keripy version equals the installed one. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest +import yaml + +import oracle as o + +HERE = Path(__file__).resolve().parent +KERI_VERSION = __import__("keri").__version__ + + +def _canon_bytes(obj) -> bytes: + """Must match gen_vectors._canon_bytes exactly.""" + return (json.dumps(obj, sort_keys=True, separators=(",", ":")) + "\n").encode() + + +def _expected_files() -> dict[str, bytes]: + """The full set of {relpath: bytes} gen_vectors.py would write, from keripy.""" + icp, icp2, icp_p = o.icp_ed(), o.icp_ed2(), o.icp_p256() + sender, recipient = icp.pre, icp2.pre + return { + # fixtures (inputs) + "fixtures/kel-ed25519.json": _canon_bytes(o.kel_json(icp)), + "fixtures/kel-p256.json": _canon_bytes(o.kel_json(icp_p)), + "fixtures/ksn-ingest.json": _canon_bytes(o.ksn_ingest_input(icp)), + "fixtures/acdc.json": _canon_bytes(o.ipex_acdc(sender, recipient)), + # vectors (outputs) + "vectors/ksn-emit.json": _canon_bytes(o.ksn_state(icp)), + "vectors/ksn-ingest-resolved.json": _canon_bytes(o.ksn_ingest_expected(icp)), + "vectors/did-webs-ed25519.json": _canon_bytes( + o.did_webs(icp, o.ed_verfer(), "example.com") + ), + "vectors/did-webs-p256.json": _canon_bytes( + o.did_webs(icp_p, o.p256_verfer(), "example.com") + ), + "vectors/oobi-loc-scheme.json": _canon_bytes( + o.oobi_loc_scheme(icp, "http://127.0.0.1:5642/") + ), + "vectors/oobi-end-role.json": _canon_bytes(o.oobi_end_role(icp)), + "vectors/ipex-grant.json": _canon_bytes(o.ipex_grant(sender, recipient)), + "vectors/ipex-admit.json": _canon_bytes(o.ipex_admit(sender, recipient)), + } + + +@pytest.mark.parametrize("rel", list(_expected_files().keys())) +def test_vector_reproduces_byte_for_byte(rel): + """Each checked-in fixture/vector == freshly regenerated keripy bytes.""" + expected = _expected_files()[rel] + on_disk = (HERE / rel).read_bytes() + assert on_disk == expected, ( + f"{rel} drifted from keripy {KERI_VERSION}; re-run `python3 gen_vectors.py` " + "and review the diff (or a keripy version pin changed)" + ) + + +def test_manifest_matches_disk(): + """Every MANIFEST entry's sha256 matches the file, version matches keripy.""" + manifest = yaml.safe_load((HERE / "MANIFEST.yaml").read_text()) + entries = manifest["vectors"] + assert entries, "MANIFEST.yaml has no entries" + + for e in entries: + path = HERE / e["path"] + assert path.exists(), f"MANIFEST references missing file {e['path']}" + digest = hashlib.sha256(path.read_bytes()).hexdigest() + assert digest == e["sha256"], ( + f"{e['path']} sha256 mismatch: manifest {e['sha256']}, disk {digest}" + ) + assert e["source"] == "keripy" + assert e["version"] == KERI_VERSION, ( + f"{e['path']} recorded keripy {e['version']} but {KERI_VERSION} is installed" + ) + + +def test_manifest_covers_all_vector_files(): + """No fixture/vector file is missing from the MANIFEST (provenance gap).""" + manifest = yaml.safe_load((HERE / "MANIFEST.yaml").read_text()) + recorded = {e["path"] for e in manifest["vectors"]} + on_disk = { + f"{d}/{p.name}" + for d in ("fixtures", "vectors") + for p in (HERE / d).glob("*.json") + } + assert on_disk == recorded, ( + f"MANIFEST out of sync with disk; missing={on_disk - recorded} " + f"extra={recorded - on_disk}" + ) diff --git a/tests/conformance/vectors/did-webs-ed25519.json b/tests/conformance/vectors/did-webs-ed25519.json new file mode 100644 index 00000000..b587178e --- /dev/null +++ b/tests/conformance/vectors/did-webs-ed25519.json @@ -0,0 +1 @@ +{"alsoKnownAs":["did:keri:EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J"],"id":"did:webs:example.com:EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","service":[],"verificationMethod":[{"controller":"did:webs:example.com:EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","id":"#DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f","publicKeyJwk":{"crv":"Ed25519","kid":"DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f","kty":"OKP","x":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"},"type":"JsonWebKey"}]} diff --git a/tests/conformance/vectors/did-webs-p256.json b/tests/conformance/vectors/did-webs-p256.json new file mode 100644 index 00000000..4468efc8 --- /dev/null +++ b/tests/conformance/vectors/did-webs-p256.json @@ -0,0 +1 @@ +{"alsoKnownAs":["did:keri:EBsDyB7uU0uNYtO_t8qYIocgYIld5JCC62TCZclHYt0K"],"id":"did:webs:example.com:EBsDyB7uU0uNYtO_t8qYIocgYIld5JCC62TCZclHYt0K","service":[],"verificationMethod":[{"controller":"did:webs:example.com:EBsDyB7uU0uNYtO_t8qYIocgYIld5JCC62TCZclHYt0K","id":"#1AAJAlFcPW6545a5BNP-yn9U_c0MwemXvzddylFa0KbDtANf","publicKeyJwk":{"crv":"P-256","kid":"1AAJAlFcPW6545a5BNP-yn9U_c0MwemXvzddylFa0KbDtANf","kty":"EC","x":"UVw9brnjlrkE0_7Kf1T9zQzB6Ze_N13KUVrQpsO0A18","y":"RTa-OlDzGPv5pUdZAqIhUCvvDVfgjFOyzApW8X2fk1Q"},"type":"JsonWebKey"}]} diff --git a/tests/conformance/vectors/ipex-admit.json b/tests/conformance/vectors/ipex-admit.json new file mode 100644 index 00000000..64950e48 --- /dev/null +++ b/tests/conformance/vectors/ipex-admit.json @@ -0,0 +1 @@ +{"a":{"m":""},"d":"EGIoEIE8A2HIclGXWHxZV0rC0ESOfHrykyMRObPvGg03","dt":"2024-01-01T00:00:00.000000+00:00","e":{},"i":"EIL3BI6FNMvZKzQw4W_VPPW9amBlIbxVQxENDAUYgjOS","p":"EO_tOX6rN3E_LEMGbiK88hOtdWMyFhmD1NaY3KlCnkEY","q":{},"r":"/ipex/admit","rp":"","t":"exn","v":"KERI10JSON000119_"} diff --git a/tests/conformance/vectors/ipex-grant.json b/tests/conformance/vectors/ipex-grant.json new file mode 100644 index 00000000..0f9a3339 --- /dev/null +++ b/tests/conformance/vectors/ipex-grant.json @@ -0,0 +1 @@ +{"a":{"i":"EIL3BI6FNMvZKzQw4W_VPPW9amBlIbxVQxENDAUYgjOS","m":""},"d":"EO_tOX6rN3E_LEMGbiK88hOtdWMyFhmD1NaY3KlCnkEY","dt":"2024-01-01T00:00:00.000000+00:00","e":{"acdc":{"a":{"d":"EM87KRWNVWdwTbahWe3X5pV-sspJ0cQcTYV0VBC2GDZ1","dt":"2024-01-01T00:00:00.000000+00:00","i":"EIL3BI6FNMvZKzQw4W_VPPW9amBlIbxVQxENDAUYgjOS"},"d":"EPEa_K4Qzu4pDckEIiGeTONlWy2db2a3tXcuksOHPSLl","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","ri":"EO0_SHla5Gnzc-T3jkTNAclpA1iv1L9k3lQZw5cFOe9o","s":"EMQWEcCnVRk1hatTNyK3sIykYSrrFvafX3bHQ9Gkk1kC","v":"ACDC10JSON00017a_"},"d":"EDU_pMrI77AvMvdzZ9BBrs98VUZNP1pBSLYQVX8WhXdZ"},"i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","p":"","q":{},"r":"/ipex/grant","rp":"","t":"exn","v":"KERI10JSON0002d4_"} diff --git a/tests/conformance/vectors/ksn-emit.json b/tests/conformance/vectors/ksn-emit.json new file mode 100644 index 00000000..383326ab --- /dev/null +++ b/tests/conformance/vectors/ksn-emit.json @@ -0,0 +1 @@ +{"b":[],"bt":"0","c":[],"d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","di":"","dt":"1970-01-01T00:00:00+00:00","ee":{"ba":[],"br":[],"d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","s":"0"},"et":"icp","f":"0","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"kt":"1","n":[],"nt":"0","p":"","s":"0","vn":[1,0]} diff --git a/tests/conformance/vectors/ksn-ingest-resolved.json b/tests/conformance/vectors/ksn-ingest-resolved.json new file mode 100644 index 00000000..a192a71f --- /dev/null +++ b/tests/conformance/vectors/ksn-ingest-resolved.json @@ -0,0 +1 @@ +{"i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"s":"0"} diff --git a/tests/conformance/vectors/oobi-end-role.json b/tests/conformance/vectors/oobi-end-role.json new file mode 100644 index 00000000..704b0604 --- /dev/null +++ b/tests/conformance/vectors/oobi-end-role.json @@ -0,0 +1 @@ +{"a":{"cid":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","eid":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","role":"controller"},"d":"EA_36cdbznGcFrcATtYbt0RIm5txScKHZnHbCxVnqYsj","dt":"1970-01-01T00:00:00.000000+00:00","r":"/end/role/add","t":"rpy","v":"KERI10JSON000116_"} diff --git a/tests/conformance/vectors/oobi-loc-scheme.json b/tests/conformance/vectors/oobi-loc-scheme.json new file mode 100644 index 00000000..7fb132ab --- /dev/null +++ b/tests/conformance/vectors/oobi-loc-scheme.json @@ -0,0 +1 @@ +{"a":{"eid":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","scheme":"http","url":"http://127.0.0.1:5642/"},"d":"EIIHrrm_0YOEV0kRDFTa-cuVMr0xabjfRojg2Mwv7wxj","dt":"1970-01-01T00:00:00.000000+00:00","r":"/loc/scheme","t":"rpy","v":"KERI10JSON0000fa_"}