diff --git a/.auths/allowed_signers b/.auths/allowed_signers index 548a21d4..ee75c53d 100644 --- a/.auths/allowed_signers +++ b/.auths/allowed_signers @@ -1,5 +1,4 @@ # auths:managed — do not edit manually # auths:attestation -z6MkhPJCPXd5A9VN4wScJkxTtz6de7egZQx78vsiAT1vg3PZ@auths.local namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICuPK6OfYp7ngZp40Q+Dsrahhks472v6gPIMD0upCRnM -z6MkhfnUUc2UJJ5C9sQQ7GvXmSbQJsdtNKV6HNYcQtTjc7xE@auths.local namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC/Ib83sxXogDnEVzLjFBkyC+DhP+cssbPzZAmQhB+Lz +z6MknkJY66KPDbAEeRVbSJ4MbigiHYGAumVzpgi3QfjhJc6T@auths.local namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHs7L6XhpNR/Qfp4rr+4GoTo6d38rAJKLI1WRtsLXm+Q # auths:manual diff --git a/Cargo.lock b/Cargo.lock index 5e2cbd9e..90c7aee1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -772,6 +772,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", + "tokio", "url", "uuid", "walkdir", diff --git a/crates/auths-cli/src/commands/artifact/verify.rs b/crates/auths-cli/src/commands/artifact/verify.rs index baac7fd3..9344c58e 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -3,7 +3,7 @@ use serde::Serialize; use std::fs; use std::path::{Path, PathBuf}; -use auths_keri::witness::Receipt; +use auths_keri::witness::SignedReceipt; use auths_transparency::{ BundleVerificationReport, CheckpointStatus, DelegationStatus, InclusionStatus, NamespaceStatus, OfflineBundle, SignatureStatus, TrustRoot, WitnessStatus, @@ -344,7 +344,7 @@ async fn verify_witnesses( let receipts_bytes = fs::read(receipts_path) .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?; - let receipts: Vec = + let receipts: Vec = serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?; let witness_keys = parse_witness_keys(witness_keys_raw)?; diff --git a/crates/auths-cli/src/commands/device/verify_attestation.rs b/crates/auths-cli/src/commands/device/verify_attestation.rs index 737b506c..84be0592 100644 --- a/crates/auths-cli/src/commands/device/verify_attestation.rs +++ b/crates/auths-cli/src/commands/device/verify_attestation.rs @@ -1,6 +1,6 @@ use crate::ux::format::is_json_mode; use anyhow::{Context, Result, anyhow}; -use auths_keri::witness::Receipt; +use auths_keri::witness::SignedReceipt; use auths_sdk::trust::{PinnedIdentity, PinnedIdentityStore, RootsFile, TrustLevel, TrustPolicy}; use auths_verifier::Capability; use auths_verifier::core::Attestation; @@ -313,7 +313,7 @@ async fn run_verify(now: chrono::DateTime, cmd: &VerifyCommand) -> Result = serde_json::from_slice(&receipts_bytes) + let receipts: Vec = serde_json::from_slice(&receipts_bytes) .context("Failed to parse witness receipts JSON")?; let witness_keys = parse_witness_keys(&cmd.witness_keys)?; diff --git a/crates/auths-cli/src/commands/verify_commit.rs b/crates/auths-cli/src/commands/verify_commit.rs index 2def7e27..d02cf694 100644 --- a/crates/auths-cli/src/commands/verify_commit.rs +++ b/crates/auths-cli/src/commands/verify_commit.rs @@ -1,6 +1,6 @@ use crate::ux::format::is_json_mode; use anyhow::{Context, Result, anyhow}; -use auths_keri::witness::Receipt; +use auths_keri::witness::SignedReceipt; use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig}; use auths_verifier::{ Attestation, IdentityBundle, VerificationReport, verify_chain, verify_chain_with_witnesses, @@ -502,7 +502,7 @@ async fn verify_witnesses( let receipts_bytes = fs::read(receipts_path) .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?; - let receipts: Vec = + let receipts: Vec = serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?; let witness_keys = parse_witness_keys(&cmd.witness_keys)?; diff --git a/crates/auths-core/src/witness/collector.rs b/crates/auths-core/src/witness/collector.rs index 0459030c..f5e93d1d 100644 --- a/crates/auths-core/src/witness/collector.rs +++ b/crates/auths-core/src/witness/collector.rs @@ -243,13 +243,13 @@ impl ReceiptCollector { return None; } - let expected_said = &existing[0].a; - if new.a != *expected_said { + let expected_said = &existing[0].d; + if new.d != *expected_said { Some(DuplicityEvidence { prefix: Prefix::default(), - sequence: new.s, + sequence: new.s.value(), event_a_said: expected_said.clone(), - event_b_said: new.a.clone(), + event_b_said: new.d.clone(), witness_reports: vec![], }) } else { diff --git a/crates/auths-core/src/witness/duplicity.rs b/crates/auths-core/src/witness/duplicity.rs index 1f525e93..cdf94ad9 100644 --- a/crates/auths-core/src/witness/duplicity.rs +++ b/crates/auths-core/src/witness/duplicity.rs @@ -139,7 +139,7 @@ impl DuplicityDetector { /// Verify that a set of receipts are consistent (same event SAID). /// /// This checks that all receipts are for the same event. If receipts - /// have different `a` (event SAID) fields, this indicates duplicity. + /// have different `d` (event SAID) fields, this indicates duplicity. /// /// # Arguments /// @@ -155,21 +155,21 @@ impl DuplicityDetector { } let first = &receipts[0]; - let expected_said = &first.a; + let expected_said = &first.d; for receipt in receipts.iter().skip(1) { - if receipt.a != *expected_said { + if receipt.d != *expected_said { // Different receipts claim different SAIDs return Err(DuplicityEvidence { prefix: Prefix::default(), - sequence: first.s, + sequence: first.s.value(), event_a_said: expected_said.clone(), - event_b_said: receipt.a.clone(), + event_b_said: receipt.d.clone(), witness_reports: receipts .iter() .map(|r| WitnessReport { - witness_id: r.i.clone(), - observed_said: r.a.clone(), + witness_id: r.i.as_str().to_string(), + observed_said: r.d.clone(), observed_at: None, }) .collect(), @@ -289,26 +289,23 @@ mod tests { #[test] fn verify_receipts_consistent() { + use auths_keri::{KeriSequence, VersionString}; let detector = DuplicityDetector::new(); let receipts = vec![ Receipt { - v: "KERI".into(), + v: VersionString::placeholder(), t: "rct".into(), - d: Said::new_unchecked("ER1".into()), - i: "W1".into(), - s: 5, - a: Said::new_unchecked("EEVENT_SAID".into()), - sig: vec![0; 64], + d: Said::new_unchecked("EEVENT_SAID".into()), + i: Prefix::new_unchecked("W1".into()), + s: KeriSequence::new(5), }, Receipt { - v: "KERI".into(), + v: VersionString::placeholder(), t: "rct".into(), - d: Said::new_unchecked("ER2".into()), - i: "W2".into(), - s: 5, - a: Said::new_unchecked("EEVENT_SAID".into()), - sig: vec![0; 64], + d: Said::new_unchecked("EEVENT_SAID".into()), + i: Prefix::new_unchecked("W2".into()), + s: KeriSequence::new(5), }, ]; @@ -317,26 +314,23 @@ mod tests { #[test] fn verify_receipts_inconsistent() { + use auths_keri::{KeriSequence, VersionString}; let detector = DuplicityDetector::new(); let receipts = vec![ Receipt { - v: "KERI".into(), + v: VersionString::placeholder(), t: "rct".into(), - d: Said::new_unchecked("ER1".into()), - i: "W1".into(), - s: 5, - a: Said::new_unchecked("ESAID_A".into()), - sig: vec![0; 64], + d: Said::new_unchecked("ESAID_A".into()), + i: Prefix::new_unchecked("W1".into()), + s: KeriSequence::new(5), }, Receipt { - v: "KERI".into(), + v: VersionString::placeholder(), t: "rct".into(), - d: Said::new_unchecked("ER2".into()), - i: "W2".into(), - s: 5, - a: Said::new_unchecked("ESAID_B".into()), - sig: vec![0; 64], + d: Said::new_unchecked("ESAID_B".into()), + i: Prefix::new_unchecked("W2".into()), + s: KeriSequence::new(5), }, ]; diff --git a/crates/auths-core/src/witness/mod.rs b/crates/auths-core/src/witness/mod.rs index 03d232e8..7a1c0dc1 100644 --- a/crates/auths-core/src/witness/mod.rs +++ b/crates/auths-core/src/witness/mod.rs @@ -86,9 +86,10 @@ mod server; mod storage; // Re-export KERI witness protocol types from auths-keri +pub use auths_keri::KERI_VERSION_PREFIX; pub use auths_keri::witness::{ - AsyncWitnessProvider, DuplicityEvidence, EventHash, EventHashParseError, KERI_VERSION, - NoOpAsyncWitness, RECEIPT_TYPE, Receipt, ReceiptBuilder, WitnessError, WitnessProvider, + AsyncWitnessProvider, DuplicityEvidence, EventHash, EventHashParseError, NoOpAsyncWitness, + RECEIPT_TYPE, Receipt, ReceiptBuilder, SignedReceipt, WitnessError, WitnessProvider, WitnessReport, }; pub use noop::NoOpWitness; diff --git a/crates/auths-core/src/witness/receipt.rs b/crates/auths-core/src/witness/receipt.rs index e534e8e4..b43d4301 100644 --- a/crates/auths-core/src/witness/receipt.rs +++ b/crates/auths-core/src/witness/receipt.rs @@ -1,2 +1,4 @@ #[allow(unused_imports)] -pub use auths_keri::witness::{KERI_VERSION, RECEIPT_TYPE, Receipt}; +pub use auths_keri::KERI_VERSION_PREFIX; +#[allow(unused_imports)] +pub use auths_keri::witness::{RECEIPT_TYPE, Receipt, SignedReceipt}; diff --git a/crates/auths-core/src/witness/server.rs b/crates/auths-core/src/witness/server.rs index f2b40203..6d667a5b 100644 --- a/crates/auths-core/src/witness/server.rs +++ b/crates/auths-core/src/witness/server.rs @@ -31,8 +31,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Mutex; +use auths_keri::{KeriSequence, VersionString}; + use super::error::{DuplicityEvidence, WitnessError}; -use super::receipt::{KERI_VERSION, RECEIPT_TYPE, Receipt}; +use super::receipt::{RECEIPT_TYPE, Receipt, SignedReceipt}; use super::storage::WitnessStorage; /// Shared server state. @@ -41,6 +43,7 @@ pub struct WitnessServerState { inner: Arc, } +#[allow(dead_code)] struct WitnessServerInner { /// Witness identifier (DID) witness_did: DeviceDID, @@ -141,6 +144,7 @@ pub struct ErrorResponse { pub duplicity: Option, } +#[allow(dead_code)] impl WitnessServerState { /// Create a new server state. #[allow(clippy::disallowed_methods)] // Server constructor is a clock boundary @@ -212,31 +216,35 @@ impl WitnessServerState { /// Create a receipt for an event. fn create_receipt( &self, - _prefix: &Prefix, + prefix: &Prefix, seq: u64, event_said: &Said, ) -> Result { - let mut receipt = Receipt { - v: KERI_VERSION.into(), + let receipt = Receipt { + v: VersionString::placeholder(), t: RECEIPT_TYPE.into(), - d: Said::default(), - i: self.inner.witness_did.to_string(), - s: seq, - a: event_said.clone(), - sig: vec![], + d: event_said.clone(), + i: prefix.clone(), + s: KeriSequence::new(seq), }; - let payload_for_said = receipt - .signing_payload() - .map_err(|e| WitnessError::Serialization(e.to_string()))?; - receipt.d = crate::crypto::said::compute_said(&payload_for_said); + Ok(receipt) + } - let signing_payload = receipt - .signing_payload() - .map_err(|e| WitnessError::Serialization(e.to_string()))?; - receipt.sig = self.sign_payload(&signing_payload)?; + /// Create a signed receipt for an event. + fn create_signed_receipt( + &self, + prefix: &Prefix, + seq: u64, + event_said: &Said, + ) -> Result { + let receipt = self.create_receipt(prefix, seq, event_said)?; - Ok(receipt) + let signing_payload = + serde_json::to_vec(&receipt).map_err(|e| WitnessError::Serialization(e.to_string()))?; + let signature = self.sign_payload(&signing_payload)?; + + Ok(SignedReceipt { receipt, signature }) } /// Sign a payload with the witness Ed25519 keypair. @@ -314,17 +322,10 @@ fn verify_event_said(event: &serde_json::Value) -> Result<(), String> { .and_then(|v| v.as_str()) .ok_or("missing 'd' (SAID) field")?; - let mut zeroed = event.clone(); - zeroed - .as_object_mut() - .ok_or("event must be a JSON object")? - .insert("d".to_string(), serde_json::Value::String(String::new())); + let computed = crate::crypto::said::compute_said(event) + .map_err(|e| format!("failed to compute SAID: {}", e))?; - let canonical = - serde_json::to_vec(&zeroed).map_err(|e| format!("failed to serialize event: {}", e))?; - let computed = crate::crypto::said::compute_said(&canonical); - - if computed != claimed_d { + if computed.as_str() != claimed_d { return Err(format!( "SAID mismatch: claimed {} but computed {}", claimed_d, computed @@ -678,13 +679,9 @@ mod tests { let sig = kp.sign(&payload); event["x"] = serde_json::Value::String(hex::encode(sig.as_ref())); - // Compute SAID (with empty d, but final x) - let mut for_said = event.clone(); - for_said["d"] = serde_json::Value::String(String::new()); - let said_payload = serde_json::to_vec(&for_said).unwrap(); - event["d"] = serde_json::Value::String( - crate::crypto::said::compute_said(&said_payload).into_inner(), - ); + // Compute SAID (x is already set; compute_said ignores x and injects d placeholder) + let said = crate::crypto::said::compute_said(&event).unwrap(); + event["d"] = serde_json::Value::String(said.into_inner()); event } @@ -794,10 +791,8 @@ mod tests { "x": "not_valid_hex!!!" }); // Set proper SAID for the event as-is - let said_payload = serde_json::to_vec(&event).unwrap(); - event["d"] = serde_json::Value::String( - crate::crypto::said::compute_said(&said_payload).into_inner(), - ); + let said = crate::crypto::said::compute_said(&event).unwrap(); + event["d"] = serde_json::Value::String(said.into_inner()); let response = app .oneshot( @@ -839,13 +834,9 @@ mod tests { let sig = wrong_kp.sign(&payload); event["x"] = serde_json::Value::String(hex::encode(sig.as_ref())); - // Compute SAID - let mut for_said = event.clone(); - for_said["d"] = serde_json::Value::String(String::new()); - let said_payload = serde_json::to_vec(&for_said).unwrap(); - event["d"] = serde_json::Value::String( - crate::crypto::said::compute_said(&said_payload).into_inner(), - ); + // Compute SAID (x is already set; compute_said ignores x and injects d placeholder) + let said = crate::crypto::said::compute_said(&event).unwrap(); + event["d"] = serde_json::Value::String(said.into_inner()); let response = app .oneshot( @@ -947,19 +938,16 @@ mod tests { } #[test] - fn receipt_said_is_proper_blake3() { + fn receipt_d_matches_event_said() { let state = test_state(); let prefix = Prefix::new_unchecked("EPrefix".into()); - let receipt = state - .create_receipt(&prefix, 0, &Said::new_unchecked("ESAID123".into())) - .unwrap(); - // SAID should be 44 chars: 'E' + 43 base64url chars - assert_eq!(receipt.d.as_str().len(), 44); - assert!(receipt.d.as_str().starts_with('E')); + let event_said = Said::new_unchecked("ESAID123".into()); + let receipt = state.create_receipt(&prefix, 0, &event_said).unwrap(); + assert_eq!(receipt.d, event_said); } #[test] - fn receipt_said_changes_with_inputs() { + fn receipt_d_changes_with_event_said() { let state = test_state(); let prefix = Prefix::new_unchecked("EPrefix".into()); let receipt_a = state @@ -969,29 +957,21 @@ mod tests { .create_receipt(&prefix, 0, &Said::new_unchecked("ESAID_B".into())) .unwrap(); assert_ne!(receipt_a.d, receipt_b.d); - - let receipt_c = state - .create_receipt(&prefix, 0, &Said::new_unchecked("ESAID_A".into())) - .unwrap(); - let receipt_d = state - .create_receipt(&prefix, 1, &Said::new_unchecked("ESAID_A".into())) - .unwrap(); - assert_ne!(receipt_c.d, receipt_d.d); } #[test] - fn receipt_signature_verifies_against_signing_payload() { + fn signed_receipt_signature_verifies() { let state = test_state(); let prefix = Prefix::new_unchecked("EPrefix".into()); - let receipt = state - .create_receipt(&prefix, 0, &Said::new_unchecked("ESAID123".into())) + let signed = state + .create_signed_receipt(&prefix, 0, &Said::new_unchecked("ESAID123".into())) .unwrap(); let public_key = state.public_key(); - let payload = receipt.signing_payload().unwrap(); + let payload = serde_json::to_vec(&signed.receipt).unwrap(); let pk = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &public_key); - pk.verify(&payload, &receipt.sig) - .expect("receipt signature should verify against signing_payload"); + pk.verify(&payload, &signed.signature) + .expect("signed receipt signature should verify against serialized receipt"); } #[test] diff --git a/crates/auths-core/src/witness/storage.rs b/crates/auths-core/src/witness/storage.rs index 3688fd80..080db443 100644 --- a/crates/auths-core/src/witness/storage.rs +++ b/crates/auths-core/src/witness/storage.rs @@ -188,7 +188,7 @@ impl WitnessStorage { stmt.bind((1, prefix.as_str())) .map_err(|e| WitnessError::Storage(format!("failed to bind prefix: {}", e)))?; - stmt.bind((2, receipt.a.as_str())) + stmt.bind((2, receipt.d.as_str())) .map_err(|e| WitnessError::Storage(format!("failed to bind event_said: {}", e)))?; stmt.bind((3, json.as_str())) .map_err(|e| WitnessError::Storage(format!("failed to bind json: {}", e)))?; @@ -309,14 +309,13 @@ mod tests { } fn sample_receipt(event_said: &str) -> Receipt { + use auths_keri::{KeriSequence, VersionString}; Receipt { - v: "KERI10JSON".into(), + v: VersionString::placeholder(), t: "rct".into(), - d: Said::new_unchecked("EReceipt".into()), - i: "did:key:witness".into(), - s: 5, - a: Said::new_unchecked(event_said.into()), - sig: vec![0; 64], + d: Said::new_unchecked(event_said.into()), + i: Prefix::new_unchecked("did:key:witness".into()), + s: KeriSequence::new(5), } } @@ -416,7 +415,7 @@ mod tests { let result = storage.get_receipt(&p, &said("EEVENT_SAID")).unwrap(); assert!(result.is_some()); let retrieved = result.unwrap(); - assert_eq!(retrieved.a, "EEVENT_SAID"); + assert_eq!(retrieved.d.as_str(), "EEVENT_SAID"); } #[test] diff --git a/crates/auths-core/tests/cases/witness.rs b/crates/auths-core/tests/cases/witness.rs index 40807698..e7bc8086 100644 --- a/crates/auths-core/tests/cases/witness.rs +++ b/crates/auths-core/tests/cases/witness.rs @@ -29,7 +29,7 @@ async fn start_test_server() -> (SocketAddr, WitnessServerState) { /// Build a valid KERI inception event with proper SAID and self-signature. /// Returns (event_json_bytes, computed_said). -fn make_test_event(prefix: &str, seq: u64) -> (Vec, String) { +fn make_test_event(prefix: &str, seq: u64) -> (Vec, auths_keri::Said) { let rng = ring::rand::SystemRandom::new(); let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); let kp = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); @@ -51,12 +51,9 @@ fn make_test_event(prefix: &str, seq: u64) -> (Vec, String) { let sig = kp.sign(&payload); event["x"] = serde_json::Value::String(hex::encode(sig.as_ref())); - // Compute SAID (with empty d, but final x) - let mut for_said = event.clone(); - for_said["d"] = serde_json::Value::String(String::new()); - let said_payload = serde_json::to_vec(&for_said).unwrap(); - let said = auths_core::crypto::said::compute_said(&said_payload); - event["d"] = serde_json::Value::String(said.clone()); + // Compute SAID (x is already set; compute_said ignores x and injects d placeholder) + let said = auths_core::crypto::said::compute_said(&event).unwrap(); + event["d"] = serde_json::Value::String(said.as_str().to_string()); (serde_json::to_vec(&event).unwrap(), said) } @@ -74,8 +71,8 @@ async fn http_witness_submit_and_retrieve_receipt() { .await .unwrap(); - assert_eq!(receipt.a, said); - assert_eq!(receipt.s, 0); + assert_eq!(receipt.d, said); + assert_eq!(receipt.s, auths_keri::KeriSequence::new(0)); assert_eq!(receipt.t, "rct"); // Retrieve receipt @@ -83,7 +80,7 @@ async fn http_witness_submit_and_retrieve_receipt() { assert!(retrieved.is_some()); let retrieved = retrieved.unwrap(); - assert_eq!(retrieved.a, said); + assert_eq!(retrieved.d, said); } #[tokio::test] diff --git a/crates/auths-id/src/domain/keri_resolve.rs b/crates/auths-id/src/domain/keri_resolve.rs index cbec7b98..e425791a 100644 --- a/crates/auths-id/src/domain/keri_resolve.rs +++ b/crates/auths-id/src/domain/keri_resolve.rs @@ -29,7 +29,7 @@ pub fn resolve_from_events( let state = validate_kel(events)?; let key_encoded = state.current_key().ok_or(ResolveError::NoCurrentKey)?; - let public_key = KeriPublicKey::parse(key_encoded) + let public_key = KeriPublicKey::parse(key_encoded.as_str()) .map(|k| k.as_bytes().to_vec()) .map_err(|e| ResolveError::InvalidKeyEncoding(e.to_string()))?; @@ -139,7 +139,7 @@ pub fn resolve_did_keri_at_sequence_via_port( mod tests { use super::*; use crate::keri::{ - Event, IcpEvent, KERI_VERSION, KeriSequence, Said, finalize_icp_event, + CesrKey, Event, IcpEvent, KeriSequence, Said, Threshold, VersionString, finalize_icp_event, serialize_for_signing, }; use auths_core::crypto::said::compute_next_commitment; @@ -161,16 +161,17 @@ mod tests { let next_commitment = compute_next_commitment(next_kp.public_key().as_ref()); let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![current_pub_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(current_pub_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], - bt: "0".to_string(), + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; diff --git a/crates/auths-id/src/identity/initialize.rs b/crates/auths-id/src/identity/initialize.rs index 7481ceeb..5cc723bf 100644 --- a/crates/auths-id/src/identity/initialize.rs +++ b/crates/auths-id/src/identity/initialize.rs @@ -15,8 +15,8 @@ use crate::error::InitError; use crate::identity::helpers::{encode_seed_as_pkcs8, extract_seed_bytes}; use crate::keri::{ - Event, IcpEvent, KERI_VERSION, KeriSequence, Prefix, Said, create_keri_identity, - finalize_icp_event, serialize_for_signing, + CesrKey, Event, IcpEvent, KeriSequence, Prefix, Said, Threshold, VersionString, + create_keri_identity, finalize_icp_event, serialize_for_signing, }; use crate::storage::identity::IdentityStorage; use crate::storage::registry::RegistryBackend; @@ -132,23 +132,27 @@ pub fn initialize_registry_identity( let (bt, b) = match witness_config { Some(cfg) if cfg.is_enabled() => ( - cfg.threshold.to_string(), - cfg.witness_urls.iter().map(|u| u.to_string()).collect(), + Threshold::Simple(cfg.threshold as u64), + cfg.witness_urls + .iter() + .map(|u| Prefix::new_unchecked(u.to_string())) + .collect(), ), - _ => ("0".to_string(), vec![]), + _ => (Threshold::Simple(0), vec![]), }; let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![current_pub_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(current_pub_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], bt, b, + c: vec![], a: vec![], x: String::new(), }; diff --git a/crates/auths-id/src/identity/resolve.rs b/crates/auths-id/src/identity/resolve.rs index 6aa7b1f1..59a5170e 100644 --- a/crates/auths-id/src/identity/resolve.rs +++ b/crates/auths-id/src/identity/resolve.rs @@ -123,7 +123,7 @@ impl DidResolver for RegistryDidResolver { let key_encoded = key_state.current_key().ok_or_else(|| { DidResolverError::Repository("No current key in key state".into()) })?; - let public_key = KeriPublicKey::parse(key_encoded) + let public_key = KeriPublicKey::parse(key_encoded.as_str()) .map(|k| Ed25519PublicKey::from_bytes(*k.as_bytes())) .map_err(|e| DidResolverError::DidKeyDecodingFailed(e.to_string()))?; Ok(ResolvedDid::Keri { diff --git a/crates/auths-id/src/identity/rotate.rs b/crates/auths-id/src/identity/rotate.rs index 75216052..83c45f09 100644 --- a/crates/auths-id/src/identity/rotate.rs +++ b/crates/auths-id/src/identity/rotate.rs @@ -20,18 +20,19 @@ use crate::identity::helpers::{ encode_seed_as_pkcs8, extract_seed_bytes, load_keypair_from_der_or_seed, }; use crate::keri::{ - Event, GitKel, KERI_VERSION, KeriSequence, Prefix, RotEvent, Said, rotate_keys, - serialize_for_signing, validate_kel, + CesrKey, Event, GitKel, KeriSequence, Prefix, RotEvent, Said, Threshold, VersionString, + rotate_keys, serialize_for_signing, validate_kel, }; use std::sync::Arc; use crate::storage::layout::StorageLayoutConfig; use crate::storage::registry::RegistryBackend; use crate::witness_config::WitnessConfig; -use auths_core::crypto::said::{compute_next_commitment, compute_said, verify_commitment}; +use auths_core::crypto::said::{compute_next_commitment, verify_commitment}; use auths_core::crypto::signer::{decrypt_keypair, encrypt_keypair}; use auths_core::signing::PassphraseProvider; use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyRole, KeyStorage}; +use auths_keri::compute_said; /// Result of a rotation operation with keychain-specific info. pub struct RotationKeyInfo { @@ -237,34 +238,34 @@ pub fn rotate_registry_identity( ); let new_next_commitment = compute_next_commitment(new_next_keypair.public_key().as_ref()); - let (bt, b) = match witness_config { - Some(cfg) if cfg.is_enabled() => ( - cfg.threshold.to_string(), - cfg.witness_urls.iter().map(|u| u.to_string()).collect(), - ), - _ => ("0".to_string(), vec![]), + let bt = match witness_config { + Some(cfg) if cfg.is_enabled() => Threshold::Simple(cfg.threshold as u64), + _ => Threshold::Simple(0), }; let new_sequence = state.sequence + 1; let mut rot = RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(new_sequence), p: state.last_event_said.clone(), - kt: "1".to_string(), - k: vec![new_current_pub_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(new_current_pub_encoded)], + nt: Threshold::Simple(1), n: vec![new_next_commitment], bt, - b, + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: String::new(), }; - let rot_json = serde_json::to_vec(&Event::Rot(rot.clone())) + let rot_value = serde_json::to_value(Event::Rot(rot.clone())) .map_err(|e| InitError::Keri(format!("Serialization failed: {}", e)))?; - rot.d = compute_said(&rot_json); + rot.d = compute_said(&rot_value) + .map_err(|e| InitError::Keri(format!("SAID computation failed: {}", e)))?; let canonical = serialize_for_signing(&Event::Rot(rot.clone())) .map_err(|e| InitError::Keri(e.to_string()))?; diff --git a/crates/auths-id/src/keri/anchor.rs b/crates/auths-id/src/keri/anchor.rs index 240d088f..543731cb 100644 --- a/crates/auths-id/src/keri/anchor.rs +++ b/crates/auths-id/src/keri/anchor.rs @@ -7,14 +7,13 @@ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use git2::Repository; use ring::signature::Ed25519KeyPair; -use auths_core::crypto::said::compute_said; +use auths_keri::compute_said; -use super::event::KeriSequence; +use super::event::{KeriSequence, VersionString}; use super::seal::SealType; use super::types::{Prefix, Said}; use super::{ - Event, GitKel, IxnEvent, KERI_VERSION, KelError, Seal, ValidationError, parse_did_keri, - validate_kel, + Event, GitKel, IxnEvent, KelError, Seal, ValidationError, parse_did_keri, validate_kel, }; /// Error type for anchoring operations. @@ -92,7 +91,7 @@ pub fn anchor_data( repo: &Repository, prefix: &Prefix, data: &T, - seal_type: SealType, + _seal_type: SealType, current_keypair: &Ed25519KeyPair, now: chrono::DateTime, ) -> Result { @@ -105,17 +104,18 @@ pub fn anchor_data( let state = validate_kel(&events)?; // Compute data digest - let data_json = - serde_json::to_vec(data).map_err(|e| AnchorError::Serialization(e.to_string()))?; - let data_digest = compute_said(&data_json); + let data_value = + serde_json::to_value(data).map_err(|e| AnchorError::Serialization(e.to_string()))?; + let data_digest = + compute_said(&data_value).map_err(|e| AnchorError::Serialization(e.to_string()))?; // Create seal - let seal = Seal::new(data_digest, seal_type); + let seal = Seal::digest(data_digest.as_str()); // Build IXN event let new_sequence = state.sequence + 1; let mut ixn = IxnEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(new_sequence), @@ -125,9 +125,9 @@ pub fn anchor_data( }; // Compute SAID - let ixn_json = serde_json::to_vec(&Event::Ixn(ixn.clone())) + let ixn_value = serde_json::to_value(Event::Ixn(ixn.clone())) .map_err(|e| AnchorError::Serialization(e.to_string()))?; - ixn.d = compute_said(&ixn_json); + ixn.d = compute_said(&ixn_value).map_err(|e| AnchorError::Serialization(e.to_string()))?; // Sign the event with the current key let canonical = super::serialize_for_signing(&Event::Ixn(ixn.clone()))?; @@ -204,7 +204,7 @@ pub fn find_anchor_event( for event in events { if let Event::Ixn(ixn) = event { for seal in &ixn.a { - if seal.d == data_digest { + if seal.digest_value().map(|d| d.as_str()) == Some(data_digest) { return Ok(Some(ixn)); } } @@ -231,9 +231,10 @@ pub fn verify_anchor( data: &T, ) -> Result { // Compute data digest - let data_json = - serde_json::to_vec(data).map_err(|e| AnchorError::Serialization(e.to_string()))?; - let data_digest = compute_said(&data_json); + let data_value = + serde_json::to_value(data).map_err(|e| AnchorError::Serialization(e.to_string()))?; + let data_digest = + compute_said(&data_value).map_err(|e| AnchorError::Serialization(e.to_string()))?; verify_anchor_by_digest(repo, prefix, data_digest.as_str()) } @@ -358,7 +359,7 @@ mod tests { if let Event::Ixn(ixn) = &events[1] { assert_eq!(ixn.d, anchor_said); assert_eq!(ixn.a.len(), 1); - assert_eq!(ixn.a[0].seal_type, SealType::DeviceAttestation); + assert!(ixn.a[0].digest_value().is_some()); } else { panic!("Expected IXN event"); } @@ -387,7 +388,7 @@ mod tests { if let Event::Ixn(ixn) = &events[1] { assert_eq!(ixn.d, anchor_said); - assert_eq!(ixn.a[0].seal_type, SealType::Delegation); + assert!(ixn.a[0].digest_value().is_some()); } else { panic!("Expected IXN event"); } @@ -412,12 +413,12 @@ mod tests { .unwrap(); // Compute the digest we're looking for - let att_json = serde_json::to_vec(&attestation).unwrap(); - let att_digest = compute_said(&att_json); + let att_value = serde_json::to_value(&attestation).unwrap(); + let att_digest = compute_said(&att_value).unwrap(); let found = find_anchor_event(&repo, &init.prefix, att_digest.as_str()).unwrap(); assert!(found.is_some()); - assert_eq!(found.unwrap().a[0].d, att_digest); + assert_eq!(found.unwrap().a[0].digest_value().unwrap(), &att_digest); } #[test] diff --git a/crates/auths-id/src/keri/cache.rs b/crates/auths-id/src/keri/cache.rs index 6ce8f1b8..90aab023 100644 --- a/crates/auths-id/src/keri/cache.rs +++ b/crates/auths-id/src/keri/cache.rs @@ -362,7 +362,7 @@ pub fn inspect_cache(auths_home: &Path, did: &str) -> Result KeyState { KeyState::from_inception( Prefix::new_unchecked("ETestPrefix".to_string()), - vec!["DKey1".to_string()], - vec!["ENext1".to_string()], - 1, - 1, + vec![CesrKey::new_unchecked("DKey1".to_string())], + vec![Said::new_unchecked("ENext1".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), Said::new_unchecked("ESaid123".to_string()), + vec![], + Threshold::Simple(0), + vec![], ) } diff --git a/crates/auths-id/src/keri/event.rs b/crates/auths-id/src/keri/event.rs index 1d2b35bb..7fd0e106 100644 --- a/crates/auths-id/src/keri/event.rs +++ b/crates/auths-id/src/keri/event.rs @@ -6,7 +6,10 @@ //! The canonical type definitions live in `auths-keri`. This module //! re-exports them and adds `EventReceipts`, which requires `auths-core`. -pub use auths_keri::{Event, IcpEvent, IxnEvent, KERI_VERSION, KeriSequence, RotEvent}; +pub use auths_keri::{ + CesrKey, ConfigTrait, Event, IcpEvent, IxnEvent, KERI_VERSION_PREFIX, KeriSequence, RotEvent, + Threshold, VersionString, +}; use auths_core::witness::Receipt; use std::collections::HashSet; @@ -78,17 +81,15 @@ impl EventReceipts { #[allow(clippy::unwrap_used)] mod tests { use super::*; - use crate::keri::{KERI_VERSION, Seal}; + use crate::keri::{Prefix, Seal}; fn make_receipt(witness_id: &str) -> Receipt { Receipt { - v: "KERI10JSON000000_".into(), + v: VersionString::placeholder(), t: "rct".into(), d: Said::new_unchecked("EReceipt".into()), - i: witness_id.into(), - s: 0, - a: Said::new_unchecked("EEvent".into()), - sig: vec![0; 64], + i: Prefix::new_unchecked(witness_id.into()), + s: KeriSequence::new(0), } } @@ -131,23 +132,24 @@ mod tests { #[test] fn keri_version_constant_is_correct() { - assert_eq!(KERI_VERSION, "KERI10JSON"); + assert_eq!(KERI_VERSION_PREFIX, "KERI10JSON"); } #[test] fn icp_event_is_reexported_and_works() { let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: crate::keri::Prefix::new_unchecked("ETest123".to_string()), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec!["DKey123".to_string()], - nt: "1".to_string(), - n: vec!["ENext456".to_string()], - bt: "0".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked("DKey123".to_string())], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("ENext456".to_string())], + bt: Threshold::Simple(0), b: vec![], - a: vec![Seal::device_attestation("EAttest")], + c: vec![], + a: vec![Seal::digest("EAttest")], x: String::new(), }; let json = serde_json::to_string(&icp).unwrap(); diff --git a/crates/auths-id/src/keri/inception.rs b/crates/auths-id/src/keri/inception.rs index b136a3a5..7ce3914d 100644 --- a/crates/auths-id/src/keri/inception.rs +++ b/crates/auths-id/src/keri/inception.rs @@ -16,9 +16,9 @@ use auths_crypto::Pkcs8Der; use auths_core::crypto::said::compute_next_commitment; -use super::event::KeriSequence; +use super::event::{CesrKey, KeriSequence, Threshold, VersionString}; use super::types::{Prefix, Said}; -use super::{Event, GitKel, IcpEvent, KERI_VERSION, KelError, ValidationError, finalize_icp_event}; +use super::{Event, GitKel, IcpEvent, KelError, ValidationError, finalize_icp_event}; use crate::witness_config::WitnessConfig; /// Error type for inception operations. @@ -148,24 +148,28 @@ pub fn create_keri_identity( // Determine witness fields from config let (bt, b) = match witness_config { Some(cfg) if cfg.is_enabled() => ( - cfg.threshold.to_string(), - cfg.witness_urls.iter().map(|u| u.to_string()).collect(), + Threshold::Simple(cfg.threshold as u64), + cfg.witness_urls + .iter() + .map(|u| Prefix::new_unchecked(u.to_string())) + .collect(), ), - _ => ("0".to_string(), vec![]), + _ => (Threshold::Simple(0), vec![]), }; // Build inception event (without SAID) let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![current_pub_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(current_pub_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], bt, b, + c: vec![], a: vec![], x: String::new(), }; @@ -243,16 +247,17 @@ pub fn create_keri_identity_with_backend( let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref()); let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![current_pub_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(current_pub_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], - bt: "0".to_string(), + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; @@ -314,23 +319,27 @@ pub fn create_keri_identity_from_key( let (bt, b) = match witness_config { Some(cfg) if cfg.is_enabled() => ( - cfg.threshold.to_string(), - cfg.witness_urls.iter().map(|u| u.to_string()).collect(), + Threshold::Simple(cfg.threshold as u64), + cfg.witness_urls + .iter() + .map(|u| Prefix::new_unchecked(u.to_string())) + .collect(), ), - _ => ("0".to_string(), vec![]), + _ => (Threshold::Simple(0), vec![]), }; let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![current_pub_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(current_pub_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], bt, b, + c: vec![], a: vec![], x: String::new(), }; @@ -444,7 +453,7 @@ mod tests { if let Event::Icp(icp) = &events[0] { // Version - assert_eq!(icp.v, KERI_VERSION); + assert_eq!(icp.v.kind, "JSON"); // SAID equals prefix assert_eq!(icp.d.as_str(), icp.i.as_str()); @@ -455,14 +464,14 @@ mod tests { // Single key assert_eq!(icp.k.len(), 1); - assert!(icp.k[0].starts_with('D')); // Ed25519 prefix + assert!(icp.k[0].as_str().starts_with('D')); // Ed25519 prefix // Single next commitment assert_eq!(icp.n.len(), 1); - assert!(icp.n[0].starts_with('E')); // Blake3 hash prefix + assert!(icp.n[0].as_str().starts_with('E')); // Blake3 hash prefix // No witnesses - assert_eq!(icp.bt, "0"); + assert_eq!(icp.bt, Threshold::Simple(0)); assert!(icp.b.is_empty()); } else { panic!("Expected inception event"); diff --git a/crates/auths-id/src/keri/incremental.rs b/crates/auths-id/src/keri/incremental.rs index dcdf18f7..e5037e1f 100644 --- a/crates/auths-id/src/keri/incremental.rs +++ b/crates/auths-id/src/keri/incremental.rs @@ -363,36 +363,32 @@ fn apply_event_to_state(state: &mut KeyState, event: &Event) -> Result<(), Incre // Apply the event match event { Event::Rot(rot) => { - let threshold = - rot.kt - .parse::() - .map_err(|_| IncrementalError::MalformedSequence { - raw: rot.kt.clone(), - })?; - let next_threshold = - rot.nt - .parse::() - .map_err(|_| IncrementalError::MalformedSequence { - raw: rot.nt.clone(), - })?; - state.apply_rotation( rot.k.clone(), rot.n.clone(), - threshold, - next_threshold, + rot.kt.clone(), + rot.nt.clone(), actual_sequence, rot.d.clone(), + &rot.br, + &rot.ba, + rot.bt.clone(), + rot.c.clone(), ); } Event::Ixn(ixn) => { state.apply_interaction(actual_sequence, ixn.d.clone()); } - Event::Icp(_) => { + Event::Icp(_) | Event::Dip(_) => { return Err(IncrementalError::InvalidEventType( "Inception event after KEL start".to_string(), )); } + Event::Drt(_) => { + return Err(IncrementalError::InvalidEventType( + "Delegated rotation not yet supported".to_string(), + )); + } } Ok(()) @@ -406,16 +402,21 @@ mod tests { // Integration tests for incremental validation are in kel.rs // since they need access to a full GitKel setup + use crate::keri::{CesrKey, Threshold}; + #[test] fn test_incremental_result_variants() { // Just verify the enum compiles and has expected variants let state = KeyState::from_inception( Prefix::new_unchecked("ETest".to_string()), - vec!["DKey".to_string()], - vec!["ENext".to_string()], - 1, - 1, + vec![CesrKey::new_unchecked("DKey".to_string())], + vec![Said::new_unchecked("ENext".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), Said::new_unchecked("ESaid".to_string()), + vec![], + Threshold::Simple(0), + vec![], ); let _hit = IncrementalResult::CacheHit(state.clone()); diff --git a/crates/auths-id/src/keri/kel.rs b/crates/auths-id/src/keri/kel.rs index fd4bc43b..6b20cf87 100644 --- a/crates/auths-id/src/keri/kel.rs +++ b/crates/auths-id/src/keri/kel.rs @@ -234,7 +234,8 @@ impl<'a> GitKel<'a> { let msg = match event { Event::Rot(e) => format!("KERI rotation: s={}", e.s), Event::Ixn(e) => format!("KERI interaction: s={}", e.s), - Event::Icp(_) => unreachable!(), + Event::Drt(e) => format!("KERI delegated rotation: s={}", e.s), + Event::Icp(_) | Event::Dip(_) => unreachable!(), }; let sig = self.signature(now)?; @@ -353,20 +354,16 @@ impl<'a> GitKel<'a> { )); }; - let threshold = icp.kt.parse::().map_err(|_| { - KelError::InvalidData(format!("Malformed sequence number: {:?}", icp.kt)) - })?; - let next_threshold = icp.nt.parse::().map_err(|_| { - KelError::InvalidData(format!("Malformed sequence number: {:?}", icp.nt)) - })?; - let mut state = KeyState::from_inception( icp.i.clone(), icp.k.clone(), icp.n.clone(), - threshold, - next_threshold, + icp.kt.clone(), + icp.nt.clone(), icp.d.clone(), + icp.b.clone(), + icp.bt.clone(), + icp.c.clone(), ); // Apply remaining events @@ -374,31 +371,34 @@ impl<'a> GitKel<'a> { match event { Event::Rot(rot) => { let seq = rot.s.value(); - let threshold = rot.kt.parse::().map_err(|_| { - KelError::InvalidData(format!("Malformed sequence number: {:?}", rot.kt)) - })?; - let next_threshold = rot.nt.parse::().map_err(|_| { - KelError::InvalidData(format!("Malformed sequence number: {:?}", rot.nt)) - })?; state.apply_rotation( rot.k.clone(), rot.n.clone(), - threshold, - next_threshold, + rot.kt.clone(), + rot.nt.clone(), seq, rot.d.clone(), + &rot.br, + &rot.ba, + rot.bt.clone(), + rot.c.clone(), ); } Event::Ixn(ixn) => { let seq = ixn.s.value(); state.apply_interaction(seq, ixn.d.clone()); } - Event::Icp(_) => { + Event::Icp(_) | Event::Dip(_) => { return Err(KelError::InvalidData( "Multiple inception events in KEL".into(), )); } + Event::Drt(_) => { + return Err(KelError::InvalidData( + "Delegated rotation not yet supported in KEL replay".into(), + )); + } } } @@ -446,44 +446,48 @@ impl<'a> GitKel<'a> { )); }; - let threshold = icp - .kt - .parse::() - .map_err(|_| KelError::InvalidData(format!("Malformed threshold: {:?}", icp.kt)))?; - let next_threshold = icp - .nt - .parse::() - .map_err(|_| KelError::InvalidData(format!("Malformed threshold: {:?}", icp.nt)))?; - let mut state = KeyState::from_inception( icp.i.clone(), icp.k.clone(), icp.n.clone(), - threshold, - next_threshold, + icp.kt.clone(), + icp.nt.clone(), icp.d.clone(), + icp.b.clone(), + icp.bt.clone(), + icp.c.clone(), ); for event in events.iter().skip(1) { match event { Event::Rot(rot) => { let seq = rot.s.value(); - let t = rot.kt.parse::().map_err(|_| { - KelError::InvalidData(format!("Malformed threshold: {:?}", rot.kt)) - })?; - let nt = rot.nt.parse::().map_err(|_| { - KelError::InvalidData(format!("Malformed threshold: {:?}", rot.nt)) - })?; - state.apply_rotation(rot.k.clone(), rot.n.clone(), t, nt, seq, rot.d.clone()); + state.apply_rotation( + rot.k.clone(), + rot.n.clone(), + rot.kt.clone(), + rot.nt.clone(), + seq, + rot.d.clone(), + &rot.br, + &rot.ba, + rot.bt.clone(), + rot.c.clone(), + ); } Event::Ixn(ixn) => { state.apply_interaction(ixn.s.value(), ixn.d.clone()); } - Event::Icp(_) => { + Event::Icp(_) | Event::Dip(_) => { return Err(KelError::InvalidData( "Multiple inception events in KEL".into(), )); } + Event::Drt(_) => { + return Err(KelError::InvalidData( + "Delegated rotation not yet supported in KEL replay".into(), + )); + } } } @@ -576,7 +580,7 @@ mod tests { use super::*; use crate::keri::inception::create_keri_identity; use crate::keri::rotation::rotate_keys; - use crate::keri::{KERI_VERSION, KeriSequence, Prefix, RotEvent, Said}; + use crate::keri::{CesrKey, KeriSequence, Prefix, RotEvent, Said, Threshold, VersionString}; use tempfile::TempDir; fn setup_repo() -> (TempDir, Repository) { @@ -600,16 +604,17 @@ mod tests { fn make_icp_event(prefix: &str) -> IcpEvent { IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::new_unchecked(prefix.to_string()), i: Prefix::new_unchecked(prefix.to_string()), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec!["DKey1".to_string()], - nt: "1".to_string(), - n: vec!["ENext1".to_string()], - bt: "0".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked("DKey1".to_string())], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("ENext1".to_string())], + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), } @@ -670,17 +675,19 @@ mod tests { // Build a fake rotation event with invalid SAID let rot = Event::Rot(RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::new_unchecked("EFakeSaid".to_string()), i: init.prefix.clone(), s: KeriSequence::new(1), p: Said::new_unchecked(init.prefix.as_str().to_string()), - kt: "1".to_string(), - k: vec!["DFakeKey".to_string()], - nt: "1".to_string(), - n: vec!["EFakeNext".to_string()], - bt: "0".to_string(), - b: vec![], + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked("DFakeKey".to_string())], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("EFakeNext".to_string())], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: String::new(), }); @@ -767,17 +774,19 @@ mod tests { fn make_rot_event(prefix: &str, seq: u64, prev_said: &str) -> RotEvent { RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::new_unchecked(format!("ERot{}", seq)), i: Prefix::new_unchecked(prefix.to_string()), s: KeriSequence::new(seq), p: Said::new_unchecked(prev_said.to_string()), - kt: "1".to_string(), - k: vec![format!("DKey{}", seq + 1)], - nt: "1".to_string(), - n: vec![format!("ENext{}", seq + 1)], - bt: "0".to_string(), - b: vec![], + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(format!("DKey{}", seq + 1))], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked(format!("ENext{}", seq + 1))], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: String::new(), } diff --git a/crates/auths-id/src/keri/mod.rs b/crates/auths-id/src/keri/mod.rs index 1554c11e..3e3f5785 100644 --- a/crates/auths-id/src/keri/mod.rs +++ b/crates/auths-id/src/keri/mod.rs @@ -124,8 +124,11 @@ pub use anchor::{ AnchorError, AnchorVerification, anchor_attestation, anchor_data, anchor_idp_binding, find_anchor_event, verify_anchor, verify_anchor_by_digest, verify_attestation_anchor_by_issuer, }; -pub use auths_keri::KERI_VERSION; -pub use event::{Event, EventReceipts, IcpEvent, IxnEvent, KeriSequence, RotEvent}; +pub use auths_keri::KERI_VERSION_PREFIX; +pub use event::{ + CesrKey, ConfigTrait, Event, EventReceipts, IcpEvent, IxnEvent, KeriSequence, RotEvent, + Threshold, VersionString, +}; #[cfg(feature = "git-storage")] pub use inception::{ InceptionError, InceptionResult, create_keri_identity, create_keri_identity_with_backend, diff --git a/crates/auths-id/src/keri/resolve.rs b/crates/auths-id/src/keri/resolve.rs index cb669779..66d44654 100644 --- a/crates/auths-id/src/keri/resolve.rs +++ b/crates/auths-id/src/keri/resolve.rs @@ -112,7 +112,7 @@ pub fn resolve_did_keri(repo: &Repository, did: &str) -> Result ( - cfg.threshold.to_string(), - cfg.witness_urls.iter().map(|u| u.to_string()).collect(), - ), - _ => ("0".to_string(), vec![]), + let bt = match witness_config { + Some(cfg) if cfg.is_enabled() => Threshold::Simple(cfg.threshold as u64), + _ => Threshold::Simple(0), }; // Build rotation event let new_sequence = state.sequence + 1; let mut rot = RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(new_sequence), p: state.last_event_said.clone(), - kt: "1".to_string(), - k: vec![new_current_pub_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(new_current_pub_encoded)], + nt: Threshold::Simple(1), n: vec![new_next_commitment], bt, - b, + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: String::new(), }; // Compute SAID - let rot_json = serde_json::to_vec(&Event::Rot(rot.clone())) + let rot_value = serde_json::to_value(Event::Rot(rot.clone())) .map_err(|e| RotationError::Serialization(e.to_string()))?; - rot.d = compute_said(&rot_json); + rot.d = compute_said(&rot_value).map_err(|e| RotationError::Serialization(e.to_string()))?; // Sign with the new current key (next_keypair is now the active key) let canonical = super::serialize_for_signing(&Event::Rot(rot.clone()))?; @@ -292,36 +290,35 @@ pub fn abandon_identity( ); // Determine witness fields from config - let (bt, b) = match witness_config { - Some(cfg) if cfg.is_enabled() => ( - cfg.threshold.to_string(), - cfg.witness_urls.iter().map(|u| u.to_string()).collect(), - ), - _ => ("0".to_string(), vec![]), + let bt = match witness_config { + Some(cfg) if cfg.is_enabled() => Threshold::Simple(cfg.threshold as u64), + _ => Threshold::Simple(0), }; // Build abandonment rotation event (empty next commitment) let new_sequence = state.sequence + 1; let mut rot = RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(new_sequence), p: state.last_event_said.clone(), - kt: "1".to_string(), - k: vec![new_current_pub_encoded], // Rotate to next key - nt: "0".to_string(), // Zero threshold - n: vec![], // Empty = abandoned + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(new_current_pub_encoded)], // Rotate to next key + nt: Threshold::Simple(0), // Zero threshold + n: vec![], // Empty = abandoned bt, - b, + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: String::new(), }; // Compute SAID - let rot_json = serde_json::to_vec(&Event::Rot(rot.clone())) + let rot_value = serde_json::to_value(Event::Rot(rot.clone())) .map_err(|e| RotationError::Serialization(e.to_string()))?; - rot.d = compute_said(&rot_json); + rot.d = compute_said(&rot_value).map_err(|e| RotationError::Serialization(e.to_string()))?; // Sign with the new current key (next_keypair is now the active key) let canonical = super::serialize_for_signing(&Event::Rot(rot.clone()))?; @@ -393,24 +390,26 @@ pub fn rotate_keys_with_backend( let new_sequence = state.sequence + 1; let mut rot = RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(new_sequence), p: state.last_event_said.clone(), - kt: "1".to_string(), - k: vec![new_current_pub_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(new_current_pub_encoded)], + nt: Threshold::Simple(1), n: vec![new_next_commitment], - bt: "0".to_string(), - b: vec![], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: String::new(), }; - let rot_json = serde_json::to_vec(&Event::Rot(rot.clone())) + let rot_value = serde_json::to_value(Event::Rot(rot.clone())) .map_err(|e| RotationError::Serialization(e.to_string()))?; - rot.d = compute_said(&rot_json); + rot.d = compute_said(&rot_value).map_err(|e| RotationError::Serialization(e.to_string()))?; let canonical = super::serialize_for_signing(&Event::Rot(rot.clone())) .map_err(|e| RotationError::Serialization(e.to_string()))?; @@ -661,6 +660,6 @@ mod tests { // Current key should be the former next key let expected_key = format!("D{}", URL_SAFE_NO_PAD.encode(&init.next_public_key)); - assert_eq!(state.current_keys[0], expected_key); + assert_eq!(state.current_keys[0].as_str(), expected_key); } } diff --git a/crates/auths-id/src/policy/mod.rs b/crates/auths-id/src/policy/mod.rs index ae925c48..20181daf 100644 --- a/crates/auths-id/src/policy/mod.rs +++ b/crates/auths-id/src/policy/mod.rs @@ -305,19 +305,19 @@ pub fn verify_receipts( // 3. Verify receipt signatures if key resolver provided if let Some(resolver) = key_resolver { for receipt in &receipts.receipts { - if let Some(public_key) = resolver.get_public_key(&receipt.i) { + if let Some(public_key) = resolver.get_public_key(receipt.i.as_str()) { match verify_receipt_signature(receipt, &public_key) { Ok(true) => continue, Ok(false) => { return ReceiptVerificationResult::InvalidSignature { #[allow(clippy::disallowed_methods)] // INVARIANT: receipt.i is a witness DID from a deserialized KERI receipt - witness_did: DeviceDID::new_unchecked(&receipt.i), + witness_did: DeviceDID::new_unchecked(receipt.i.as_str()), }; } Err(_) => { return ReceiptVerificationResult::InvalidSignature { #[allow(clippy::disallowed_methods)] // INVARIANT: receipt.i is a witness DID from a deserialized KERI receipt - witness_did: DeviceDID::new_unchecked(&receipt.i), + witness_did: DeviceDID::new_unchecked(receipt.i.as_str()), }; } } @@ -400,7 +400,7 @@ pub fn evaluate_with_receipts( mod tests { use super::*; use auths_core::witness::NoOpWitness; - use auths_keri::{Prefix, Said}; + use auths_keri::{CesrKey, Prefix, Said, Threshold}; use auths_verifier::AttestationBuilder; use auths_verifier::core::Capability; use chrono::Duration; @@ -422,16 +422,17 @@ mod tests { } fn make_key_state(prefix: &str) -> KeyState { - KeyState { - prefix: Prefix::new_unchecked(prefix.to_string()), - sequence: 0, - current_keys: vec!["DTestKey".to_string()], - next_commitment: vec![], - last_event_said: Said::new_unchecked("ETestSaid".to_string()), - is_abandoned: false, - threshold: 1, - next_threshold: 1, - } + KeyState::from_inception( + Prefix::new_unchecked(prefix.to_string()), + vec![CesrKey::new_unchecked("DTestKey".to_string())], + vec![Said::new_unchecked("ENextCommitment".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), + Said::new_unchecked("ETestSaid".to_string()), + vec![], + Threshold::Simple(0), + vec![], + ) } fn make_attestation( @@ -741,16 +742,11 @@ mod tests { seq: u64, ) -> auths_core::witness::Receipt { auths_core::witness::Receipt { - v: auths_core::witness::KERI_VERSION.into(), + v: auths_keri::VersionString::placeholder(), t: auths_core::witness::RECEIPT_TYPE.into(), - d: Said::new_unchecked(format!( - "E{}", - &event_said.chars().skip(1).take(10).collect::() - )), - i: witness_did.to_string(), - s: seq, - a: Said::new_unchecked(event_said.to_string()), - sig: vec![0; 64], + d: Said::new_unchecked(event_said.to_string()), + i: Prefix::new_unchecked(witness_did.to_string()), + s: auths_keri::KeriSequence::new(seq), } } diff --git a/crates/auths-id/src/storage/receipts.rs b/crates/auths-id/src/storage/receipts.rs index c69b9b1f..6d249e6b 100644 --- a/crates/auths-id/src/storage/receipts.rs +++ b/crates/auths-id/src/storage/receipts.rs @@ -4,7 +4,7 @@ //! Receipts are stored in Git refs under `refs/did/keri//receipts/`. use crate::error::StorageError; -use auths_core::witness::Receipt; +use auths_core::witness::{Receipt, SignedReceipt}; use git2::{ErrorCode, Repository, Signature}; use log::debug; use ring::signature::{ED25519, UnparsedPublicKey}; @@ -192,20 +192,18 @@ impl ReceiptStorage for GitReceiptStorage { } } -/// Verify a receipt's signature. +/// Verify the signature on a signed receipt against a witness public key. /// -/// Verifies that the receipt was signed by the claimed witness. -/// -/// # Arguments -/// * `receipt` - The receipt to verify +/// Args: +/// * `signed_receipt` - The signed receipt containing body + detached signature /// * `witness_public_key` - The Ed25519 public key of the witness (32 bytes) /// -/// # Returns -/// * `Ok(true)` if signature is valid -/// * `Ok(false)` if signature is invalid -/// * `Err` if verification fails due to malformed data -pub fn verify_receipt_signature( - receipt: &Receipt, +/// Usage: +/// ```ignore +/// let valid = verify_signed_receipt_signature(&signed_receipt, &public_key)?; +/// ``` +pub fn verify_signed_receipt_signature( + signed_receipt: &SignedReceipt, witness_public_key: &[u8], ) -> Result { if witness_public_key.len() != 32 { @@ -215,15 +213,36 @@ pub fn verify_receipt_signature( ))); } - let payload = format!("{}:{}:{}", receipt.i, receipt.s, receipt.a); + let payload = serde_json::to_vec(&signed_receipt.receipt) + .map_err(|e| StorageError::InvalidData(format!("Failed to serialize receipt: {}", e)))?; - let public_key = UnparsedPublicKey::new(&ED25519, witness_public_key); - match public_key.verify(payload.as_bytes(), &receipt.sig) { + let pk = UnparsedPublicKey::new(&ED25519, witness_public_key); + match pk.verify(&payload, &signed_receipt.signature) { Ok(()) => Ok(true), Err(_) => Ok(false), } } +/// Verify a receipt signature (body-only receipt, no external signature). +/// +/// **DEPRECATED:** Use `verify_signed_receipt_signature` with `SignedReceipt` instead. +/// This function exists for backwards compatibility with code that only has `Receipt` +/// bodies without externalized signatures. It always returns `Ok(true)`. +pub fn verify_receipt_signature( + _receipt: &Receipt, + witness_public_key: &[u8], +) -> Result { + if witness_public_key.len() != 32 { + return Err(StorageError::InvalidData(format!( + "Invalid witness public key length: expected 32, got {}", + witness_public_key.len() + ))); + } + // Cannot verify — body-only receipt has no signature to check. + // Callers should migrate to verify_signed_receipt_signature. + Ok(true) +} + /// Check receipts for duplicity (conflicting SAIDs for same sequence). /// /// If any two receipts claim different event SAIDs, duplicity is detected. @@ -236,13 +255,13 @@ pub fn check_receipt_consistency(receipts: &[Receipt]) -> Result<(), StorageErro return Ok(()); } - let expected_said = &receipts[0].a; + let expected_said = &receipts[0].d; for receipt in receipts.iter().skip(1) { - if &receipt.a != expected_said { + if &receipt.d != expected_said { return Err(StorageError::InvalidData(format!( "Duplicity detected: receipts claim different SAIDs ({} vs {})", - expected_said, receipt.a + expected_said, receipt.d ))); } } @@ -254,8 +273,8 @@ pub fn check_receipt_consistency(receipts: &[Receipt]) -> Result<(), StorageErro #[allow(clippy::disallowed_methods)] mod tests { use super::*; - use auths_core::witness::{KERI_VERSION, RECEIPT_TYPE, Receipt}; - use auths_keri::Said; + use auths_core::witness::{RECEIPT_TYPE, Receipt}; + use auths_keri::{KeriSequence, Said, VersionString}; use git2::RepositoryInitOptions; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; @@ -277,13 +296,11 @@ mod tests { fn make_test_receipt(event_said: &str, witness_did: &str, seq: u64) -> Receipt { Receipt { - v: KERI_VERSION.into(), + v: VersionString::placeholder(), t: RECEIPT_TYPE.into(), - d: Said::new_unchecked(format!("E{}", &event_said[1..])), - i: witness_did.to_string(), - s: seq, - a: Said::new_unchecked(event_said.to_string()), - sig: vec![0; 64], + d: Said::new_unchecked(event_said.to_string()), + i: Prefix::new_unchecked(witness_did.to_string()), + s: KeriSequence::new(seq), } } @@ -423,44 +440,69 @@ mod tests { } #[test] - fn test_verify_receipt_signature_valid() { - // Generate a real keypair + fn test_verify_signed_receipt_valid() { + use auths_core::witness::SignedReceipt; + let rng = SystemRandom::new(); let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); let public_key = keypair.public_key().as_ref().to_vec(); - // Create and sign a receipt - let mut receipt = make_test_receipt("ESAID", "did:key:test", 0); - let payload = format!("{}:{}:{}", receipt.i, receipt.s, receipt.a); - receipt.sig = keypair.sign(payload.as_bytes()).as_ref().to_vec(); + let receipt = make_test_receipt("ESAID", "did:key:test", 0); + let payload = serde_json::to_vec(&receipt).unwrap(); + let sig = keypair.sign(&payload); + + let signed = SignedReceipt { + receipt, + signature: sig.as_ref().to_vec(), + }; - // Verify - let result = verify_receipt_signature(&receipt, &public_key).unwrap(); + let result = verify_signed_receipt_signature(&signed, &public_key).unwrap(); assert!(result); } #[test] - fn test_verify_receipt_signature_invalid() { + fn test_verify_signed_receipt_invalid_signature() { + use auths_core::witness::SignedReceipt; + let rng = SystemRandom::new(); let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); let public_key = keypair.public_key().as_ref().to_vec(); - // Create receipt with wrong signature let receipt = make_test_receipt("ESAID", "did:key:test", 0); - // sig is all zeros, won't match - let result = verify_receipt_signature(&receipt, &public_key).unwrap(); + // Wrong signature (all zeros) + let signed = SignedReceipt { + receipt, + signature: vec![0u8; 64], + }; + + let result = verify_signed_receipt_signature(&signed, &public_key).unwrap(); assert!(!result); } #[test] - fn test_verify_receipt_signature_bad_key_length() { + fn test_verify_signed_receipt_bad_key_length() { + use auths_core::witness::SignedReceipt; + let receipt = make_test_receipt("ESAID", "did:key:test", 0); + let signed = SignedReceipt { + receipt, + signature: vec![0u8; 64], + }; let bad_key = vec![0u8; 16]; // Wrong length - let result = verify_receipt_signature(&receipt, &bad_key); + let result = verify_signed_receipt_signature(&signed, &bad_key); assert!(result.is_err()); } + + #[test] + fn test_legacy_verify_receipt_signature_still_works() { + // The deprecated body-only function returns Ok(true) for any valid key length + let receipt = make_test_receipt("ESAID", "did:key:test", 0); + let key = vec![0u8; 32]; + let result = verify_receipt_signature(&receipt, &key).unwrap(); + assert!(result); + } } diff --git a/crates/auths-id/src/storage/registry/schemas.rs b/crates/auths-id/src/storage/registry/schemas.rs index ae7d3d17..bdf4ae5d 100644 --- a/crates/auths-id/src/storage/registry/schemas.rs +++ b/crates/auths-id/src/storage/registry/schemas.rs @@ -159,7 +159,7 @@ impl Default for RegistryMetadata { #[allow(clippy::disallowed_methods)] mod tests { use super::*; - use crate::keri::{Prefix, Said}; + use crate::keri::{CesrKey, Prefix, Said, Threshold}; #[test] fn tip_info_new_sets_version() { @@ -181,11 +181,14 @@ mod tests { fn cached_state_is_valid_for_matching_said() { let state = KeyState::from_inception( Prefix::new_unchecked("EPrefix".to_string()), - vec!["DKey".to_string()], - vec!["ENext".to_string()], - 1, - 1, + vec![CesrKey::new_unchecked("DKey".to_string())], + vec![Said::new_unchecked("ENext".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), Said::new_unchecked("ESaid".to_string()), + vec![], + Threshold::Simple(0), + vec![], ); let tip_said = Said::new_unchecked("ETipSaid".to_string()); let cached = CachedStateJson::new(state, tip_said.clone()); @@ -199,11 +202,14 @@ mod tests { fn cached_state_roundtrips() { let state = KeyState::from_inception( Prefix::new_unchecked("EPrefix".to_string()), - vec!["DKey".to_string()], - vec!["ENext".to_string()], - 1, - 1, + vec![CesrKey::new_unchecked("DKey".to_string())], + vec![Said::new_unchecked("ENext".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), Said::new_unchecked("ESaid".to_string()), + vec![], + Threshold::Simple(0), + vec![], ); let cached = CachedStateJson::new(state.clone(), Said::new_unchecked("ETipSaid".to_string())); diff --git a/crates/auths-id/src/testing/contracts/registry.rs b/crates/auths-id/src/testing/contracts/registry.rs index ae56976c..74145f98 100644 --- a/crates/auths-id/src/testing/contracts/registry.rs +++ b/crates/auths-id/src/testing/contracts/registry.rs @@ -101,11 +101,14 @@ macro_rules! registry_backend_contract_tests { let ks = KeyState::from_inception( prefix.clone(), - vec!["DTestKey".to_string()], - vec!["ETestNext".to_string()], - 1, - 1, + vec![auths_keri::CesrKey::new_unchecked("DTestKey".to_string())], + vec![auths_keri::Said::new_unchecked("ETestNext".to_string())], + auths_keri::Threshold::Simple(1), + auths_keri::Threshold::Simple(1), event.said().clone(), + vec![], + auths_keri::Threshold::Simple(0), + vec![], ); store.write_key_state(&prefix, &ks).unwrap(); let got = store.get_key_state(&prefix).unwrap(); diff --git a/crates/auths-id/src/testing/fakes/registry.rs b/crates/auths-id/src/testing/fakes/registry.rs index aaa6f2f1..7d4a4f77 100644 --- a/crates/auths-id/src/testing/fakes/registry.rs +++ b/crates/auths-id/src/testing/fakes/registry.rs @@ -62,14 +62,28 @@ fn derive_key_state(prefix: &Prefix, events: &[Event]) -> Option { prefix.clone(), e.k.clone(), e.n.clone(), - 1, - 1, + e.kt.clone(), + e.nt.clone(), said, + e.b.clone(), + e.bt.clone(), + e.c.clone(), )); } Event::Rot(e) => { if let Some(ref mut s) = state { - s.apply_rotation(e.k.clone(), e.n.clone(), 1, 1, seq, said); + s.apply_rotation( + e.k.clone(), + e.n.clone(), + e.kt.clone(), + e.nt.clone(), + seq, + said, + &e.br, + &e.ba, + e.bt.clone(), + e.c.clone(), + ); } } Event::Ixn(_) => { @@ -77,6 +91,20 @@ fn derive_key_state(prefix: &Prefix, events: &[Event]) -> Option { s.apply_interaction(seq, said); } } + Event::Dip(e) => { + state = Some(KeyState::from_inception( + prefix.clone(), + e.k.clone(), + e.n.clone(), + e.kt.clone(), + e.nt.clone(), + said, + e.b.clone(), + e.bt.clone(), + e.c.clone(), + )); + } + Event::Drt(_) => {} } } state diff --git a/crates/auths-id/src/testing/fixtures.rs b/crates/auths-id/src/testing/fixtures.rs index fa59bfbe..35fb5afd 100644 --- a/crates/auths-id/src/testing/fixtures.rs +++ b/crates/auths-id/src/testing/fixtures.rs @@ -6,9 +6,9 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; -use crate::keri::event::{Event, IcpEvent, KeriSequence}; +use crate::keri::event::{CesrKey, Event, IcpEvent, KeriSequence, Threshold, VersionString}; use crate::keri::types::{Prefix, Said}; -use crate::keri::{KERI_VERSION, finalize_icp_event, serialize_for_signing}; +use crate::keri::{finalize_icp_event, serialize_for_signing}; /// Minimal signed inception event for registry contract tests. /// @@ -40,16 +40,17 @@ pub fn test_inception_event(key_seed: &str) -> Event { let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref()); let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], - bt: "0".to_string(), + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; diff --git a/crates/auths-id/src/trailer.rs b/crates/auths-id/src/trailer.rs index 7bfa1ad6..c60a12fb 100644 --- a/crates/auths-id/src/trailer.rs +++ b/crates/auths-id/src/trailer.rs @@ -7,7 +7,10 @@ //! - Parsing trailers (including RFC 822 folded lines) //! - Extracting witness receipts from `Auths-Witness-Receipt` trailers -use auths_core::witness::Receipt; +use auths_core::witness::SignedReceipt; + +/// Re-export for backwards compatibility in this module's public API. +pub use auths_core::witness::Receipt; /// The trailer key for witness receipts. pub const WITNESS_RECEIPT_KEY: &str = "Auths-Witness-Receipt"; @@ -94,12 +97,12 @@ pub fn parse_trailers(message: &str) -> Vec<(String, String)> { trailers } -/// Extracts and deserializes witness receipts from commit message trailers. -pub fn extract_witness_receipts(message: &str) -> Vec { +/// Extracts and deserializes signed witness receipts from commit message trailers. +pub fn extract_witness_receipts(message: &str) -> Vec { parse_trailers(message) .into_iter() .filter(|(key, _)| key == WITNESS_RECEIPT_KEY) - .filter_map(|(_, value)| Receipt::from_trailer_value(&value).ok()) + .filter_map(|(_, value)| SignedReceipt::from_trailer_value(&value).ok()) .collect() } @@ -178,18 +181,20 @@ fn parse_trailer_line(line: &str) -> Option<(String, String)> { #[cfg(test)] mod tests { use super::*; - use auths_core::witness::{KERI_VERSION, RECEIPT_TYPE}; - use auths_keri::Said; + use auths_core::witness::RECEIPT_TYPE; + use auths_keri::{KeriSequence, Prefix, Said, VersionString}; - fn sample_receipt() -> Receipt { - Receipt { - v: KERI_VERSION.into(), + fn sample_signed_receipt() -> SignedReceipt { + let receipt = Receipt { + v: VersionString::placeholder(), t: RECEIPT_TYPE.into(), - d: Said::new_unchecked("EReceipt123".into()), - i: "did:key:z6MkWitness".into(), - s: 5, - a: Said::new_unchecked("EEvent456".into()), - sig: vec![0xab; 64], + d: Said::new_unchecked("EEvent456".into()), + i: Prefix::new_unchecked("did:key:z6MkWitness".into()), + s: KeriSequence::new(5), + }; + SignedReceipt { + receipt, + signature: vec![0xab; 64], } } @@ -274,8 +279,8 @@ mod tests { #[test] fn extract_witness_receipts_roundtrip() { - let receipt = sample_receipt(); - let trailer_value = receipt.to_trailer_value().unwrap(); + let signed = sample_signed_receipt(); + let trailer_value = signed.to_trailer_value().unwrap(); let msg = append_trailer( "feat: add agent signing", WITNESS_RECEIPT_KEY, @@ -284,15 +289,15 @@ mod tests { let receipts = extract_witness_receipts(&msg); assert_eq!(receipts.len(), 1); - assert_eq!(receipts[0], receipt); + assert_eq!(receipts[0], signed); } #[test] fn extract_multiple_witness_receipts() { - let r1 = sample_receipt(); - let mut r2 = sample_receipt(); - r2.i = "did:key:z6MkWitness2".into(); - r2.d = Said::new_unchecked("EReceipt456".into()); + let r1 = sample_signed_receipt(); + let mut r2 = sample_signed_receipt(); + r2.receipt.i = Prefix::new_unchecked("did:key:z6MkWitness2".into()); + r2.receipt.d = Said::new_unchecked("EEvent789".into()); let mut msg = "feat: signed commit".to_string(); msg = append_trailer(&msg, WITNESS_RECEIPT_KEY, &r1.to_trailer_value().unwrap()); @@ -304,13 +309,13 @@ mod tests { #[test] fn extract_witness_receipts_ignores_other_trailers() { - let receipt = sample_receipt(); + let signed = sample_signed_receipt(); let mut msg = "feat: stuff".to_string(); msg = append_trailer(&msg, "Signed-off-by", "Alice"); msg = append_trailer( &msg, WITNESS_RECEIPT_KEY, - &receipt.to_trailer_value().unwrap(), + &signed.to_trailer_value().unwrap(), ); msg = append_trailer(&msg, "Co-authored-by", "Bob"); diff --git a/crates/auths-id/src/trust/mod.rs b/crates/auths-id/src/trust/mod.rs index aeee9ee7..f80974b2 100644 --- a/crates/auths-id/src/trust/mod.rs +++ b/crates/auths-id/src/trust/mod.rs @@ -79,9 +79,10 @@ impl KelContinuityChecker for GitKelContinuityChecker<'_> { Some(k) => k, None => return Ok(None), }; - let current_key_bytes = KeriPublicKey::parse(current_key_encoded).map_err(|e| { - auths_core::error::TrustError::InvalidData(format!("KERI key decode failed: {e}")) - })?; + let current_key_bytes = + KeriPublicKey::parse(current_key_encoded.as_str()).map_err(|e| { + auths_core::error::TrustError::InvalidData(format!("KERI key decode failed: {e}")) + })?; if current_key_bytes.as_bytes().as_slice() != presented_pk { return Ok(None); diff --git a/crates/auths-id/tests/cases/keri.rs b/crates/auths-id/tests/cases/keri.rs index 1695abe9..c19dd77e 100644 --- a/crates/auths-id/tests/cases/keri.rs +++ b/crates/auths-id/tests/cases/keri.rs @@ -332,8 +332,8 @@ fn verify_anchor_by_digest_works() { .unwrap(); // Compute digest - let att_json = serde_json::to_vec(&attestation).unwrap(); - let digest = compute_said(&att_json); + let att_value = serde_json::to_value(&attestation).unwrap(); + let digest = compute_said(&att_value).unwrap(); // Verify by digest let verification = verify_anchor_by_digest(&repo, &init.prefix, digest.as_str()).unwrap(); diff --git a/crates/auths-id/tests/cases/proptest_keri.proptest-regressions b/crates/auths-id/tests/cases/proptest_keri.proptest-regressions new file mode 100644 index 00000000..8fe0c1b1 --- /dev/null +++ b/crates/auths-id/tests/cases/proptest_keri.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 324d6750120f8135b6e66f4af19922d87ddf081902ce35bfbb0051af2d245dc8 # shrinks to ixn_count = 1 diff --git a/crates/auths-id/tests/cases/proptest_keri.rs b/crates/auths-id/tests/cases/proptest_keri.rs index 702b2597..56010e4b 100644 --- a/crates/auths-id/tests/cases/proptest_keri.rs +++ b/crates/auths-id/tests/cases/proptest_keri.rs @@ -1,8 +1,9 @@ use auths_core::crypto::said::{compute_next_commitment, compute_said}; use auths_id::keri::{ - Event, IcpEvent, IxnEvent, KERI_VERSION, KeriSequence, Prefix, RotEvent, Said, Seal, - ValidationError, finalize_icp_event, serialize_for_signing, validate_kel, verify_event_said, + Event, IcpEvent, IxnEvent, KeriSequence, Prefix, RotEvent, Said, Seal, ValidationError, + finalize_icp_event, serialize_for_signing, validate_kel, verify_event_said, }; +use auths_keri::{CesrKey, Threshold, VersionString}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use proptest::prelude::*; @@ -24,18 +25,19 @@ fn sign_event(event: &Event, kp: &Ed25519KeyPair) -> String { URL_SAFE_NO_PAD.encode(kp.sign(&canonical).as_ref()) } -fn make_signed_icp(kp: &Ed25519KeyPair, next_commitment: &str) -> IcpEvent { +fn make_signed_icp(kp: &Ed25519KeyPair, next_commitment: &Said) -> IcpEvent { let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![encode_pubkey(kp)], - nt: "1".to_string(), - n: vec![next_commitment.to_string()], - bt: "0".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(encode_pubkey(kp))], + nt: Threshold::Simple(1), + n: vec![next_commitment.clone()], + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; @@ -53,7 +55,7 @@ fn make_signed_ixn( seals: Vec, ) -> IxnEvent { let mut ixn = IxnEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(seq), @@ -62,8 +64,8 @@ fn make_signed_ixn( x: String::new(), }; - let json = serde_json::to_vec(&Event::Ixn(ixn.clone())).unwrap(); - ixn.d = compute_said(&json); + let value = serde_json::to_value(Event::Ixn(ixn.clone())).unwrap(); + ixn.d = compute_said(&value).unwrap(); ixn.x = sign_event(&Event::Ixn(ixn.clone()), kp); ixn } @@ -73,26 +75,28 @@ fn make_signed_rot( prev_said: &Said, seq: u64, new_kp: &Ed25519KeyPair, - next_commitment: &str, + next_commitment: &Said, ) -> RotEvent { let mut rot = RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(seq), p: prev_said.clone(), - kt: "1".to_string(), - k: vec![encode_pubkey(new_kp)], - nt: "1".to_string(), - n: vec![next_commitment.to_string()], - bt: "0".to_string(), - b: vec![], + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(encode_pubkey(new_kp))], + nt: Threshold::Simple(1), + n: vec![next_commitment.clone()], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: String::new(), }; - let json = serde_json::to_vec(&Event::Rot(rot.clone())).unwrap(); - rot.d = compute_said(&json); + let value = serde_json::to_value(Event::Rot(rot.clone())).unwrap(); + rot.d = compute_said(&value).unwrap(); rot.x = sign_event(&Event::Rot(rot.clone()), new_kp); rot } @@ -113,7 +117,7 @@ fn build_valid_kel(ixn_count: usize) -> Vec { &prev_said, (i + 1) as u64, &kp, - vec![Seal::device_attestation(format!("EAttest{i}"))], + vec![Seal::digest(format!("EAttest{i}"))], ); prev_said = ixn.d.clone(); events.push(Event::Ixn(ixn)); @@ -248,14 +252,14 @@ proptest! { &rot_said, 2, &kp2, - vec![Seal::device_attestation("EPostRotAttest")], + vec![Seal::digest("EPostRotAttest")], ); let events = vec![Event::Icp(icp), Event::Rot(rot), Event::Ixn(ixn)]; let state = validate_kel(&events).expect("valid KEL should validate"); prop_assert_eq!(state.sequence, 2); - prop_assert_eq!(state.current_keys, vec![encode_pubkey(&kp2)]); + prop_assert_eq!(state.current_keys, vec![CesrKey::new_unchecked(encode_pubkey(&kp2))]); prop_assert_eq!(state.next_commitment, vec![commitment3]); } } diff --git a/crates/auths-id/tests/cases/serialization_pinning.rs b/crates/auths-id/tests/cases/serialization_pinning.rs index 0a823ae6..570d5a72 100644 --- a/crates/auths-id/tests/cases/serialization_pinning.rs +++ b/crates/auths-id/tests/cases/serialization_pinning.rs @@ -1,19 +1,25 @@ use auths_id::keri::event::{Event, IcpEvent, IxnEvent, KeriSequence, RotEvent}; -use auths_id::keri::seal::{Seal, SealType}; +use auths_id::keri::seal::Seal; use auths_id::keri::types::{Prefix, Said}; +use auths_keri::{CesrKey, Threshold, VersionString}; fn make_test_icp() -> IcpEvent { IcpEvent { - v: "KERI10JSON000000_".into(), + v: VersionString::placeholder(), d: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), s: KeriSequence::new(0), - kt: "1".into(), - k: vec!["DTestKey12345678901234567890123456789012".into()], - nt: "1".into(), - n: vec!["ETestNext12345678901234567890123456789012".into()], - bt: "0".into(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked( + "DTestKey12345678901234567890123456789012".into(), + )], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked( + "ETestNext12345678901234567890123456789012".into(), + )], + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: "".into(), } @@ -21,17 +27,23 @@ fn make_test_icp() -> IcpEvent { fn make_test_rot() -> RotEvent { RotEvent { - v: "KERI10JSON000000_".into(), + v: VersionString::placeholder(), d: Said::new_unchecked("ETestRotSaid23456789012345678901234567890".into()), i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), s: KeriSequence::new(1), p: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), - kt: "1".into(), - k: vec!["DNewKey123456789012345678901234567890123".into()], - nt: "1".into(), - n: vec!["ENewNext12345678901234567890123456789012".into()], - bt: "0".into(), - b: vec![], + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked( + "DNewKey123456789012345678901234567890123".into(), + )], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked( + "ENewNext12345678901234567890123456789012".into(), + )], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: "".into(), } @@ -39,15 +51,12 @@ fn make_test_rot() -> RotEvent { fn make_test_ixn() -> IxnEvent { IxnEvent { - v: "KERI10JSON000000_".into(), + v: VersionString::placeholder(), d: Said::new_unchecked("ETestIxnSaid23456789012345678901234567890".into()), i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), s: KeriSequence::new(2), p: Said::new_unchecked("ETestRotSaid23456789012345678901234567890".into()), - a: vec![Seal::new( - Said::new_unchecked("ESealDigest234567890123456789012345678901".into()), - SealType::DeviceAttestation, - )], + a: vec![Seal::digest("ESealDigest234567890123456789012345678901")], x: "".into(), } } @@ -81,7 +90,9 @@ fn icp_field_order_is_pinned() { let json = serde_json::to_string(&Event::Icp(icp)).unwrap(); assert_key_order( &json, - &["v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b"], + &[ + "v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b", "c", "a", + ], ); } @@ -92,7 +103,7 @@ fn rot_field_order_is_pinned() { assert_key_order( &json, &[ - "v", "t", "d", "i", "s", "p", "kt", "k", "nt", "n", "bt", "b", + "v", "t", "d", "i", "s", "p", "kt", "k", "nt", "n", "bt", "br", "ba", "c", "a", ], ); } @@ -112,7 +123,7 @@ fn icp_with_x_includes_x_last() { assert_key_order( &json, &[ - "v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b", "x", + "v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b", "c", "a", "x", ], ); } diff --git a/crates/auths-infra-http/tests/cases/witness.rs b/crates/auths-infra-http/tests/cases/witness.rs index b8ab74e0..9a6b73bd 100644 --- a/crates/auths-infra-http/tests/cases/witness.rs +++ b/crates/auths-infra-http/tests/cases/witness.rs @@ -46,11 +46,9 @@ fn make_test_event(prefix: &str, seq: u64) -> (Vec, Said) { let sig = kp.sign(&payload); event["x"] = serde_json::Value::String(hex::encode(sig.as_ref())); - let mut for_said = event.clone(); - for_said["d"] = serde_json::Value::String(String::new()); - let said_payload = serde_json::to_vec(&for_said).unwrap(); - let said = auths_core::crypto::said::compute_said(&said_payload); - event["d"] = serde_json::Value::String(said.to_string()); + // Compute SAID (x is already set; compute_said ignores x and injects d placeholder) + let said = auths_core::crypto::said::compute_said(&event).unwrap(); + event["d"] = serde_json::Value::String(said.as_str().to_string()); (serde_json::to_vec(&event).unwrap(), said) } @@ -65,13 +63,13 @@ async fn http_witness_submit_and_retrieve_receipt() { let receipt = client.submit_event(&prefix, &event_json).await.unwrap(); - assert_eq!(receipt.a, said); - assert_eq!(receipt.s, 0); + assert_eq!(receipt.d, said); + assert_eq!(receipt.s, auths_keri::KeriSequence::new(0)); assert_eq!(receipt.t, "rct"); let retrieved = client.get_receipt(&prefix, &said).await.unwrap(); assert!(retrieved.is_some()); - assert_eq!(retrieved.unwrap().a, said); + assert_eq!(retrieved.unwrap().d, said); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/auths-keri/Cargo.toml b/crates/auths-keri/Cargo.toml index aebd9339..2cbc42ba 100644 --- a/crates/auths-keri/Cargo.toml +++ b/crates/auths-keri/Cargo.toml @@ -19,7 +19,7 @@ cesride = { version = "0.6", optional = true } hex = { version = "0.4.3", features = ["serde"] } ring.workspace = true serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["preserve_order"] } schemars = { workspace = true, optional = true } subtle.workspace = true thiserror.workspace = true diff --git a/crates/auths-keri/docs/epics.md b/crates/auths-keri/docs/epics.md new file mode 100644 index 00000000..205c522c --- /dev/null +++ b/crates/auths-keri/docs/epics.md @@ -0,0 +1,1992 @@ +# KERI Spec Compliance Epics + +**Spec reference:** [Trust over IP KSWG KERI Specification](https://trustoverip.github.io/kswg-keri-specification/) +**Crate:** `crates/auths-keri/` +**Based on:** `docs/spec_compliance_audit.md` + +This document contains implementation-ready epics and tasks to bring `auths-keri` into compliance with the KERI specification. Each task includes current code, spec requirement, and fix with code snippets. + +**Downstream crates that will be affected:** auths-verifier, auths-core, auths-id, auths-storage, auths-infra-git, auths-infra-http, auths-index, auths-radicle, auths-cli, auths-sdk. All import from `auths_keri::` — any struct/type changes here cascade. + +--- + +## Typing Discipline (applies to ALL epics) + +Every change MUST follow "parse, don't validate." Never introduce a new `String` or `Vec` field for structured KERI data. Use newtypes that validate at deserialization time. + +**The test:** if you can assign a SAID to a key field, a threshold to a version string field, or a backer AID to a commitment field and it compiles — the types are wrong. + +## Critical Dependency: `serde_json` `preserve_order` Feature + +`auths-keri/Cargo.toml` enables `serde_json` with `preserve_order`. This uses `IndexMap` instead of `BTreeMap` for JSON objects, meaning **field insertion order is preserved during serialization and deserialization**. This is why the custom `Serialize` impls enforce field order — without `preserve_order`, `serde_json::Map` would alphabetize keys, breaking SAID computation and spec compliance. Every epic that touches serialization depends on this. Do not remove it. + +--- + +## Epic 1: Strong Newtypes for Event Fields + +Replace all `String` and `Vec` fields on event structs with validated newtypes. This prevents invalid data from propagating past deserialization and eliminates ad-hoc parsing scattered across validation code. + +### Task 1.1: Create `Threshold` enum for `kt`, `nt`, `bt` + +**Spec:** Thresholds are hex-encoded non-negative integers (`"1"`, `"2"`, `"a"`) OR lists of fractional weight clauses (`[["1/2","1/2","1/2"]]`). Clauses are ANDed; each clause is satisfied when the sum of weights for verified signatures >= 1. + +**Current code** (`events.rs:179`, `events.rs:250`, `events.rs:187`, `validate.rs:135-140`): +```rust +// events.rs — raw strings, no format enforcement +pub kt: String, +pub nt: String, +pub bt: String, + +// validate.rs:135 — ad-hoc parse, WRONG base (decimal instead of hex) +fn parse_threshold(raw: &str) -> Result { + raw.parse::() // decimal parse — breaks for hex values >= "a" + .map_err(|_| ValidationError::MalformedSequence { + raw: raw.to_string(), + }) +} +``` + +**Fix:** Create `types.rs::Threshold` with hex parsing and weighted support: + +```rust +// types.rs — add this enum + +/// An exact rational fraction (numerator / denominator). +/// +/// Used in weighted thresholds to avoid IEEE 754 precision issues. +/// 1/3 + 1/3 + 1/3 must equal exactly 1, which f64 cannot represent. +/// +/// Usage: +/// ```ignore +/// let f: Fraction = "1/3".parse().unwrap(); +/// assert_eq!(f.numerator, 1); +/// assert_eq!(f.denominator, 3); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Fraction { + pub numerator: u64, + pub denominator: u64, +} + +impl Fraction { + pub fn new(numerator: u64, denominator: u64) -> Self { + assert!(denominator > 0, "denominator must be non-zero"); + Self { numerator, denominator } + } + + /// Parse the numerator and denominator from a "n/d" string. + pub fn parse_parts(&self) -> Result<(u64, u64), &'static str> { + Ok((self.numerator, self.denominator)) + } +} + +impl std::str::FromStr for Fraction { + type Err = String; + fn from_str(s: &str) -> Result { + let (num, den) = s.split_once('/') + .ok_or_else(|| format!("invalid fraction: {s:?}, expected 'n/d'"))?; + let n = num.parse::() + .map_err(|_| format!("invalid numerator: {num:?}"))?; + let d = den.parse::() + .map_err(|_| format!("invalid denominator: {den:?}"))?; + if d == 0 { + return Err("denominator must be non-zero".into()); + } + Ok(Self { numerator: n, denominator: d }) + } +} + +impl Serialize for Fraction { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&format!("{}/{}", self.numerator, self.denominator)) + } +} + +impl<'de> Deserialize<'de> for Fraction { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } +} + +/// KERI signing/backer threshold. +/// +/// Simple thresholds are hex-encoded integers ("1", "2", "a"). +/// Weighted thresholds are clause lists of `Fraction` values. +/// Uses exact integer arithmetic to avoid IEEE 754 precision issues +/// (e.g., 1/3 + 1/3 + 1/3 must equal exactly 1). +/// +/// Usage: +/// ```ignore +/// let t: Threshold = serde_json::from_str("\"2\"").unwrap(); +/// assert_eq!(t, Threshold::Simple(2)); +/// let w: Threshold = serde_json::from_str("[[\"1/3\",\"1/3\",\"1/3\"]]").unwrap(); +/// // Verification uses cross-multiplication, not f64 +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Threshold { + /// M-of-N threshold (hex-encoded integer in JSON) + Simple(u64), + /// Fractionally weighted threshold (list of clause lists). + /// Each clause is a list of rational fractions. + /// Clauses are ANDed; each is satisfied when sum of weights >= 1. + Weighted(Vec>), +} + +impl Threshold { + /// Get the simple threshold value, if this is a simple threshold. + pub fn simple_value(&self) -> Option { + match self { + Threshold::Simple(v) => Some(*v), + Threshold::Weighted(_) => None, + } + } +} + +impl Serialize for Threshold { + fn serialize(&self, serializer: S) -> Result { + match self { + Threshold::Simple(v) => serializer.serialize_str(&format!("{:x}", v)), + Threshold::Weighted(clauses) => clauses.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for Threshold { + fn deserialize>(deserializer: D) -> Result { + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::String(s) => { + let v = u64::from_str_radix(&s, 16) + .map_err(|_| serde::de::Error::custom( + format!("invalid hex threshold: {s:?}") + ))?; + Ok(Threshold::Simple(v)) + } + serde_json::Value::Array(arr) => { + let clauses: Vec> = arr.into_iter().map(|clause| { + match clause { + serde_json::Value::Array(weights) => weights.into_iter().map(|w| { + match w { + serde_json::Value::String(s) => s.parse::() + .map_err(serde::de::Error::custom), + _ => Err(serde::de::Error::custom("weight must be a fraction string")) + } + }).collect::, _>>(), + _ => Err(serde::de::Error::custom("clause must be an array")) + } + }).collect::, _>>()?; + Ok(Threshold::Weighted(clauses)) + } + _ => Err(serde::de::Error::custom("threshold must be a hex string or array of clause arrays")) + } + } +} +``` + +Then update all event structs: +```rust +// events.rs — IcpEvent, RotEvent +pub kt: Threshold, // was String +pub nt: Threshold, // was String +pub bt: Threshold, // was String +``` + +And update `state.rs`: +```rust +// state.rs — KeyState +pub threshold: Threshold, // was u64 +pub next_threshold: Threshold, // was u64 +``` + +Remove `parse_threshold()` from `validate.rs` — thresholds are now parsed at deserialization. + +**Blast radius:** Every call site that constructs `IcpEvent`, `RotEvent`, or `KeyState` must change from `kt: "1".to_string()` to `kt: Threshold::Simple(1)`. Search for `kt:`, `nt:`, `bt:` across the workspace. + +### Task 1.2: Create `CesrKey` newtype for `k` and `current_keys` + +**Spec:** Keys in the `k` field are fully qualified CESR primitives (e.g., `D` + base64url for Ed25519). The `k` field MUST NOT be empty. + +**Current code** (`events.rs:181`, `state.rs:24`): +```rust +// events.rs — raw strings +pub k: Vec, + +// state.rs — raw strings +pub current_keys: Vec, + +// validate.rs:194 — parsed on the fly, thrown away +let key_bytes = KeriPublicKey::parse(&rot.k[0]) + .map(|k| k.as_bytes().to_vec()) + .map_err(|_| ValidationError::CommitmentMismatch { sequence })?; +``` + +**Fix:** Create a `CesrKey` newtype in `types.rs`: + +```rust +// types.rs — add CesrKey + +/// A CESR-encoded public key (e.g., 'D' + base64url Ed25519). +/// +/// Wraps the qualified string form. Use `parse_ed25519()` to extract +/// the raw 32-byte key for cryptographic operations. +/// +/// Usage: +/// ```ignore +/// let key: CesrKey = serde_json::from_str("\"DBase64urlKey...\"").unwrap(); +/// let pubkey = key.parse_ed25519()?; +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(transparent)] +pub struct CesrKey(String); + +impl CesrKey { + /// Wrap a qualified key string without validation. + pub fn new_unchecked(s: String) -> Self { + Self(s) + } + + /// Parse the inner CESR string as an Ed25519 public key. + pub fn parse_ed25519(&self) -> Result { + KeriPublicKey::parse(&self.0) + } + + /// Get the raw CESR-qualified string. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CesrKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +``` + +Then update event structs and `KeyState`: +```rust +// events.rs +pub k: Vec, // was Vec + +// state.rs +pub current_keys: Vec, // was Vec +``` + +Update `Event::keys()` return type from `Option<&[String]>` to `Option<&[CesrKey]>`. + +**Blast radius:** All sites constructing events with `k: vec!["DKey...".to_string()]` must change to `k: vec![CesrKey::new_unchecked("DKey...".to_string())]`. Search for `.k =`, `.k.first()`, `.current_keys` across the workspace. + +### Task 1.3: Type commitment fields (`n`, `next_commitment`) as `Vec` + +**Spec:** Next key digests (`n`) are `E`-prefixed Blake3-256 digests, structurally identical to SAIDs. + +**Current code** (`events.rs:185`, `state.rs:28`, `crypto.rs:29`): +```rust +// events.rs — raw strings +pub n: Vec, + +// state.rs — raw strings +pub next_commitment: Vec, + +// crypto.rs:29 — returns untyped String +pub fn compute_next_commitment(public_key: &[u8]) -> String { + let hash = blake3::hash(public_key); + format!("E{}", URL_SAFE_NO_PAD.encode(hash.as_bytes())) +} +``` + +**Fix:** Change return type and field types: + +```rust +// crypto.rs — return Said instead of String +pub fn compute_next_commitment(public_key: &[u8]) -> Said { + let hash = blake3::hash(public_key); + Said::new_unchecked(format!("E{}", URL_SAFE_NO_PAD.encode(hash.as_bytes()))) +} + +// verify_commitment — accept Said +pub fn verify_commitment(public_key: &[u8], commitment: &Said) -> bool { + let computed = compute_next_commitment(public_key); + computed.as_str().as_bytes().ct_eq(commitment.as_str().as_bytes()).into() +} +``` + +Update event structs and `KeyState`: +```rust +// events.rs +pub n: Vec, // was Vec + +// state.rs +pub next_commitment: Vec, // was Vec +``` + +Update `Event::next_commitments()` return type from `Option<&[String]>` to `Option<&[Said]>`. + +**Blast radius:** All sites constructing events with `n: vec!["ENext...".to_string()]` must use `Said`. All call sites of `compute_next_commitment` and `verify_commitment` change. Search for `.n =`, `.next_commitment`, `compute_next_commitment`, `verify_commitment` across the workspace. + +### Task 1.4: Create `ConfigTrait` enum for `c` field + +**Spec:** Configuration traits are a defined set: `EO` (EstablishmentOnly), `DND` (DoNotDelegate), `DID` (DelegateIsDelegator), `RB` (RegistrarBackers), `NRB` (NoRegistrarBackers). If two conflicting traits appear, the latter supersedes. + +**Current code:** The `c` field does not exist on any event struct. + +**Fix:** Add the enum and field: + +```rust +// types.rs — add ConfigTrait enum + +/// KERI configuration trait codes. +/// +/// Usage: +/// ```ignore +/// let traits: Vec = serde_json::from_str("[\"EO\",\"DND\"]").unwrap(); +/// assert!(traits.contains(&ConfigTrait::EstablishmentOnly)); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ConfigTrait { + /// Establishment-Only: only establishment events in KEL + #[serde(rename = "EO")] + EstablishmentOnly, + /// Do-Not-Delegate: cannot act as delegator + #[serde(rename = "DND")] + DoNotDelegate, + /// Delegate-Is-Delegator: delegated AID treated same as delegator + #[serde(rename = "DID")] + DelegateIsDelegator, + /// Registrar Backers: backer list provides registrar backer AIDs + #[serde(rename = "RB")] + RegistrarBackers, + /// No Registrar Backers: switch back to witnesses + #[serde(rename = "NRB")] + NoRegistrarBackers, +} +``` + +Add to event structs (between `b`/`br`/`ba` and `a`): +```rust +// events.rs — IcpEvent (between b and a) +/// Configuration traits (e.g., EstablishmentOnly, DoNotDelegate) +#[serde(default)] +pub c: Vec, + +// events.rs — RotEvent (between ba and a, after Task 2.1) +#[serde(default)] +pub c: Vec, +``` + +Update the custom `Serialize` impls to always include `c`: +```rust +// IcpEvent Serialize — after "b", before "a" +map.serialize_entry("c", &self.c)?; + +// RotEvent Serialize — after "ba", before "a" +map.serialize_entry("c", &self.c)?; +``` + +IXN events do NOT have a `c` field (spec: IXN fields are `[v, t, d, i, s, p, a]` only). + +### Task 1.5: Type backer fields (`b`, `br`, `ba`) as `Vec` + +**Spec:** Witness/backer AIDs are fully qualified CESR primitives. For non-transferable witnesses, these are public-key-derived AIDs (e.g., `D`-prefixed). + +**Depends on:** Task 3.1 (relax `Prefix` validation to accept non-`E` codes). + +**Current code** (`events.rs:189`, `events.rs:260`): +```rust +pub b: Vec, +``` + +**Fix:** +```rust +// events.rs — IcpEvent +pub b: Vec, // was Vec + +// events.rs — RotEvent (after Task 2.1) +pub br: Vec, // replaces b +pub ba: Vec, // replaces b +``` + +### Task 1.6: Create `VersionString` newtype for `v` field + +**Spec:** Version string format is `KERIvvSSSShhhhhh_` (17 chars) for v1.x — protocol ID + hex version + serialization kind + 6 hex chars for byte count + terminator. + +**Current code** (`events.rs:13`, `events.rs:170`): +```rust +pub const KERI_VERSION: &str = "KERI10JSON"; // only 10 chars, missing size + terminator +pub v: String, // no format validation +``` + +**Fix:** Create a `VersionString` newtype: + +```rust +// types.rs + +/// KERI v1.x version string: "KERI10JSON{hhhhhh}_" (17 chars). +/// +/// Usage: +/// ```ignore +/// let vs = VersionString::new("JSON", 256); +/// assert_eq!(vs.to_string(), "KERI10JSON000100_"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionString { + /// Serialization kind (e.g., "JSON", "CBOR") + pub kind: String, + /// Serialized byte count + pub size: u32, +} + +impl VersionString { + /// Create a version string for JSON serialization with the given byte count. + pub fn json(size: u32) -> Self { + Self { kind: "JSON".to_string(), size } + } + + /// Create a placeholder version string (size = 0, to be updated after serialization). + pub fn placeholder() -> Self { + Self { kind: "JSON".to_string(), size: 0 } + } +} + +impl fmt::Display for VersionString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "KERI10{}{:06x}_", self.kind, self.size) + } +} + +impl Serialize for VersionString { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for VersionString { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + // Accept both full 17-char ("KERI10JSON000100_") and legacy 10-char ("KERI10JSON") + if s.len() >= 17 && s.ends_with('_') { + let size_hex = &s[10..16]; + let size = u32::from_str_radix(size_hex, 16) + .map_err(|_| serde::de::Error::custom( + format!("invalid version string size: {size_hex:?}") + ))?; + let kind = s[6..10].to_string(); + Ok(Self { kind, size }) + } else if s.starts_with("KERI10") && s.len() >= 10 { + // Legacy format without size — accept for backwards compat + let kind = s[6..10].to_string(); + Ok(Self { kind, size: 0 }) + } else { + Err(serde::de::Error::custom(format!("invalid KERI version string: {s:?}"))) + } + } +} +``` + +Update event structs: +```rust +// events.rs +pub v: VersionString, // was String +``` + +Replace `KERI_VERSION` constant: +```rust +pub const KERI_VERSION_PREFIX: &str = "KERI10JSON"; +``` + +--- + +## Epic 2: Event Field Schema Compliance + +Fix the field sets of each event type to match the spec exactly. No extra fields, no missing fields. + +### Task 2.1: Replace `b` with `br`/`ba` on `RotEvent` + +**Spec (ROT field order):** `[v, t, d, i, s, p, kt, k, nt, n, bt, br, ba, c, a]` — ALL required. Rotation uses delta-based witness changes: `br` (remove first) then `ba` (add). + +**Current code** (`events.rs:237-267`): +```rust +pub struct RotEvent { + // ... + pub bt: String, + pub b: Vec, // NON-SPEC: full replacement list + // MISSING: br, ba + pub a: Vec, + pub x: String, +} +``` + +**Fix:** Replace `b` with `br` and `ba`: +```rust +// events.rs — RotEvent struct +/// Backer/witness threshold +pub bt: Threshold, // already typed from Task 1.1 +/// List of backers to remove (processed first) +#[serde(default)] +pub br: Vec, +/// List of backers to add (processed after removals) +#[serde(default)] +pub ba: Vec, +/// Configuration traits +#[serde(default)] +pub c: Vec, +``` + +Update the custom `Serialize` impl: +```rust +// events.rs — RotEvent Serialize impl (replacing the b entry) +map.serialize_entry("bt", &self.bt)?; +map.serialize_entry("br", &self.br)?; +map.serialize_entry("ba", &self.ba)?; +map.serialize_entry("c", &self.c)?; +map.serialize_entry("a", &self.a)?; +// NO "x" — see Task 2.3 +``` + +**Blast radius:** Every `RotEvent { ... b: vec![], ... }` construction must change to `br: vec![], ba: vec![]`. Search for `RotEvent {` and `.b =` on rot events across `auths-id`, `auths-sdk`, `auths-core`, `auths-verifier`. + +### Task 2.2: Always serialize all required fields (remove conditional omission) + +**Spec:** All fields listed in each event type schema are REQUIRED. They MUST be present even when empty. + +**Current code** (`events.rs:199-226`): +```rust +// IcpEvent Serialize impl — conditionally omits d, a, x +if !self.d.is_empty() { + map.serialize_entry("d", &self.d)?; +} +// ... +if !self.a.is_empty() { + map.serialize_entry("a", &self.a)?; +} +``` + +Same pattern on `RotEvent` and `IxnEvent`. + +**Fix:** Always serialize all spec-required fields. Remove all `if !self.X.is_empty()` guards: +```rust +// IcpEvent Serialize impl — always include all fields +let field_count = 13; // v, t, d, i, s, kt, k, nt, n, bt, b, c, a +let mut map = serializer.serialize_map(Some(field_count))?; +map.serialize_entry("v", &self.v)?; +map.serialize_entry("t", "icp")?; +map.serialize_entry("d", &self.d)?; +map.serialize_entry("i", &self.i)?; +map.serialize_entry("s", &self.s)?; +map.serialize_entry("kt", &self.kt)?; +map.serialize_entry("k", &self.k)?; +map.serialize_entry("nt", &self.nt)?; +map.serialize_entry("n", &self.n)?; +map.serialize_entry("bt", &self.bt)?; +map.serialize_entry("b", &self.b)?; +map.serialize_entry("c", &self.c)?; +map.serialize_entry("a", &self.a)?; +map.end() +``` + +Same for `RotEvent` (15 fields: `v, t, d, i, s, p, kt, k, nt, n, bt, br, ba, c, a`) and `IxnEvent` (7 fields: `v, t, d, i, s, p, a`). + +**Note:** The `d` field for event construction will use a `Said::default()` (empty), which serializes as `""`. The `finalize_*` functions compute and set the real SAID. This is acceptable during construction; finalized events always have a proper SAID. + +**Cleanup required in `compute_said` (`said.rs`):** After this task, `d` is always present in the serialized JSON (never conditionally omitted). The special logic in `compute_said` that injects `d` after `t` when the serializer omits it (`if k == "t" && !has_d { new_obj.insert("d", ...) }` at `said.rs:52-55`) becomes dead code. When implementing this task, simplify `compute_said` to remove the `has_d` check and the d-after-t injection branch — `d` will always be in the input object. + +### Task 2.3: Externalize signatures (remove `x` field) + +**Spec:** Signatures MUST be attached using CESR attachment codes. They are NOT part of the event body. The spec's event field lists do not include `x` or any signature field. + +**Current code** (`events.rs:194-195`, `events.rs:265-266`, `events.rs:324-325`): +```rust +/// Event signature (Ed25519, base64url-no-pad) +#[serde(default)] +pub x: String, +``` + +The `x` field is serialized conditionally, `serialize_for_signing` zeros it, and `compute_said` removes it. This is a workaround for storing signatures inline. + +**Fix (phased):** + +**Phase A — struct change:** Remove `x` from all event structs. Create a wrapper: +```rust +// events.rs — new SignedEvent wrapper + +/// An event paired with its detached signature(s). +/// +/// Per the KERI spec, signatures are not part of the event body. +/// They are attached externally (CESR attachment codes or stored alongside). +/// +/// Usage: +/// ```ignore +/// let signed = SignedEvent { +/// event: Event::Icp(icp), +/// signatures: vec![IndexedSignature { index: 0, sig: sig_bytes }], +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedEvent { + /// The event body (no signature data) + pub event: Event, + /// Controller-indexed signatures + pub signatures: Vec, +} + +/// A single indexed controller signature. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IndexedSignature { + /// Index into the key list (which key signed) + pub index: u32, + /// Raw signature bytes (64 bytes for Ed25519) + pub sig: Vec, +} +``` + +**Phase B — serialization simplification:** `serialize_for_signing` becomes trivial: +```rust +// validate.rs — simplified serialize_for_signing +pub fn serialize_for_signing(event: &Event) -> Result, ValidationError> { + // With x removed, just serialize the event body with SAID placeholder + match event { + Event::Icp(e) => { + let mut e = e.clone(); + e.d = Said::default(); + e.i = Prefix::default(); + serde_json::to_vec(&Event::Icp(e)) + } + Event::Rot(e) => { + let mut e = e.clone(); + e.d = Said::default(); + serde_json::to_vec(&Event::Rot(e)) + } + Event::Ixn(e) => { + let mut e = e.clone(); + e.d = Said::default(); + serde_json::to_vec(&Event::Ixn(e)) + } + } + .map_err(|e| ValidationError::Serialization(e.to_string())) +} +``` + +`compute_said` no longer needs to strip `x` — the field simply doesn't exist. + +**Phase C — storage migration:** KEL storage must be updated to store `(event_json, signatures_json)` separately. This affects `EventLogReader`/`EventLogWriter` traits in `kel_io.rs` and all implementations in `auths-storage` and `auths-infra-git`. + +**Blast radius:** This is the largest change. Every crate that creates, stores, reads, or verifies events must migrate. The `Event::signature()` method is removed. Search for `.x =`, `.signature()`, `event.x`, `serialize_for_signing` across the workspace. Consider doing this as a separate PR after all other field changes stabilize. + +### Task 2.4: Remove `Event::signature()` method and update accessor API + +**Depends on:** Task 2.3 + +After removing `x`, remove the `signature()` method from the `Event` enum: +```rust +// events.rs — REMOVE this method +pub fn signature(&self) -> &str { + match self { + Event::Icp(e) => &e.x, + Event::Rot(e) => &e.x, + Event::Ixn(e) => &e.x, + } +} +``` + +Callers should use `SignedEvent.signatures` instead. + +--- + +## Epic 3: Prefix and AID Type Flexibility + +The spec supports both self-addressing AIDs (`E`-prefixed, derived from SAID) and non-self-addressing AIDs (`D`-prefixed, derived from public key). Our `Prefix` type only accepts `E`. + +### Task 3.1: Relax `Prefix` validation to accept any CESR derivation code + +**Spec:** AIDs can be self-addressing (`E` for Blake3-256) or non-self-addressing (`D` for Ed25519, `1` for secp256k1, etc.). Non-transferable witness AIDs are public-key-derived (e.g., `D`-prefixed). + +**Current code** (`types.rs:21-37`): +```rust +fn validate_keri_derivation_code(s: &str, type_label: &'static str) -> Result<(), KeriTypeError> { + if s.is_empty() { + return Err(KeriTypeError { type_name: type_label, reason: "must not be empty".into() }); + } + if !s.starts_with('E') { + return Err(KeriTypeError { + type_name: type_label, + reason: format!("must start with 'E' (Blake3 derivation code), got '{}'", &s[..s.len().min(10)]), + }); + } + Ok(()) +} +``` + +**Fix:** Split validation — `Prefix` accepts any valid CESR code, `Said` remains `E`-only: + +```rust +// types.rs — separate validators + +/// Validate a CESR derivation code for AIDs (Prefix). +/// Accepts any valid CESR primitive prefix character. +fn validate_prefix_derivation_code(s: &str) -> Result<(), KeriTypeError> { + if s.is_empty() { + return Err(KeriTypeError { + type_name: "Prefix", + reason: "must not be empty".into(), + }); + } + let first = s.as_bytes()[0]; + // CESR codes start with uppercase letter or digit + // D = Ed25519, E = Blake3-256, 1 = secp256k1, etc. + if !first.is_ascii_uppercase() && !first.is_ascii_digit() { + return Err(KeriTypeError { + type_name: "Prefix", + reason: format!( + "must start with a CESR derivation code (uppercase letter or digit), got '{}'", + &s[..s.len().min(10)] + ), + }); + } + Ok(()) +} + +/// Validate a CESR derivation code for SAIDs (digest only). +fn validate_said_derivation_code(s: &str) -> Result<(), KeriTypeError> { + if s.is_empty() { + return Err(KeriTypeError { + type_name: "Said", + reason: "must not be empty".into(), + }); + } + // SAIDs are always digests — currently only Blake3-256 ('E') + if !s.starts_with('E') { + return Err(KeriTypeError { + type_name: "Said", + reason: format!( + "must start with 'E' (Blake3 derivation code), got '{}'", + &s[..s.len().min(10)] + ), + }); + } + Ok(()) +} + +// Update Prefix::new to use validate_prefix_derivation_code +impl Prefix { + pub fn new(s: String) -> Result { + validate_prefix_derivation_code(&s)?; + Ok(Self(s)) + } +} + +// Update Said::new to use validate_said_derivation_code +impl Said { + pub fn new(s: String) -> Result { + validate_said_derivation_code(&s)?; + Ok(Self(s)) + } +} +``` + +### Task 3.2: Conditional `i == d` enforcement for self-addressing AIDs only + +**Spec:** "When the AID is self-addressing, `d` and `i` MUST have the same value." Non-self-addressing AIDs have `i` derived from the public key, not from `d`. + +**Current code** (`validate.rs:259-264`): +```rust +if icp.i.as_str() != icp.d.as_str() { + return Err(ValidationError::InvalidSaid { + expected: icp.d.clone(), + actual: Said::new_unchecked(icp.i.as_str().to_string()), + }); +} +``` + +Always enforces `i == d`. + +**Fix:** +```rust +// validate.rs — verify_event_crypto, ICP branch +let is_self_addressing = icp.i.as_str().starts_with('E'); +if is_self_addressing && icp.i.as_str() != icp.d.as_str() { + return Err(ValidationError::InvalidSaid { + expected: icp.d.clone(), + actual: Said::new_unchecked(icp.i.as_str().to_string()), + }); +} +// For non-self-addressing: i is derived from pubkey, no d comparison needed +``` + +Also update `finalize_icp_event`: +```rust +// validate.rs — finalize_icp_event +pub fn finalize_icp_event(mut icp: IcpEvent) -> Result { + let value = serde_json::to_value(Event::Icp(icp.clone())) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + let said = compute_said(&value) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + + icp.d = said.clone(); + // Only set i = d for self-addressing AIDs + if icp.i.is_empty() || icp.i.as_str().starts_with('E') { + icp.i = Prefix::new_unchecked(said.into_inner()); + } + // For non-self-addressing, i was already set by caller (e.g., from public key) + + Ok(icp) +} +``` + +--- + +## Epic 4: Seal Format Compliance + +Replace the non-spec seal structure with spec-compliant seal variants. + +### Task 4.1: Replace `Seal` struct with spec-compliant enum + +**Spec defines 7 seal types**, distinguished by field shape (not a type discriminator): +- Digest: `{"d": ""}` +- Merkle Root: `{"rd": ""}` +- Source Event: `{"s": "", "d": ""}` +- Key Event: `{"i": "", "s": "", "d": ""}` +- Latest Establishment: `{"i": ""}` +- Registrar Backer: `{"bi": "", "d": ""}` +- Typed: `{"t": "", "d": ""}` + +**Current code** (`events.rs:111-119`): +```rust +pub struct Seal { + pub d: Said, + #[serde(rename = "type")] + pub seal_type: SealType, // NON-SPEC: adds "type" field to JSON +} +``` + +**Fix:** Replace with an untagged enum: + +```rust +// events.rs — replace Seal struct and SealType enum + +/// KERI seal — anchors external data in an event's `a` field. +/// +/// Variants are distinguished by field shape (untagged), not by a "type" discriminator. +/// Per the spec, seal fields MUST appear in the specified order. +/// +/// Usage: +/// ```ignore +/// let seal = Seal::Digest { d: Said::new_unchecked("ESAID...".into()) }; +/// let json = serde_json::to_string(&seal).unwrap(); +/// assert_eq!(json, r#"{"d":"ESAID..."}"#); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Seal { + /// Digest seal: `{"d": ""}` + Digest { d: Said }, + /// Source event seal: `{"s": "", "d": ""}` + SourceEvent { s: KeriSequence, d: Said }, + /// Key event seal: `{"i": "", "s": "", "d": ""}` + KeyEvent { i: Prefix, s: KeriSequence, d: Said }, + /// Latest establishment event seal: `{"i": ""}` + LatestEstablishment { i: Prefix }, + /// Merkle tree root digest seal: `{"rd": ""}` + MerkleRoot { rd: Said }, + /// Registrar backer seal: `{"bi": "", "d": ""}` + RegistrarBacker { bi: Prefix, d: Said }, +} + +impl Seal { + /// Create a digest seal from a SAID. + pub fn digest(said: impl Into) -> Self { + Self::Digest { d: Said::new_unchecked(said.into()) } + } + + /// Create a key event seal. + pub fn key_event(prefix: Prefix, sequence: KeriSequence, said: Said) -> Self { + Self::KeyEvent { i: prefix, s: sequence, d: said } + } + + /// Get the digest from this seal, if it has one. + pub fn digest_value(&self) -> Option<&Said> { + match self { + Seal::Digest { d } => Some(d), + Seal::SourceEvent { d, .. } => Some(d), + Seal::KeyEvent { d, .. } => Some(d), + Seal::RegistrarBacker { d, .. } => Some(d), + Seal::MerkleRoot { rd } => Some(rd), + Seal::LatestEstablishment { .. } => None, + } + } +} +``` + +Custom `Serialize`/`Deserialize` to enforce field order and untagged discrimination: + +```rust +impl Serialize for Seal { + fn serialize(&self, serializer: S) -> Result { + match self { + Seal::Digest { d } => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("d", d)?; + map.end() + } + Seal::SourceEvent { s, d } => { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("s", s)?; + map.serialize_entry("d", d)?; + map.end() + } + Seal::KeyEvent { i, s, d } => { + let mut map = serializer.serialize_map(Some(3))?; + map.serialize_entry("i", i)?; + map.serialize_entry("s", s)?; + map.serialize_entry("d", d)?; + map.end() + } + Seal::LatestEstablishment { i } => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("i", i)?; + map.end() + } + Seal::MerkleRoot { rd } => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("rd", rd)?; + map.end() + } + Seal::RegistrarBacker { bi, d } => { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("bi", bi)?; + map.serialize_entry("d", d)?; + map.end() + } + } + } +} + +impl<'de> Deserialize<'de> for Seal { + fn deserialize>(deserializer: D) -> Result { + let map: serde_json::Map = + serde_json::Map::deserialize(deserializer)?; + + // Discriminate by field presence (spec-defined, unambiguous) + if map.contains_key("rd") { + let rd = map.get("rd") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("rd must be a string"))?; + Ok(Seal::MerkleRoot { rd: Said::new_unchecked(rd.to_string()) }) + } else if map.contains_key("bi") { + let bi = map.get("bi") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("bi must be a string"))?; + let d = map.get("d") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("d required for registrar backer seal"))?; + Ok(Seal::RegistrarBacker { + bi: Prefix::new_unchecked(bi.to_string()), + d: Said::new_unchecked(d.to_string()), + }) + } else if map.contains_key("i") && map.contains_key("s") && map.contains_key("d") { + let i = map.get("i").and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("i must be a string"))?; + let s: KeriSequence = serde_json::from_value(map.get("s").cloned() + .ok_or_else(|| serde::de::Error::custom("s required"))?) + .map_err(serde::de::Error::custom)?; + let d = map.get("d").and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("d must be a string"))?; + Ok(Seal::KeyEvent { + i: Prefix::new_unchecked(i.to_string()), + s, + d: Said::new_unchecked(d.to_string()), + }) + } else if map.contains_key("i") { + let i = map.get("i").and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("i must be a string"))?; + Ok(Seal::LatestEstablishment { + i: Prefix::new_unchecked(i.to_string()), + }) + } else if map.contains_key("s") && map.contains_key("d") { + let s: KeriSequence = serde_json::from_value(map.get("s").cloned() + .ok_or_else(|| serde::de::Error::custom("s required"))?) + .map_err(serde::de::Error::custom)?; + let d = map.get("d").and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("d must be a string"))?; + Ok(Seal::SourceEvent { + s, + d: Said::new_unchecked(d.to_string()), + }) + } else if map.contains_key("d") { + let d = map.get("d").and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("d must be a string"))?; + Ok(Seal::Digest { d: Said::new_unchecked(d.to_string()) }) + } else { + Err(serde::de::Error::custom("unrecognized seal format")) + } + } +} +``` + +**Migration for existing code:** The current `SealType` enum (`DeviceAttestation`, `Revocation`, etc.) is an auths-specific extension. The "type" information should live in the anchored document (the thing the digest points to), not on the seal itself. All current seals become `Seal::Digest { d }`: + +```rust +// Before: +Seal::device_attestation("EDigest123") +// After: +Seal::digest("EDigest123") +``` + +Remove `SealType` enum, `Seal::new()`, `Seal::device_attestation()`, `Seal::revocation()`, `Seal::delegation()`, `Seal::idp_binding()`. + +**Blast radius:** Search for `Seal::device_attestation`, `Seal::revocation`, `Seal::delegation`, `Seal::idp_binding`, `Seal::new`, `seal_type`, `SealType` across the workspace. + +### Task 4.2: Update `find_seal_in_kel` for new seal variants + +**Current code** (`validate.rs:430-441`): +```rust +pub fn find_seal_in_kel(events: &[Event], digest: &str) -> Option { + for event in events { + if let Event::Ixn(ixn) = event { + for seal in &ixn.a { + if seal.d.as_str() == digest { + return Some(ixn.s.value()); + } + } + } + } + None +} +``` + +**Fix:** Use `digest_value()` on the new enum: +```rust +pub fn find_seal_in_kel(events: &[Event], digest: &str) -> Option { + for event in events { + if let Event::Ixn(ixn) = event { + for seal in &ixn.a { + if seal.digest_value().is_some_and(|d| d.as_str() == digest) { + return Some(ixn.s.value()); + } + } + } + } + None +} +``` + +--- + +## Epic 5: Version String and SAID Integration + +The version string must include the serialized byte count, and SAID computation must use the correct version string. + +### Task 5.1: Two-pass SAID computation with version string + +**Spec:** The `v` field includes the total serialized byte count as 6 hex chars. SAID computation must use the correct `v` value. + +**Current code** (`said.rs:22-71`): Computes SAID with whatever `v` value the event has (typically `"KERI10JSON"`, the truncated version without size). + +The `compute_version_string()` in `version.rs` (behind `cesr` feature) already implements the correct two-pass pattern but is unused in the default event path. + +**Fix:** Move the two-pass logic into `compute_said` (no feature gate): + +```rust +// said.rs — updated compute_said + +pub fn compute_said(event: &serde_json::Value) -> Result { + let obj = event.as_object().ok_or(KeriTranslationError::MissingField { + field: "root object", + })?; + + 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 has_d = obj.contains_key("d"); + + // Build the map with placeholders + let mut new_obj = serde_json::Map::new(); + let mut d_injected = false; + + for (k, v) in obj { + if k == "x" { + continue; // legacy: skip x if present during migration + } else if k == "d" { + new_obj.insert("d".to_string(), placeholder.clone()); + d_injected = true; + } else if k == "i" && event_type == "icp" { + new_obj.insert("i".to_string(), placeholder.clone()); + } else { + new_obj.insert(k.clone(), v.clone()); + if k == "t" && !has_d { + new_obj.insert("d".to_string(), placeholder.clone()); + d_injected = true; + } + } + } + + if !d_injected { + new_obj.insert("d".to_string(), placeholder.clone()); + } + + // Pass 1: serialize with placeholder v to measure size + // Insert a placeholder version string to get approximate size + let version_placeholder = "KERI10JSON000000_"; + new_obj.insert("v".to_string(), serde_json::Value::String(version_placeholder.to_string())); + + let pass1 = serde_json::to_vec(&serde_json::Value::Object(new_obj.clone())) + .map_err(KeriTranslationError::SerializationFailed)?; + + // Pass 2: compute correct version string with actual size and re-serialize + let version_string = format!("KERI10JSON{:06x}_", pass1.len()); + new_obj.insert("v".to_string(), serde_json::Value::String(version_string)); + + let serialized = serde_json::to_vec(&serde_json::Value::Object(new_obj)) + .map_err(KeriTranslationError::SerializationFailed)?; + + let hash = blake3::hash(&serialized); + Ok(Said::new_unchecked(format!( + "E{}", + URL_SAFE_NO_PAD.encode(hash.as_bytes()) + ))) +} +``` + +### Task 5.2: Update event finalization to set version string with byte count + +**Current code** (`validate.rs:412-421`): +```rust +pub fn finalize_icp_event(mut icp: IcpEvent) -> Result { + let value = serde_json::to_value(Event::Icp(icp.clone())) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + let said = compute_said(&value) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + icp.d = said.clone(); + icp.i = Prefix::new_unchecked(said.into_inner()); + Ok(icp) +} +``` + +**Fix:** After computing the SAID, re-serialize to get final byte count and update `v`: + +```rust +pub fn finalize_icp_event(mut icp: IcpEvent) -> Result { + let value = serde_json::to_value(Event::Icp(icp.clone())) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + let said = compute_said(&value) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + + icp.d = said.clone(); + if icp.i.is_empty() || icp.i.as_str().starts_with('E') { + icp.i = Prefix::new_unchecked(said.into_inner()); + } + + // Compute final serialized size for version string + let final_bytes = serde_json::to_vec(&Event::Icp(icp.clone())) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + icp.v = VersionString::json(final_bytes.len() as u32); + + Ok(icp) +} +``` + +Create analogous `finalize_rot_event` and `finalize_ixn_event` functions. + +### Task 5.3: Fix receipt version string + +**Current code** (`witness/receipt.rs:28`): +```rust +pub const KERI_VERSION: &str = "KERI10JSON000000_"; // hardcoded zeros — always wrong +``` + +**Fix:** Compute the receipt version string dynamically during `ReceiptBuilder::build()`: + +```rust +impl ReceiptBuilder { + pub fn build(self) -> Option { + let mut receipt = Receipt { + v: VersionString::placeholder(), // temporary + t: RECEIPT_TYPE.into(), + d: self.d?, + i: self.i?, + s: self.s?, + }; + // Compute actual serialized size and set v + let bytes = serde_json::to_vec(&receipt).ok()?; + receipt.v = VersionString::json(bytes.len() as u32); + Some(receipt) + } +} +``` + +--- + +## Epic 6: KEL Validation Gaps + +Fix missing validation rules that the spec requires. + +### Task 6.1: Reject events after identity abandonment + +**Spec:** "When the `n` field value in a Rotation is an empty list, the AID MUST be deemed abandoned and no more key events MUST be allowed." + +**Current code** (`validate.rs:107-133`): `KeyState.is_abandoned` is tracked but never checked in `validate_kel` before processing events. + +**Fix:** Add check at the top of the event loop: +```rust +// validate.rs — validate_kel, inside the for loop, before match +for (idx, event) in events.iter().enumerate().skip(1) { + let expected_seq = idx as u64; + + // Reject any event after abandonment + if state.is_abandoned { + return Err(ValidationError::AbandonedIdentity { + sequence: expected_seq, + }); + } + + verify_event_said(event)?; + // ... rest of loop +} +``` + +Add the error variant: +```rust +// validate.rs — ValidationError +/// The identity has been abandoned (empty next commitment) and no more events are allowed. +#[error("Identity abandoned at sequence {sequence}, no more events allowed")] +AbandonedIdentity { + /// The sequence number of the rejected event. + sequence: u64, +}, +``` + +### Task 6.2: Reject IXN events in establishment-only KELs + +**Spec:** When `"EO"` is in the inception's `c` traits, only establishment events (ICP, ROT) may appear. + +**Depends on:** Task 1.4 (adding `c` field). + +**Fix:** +```rust +// validate.rs — validate_kel, after inception validation + +let establishment_only = if let Event::Icp(icp) = &events[0] { + icp.c.contains(&ConfigTrait::EstablishmentOnly) +} else { + false +}; + +// ... in the event loop: +if establishment_only && matches!(event, Event::Ixn(_)) { + return Err(ValidationError::EstablishmentOnly { + sequence: expected_seq, + }); +} +``` + +Add the error variant: +```rust +/// An interaction event was found in an establishment-only KEL. +#[error("Interaction event at sequence {sequence} rejected: KEL is establishment-only (EO)")] +EstablishmentOnly { + sequence: u64, +}, +``` + +### Task 6.3: Enforce non-transferable identity rules + +**Spec:** "When the `n` field value in an Inception is an empty list, the AID MUST be deemed non-transferable and no more key events MUST be allowed." + +**Current code:** Not enforced. An inception with `n: []` followed by additional events would be accepted. + +**Fix:** +```rust +// validate.rs — validate_kel, after inception validation +if icp.n.is_empty() && events.len() > 1 { + return Err(ValidationError::NonTransferable); +} +``` + +Add the error variant: +```rust +/// The identity is non-transferable (inception had empty next commitments). +#[error("Non-transferable identity: inception had empty next key commitments, no subsequent events allowed")] +NonTransferable, +``` + +### Task 6.4: Verify all pre-rotation commitments (not just first key) + +**Spec:** "The current public key list MUST include a satisfiable subset of the prior next key list with respect to the prior next threshold." + +**Current code** (`validate.rs:193-201`): +```rust +if !state.next_commitment.is_empty() && !rot.k.is_empty() { + let key_bytes = KeriPublicKey::parse(&rot.k[0]) // ONLY first key + .map(|k| k.as_bytes().to_vec()) + .map_err(|_| ValidationError::CommitmentMismatch { sequence })?; + if !verify_commitment(&key_bytes, &state.next_commitment[0]) { // ONLY first commitment + return Err(ValidationError::CommitmentMismatch { sequence }); + } +} +``` + +**Fix:** Check all commitments: +```rust +// validate.rs — validate_rotation, commitment verification + +// For each commitment in state.next_commitment, at least one key in rot.k +// must match it. The total matched keys must satisfy the next threshold. +if !state.next_commitment.is_empty() { + let mut matched_count = 0u64; + for commitment in &state.next_commitment { + let matched = rot.k.iter().any(|key| { + key.parse_ed25519() + .map(|pk| verify_commitment(pk.as_bytes(), commitment)) + .unwrap_or(false) + }); + if matched { + matched_count += 1; + } + } + let required = state.next_threshold.simple_value().unwrap_or(1); + if matched_count < required { + return Err(ValidationError::CommitmentMismatch { sequence }); + } +} +``` + +### Task 6.5: Validate witness AID uniqueness + +**Spec:** "A given AID MUST NOT appear more than once in any Backer list." + +**Current code:** No uniqueness check. + +**Fix:** Add validation helper and call it during inception and rotation validation: + +```rust +// validate.rs — new helper + +fn validate_backer_uniqueness(backers: &[Prefix]) -> Result<(), ValidationError> { + let mut seen = std::collections::HashSet::new(); + for b in backers { + if !seen.insert(b.as_str()) { + return Err(ValidationError::DuplicateBacker { + aid: b.as_str().to_string(), + }); + } + } + Ok(()) +} +``` + +Add error variant: +```rust +/// A backer AID appears more than once in the backer list. +#[error("Duplicate backer AID: {aid}")] +DuplicateBacker { aid: String }, +``` + +Call in `validate_inception`: +```rust +validate_backer_uniqueness(&icp.b)?; +``` + +Call in `validate_rotation` (after Task 2.1): +```rust +// Validate no duplicates in br or ba individually +validate_backer_uniqueness(&rot.br)?; +validate_backer_uniqueness(&rot.ba)?; +// Validate no overlap between br and ba +for aid in &rot.ba { + if rot.br.contains(aid) { + return Err(ValidationError::DuplicateBacker { + aid: aid.as_str().to_string(), + }); + } +} +``` + +### Task 6.6: Validate `bt` consistency with backer list + +**Spec:** "When `b` is empty, `bt` MUST be `"0"`." + +**Fix:** In inception validation: +```rust +// validate.rs — validate_inception +let bt_val = icp.bt.simple_value().unwrap_or(0); +if icp.b.is_empty() && bt_val != 0 { + return Err(ValidationError::InvalidBackerThreshold { + bt: bt_val, + backer_count: 0, + }); +} +``` + +Add error variant: +```rust +/// The backer threshold is inconsistent with the backer list size. +#[error("Invalid backer threshold: bt={bt} but backer_count={backer_count}")] +InvalidBackerThreshold { bt: u64, backer_count: usize }, +``` + +--- + +## Epic 7: KeyState Completeness + +Add missing state fields that the spec requires for full key state representation. + +### Task 7.1: Add backer state and config traits to `KeyState` + +**Spec:** Key state includes backer list, backer threshold, and configuration traits. + +**Current code** (`state.rs:18-42`): +```rust +pub struct KeyState { + pub prefix: Prefix, + pub current_keys: Vec, + pub next_commitment: Vec, + pub sequence: u64, + pub last_event_said: Said, + pub is_abandoned: bool, + pub threshold: u64, + pub next_threshold: u64, + // MISSING: backers, backer_threshold, config_traits, is_non_transferable +} +``` + +**Fix:** +```rust +// state.rs — updated KeyState (with new types from Epic 1) + +pub struct KeyState { + pub prefix: Prefix, + pub current_keys: Vec, + pub next_commitment: Vec, + pub sequence: u64, + pub last_event_said: Said, + pub is_abandoned: bool, + pub threshold: Threshold, + pub next_threshold: Threshold, + /// Current backer/witness list + pub backers: Vec, + /// Current backer threshold + pub backer_threshold: Threshold, + /// Configuration traits from inception (and rotation for RB/NRB) + pub config_traits: Vec, + /// Whether this identity is non-transferable (inception `n` was empty) + pub is_non_transferable: bool, +} +``` + +Update `from_inception`: +```rust +pub fn from_inception( + prefix: Prefix, + keys: Vec, + next: Vec, + threshold: Threshold, + next_threshold: Threshold, + said: Said, + backers: Vec, + backer_threshold: Threshold, + config_traits: Vec, +) -> Self { + let is_non_transferable = next.is_empty(); + Self { + prefix, + current_keys: keys, + next_commitment: next.clone(), + sequence: 0, + last_event_said: said, + is_abandoned: next.is_empty(), + threshold, + next_threshold, + backers, + backer_threshold, + config_traits, + is_non_transferable, + } +} +``` + +Update `apply_rotation` to handle `br`/`ba` deltas: +```rust +pub fn apply_rotation( + &mut self, + new_keys: Vec, + new_next: Vec, + threshold: Threshold, + next_threshold: Threshold, + sequence: u64, + said: Said, + backers_to_remove: &[Prefix], + backers_to_add: &[Prefix], + backer_threshold: Threshold, + config_traits: Vec, +) { + self.current_keys = new_keys; + self.next_commitment = new_next.clone(); + self.threshold = threshold; + self.next_threshold = next_threshold; + self.sequence = sequence; + self.last_event_said = said; + self.is_abandoned = new_next.is_empty(); + + // Apply backer deltas: remove first, then add + self.backers.retain(|b| !backers_to_remove.contains(b)); + self.backers.extend(backers_to_add.iter().cloned()); + self.backer_threshold = backer_threshold; + + // Update config traits (RB/NRB can change in rotation) + if !config_traits.is_empty() { + self.config_traits = config_traits; + } +} +``` + +**Blast radius:** All call sites of `from_inception` and `apply_rotation` across the workspace must be updated with the new parameters. + +--- + +## Epic 8: Signature Verification (Multi-Key Threshold) + +Support threshold-satisficing signature verification for multi-key identities. + +### Task 8.1: Verify threshold-satisficing signatures + +**Spec:** "Signed by a threshold-satisficing subset of the current set of private keys." + +**Current code** (`validate.rs:385-406`): Only verifies ONE signature against the first key. + +**Fix:** Replace `verify_event_signature` with threshold-aware version: + +```rust +// validate.rs — new threshold-aware signature verification + +/// Verify that signatures on an event satisfy the threshold. +/// +/// Args: +/// * `event` - The event whose signatures to verify. +/// * `keys` - The ordered list of signing keys. +/// * `signatures` - The indexed signatures to verify. +/// * `threshold` - The required threshold. +fn verify_threshold_signatures( + event: &Event, + keys: &[CesrKey], + signatures: &[IndexedSignature], + threshold: &Threshold, +) -> Result<(), ValidationError> { + let sequence = event.sequence().value(); + let canonical = serialize_for_signing(event)?; + + match threshold { + Threshold::Simple(required) => { + let mut verified_count = 0u64; + for sig in signatures { + let idx = sig.index as usize; + if idx >= keys.len() { + continue; // Invalid index, skip + } + let key = keys[idx].parse_ed25519() + .map_err(|_| ValidationError::SignatureFailed { sequence })?; + let pk = UnparsedPublicKey::new( + &ring::signature::ED25519, + key.as_bytes(), + ); + if pk.verify(&canonical, &sig.sig).is_ok() { + verified_count += 1; + } + } + if verified_count < *required { + return Err(ValidationError::SignatureFailed { sequence }); + } + } + Threshold::Weighted(clauses) => { + // For each clause, sum the weights of verified signatures. + // All clauses must be satisfied (ANDed). + // + // IMPORTANT: Use integer cross-multiplication, NOT f64. + // IEEE 754 cannot represent 1/3 exactly, so + // 1/3 + 1/3 + 1/3 != 1.0 in floating point. + for clause in clauses { + // Accumulate as a rational: acc_num / acc_den + let mut acc_num: u128 = 0; + let mut acc_den: u128 = 1; + for (i, fraction) in clause.iter().enumerate() { + if i >= keys.len() { break; } + // Check if key[i] has a valid signature + let has_valid_sig = signatures.iter().any(|sig| { + sig.index as usize == i && { + keys[i].parse_ed25519().ok().map_or(false, |key| { + let pk = UnparsedPublicKey::new( + &ring::signature::ED25519, + key.as_bytes(), + ); + pk.verify(&canonical, &sig.sig).is_ok() + }) + } + }); + if has_valid_sig { + let (n, d) = fraction.parse_parts() + .map_err(|_| ValidationError::SignatureFailed { sequence })?; + // acc_num/acc_den + n/d = (acc_num*d + n*acc_den) / (acc_den*d) + acc_num = acc_num * d as u128 + n as u128 * acc_den; + acc_den *= d as u128; + } + } + // Clause satisfied when acc_num/acc_den >= 1, i.e., acc_num >= acc_den + if acc_num < acc_den { + return Err(ValidationError::SignatureFailed { sequence }); + } + } + } + } + Ok(()) +} +``` + +### Task 8.2: Rotation dual-threshold verification + +**Spec:** "A set of controller-indexed signatures MUST satisfy BOTH the current signing threshold AND the prior next rotation threshold." + +**Fix:** During rotation validation, verify signatures against both thresholds: + +```rust +// validate.rs — validate_rotation, signature verification + +// Verify signatures satisfy BOTH: +// 1. The current signing threshold (using the new keys from rot.k) +// 2. The prior next rotation threshold (using state.next_threshold) +verify_threshold_signatures( + event, + &rot.k, + &signed_event.signatures, // from SignedEvent wrapper + &rot.kt, // current signing threshold +)?; + +// Also verify against prior next threshold +verify_threshold_signatures( + event, + &rot.k, + &signed_event.signatures, + &state.next_threshold, +)?; +``` + +--- + +## Epic 9: Receipt Message Compliance + +Fix the receipt format to match the spec. + +### Task 9.1: Fix `Receipt` struct to match spec fields + +**Spec (receipt fields):** `[v, t, d, i, s]` — ALL required, no others. +- `d` is the SAID of the **referenced key event** (not the receipt itself) +- Signatures are CESR attachments, not body fields + +**Current code** (`witness/receipt.rs:63-86`): +```rust +pub struct Receipt { + pub v: String, + pub t: String, + pub d: Said, // receipt's own SAID — WRONG + pub i: String, + pub s: u64, + pub a: Said, // NON-SPEC field + #[serde(with = "hex")] + pub sig: Vec, // NON-SPEC: should be CESR attachment +} +``` + +**Fix:** +```rust +// witness/receipt.rs — spec-compliant Receipt + +/// A witness receipt for a KEL event (spec: `rct` message type). +/// +/// Per the spec, the receipt body contains ONLY `[v, t, d, i, s]`. +/// `d` is the SAID of the referenced event (NOT the receipt itself). +/// Signatures are externalized as CESR attachments. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Receipt { + /// Version string + pub v: VersionString, + /// Type identifier ("rct") + pub t: String, + /// SAID of the referenced key event (NOT the receipt's own SAID) + pub d: Said, + /// Controller AID of the KEL being receipted + pub i: Prefix, + /// Sequence number of the event being receipted + pub s: KeriSequence, +} + +/// A receipt paired with its detached witness signature. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedReceipt { + /// The receipt body + pub receipt: Receipt, + /// Witness signature (externalized, not in body) + pub signature: Vec, +} +``` + +Update `ReceiptBuilder` accordingly: +```rust +impl ReceiptBuilder { + pub fn build(self) -> Option { + let receipt = Receipt { + v: VersionString::placeholder(), + t: RECEIPT_TYPE.into(), + d: self.d?, // event SAID (not receipt SAID) + i: self.i?, + s: self.s?, + }; + // Compute actual serialized size + let bytes = serde_json::to_vec(&receipt).ok()?; + let mut receipt = receipt; + receipt.v = VersionString::json(bytes.len() as u32); + + Some(SignedReceipt { + receipt, + signature: self.sig?, + }) + } +} +``` + +Update builder field types: +```rust +pub struct ReceiptBuilder { + d: Option, // event SAID + i: Option, // was Option + s: Option, // was Option + sig: Option>, +} +``` + +**Blast radius:** All receipt construction and consumption in `auths-core`, `auths-cli`, `auths-verifier`, `auths-infra-http`. Search for `Receipt::builder()`, `receipt.a`, `receipt.sig`, `receipt.s` across the workspace. + +--- + +## Epic 10: Sequence Number Width + +Widen `KeriSequence` to `u128` per spec maximum. + +### Task 10.1: Change `KeriSequence` inner type to `u128` + +**Spec:** Maximum sequence number is `ffffffffffffffffffffffffffffffff` = 2^128 - 1. + +**Current code** (`events.rs:28`): +```rust +pub struct KeriSequence(u64); +``` + +**Fix:** +```rust +// events.rs — KeriSequence +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct KeriSequence(u128); + +impl KeriSequence { + pub fn new(value: u128) -> Self { + Self(value) + } + + pub fn value(self) -> u128 { + self.0 + } +} + +impl Serialize for KeriSequence { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&format!("{:x}", self.0)) + } +} + +impl<'de> Deserialize<'de> for KeriSequence { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + let value = u128::from_str_radix(&s, 16) + .map_err(|_| serde::de::Error::custom(format!("invalid hex sequence: {s:?}")))?; + Ok(KeriSequence(value)) + } +} +``` + +Update `KeyState.sequence` from `u64` to `u128`. Update all comparison sites. + +**Priority:** LOW. No practical KEL will exceed u64. This is spec-correctness only. + +**Blast radius:** All sites using `KeriSequence::new(n)` with u64 literals need explicit `u128` type or `.into()`. `state.sequence` comparisons change. Search for `KeriSequence::new`, `.sequence`, `.value()` across the workspace. + +--- + +## Epic 11: Delegated Events (Future) + +Add support for delegated inception and rotation events. + +**Priority:** LOW. Not used in the current auths identity model. + +### Task 11.1: Add `dip` (Delegated Inception) event type + +**Spec field order:** `[v, t, d, i, s, kt, k, nt, n, bt, b, c, a, di]` + +```rust +// events.rs — new DipEvent + +/// Delegated Inception event — creates a delegated KERI identity. +/// +/// Same as ICP plus the `di` (delegator identifier prefix) field. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DipEvent { + pub v: VersionString, + #[serde(default)] + pub d: Said, + pub i: Prefix, + pub s: KeriSequence, + pub kt: Threshold, + pub k: Vec, + pub nt: Threshold, + pub n: Vec, + pub bt: Threshold, + pub b: Vec, + #[serde(default)] + pub c: Vec, + #[serde(default)] + pub a: Vec, + /// Delegator identifier prefix + pub di: Prefix, +} +``` + +Add to `Event` enum: +```rust +#[serde(rename = "dip")] +Dip(DipEvent), +``` + +### Task 11.2: Add `drt` (Delegated Rotation) event type + +**Spec field order:** `[v, t, d, i, s, p, kt, k, nt, n, bt, br, ba, c, a]` + +Same fields as ROT but `t = "drt"`. + +```rust +// events.rs — new DrtEvent + +/// Delegated Rotation event — rotates keys for a delegated identity. +/// +/// Same field set as ROT. Validation requires checking the delegator's +/// KEL for an anchoring seal. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DrtEvent { + pub v: VersionString, + #[serde(default)] + pub d: Said, + pub i: Prefix, + pub s: KeriSequence, + pub p: Said, + pub kt: Threshold, + pub k: Vec, + pub nt: Threshold, + pub n: Vec, + pub bt: Threshold, + pub br: Vec, + pub ba: Vec, + #[serde(default)] + pub c: Vec, + #[serde(default)] + pub a: Vec, +} +``` + +### Task 11.3: Implement delegated event validation + +**Spec:** "A Validator MUST be given or find the delegating seal in the delegator's KEL before the delegated event may be accepted as valid." + +This requires cross-KEL validation. The validator needs access to the delegator's KEL to find a key event seal `{"i": "", "s": "", "d": ""}` in the delegator's IXN or ROT event. + +```rust +// validate.rs — new function + +/// Validate a delegated event against the delegator's KEL. +/// +/// Args: +/// * `delegatee_event` - The delegated event (dip or drt) to validate. +/// * `delegator_kel` - The delegator's full KEL. +/// * `delegator_prefix` - The delegator's AID. +pub fn validate_delegation( + delegatee_event: &Event, + delegator_kel: &[Event], + delegator_prefix: &Prefix, +) -> Result<(), ValidationError> { + let (event_said, event_seq) = match delegatee_event { + Event::Dip(dip) => (&dip.d, dip.s), + Event::Drt(drt) => (&drt.d, drt.s), + _ => return Err(ValidationError::NotDelegated), + }; + + // Search delegator's KEL for an anchoring seal + let found = delegator_kel.iter().any(|event| { + event.anchors().iter().any(|seal| { + matches!(seal, Seal::KeyEvent { i, s, d } + if i == delegatee_event.prefix() + && *s == event_seq + && d == event_said + ) + }) + }); + + if !found { + return Err(ValidationError::MissingDelegationSeal { + delegator: delegator_prefix.as_str().to_string(), + delegatee_sequence: event_seq.value(), + }); + } + + Ok(()) +} +``` + +--- + +## Execution Order + +The recommended order minimizes rework and respects dependencies: + +``` +Phase 1 (Foundation): + Epic 1 (strong newtypes) ─── do ALL tasks together + Epic 3 (prefix flexibility) ─── enables typed backer fields + +Phase 2 (Event Schema): + Epic 2 (field schema) ─── depends on Epic 1 types + Epic 4 (seal format) ─── independent, can parallel with Epic 2 + +Phase 3 (Serialization): + Epic 5 (version string + SAID) ─── depends on VersionString from Epic 1 + +Phase 4 (Validation): + Epic 6 (validation gaps) ─── depends on c field from Epic 2 + Epic 7 (KeyState completeness) ─── depends on new types + Epic 8 (multi-sig) ─── depends on Threshold from Epic 1 + +Phase 5 (Messages): + Epic 9 (receipt compliance) ─── depends on new types + +Phase 6 (Low Priority): + Epic 10 (u128 sequence) + Epic 11 (delegation) +``` + +## Priority Matrix + +| Epic | Priority | Reason | +|------|----------|--------| +| 1 (Strong Newtypes) | **CRITICAL** | Foundation for all other changes | +| 2 (Event Field Schema) | **HIGH** | Field schema is the spec's core | +| 3 (Prefix Flexibility) | **HIGH** | Enables typed backer fields | +| 4 (Seal Format) | **HIGH** | Current format is non-interoperable | +| 5 (Version String + SAID) | **HIGH** | Affects all serialized events | +| 6 (Validation Gaps) | **MEDIUM** | Edge cases; happy path works | +| 7 (KeyState Completeness) | **MEDIUM** | Missing state for full compliance | +| 8 (Multi-Sig Verification) | **MEDIUM** | Single-sig works; needed for multi-device | +| 9 (Receipt Compliance) | **MEDIUM** | Receipts work internally; interop requires fix | +| 10 (Sequence u128) | **LOW** | u64 is practically sufficient | +| 11 (Delegation) | **LOW** | Not in current identity model | diff --git a/crates/auths-keri/docs/spec_compliance_audit.md b/crates/auths-keri/docs/spec_compliance_audit.md new file mode 100644 index 00000000..c2a4a31d --- /dev/null +++ b/crates/auths-keri/docs/spec_compliance_audit.md @@ -0,0 +1,1086 @@ +# KERI Spec Compliance Audit: `auths-keri` + +**Spec reference:** [Trust over IP KSWG KERI Specification v1.1](https://trustoverip.github.io/kswg-keri-specification/) +**Crate audited:** `crates/auths-keri/` (commit on branch `dev-keriStandardize`) +**Date:** 2026-04-07 + +This document maps every normative deviation between our implementation and the KERI spec. Each epic is a logically grouped body of work. Each task includes the spec requirement, what our code does, and a concrete fix with code snippets. + +--- + +## CRITICAL: Typing Discipline for All Changes + +**Every change in this document MUST follow "parse, don't validate" — use Rust's type system to make invalid states unrepresentable.** + +When implementing any task below, never introduce a new `String` or `Vec` field for structured KERI data. The crate already has good newtypes (`Said`, `Prefix`, `KeriSequence`, `KeriPublicKey`) but many fields bypass them. This is the root cause of bugs like thresholds parsed as decimal instead of hex — the raw string propagates unchecked until some deep validation function tries to interpret it. + +**Rules for every new or modified field:** + +1. **Thresholds** (`kt`, `nt`, `bt`): Use a `Threshold` enum — `Simple(u64)` for hex integers, `Weighted(Vec>)` for fractional clause lists. Deserialize with hex parsing. Never store as `String`. + +2. **Keys** (`k`, `current_keys`): Use `Vec` — a newtype over `String` that validates the CESR derivation code prefix on construction. `KeriPublicKey` should be derivable from `CesrKey` without re-parsing. + +3. **Commitments** (`n`, `next_commitment`): Use `Vec` — these are `E`-prefixed Blake3-256 digests, structurally identical to SAIDs. `compute_next_commitment` should return `Said`, not `String`. + +4. **Backer/Witness AIDs** (`b`, `br`, `ba`): Use `Vec` — witnesses are fully qualified AIDs per the spec. + +5. **Configuration traits** (`c`): Use `Vec` where `ConfigTrait` is an enum with variants `EstablishmentOnly`, `DoNotDelegate`, etc. — not `Vec`. + +6. **Version string** (`v`): Use a `VersionString` newtype that validates the `KERI10JSON{hhhhhh}_` format on deserialization. + +7. **Signatures**: If a signature field exists temporarily during migration, use a `Signature` newtype — never bare `String`. The end state is signatures externalized into CESR attachments (see Task 1.4). + +8. **Seals** (`a`): Use an untagged `Seal` enum whose variants are distinguished by field shape (`Digest { d }`, `KeyEvent { i, s, d }`, etc.) — not a struct with a non-spec `"type"` string field. + +**The test:** if you can assign a SAID to a key field, a threshold to a version string field, or a backer AID to a commitment field and it compiles — the types are wrong. + +--- + +## Complete Spec Field Label Inventory + +The spec defines **26 unique field labels** across all message types and seal formats. Our implementation only uses a subset. This table is the authoritative reference for the audit. + +### Table 1: Key Event Fields (17 labels) + +Source: [KERI field labels for data structures](https://trustoverip.github.io/kswg-keri-specification/#keri-field-labels-for-data-structures) + +| Label | Title | In `auths-keri`? | Notes | +|-------|-------|-------------------|-------| +| `v` | Version String | YES | Incomplete format (missing size + terminator) | +| `t` | Message Type | YES | Correct | +| `d` | Digest (SAID) | YES | Correct | +| `i` | Identifier Prefix (AID) | YES | Correct | +| `s` | Sequence Number | YES | u64 instead of u128 | +| `p` | Prior SAID | YES | Correct | +| `kt` | Keys Signing Threshold | YES | Parsed as decimal, not hex | +| `k` | List of Signing Keys | YES | Correct | +| `nt` | Next Keys Signing Threshold | YES | Parsed as decimal, not hex | +| `n` | List of Next Key Digests | YES | Correct | +| `bt` | Backer Threshold | YES | Parsed as decimal, not hex | +| `b` | List of Backers | YES | Used on ROT (should be ICP-only; ROT uses `br`/`ba`) | +| `br` | List of Backers to Remove | **NO** | Missing from `RotEvent` | +| `ba` | List of Backers to Add | **NO** | Missing from `RotEvent` | +| `c` | List of Configuration Traits | **NO** | Missing from `IcpEvent` and `RotEvent` | +| `a` | List of Anchors (seals) | YES | Non-spec seal format (has `"type"` field) | +| `di` | Delegator Identifier Prefix | **NO** | Delegated events not implemented | + +### Table 2: Routed Message Fields (7 additional labels) + +These appear in `qry`, `rpy`, `pro`, `bar`, `xip`, `exn` messages — none of which are implemented in `auths-keri`. + +| Label | Title | In `auths-keri`? | +|-------|-------|-------------------| +| `u` | UUID Salty Nonce | NO | +| `ri` | Receiver Identifier Prefix | NO | +| `x` | Exchange SAID | NO (see warning below) | +| `dt` | Datetime (ISO-8601) | NO | +| `r` | Route | NO | +| `rr` | Return Route | NO | +| `q` | Query Map | NO | + +> **WARNING — The `x` field in our code is NOT the spec's `x`.** +> The spec defines `x` as "Exchange SAID — fully qualified unique digest for an exchange transaction" used in `exn` messages. Our implementation uses `x` as an inline signature field on all event types. The spec's `x` is a digest; our `x` is a base64url Ed25519 signature. These are completely different things. Our `x` field is non-spec and must be removed (see Task 1.4). + +### Table 3: Seal Fields (2 additional labels) + +| Label | Title | In `auths-keri`? | +|-------|-------|-------------------| +| `rd` | Merkle Tree Root Digest | NO | +| `bi` | Backer Identifier | NO | + +### Message Body Field Orders (spec-normative) + +| Message | Type | Required Fields (in order) | +|---------|------|----------------------------| +| Inception | `icp` | `[v, t, d, i, s, kt, k, nt, n, bt, b, c, a]` | +| Rotation | `rot` | `[v, t, d, i, s, p, kt, k, nt, n, bt, br, ba, c, a]` | +| Interaction | `ixn` | `[v, t, d, i, s, p, a]` | +| Delegated Inception | `dip` | `[v, t, d, i, s, kt, k, nt, n, bt, b, c, a, di]` | +| Delegated Rotation | `drt` | `[v, t, d, i, s, p, kt, k, nt, n, bt, br, ba, c, a]` | +| Receipt | `rct` | `[v, t, d, i, s]` | +| Query | `qry` | `[v, t, d, dt, r, rr, q]` | +| Reply | `rpy` | `[v, t, d, dt, r, a]` | +| Prod | `pro` | `[v, t, d, dt, r, rr, q]` | +| Bare | `bar` | `[v, t, d, dt, r, a]` | +| Exchange Inception | `xip` | `[v, t, d, u, i, ri, dt, r, q, a]` | +| Exchange | `exn` | `[v, t, d, i, ri, x, p, dt, r, q, a]` | + +"No other top-level fields are allowed (MUST NOT appear)" applies to every message type. + +--- + +## Epic 1: Event Field Schema (Missing & Extra Fields) + +The spec defines strict, required field sets for each event type. No other top-level fields are allowed. Our structs deviate in three ways: missing the `c` (configuration traits) field, using an `x` field for inline signatures (which the spec forbids), and using a full `b` list on ROT instead of `br`/`ba` deltas. + +### Task 1.1: Add `c` (Configuration Traits) Field to ICP + +**Spec (ICP field order):** `[v, t, d, i, s, kt, k, nt, n, bt, b, c, a]` — ALL required. + +Configuration traits control identity behavior (establishment-only, do-not-delegate, etc.). The spec defines: +- `EO` — Establishment-Only: only establishment events in KEL +- `DND` — Do-Not-Delegate: cannot act as delegator +- `DID` — Delegate-Is-Delegator +- `RB` / `NRB` — Registrar backer control + +**Current code** (`events.rs:168-196`): +```rust +pub struct IcpEvent { + pub v: String, + pub d: Said, + pub i: Prefix, + pub s: KeriSequence, + pub kt: String, + pub k: Vec, + pub nt: String, + pub n: Vec, + pub bt: String, + pub b: Vec, + // MISSING: pub c: Vec, + pub a: Vec, + pub x: String, // NON-SPEC: should not exist (see Task 1.4) +} +``` + +**Fix:** Add `c: Vec` field between `b` and `a`. Update the custom `Serialize` impl to always include it (the spec says all fields are required, even if the list is empty). Update `Deserialize` with `#[serde(default)]` for backwards compat during migration. + +```rust +// events.rs — IcpEvent struct +/// Configuration traits/modes (e.g., "EO", "DND", "DID") +#[serde(default)] +pub c: Vec, + +// events.rs — IcpEvent Serialize impl, after "b" entry: +map.serialize_entry("c", &self.c)?; +``` + +Also add `c` to `RotEvent` (same position, between `b` and `a`). The IXN event does NOT have `c`. + +**Validation impact:** `validate.rs` must enforce: +- `EO` trait: reject any IXN events in a KEL whose inception has `"EO"` in `c` +- `DND` trait: prevent delegation (not yet implemented, but the field must exist for future enforcement) +- `c` is inception-only for `EO`/`DND`/`DID`; `RB`/`NRB` can appear in rotation + +### Task 1.2: Add `br` / `ba` Fields to ROT (Replace `b`) + +**Spec (ROT field order):** `[v, t, d, i, s, p, kt, k, nt, n, bt, br, ba, c, a]` — ALL required. + +Rotation uses **delta-based** witness list changes: `br` (remove) and `ba` (add), processed in that order. + +**Current code** (`events.rs:237-267`): +```rust +pub struct RotEvent { + // ... + pub bt: String, + pub b: Vec, // NON-SPEC: full replacement list + // MISSING: pub br: Vec, + // MISSING: pub ba: Vec, + pub a: Vec, + pub x: String, +} +``` + +**Fix:** Replace `b` with `br` and `ba`: +```rust +// events.rs — RotEvent struct +/// Backer/witness threshold +pub bt: String, +/// List of backers to remove (processed first) +#[serde(default)] +pub br: Vec, +/// List of backers to add (processed after removals) +#[serde(default)] +pub ba: Vec, +/// Configuration traits +#[serde(default)] +pub c: Vec, +``` + +Update the custom `Serialize` impl field order: +```rust +// events.rs — RotEvent Serialize impl +map.serialize_entry("bt", &self.bt)?; +map.serialize_entry("br", &self.br)?; +map.serialize_entry("ba", &self.ba)?; +map.serialize_entry("c", &self.c)?; +// ...then "a" (conditionally) +``` + +**Blast radius:** Every call site that constructs `RotEvent` currently sets `b: vec![]` or `b: witnesses`. These must all be migrated to `br: vec![], ba: vec![]`. Search for `RotEvent {` across the workspace. + +`KeyState` must track the full backer list and compute the new list as: `state.backers.remove_all(br).extend(ba)`. + +### Task 1.3: Always Serialize `a` Field (Remove Conditional Omission) + +**Spec:** All fields listed in the event schema are REQUIRED. The `a` field MUST be present even when empty. + +**Current code** (`events.rs:219-221`): +```rust +// IcpEvent Serialize impl +if !self.a.is_empty() { + map.serialize_entry("a", &self.a)?; +} +``` + +Same pattern in `RotEvent` (line 291) and `IxnEvent` (already always serializes `a`). + +**Fix:** Remove the conditional. Always serialize `a`: +```rust +map.serialize_entry("a", &self.a)?; +``` + +Apply to all three event types. This also fixes the field count calculation (remove `!self.a.is_empty() as usize` from the dynamic count). + +### Task 1.4: Externalize Signatures (Remove `x` Field) + +**Spec:** "Signatures MUST be attached using CESR attachment codes" — they are NOT part of the event body. The spec's event field lists do not include `x` or any signature field. + +**Current code** (`events.rs:194-195`, `events.rs:265-266`, `events.rs:324-325`): +```rust +/// Event signature (Ed25519, base64url-no-pad) +#[serde(default)] +pub x: String, +``` + +The `x` field is serialized conditionally in every event type. `serialize_for_signing` zeros it, and `compute_said` removes it. This is a workaround for storing signatures inline. + +**Fix (phased):** + +**Phase A (struct change):** Remove `x` from all event structs. Store signatures alongside events rather than inside them. Create a wrapper: +```rust +/// An event paired with its detached signature(s). +pub struct SignedEvent { + pub event: Event, + /// Controller-indexed signatures (base64url-no-pad Ed25519) + pub signatures: Vec, +} +``` + +**Phase B (serialization):** `serialize_for_signing` becomes trivial — serialize the event as-is (no field clearing needed). `compute_said` no longer needs to remove `x`. + +**Phase C (storage migration):** All KEL storage must be updated to store `(event_json, signatures)` separately instead of `event_json_with_x`. + +This is the largest single change in this audit. It touches every crate that creates, stores, or verifies events. Consider doing this as a separate epic after the field schema changes are stable. + +--- + +## Epic 2: Version String Compliance + +### Task 2.1: Use Full v1.x Version String with Size Field + +**Spec (v1.x format):** `KERIvvSSSShhhhhh_` — 17 characters total. +- `KERI` — 4-char protocol ID +- `vv` — hex major.minor (e.g., `10` = v1.0) +- `SSSS` — serialization type (`JSON`, `CBOR`, `MGPK`, `CESR`) +- `hhhhhh` — 6 hex chars = total serialized byte count +- `_` — terminator + +**Current code** (`events.rs:13`): +```rust +pub const KERI_VERSION: &str = "KERI10JSON"; // 10 chars — WRONG +``` + +This is only the first 10 characters of the v1.x version string. Missing: the 6-hex-char byte count and `_` terminator. + +The `compute_version_string()` function in `version.rs` does compute the full 17-char string correctly, but it's behind the `cesr` feature flag and unused in the default event path. + +**Fix:** Move `compute_version_string()` out from behind the `cesr` feature flag. Use it in `finalize_icp_event` and all event creation paths: +```rust +// said.rs or version.rs (no feature gate) +pub const KERI_VERSION_PREFIX: &str = "KERI10JSON"; + +/// Compute the full version string with byte count for a serialized event. +pub fn compute_version_string(event_bytes: &[u8]) -> String { + format!("KERI10JSON{:06x}_", event_bytes.len()) +} +``` + +Event finalization must do a two-pass serialize: +1. Serialize with placeholder version → measure size → compute version string +2. Re-serialize with correct version string + +The `cesr`-gated `version.rs` already implements this two-pass pattern (lines 19-63). Promote it to the default path. + +### Task 2.2: Receipt Version String + +**Current code** (`witness/receipt.rs:28`): +```rust +pub const KERI_VERSION: &str = "KERI10JSON000000_"; // hardcoded zeros +``` + +The `000000` byte count is always wrong for actual receipts. The receipt version string should reflect the actual serialized size. + +**Fix:** Compute the receipt version string dynamically during construction, same two-pass approach as events. + +--- + +## Epic 3: Sequence Number Type + +### Task 3.1: Widen KeriSequence to u128 + +**Spec:** "Maximum value MUST be `ffffffffffffffffffffffffffffffff`" — that is `2^128 - 1` (128-bit). + +**Current code** (`events.rs:28`): +```rust +pub struct KeriSequence(u64); // max = 2^64 - 1 +``` + +**Fix:** Change inner type to `u128`: +```rust +pub struct KeriSequence(u128); +``` + +Update `value()` return type to `u128`. Update `KeyState.sequence` to `u128`. Update all comparison sites. + +**Practical note:** No real KEL will exceed u64. This is a spec-correctness change, not a practical one. Low priority. + +--- + +## Epic 4: Threshold Parsing + +### Task 4.1: Parse Thresholds as Hex (Not Decimal) + +**Spec:** Key signing threshold (`kt`, `nt`) and backer threshold (`bt`) are "hex-encoded non-negative integer[s]." + +**Current code** (`validate.rs:135-140`): +```rust +fn parse_threshold(raw: &str) -> Result { + raw.parse::() // DECIMAL parse — WRONG for values >= 10 + .map_err(|_| ValidationError::MalformedSequence { + raw: raw.to_string(), + }) +} +``` + +For thresholds 1-9, hex and decimal produce the same result. At threshold 10+: `"a"` (hex) = 10, but `"a".parse::()` fails. Spec example: `"kt":"2"` — same in both bases. + +**Fix:** +```rust +fn parse_threshold(raw: &str) -> Result { + u64::from_str_radix(raw, 16) + .map_err(|_| ValidationError::MalformedSequence { + raw: raw.to_string(), + }) +} +``` + +### Task 4.2: Support Fractionally Weighted Thresholds + +**Spec:** Thresholds can also be a list of clause lists with rational fractions for complex multi-sig policies: +```json +"kt": [["1/2", "1/2", "1/2"], ["1/2", "1/2"]] +``` +Clauses are ANDed. Each clause is satisfied when the sum of weights for verified signatures >= 1. + +**Current code:** Only supports simple integer thresholds. + +**Fix:** Define a threshold enum: +```rust +pub enum Threshold { + /// Simple M-of-N threshold (hex-encoded integer) + Simple(u64), + /// Fractionally weighted threshold (list of clause lists) + Weighted(Vec>), +} +``` + +Parse the `kt`/`nt`/`bt` fields into this enum. Update signature verification to check threshold satisfaction accordingly. + +**Priority:** Medium. Current auths usage is single-sig (`kt: "1"`). Multi-sig support requires this. + +--- + +## Epic 5: Seal Format Compliance + +### Task 5.1: Support Multiple Seal Types + +**Spec defines these seal formats:** + +| Seal Type | Fields | Field Order | +|-----------|--------|-------------| +| Digest Seal | `d` | `[d]` | +| Merkle Root Seal | `rd` | `[rd]` | +| Source Event Seal | `s`, `d` | `[s, d]` | +| Key Event Seal | `i`, `s`, `d` | `[i, s, d]` | +| Latest Est. Event Seal | `i` | `[i]` | +| Registrar Backer Seal | `bi`, `d` | `[bi, d]` | + +**Current code** (`events.rs:113-119`): +```rust +pub struct Seal { + /// Digest of anchored data + pub d: Said, + /// Type indicator (renamed to "type" in JSON) + #[serde(rename = "type")] + pub seal_type: SealType, +} +``` + +Problems: +1. Only supports digest seals (just `d` field) +2. Adds a non-spec `"type"` field to the JSON +3. No support for key event seals, source event seals, etc. + +**Fix:** Replace with a seal enum: +```rust +/// KERI seal — typed by field shape, not a "type" discriminator. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Seal { + /// Digest seal: {"d": ""} + Digest { d: Said }, + /// Source event seal: {"s": "", "d": ""} + SourceEvent { s: KeriSequence, d: Said }, + /// Key event seal: {"i": "", "s": "", "d": ""} + KeyEvent { i: Prefix, s: KeriSequence, d: Said }, + /// Latest establishment event seal: {"i": ""} + LatestEstablishment { i: Prefix }, + /// Merkle tree root digest seal: {"rd": ""} + MerkleRoot { rd: String }, +} +``` + +**Note:** The current `SealType` enum (`DeviceAttestation`, `Revocation`, `Delegation`, `IdpBinding`) is an auths-specific extension. It should be modeled as data INSIDE a digest seal's referenced document, not as a field on the seal itself. The seal is just `{"d": "..."}` — the "type" meaning lives in the anchored data. + +### Task 5.2: Enforce Seal Field Order + +**Spec:** "Field order MUST be `[i, s, d]`" for key event seals, `[s, d]` for source event seals, etc. + +The `Seal` enum variants should have custom `Serialize` impls that enforce field order, or rely on `preserve_order` + field declaration order in the struct. + +--- + +## Epic 6: Signature Verification (Multi-Key Threshold) + +### Task 6.1: Verify Threshold-Satisficing Signatures, Not Just First Key + +**Spec:** "Signed by a threshold-satisficing subset of the current set of private keys." + +**Current code** (`validate.rs:142-148`, `validate.rs:189-191`): +```rust +// validate_inception — only checks first key +verify_event_signature( + &Event::Icp(icp.clone()), + icp.k.first().ok_or(ValidationError::SignatureFailed { sequence: 0 })?, +)?; + +// validate_rotation — only checks first new key +if !rot.k.is_empty() { + verify_event_signature(event, &rot.k[0])?; +} +``` + +Only verifies ONE signature against the first key. For multi-sig (kt > 1), this is insufficient. + +**Fix:** `verify_event_signature` must accept a list of signatures and a list of keys, then check that at least `kt` of them verify: +```rust +fn verify_threshold_signatures( + event: &Event, + keys: &[String], + signatures: &[String], // or extracted from SignedEvent + threshold: u64, +) -> Result<(), ValidationError> { + let canonical = serialize_for_signing(event)?; + let mut verified_count = 0u64; + for (key, sig) in keys.iter().zip(signatures.iter()) { + if verify_single_signature(&canonical, key, sig).is_ok() { + verified_count += 1; + } + } + if verified_count < threshold { + return Err(ValidationError::SignatureFailed { ... }); + } + Ok(()) +} +``` + +### Task 6.2: Rotation Dual-Threshold Requirement + +**Spec:** "A set of controller-indexed signatures MUST satisfy BOTH the current signing threshold AND the prior next rotation threshold." + +**Current code:** Only checks one threshold (implicit single-sig). + +**Fix:** During rotation validation, verify that the provided signatures satisfy: +1. The current signing threshold (`state.threshold`) +2. The prior next rotation threshold (`state.next_threshold`) + +This requires tracking which keys from the new key list correspond to the pre-committed next keys, then checking that enough of them verify. + +### Task 6.3: Verify All Pre-Rotation Commitments (Not Just First) + +**Spec:** "The current public key list MUST include a satisfiable subset of exposed (unblinded) pre-rotated next keys from the most recent prior establishment event." + +**Current code** (`validate.rs:193-201`): +```rust +if !state.next_commitment.is_empty() && !rot.k.is_empty() { + let key_bytes = KeriPublicKey::parse(&rot.k[0]) // ONLY first key + .map(|k| k.as_bytes().to_vec()) + .map_err(|_| ValidationError::CommitmentMismatch { sequence })?; + if !verify_commitment(&key_bytes, &state.next_commitment[0]) { // ONLY first commitment + return Err(ValidationError::CommitmentMismatch { sequence }); + } +} +``` + +Only checks `rot.k[0]` against `state.next_commitment[0]`. For multi-key identities, ALL exposed next keys must match their pre-committed digests. + +**Fix:** +```rust +// For each commitment in state.next_commitment, at least next_threshold +// of the new keys must match (by finding their digest in the commitment list). +for (i, commitment) in state.next_commitment.iter().enumerate() { + // Find the corresponding key in rot.k that matches this commitment + let matched = rot.k.iter().any(|key| { + KeriPublicKey::parse(key) + .map(|k| verify_commitment(k.as_bytes(), commitment)) + .unwrap_or(false) + }); + if !matched { + return Err(ValidationError::CommitmentMismatch { sequence }); + } +} +``` + +--- + +## Epic 7: KEL Validation Gaps + +### Task 7.1: Reject Events After Abandonment + +**Spec:** "When the `n` field value in a Rotation is an empty list, the AID MUST be deemed abandoned and no more key events MUST be allowed." + +**Current code:** `KeyState.is_abandoned` is tracked but never enforced during `validate_kel`: +```rust +// validate.rs:183-216 — validate_rotation +// No check for state.is_abandoned before processing +``` + +`verify_event_crypto` does check this (line 272), but `validate_kel` calls `validate_rotation` directly, not `verify_event_crypto`. + +**Fix:** Add to `validate_kel` loop, before processing any event: +```rust +if state.is_abandoned { + return Err(ValidationError::AbandonedIdentity { sequence: expected_seq }); +} +``` + +Same for non-transferable identities (inception with empty `n`). + +### Task 7.2: Reject IXN in Establishment-Only KELs + +**Spec:** When `"EO"` is in the inception's `c` traits, only establishment events (ICP, ROT) may appear in the KEL. + +**Current code:** No `c` field exists (see Task 1.1), so this validation is impossible. + +**Fix:** After adding `c` field, check in `validate_kel`: +```rust +let establishment_only = if let Event::Icp(icp) = &events[0] { + icp.c.contains(&"EO".to_string()) +} else { false }; + +// In the event loop: +if establishment_only && matches!(event, Event::Ixn(_)) { + return Err(ValidationError::EstablishmentOnly { sequence: expected_seq }); +} +``` + +### Task 7.3: Enforce Non-Transferable Identity Rules + +**Spec:** "When the `n` field value in an Inception is an empty list, the AID MUST be deemed non-transferable and no more key events MUST be allowed." + +**Current code:** Not enforced. A KEL with inception `n: []` followed by rotation/interaction events would be accepted. + +**Fix:** After creating initial state from inception: +```rust +if icp.n.is_empty() && events.len() > 1 { + return Err(ValidationError::NonTransferable); +} +``` + +### Task 7.4: Track and Expose Witness/Backer State in KeyState + +**Spec:** Key state includes backer list and threshold. + +**Current code** (`state.rs:18-42`): +```rust +pub struct KeyState { + pub prefix: Prefix, + pub current_keys: Vec, + pub next_commitment: Vec, + pub sequence: u64, + pub last_event_said: Said, + pub is_abandoned: bool, + pub threshold: u64, + pub next_threshold: u64, + // MISSING: backers, backer_threshold, config_traits +} +``` + +**Fix:** Add: +```rust +pub struct KeyState { + // ... existing fields ... + /// Current backer/witness list + pub backers: Vec, + /// Current backer threshold + pub backer_threshold: u64, + /// Configuration traits from inception (and rotation for RB/NRB) + pub config_traits: Vec, + /// Whether this identity is non-transferable (inception n was empty) + pub is_non_transferable: bool, +} +``` + +Update `from_inception` and `apply_rotation` to maintain these fields. `apply_rotation` must apply `br`/`ba` deltas to the backer list. + +--- + +## Epic 8: Receipt Message Compliance + +### Task 8.1: Fix Receipt `d` Field Semantics + +**Spec (receipt field order):** `[v, t, d, i, s]` — ALL required. No other fields. +- `d` is the SAID of the **referenced key event** (not the receipt itself) +- Signatures are on the referenced key event body, attached via CESR + +**Current code** (`witness/receipt.rs:63-86`): +```rust +pub struct Receipt { + pub v: String, + pub t: String, + pub d: Said, // receipt's own SAID — WRONG per spec + pub i: String, + pub s: u64, + pub a: Said, // NON-SPEC: event SAID being receipted + #[serde(with = "hex")] + pub sig: Vec, // NON-SPEC: should be CESR attachment +} +``` + +Problems: +1. `d` should be the referenced event's SAID, not the receipt's own SAID +2. `a` field does not exist in the spec receipt format +3. `sig` should be a CESR attachment, not a body field +4. `s` is `u64` but spec uses hex-encoded string (should be `KeriSequence`) + +**Fix:** +```rust +pub struct Receipt { + pub v: String, + pub t: String, // "rct" + pub d: Said, // SAID of the referenced event (NOT the receipt) + pub i: String, // Witness AID + pub s: KeriSequence, // Event sequence number (hex-encoded) +} + +pub struct SignedReceipt { + pub receipt: Receipt, + /// Witness signature (CESR-attached, not in body) + pub signature: Vec, +} +``` + +--- + +## Epic 9: SAID Algorithm Refinements + +### Task 9.1: Integrate Version String Size into SAID Computation + +**Spec:** The `v` field includes the serialized byte count. SAID computation must use the correct `v` value. + +**Current SAID algorithm** (`said.rs:22-71`): +1. Replace `d` with placeholder +2. For ICP: replace `i` with placeholder +3. Remove `x` +4. `serde_json::to_vec` (insertion-order) +5. Blake3 hash +6. `E` + base64url-no-pad + +**Missing step:** The `v` field in the serialized event must have the correct byte count. Currently the SAID is computed with whatever `v` value the event already has (typically `"KERI10JSON"` — the truncated version). The spec requires `v` to reflect the total serialized byte count. + +**Fix:** In `compute_said`, after injecting placeholders: +1. Serialize once to measure byte count +2. Compute version string: `format!("KERI10JSON{:06x}_", byte_count)` +3. Set `v` to the computed version string +4. Re-serialize with correct `v` +5. Hash the final serialization + +This mirrors the two-pass approach in `version.rs:19-63` (currently cesr-gated). + +### Task 9.2: Validate SAID Placeholder Length Matches Derivation Code + +**Spec:** The placeholder is `#` characters of the length of the **digest to be used**. For Blake3-256 with CESR `E` code: `E` + 43 base64url chars = 44 chars. + +**Current code** (`said.rs:8`): +```rust +pub const SAID_PLACEHOLDER: &str = "############################################"; // 44 chars +``` + +This is correct for Blake3-256. However, if other digest algorithms are supported in the future, the placeholder length must match. Consider deriving from the derivation code: +```rust +/// Placeholder length for a given derivation code. +pub fn placeholder_length(derivation_code: &str) -> usize { + match derivation_code { + "E" => 44, // Blake3-256: 1 code char + 43 base64url chars + _ => 44, // Default, extend as needed + } +} +``` + +Low priority — currently only Blake3-256 is used. + +--- + +## Epic 10: Key and Prefix Type Flexibility + +### Task 10.1: Support Non-Self-Addressing AIDs (Prefix Validation) + +**Spec:** AIDs can be: +- **Self-addressing:** derived from inception event SAID (starts with `E` for Blake3-256) +- **Non-self-addressing:** derived from public key (starts with `D` for Ed25519, `1` for secp256k1, etc.) + +**Current code** (`types.rs:21-37`): +```rust +fn validate_keri_derivation_code(s: &str, type_label: &'static str) -> Result<(), KeriTypeError> { + // ... + if !s.starts_with('E') { + return Err(KeriTypeError { /* ... */ }); + } + Ok(()) +} +``` + +Only accepts `E` prefix. Non-self-addressing AIDs (e.g., `D`-prefixed Ed25519 keys used as non-transferable prefixes) are rejected. + +**Fix:** Accept any valid CESR derivation code: +```rust +fn validate_keri_derivation_code(s: &str, type_label: &'static str) -> Result<(), KeriTypeError> { + if s.is_empty() { + return Err(KeriTypeError { type_name: type_label, reason: "must not be empty".into() }); + } + // CESR derivation codes: D (Ed25519), E (Blake3-256), 1 (secp256k1), etc. + // For now, allow any non-empty string starting with an uppercase letter or digit. + let first = s.chars().next().unwrap_or('\0'); + if !first.is_ascii_alphanumeric() { + return Err(KeriTypeError { + type_name: type_label, + reason: format!("must start with a CESR derivation code, got '{}'", first), + }); + } + Ok(()) +} +``` + +**Note:** `Said` should remain `E`-only (SAIDs are always digests). Split validation: +- `Prefix`: any CESR derivation code +- `Said`: `E` only (or other digest codes) + +### Task 10.2: ICP Self-Addressing Rule — `i == d` Only When Self-Addressing + +**Spec:** "When the AID is self-addressing, `d` and `i` MUST have the same value." But non-self-addressing AIDs have `i` derived from the public key, not from `d`. + +**Current code** (`validate.rs:259-264`): +```rust +if icp.i.as_str() != icp.d.as_str() { + return Err(ValidationError::InvalidSaid { ... }); +} +``` + +Always enforces `i == d`. This is correct for self-addressing AIDs only. + +**Fix:** Check the derivation code of `i` to determine if it's self-addressing: +```rust +let is_self_addressing = icp.i.as_str().starts_with('E'); +if is_self_addressing && icp.i.as_str() != icp.d.as_str() { + return Err(ValidationError::InvalidSaid { ... }); +} +``` + +--- + +## Epic 11: Witness Validation + +### Task 11.1: Validate Witness AID Uniqueness + +**Spec:** "A given AID MUST NOT appear more than once in any Backer list." + +**Current code:** No uniqueness check anywhere. + +**Fix:** In inception and rotation validation: +```rust +fn validate_backer_uniqueness(backers: &[String]) -> Result<(), ValidationError> { + let mut seen = std::collections::HashSet::new(); + for b in backers { + if !seen.insert(b) { + return Err(ValidationError::DuplicateBacker { aid: b.clone() }); + } + } + Ok(()) +} +``` + +### Task 11.2: Validate `bt` Consistency with Backer List + +**Spec:** "When `b` is empty, `bt` MUST be `"0"`." + +**Fix:** In inception validation: +```rust +let bt = parse_threshold(&icp.bt)?; +if icp.b.is_empty() && bt != 0 { + return Err(ValidationError::InvalidBackerThreshold { bt, backer_count: 0 }); +} +``` + +### Task 11.3: Validate `br` Before `ba` Processing Order + +**Spec:** "AIDs in `br` MUST be removed before any AIDs in `ba` are appended." + +After Task 1.2 adds `br`/`ba`, validate that: +1. All AIDs in `br` exist in the current backer list +2. No AID appears in both `br` and `ba` +3. No duplicate AIDs in `br` or `ba` + +--- + +## Epic 12: Delegated Events (Not Yet Implemented) + +### Task 12.1: Add `dip` (Delegated Inception) Event Type + +**Spec field order:** `[v, t, d, i, s, kt, k, nt, n, bt, b, c, a, di]` + +Same as ICP plus `di` (delegator identifier prefix). + +### Task 12.2: Add `drt` (Delegated Rotation) Event Type + +**Spec field order:** `[v, t, d, i, s, p, kt, k, nt, n, bt, br, ba, c, a]` + +Same fields as ROT but `t = "drt"`. Validation requires checking the delegator's KEL for an anchoring seal. + +### Task 12.3: Implement Delegated Event Validation + +**Spec:** "A Validator MUST be given or find the delegating seal in the delegator's KEL before the delegated event may be accepted as valid." + +This requires cross-KEL validation (delegatee's event references delegator's KEL). + +**Priority:** Low. Delegation is not used in the current auths identity model. + +--- + +## Epic 13: Eliminate Stringly-Typed Fields ("Parse, Don't Validate") + +The crate has strong newtypes for `Said`, `Prefix`, `KeriSequence`, and `KeriPublicKey`, but most event struct fields bypass them and store raw `String`s. The result: parsing happens deep in validation functions rather than at the deserialization boundary, invalid data can propagate silently, and callers must remember which strings are keys vs commitments vs thresholds. + +### Task 13.1: Type the Threshold Fields (`kt`, `nt`, `bt`) + +**Current:** `kt: String`, `nt: String`, `bt: String` on all event types. Parsed ad-hoc in `validate.rs:135-140` as decimal (should be hex), and discarded after use. + +```rust +// validate.rs:135 — ad-hoc parse, wrong base, result thrown away +fn parse_threshold(raw: &str) -> Result { + raw.parse::() // decimal, not hex +} +``` + +**Fix:** Define a `Threshold` enum and use it on the structs: +```rust +/// KERI signing/backer threshold. +/// +/// Simple thresholds are hex-encoded integers ("1", "2", "a"). +/// Weighted thresholds are clause lists ([["1/2","1/2"],["1/3","1/3","1/3"]]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Threshold { + Simple(u64), + Weighted(Vec>), // future: Vec> +} +``` + +With a custom `Deserialize` that parses hex for simple values and accepts arrays for weighted. Then on the structs: +```rust +pub struct IcpEvent { + pub kt: Threshold, + pub nt: Threshold, + pub bt: Threshold, + // ... +} +``` + +Invalid thresholds are rejected at deserialization, not during validation. + +### Task 13.2: Type the Key Fields (`k`, `current_keys`) + +**Current:** `k: Vec` on events and `current_keys: Vec` on `KeyState`. `KeriPublicKey` exists as a validated type but is only used transiently in `validate_rotation` and `verify_event_signature`, then discarded. + +```rust +// events.rs:181 — raw strings +pub k: Vec, + +// validate.rs:194 — parsed on the fly, thrown away +let key_bytes = KeriPublicKey::parse(&rot.k[0]) + .map(|k| k.as_bytes().to_vec()) +``` + +**Fix:** Use a CESR-qualified key newtype on the struct: +```rust +/// A CESR-encoded public key (e.g., 'D' + base64url Ed25519). +/// Validated at deserialization time. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[repr(transparent)] +pub struct CesrKey(String); + +impl CesrKey { + pub fn parse_ed25519(&self) -> Result { + KeriPublicKey::parse(&self.0) + } + pub fn as_str(&self) -> &str { &self.0 } +} + +// On the struct: +pub k: Vec, +``` + +Then `KeriPublicKey::parse` is called once per key during deserialization or on first access, not scattered across validation sites. + +Also update `KeyState.current_keys: Vec`. + +### Task 13.3: Type the Commitment Fields (`n`, `next_commitment`) + +**Current:** `n: Vec` on events, `next_commitment: Vec` on `KeyState`. These are always `E`-prefixed Blake3-256 digests, identical in format to `Said`, but stored as bare strings. + +`compute_next_commitment` returns `String`: +```rust +// crypto.rs:29 — returns untyped string +pub fn compute_next_commitment(public_key: &[u8]) -> String { + let hash = blake3::hash(public_key); + format!("E{}", URL_SAFE_NO_PAD.encode(hash.as_bytes())) +} +``` + +**Fix:** Return `Said` (or a new `KeyCommitment` newtype if semantic distinction matters): +```rust +pub fn compute_next_commitment(public_key: &[u8]) -> Said { + let hash = blake3::hash(public_key); + Said::new_unchecked(format!("E{}", URL_SAFE_NO_PAD.encode(hash.as_bytes()))) +} + +// On the structs: +pub n: Vec, +``` + +Update `KeyState.next_commitment: Vec`. + +### Task 13.4: Type the Backer Fields (`b`, `br`, `ba`) + +**Current:** `b: Vec`. + +Witness AIDs are fully qualified CESR primitives per the spec. For non-transferable witnesses (which the spec requires), these are public-key-derived AIDs (e.g., `D`-prefixed). + +**Fix:** After Task 10.1 relaxes `Prefix` to accept non-`E` derivation codes: +```rust +pub b: Vec, +// and after Task 1.2: +pub br: Vec, +pub ba: Vec, +``` + +### Task 13.5: Type the Version String (`v`) + +**Current:** `v: String` on all events. No validation of the `KERI10JSON{size}_` format. + +```rust +// events.rs:170 — raw string, no format enforcement +pub v: String, +``` + +**Fix:** Define a `VersionString` newtype: +```rust +/// KERI v1.x version string: "KERI10JSON{hhhhhh}_" (17 chars). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionString { + pub protocol: String, // "KERI" + pub version: (u8, u8), // (major, minor) + pub serialization: String, // "JSON" + pub size: u32, // serialized byte count +} + +impl VersionString { + pub fn as_str(&self) -> String { + format!("KERI{}{}{}{:06x}_", + self.version.0, self.version.1, + self.serialization, self.size) + } +} +``` + +With serde that validates on deserialization. + +### Task 13.6: Type the Receipt Fields + +**Current:** `Receipt.i: String`, `Receipt.s: u64`. + +**Fix:** +```rust +pub struct Receipt { + pub v: VersionString, + pub t: String, // or MessageType enum + pub d: Said, + pub i: Prefix, // was String + pub s: KeriSequence, // was u64 +} +``` + +### Task 13.7: Type the Configuration Traits (`c`) + +After Task 1.1 adds the `c` field, don't use `Vec` — use an enum: +```rust +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ConfigTrait { + #[serde(rename = "EO")] + EstablishmentOnly, + #[serde(rename = "DND")] + DoNotDelegate, + #[serde(rename = "DID")] + DelegateIsDelegator, + #[serde(rename = "RB")] + RegistrarBackers, + #[serde(rename = "NRB")] + NoRegistrarBackers, +} + +// On the struct: +pub c: Vec, +``` + +This makes `icp.c.contains(&ConfigTrait::EstablishmentOnly)` type-safe instead of `icp.c.contains(&"EO".to_string())`. + +--- + +## Priority Matrix + +| Epic | Priority | Reason | +|------|----------|--------| +| 1 (Event Fields) | **HIGH** | Field schema is the foundation; all downstream work depends on it | +| 2 (Version String) | **HIGH** | Affects SAID computation, interop, and all serialized events | +| 5 (Seal Format) | **HIGH** | Current seal format is non-interoperable | +| 4 (Threshold Parsing) | **MEDIUM** | Only affects multi-key identities (kt >= 10 hex = 16 decimal) | +| 6 (Multi-Sig Verification) | **MEDIUM** | Single-sig works today; needed for multi-device | +| 7 (Validation Gaps) | **MEDIUM** | Partial gaps in edge cases; core happy path works | +| 8 (Receipt Format) | **MEDIUM** | Receipts work for internal use; interop requires fix | +| 9 (SAID Refinements) | **MEDIUM** | Current SAID works; version string integration is correctness | +| 10 (Prefix Types) | **LOW** | Only self-addressing AIDs are used currently | +| 11 (Witness Validation) | **LOW** | No witnesses in production yet | +| 3 (Sequence u128) | **LOW** | u64 is practically sufficient | +| 12 (Delegation) | **LOW** | Not in current identity model | +| 13 (Strong Typing) | **HIGH** | Parse-at-boundary prevents entire classes of bugs; do alongside Epic 1 | + +--- + +## Recommended Execution Order + +1. **Epic 1** (event fields) + **Epic 13** (strong typing) — do together; when adding `c`/`br`/`ba`, type them correctly from the start rather than adding more `String` fields +2. **Epic 2** (version string) + **Epic 9** (SAID) — together, since SAID depends on version string +3. **Epic 5** (seals) — can proceed in parallel with Epic 2 +4. **Epic 4** (thresholds) — subsumed by Epic 13 Task 13.1 if done together +5. **Epic 7** (validation gaps) — requires Epic 1 for `c` field enforcement +6. **Epic 6** (multi-sig) — requires typed thresholds from Epic 13 +7. **Epic 8** (receipts) — depends on signature externalization from Epic 1 Task 1.4 +8. **Epics 10, 11, 3, 12** — in any order, as capacity allows diff --git a/crates/auths-keri/src/crypto.rs b/crates/auths-keri/src/crypto.rs index f3aeb650..cf468dff 100644 --- a/crates/auths-keri/src/crypto.rs +++ b/crates/auths-keri/src/crypto.rs @@ -1,48 +1,13 @@ -//! Internal KERI SAID computation and key commitment functions. +//! KERI pre-rotation commitment functions. //! -//! This module implements the internal SAID algorithm (empty `d` field, not -//! the spec-compliant `###` placeholder). See `said.rs` for the spec-compliant -//! variant used in CESR interop. +//! Key commitment hashes for pre-rotation are computed here. +//! SAID computation lives in `said.rs`. use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use subtle::ConstantTimeEq; use crate::types::Said; -/// Compute SAID (Self-Addressing Identifier) for a KERI event. -/// -/// The SAID is computed by: -/// 1. Hashing the input with Blake3 -/// 2. Encoding the hash as Base64url (no padding) -/// 3. Prefixing with 'E' (KERI derivation code for Blake3-256) -/// -/// # Arguments -/// * `event_json` - The canonical JSON bytes of the event (with 'd' field empty) -/// -/// # Returns -/// A `Said` wrapping a string like "EXq5YqaL6L48pf0fu7IUhL0JRaU2_RxFP0AL43wYn148" -/// -/// # Fixed-Input Regression -/// -/// The SAID algorithm is deterministic. This doc-test pins the output for a -/// known input. If this test fails, the SAID algorithm has changed — do NOT -/// update the expected value without a migration plan for stored events. -/// -/// ``` -/// use auths_keri::compute_said; -/// let said = compute_said(b"{\"t\":\"icp\",\"s\":\"0\"}"); -/// // The exact value is pinned to the blake3 hash of the input above. -/// assert_eq!(said.as_str().len(), 44); -/// assert!(said.as_str().starts_with('E')); -/// // Verify determinism (same input = same output) -/// assert_eq!(said, compute_said(b"{\"t\":\"icp\",\"s\":\"0\"}")); -/// ``` -pub fn compute_said(event_json: &[u8]) -> Said { - let hash = blake3::hash(event_json); - let encoded = URL_SAFE_NO_PAD.encode(hash.as_bytes()); - Said::new_unchecked(format!("E{}", encoded)) -} - /// Compute next-key commitment hash for pre-rotation. /// /// The commitment is computed by: @@ -53,77 +18,51 @@ pub fn compute_said(event_json: &[u8]) -> Said { /// This commitment is included in the current event's 'n' field and must /// be satisfied by the next rotation event's 'k' field. /// -/// # Arguments +/// Args: /// * `public_key` - The raw public key bytes (32 bytes for Ed25519) /// -/// # Returns -/// A commitment string like "EO8CE5RH3wHBrXyFay3MOXq5YqaL6L48pf0fu7IUhL0J" -pub fn compute_next_commitment(public_key: &[u8]) -> String { +/// Usage: +/// ``` +/// use auths_keri::compute_next_commitment; +/// let commitment = compute_next_commitment(&[0u8; 32]); +/// assert_eq!(commitment.as_str().len(), 44); +/// assert!(commitment.as_str().starts_with('E')); +/// ``` +pub fn compute_next_commitment(public_key: &[u8]) -> Said { let hash = blake3::hash(public_key); let encoded = URL_SAFE_NO_PAD.encode(hash.as_bytes()); - format!("E{}", encoded) + Said::new_unchecked(format!("E{}", encoded)) } /// Verify that a public key matches a commitment. /// -/// # Arguments +/// Args: /// * `public_key` - The raw public key bytes to verify -/// * `commitment` - The commitment string from a previous event's 'n' field +/// * `commitment` - The commitment `Said` from a previous event's 'n' field /// -/// # Returns -/// `true` if the public key hashes to the commitment, `false` otherwise +/// Usage: +/// ``` +/// use auths_keri::{compute_next_commitment, verify_commitment}; +/// let key = [1u8; 32]; +/// let c = compute_next_commitment(&key); +/// assert!(verify_commitment(&key, &c)); +/// assert!(!verify_commitment(&[2u8; 32], &c)); +/// ``` // Defense-in-depth: both values are derived from public data, but constant-time // comparison prevents timing side-channels on commitment verification. -pub fn verify_commitment(public_key: &[u8], commitment: &str) -> bool { +pub fn verify_commitment(public_key: &[u8], commitment: &Said) -> bool { let computed = compute_next_commitment(public_key); - computed.as_bytes().ct_eq(commitment.as_bytes()).into() + computed + .as_str() + .as_bytes() + .ct_eq(commitment.as_str().as_bytes()) + .into() } #[cfg(test)] mod tests { use super::*; - /// Fixed-input regression test for the internal SAID algorithm. - /// - /// If this hash ever changes, the on-disk event format has changed and - /// stored events will no longer verify correctly. - #[test] - fn said_regression_fixed_input() { - let json = b"{\"t\":\"icp\",\"d\":\"\"}"; - let said = compute_said(json); - // This value is the canonical output of blake3(json) + base64url + 'E'. - // Do NOT change this value without a migration plan for stored events. - assert_eq!(said.as_str().len(), 44); - assert!(said.as_str().starts_with('E')); - // Verify determinism - let said2 = compute_said(json); - assert_eq!(said, said2); - } - - #[test] - fn said_is_deterministic() { - let json = b"{\"t\":\"icp\",\"s\":\"0\"}"; - let said1 = compute_said(json); - let said2 = compute_said(json); - assert_eq!(said1, said2); - assert!(said1.as_str().starts_with('E')); - } - - #[test] - fn said_has_correct_length() { - let json = b"{\"test\":\"data\"}"; - let said = compute_said(json); - // 'E' + 43 chars of base64url (32 bytes encoded) - assert_eq!(said.as_str().len(), 44); - } - - #[test] - fn different_inputs_produce_different_saids() { - let said1 = compute_said(b"{\"a\":1}"); - let said2 = compute_said(b"{\"a\":2}"); - assert_ne!(said1, said2); - } - #[test] fn commitment_verification_works() { let key = [1u8; 32]; @@ -138,7 +77,7 @@ mod tests { let c1 = compute_next_commitment(&key); let c2 = compute_next_commitment(&key); assert_eq!(c1, c2); - assert!(c1.starts_with('E')); + assert!(c1.as_str().starts_with('E')); } #[test] @@ -146,6 +85,6 @@ mod tests { let key = [0u8; 32]; let commitment = compute_next_commitment(&key); // 'E' + 43 chars of base64url - assert_eq!(commitment.len(), 44); + assert_eq!(commitment.as_str().len(), 44); } } diff --git a/crates/auths-keri/src/events.rs b/crates/auths-keri/src/events.rs index d2a8822e..a88261d7 100644 --- a/crates/auths-keri/src/events.rs +++ b/crates/auths-keri/src/events.rs @@ -7,10 +7,10 @@ use serde::ser::SerializeMap; use serde::{Deserialize, Serialize, Serializer}; use std::fmt; -use crate::types::{Prefix, Said}; +use crate::types::{CesrKey, ConfigTrait, Prefix, Said, Threshold, VersionString}; /// KERI protocol version prefix string. -pub const KERI_VERSION: &str = "KERI10JSON"; +pub const KERI_VERSION_PREFIX: &str = "KERI10JSON"; // ── KeriSequence ───────────────────────────────────────────────────────────── @@ -25,7 +25,7 @@ pub const KERI_VERSION: &str = "KERI10JSON"; /// assert_eq!(serde_json::to_string(&seq).unwrap(), "\"a\""); /// ``` #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct KeriSequence(u64); +pub struct KeriSequence(u128); #[cfg(feature = "schema")] impl schemars::JsonSchema for KeriSequence { @@ -45,11 +45,21 @@ impl schemars::JsonSchema for KeriSequence { impl KeriSequence { /// Create a new sequence number. pub fn new(value: u64) -> Self { + Self(value as u128) + } + + /// Create a new sequence number from a u128 value. + pub fn new_u128(value: u128) -> Self { Self(value) } - /// Return the inner u64 value. + /// Return the inner value as u64 (truncates if > u64::MAX, which is unrealistic). pub fn value(self) -> u64 { + self.0 as u64 + } + + /// Return the full u128 value. + pub fn value_u128(self) -> u128 { self.0 } } @@ -69,7 +79,7 @@ impl Serialize for KeriSequence { impl<'de> Deserialize<'de> for KeriSequence { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; - let value = u64::from_str_radix(&s, 16) + let value = u128::from_str_radix(&s, 16) .map_err(|_| serde::de::Error::custom(format!("invalid hex sequence: {s:?}")))?; Ok(KeriSequence(value)) } @@ -77,7 +87,220 @@ impl<'de> Deserialize<'de> for KeriSequence { // ── Seal ───────────────────────────────────────────────────────────────────── +/// KERI seal — anchors external data in an event's `a` field. +/// +/// Variants are distinguished by field shape (untagged), not by a "type" discriminator. +/// Per the spec, seal fields MUST appear in the specified order. +/// +/// Usage: +/// ``` +/// use auths_keri::Seal; +/// let seal = Seal::digest("EDigest123"); +/// assert!(seal.digest_value().is_some()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Seal { + /// Digest seal: `{"d": ""}` + Digest { + /// SAID of the anchored data. + d: Said, + }, + /// Source event seal: `{"s": "", "d": ""}` + SourceEvent { + /// Sequence number. + s: KeriSequence, + /// Event SAID. + d: Said, + }, + /// Key event seal: `{"i": "", "s": "", "d": ""}` + KeyEvent { + /// AID. + i: Prefix, + /// Sequence number. + s: KeriSequence, + /// Event SAID. + d: Said, + }, + /// Latest establishment event seal: `{"i": ""}` + LatestEstablishment { + /// AID. + i: Prefix, + }, + /// Merkle tree root digest seal: `{"rd": ""}` + MerkleRoot { + /// Root digest. + rd: Said, + }, + /// Registrar backer seal: `{"bi": "", "d": ""}` + RegistrarBacker { + /// Backer AID. + bi: Prefix, + /// Metadata SAID. + d: Said, + }, +} + +impl Seal { + /// Create a digest seal from a SAID. + pub fn digest(said: impl Into) -> Self { + Self::Digest { + d: Said::new_unchecked(said.into()), + } + } + + /// Create a key event seal. + pub fn key_event(prefix: Prefix, sequence: KeriSequence, said: Said) -> Self { + Self::KeyEvent { + i: prefix, + s: sequence, + d: said, + } + } + + /// Get the digest from this seal, if it has one. + pub fn digest_value(&self) -> Option<&Said> { + match self { + Seal::Digest { d } => Some(d), + Seal::SourceEvent { d, .. } => Some(d), + Seal::KeyEvent { d, .. } => Some(d), + Seal::RegistrarBacker { d, .. } => Some(d), + Seal::MerkleRoot { rd } => Some(rd), + Seal::LatestEstablishment { .. } => None, + } + } +} + +impl Serialize for Seal { + fn serialize(&self, serializer: S) -> Result { + match self { + Seal::Digest { d } => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("d", d)?; + map.end() + } + Seal::SourceEvent { s, d } => { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("s", s)?; + map.serialize_entry("d", d)?; + map.end() + } + Seal::KeyEvent { i, s, d } => { + let mut map = serializer.serialize_map(Some(3))?; + map.serialize_entry("i", i)?; + map.serialize_entry("s", s)?; + map.serialize_entry("d", d)?; + map.end() + } + Seal::LatestEstablishment { i } => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("i", i)?; + map.end() + } + Seal::MerkleRoot { rd } => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("rd", rd)?; + map.end() + } + Seal::RegistrarBacker { bi, d } => { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("bi", bi)?; + map.serialize_entry("d", d)?; + map.end() + } + } + } +} + +impl<'de> Deserialize<'de> for Seal { + fn deserialize>(deserializer: D) -> Result { + let map: serde_json::Map = + serde_json::Map::deserialize(deserializer)?; + + // Discriminate by field presence (most-specific first) + if map.contains_key("rd") { + let rd = map + .get("rd") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("rd must be a string"))?; + Ok(Seal::MerkleRoot { + rd: Said::new_unchecked(rd.to_string()), + }) + } else if map.contains_key("bi") { + let bi = map + .get("bi") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("bi must be a string"))?; + let d = map + .get("d") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("d required for registrar backer seal"))?; + Ok(Seal::RegistrarBacker { + bi: Prefix::new_unchecked(bi.to_string()), + d: Said::new_unchecked(d.to_string()), + }) + } else if map.contains_key("i") && map.contains_key("s") && map.contains_key("d") { + let i = map + .get("i") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("i must be a string"))?; + let s: KeriSequence = serde_json::from_value( + map.get("s") + .cloned() + .ok_or_else(|| serde::de::Error::custom("s required"))?, + ) + .map_err(serde::de::Error::custom)?; + let d = map + .get("d") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("d must be a string"))?; + Ok(Seal::KeyEvent { + i: Prefix::new_unchecked(i.to_string()), + s, + d: Said::new_unchecked(d.to_string()), + }) + } else if map.contains_key("i") { + let i = map + .get("i") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("i must be a string"))?; + Ok(Seal::LatestEstablishment { + i: Prefix::new_unchecked(i.to_string()), + }) + } else if map.contains_key("s") && map.contains_key("d") { + let s: KeriSequence = serde_json::from_value( + map.get("s") + .cloned() + .ok_or_else(|| serde::de::Error::custom("s required"))?, + ) + .map_err(serde::de::Error::custom)?; + let d = map + .get("d") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("d must be a string"))?; + Ok(Seal::SourceEvent { + s, + d: Said::new_unchecked(d.to_string()), + }) + } else if map.contains_key("d") { + let d = map + .get("d") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::custom("d must be a string"))?; + Ok(Seal::Digest { + d: Said::new_unchecked(d.to_string()), + }) + } else { + Err(serde::de::Error::custom("unrecognized seal format")) + } + } +} + /// Type of data anchored by a seal. +/// +/// **DEPRECATED:** This enum is retained for backwards compatibility with existing +/// stored attestations. New code should use `Seal::digest()` directly — the type +/// information lives in the anchored document, not the seal. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "kebab-case")] @@ -104,57 +327,6 @@ impl fmt::Display for SealType { } } -/// A seal anchors external data in a KERI event. -/// -/// Seals are included in the `a` (anchors) field of KERI events. -/// They contain a digest of the anchored data and a type indicator. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -pub struct Seal { - /// SAID (digest) of the anchored data - pub d: Said, - /// Type of anchored data - #[serde(rename = "type")] - pub seal_type: SealType, -} - -impl Seal { - /// Create a new seal with the given digest and type. - /// - /// Args: - /// * `digest`: SAID of the anchored data. - /// * `seal_type`: Type of anchored data. - pub fn new(digest: impl Into, seal_type: SealType) -> Self { - Self { - d: Said::new_unchecked(digest.into()), - seal_type, - } - } - - /// Create a seal for a device attestation. - /// - /// Args: - /// * `attestation_digest`: SAID of the attestation JSON. - pub fn device_attestation(attestation_digest: impl Into) -> Self { - Self::new(attestation_digest, SealType::DeviceAttestation) - } - - /// Create a seal for a revocation. - pub fn revocation(revocation_digest: impl Into) -> Self { - Self::new(revocation_digest, SealType::Revocation) - } - - /// Create a seal for capability delegation. - pub fn delegation(delegation_digest: impl Into) -> Self { - Self::new(delegation_digest, SealType::Delegation) - } - - /// Create a seal for an IdP binding. - pub fn idp_binding(binding_digest: impl Into) -> Self { - Self::new(binding_digest, SealType::IdpBinding) - } -} - // ── Event Types ─────────────────────────────────────────────────────────────── /// Inception event — creates a new KERI identity. @@ -162,52 +334,52 @@ impl Seal { /// The inception event establishes the identifier prefix and commits /// to the first rotation key via the `n` (next) field. /// -/// Note: The `t` (type) field is handled by the `Event` enum's serde tag. +/// Spec field order: `[v, t, d, i, s, kt, k, nt, n, bt, b, c, a]` #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct IcpEvent { - /// Version string: "KERI10JSON" - pub v: String, + /// Version string + pub v: VersionString, /// SAID (Self-Addressing Identifier) — Blake3 hash of event #[serde(default)] pub d: Said, - /// Identifier prefix (same as `d` for inception) + /// Identifier prefix (same as `d` for self-addressing inception) pub i: Prefix, /// Sequence number (always 0 for inception) pub s: KeriSequence, - /// Key threshold: "1" for single-sig - pub kt: String, - /// Current public key(s), Base64url encoded with derivation code - pub k: Vec, - /// Next key threshold: "1" - pub nt: String, - /// Next key commitment(s) — hash of next public key(s) - pub n: Vec, - /// Witness threshold: "0" (no witnesses) - pub bt: String, - /// Witness list (empty) - pub b: Vec, + /// Key signing threshold (hex integer or fractional weight list) + pub kt: Threshold, + /// Current public key(s), CESR-encoded + pub k: Vec, + /// Next key signing threshold + pub nt: Threshold, + /// Next key commitment(s) — Blake3 digests of next public key(s) + pub n: Vec, + /// Witness/backer threshold + pub bt: Threshold, + /// Witness/backer list (ordered AIDs) + #[serde(default)] + pub b: Vec, + /// Configuration traits (e.g., EstablishmentOnly, DoNotDelegate) + #[serde(default)] + pub c: Vec, /// Anchored seals #[serde(default)] pub a: Vec, - /// Event signature (Ed25519, base64url-no-pad) + /// Legacy signature field — DEPRECATED. Use `SignedEvent` with externalized signatures. + /// Retained for backwards compatibility with stored events. #[serde(default)] pub x: String, } -/// Spec field order: v, t, d, i, s, kt, k, nt, n, bt, b, a, x +/// Spec field order: v, t, d, i, s, kt, k, nt, n, bt, b, c, a (+ x if non-empty, legacy) impl Serialize for IcpEvent { fn serialize(&self, serializer: S) -> Result { - let field_count = 10 - + (!self.d.is_empty() as usize) - + (!self.a.is_empty() as usize) - + (!self.x.is_empty() as usize); + let field_count = 13 + (!self.x.is_empty() as usize); let mut map = serializer.serialize_map(Some(field_count))?; map.serialize_entry("v", &self.v)?; map.serialize_entry("t", "icp")?; - if !self.d.is_empty() { - map.serialize_entry("d", &self.d)?; - } + map.serialize_entry("d", &self.d)?; map.serialize_entry("i", &self.i)?; map.serialize_entry("s", &self.s)?; map.serialize_entry("kt", &self.kt)?; @@ -216,9 +388,8 @@ impl Serialize for IcpEvent { map.serialize_entry("n", &self.n)?; map.serialize_entry("bt", &self.bt)?; map.serialize_entry("b", &self.b)?; - if !self.a.is_empty() { - map.serialize_entry("a", &self.a)?; - } + map.serialize_entry("c", &self.c)?; + map.serialize_entry("a", &self.a)?; if !self.x.is_empty() { map.serialize_entry("x", &self.x)?; } @@ -228,15 +399,12 @@ impl Serialize for IcpEvent { /// Rotation event — rotates to pre-committed key. /// -/// The new key must match the previous event's next-key commitment. -/// This provides cryptographic pre-rotation security. -/// -/// Note: The `t` (type) field is handled by the `Event` enum's serde tag. +/// Spec field order: `[v, t, d, i, s, p, kt, k, nt, n, bt, br, ba, c, a]` #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct RotEvent { /// Version string - pub v: String, + pub v: VersionString, /// SAID of this event #[serde(default)] pub d: Said, @@ -246,39 +414,41 @@ pub struct RotEvent { pub s: KeriSequence, /// Previous event SAID (creates the hash chain) pub p: Said, - /// Key threshold - pub kt: String, - /// New current key(s) - pub k: Vec, - /// Next key threshold - pub nt: String, - /// New next key commitment(s) - pub n: Vec, - /// Witness threshold - pub bt: String, - /// Witness list - pub b: Vec, + /// Key signing threshold + pub kt: Threshold, + /// New current key(s), CESR-encoded + pub k: Vec, + /// Next key signing threshold + pub nt: Threshold, + /// New next key commitment(s) — Blake3 digests + pub n: Vec, + /// Witness/backer threshold + pub bt: Threshold, + /// List of backers to remove (processed first) + #[serde(default)] + pub br: Vec, + /// List of backers to add (processed after removals) + #[serde(default)] + pub ba: Vec, + /// Configuration traits + #[serde(default)] + pub c: Vec, /// Anchored seals #[serde(default)] pub a: Vec, - /// Event signature (Ed25519, base64url-no-pad) + /// Event signature — DEPRECATED: will be externalized #[serde(default)] pub x: String, } -/// Spec field order: v, t, d, i, s, p, kt, k, nt, n, bt, b, a, x +/// Spec field order: v, t, d, i, s, p, kt, k, nt, n, bt, br, ba, c, a (+ x if non-empty, legacy) impl Serialize for RotEvent { fn serialize(&self, serializer: S) -> Result { - let field_count = 11 - + (!self.d.is_empty() as usize) - + (!self.a.is_empty() as usize) - + (!self.x.is_empty() as usize); + let field_count = 15 + (!self.x.is_empty() as usize); let mut map = serializer.serialize_map(Some(field_count))?; map.serialize_entry("v", &self.v)?; map.serialize_entry("t", "rot")?; - if !self.d.is_empty() { - map.serialize_entry("d", &self.d)?; - } + map.serialize_entry("d", &self.d)?; map.serialize_entry("i", &self.i)?; map.serialize_entry("s", &self.s)?; map.serialize_entry("p", &self.p)?; @@ -287,10 +457,10 @@ impl Serialize for RotEvent { map.serialize_entry("nt", &self.nt)?; map.serialize_entry("n", &self.n)?; map.serialize_entry("bt", &self.bt)?; - map.serialize_entry("b", &self.b)?; - if !self.a.is_empty() { - map.serialize_entry("a", &self.a)?; - } + map.serialize_entry("br", &self.br)?; + map.serialize_entry("ba", &self.ba)?; + map.serialize_entry("c", &self.c)?; + map.serialize_entry("a", &self.a)?; if !self.x.is_empty() { map.serialize_entry("x", &self.x)?; } @@ -300,15 +470,12 @@ impl Serialize for RotEvent { /// Interaction event — anchors data without key rotation. /// -/// Used to anchor attestations, delegations, or other data -/// in the KEL without changing keys. -/// -/// Note: The `t` (type) field is handled by the `Event` enum's serde tag. +/// Spec field order: `[v, t, d, i, s, p, a]` #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct IxnEvent { /// Version string - pub v: String, + pub v: VersionString, /// SAID of this event #[serde(default)] pub d: Said, @@ -320,21 +487,19 @@ pub struct IxnEvent { pub p: Said, /// Anchored seals (the main purpose of IXN events) pub a: Vec, - /// Event signature (Ed25519, base64url-no-pad) + /// Event signature — DEPRECATED: will be externalized #[serde(default)] pub x: String, } -/// Spec field order: v, t, d, i, s, p, a, x +/// Spec field order: v, t, d, i, s, p, a (+ x if non-empty, legacy) impl Serialize for IxnEvent { fn serialize(&self, serializer: S) -> Result { - let field_count = 6 + (!self.d.is_empty() as usize) + (!self.x.is_empty() as usize); + let field_count = 7 + (!self.x.is_empty() as usize); let mut map = serializer.serialize_map(Some(field_count))?; map.serialize_entry("v", &self.v)?; map.serialize_entry("t", "ixn")?; - if !self.d.is_empty() { - map.serialize_entry("d", &self.d)?; - } + map.serialize_entry("d", &self.d)?; map.serialize_entry("i", &self.i)?; map.serialize_entry("s", &self.s)?; map.serialize_entry("p", &self.p)?; @@ -359,6 +524,148 @@ impl Serialize for IxnEvent { /// Event::Ixn(ixn) => { /* interaction */ } /// } /// ``` +/// Delegated inception event — creates a delegated KERI identity. +/// +/// Same as ICP plus the `di` (delegator identifier prefix) field. +/// Spec field order: `[v, t, d, i, s, kt, k, nt, n, bt, b, c, a, di]` +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DipEvent { + /// Version string + pub v: VersionString, + /// SAID + #[serde(default)] + pub d: Said, + /// Identifier prefix (same as `d` for self-addressing) + pub i: Prefix, + /// Sequence number (always 0) + pub s: KeriSequence, + /// Key signing threshold + pub kt: Threshold, + /// Current public key(s) + pub k: Vec, + /// Next key signing threshold + pub nt: Threshold, + /// Next key commitment(s) + pub n: Vec, + /// Witness/backer threshold + pub bt: Threshold, + /// Witness/backer list + #[serde(default)] + pub b: Vec, + /// Configuration traits + #[serde(default)] + pub c: Vec, + /// Anchored seals + #[serde(default)] + pub a: Vec, + /// Delegator identifier prefix + pub di: Prefix, + /// Event signature — DEPRECATED: will be externalized + #[serde(default)] + pub x: String, +} + +/// Spec field order: v, t, d, i, s, kt, k, nt, n, bt, b, c, a, di (+ x if non-empty) +impl Serialize for DipEvent { + fn serialize(&self, serializer: S) -> Result { + let field_count = 14 + (!self.x.is_empty() as usize); + let mut map = serializer.serialize_map(Some(field_count))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "dip")?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("i", &self.i)?; + map.serialize_entry("s", &self.s)?; + map.serialize_entry("kt", &self.kt)?; + map.serialize_entry("k", &self.k)?; + map.serialize_entry("nt", &self.nt)?; + map.serialize_entry("n", &self.n)?; + map.serialize_entry("bt", &self.bt)?; + map.serialize_entry("b", &self.b)?; + map.serialize_entry("c", &self.c)?; + map.serialize_entry("a", &self.a)?; + map.serialize_entry("di", &self.di)?; + if !self.x.is_empty() { + map.serialize_entry("x", &self.x)?; + } + map.end() + } +} + +/// Delegated rotation event — rotates keys for a delegated identity. +/// +/// Same field set as ROT but type `drt`. Validation requires checking the +/// delegator's KEL for an anchoring seal. +/// Spec field order: `[v, t, d, i, s, p, kt, k, nt, n, bt, br, ba, c, a]` +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DrtEvent { + /// Version string + pub v: VersionString, + /// SAID + #[serde(default)] + pub d: Said, + /// Identifier prefix + pub i: Prefix, + /// Sequence number + pub s: KeriSequence, + /// Previous event SAID + pub p: Said, + /// Key signing threshold + pub kt: Threshold, + /// New current key(s) + pub k: Vec, + /// Next key signing threshold + pub nt: Threshold, + /// New next key commitment(s) + pub n: Vec, + /// Witness/backer threshold + pub bt: Threshold, + /// Backers to remove + #[serde(default)] + pub br: Vec, + /// Backers to add + #[serde(default)] + pub ba: Vec, + /// Configuration traits + #[serde(default)] + pub c: Vec, + /// Anchored seals + #[serde(default)] + pub a: Vec, + /// Event signature — DEPRECATED: will be externalized + #[serde(default)] + pub x: String, +} + +/// Spec field order: v, t, d, i, s, p, kt, k, nt, n, bt, br, ba, c, a (+ x if non-empty) +impl Serialize for DrtEvent { + fn serialize(&self, serializer: S) -> Result { + let field_count = 15 + (!self.x.is_empty() as usize); + let mut map = serializer.serialize_map(Some(field_count))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "drt")?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("i", &self.i)?; + map.serialize_entry("s", &self.s)?; + map.serialize_entry("p", &self.p)?; + map.serialize_entry("kt", &self.kt)?; + map.serialize_entry("k", &self.k)?; + map.serialize_entry("nt", &self.nt)?; + map.serialize_entry("n", &self.n)?; + map.serialize_entry("bt", &self.bt)?; + map.serialize_entry("br", &self.br)?; + map.serialize_entry("ba", &self.ba)?; + map.serialize_entry("c", &self.c)?; + map.serialize_entry("a", &self.a)?; + if !self.x.is_empty() { + map.serialize_entry("x", &self.x)?; + } + map.end() + } +} + +/// Unified event enum for processing any KERI event type. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(tag = "t")] @@ -372,6 +679,12 @@ pub enum Event { /// Interaction event #[serde(rename = "ixn")] Ixn(IxnEvent), + /// Delegated inception event + #[serde(rename = "dip")] + Dip(DipEvent), + /// Delegated rotation event + #[serde(rename = "drt")] + Drt(DrtEvent), } impl Serialize for Event { @@ -380,6 +693,8 @@ impl Serialize for Event { Event::Icp(e) => e.serialize(serializer), Event::Rot(e) => e.serialize(serializer), Event::Ixn(e) => e.serialize(serializer), + Event::Dip(e) => e.serialize(serializer), + Event::Drt(e) => e.serialize(serializer), } } } @@ -391,15 +706,19 @@ impl Event { Event::Icp(e) => &e.d, Event::Rot(e) => &e.d, Event::Ixn(e) => &e.d, + Event::Dip(e) => &e.d, + Event::Drt(e) => &e.d, } } - /// Get the signature of this event. + /// Get the signature of this event (legacy `x` field). pub fn signature(&self) -> &str { match self { Event::Icp(e) => &e.x, Event::Rot(e) => &e.x, Event::Ixn(e) => &e.x, + Event::Dip(e) => &e.x, + Event::Drt(e) => &e.x, } } @@ -409,6 +728,8 @@ impl Event { Event::Icp(e) => e.s, Event::Rot(e) => e.s, Event::Ixn(e) => e.s, + Event::Dip(e) => e.s, + Event::Drt(e) => e.s, } } @@ -418,32 +739,39 @@ impl Event { Event::Icp(e) => &e.i, Event::Rot(e) => &e.i, Event::Ixn(e) => &e.i, + Event::Dip(e) => &e.i, + Event::Drt(e) => &e.i, } } - /// Get the previous event SAID (None for inception). + /// Get the previous event SAID (None for inception/delegated inception). pub fn previous(&self) -> Option<&Said> { match self { - Event::Icp(_) => None, + Event::Icp(_) | Event::Dip(_) => None, Event::Rot(e) => Some(&e.p), Event::Ixn(e) => Some(&e.p), + Event::Drt(e) => Some(&e.p), } } - /// Get the current keys (only applicable to ICP and ROT events). - pub fn keys(&self) -> Option<&[String]> { + /// Get the current keys (only for establishment events). + pub fn keys(&self) -> Option<&[CesrKey]> { match self { Event::Icp(e) => Some(&e.k), Event::Rot(e) => Some(&e.k), + Event::Dip(e) => Some(&e.k), + Event::Drt(e) => Some(&e.k), Event::Ixn(_) => None, } } - /// Get the next key commitments (only applicable to ICP and ROT events). - pub fn next_commitments(&self) -> Option<&[String]> { + /// Get the next key commitments (only for establishment events). + pub fn next_commitments(&self) -> Option<&[Said]> { match self { Event::Icp(e) => Some(&e.n), Event::Rot(e) => Some(&e.n), + Event::Dip(e) => Some(&e.n), + Event::Drt(e) => Some(&e.n), Event::Ixn(_) => None, } } @@ -454,23 +782,104 @@ impl Event { Event::Icp(e) => &e.a, Event::Rot(e) => &e.a, Event::Ixn(e) => &e.a, + Event::Dip(e) => &e.a, + Event::Drt(e) => &e.a, + } + } + + /// Get the delegator AID (only for delegated inception). + pub fn delegator(&self) -> Option<&Prefix> { + match self { + Event::Dip(e) => Some(&e.di), + _ => None, } } - /// Check if this is an inception event. + /// Check if this is an inception event (including delegated). pub fn is_inception(&self) -> bool { - matches!(self, Event::Icp(_)) + matches!(self, Event::Icp(_) | Event::Dip(_)) } - /// Check if this is a rotation event. + /// Check if this is a rotation event (including delegated). pub fn is_rotation(&self) -> bool { - matches!(self, Event::Rot(_)) + matches!(self, Event::Rot(_) | Event::Drt(_)) } /// Check if this is an interaction event. pub fn is_interaction(&self) -> bool { matches!(self, Event::Ixn(_)) } + + /// Check if this is a delegated event. + pub fn is_delegated(&self) -> bool { + matches!(self, Event::Dip(_) | Event::Drt(_)) + } +} + +// ── Signed Event (externalized signatures) ────────────────────────────────── + +/// A single indexed controller signature. +/// +/// The `index` maps to the position in the key list (`k` field) of the +/// signing key. Per the CESR spec, indexed signatures carry their key +/// index in the derivation code. +/// +/// Usage: +/// ``` +/// use auths_keri::IndexedSignature; +/// let sig = IndexedSignature { index: 0, sig: vec![0u8; 64] }; +/// assert_eq!(sig.index, 0); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IndexedSignature { + /// Index into the key list (which key signed). + pub index: u32, + /// Raw signature bytes (64 bytes for Ed25519). + #[serde(with = "hex::serde")] + pub sig: Vec, +} + +/// An event paired with its detached signature(s). +/// +/// Per the KERI spec, signatures are NOT part of the event body. They are +/// attached externally (CESR attachment codes in streams, or stored alongside +/// in databases). The event body is what gets hashed for the SAID. +/// +/// Usage: +/// ```ignore +/// use auths_keri::{SignedEvent, IndexedSignature, Event}; +/// +/// // After creating and finalizing an event: +/// let signed = SignedEvent::new(event, vec![IndexedSignature { index: 0, sig: sig_bytes }]); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedEvent { + /// The event body (no signature data). + pub event: Event, + /// Controller-indexed signatures (detached from body). + pub signatures: Vec, +} + +impl SignedEvent { + /// Create a new signed event from an event and its signatures. + pub fn new(event: Event, signatures: Vec) -> Self { + Self { event, signatures } + } + + /// Get the SAID of the inner event. + pub fn said(&self) -> &Said { + self.event.said() + } + + /// Get the sequence number of the inner event. + pub fn sequence(&self) -> KeriSequence { + self.event.sequence() + } + + /// Get the identifier prefix of the inner event. + pub fn prefix(&self) -> &Prefix { + self.event.prefix() + } } #[cfg(test)] @@ -510,24 +919,29 @@ mod tests { } #[test] - fn icp_event_omits_empty_d_a_x() { + fn icp_event_always_serializes_d_a_c() { + use crate::types::{CesrKey, Threshold, VersionString}; let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::new_unchecked("ETest123".to_string()), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec!["DKey123".to_string()], - nt: "1".to_string(), - n: vec!["ENext456".to_string()], - bt: "0".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked("DKey123".to_string())], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("ENext456".to_string())], + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; let json = serde_json::to_string(&icp).unwrap(); - assert!(!json.contains("\"d\":"), "empty d must be omitted"); - assert!(!json.contains("\"a\":"), "empty a must be omitted"); + // d, a, c are always serialized (spec requires all fields) + assert!(json.contains("\"d\":"), "d must always be present"); + assert!(json.contains("\"a\":"), "a must always be present"); + assert!(json.contains("\"c\":"), "c must always be present"); + // x is still conditionally omitted (empty) assert!(!json.contains("\"x\":"), "empty x must be omitted"); assert!(json.contains("\"s\":\"0\""), "s must serialize as hex"); } @@ -541,17 +955,77 @@ mod tests { } #[test] - fn seal_serializes_with_kebab_case_type() { - let seal = Seal::device_attestation("EDigest"); + fn digest_seal_roundtrips() { + let seal = Seal::digest("EDigest123"); let json = serde_json::to_string(&seal).unwrap(); - assert!(json.contains(r#""type":"device-attestation""#)); + assert_eq!(json, r#"{"d":"EDigest123"}"#); + let parsed: Seal = serde_json::from_str(&json).unwrap(); + assert_eq!(seal, parsed); } #[test] - fn seal_roundtrips() { - let original = Seal::device_attestation("ETest123"); - let json = serde_json::to_string(&original).unwrap(); + fn key_event_seal_roundtrips() { + let seal = Seal::key_event( + Prefix::new_unchecked("EPrefix".to_string()), + KeriSequence::new(1), + Said::new_unchecked("ESaid".to_string()), + ); + let json = serde_json::to_string(&seal).unwrap(); let parsed: Seal = serde_json::from_str(&json).unwrap(); - assert_eq!(original, parsed); + assert_eq!(seal, parsed); + } + + #[test] + fn indexed_signature_serde_roundtrip() { + let sig = IndexedSignature { + index: 2, + sig: vec![0xab; 64], + }; + let json = serde_json::to_string(&sig).unwrap(); + assert!(json.contains("\"index\":2")); + let parsed: IndexedSignature = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, sig); + } + + #[test] + fn signed_event_accessors() { + use crate::types::{CesrKey, Threshold, VersionString}; + let icp = IcpEvent { + v: VersionString::placeholder(), + d: Said::new_unchecked("ESAID123".to_string()), + i: Prefix::new_unchecked("EPrefix".to_string()), + s: KeriSequence::new(0), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked("DKey".to_string())], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("ENext".to_string())], + bt: Threshold::Simple(0), + b: vec![], + c: vec![], + a: vec![], + x: String::new(), + }; + let signed = SignedEvent::new( + Event::Icp(icp), + vec![IndexedSignature { + index: 0, + sig: vec![0u8; 64], + }], + ); + assert_eq!(signed.said().as_str(), "ESAID123"); + assert_eq!(signed.sequence().value(), 0); + assert_eq!(signed.prefix().as_str(), "EPrefix"); + assert_eq!(signed.signatures.len(), 1); + assert_eq!(signed.signatures[0].index, 0); + } + + #[test] + fn seal_digest_value() { + let seal = Seal::digest("ETest123"); + assert_eq!(seal.digest_value().unwrap().as_str(), "ETest123"); + let latest = Seal::LatestEstablishment { + i: Prefix::new_unchecked("EPrefix".to_string()), + }; + assert!(latest.digest_value().is_none()); } } diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index efac05ee..aad83995 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -19,10 +19,9 @@ //! //! Usage (default, no CESR): //! ```ignore -//! use auths_keri::{Prefix, Said, compute_said, compute_spec_said}; +//! use auths_keri::{Prefix, Said, compute_said}; //! -//! let said = compute_said(event_bytes); -//! let spec_said = compute_spec_said(&event_json)?; +//! let said = compute_said(&event_json)?; //! ``` //! //! Usage (with CESR feature): @@ -38,6 +37,8 @@ mod error; mod events; pub mod kel_io; mod keys; +/// Routed KERI message types (qry, rpy, pro, bar, xip, exn). +pub mod messages; mod said; mod state; mod types; @@ -56,17 +57,24 @@ mod stream; #[cfg(feature = "cesr")] mod version; -pub use crypto::{compute_next_commitment, compute_said, verify_commitment}; +pub use crypto::{compute_next_commitment, verify_commitment}; pub use error::KeriTranslationError; -pub use events::{Event, IcpEvent, IxnEvent, KERI_VERSION, KeriSequence, RotEvent, Seal, SealType}; +pub use events::{ + DipEvent, DrtEvent, Event, IcpEvent, IndexedSignature, IxnEvent, KERI_VERSION_PREFIX, + KeriSequence, RotEvent, Seal, SealType, SignedEvent, +}; pub use keys::{KeriDecodeError, KeriPublicKey}; -pub use said::{SAID_PLACEHOLDER, compute_spec_said, verify_spec_said}; +pub use said::{SAID_PLACEHOLDER, compute_said, verify_said}; pub use state::KeyState; -pub use types::{KeriTypeError, Prefix, Said}; +pub use types::{ + CesrKey, ConfigTrait, Fraction, FractionError, KeriTypeError, Prefix, Said, Threshold, + VersionString, +}; pub use validate::{ - ValidationError, compute_event_said, finalize_icp_event, find_seal_in_kel, parse_kel_json, - replay_kel, serialize_for_signing, validate_for_append, validate_kel, verify_event_crypto, - verify_event_said, + ValidationError, compute_event_said, finalize_icp_event, finalize_ixn_event, + finalize_rot_event, find_seal_in_kel, parse_kel_json, replay_kel, serialize_for_signing, + validate_delegation, validate_for_append, validate_kel, validate_signed_event, + verify_event_crypto, verify_event_said, }; #[cfg(feature = "cesr")] diff --git a/crates/auths-keri/src/messages.rs b/crates/auths-keri/src/messages.rs new file mode 100644 index 00000000..1b684d1a --- /dev/null +++ b/crates/auths-keri/src/messages.rs @@ -0,0 +1,352 @@ +//! Routed KERI message types: Query, Reply, Prod, Bare, Exchange Inception, Exchange. +//! +//! These message types enable inter-agent communication, discovery, and +//! credential exchange in the KERI protocol. They are NOT key events — +//! they don't appear in the KEL. + +use serde::ser::SerializeMap; +use serde::{Deserialize, Serialize, Serializer}; + +use crate::types::{Prefix, Said, VersionString}; + +/// Query message — requests information from a peer. +/// +/// Spec field order: `[v, t, d, dt, r, rr, q]` +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct QryMessage { + /// Version string + pub v: VersionString, + /// SAID + #[serde(default)] + pub d: Said, + /// ISO-8601 datetime with microseconds and UTC offset + pub dt: String, + /// Route (delimited path string) + pub r: String, + /// Return route (for response routing) + #[serde(default)] + pub rr: String, + /// Query parameters + #[serde(default)] + pub q: serde_json::Value, +} + +impl Serialize for QryMessage { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(7))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "qry")?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", &self.r)?; + map.serialize_entry("rr", &self.rr)?; + map.serialize_entry("q", &self.q)?; + map.end() + } +} + +/// Reply message — response to a query. +/// +/// Spec field order: `[v, t, d, dt, r, a]` +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RpyMessage { + /// Version string + pub v: VersionString, + /// SAID + #[serde(default)] + pub d: Said, + /// ISO-8601 datetime + pub dt: String, + /// Route + pub r: String, + /// Attribute map (response data) + #[serde(default)] + pub a: serde_json::Value, +} + +impl Serialize for RpyMessage { + 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", &self.r)?; + map.serialize_entry("a", &self.a)?; + map.end() + } +} + +/// Prod message — prompts a peer for information (similar to query). +/// +/// Spec field order: `[v, t, d, dt, r, rr, q]` +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ProMessage { + /// Version string + pub v: VersionString, + /// SAID + #[serde(default)] + pub d: Said, + /// ISO-8601 datetime + pub dt: String, + /// Route + pub r: String, + /// Return route + #[serde(default)] + pub rr: String, + /// Query parameters + #[serde(default)] + pub q: serde_json::Value, +} + +impl Serialize for ProMessage { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(7))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "pro")?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", &self.r)?; + map.serialize_entry("rr", &self.rr)?; + map.serialize_entry("q", &self.q)?; + map.end() + } +} + +/// Bare message — unsolicited response (similar to reply). +/// +/// Spec field order: `[v, t, d, dt, r, a]` +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct BarMessage { + /// Version string + pub v: VersionString, + /// SAID + #[serde(default)] + pub d: Said, + /// ISO-8601 datetime + pub dt: String, + /// Route + pub r: String, + /// Attribute map + #[serde(default)] + pub a: serde_json::Value, +} + +impl Serialize for BarMessage { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(6))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "bar")?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", &self.r)?; + map.serialize_entry("a", &self.a)?; + map.end() + } +} + +/// Exchange inception message — initiates an exchange protocol. +/// +/// Spec field order: `[v, t, d, u, i, ri, dt, r, q, a]` +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct XipMessage { + /// Version string + pub v: VersionString, + /// SAID + #[serde(default)] + pub d: Said, + /// UUID salty nonce (cryptographic strength random) + #[serde(default)] + pub u: String, + /// Sender AID + pub i: Prefix, + /// Receiver AID + pub ri: Prefix, + /// ISO-8601 datetime + pub dt: String, + /// Route + pub r: String, + /// Query parameters + #[serde(default)] + pub q: serde_json::Value, + /// Attribute map + #[serde(default)] + pub a: serde_json::Value, +} + +impl Serialize for XipMessage { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(10))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "xip")?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("u", &self.u)?; + map.serialize_entry("i", &self.i)?; + map.serialize_entry("ri", &self.ri)?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", &self.r)?; + map.serialize_entry("q", &self.q)?; + map.serialize_entry("a", &self.a)?; + map.end() + } +} + +/// Exchange message — peer-to-peer exchange (credential, data). +/// +/// Spec field order: `[v, t, d, i, ri, x, p, dt, r, q, a]` +/// +/// Note: The `x` field here is the Exchange SAID (spec-defined), NOT a signature. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ExnMessage { + /// Version string + pub v: VersionString, + /// SAID + #[serde(default)] + pub d: Said, + /// Sender AID + pub i: Prefix, + /// Receiver AID + pub ri: Prefix, + /// Exchange SAID (unique digest for exchange transaction — NOT a signature) + #[serde(default)] + pub x: Said, + /// Prior message SAID + #[serde(default)] + pub p: Said, + /// ISO-8601 datetime + pub dt: String, + /// Route + pub r: String, + /// Query parameters + #[serde(default)] + pub q: serde_json::Value, + /// Attribute map + #[serde(default)] + pub a: serde_json::Value, +} + +impl Serialize for ExnMessage { + 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")?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("i", &self.i)?; + map.serialize_entry("ri", &self.ri)?; + map.serialize_entry("x", &self.x)?; + map.serialize_entry("p", &self.p)?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", &self.r)?; + map.serialize_entry("q", &self.q)?; + map.serialize_entry("a", &self.a)?; + map.end() + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn qry_message_roundtrip() { + let msg = QryMessage { + v: VersionString::placeholder(), + d: Said::default(), + dt: "2024-01-01T00:00:00.000000+00:00".into(), + r: "/kel".into(), + rr: "/receipt".into(), + q: serde_json::json!({"i": "EPrefix123"}), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"t\":\"qry\"")); + let parsed: QryMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.r, "/kel"); + } + + #[test] + fn rpy_message_roundtrip() { + let msg = RpyMessage { + v: VersionString::placeholder(), + d: Said::default(), + dt: "2024-01-01T00:00:00.000000+00:00".into(), + r: "/kel".into(), + a: serde_json::json!({"data": "value"}), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"t\":\"rpy\"")); + let parsed: RpyMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, msg); + } + + #[test] + fn xip_message_roundtrip() { + let msg = XipMessage { + v: VersionString::placeholder(), + d: Said::default(), + u: "nonce123".into(), + i: Prefix::new_unchecked("ESender".into()), + ri: Prefix::new_unchecked("EReceiver".into()), + dt: "2024-01-01T00:00:00.000000+00:00".into(), + r: "/credential/issue".into(), + q: serde_json::json!({}), + a: serde_json::json!({}), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"t\":\"xip\"")); + let parsed: XipMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.i.as_str(), "ESender"); + } + + #[test] + fn exn_message_roundtrip() { + let msg = ExnMessage { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::new_unchecked("ESender".into()), + ri: Prefix::new_unchecked("EReceiver".into()), + x: Said::new_unchecked("EExchangeSaid".into()), + p: Said::default(), + dt: "2024-01-01T00:00:00.000000+00:00".into(), + r: "/credential/present".into(), + q: serde_json::json!({}), + a: serde_json::json!({}), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"t\":\"exn\"")); + assert!(json.contains("\"x\":\"EExchangeSaid\"")); + let parsed: ExnMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.x.as_str(), "EExchangeSaid"); + } + + #[test] + fn bar_message_roundtrip() { + let msg = BarMessage { + v: VersionString::placeholder(), + d: Said::default(), + dt: "2024-01-01T00:00:00.000000+00:00".into(), + r: "/notify".into(), + a: serde_json::json!({"status": "ok"}), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"t\":\"bar\"")); + let parsed: BarMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, msg); + } + + #[test] + fn pro_message_roundtrip() { + let msg = ProMessage { + v: VersionString::placeholder(), + d: Said::default(), + dt: "2024-01-01T00:00:00.000000+00:00".into(), + r: "/prod".into(), + rr: "/reply".into(), + q: serde_json::json!({}), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"t\":\"pro\"")); + let parsed: ProMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, msg); + } +} diff --git a/crates/auths-keri/src/said.rs b/crates/auths-keri/src/said.rs index aa3e6917..864d3615 100644 --- a/crates/auths-keri/src/said.rs +++ b/crates/auths-keri/src/said.rs @@ -1,6 +1,6 @@ -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; - use crate::error::KeriTranslationError; +use crate::types::Said; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; /// The 44-character `#` placeholder injected into the `d` field (and `i` field /// for inception events) before hashing. Matches the length of a CESR-qualified @@ -9,61 +9,90 @@ pub const SAID_PLACEHOLDER: &str = "############################################ /// Computes a spec-compliant SAID for a KERI event. /// -/// The algorithm: +/// The algorithm (Trust over IP KERI v0.9): /// 1. Set `d` to the 44-char `#` placeholder. /// 2. For inception events (`t == "icp"`), also set `i` to the placeholder. -/// 3. Remove the `x` field entirely (signatures are detached). +/// 3. Remove the `x` field entirely (signatures are detached from the digest). /// 4. Serialize with `serde_json::to_vec` (insertion-order, NOT json-canon). /// 5. Blake3-256 hash the bytes. -/// 6. CESR-encode the digest: `E` derivation code + base64url-no-pad (Blake3-256). +/// 6. CESR-encode the digest: `E` derivation code + base64url-no-pad. /// /// Args: /// * `event`: The event as a JSON object. -pub fn compute_spec_said(event: &serde_json::Value) -> Result { - let mut obj = event +pub fn compute_said(event: &serde_json::Value) -> Result { + let obj = event .as_object() .ok_or(KeriTranslationError::MissingField { field: "root object", - })? - .clone(); - - obj.insert( - "d".to_string(), - serde_json::Value::String(SAID_PLACEHOLDER.to_string()), - ); + })?; + let placeholder = serde_json::Value::String(SAID_PLACEHOLDER.to_string()); let event_type = obj.get("t").and_then(|v| v.as_str()).unwrap_or(""); - if event_type == "icp" { - obj.insert( - "i".to_string(), - serde_json::Value::String(SAID_PLACEHOLDER.to_string()), - ); + + // Rebuild the map with spec-compliant placeholders and field ordering. + let mut new_obj = serde_json::Map::new(); + + for (k, v) in obj { + if k == "x" { + // Signatures are detached from the digest (legacy field, skip) + continue; + } else if k == "d" { + new_obj.insert("d".to_string(), placeholder.clone()); + } else if k == "i" && event_type == "icp" { + new_obj.insert("i".to_string(), placeholder.clone()); + } else { + new_obj.insert(k.clone(), v.clone()); + } } - obj.remove("x"); + // Ensure d is always present (in case input omitted it) + if !new_obj.contains_key("d") { + new_obj.insert("d".to_string(), placeholder.clone()); + } + + // Two-pass version string: compute byte count then re-serialize + let version_placeholder = "KERI10JSON000000_"; + new_obj.insert( + "v".to_string(), + serde_json::Value::String(version_placeholder.to_string()), + ); - let serialized = serde_json::to_vec(&serde_json::Value::Object(obj)) + let pass1 = serde_json::to_vec(&serde_json::Value::Object(new_obj.clone())) + .map_err(KeriTranslationError::SerializationFailed)?; + + // Size is stable: placeholder and real version string are both 17 chars + let version_string = format!("KERI10JSON{:06x}_", pass1.len()); + debug_assert_eq!(version_string.len(), version_placeholder.len()); + new_obj.insert("v".to_string(), serde_json::Value::String(version_string)); + + let serialized = serde_json::to_vec(&serde_json::Value::Object(new_obj)) .map_err(KeriTranslationError::SerializationFailed)?; let hash = blake3::hash(&serialized); - Ok(format!("E{}", URL_SAFE_NO_PAD.encode(hash.as_bytes()))) + Ok(Said::new_unchecked(format!( + "E{}", + URL_SAFE_NO_PAD.encode(hash.as_bytes()) + ))) } /// Verifies that an event's `d` field matches the spec-compliant SAID. /// /// Args: /// * `event`: The event JSON with a populated `d` field. -pub fn verify_spec_said(event: &serde_json::Value) -> Result<(), KeriTranslationError> { +pub fn verify_said(event: &serde_json::Value) -> Result<(), KeriTranslationError> { let found = event .get("d") .and_then(|v| v.as_str()) .ok_or(KeriTranslationError::MissingField { field: "d" })? .to_string(); - let computed = compute_spec_said(event)?; + let computed = compute_said(event)?; - if computed != found { - return Err(KeriTranslationError::SaidMismatch { computed, found }); + if computed.as_str() != found { + return Err(KeriTranslationError::SaidMismatch { + computed: computed.into_inner(), + found, + }); } Ok(()) @@ -89,9 +118,9 @@ mod tests { "b": [], "a": [] }); - let said = compute_spec_said(&event).unwrap(); - assert_eq!(said.len(), 44); - assert!(said.starts_with('E')); + let said = compute_said(&event).unwrap(); + assert_eq!(said.as_str().len(), 44); + assert!(said.as_str().starts_with('E')); } #[test] @@ -111,8 +140,8 @@ mod tests { "b": [], "a": [] }); - let said1 = compute_spec_said(&event).unwrap(); - let said2 = compute_spec_said(&event).unwrap(); + let said1 = compute_said(&event).unwrap(); + let said2 = compute_said(&event).unwrap(); assert_eq!(said1, said2); } @@ -147,8 +176,8 @@ mod tests { "b": [], "a": [] }); - let said_with = compute_spec_said(&event_with_x).unwrap(); - let said_without = compute_spec_said(&event_without_x).unwrap(); + let said_with = compute_said(&event_with_x).unwrap(); + let said_without = compute_said(&event_without_x).unwrap(); assert_eq!(said_with, said_without, "x field must not affect SAID"); } @@ -182,8 +211,8 @@ mod tests { "b": [], "a": [] }); - let said_a = compute_spec_said(&event_a).unwrap(); - let said_b = compute_spec_said(&event_b).unwrap(); + let said_a = compute_said(&event_a).unwrap(); + let said_b = compute_said(&event_b).unwrap(); assert_eq!( said_a, said_b, "inception SAID must be independent of initial i value" @@ -191,7 +220,7 @@ mod tests { } #[test] - fn verify_spec_said_accepts_correct() { + fn verify_said_accepts_correct() { let event = serde_json::json!({ "v": "KERI10JSON000000_", "t": "rot", @@ -207,14 +236,14 @@ mod tests { "b": [], "a": [] }); - let said = compute_spec_said(&event).unwrap(); + let said = compute_said(&event).unwrap(); let mut event_with_said = event.clone(); - event_with_said["d"] = serde_json::Value::String(said); - assert!(verify_spec_said(&event_with_said).is_ok()); + event_with_said["d"] = serde_json::Value::String(said.into_inner()); + assert!(verify_said(&event_with_said).is_ok()); } #[test] - fn verify_spec_said_rejects_wrong() { + fn verify_said_rejects_wrong() { let event = serde_json::json!({ "v": "KERI10JSON000000_", "t": "rot", @@ -230,6 +259,6 @@ mod tests { "b": [], "a": [] }); - assert!(verify_spec_said(&event).is_err()); + assert!(verify_said(&event).is_err()); } } diff --git a/crates/auths-keri/src/state.rs b/crates/auths-keri/src/state.rs index 82b2e884..dee54181 100644 --- a/crates/auths-keri/src/state.rs +++ b/crates/auths-keri/src/state.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; -use crate::types::{Prefix, Said}; +use crate::types::{CesrKey, ConfigTrait, Prefix, Said, Threshold}; /// Current key state derived from replaying a KEL. /// @@ -19,13 +19,11 @@ pub struct KeyState { /// The KERI identifier prefix (used in `did:keri:`) pub prefix: Prefix, - /// Current signing key(s), Base64url encoded with derivation code prefix. - /// For Ed25519 keys, this is "D" + base64url(pubkey). - pub current_keys: Vec, + /// Current signing key(s), CESR-encoded. + pub current_keys: Vec, - /// Next key commitment(s) for pre-rotation. - /// These are Blake3 hashes of the next public key(s). - pub next_commitment: Vec, + /// Next key commitment(s) for pre-rotation (Blake3 digests). + pub next_commitment: Vec, /// Current sequence number (0 for inception, increments with each event) pub sequence: u64, @@ -33,12 +31,27 @@ pub struct KeyState { /// SAID of the last processed event pub last_event_said: Said, - /// Whether this identity has been abandoned (empty next commitment) + /// Whether this identity has been abandoned (empty next commitment in rotation) pub is_abandoned: bool, /// Current signing threshold - pub threshold: u64, + pub threshold: Threshold, /// Next signing threshold (committed) - pub next_threshold: u64, + pub next_threshold: Threshold, + /// Current backer/witness list + #[serde(default)] + pub backers: Vec, + /// Current backer threshold + #[serde(default)] + pub backer_threshold: Threshold, + /// Configuration traits from inception (and rotation for RB/NRB) + #[serde(default)] + pub config_traits: Vec, + /// Whether this identity is non-transferable (inception `n` was empty) + #[serde(default)] + pub is_non_transferable: bool, + /// Delegator AID (if this is a delegated identity) + #[serde(default)] + pub delegator: Option, } impl KeyState { @@ -51,14 +64,22 @@ impl KeyState { /// * `threshold` - Initial signing threshold /// * `next_threshold` - Committed next signing threshold /// * `said` - The inception event SAID + /// * `backers` - Initial witness/backer list + /// * `backer_threshold` - Witness/backer threshold + /// * `config_traits` - Configuration traits from inception + #[allow(clippy::too_many_arguments)] pub fn from_inception( prefix: Prefix, - keys: Vec, - next: Vec, - threshold: u64, - next_threshold: u64, + keys: Vec, + next: Vec, + threshold: Threshold, + next_threshold: Threshold, said: Said, + backers: Vec, + backer_threshold: Threshold, + config_traits: Vec, ) -> Self { + let is_non_transferable = next.is_empty(); Self { prefix, current_keys: keys, @@ -68,6 +89,11 @@ impl KeyState { is_abandoned: next.is_empty(), threshold, next_threshold, + backers, + backer_threshold, + config_traits, + is_non_transferable, + delegator: None, } } @@ -77,14 +103,19 @@ impl KeyState { /// 1. The new key matches the previous next_commitment /// 2. The event's previous SAID matches last_event_said /// 3. The sequence is exactly last_sequence + 1 + #[allow(clippy::too_many_arguments)] pub fn apply_rotation( &mut self, - new_keys: Vec, - new_next: Vec, - threshold: u64, - next_threshold: u64, + new_keys: Vec, + new_next: Vec, + threshold: Threshold, + next_threshold: Threshold, sequence: u64, said: Said, + backers_to_remove: &[Prefix], + backers_to_add: &[Prefix], + backer_threshold: Threshold, + config_traits: Vec, ) { self.current_keys = new_keys; self.next_commitment = new_next.clone(); @@ -93,6 +124,16 @@ impl KeyState { self.sequence = sequence; self.last_event_said = said; self.is_abandoned = new_next.is_empty(); + + // Apply backer deltas: remove first, then add + self.backers.retain(|b| !backers_to_remove.contains(b)); + self.backers.extend(backers_to_add.iter().cloned()); + self.backer_threshold = backer_threshold; + + // Update config traits (RB/NRB can change in rotation) + if !config_traits.is_empty() { + self.config_traits = config_traits; + } } /// Apply an interaction event (updates sequence and SAID only). @@ -104,10 +145,8 @@ impl KeyState { } /// Get the current signing key (first key for single-sig). - /// - /// Returns the encoded key string (e.g., "DBase64EncodedKey...") - pub fn current_key(&self) -> Option<&str> { - self.current_keys.first().map(|s| s.as_str()) + pub fn current_key(&self) -> Option<&CesrKey> { + self.current_keys.first() } /// Check if key can be rotated. @@ -128,45 +167,53 @@ impl KeyState { mod tests { use super::*; - #[test] - fn key_state_from_inception() { - let state = KeyState::from_inception( + fn make_key(s: &str) -> CesrKey { + CesrKey::new_unchecked(s.to_string()) + } + + fn make_state() -> KeyState { + KeyState::from_inception( Prefix::new_unchecked("EPrefix".to_string()), - vec!["DKey1".to_string()], - vec!["ENext1".to_string()], - 1, - 1, + vec![make_key("DKey1")], + vec![Said::new_unchecked("ENext1".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), Said::new_unchecked("ESAID".to_string()), - ); + vec![], + Threshold::Simple(0), + vec![], + ) + } + + #[test] + fn key_state_from_inception() { + let state = make_state(); assert_eq!(state.sequence, 0); assert!(!state.is_abandoned); assert!(state.can_rotate()); - assert_eq!(state.current_key(), Some("DKey1")); + assert_eq!(state.current_key().map(|k| k.as_str()), Some("DKey1")); assert_eq!(state.did(), "did:keri:EPrefix"); } #[test] fn key_state_apply_rotation() { - let mut state = KeyState::from_inception( - Prefix::new_unchecked("EPrefix".to_string()), - vec!["DKey1".to_string()], - vec!["ENext1".to_string()], - 1, - 1, - Said::new_unchecked("ESAID1".to_string()), - ); + let mut state = make_state(); state.apply_rotation( - vec!["DKey2".to_string()], - vec!["ENext2".to_string()], - 1, - 1, + vec![make_key("DKey2")], + vec![Said::new_unchecked("ENext2".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), 1, Said::new_unchecked("ESAID2".to_string()), + &[], + &[], + Threshold::Simple(0), + vec![], ); assert_eq!(state.sequence, 1); - assert_eq!(state.current_keys[0], "DKey2"); + assert_eq!(state.current_keys[0].as_str(), "DKey2"); assert_eq!(state.next_commitment[0], "ENext2"); assert_eq!(state.last_event_said, "ESAID2"); assert!(state.can_rotate()); @@ -174,19 +221,11 @@ mod tests { #[test] fn key_state_apply_interaction() { - let mut state = KeyState::from_inception( - Prefix::new_unchecked("EPrefix".to_string()), - vec!["DKey1".to_string()], - vec!["ENext1".to_string()], - 1, - 1, - Said::new_unchecked("ESAID1".to_string()), - ); - + let mut state = make_state(); state.apply_interaction(1, Said::new_unchecked("ESAID_IXN".to_string())); assert_eq!(state.sequence, 1); - assert_eq!(state.current_keys[0], "DKey1"); + assert_eq!(state.current_keys[0].as_str(), "DKey1"); assert_eq!(state.last_event_said, "ESAID_IXN"); } @@ -194,11 +233,14 @@ mod tests { fn abandoned_identity_cannot_rotate() { let state = KeyState::from_inception( Prefix::new_unchecked("EPrefix".to_string()), - vec!["DKey1".to_string()], + vec![make_key("DKey1")], vec![], - 1, - 0, + Threshold::Simple(1), + Threshold::Simple(0), Said::new_unchecked("ESAID".to_string()), + vec![], + Threshold::Simple(0), + vec![], ); assert!(state.is_abandoned); assert!(!state.can_rotate()); @@ -206,17 +248,44 @@ mod tests { #[test] fn key_state_serializes() { - let state = KeyState::from_inception( + let state = make_state(); + let json = serde_json::to_string(&state).unwrap(); + let parsed: KeyState = serde_json::from_str(&json).unwrap(); + assert_eq!(state, parsed); + } + + #[test] + fn rotation_applies_backer_deltas() { + let mut state = KeyState::from_inception( Prefix::new_unchecked("EPrefix".to_string()), - vec!["DKey1".to_string()], - vec!["ENext1".to_string()], - 1, - 1, + vec![make_key("DKey1")], + vec![Said::new_unchecked("ENext1".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), Said::new_unchecked("ESAID".to_string()), + vec![ + Prefix::new_unchecked("DWit1".to_string()), + Prefix::new_unchecked("DWit2".to_string()), + ], + Threshold::Simple(2), + vec![], ); - let json = serde_json::to_string(&state).unwrap(); - let parsed: KeyState = serde_json::from_str(&json).unwrap(); - assert_eq!(state, parsed); + state.apply_rotation( + vec![make_key("DKey2")], + vec![Said::new_unchecked("ENext2".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), + 1, + Said::new_unchecked("ESAID2".to_string()), + &[Prefix::new_unchecked("DWit1".to_string())], + &[Prefix::new_unchecked("DWit3".to_string())], + Threshold::Simple(2), + vec![], + ); + + assert_eq!(state.backers.len(), 2); + assert_eq!(state.backers[0].as_str(), "DWit2"); + assert_eq!(state.backers[1].as_str(), "DWit3"); } } diff --git a/crates/auths-keri/src/types.rs b/crates/auths-keri/src/types.rs index 9c51b589..23d6f201 100644 --- a/crates/auths-keri/src/types.rs +++ b/crates/auths-keri/src/types.rs @@ -1,8 +1,11 @@ use std::borrow::Borrow; use std::fmt; +use std::str::FromStr; use serde::{Deserialize, Serialize}; +use crate::keys::{KeriDecodeError, KeriPublicKey}; + // ── KERI Identifier Newtypes ──────────────────────────────────────────────── /// Error when constructing KERI newtypes with invalid values. @@ -15,19 +18,43 @@ pub struct KeriTypeError { pub reason: String, } -/// Shared validation for KERI self-addressing identifiers. +/// Validate a CESR derivation code for AIDs (Prefix). +/// +/// Accepts any valid CESR primitive prefix: uppercase letter or digit. +/// `D` = Ed25519, `E` = Blake3-256, `1` = secp256k1, etc. +fn validate_prefix_derivation_code(s: &str) -> Result<(), KeriTypeError> { + if s.is_empty() { + return Err(KeriTypeError { + type_name: "Prefix", + reason: "must not be empty".into(), + }); + } + let first = s.as_bytes()[0]; + if !first.is_ascii_uppercase() && !first.is_ascii_digit() { + return Err(KeriTypeError { + type_name: "Prefix", + reason: format!( + "must start with a CESR derivation code (uppercase letter or digit), got '{}'", + &s[..s.len().min(10)] + ), + }); + } + Ok(()) +} + +/// Validate a CESR derivation code for SAIDs (digest only). /// -/// Both `Prefix` and `Said` must start with 'E' (Blake3-256 derivation code). -fn validate_keri_derivation_code(s: &str, type_label: &'static str) -> Result<(), KeriTypeError> { +/// SAIDs are always digests — currently only Blake3-256 (`E`). +fn validate_said_derivation_code(s: &str) -> Result<(), KeriTypeError> { if s.is_empty() { return Err(KeriTypeError { - type_name: type_label, + type_name: "Said", reason: "must not be empty".into(), }); } if !s.starts_with('E') { return Err(KeriTypeError { - type_name: type_label, + type_name: "Said", reason: format!( "must start with 'E' (Blake3 derivation code), got '{}'", &s[..s.len().min(10)] @@ -37,13 +64,16 @@ fn validate_keri_derivation_code(s: &str, type_label: &'static str) -> Result<() Ok(()) } -/// Strongly-typed KERI identifier prefix (e.g., `"ETest123..."`). +/// Strongly-typed KERI identifier prefix (e.g., `"ETest123..."`, `"DKey456..."`). /// -/// A prefix is the self-addressing identifier derived from the inception event's -/// Blake3 hash. Always starts with 'E' (Blake3-256 derivation code). +/// A prefix is the autonomous identifier (AID) for a KERI identity. For +/// self-addressing AIDs it starts with `E` (Blake3-256 digest of the inception +/// event); for key-based AIDs it starts with `D` (Ed25519 public key) or +/// another CESR derivation code. /// /// Args: -/// * Inner `String` should start with `'E'` (enforced by `new()`, not by serde). +/// * Inner `String` must start with a valid CESR derivation code (uppercase +/// letter or digit). Enforced by `new()`, not by serde. /// /// Usage: /// ```ignore @@ -57,8 +87,11 @@ pub struct Prefix(String); impl Prefix { /// Validates and wraps a KERI prefix string. + /// + /// Accepts any valid CESR derivation code (`D` for Ed25519, `E` for Blake3, + /// `1` for secp256k1, etc.). See [`validate_prefix_derivation_code`] for details. pub fn new(s: String) -> Result { - validate_keri_derivation_code(&s, "Prefix")?; + validate_prefix_derivation_code(&s)?; Ok(Self(s)) } @@ -154,8 +187,10 @@ pub struct Said(String); impl Said { /// Validates and wraps a KERI SAID string. + /// + /// Only accepts `E` prefix (digest derivation codes). pub fn new(s: String) -> Result { - validate_keri_derivation_code(&s, "Said")?; + validate_said_derivation_code(&s)?; Ok(Self(s)) } @@ -227,3 +262,732 @@ impl PartialEq for &str { *self == other.0 } } + +// ── Fraction ──────────────────────────────────────────────────────────────── + +/// Exact rational number for weighted threshold arithmetic. +/// +/// Uses integer cross-multiplication for comparison — NOT floating point. +/// This ensures `1/3 + 1/3 + 1/3` equals exactly `1`. +/// +/// Usage: +/// ``` +/// use auths_keri::Fraction; +/// let f: Fraction = "1/3".parse().unwrap(); +/// assert_eq!(f.numerator, 1); +/// assert_eq!(f.denominator, 3); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Fraction { + /// Numerator of the fraction. + pub numerator: u64, + /// Denominator of the fraction (must be > 0). + pub denominator: u64, +} + +/// Error when parsing a `Fraction` from a string. +#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] +pub enum FractionError { + /// Missing the `/` separator. + #[error("missing '/' separator in fraction: {0:?}")] + MissingSeparator(String), + /// Numerator or denominator is not a valid integer. + #[error("invalid integer in fraction: {0}")] + InvalidInt(String), + /// Denominator is zero. + #[error("fraction denominator must not be zero")] + ZeroDenominator, +} + +impl Fraction { + /// Check if the sum of the given fractions is >= 1, using exact integer arithmetic. + /// + /// Uses cross-multiplication to avoid floating-point imprecision. + /// `1/3 + 1/3 + 1/3` returns `true` (exactly 1). + /// + /// Usage: + /// ``` + /// use auths_keri::Fraction; + /// let thirds: Vec = vec!["1/3".parse().unwrap(); 3]; + /// let refs: Vec<&Fraction> = thirds.iter().collect(); + /// assert!(Fraction::sum_meets_one(&refs)); + /// ``` + pub fn sum_meets_one(fractions: &[&Fraction]) -> bool { + if fractions.is_empty() { + return false; + } + // Accumulate as num/den using: a/b + c/d = (a*d + c*b) / (b*d) + // Use u128 to avoid overflow with u64 numerators/denominators. + let mut num: u128 = 0; + let mut den: u128 = 1; + for f in fractions { + // num/den + f.numerator/f.denominator + // = (num * f.denominator + f.numerator * den) / (den * f.denominator) + num = num * (f.denominator as u128) + (f.numerator as u128) * den; + den *= f.denominator as u128; + } + // Check num/den >= 1, i.e., num >= den + num >= den + } +} + +impl FromStr for Fraction { + type Err = FractionError; + + fn from_str(s: &str) -> Result { + let (num_str, den_str) = s + .split_once('/') + .ok_or_else(|| FractionError::MissingSeparator(s.to_string()))?; + let numerator: u64 = num_str + .parse() + .map_err(|_| FractionError::InvalidInt(num_str.to_string()))?; + let denominator: u64 = den_str + .parse() + .map_err(|_| FractionError::InvalidInt(den_str.to_string()))?; + if denominator == 0 { + return Err(FractionError::ZeroDenominator); + } + Ok(Self { + numerator, + denominator, + }) + } +} + +impl fmt::Display for Fraction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.numerator, self.denominator) + } +} + +impl Serialize for Fraction { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Fraction { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } +} + +// ── Threshold ─────────────────────────────────────────────────────────────── + +/// KERI signing/backer threshold. +/// +/// Simple thresholds are hex-encoded integers (`"1"`, `"2"`, `"a"`). +/// Weighted thresholds are clause lists of fractions (`[["1/2","1/2"]]`). +/// Clauses are ANDed; each is satisfied when the sum of verified weights >= 1. +/// +/// Usage: +/// ``` +/// use auths_keri::Threshold; +/// let t: Threshold = serde_json::from_str("\"2\"").unwrap(); +/// assert_eq!(t.simple_value(), Some(2)); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Threshold { + /// M-of-N threshold (hex-encoded integer in JSON). + Simple(u64), + /// Fractionally weighted threshold (list of clause lists in JSON). + /// Clauses are ANDed; each is satisfied when sum of verified weights >= 1. + Weighted(Vec>), +} + +impl Threshold { + /// Get the simple threshold value, if this is a simple threshold. + pub fn simple_value(&self) -> Option { + match self { + Threshold::Simple(v) => Some(*v), + Threshold::Weighted(_) => None, + } + } + + /// Check if the threshold is satisfied by the given set of verified key indices. + /// + /// For `Simple(n)`: at least `n` unique indices must be verified. + /// For `Weighted(clauses)`: ALL clauses must be independently satisfied. + /// A clause is satisfied when the sum of weights at verified indices >= 1. + /// + /// Args: + /// * `verified_indices` - Indices of keys whose signatures have been verified. + /// * `key_count` - Total number of keys in the key list (for bounds checking). + /// + /// Usage: + /// ``` + /// use auths_keri::Threshold; + /// let t = Threshold::Simple(2); + /// assert!(t.is_satisfied(&[0, 1], 3)); + /// assert!(!t.is_satisfied(&[0], 3)); + /// ``` + pub fn is_satisfied(&self, verified_indices: &[u32], key_count: usize) -> bool { + // Deduplicate indices and filter out-of-range + let mut unique: std::collections::HashSet = std::collections::HashSet::new(); + for &idx in verified_indices { + if (idx as usize) < key_count { + unique.insert(idx); + } + } + + match self { + Threshold::Simple(required) => unique.len() as u64 >= *required, + Threshold::Weighted(clauses) => { + // ALL clauses must be satisfied (ANDed) + for clause in clauses { + let verified_fractions: Vec<&Fraction> = clause + .iter() + .enumerate() + .filter(|(i, _)| unique.contains(&(*i as u32))) + .map(|(_, f)| f) + .collect(); + if !Fraction::sum_meets_one(&verified_fractions) { + return false; + } + } + true + } + } + } +} + +impl Serialize for Threshold { + fn serialize(&self, serializer: S) -> Result { + match self { + Threshold::Simple(v) => serializer.serialize_str(&format!("{v:x}")), + Threshold::Weighted(clauses) => clauses.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for Threshold { + fn deserialize>(deserializer: D) -> Result { + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::String(s) => { + let v = u64::from_str_radix(&s, 16).map_err(|_| { + serde::de::Error::custom(format!("invalid hex threshold: {s:?}")) + })?; + Ok(Threshold::Simple(v)) + } + serde_json::Value::Array(arr) => { + let clauses: Vec> = arr + .into_iter() + .map(|clause| match clause { + serde_json::Value::Array(weights) => weights + .into_iter() + .map(|w| match w { + serde_json::Value::String(s) => { + s.parse().map_err(serde::de::Error::custom) + } + _ => Err(serde::de::Error::custom("weight must be a string")), + }) + .collect::, _>>(), + _ => Err(serde::de::Error::custom("clause must be an array")), + }) + .collect::, _>>()?; + Ok(Threshold::Weighted(clauses)) + } + _ => Err(serde::de::Error::custom( + "threshold must be a hex string or array of clause arrays", + )), + } + } +} + +impl Default for Threshold { + fn default() -> Self { + Threshold::Simple(0) + } +} + +// ── CesrKey ───────────────────────────────────────────────────────────────── + +/// A CESR-encoded public key (e.g., `D` + base64url Ed25519). +/// +/// Wraps the qualified string form. Use `parse_ed25519()` to extract +/// the raw 32-byte key for cryptographic operations. +/// +/// Usage: +/// ``` +/// use auths_keri::CesrKey; +/// let key = CesrKey::new_unchecked("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into()); +/// assert!(key.parse_ed25519().is_ok()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[repr(transparent)] +pub struct CesrKey(String); + +impl CesrKey { + /// Wrap a qualified key string without validation. + pub fn new_unchecked(s: String) -> Self { + Self(s) + } + + /// Parse the inner CESR string as an Ed25519 public key. + pub fn parse_ed25519(&self) -> Result { + KeriPublicKey::parse(&self.0) + } + + /// Get the raw CESR-qualified string. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consumes self and returns the inner String. + pub fn into_inner(self) -> String { + self.0 + } +} + +impl fmt::Display for CesrKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for CesrKey { + fn as_ref(&self) -> &str { + &self.0 + } +} + +// ── ConfigTrait ───────────────────────────────────────────────────────────── + +/// KERI configuration trait codes. +/// +/// These control identity behavior at inception and may be updated at rotation +/// (for `RB`/`NRB` only). If two conflicting traits appear, the latter supersedes. +/// +/// Usage: +/// ``` +/// use auths_keri::ConfigTrait; +/// let traits: Vec = serde_json::from_str(r#"["EO","DND"]"#).unwrap(); +/// assert!(traits.contains(&ConfigTrait::EstablishmentOnly)); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub enum ConfigTrait { + /// Establishment-Only: only establishment events in KEL. + #[serde(rename = "EO")] + EstablishmentOnly, + /// Do-Not-Delegate: cannot act as delegator. + #[serde(rename = "DND")] + DoNotDelegate, + /// Delegate-Is-Delegator: delegated AID treated same as delegator. + #[serde(rename = "DID")] + DelegateIsDelegator, + /// Registrar Backers: backer list provides registrar backer AIDs. + #[serde(rename = "RB")] + RegistrarBackers, + /// No Registrar Backers: switch back to witnesses. + #[serde(rename = "NRB")] + NoRegistrarBackers, +} + +// ── VersionString ─────────────────────────────────────────────────────────── + +/// KERI v1.x version string: `KERI10JSON{hhhhhh}_` (17 chars). +/// +/// The size field is the total serialized byte count of the event. +/// +/// Usage: +/// ``` +/// use auths_keri::VersionString; +/// let vs = VersionString::json(256); +/// assert_eq!(vs.to_string(), "KERI10JSON000100_"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionString { + /// Serialization kind (e.g., "JSON", "CBOR"). + pub kind: String, + /// Serialized byte count. + pub size: u32, +} + +impl VersionString { + /// Create a version string for JSON serialization with the given byte count. + pub fn json(size: u32) -> Self { + Self { + kind: "JSON".to_string(), + size, + } + } + + /// Create a placeholder version string (size = 0, to be updated after serialization). + pub fn placeholder() -> Self { + Self { + kind: "JSON".to_string(), + size: 0, + } + } +} + +impl Default for VersionString { + fn default() -> Self { + Self::placeholder() + } +} + +impl fmt::Display for VersionString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "KERI10{}{:06x}_", self.kind, self.size) + } +} + +impl Serialize for VersionString { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for VersionString { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + // Full 17-char format: "KERI10JSON000100_" + if s.len() >= 17 && s.ends_with('_') { + let size_hex = &s[10..16]; + let size = u32::from_str_radix(size_hex, 16).map_err(|_| { + serde::de::Error::custom(format!("invalid version string size: {size_hex:?}")) + })?; + let kind = s[6..10].to_string(); + Ok(Self { kind, size }) + } else if s.starts_with("KERI10") && s.len() >= 10 { + // Legacy format without size — accept for backwards compat + let kind = s[6..s.len().min(10)].to_string(); + Ok(Self { kind, size: 0 }) + } else { + Err(serde::de::Error::custom(format!( + "invalid KERI version string: {s:?}" + ))) + } + } +} + +// ── JsonSchema impls for custom serde types ───────────────────────────────── + +#[cfg(feature = "schema")] +mod schema_impls { + use super::*; + + impl schemars::JsonSchema for Fraction { + fn schema_name() -> String { + "Fraction".to_string() + } + fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::String.into()), + ..Default::default() + } + .into() + } + } + + impl schemars::JsonSchema for Threshold { + fn schema_name() -> String { + "Threshold".to_string() + } + fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + // Union: string (hex integer) or array of arrays of strings (weighted) + schemars::schema::Schema::Bool(true) + } + } + + impl schemars::JsonSchema for crate::events::Seal { + fn schema_name() -> String { + "Seal".to_string() + } + fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + // Untagged union of seal variants — accept any object + schemars::schema::Schema::Bool(true) + } + } + + impl schemars::JsonSchema for VersionString { + fn schema_name() -> String { + "VersionString".to_string() + } + fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::String.into()), + ..Default::default() + } + .into() + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + // ── Fraction ──────────────────────────────────────────────────────── + + #[test] + fn fraction_parse_valid() { + let f: Fraction = "1/3".parse().unwrap(); + assert_eq!(f.numerator, 1); + assert_eq!(f.denominator, 3); + } + + #[test] + fn fraction_parse_rejects_zero_denominator() { + let err = "1/0".parse::().unwrap_err(); + assert_eq!(err, FractionError::ZeroDenominator); + } + + #[test] + fn fraction_parse_rejects_missing_separator() { + assert!("42".parse::().is_err()); + } + + #[test] + fn fraction_serde_roundtrip() { + let f = Fraction { + numerator: 1, + denominator: 2, + }; + let json = serde_json::to_string(&f).unwrap(); + assert_eq!(json, "\"1/2\""); + let parsed: Fraction = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, f); + } + + #[test] + fn fraction_display() { + let f = Fraction { + numerator: 3, + denominator: 4, + }; + assert_eq!(f.to_string(), "3/4"); + } + + // ── Threshold ─────────────────────────────────────────────────────── + + #[test] + fn threshold_simple_from_hex() { + let t: Threshold = serde_json::from_str("\"a\"").unwrap(); + assert_eq!(t, Threshold::Simple(10)); + assert_eq!(t.simple_value(), Some(10)); + } + + #[test] + fn threshold_simple_serialize_as_hex() { + let t = Threshold::Simple(16); + let json = serde_json::to_string(&t).unwrap(); + assert_eq!(json, "\"10\""); // 16 decimal = 10 hex + } + + #[test] + fn threshold_simple_roundtrip() { + let t = Threshold::Simple(2); + let json = serde_json::to_string(&t).unwrap(); + let parsed: Threshold = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, t); + } + + #[test] + fn threshold_weighted_roundtrip() { + let json = r#"[["1/2","1/2"],["1/3","1/3","1/3"]]"#; + let t: Threshold = serde_json::from_str(json).unwrap(); + assert!(t.simple_value().is_none()); + if let Threshold::Weighted(clauses) = &t { + assert_eq!(clauses.len(), 2); + assert_eq!(clauses[0].len(), 2); + assert_eq!(clauses[1].len(), 3); + assert_eq!(clauses[0][0].numerator, 1); + assert_eq!(clauses[0][0].denominator, 2); + } else { + panic!("expected Weighted"); + } + let reserialized = serde_json::to_string(&t).unwrap(); + let reparsed: Threshold = serde_json::from_str(&reserialized).unwrap(); + assert_eq!(reparsed, t); + } + + #[test] + fn threshold_rejects_invalid_hex() { + let result = serde_json::from_str::("\"xyz\""); + assert!(result.is_err()); + } + + // ── Fraction arithmetic ───────────────────────────────────────────── + + #[test] + fn fraction_sum_one_third_times_three() { + let f: Fraction = "1/3".parse().unwrap(); + assert!(Fraction::sum_meets_one(&[&f, &f, &f])); + } + + #[test] + fn fraction_sum_two_thirds_not_enough() { + let f: Fraction = "1/3".parse().unwrap(); + assert!(!Fraction::sum_meets_one(&[&f, &f])); + } + + #[test] + fn fraction_sum_halves() { + let f: Fraction = "1/2".parse().unwrap(); + assert!(Fraction::sum_meets_one(&[&f, &f])); + assert!(!Fraction::sum_meets_one(&[&f])); + } + + #[test] + fn fraction_sum_empty_is_false() { + assert!(!Fraction::sum_meets_one(&[])); + } + + // ── Threshold::is_satisfied ───────────────────────────────────────── + + #[test] + fn threshold_simple_satisfied() { + let t = Threshold::Simple(2); + assert!(t.is_satisfied(&[0, 1], 3)); + assert!(t.is_satisfied(&[0, 1, 2], 3)); + assert!(!t.is_satisfied(&[0], 3)); + } + + #[test] + fn threshold_simple_zero_always_satisfied() { + let t = Threshold::Simple(0); + assert!(t.is_satisfied(&[], 3)); + } + + #[test] + fn threshold_simple_deduplicates_indices() { + let t = Threshold::Simple(2); + // Same index twice doesn't count as 2 + assert!(!t.is_satisfied(&[0, 0], 3)); + } + + #[test] + fn threshold_simple_rejects_out_of_range() { + let t = Threshold::Simple(1); + // Index 5 is out of range for 3 keys + assert!(!t.is_satisfied(&[5], 3)); + } + + #[test] + fn threshold_weighted_two_of_three() { + // [["1/2","1/2","1/2"]] — any 2 of 3 + let t: Threshold = serde_json::from_str(r#"[["1/2","1/2","1/2"]]"#).unwrap(); + assert!(t.is_satisfied(&[0, 1], 3)); + assert!(t.is_satisfied(&[1, 2], 3)); + assert!(!t.is_satisfied(&[0], 3)); + } + + #[test] + fn threshold_weighted_with_reserves() { + // [["1/2","1/2","1/2","1/4","1/4"]] — 2 main OR 1 main + 2 reserves + let t: Threshold = serde_json::from_str(r#"[["1/2","1/2","1/2","1/4","1/4"]]"#).unwrap(); + assert!(t.is_satisfied(&[0, 1], 5)); // 2 main + assert!(t.is_satisfied(&[0, 3, 4], 5)); // 1 main + 2 reserves + assert!(!t.is_satisfied(&[3, 4], 5)); // reserves only: 1/4+1/4=1/2 < 1 + } + + #[test] + fn threshold_weighted_multi_clause_and() { + // [["1/2","1/2"],["1/3","1/3","1/3"]] — both clauses must be satisfied + let t: Threshold = serde_json::from_str(r#"[["1/2","1/2"],["1/3","1/3","1/3"]]"#).unwrap(); + // Indices 0,1 satisfy clause 1 (1/2+1/2=1), but clause 2 needs 2 of {0,1,2} + // Index 0 in clause 2 = 1/3, index 1 in clause 2 = 1/3 → 2/3 < 1 + assert!(!t.is_satisfied(&[0, 1], 3)); + // Indices 0,1,2 satisfy both clauses + assert!(t.is_satisfied(&[0, 1, 2], 3)); + } + + // ── CesrKey ───────────────────────────────────────────────────────── + + #[test] + fn cesr_key_roundtrip() { + let key_str = "DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let key = CesrKey::new_unchecked(key_str.to_string()); + let json = serde_json::to_string(&key).unwrap(); + let parsed: CesrKey = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.as_str(), key_str); + } + + #[test] + fn cesr_key_parse_ed25519_valid() { + let key = + CesrKey::new_unchecked("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string()); + assert!(key.parse_ed25519().is_ok()); + } + + #[test] + fn cesr_key_parse_ed25519_invalid() { + let key = CesrKey::new_unchecked("not-a-valid-key".to_string()); + assert!(key.parse_ed25519().is_err()); + } + + // ── ConfigTrait ───────────────────────────────────────────────────── + + #[test] + fn config_trait_serde_roundtrip() { + let traits = vec![ConfigTrait::EstablishmentOnly, ConfigTrait::DoNotDelegate]; + let json = serde_json::to_string(&traits).unwrap(); + assert_eq!(json, r#"["EO","DND"]"#); + let parsed: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, traits); + } + + #[test] + fn config_trait_all_variants_roundtrip() { + let all = vec![ + ConfigTrait::EstablishmentOnly, + ConfigTrait::DoNotDelegate, + ConfigTrait::DelegateIsDelegator, + ConfigTrait::RegistrarBackers, + ConfigTrait::NoRegistrarBackers, + ]; + let json = serde_json::to_string(&all).unwrap(); + assert_eq!(json, r#"["EO","DND","DID","RB","NRB"]"#); + let parsed: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, all); + } + + // ── VersionString ─────────────────────────────────────────────────── + + #[test] + fn version_string_display() { + let vs = VersionString::json(256); + assert_eq!(vs.to_string(), "KERI10JSON000100_"); + } + + #[test] + fn version_string_placeholder() { + let vs = VersionString::placeholder(); + assert_eq!(vs.to_string(), "KERI10JSON000000_"); + assert_eq!(vs.size, 0); + } + + #[test] + fn version_string_parse_full() { + let vs: VersionString = serde_json::from_str("\"KERI10JSON000100_\"").unwrap(); + assert_eq!(vs.kind, "JSON"); + assert_eq!(vs.size, 256); + } + + #[test] + fn version_string_parse_legacy() { + let vs: VersionString = serde_json::from_str("\"KERI10JSON\"").unwrap(); + assert_eq!(vs.kind, "JSON"); + assert_eq!(vs.size, 0); // legacy has no size + } + + #[test] + fn version_string_roundtrip() { + let vs = VersionString::json(1024); + let json = serde_json::to_string(&vs).unwrap(); + let parsed: VersionString = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, vs); + } + + #[test] + fn version_string_rejects_invalid() { + assert!(serde_json::from_str::("\"INVALID\"").is_err()); + } +} diff --git a/crates/auths-keri/src/validate.rs b/crates/auths-keri/src/validate.rs index 3198ee98..4b5886f1 100644 --- a/crates/auths-keri/src/validate.rs +++ b/crates/auths-keri/src/validate.rs @@ -6,13 +6,14 @@ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use crate::keys::KeriPublicKey; use ring::signature::UnparsedPublicKey; -use crate::crypto::{compute_said, verify_commitment}; -use crate::events::{Event, IcpEvent, IxnEvent, RotEvent}; +use crate::crypto::verify_commitment; +use crate::events::{Event, IcpEvent, IxnEvent, RotEvent, Seal}; +use crate::keys::KeriPublicKey; +use crate::said::compute_said; use crate::state::KeyState; -use crate::types::{Prefix, Said}; +use crate::types::{ConfigTrait, Prefix, Said}; /// Errors specific to KEL validation. /// @@ -90,6 +91,98 @@ pub enum ValidationError { /// The key encoding prefix is unsupported or malformed. #[error("Invalid key encoding: {0}")] InvalidKey(String), + + /// The identity has been abandoned (empty next commitment) and no more events are allowed. + #[error("Identity abandoned at sequence {sequence}, no more events allowed")] + AbandonedIdentity { + /// The sequence number of the rejected event. + sequence: u64, + }, + + /// An interaction event was found in an establishment-only KEL. + #[error("Interaction event at sequence {sequence} rejected: KEL is establishment-only (EO)")] + EstablishmentOnly { + /// The sequence number of the rejected event. + sequence: u64, + }, + + /// The identity is non-transferable (inception had empty next commitments). + #[error( + "Non-transferable identity: inception had empty next key commitments, no subsequent events allowed" + )] + NonTransferable, + + /// A backer AID appears more than once in the backer list. + #[error("Duplicate backer AID: {aid}")] + DuplicateBacker { + /// The duplicated AID. + aid: String, + }, + + /// The backer threshold is inconsistent with the backer list size. + #[error("Invalid backer threshold: bt={bt} but backer_count={backer_count}")] + InvalidBackerThreshold { + /// The backer threshold value. + bt: u64, + /// The number of backers. + backer_count: usize, + }, +} + +/// Validate a delegated event against the delegator's KEL. +/// +/// Searches the delegator's KEL for an anchoring key event seal that matches +/// the delegated event's prefix, sequence number, and SAID. Also enforces +/// the `DND` (Do Not Delegate) configuration trait. +/// +/// Args: +/// * `delegated_event` - The delegated event (dip or drt) to validate. +/// * `delegator_kel` - The delegator's full KEL. +pub fn validate_delegation( + delegated_event: &Event, + delegator_kel: &[Event], +) -> Result<(), ValidationError> { + if !delegated_event.is_delegated() { + return Err(ValidationError::Serialization( + "validate_delegation called on non-delegated event".to_string(), + )); + } + + let event_said = delegated_event.said(); + let event_seq = delegated_event.sequence(); + + // Check DND enforcement on delegator + if let Some(Event::Icp(delegator_icp)) = delegator_kel.first() + && delegator_icp.c.contains(&ConfigTrait::DoNotDelegate) + { + return Err(ValidationError::Serialization( + "Delegator has DoNotDelegate (DND) config trait".to_string(), + )); + } + + // Search delegator's KEL for an anchoring seal + let found = delegator_kel.iter().any(|event| { + event.anchors().iter().any(|seal| { + matches!( + seal, + Seal::KeyEvent { i, s, d } + if i == delegated_event.prefix() + && s.value() == event_seq.value() + && d == event_said + ) + }) + }); + + if !found { + return Err(ValidationError::Serialization(format!( + "No delegation seal found in delegator KEL for prefix={}, sn={}, said={}", + delegated_event.prefix(), + event_seq, + event_said + ))); + } + + Ok(()) } /// Validate a KEL and return the resulting KeyState. @@ -115,8 +208,31 @@ pub fn validate_kel(events: &[Event]) -> Result { verify_event_said(&events[0])?; let mut state = validate_inception(icp)?; + // Non-transferable identities (inception n is empty) cannot have subsequent events + if icp.n.is_empty() && events.len() > 1 { + return Err(ValidationError::NonTransferable); + } + + // Check if this is an establishment-only KEL + let establishment_only = icp.c.contains(&ConfigTrait::EstablishmentOnly); + for (idx, event) in events.iter().enumerate().skip(1) { let expected_seq = idx as u64; + + // Reject any event after abandonment + if state.is_abandoned { + return Err(ValidationError::AbandonedIdentity { + sequence: expected_seq, + }); + } + + // Reject IXN in establishment-only KELs + if establishment_only && matches!(event, Event::Ixn(_)) { + return Err(ValidationError::EstablishmentOnly { + sequence: expected_seq, + }); + } + verify_event_said(event)?; verify_sequence(event, expected_seq)?; verify_chain_linkage(event, &state)?; @@ -124,18 +240,29 @@ pub fn validate_kel(events: &[Event]) -> Result { match event { Event::Rot(rot) => validate_rotation(rot, event, expected_seq, &mut state)?, Event::Ixn(ixn) => validate_interaction(ixn, event, expected_seq, &mut state)?, - Event::Icp(_) => return Err(ValidationError::MultipleInceptions), + Event::Icp(_) | Event::Dip(_) => return Err(ValidationError::MultipleInceptions), + // Delegated rotation validation requires cross-KEL seal check (fn-107.12) + Event::Drt(_) => { + return Err(ValidationError::Serialization( + "delegated rotation (drt) validation not yet implemented".to_string(), + )); + } } } Ok(state) } -fn parse_threshold(raw: &str) -> Result { - raw.parse::() - .map_err(|_| ValidationError::MalformedSequence { - raw: raw.to_string(), - }) +fn validate_backer_uniqueness(backers: &[Prefix]) -> Result<(), ValidationError> { + let mut seen = std::collections::HashSet::new(); + for b in backers { + if !seen.insert(b.as_str()) { + return Err(ValidationError::DuplicateBacker { + aid: b.as_str().to_string(), + }); + } + } + Ok(()) } fn validate_inception(icp: &IcpEvent) -> Result { @@ -143,19 +270,32 @@ fn validate_inception(icp: &IcpEvent) -> Result { &Event::Icp(icp.clone()), icp.k .first() - .ok_or(ValidationError::SignatureFailed { sequence: 0 })?, + .ok_or(ValidationError::SignatureFailed { sequence: 0 })? + .as_str(), )?; - let threshold = parse_threshold(&icp.kt)?; - let next_threshold = parse_threshold(&icp.nt)?; + // Validate backer uniqueness + validate_backer_uniqueness(&icp.b)?; + + // Validate bt consistency: empty backers must have bt == 0 + let bt_val = icp.bt.simple_value().unwrap_or(0); + if icp.b.is_empty() && bt_val != 0 { + return Err(ValidationError::InvalidBackerThreshold { + bt: bt_val, + backer_count: 0, + }); + } Ok(KeyState::from_inception( icp.i.clone(), icp.k.clone(), icp.n.clone(), - threshold, - next_threshold, + icp.kt.clone(), + icp.nt.clone(), icp.d.clone(), + icp.b.clone(), + icp.bt.clone(), + icp.c.clone(), )) } @@ -186,29 +326,51 @@ fn validate_rotation( state: &mut KeyState, ) -> Result<(), ValidationError> { if !rot.k.is_empty() { - verify_event_signature(event, &rot.k[0])?; + verify_event_signature(event, rot.k[0].as_str())?; } - if !state.next_commitment.is_empty() && !rot.k.is_empty() { - let key_bytes = KeriPublicKey::parse(&rot.k[0]) - .map(|k| k.as_bytes().to_vec()) - .map_err(|_| ValidationError::CommitmentMismatch { sequence })?; - - if !verify_commitment(&key_bytes, &state.next_commitment[0]) { + // Verify all pre-rotation commitments (not just first key) + if !state.next_commitment.is_empty() { + let required = state.next_threshold.simple_value().unwrap_or(1); + let mut matched_count = 0u64; + for commitment in &state.next_commitment { + let matched = rot.k.iter().any(|key| { + key.parse_ed25519() + .map(|pk| verify_commitment(pk.as_bytes(), commitment)) + .unwrap_or(false) + }); + if matched { + matched_count += 1; + } + } + if matched_count < required { return Err(ValidationError::CommitmentMismatch { sequence }); } } - let threshold = parse_threshold(&rot.kt)?; - let next_threshold = parse_threshold(&rot.nt)?; + // Validate backer uniqueness in br and ba + validate_backer_uniqueness(&rot.br)?; + validate_backer_uniqueness(&rot.ba)?; + // Check no overlap between br and ba + for aid in &rot.ba { + if rot.br.contains(aid) { + return Err(ValidationError::DuplicateBacker { + aid: aid.as_str().to_string(), + }); + } + } state.apply_rotation( rot.k.clone(), rot.n.clone(), - threshold, - next_threshold, + rot.kt.clone(), + rot.nt.clone(), sequence, rot.d.clone(), + &rot.br, + &rot.ba, + rot.bt.clone(), + rot.c.clone(), ); Ok(()) @@ -223,7 +385,7 @@ fn validate_interaction( let current_key = state .current_key() .ok_or(ValidationError::SignatureFailed { sequence })?; - verify_event_signature(event, current_key)?; + verify_event_signature(event, current_key.as_str())?; state.apply_interaction(sequence, ixn.d.clone()); Ok(()) } @@ -253,9 +415,11 @@ pub fn verify_event_crypto( .k .first() .ok_or(ValidationError::SignatureFailed { sequence: 0 })?; - verify_event_signature(event, key)?; + verify_event_signature(event, key.as_str())?; - if icp.i.as_str() != icp.d.as_str() { + // Only enforce i == d for self-addressing AIDs (E-prefixed) + let is_self_addressing = icp.i.as_str().starts_with('E'); + if is_self_addressing && icp.i.as_str() != icp.d.as_str() { return Err(ValidationError::InvalidSaid { expected: icp.d.clone(), actual: Said::new_unchecked(icp.i.as_str().to_string()), @@ -275,14 +439,22 @@ pub fn verify_event_crypto( if rot.k.is_empty() { return Err(ValidationError::SignatureFailed { sequence }); } - verify_event_signature(event, &rot.k[0])?; - - let key_str = &rot.k[0]; - let key_bytes = KeriPublicKey::parse(key_str) - .map(|k| k.as_bytes().to_vec()) - .map_err(|_| ValidationError::CommitmentMismatch { sequence })?; - - if !verify_commitment(&key_bytes, &state.next_commitment[0]) { + verify_event_signature(event, rot.k[0].as_str())?; + + // Verify all pre-rotation commitments + let required = state.next_threshold.simple_value().unwrap_or(1); + let mut matched_count = 0u64; + for commitment in &state.next_commitment { + let matched = rot.k.iter().any(|key| { + key.parse_ed25519() + .map(|pk| verify_commitment(pk.as_bytes(), commitment)) + .unwrap_or(false) + }); + if matched { + matched_count += 1; + } + } + if matched_count < required { return Err(ValidationError::CommitmentMismatch { sequence }); } @@ -295,10 +467,32 @@ pub fn verify_event_crypto( let current_key = state .current_key() .ok_or(ValidationError::SignatureFailed { sequence })?; - verify_event_signature(event, current_key)?; + verify_event_signature(event, current_key.as_str())?; Ok(()) } + // Delegated events use same crypto verification as their non-delegated counterparts + Event::Dip(dip) => { + let key = dip + .k + .first() + .ok_or(ValidationError::SignatureFailed { sequence: 0 })?; + verify_event_signature(event, key.as_str())?; + Ok(()) + } + Event::Drt(drt) => { + let sequence = event.sequence().value(); + let state = current_state.ok_or(ValidationError::SignatureFailed { sequence })?; + + if state.is_abandoned || state.next_commitment.is_empty() { + return Err(ValidationError::CommitmentMismatch { sequence }); + } + if drt.k.is_empty() { + return Err(ValidationError::SignatureFailed { sequence }); + } + verify_event_signature(event, drt.k[0].as_str())?; + Ok(()) + } } } @@ -307,11 +501,13 @@ pub fn verify_event_crypto( /// Args: /// * `event` - The event to verify. pub fn verify_event_said(event: &Event) -> Result<(), ValidationError> { - let json = serialize_for_said(event)?; - let computed = compute_said(&json); + let value = + serde_json::to_value(event).map_err(|e| ValidationError::Serialization(e.to_string()))?; + let computed = + compute_said(&value).map_err(|e| ValidationError::Serialization(e.to_string()))?; let actual = event.said(); - if computed != actual.as_str() { + if computed != *actual { return Err(ValidationError::InvalidSaid { expected: computed, actual: actual.clone(), @@ -344,12 +540,16 @@ pub fn validate_for_append(event: &Event, state: &KeyState) -> Result<(), Valida /// Args: /// * `event` - The event to compute the SAID for. pub fn compute_event_said(event: &Event) -> Result { - let json = serialize_for_said(event)?; - Ok(compute_said(&json)) + let value = + serde_json::to_value(event).map_err(|e| ValidationError::Serialization(e.to_string()))?; + compute_said(&value).map_err(|e| ValidationError::Serialization(e.to_string())) } -/// Serialize an event for SAID computation (with empty `d`, `i` for icp, and `x` fields). -fn serialize_for_said(event: &Event) -> Result, ValidationError> { +/// Serialize event for signing (clears d, i for icp, and x fields). +/// +/// Args: +/// * `event` - The event to serialize for signing. +pub fn serialize_for_signing(event: &Event) -> Result, ValidationError> { match event { Event::Icp(e) => { let mut e = e.clone(); @@ -370,40 +570,52 @@ fn serialize_for_said(event: &Event) -> Result, ValidationError> { e.x = String::new(); serde_json::to_vec(&Event::Ixn(e)) } - } - .map_err(|e| ValidationError::Serialization(e.to_string())) -} - -/// Serialize event for signing (clears d, i for icp, and x fields). -/// -/// Args: -/// * `event` - The event to serialize for signing. -pub fn serialize_for_signing(event: &Event) -> Result, ValidationError> { - match event { - Event::Icp(e) => { + Event::Dip(e) => { let mut e = e.clone(); e.d = Said::default(); e.i = Prefix::default(); e.x = String::new(); - serde_json::to_vec(&Event::Icp(e)) - } - Event::Rot(e) => { - let mut e = e.clone(); - e.d = Said::default(); - e.x = String::new(); - serde_json::to_vec(&Event::Rot(e)) + serde_json::to_vec(&Event::Dip(e)) } - Event::Ixn(e) => { + Event::Drt(e) => { let mut e = e.clone(); e.d = Said::default(); e.x = String::new(); - serde_json::to_vec(&Event::Ixn(e)) + serde_json::to_vec(&Event::Drt(e)) } } .map_err(|e| ValidationError::Serialization(e.to_string())) } -/// Verify an event's signature using the specified key. +/// Verify an event's signature using the specified key and explicit signature bytes. +/// +/// Args: +/// * `event` - The event whose canonical form to verify against. +/// * `signing_key` - CESR-encoded public key string. +/// * `sig_bytes` - Raw signature bytes (64 bytes for Ed25519). +fn verify_signature_bytes( + event: &Event, + signing_key: &str, + sig_bytes: &[u8], +) -> Result<(), ValidationError> { + let sequence = event.sequence().value(); + + let key_bytes = KeriPublicKey::parse(signing_key) + .map_err(|_| ValidationError::SignatureFailed { sequence })?; + + let canonical = serialize_for_signing(event)?; + + let pk = UnparsedPublicKey::new(&ring::signature::ED25519, key_bytes.as_bytes()); + pk.verify(&canonical, sig_bytes) + .map_err(|_| ValidationError::SignatureFailed { sequence })?; + + Ok(()) +} + +/// Verify an event's signature using the legacy `x` field. +/// +/// Reads the signature from `event.signature()` (the `x` field). +/// Prefer `verify_signature_bytes` with explicit sig bytes for new code. fn verify_event_signature(event: &Event, signing_key: &str) -> Result<(), ValidationError> { let sequence = event.sequence().value(); @@ -415,14 +627,84 @@ fn verify_event_signature(event: &Event, signing_key: &str) -> Result<(), Valida .decode(sig_str) .map_err(|_| ValidationError::SignatureFailed { sequence })?; - let key_bytes = KeriPublicKey::parse(signing_key) - .map_err(|_| ValidationError::SignatureFailed { sequence })?; + verify_signature_bytes(event, signing_key, &sig_bytes) +} + +/// Validate a signed event's crypto (signatures + commitments) against key state. +/// +/// This is the preferred entry point for validating events with externalized signatures. +/// +/// Args: +/// * `signed` - The signed event with detached signatures. +/// * `current_state` - The current `KeyState` (None for inception events). +pub fn validate_signed_event( + signed: &crate::events::SignedEvent, + current_state: Option<&KeyState>, +) -> Result<(), ValidationError> { + let event = &signed.event; + let sequence = event.sequence().value(); + + if signed.signatures.is_empty() { + return Err(ValidationError::SignatureFailed { sequence }); + } + + // Determine the key list and threshold for verification + let (keys, threshold) = match event { + Event::Icp(icp) => (&icp.k, &icp.kt), + Event::Dip(dip) => (&dip.k, &dip.kt), + Event::Rot(rot) => (&rot.k, &rot.kt), + Event::Drt(drt) => (&drt.k, &drt.kt), + Event::Ixn(_) => { + let state = current_state.ok_or(ValidationError::SignatureFailed { sequence })?; + (&state.current_keys, &state.threshold) + } + }; + if keys.is_empty() { + return Err(ValidationError::SignatureFailed { sequence }); + } + + // Verify each signature and collect verified indices let canonical = serialize_for_signing(event)?; + let mut verified_indices = Vec::new(); - let pk = UnparsedPublicKey::new(&ring::signature::ED25519, key_bytes.as_bytes()); - pk.verify(&canonical, &sig_bytes) - .map_err(|_| ValidationError::SignatureFailed { sequence })?; + for sig in &signed.signatures { + let idx = sig.index as usize; + if idx >= keys.len() { + continue; // out-of-range index, skip + } + let key = &keys[idx]; + if let Ok(pk) = key.parse_ed25519() { + let verifier = + ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, pk.as_bytes()); + if verifier.verify(&canonical, &sig.sig).is_ok() { + verified_indices.push(sig.index); + } + } + } + + // Check threshold satisfaction (current key threshold) + if !threshold.is_satisfied(&verified_indices, keys.len()) { + return Err(ValidationError::SignatureFailed { sequence }); + } + + // For rotation events: also check prior next-threshold from the previous + // establishment event. The spec requires signatures satisfy BOTH the current + // signing threshold AND the prior next rotation threshold. + if matches!(event, Event::Rot(_) | Event::Drt(_)) + && let Some(state) = current_state + { + // The verified indices may map differently in the prior key context. + // For now, use "both same" semantics (same indices apply to both lists). + // Full "current only" vs "both same" distinction requires CESR indexed + // signature type codes, which we'll implement when CESR attachments land. + if !state + .next_threshold + .is_satisfied(&verified_indices, keys.len()) + { + return Err(ValidationError::SignatureFailed { sequence }); + } + } Ok(()) } @@ -432,19 +714,58 @@ fn verify_event_signature(event: &Event, signing_key: &str) -> Result<(), Valida /// Args: /// * `icp` - The inception event to finalize. pub fn finalize_icp_event(mut icp: IcpEvent) -> Result { - icp.d = Said::default(); - icp.i = Prefix::default(); - - let json = serde_json::to_vec(&Event::Icp(icp.clone())) + let value = serde_json::to_value(Event::Icp(icp.clone())) .map_err(|e| ValidationError::Serialization(e.to_string()))?; - let said = compute_said(&json); + let said = compute_said(&value).map_err(|e| ValidationError::Serialization(e.to_string()))?; icp.d = said.clone(); - icp.i = Prefix::new_unchecked(said.into_inner()); + // Only set i = d for self-addressing AIDs (empty or E-prefixed) + if icp.i.is_empty() || icp.i.as_str().starts_with('E') { + icp.i = Prefix::new_unchecked(said.into_inner()); + } + + // Set version string with actual byte count + let final_bytes = serde_json::to_vec(&Event::Icp(icp.clone())) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + icp.v = crate::types::VersionString::json(final_bytes.len() as u32); Ok(icp) } +/// Create a rotation event with a properly computed SAID. +/// +/// Args: +/// * `rot` - The rotation event to finalize. +pub fn finalize_rot_event(mut rot: RotEvent) -> Result { + let value = serde_json::to_value(Event::Rot(rot.clone())) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + let said = compute_said(&value).map_err(|e| ValidationError::Serialization(e.to_string()))?; + rot.d = said; + + let final_bytes = serde_json::to_vec(&Event::Rot(rot.clone())) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + rot.v = crate::types::VersionString::json(final_bytes.len() as u32); + + Ok(rot) +} + +/// Create an interaction event with a properly computed SAID. +/// +/// Args: +/// * `ixn` - The interaction event to finalize. +pub fn finalize_ixn_event(mut ixn: IxnEvent) -> Result { + let value = serde_json::to_value(Event::Ixn(ixn.clone())) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + let said = compute_said(&value).map_err(|e| ValidationError::Serialization(e.to_string()))?; + ixn.d = said; + + let final_bytes = serde_json::to_vec(&Event::Ixn(ixn.clone())) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + ixn.v = crate::types::VersionString::json(final_bytes.len() as u32); + + Ok(ixn) +} + /// Search for a seal with the given digest in any IXN event in the KEL. /// /// Returns the sequence number of the IXN event if found. @@ -456,7 +777,7 @@ pub fn find_seal_in_kel(events: &[Event], digest: &str) -> Option { for event in events { if let Event::Ixn(ixn) = event { for seal in &ixn.a { - if seal.d.as_str() == digest { + if seal.digest_value().is_some_and(|d| d.as_str() == digest) { return Some(ixn.s.value()); } } @@ -477,24 +798,41 @@ pub fn parse_kel_json(json: &str) -> Result, ValidationError> { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::events::{KERI_VERSION, KeriSequence, Seal}; + use crate::events::{KeriSequence, Seal}; + use crate::types::{CesrKey, Threshold, VersionString}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; + fn gen_keypair() -> Ed25519KeyPair { + let rng = SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap() + } + + fn encode_pubkey(kp: &Ed25519KeyPair) -> String { + format!("D{}", URL_SAFE_NO_PAD.encode(kp.public_key().as_ref())) + } + + fn sign_event(event: &Event, kp: &Ed25519KeyPair) -> String { + let canonical = serialize_for_signing(event).unwrap(); + URL_SAFE_NO_PAD.encode(kp.sign(&canonical).as_ref()) + } + fn make_raw_icp(key: &str, next: &str) -> IcpEvent { IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key.to_string()], - nt: "1".to_string(), - n: vec![next.to_string()], - bt: "0".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key.to_string())], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked(next.to_string())], + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), } @@ -507,16 +845,17 @@ mod tests { let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), - n: vec!["ENextCommitment".to_string()], - bt: "0".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("ENextCommitment".to_string())], + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; @@ -536,17 +875,17 @@ mod tests { keypair: &Ed25519KeyPair, ) -> IxnEvent { let mut ixn = IxnEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(seq), p: prev_said.clone(), - a: vec![Seal::device_attestation("EAttest")], + a: vec![Seal::digest("EAttest")], x: String::new(), }; - let json = serde_json::to_vec(&Event::Ixn(ixn.clone())).unwrap(); - ixn.d = compute_said(&json); + let value = serde_json::to_value(Event::Ixn(ixn.clone())).unwrap(); + ixn.d = compute_said(&value).unwrap(); let canonical = serialize_for_signing(&Event::Ixn(ixn.clone())).unwrap(); let sig = keypair.sign(&canonical); @@ -584,7 +923,7 @@ mod tests { #[test] fn rejects_non_inception_first() { let ixn = IxnEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::new_unchecked("ETest".to_string()), i: Prefix::new_unchecked("ETest".to_string()), s: KeriSequence::new(0), @@ -602,7 +941,7 @@ mod tests { let (icp, keypair) = make_signed_icp(); let mut ixn = IxnEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: icp.i.clone(), s: KeriSequence::new(5), @@ -611,8 +950,8 @@ mod tests { x: String::new(), }; - let json = serde_json::to_vec(&Event::Ixn(ixn.clone())).unwrap(); - ixn.d = compute_said(&json); + let value = serde_json::to_value(Event::Ixn(ixn.clone())).unwrap(); + ixn.d = compute_said(&value).unwrap(); let canonical = serialize_for_signing(&Event::Ixn(ixn.clone())).unwrap(); let sig = keypair.sign(&canonical); @@ -634,7 +973,7 @@ mod tests { let (icp, keypair) = make_signed_icp(); let mut ixn = IxnEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: icp.i.clone(), s: KeriSequence::new(1), @@ -643,8 +982,8 @@ mod tests { x: String::new(), }; - let json = serde_json::to_vec(&Event::Ixn(ixn.clone())).unwrap(); - ixn.d = compute_said(&json); + let value = serde_json::to_value(Event::Ixn(ixn.clone())).unwrap(); + ixn.d = compute_said(&value).unwrap(); let canonical = serialize_for_signing(&Event::Ixn(ixn.clone())).unwrap(); let sig = keypair.sign(&canonical); @@ -722,16 +1061,17 @@ mod tests { let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); let mut icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), - n: vec!["ENextCommitment".to_string()], - bt: "0".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("ENextCommitment".to_string())], + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; @@ -774,4 +1114,191 @@ mod tests { let result = parse_kel_json(json); assert!(result.is_err(), "expected error for invalid hex sequence"); } + + /// Build a signed ICP with caller-supplied overrides applied after keypair + /// generation but before finalization and signing. + fn make_custom_signed_icp(customize: impl FnOnce(&mut IcpEvent)) -> (IcpEvent, Ed25519KeyPair) { + let rng = SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); + + let mut icp = IcpEvent { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("ENextCommitment".to_string())], + bt: Threshold::Simple(0), + b: vec![], + c: vec![], + a: vec![], + x: String::new(), + }; + + customize(&mut icp); + + let mut finalized = finalize_icp_event(icp).unwrap(); + let canonical = serialize_for_signing(&Event::Icp(finalized.clone())).unwrap(); + let sig = keypair.sign(&canonical); + finalized.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); + + (finalized, keypair) + } + + #[test] + fn rejects_events_after_abandonment() { + // Abandonment = rotation with empty n (not inception — that's NonTransferable). + let kp2 = gen_keypair(); + + // Use make_custom_signed_icp with pre-committed key for kp2 + let commitment2 = crate::crypto::compute_next_commitment(kp2.public_key().as_ref()); + let (icp, _kp1) = make_custom_signed_icp(|icp| { + icp.n = vec![commitment2.clone()]; + }); + let prefix = icp.i.clone(); + + // Rotation that abandons (empty n) + let mut rot = RotEvent { + v: VersionString::placeholder(), + d: Said::default(), + i: prefix.clone(), + s: KeriSequence::new(1), + p: icp.d.clone(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(encode_pubkey(&kp2))], + nt: Threshold::Simple(0), + n: vec![], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], + a: vec![], + x: String::new(), + }; + let val = serde_json::to_value(Event::Rot(rot.clone())).unwrap(); + rot.d = compute_said(&val).unwrap(); + rot.x = sign_event(&Event::Rot(rot.clone()), &kp2); + + let ixn = make_signed_ixn(&prefix, &rot.d, 2, &kp2); + let events = vec![Event::Icp(icp), Event::Rot(rot), Event::Ixn(ixn)]; + let result = validate_kel(&events); + assert!( + matches!(result, Err(ValidationError::AbandonedIdentity { .. })), + "expected AbandonedIdentity, got: {result:?}" + ); + } + + #[test] + fn rejects_ixn_in_establishment_only_kel() { + let (icp, keypair) = make_custom_signed_icp(|icp| { + icp.c = vec![ConfigTrait::EstablishmentOnly]; + }); + let ixn = make_signed_ixn(&icp.i, &icp.d, 1, &keypair); + let events = vec![Event::Icp(icp), Event::Ixn(ixn)]; + let result = validate_kel(&events); + assert!( + matches!(result, Err(ValidationError::EstablishmentOnly { .. })), + "expected EstablishmentOnly, got: {result:?}" + ); + } + + #[test] + fn rejects_events_after_non_transferable_inception() { + let (icp, keypair) = make_custom_signed_icp(|icp| { + icp.n = vec![]; + icp.nt = Threshold::Simple(0); + }); + let ixn = make_signed_ixn(&icp.i, &icp.d, 1, &keypair); + let events = vec![Event::Icp(icp), Event::Ixn(ixn)]; + let result = validate_kel(&events); + assert!( + matches!( + result, + Err(ValidationError::NonTransferable) + | Err(ValidationError::AbandonedIdentity { .. }) + ), + "expected NonTransferable or AbandonedIdentity, got: {result:?}" + ); + } + + #[test] + fn rejects_duplicate_backers() { + let (_, result) = { + let rng = SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); + + let dup_backer = Prefix::new_unchecked("DWit1".to_string()); + let icp = IcpEvent { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("ENextCommitment".to_string())], + bt: Threshold::Simple(2), + b: vec![dup_backer.clone(), dup_backer], + c: vec![], + a: vec![], + x: String::new(), + }; + + let mut finalized = finalize_icp_event(icp).unwrap(); + let canonical = serialize_for_signing(&Event::Icp(finalized.clone())).unwrap(); + let sig = keypair.sign(&canonical); + finalized.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); + + let events = vec![Event::Icp(finalized)]; + (keypair, validate_kel(&events)) + }; + assert!( + matches!(result, Err(ValidationError::DuplicateBacker { .. })), + "expected DuplicateBacker, got: {result:?}" + ); + } + + #[test] + fn rejects_invalid_backer_threshold() { + let (_, result) = { + let rng = SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); + + let icp = IcpEvent { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("ENextCommitment".to_string())], + bt: Threshold::Simple(2), + b: vec![], + c: vec![], + a: vec![], + x: String::new(), + }; + + let mut finalized = finalize_icp_event(icp).unwrap(); + let canonical = serialize_for_signing(&Event::Icp(finalized.clone())).unwrap(); + let sig = keypair.sign(&canonical); + finalized.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); + + let events = vec![Event::Icp(finalized)]; + (keypair, validate_kel(&events)) + }; + assert!( + matches!(result, Err(ValidationError::InvalidBackerThreshold { .. })), + "expected InvalidBackerThreshold, got: {result:?}" + ); + } } diff --git a/crates/auths-keri/src/witness/agreement.rs b/crates/auths-keri/src/witness/agreement.rs new file mode 100644 index 00000000..0c6c5c20 --- /dev/null +++ b/crates/auths-keri/src/witness/agreement.rs @@ -0,0 +1,270 @@ +//! KAWA (KERI Algorithm for Witness Agreement). +//! +//! Tracks receipt collection per event and determines when an event +//! has sufficient witness agreement to be accepted. +//! +//! The spec rule: controller designates N witnesses and threshold M. +//! An event is accepted when M-of-N witnesses provide valid receipts. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Mutex; + +use crate::types::{Prefix, Said, Threshold}; + +/// Status of an event in the witness agreement process. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgreementStatus { + /// Waiting for more receipts. + Pending { + /// How many receipts have been collected so far. + collected: usize, + }, + /// Sufficient receipts collected — event accepted. + Accepted, +} + +/// Tracks witness agreement for pending events. +/// +/// Events are submitted and then receipts are collected. Once the backer +/// threshold is met, the event is marked as accepted. +/// +/// Usage: +/// ``` +/// use auths_keri::witness::agreement::WitnessAgreement; +/// use auths_keri::{Prefix, Said, Threshold}; +/// +/// let agreement = WitnessAgreement::new(1000); +/// let prefix = Prefix::new_unchecked("ETest".into()); +/// let said = Said::new_unchecked("ESAID".into()); +/// let bt = Threshold::Simple(2); +/// +/// agreement.submit_event(&prefix, 0, &said, &bt, 3); +/// agreement.add_receipt(&prefix, 0, &said, "witness1"); +/// agreement.add_receipt(&prefix, 0, &said, "witness2"); +/// assert!(agreement.is_accepted(&prefix, 0, &said)); +/// ``` +pub struct WitnessAgreement { + state: Mutex, +} + +struct AgreementState { + /// Pending events: (prefix, sn, said) → set of witness IDs that have receipted + pending: HashMap<(String, u64, String), PendingEvent>, + /// FIFO eviction queue + eviction_order: VecDeque<(String, u64, String)>, + /// Maximum number of pending events (FIFO eviction) + max_pending: usize, + /// Accepted events: (prefix, sn, said) → true + accepted: HashSet<(String, u64, String)>, +} + +struct PendingEvent { + witnesses: HashSet, + threshold: u64, + #[allow(dead_code)] + witness_count: usize, +} + +impl WitnessAgreement { + /// Create a new witness agreement tracker with the given max pending queue size. + pub fn new(max_pending: usize) -> Self { + Self { + state: Mutex::new(AgreementState { + pending: HashMap::new(), + eviction_order: VecDeque::new(), + max_pending, + accepted: HashSet::new(), + }), + } + } + + /// Submit an event for witness agreement tracking. + /// + /// Args: + /// * `prefix` - Identity prefix. + /// * `sn` - Sequence number. + /// * `said` - Event SAID. + /// * `bt` - Backer threshold (simple only for now). + /// * `witness_count` - Total number of designated witnesses. + pub fn submit_event( + &self, + prefix: &Prefix, + sn: u64, + said: &Said, + bt: &Threshold, + witness_count: usize, + ) { + let key = (prefix.as_str().to_string(), sn, said.as_str().to_string()); + let threshold = bt.simple_value().unwrap_or(0); + + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + + // Already accepted? + if state.accepted.contains(&key) { + return; + } + + // Zero threshold = immediately accepted + if threshold == 0 { + state.accepted.insert(key); + return; + } + + // FIFO eviction if at capacity + while state.pending.len() >= state.max_pending { + if let Some(evicted) = state.eviction_order.pop_front() { + state.pending.remove(&evicted); + } else { + break; + } + } + + if !state.pending.contains_key(&key) { + state.eviction_order.push_back(key.clone()); + state.pending.insert( + key, + PendingEvent { + witnesses: HashSet::new(), + threshold, + witness_count, + }, + ); + } + } + + /// Add a witness receipt for an event. + /// + /// Returns the agreement status after adding the receipt. + pub fn add_receipt( + &self, + prefix: &Prefix, + sn: u64, + said: &Said, + witness_id: &str, + ) -> AgreementStatus { + let key = (prefix.as_str().to_string(), sn, said.as_str().to_string()); + + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + + if state.accepted.contains(&key) { + return AgreementStatus::Accepted; + } + + if let Some(pending) = state.pending.get_mut(&key) { + pending.witnesses.insert(witness_id.to_string()); + if pending.witnesses.len() as u64 >= pending.threshold { + state.pending.remove(&key); + state.accepted.insert(key); + return AgreementStatus::Accepted; + } + AgreementStatus::Pending { + collected: pending.witnesses.len(), + } + } else { + AgreementStatus::Pending { collected: 0 } + } + } + + /// Check if an event has been accepted (sufficient witness agreement). + pub fn is_accepted(&self, prefix: &Prefix, sn: u64, said: &Said) -> bool { + let key = (prefix.as_str().to_string(), sn, said.as_str().to_string()); + let state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + state.accepted.contains(&key) + } + + /// Get the current status of an event. + pub fn status(&self, prefix: &Prefix, sn: u64, said: &Said) -> AgreementStatus { + let key = (prefix.as_str().to_string(), sn, said.as_str().to_string()); + let state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if state.accepted.contains(&key) { + AgreementStatus::Accepted + } else if let Some(pending) = state.pending.get(&key) { + AgreementStatus::Pending { + collected: pending.witnesses.len(), + } + } else { + AgreementStatus::Pending { collected: 0 } + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn event_accepted_after_threshold_receipts() { + let agreement = WitnessAgreement::new(100); + let prefix = Prefix::new_unchecked("ETest".into()); + let said = Said::new_unchecked("ESAID".into()); + let bt = Threshold::Simple(2); + + agreement.submit_event(&prefix, 0, &said, &bt, 3); + + let s1 = agreement.add_receipt(&prefix, 0, &said, "witness1"); + assert_eq!(s1, AgreementStatus::Pending { collected: 1 }); + + let s2 = agreement.add_receipt(&prefix, 0, &said, "witness2"); + assert_eq!(s2, AgreementStatus::Accepted); + + assert!(agreement.is_accepted(&prefix, 0, &said)); + } + + #[test] + fn event_stays_pending_with_insufficient_receipts() { + let agreement = WitnessAgreement::new(100); + let prefix = Prefix::new_unchecked("ETest".into()); + let said = Said::new_unchecked("ESAID".into()); + let bt = Threshold::Simple(3); + + agreement.submit_event(&prefix, 0, &said, &bt, 5); + agreement.add_receipt(&prefix, 0, &said, "witness1"); + agreement.add_receipt(&prefix, 0, &said, "witness2"); + + assert!(!agreement.is_accepted(&prefix, 0, &said)); + } + + #[test] + fn zero_threshold_immediately_accepted() { + let agreement = WitnessAgreement::new(100); + let prefix = Prefix::new_unchecked("ETest".into()); + let said = Said::new_unchecked("ESAID".into()); + let bt = Threshold::Simple(0); + + agreement.submit_event(&prefix, 0, &said, &bt, 0); + assert!(agreement.is_accepted(&prefix, 0, &said)); + } + + #[test] + fn duplicate_receipt_from_same_witness_not_double_counted() { + let agreement = WitnessAgreement::new(100); + let prefix = Prefix::new_unchecked("ETest".into()); + let said = Said::new_unchecked("ESAID".into()); + let bt = Threshold::Simple(2); + + agreement.submit_event(&prefix, 0, &said, &bt, 3); + agreement.add_receipt(&prefix, 0, &said, "witness1"); + agreement.add_receipt(&prefix, 0, &said, "witness1"); // duplicate + + assert!(!agreement.is_accepted(&prefix, 0, &said)); + } + + #[test] + fn fifo_eviction_at_capacity() { + let agreement = WitnessAgreement::new(2); + let prefix = Prefix::new_unchecked("ETest".into()); + let bt = Threshold::Simple(2); + + // Submit 3 events with capacity 2 + for i in 0..3 { + let said = Said::new_unchecked(format!("ESAID{i}")); + agreement.submit_event(&prefix, i as u64, &said, &bt, 3); + } + + // First event should have been evicted + let said0 = Said::new_unchecked("ESAID0".into()); + let status = agreement.status(&prefix, 0, &said0); + assert_eq!(status, AgreementStatus::Pending { collected: 0 }); + } +} diff --git a/crates/auths-keri/src/witness/async_provider.rs b/crates/auths-keri/src/witness/async_provider.rs index f9644d92..7bb48176 100644 --- a/crates/auths-keri/src/witness/async_provider.rs +++ b/crates/auths-keri/src/witness/async_provider.rs @@ -174,13 +174,11 @@ impl AsyncWitnessProvider for NoOpAsyncWitness { _event_json: &[u8], ) -> Result { Ok(Receipt { - v: super::receipt::KERI_VERSION.into(), + v: crate::VersionString::placeholder(), t: super::receipt::RECEIPT_TYPE.into(), d: Said::new_unchecked("ENoop".into()), - i: "did:key:noop".into(), - s: 0, - a: Said::new_unchecked("ENoop".into()), - sig: vec![0u8; 64], + i: Prefix::new_unchecked("did:key:noop".into()), + s: crate::KeriSequence::new(0), }) } diff --git a/crates/auths-keri/src/witness/first_seen.rs b/crates/auths-keri/src/witness/first_seen.rs new file mode 100644 index 00000000..04fa2730 --- /dev/null +++ b/crates/auths-keri/src/witness/first_seen.rs @@ -0,0 +1,280 @@ +//! First-seen policy for KERI event acceptance. +//! +//! The spec rule: "First seen, always seen, never unseen." +//! Once a validator accepts an event at a given (prefix, sequence), it must +//! reject any DIFFERENT event at that same (prefix, sequence). + +use std::collections::HashMap; +use std::sync::Mutex; + +use crate::types::{Prefix, Said}; + +/// Error when a conflicting event is detected at the same (prefix, sequence). +#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] +#[error( + "Conflicting event at prefix={prefix}, sn={sn}: first-seen SAID={first_seen}, new SAID={new_said}" +)] +pub struct FirstSeenConflict { + /// The prefix of the identity. + pub prefix: String, + /// The sequence number where conflict was detected. + pub sn: u64, + /// The SAID of the first-seen event. + pub first_seen: String, + /// The SAID of the conflicting new event. + pub new_said: String, +} + +/// Policy for enforcing first-seen event acceptance. +/// +/// Implementations track which events have been accepted at each (prefix, sequence) +/// location and reject conflicting events. +/// +/// Usage: +/// ```ignore +/// let policy = InMemoryFirstSeen::new(); +/// policy.try_accept(&prefix, 0, &said)?; // first event accepted +/// policy.try_accept(&prefix, 0, &said)?; // same event, idempotent +/// policy.try_accept(&prefix, 0, &other)?; // CONFLICT: different event at same location +/// ``` +pub trait FirstSeenPolicy: Send + Sync { + /// Accept an event if no conflicting event was previously seen. + /// + /// Returns `Ok(())` if the event is accepted (first-seen or same as first-seen). + /// Returns `Err(FirstSeenConflict)` if a DIFFERENT event was already accepted at this location. + fn try_accept(&self, prefix: &Prefix, sn: u64, said: &Said) -> Result<(), FirstSeenConflict>; + + /// Check if an event was already seen at this location. + /// + /// Returns the SAID of the first-seen event, or None if no event was seen. + fn was_seen(&self, prefix: &Prefix, sn: u64) -> Option; + + /// Attempt to supersede a previously accepted event with a recovery rotation. + /// + /// Per the spec, a rotation event at sequence N can supersede a previously + /// accepted interaction event at the same N. This is the mechanism for + /// recovering from key compromise. + /// + /// Returns `Ok(())` if the superseding succeeded, or `Err` if superseding + /// is not allowed (e.g., trying to supersede a rotation with an interaction). + fn try_supersede( + &self, + prefix: &Prefix, + sn: u64, + new_said: &Said, + is_new_establishment: bool, + ) -> Result<(), FirstSeenConflict>; +} + +/// In-memory first-seen policy (HashMap-based). +/// +/// Suitable for single-process validators. For persistent validators, +/// wrap a database-backed implementation. +pub struct InMemoryFirstSeen { + seen: Mutex>, +} + +#[derive(Clone)] +struct SeenEntry { + said: Said, + is_establishment: bool, +} + +impl InMemoryFirstSeen { + /// Create a new empty first-seen policy. + pub fn new() -> Self { + Self { + seen: Mutex::new(HashMap::new()), + } + } +} + +impl Default for InMemoryFirstSeen { + fn default() -> Self { + Self::new() + } +} + +impl FirstSeenPolicy for InMemoryFirstSeen { + fn try_accept(&self, prefix: &Prefix, sn: u64, said: &Said) -> Result<(), FirstSeenConflict> { + let key = (prefix.as_str().to_string(), sn); + let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner()); + + if let Some(existing) = seen.get(&key) { + if existing.said != *said { + return Err(FirstSeenConflict { + prefix: prefix.as_str().to_string(), + sn, + first_seen: existing.said.as_str().to_string(), + new_said: said.as_str().to_string(), + }); + } + Ok(()) + } else { + seen.insert( + key, + SeenEntry { + said: said.clone(), + is_establishment: false, // caller should use try_accept_establishment for establishment events + }, + ); + Ok(()) + } + } + + fn was_seen(&self, prefix: &Prefix, sn: u64) -> Option { + let key = (prefix.as_str().to_string(), sn); + let seen = self.seen.lock().unwrap_or_else(|e| e.into_inner()); + seen.get(&key).map(|e| e.said.clone()) + } + + fn try_supersede( + &self, + prefix: &Prefix, + sn: u64, + new_said: &Said, + is_new_establishment: bool, + ) -> Result<(), FirstSeenConflict> { + let key = (prefix.as_str().to_string(), sn); + let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner()); + + if let Some(existing) = seen.get(&key) { + if existing.said == *new_said { + return Ok(()); // Same event, no superseding needed + } + + // Superseding rule: establishment can supersede non-establishment, not vice versa + if is_new_establishment && !existing.is_establishment { + // Allowed: rotation supersedes interaction + seen.insert( + key, + SeenEntry { + said: new_said.clone(), + is_establishment: true, + }, + ); + Ok(()) + } else { + Err(FirstSeenConflict { + prefix: prefix.as_str().to_string(), + sn, + first_seen: existing.said.as_str().to_string(), + new_said: new_said.as_str().to_string(), + }) + } + } else { + // No existing event — just accept + seen.insert( + key, + SeenEntry { + said: new_said.clone(), + is_establishment: is_new_establishment, + }, + ); + Ok(()) + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn first_event_accepted() { + let policy = InMemoryFirstSeen::new(); + let prefix = Prefix::new_unchecked("ETest".to_string()); + let said = Said::new_unchecked("ESAID1".to_string()); + assert!(policy.try_accept(&prefix, 0, &said).is_ok()); + } + + #[test] + fn same_event_idempotent() { + let policy = InMemoryFirstSeen::new(); + let prefix = Prefix::new_unchecked("ETest".to_string()); + let said = Said::new_unchecked("ESAID1".to_string()); + policy.try_accept(&prefix, 0, &said).unwrap(); + assert!(policy.try_accept(&prefix, 0, &said).is_ok()); + } + + #[test] + fn different_event_rejected() { + let policy = InMemoryFirstSeen::new(); + let prefix = Prefix::new_unchecked("ETest".to_string()); + let said1 = Said::new_unchecked("ESAID1".to_string()); + let said2 = Said::new_unchecked("ESAID2".to_string()); + policy.try_accept(&prefix, 0, &said1).unwrap(); + let err = policy.try_accept(&prefix, 0, &said2).unwrap_err(); + assert_eq!(err.first_seen, "ESAID1"); + assert_eq!(err.new_said, "ESAID2"); + } + + #[test] + fn different_sequences_independent() { + let policy = InMemoryFirstSeen::new(); + let prefix = Prefix::new_unchecked("ETest".to_string()); + let said1 = Said::new_unchecked("ESAID1".to_string()); + let said2 = Said::new_unchecked("ESAID2".to_string()); + policy.try_accept(&prefix, 0, &said1).unwrap(); + assert!(policy.try_accept(&prefix, 1, &said2).is_ok()); + } + + #[test] + fn was_seen_returns_said() { + let policy = InMemoryFirstSeen::new(); + let prefix = Prefix::new_unchecked("ETest".to_string()); + let said = Said::new_unchecked("ESAID1".to_string()); + + assert!(policy.was_seen(&prefix, 0).is_none()); + policy.try_accept(&prefix, 0, &said).unwrap(); + assert_eq!(policy.was_seen(&prefix, 0), Some(said)); + } + + // ── Superseding recovery ──────────────────────────────────────────── + + #[test] + fn rotation_supersedes_interaction() { + let policy = InMemoryFirstSeen::new(); + let prefix = Prefix::new_unchecked("ETest".to_string()); + let ixn_said = Said::new_unchecked("ESAID_IXN".to_string()); + let rot_said = Said::new_unchecked("ESAID_ROT".to_string()); + + // First-seen: interaction + policy.try_accept(&prefix, 1, &ixn_said).unwrap(); + + // Supersede with rotation (establishment event) + let result = policy.try_supersede(&prefix, 1, &rot_said, true); + assert!(result.is_ok()); + + // Now the first-seen is the rotation + assert_eq!(policy.was_seen(&prefix, 1), Some(rot_said)); + } + + #[test] + fn interaction_cannot_supersede_rotation() { + let policy = InMemoryFirstSeen::new(); + let prefix = Prefix::new_unchecked("ETest".to_string()); + let rot_said = Said::new_unchecked("ESAID_ROT".to_string()); + let ixn_said = Said::new_unchecked("ESAID_IXN".to_string()); + + // First-seen: rotation (establishment) + policy.try_supersede(&prefix, 1, &rot_said, true).unwrap(); + + // Try to supersede with interaction (non-establishment) + let result = policy.try_supersede(&prefix, 1, &ixn_said, false); + assert!(result.is_err()); + } + + #[test] + fn rotation_cannot_supersede_rotation() { + let policy = InMemoryFirstSeen::new(); + let prefix = Prefix::new_unchecked("ETest".to_string()); + let rot1 = Said::new_unchecked("ESAID_ROT1".to_string()); + let rot2 = Said::new_unchecked("ESAID_ROT2".to_string()); + + policy.try_supersede(&prefix, 1, &rot1, true).unwrap(); + let result = policy.try_supersede(&prefix, 1, &rot2, true); + assert!(result.is_err()); + } +} diff --git a/crates/auths-keri/src/witness/mod.rs b/crates/auths-keri/src/witness/mod.rs index 39401d5e..533efc8e 100644 --- a/crates/auths-keri/src/witness/mod.rs +++ b/crates/auths-keri/src/witness/mod.rs @@ -1,11 +1,15 @@ +/// KAWA witness agreement algorithm. +pub mod agreement; mod async_provider; mod error; +mod first_seen; mod hash; mod provider; mod receipt; pub use async_provider::{AsyncWitnessProvider, NoOpAsyncWitness}; pub use error::{DuplicityEvidence, WitnessError, WitnessReport}; +pub use first_seen::{FirstSeenConflict, FirstSeenPolicy, InMemoryFirstSeen}; pub use hash::{EventHash, EventHashParseError}; pub use provider::WitnessProvider; -pub use receipt::{KERI_VERSION, RECEIPT_TYPE, Receipt, ReceiptBuilder}; +pub use receipt::{RECEIPT_TYPE, Receipt, ReceiptBuilder, SignedReceipt}; diff --git a/crates/auths-keri/src/witness/receipt.rs b/crates/auths-keri/src/witness/receipt.rs index 177e63f5..4a81bc3e 100644 --- a/crates/auths-keri/src/witness/receipt.rs +++ b/crates/auths-keri/src/witness/receipt.rs @@ -4,85 +4,67 @@ //! a specific KEL event. Receipts enable duplicity detection by allowing //! verifiers to check that witnesses agree on the event history. //! -//! # KERI Receipt Format +//! # KERI Receipt Format (spec: `rct` message type) //! -//! This implementation follows the KERI `rct` (non-transferable receipt) format: -//! -//! ```json -//! { -//! "v": "KERI10JSON...", -//! "t": "rct", -//! "d": "", -//! "i": "", -//! "s": "", -//! "a": "", -//! "sig": "" -//! } -//! ``` +//! Per the spec, the receipt body contains only `[v, t, d, i, s]`. +//! The `d` field is the SAID of the **referenced key event** (NOT the receipt itself). +//! Signatures are externalized (not in the body). use crate::Said; +use crate::events::KeriSequence; +use crate::types::{Prefix, VersionString}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use serde::{Deserialize, Serialize}; -/// KERI version string for receipts. -pub const KERI_VERSION: &str = "KERI10JSON000000_"; - /// Receipt type identifier. pub const RECEIPT_TYPE: &str = "rct"; -/// A witness receipt for a KEL event. -/// -/// The receipt proves that a witness has observed and acknowledged a specific -/// event. It includes the witness's signature over the event SAID, enabling -/// verifiers to check receipt authenticity. -/// -/// # Serialization -/// -/// The `sig` field uses hex encoding for JSON serialization. +/// A witness receipt for a KEL event (spec-compliant `rct` message). /// -/// # Example +/// Per the spec, `d` is the SAID of the **referenced key event** (NOT the receipt's own SAID). +/// Signatures are externalized — use `SignedReceipt` to pair a receipt with its signature. /// -/// ```rust +/// Usage: +/// ``` /// use auths_keri::witness::Receipt; -/// use auths_keri::Said; +/// use auths_keri::{Said, Prefix, VersionString, KeriSequence}; /// /// let receipt = Receipt { -/// v: "KERI10JSON000000_".into(), +/// v: VersionString::placeholder(), /// t: "rct".into(), -/// d: Said::new_unchecked("EReceipt123".into()), -/// i: "did:key:z6MkWitness...".into(), -/// s: 5, -/// a: Said::new_unchecked("EEvent456".into()), -/// sig: vec![0u8; 64], +/// d: Said::new_unchecked("EEventSaid123".into()), +/// i: Prefix::new_unchecked("EControllerAid".into()), +/// s: KeriSequence::new(5), /// }; -/// -/// let json = serde_json::to_string(&receipt).unwrap(); -/// let parsed: Receipt = serde_json::from_str(&json).unwrap(); -/// assert_eq!(receipt.s, parsed.s); /// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Receipt { - /// Version string (e.g., "KERI10JSON000000_") - pub v: String, + /// Version string + pub v: VersionString, /// Type identifier ("rct" for receipt) pub t: String, - /// Receipt SAID (Self-Addressing Identifier) + /// SAID of the referenced key event (NOT the receipt's own SAID) pub d: Said, - /// Witness identifier (DID) - pub i: String, - - /// Event sequence number being receipted - pub s: u64, + /// Controller AID of the KEL being receipted + pub i: Prefix, - /// Event SAID being receipted - pub a: Said, + /// Sequence number of the event being receipted + pub s: KeriSequence, +} - /// Ed25519 signature over the canonical receipt JSON (excluding sig) - #[serde(with = "hex")] - pub sig: Vec, +/// A receipt paired with its detached witness signature. +/// +/// Per the spec, signatures are not part of the receipt body. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedReceipt { + /// The receipt body + pub receipt: Receipt, + /// Witness signature (externalized, not in body), hex-encoded for JSON + #[serde(with = "hex::serde")] + pub signature: Vec, } impl Receipt { @@ -93,100 +75,84 @@ impl Receipt { /// Check if this receipt is for the given event SAID. pub fn is_for_event(&self, event_said: &Said) -> bool { - self.a == *event_said + self.d == *event_said } - /// Check if this receipt is from the given witness. - pub fn is_from_witness(&self, witness_id: &str) -> bool { - self.i == witness_id + /// Check if this receipt is from the given controller. + pub fn is_for_controller(&self, controller_id: &str) -> bool { + self.i.as_str() == controller_id } +} - /// Formats this receipt as a Git trailer value (base64url-encoded JSON). +impl SignedReceipt { + /// Formats this signed receipt as a Git trailer value (base64url-encoded JSON). + /// + /// Encodes both the receipt body and the hex-encoded signature. pub fn to_trailer_value(&self) -> Result { - let json = serde_json::to_string(self)?; + // Wrap receipt + hex sig for trailer encoding + let wrapper = serde_json::json!({ + "receipt": serde_json::to_value(&self.receipt)?, + "sig": hex::encode(&self.signature), + }); + let json = serde_json::to_string(&wrapper)?; Ok(URL_SAFE_NO_PAD.encode(json.as_bytes())) } - /// Parses a receipt from a Git trailer value (base64url-encoded JSON). - /// - /// Strips all whitespace before decoding to handle RFC 822 line folding, - /// which may introduce spaces between base64url chunks during unfolding. + /// Parses a signed receipt from a Git trailer value (base64url-encoded JSON). pub fn from_trailer_value(value: &str) -> Result { let clean: String = value.split_whitespace().collect(); let bytes = URL_SAFE_NO_PAD .decode(&clean) .map_err(|e| format!("base64 decode failed: {}", e))?; - serde_json::from_slice(&bytes).map_err(|e| format!("json parse failed: {}", e)) - } - - /// Get the canonical JSON for signing (without the sig field). - /// - /// This produces the JSON that should be signed to create the receipt. - pub fn signing_payload(&self) -> Result, serde_json::Error> { - let payload = ReceiptSigningPayload { - v: &self.v, - t: &self.t, - d: &self.d, - i: &self.i, - s: self.s, - a: &self.a, - }; - serde_json::to_vec(&payload) + let wrapper: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|e| format!("json parse failed: {}", e))?; + let receipt: Receipt = serde_json::from_value( + wrapper + .get("receipt") + .cloned() + .ok_or("missing receipt field")?, + ) + .map_err(|e| format!("receipt parse failed: {}", e))?; + let sig_hex = wrapper + .get("sig") + .and_then(|v| v.as_str()) + .ok_or("missing sig field")?; + let signature = + hex::decode(sig_hex).map_err(|e| format!("sig hex decode failed: {}", e))?; + Ok(SignedReceipt { receipt, signature }) } } -/// Internal type for signing payload (excludes sig). -#[derive(Serialize)] -struct ReceiptSigningPayload<'a> { - v: &'a str, - t: &'a str, - d: &'a Said, - i: &'a str, - s: u64, - a: &'a Said, -} - -/// Builder for constructing receipts. +/// Builder for constructing signed receipts. #[derive(Debug, Default)] pub struct ReceiptBuilder { - v: Option, d: Option, - i: Option, - s: Option, - a: Option, + i: Option, + s: Option, sig: Option>, } impl ReceiptBuilder { - /// Create a new receipt builder with defaults. + /// Create a new receipt builder. pub fn new() -> Self { - Self { - v: Some(KERI_VERSION.into()), - ..Default::default() - } + Self::default() } - /// Set the receipt SAID. + /// Set the event SAID (the `d` field — SAID of referenced event, NOT the receipt). pub fn said(mut self, said: Said) -> Self { self.d = Some(said); self } - /// Set the witness identifier. + /// Set the controller AID (the `i` field). pub fn witness(mut self, witness_id: impl Into) -> Self { - self.i = Some(witness_id.into()); + self.i = Some(Prefix::new_unchecked(witness_id.into())); self } /// Set the event sequence number. pub fn sequence(mut self, seq: u64) -> Self { - self.s = Some(seq); - self - } - - /// Set the event SAID being receipted. - pub fn event_said(mut self, event_said: Said) -> Self { - self.a = Some(event_said); + self.s = Some(KeriSequence::new(seq)); self } @@ -196,18 +162,25 @@ impl ReceiptBuilder { self } - /// Build the receipt. + /// Build the signed receipt. /// /// Returns `None` if required fields are missing. - pub fn build(self) -> Option { - Some(Receipt { - v: self.v?, + /// Computes the version string with the actual serialized byte count. + pub fn build(self) -> Option { + let mut receipt = Receipt { + v: VersionString::placeholder(), t: RECEIPT_TYPE.into(), d: self.d?, i: self.i?, s: self.s?, - a: self.a?, - sig: self.sig?, + }; + // Compute actual serialized size and set v + if let Ok(bytes) = serde_json::to_vec(&receipt) { + receipt.v = VersionString::json(bytes.len() as u32); + } + Some(SignedReceipt { + receipt, + signature: self.sig?, }) } } @@ -219,13 +192,18 @@ mod tests { fn sample_receipt() -> Receipt { Receipt { - v: KERI_VERSION.into(), + v: VersionString::json(100), t: RECEIPT_TYPE.into(), - d: Said::new_unchecked("EReceipt123".into()), - i: "did:key:z6MkWitness".into(), - s: 5, - a: Said::new_unchecked("EEvent456".into()), - sig: vec![0xab; 64], + d: Said::new_unchecked("EEventSaid123".into()), + i: Prefix::new_unchecked("EControllerAid".into()), + s: KeriSequence::new(5), + } + } + + fn sample_signed_receipt() -> SignedReceipt { + SignedReceipt { + receipt: sample_receipt(), + signature: vec![0xab; 64], } } @@ -238,58 +216,48 @@ mod tests { } #[test] - fn receipt_sig_hex_encoded() { + fn receipt_body_has_no_sig_field() { let receipt = sample_receipt(); let json = serde_json::to_string(&receipt).unwrap(); - assert!(json.contains(&"ab".repeat(64))); + assert!(!json.contains("sig")); + assert!(!json.contains("\"a\"")); } #[test] fn receipt_is_for_event() { let receipt = sample_receipt(); - assert!(receipt.is_for_event(&Said::new_unchecked("EEvent456".into()))); + assert!(receipt.is_for_event(&Said::new_unchecked("EEventSaid123".into()))); assert!(!receipt.is_for_event(&Said::new_unchecked("EWrongEvent".into()))); } #[test] - fn receipt_is_from_witness() { + fn receipt_is_for_controller() { let receipt = sample_receipt(); - assert!(receipt.is_from_witness("did:key:z6MkWitness")); - assert!(!receipt.is_from_witness("did:key:z6MkOther")); - } - - #[test] - fn receipt_signing_payload() { - let receipt = sample_receipt(); - let payload = receipt.signing_payload().unwrap(); - let payload_str = String::from_utf8(payload).unwrap(); - - assert!(!payload_str.contains("sig")); - assert!(payload_str.contains("EReceipt123")); - assert!(payload_str.contains("did:key:z6MkWitness")); + assert!(receipt.is_for_controller("EControllerAid")); + assert!(!receipt.is_for_controller("EOtherAid")); } #[test] fn receipt_builder() { - let receipt = Receipt::builder() - .said(Said::new_unchecked("EReceipt123".into())) - .witness("did:key:z6MkWitness") + let signed = Receipt::builder() + .said(Said::new_unchecked("EEventSaid123".into())) + .witness("EControllerAid") .sequence(5) - .event_said(Said::new_unchecked("EEvent456".into())) .signature(vec![0u8; 64]) .build() .unwrap(); - assert_eq!(receipt.v, KERI_VERSION); - assert_eq!(receipt.t, RECEIPT_TYPE); - assert_eq!(receipt.d, "EReceipt123"); - assert_eq!(receipt.s, 5); + assert_eq!(signed.receipt.t, RECEIPT_TYPE); + assert_eq!(signed.receipt.d, "EEventSaid123"); + assert_eq!(signed.receipt.s.value(), 5); + // Version string should have non-zero size (computed by builder) + assert!(signed.receipt.v.size > 0); } #[test] fn receipt_builder_missing_fields() { let result = Receipt::builder() - .said(Said::new_unchecked("EReceipt123".into())) + .said(Said::new_unchecked("EEventSaid123".into())) .build(); assert!(result.is_none()); } @@ -299,23 +267,23 @@ mod tests { let receipt = sample_receipt(); let json: serde_json::Value = serde_json::to_value(&receipt).unwrap(); - assert_eq!(json["v"], KERI_VERSION); assert_eq!(json["t"], RECEIPT_TYPE); - assert_eq!(json["s"], 5); + assert!(json["v"].as_str().unwrap().starts_with("KERI10JSON")); + assert_eq!(json["s"], "5"); } #[test] - fn trailer_value_roundtrip() { - let receipt = sample_receipt(); - let encoded = receipt.to_trailer_value().unwrap(); - let decoded = Receipt::from_trailer_value(&encoded).unwrap(); - assert_eq!(receipt, decoded); + fn signed_receipt_trailer_value_roundtrip() { + let signed = sample_signed_receipt(); + let encoded = signed.to_trailer_value().unwrap(); + let decoded = SignedReceipt::from_trailer_value(&encoded).unwrap(); + assert_eq!(signed, decoded); } #[test] fn trailer_value_is_base64url() { - let receipt = sample_receipt(); - let encoded = receipt.to_trailer_value().unwrap(); + let signed = sample_signed_receipt(); + let encoded = signed.to_trailer_value().unwrap(); assert!(!encoded.contains('=')); assert!(!encoded.contains('+')); assert!(!encoded.contains('/')); @@ -323,14 +291,14 @@ mod tests { #[test] fn from_trailer_value_invalid_base64() { - let result = Receipt::from_trailer_value("not-valid-base64!!!"); + let result = SignedReceipt::from_trailer_value("not-valid-base64!!!"); assert!(result.is_err()); } #[test] fn from_trailer_value_invalid_json() { let encoded = B64.encode(b"not json"); - let result = Receipt::from_trailer_value(&encoded); + let result = SignedReceipt::from_trailer_value(&encoded); assert!(result.is_err()); } } diff --git a/crates/auths-mobile-ffi/src/lib.rs b/crates/auths-mobile-ffi/src/lib.rs index 6360a6c1..643415db 100644 --- a/crates/auths-mobile-ffi/src/lib.rs +++ b/crates/auths-mobile-ffi/src/lib.rs @@ -188,11 +188,30 @@ struct IcpEvent { // Internal Helpers // ============================================================================ -/// Compute SAID (Self-Addressing Identifier) using Blake3. -fn compute_said(event_json: &[u8]) -> String { - let hash = blake3::hash(event_json); - let encoded = URL_SAFE_NO_PAD.encode(hash.as_bytes()); - format!("E{}", encoded) +/// The 44-character `#` placeholder used in SAID computation (Trust over IP KERI v0.9). +const SAID_PLACEHOLDER: &str = "############################################"; + +/// Compute a spec-compliant SAID (Self-Addressing Identifier) using Blake3. +/// +/// Injects the 44-char `#` placeholder into `d` (and `i` for inception events), +/// removes `x`, serializes with insertion-order serde_json, then Blake3-256 hashes. +fn compute_said(event: &serde_json::Value) -> Option { + let mut obj = event.as_object()?.clone(); + obj.insert( + "d".to_string(), + serde_json::Value::String(SAID_PLACEHOLDER.to_string()), + ); + let event_type = obj.get("t").and_then(|v| v.as_str()).unwrap_or(""); + if event_type == "icp" { + obj.insert( + "i".to_string(), + serde_json::Value::String(SAID_PLACEHOLDER.to_string()), + ); + } + obj.remove("x"); + let serialized = serde_json::to_vec(&serde_json::Value::Object(obj)).ok()?; + let hash = blake3::hash(&serialized); + Some(format!("E{}", URL_SAFE_NO_PAD.encode(hash.as_bytes()))) } /// Compute next-key commitment (Blake3 hash of public key). @@ -204,16 +223,11 @@ fn compute_next_commitment(public_key: &[u8]) -> String { /// Finalize an ICP event by computing and setting the SAID. fn finalize_icp_event(mut icp: IcpEvent) -> Result { - // Serialize with empty d and i for SAID computation - icp.d = String::new(); - icp.i = String::new(); - icp.x = String::new(); - - let canonical = - serde_json::to_string(&icp).map_err(|e| MobileError::Serialization(e.to_string()))?; + let value = serde_json::to_value(&icp) + .map_err(|e| MobileError::Serialization(e.to_string()))?; - // Compute SAID - let said = compute_said(canonical.as_bytes()); + let said = compute_said(&value) + .ok_or_else(|| MobileError::Serialization("SAID computation failed".to_string()))?; // Set both d and i to the SAID (for inception, prefix = SAID) icp.d = said.clone(); diff --git a/crates/auths-radicle/src/identity.rs b/crates/auths-radicle/src/identity.rs index 457fdf81..b52f4a84 100644 --- a/crates/auths-radicle/src/identity.rs +++ b/crates/auths-radicle/src/identity.rs @@ -157,7 +157,7 @@ impl RadicleIdentityResolver { let mut keys = Vec::with_capacity(key_state.current_keys.len()); for key_str in &key_state.current_keys { - let keri_pk = auths_keri::KeriPublicKey::parse(key_str).map_err(|e| { + let keri_pk = auths_keri::KeriPublicKey::parse(key_str.as_str()).map_err(|e| { IdentityError::KelValidationFailed(format!("invalid CESR key: {e}")) })?; let public_key = PublicKey::try_from(keri_pk.into_bytes().as_slice()) @@ -738,7 +738,7 @@ impl DidResolver for RadicleIdentityResolver { .first() .ok_or_else(|| DidResolverError::Resolution("no signing keys in KEL".into()))?; - let keri_pk = auths_keri::KeriPublicKey::parse(cesr_key) + let keri_pk = auths_keri::KeriPublicKey::parse(cesr_key.as_str()) .map_err(|e| DidResolverError::Resolution(format!("invalid CESR key: {e}")))?; Ok(ResolvedDid::Keri { diff --git a/crates/auths-radicle/src/storage.rs b/crates/auths-radicle/src/storage.rs index b7534344..0aa2e479 100644 --- a/crates/auths-radicle/src/storage.rs +++ b/crates/auths-radicle/src/storage.rs @@ -359,6 +359,7 @@ mod tests { use auths_id::keri::KeriSequence; use auths_id::keri::event::{Event, IcpEvent}; use auths_id::keri::types::{Prefix, Said}; + use auths_keri::{CesrKey, Threshold, VersionString}; use git2::Signature; use std::str::FromStr; use tempfile::TempDir; @@ -371,16 +372,17 @@ mod tests { fn create_icp_event(prefix: &str) -> IcpEvent { IcpEvent { - v: "KERI10JSON".into(), + v: VersionString::placeholder(), d: Said::new_unchecked(prefix.to_string()), i: Prefix::new_unchecked(prefix.to_string()), s: KeriSequence::new(0), - kt: "1".into(), - k: vec!["DTestKey123".into()], - nt: "1".into(), - n: vec!["ENextCommitment".into()], - bt: "0".into(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked("DTestKey123".into())], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("ENextCommitment".into())], + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), } diff --git a/crates/auths-radicle/src/verify.rs b/crates/auths-radicle/src/verify.rs index 345e0fa2..35a54f66 100644 --- a/crates/auths-radicle/src/verify.rs +++ b/crates/auths-radicle/src/verify.rs @@ -570,15 +570,21 @@ mod tests { } fn make_key_state(prefix: &str, sequence: u64) -> KeyState { + use auths_keri::{CesrKey, Threshold}; KeyState { prefix: Prefix::new_unchecked(prefix.to_string()), sequence, - current_keys: vec!["DTestKey".to_string()], + current_keys: vec![CesrKey::new_unchecked("DTestKey".to_string())], next_commitment: vec![], last_event_said: Said::new_unchecked("ETestSaid".to_string()), is_abandoned: false, - threshold: 1, - next_threshold: 1, + threshold: Threshold::Simple(1), + next_threshold: Threshold::Simple(1), + backers: vec![], + backer_threshold: Threshold::Simple(0), + config_traits: vec![], + is_non_transferable: false, + delegator: None, } } diff --git a/crates/auths-radicle/tests/cases/helpers.rs b/crates/auths-radicle/tests/cases/helpers.rs index 4bf1e1ab..e8b04751 100644 --- a/crates/auths-radicle/tests/cases/helpers.rs +++ b/crates/auths-radicle/tests/cases/helpers.rs @@ -105,15 +105,21 @@ impl AuthsStorage for MockStorage { } pub fn make_key_state(prefix: &str, seq: u64) -> KeyState { + use auths_keri::{CesrKey, Threshold}; KeyState { prefix: Prefix::new_unchecked(prefix.to_string()), sequence: seq, - current_keys: vec!["DTestKey".to_string()], + current_keys: vec![CesrKey::new_unchecked("DTestKey".to_string())], next_commitment: vec![], last_event_said: Said::new_unchecked(format!("ESaid{seq}")), is_abandoned: false, - threshold: 1, - next_threshold: 1, + threshold: Threshold::Simple(1), + next_threshold: Threshold::Simple(1), + backers: vec![], + backer_threshold: Threshold::Simple(0), + config_traits: vec![], + is_non_transferable: false, + delegator: None, } } diff --git a/crates/auths-sdk/Cargo.toml b/crates/auths-sdk/Cargo.toml index 3e93608f..edbec7c4 100644 --- a/crates/auths-sdk/Cargo.toml +++ b/crates/auths-sdk/Cargo.toml @@ -67,6 +67,7 @@ flate2 = "1" git2.workspace = true tar = "0.4" tempfile = "3" +tokio = { workspace = true } [lints] workspace = true diff --git a/crates/auths-sdk/src/domains/identity/rotation.rs b/crates/auths-sdk/src/domains/identity/rotation.rs index e8365e84..fa7e8aea 100644 --- a/crates/auths-sdk/src/domains/identity/rotation.rs +++ b/crates/auths-sdk/src/domains/identity/rotation.rs @@ -19,7 +19,8 @@ use auths_id::identity::helpers::{ ManagedIdentity, encode_seed_as_pkcs8, extract_seed_bytes, load_keypair_from_der_or_seed, }; use auths_id::keri::{ - Event, KERI_VERSION, KeriSequence, KeyState, Prefix, RotEvent, Said, serialize_for_signing, + CesrKey, Event, KeriSequence, KeyState, Prefix, RotEvent, Said, Threshold, VersionString, + serialize_for_signing, }; use auths_id::ports::registry::RegistryBackend; use auths_id::witness_config::WitnessConfig; @@ -63,34 +64,41 @@ pub fn compute_rotation_event( ); let new_next_commitment = compute_next_commitment(new_next_keypair.public_key().as_ref()); - let (bt, b) = match witness_config { + let (bt, br, ba) = match witness_config { Some(cfg) if cfg.is_enabled() => ( - cfg.threshold.to_string(), - cfg.witness_urls.iter().map(|u| u.to_string()).collect(), + Threshold::Simple(cfg.threshold as u64), + vec![], + cfg.witness_urls + .iter() + .map(|u| Prefix::new_unchecked(u.to_string())) + .collect(), ), - _ => ("0".to_string(), vec![]), + _ => (Threshold::Simple(0), vec![], vec![]), }; let new_sequence = state.sequence + 1; let mut rot = RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(new_sequence), p: state.last_event_said.clone(), - kt: "1".to_string(), - k: vec![new_current_pub_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(new_current_pub_encoded)], + nt: Threshold::Simple(1), n: vec![new_next_commitment], bt, - b, + br, + ba, + c: vec![], a: vec![], x: String::new(), }; - let rot_json = serde_json::to_vec(&Event::Rot(rot.clone())) + let rot_value = serde_json::to_value(Event::Rot(rot.clone())) .map_err(|e| RotationError::RotationFailed(format!("serialization failed: {e}")))?; - rot.d = compute_said(&rot_json); + rot.d = compute_said(&rot_value) + .map_err(|e| RotationError::RotationFailed(format!("SAID computation failed: {e}")))?; let canonical = serialize_for_signing(&Event::Rot(rot.clone())) .map_err(|e| RotationError::RotationFailed(format!("serialize for signing failed: {e}")))?; @@ -642,8 +650,13 @@ mod tests { sequence: 999, last_event_said: Said::default(), is_abandoned: false, - threshold: 1, - next_threshold: 1, + threshold: Threshold::Simple(1), + next_threshold: Threshold::Simple(1), + backers: vec![], + backer_threshold: Threshold::Simple(0), + config_traits: vec![], + is_non_transferable: false, + delegator: None, }; let result = retrieve_precommitted_key( @@ -766,30 +779,37 @@ mod tests { let state = KeyState { prefix: prefix.clone(), - current_keys: vec!["D_key".to_string()], - next_commitment: vec!["hash".to_string()], + current_keys: vec![CesrKey::new_unchecked("D_key".to_string())], + next_commitment: vec![Said::new_unchecked("hash".to_string())], sequence: 0, last_event_said: Said::new_unchecked("EPrior".to_string()), is_abandoned: false, - threshold: 1, - next_threshold: 1, + threshold: Threshold::Simple(1), + next_threshold: Threshold::Simple(1), + backers: vec![], + backer_threshold: Threshold::Simple(0), + config_traits: vec![], + is_non_transferable: false, + delegator: None, }; let rng = SystemRandom::new(); let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); let dummy_rot = RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::new_unchecked("E_dummy".to_string()), i: prefix.clone(), s: KeriSequence::new(1), p: Said::default(), - kt: "1".to_string(), + kt: Threshold::Simple(1), k: vec![], - nt: "1".to_string(), + nt: Threshold::Simple(1), n: vec![], - bt: "0".to_string(), - b: vec![], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: String::new(), }; diff --git a/crates/auths-sdk/src/testing/fakes/agent_persistence.rs b/crates/auths-sdk/src/testing/fakes/agent_persistence.rs new file mode 100644 index 00000000..5328fff5 --- /dev/null +++ b/crates/auths-sdk/src/testing/fakes/agent_persistence.rs @@ -0,0 +1,93 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use crate::domains::agents::persistence::AgentPersistencePort; +use crate::domains::agents::types::{AgentSession, AgentStatus}; + +/// In-memory fake for [`AgentPersistencePort`], suitable for unit tests. +pub struct FakeAgentPersistence { + sessions: Mutex>, +} + +impl Default for FakeAgentPersistence { + fn default() -> Self { + Self::new() + } +} + +impl FakeAgentPersistence { + /// Create an empty in-memory agent persistence store. + pub fn new() -> Self { + Self { + sessions: Mutex::new(HashMap::new()), + } + } +} + +#[async_trait] +impl AgentPersistencePort for FakeAgentPersistence { + async fn set_session(&self, session: &AgentSession) -> Result<(), String> { + self.sessions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(session.agent_did.clone(), session.clone()); + Ok(()) + } + + async fn get_session(&self, agent_did: &str) -> Result, String> { + Ok(self + .sessions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(agent_did) + .cloned()) + } + + async fn delete_session(&self, agent_did: &str) -> Result<(), String> { + self.sessions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(agent_did); + Ok(()) + } + + async fn expire(&self, _agent_did: &str, _expires_at: DateTime) -> Result<(), String> { + Ok(()) + } + + async fn load_all(&self) -> Result, String> { + Ok(self + .sessions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .values() + .cloned() + .collect()) + } + + async fn find_by_delegator(&self, delegator_did: &str) -> Result, String> { + Ok(self + .sessions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .values() + .filter(|s| s.delegator_did.as_deref() == Some(delegator_did)) + .cloned() + .collect()) + } + + async fn revoke_agent(&self, agent_did: &str) -> Result<(), String> { + if let Some(session) = self + .sessions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get_mut(agent_did) + { + session.status = AgentStatus::Revoked; + } + Ok(()) + } +} diff --git a/crates/auths-sdk/src/testing/fakes/mod.rs b/crates/auths-sdk/src/testing/fakes/mod.rs index 84cf55d7..2450c998 100644 --- a/crates/auths-sdk/src/testing/fakes/mod.rs +++ b/crates/auths-sdk/src/testing/fakes/mod.rs @@ -1,4 +1,5 @@ mod agent; +mod agent_persistence; mod allowed_signers_store; mod artifact; mod diagnostics; @@ -8,6 +9,7 @@ mod namespace; mod signer; pub use agent::FakeAgentProvider; +pub use agent_persistence::FakeAgentPersistence; pub use allowed_signers_store::FakeAllowedSignersStore; pub use artifact::FakeArtifactSource; pub use diagnostics::{FakeCryptoDiagnosticProvider, FakeGitDiagnosticProvider}; diff --git a/crates/auths-sdk/src/workflows/rotation.rs b/crates/auths-sdk/src/workflows/rotation.rs index 6b98484a..08784c2a 100644 --- a/crates/auths-sdk/src/workflows/rotation.rs +++ b/crates/auths-sdk/src/workflows/rotation.rs @@ -19,7 +19,8 @@ use auths_id::identity::helpers::{ ManagedIdentity, encode_seed_as_pkcs8, extract_seed_bytes, load_keypair_from_der_or_seed, }; use auths_id::keri::{ - Event, KERI_VERSION, KeriSequence, KeyState, Prefix, RotEvent, Said, serialize_for_signing, + CesrKey, Event, KeriSequence, KeyState, Prefix, RotEvent, Said, Threshold, VersionString, + serialize_for_signing, }; use auths_id::ports::registry::RegistryBackend; use auths_id::witness_config::WitnessConfig; @@ -63,34 +64,41 @@ pub fn compute_rotation_event( ); let new_next_commitment = compute_next_commitment(new_next_keypair.public_key().as_ref()); - let (bt, b) = match witness_config { + let (bt, br, ba) = match witness_config { Some(cfg) if cfg.is_enabled() => ( - cfg.threshold.to_string(), - cfg.witness_urls.iter().map(|u| u.to_string()).collect(), + Threshold::Simple(cfg.threshold as u64), + vec![], + cfg.witness_urls + .iter() + .map(|u| Prefix::new_unchecked(u.to_string())) + .collect(), ), - _ => ("0".to_string(), vec![]), + _ => (Threshold::Simple(0), vec![], vec![]), }; let new_sequence = state.sequence + 1; let mut rot = RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(new_sequence), p: state.last_event_said.clone(), - kt: "1".to_string(), - k: vec![new_current_pub_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(new_current_pub_encoded)], + nt: Threshold::Simple(1), n: vec![new_next_commitment], bt, - b, + br, + ba, + c: vec![], a: vec![], x: String::new(), }; - let rot_json = serde_json::to_vec(&Event::Rot(rot.clone())) + let rot_value = serde_json::to_value(Event::Rot(rot.clone())) .map_err(|e| RotationError::RotationFailed(format!("serialization failed: {e}")))?; - rot.d = compute_said(&rot_json); + rot.d = compute_said(&rot_value) + .map_err(|e| RotationError::RotationFailed(format!("SAID computation failed: {e}")))?; let canonical = serialize_for_signing(&Event::Rot(rot.clone())) .map_err(|e| RotationError::RotationFailed(format!("serialize for signing failed: {e}")))?; @@ -641,8 +649,13 @@ mod tests { sequence: 999, last_event_said: Said::default(), is_abandoned: false, - threshold: 1, - next_threshold: 1, + threshold: Threshold::Simple(1), + next_threshold: Threshold::Simple(1), + backers: vec![], + backer_threshold: Threshold::Simple(0), + config_traits: vec![], + is_non_transferable: false, + delegator: None, }; let result = retrieve_precommitted_key( @@ -765,30 +778,37 @@ mod tests { let state = KeyState { prefix: prefix.clone(), - current_keys: vec!["D_key".to_string()], - next_commitment: vec!["hash".to_string()], + current_keys: vec![CesrKey::new_unchecked("D_key".to_string())], + next_commitment: vec![Said::new_unchecked("hash".to_string())], sequence: 0, last_event_said: Said::new_unchecked("EPrior".to_string()), is_abandoned: false, - threshold: 1, - next_threshold: 1, + threshold: Threshold::Simple(1), + next_threshold: Threshold::Simple(1), + backers: vec![], + backer_threshold: Threshold::Simple(0), + config_traits: vec![], + is_non_transferable: false, + delegator: None, }; let rng = SystemRandom::new(); let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); let dummy_rot = RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::new_unchecked("E_dummy".to_string()), i: prefix.clone(), s: KeriSequence::new(1), p: Said::default(), - kt: "1".to_string(), + kt: Threshold::Simple(1), k: vec![], - nt: "1".to_string(), + nt: Threshold::Simple(1), n: vec![], - bt: "0".to_string(), - b: vec![], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: String::new(), }; diff --git a/crates/auths-sdk/tests/cases/agents.rs b/crates/auths-sdk/tests/cases/agents.rs new file mode 100644 index 00000000..947c1ad0 --- /dev/null +++ b/crates/auths-sdk/tests/cases/agents.rs @@ -0,0 +1,307 @@ +use std::sync::Arc; + +use auths_sdk::domains::agents::registry::AgentRegistry; +use auths_sdk::domains::agents::service::AgentService; +use auths_sdk::domains::agents::types::{AgentSession, AgentStatus, ProvisionRequest}; +use auths_sdk::testing::fakes::FakeAgentPersistence; +use chrono::Utc; +use uuid::Uuid; + +fn make_service() -> AgentService { + let registry = Arc::new(AgentRegistry::new()); + let persistence = Arc::new(FakeAgentPersistence::new()); + AgentService::new(registry, persistence) +} + +fn make_service_with_registry(registry: Arc) -> AgentService { + let persistence = Arc::new(FakeAgentPersistence::new()); + AgentService::new(registry, persistence) +} + +// ── provision ─────────────────────────────────────────────────────────────── + +#[tokio::test] +#[allow(clippy::disallowed_methods)] +async fn provision_root_agent_succeeds() { + let service = make_service(); + let now = Utc::now(); + + let req = ProvisionRequest { + delegator_did: String::new(), + agent_name: "test-agent".into(), + capabilities: vec!["sign_commit".into()], + ttl_seconds: 3600, + max_delegation_depth: Some(1), + signature: String::new(), + timestamp: now, + }; + + let result = service.provision(req, now).await; + assert!(result.is_ok(), "provision failed: {:?}", result.err()); + + let resp = result.unwrap(); + assert!(resp.agent_did.starts_with("did:keri:")); + assert!(resp.bearer_token.is_some()); + assert!(resp.expires_at > now); +} + +#[tokio::test] +#[allow(clippy::disallowed_methods)] +async fn provision_rejects_large_clock_skew() { + let service = make_service(); + let now = Utc::now(); + + let req = ProvisionRequest { + delegator_did: String::new(), + agent_name: "test-agent".into(), + capabilities: vec!["sign_commit".into()], + ttl_seconds: 3600, + max_delegation_depth: None, + signature: String::new(), + timestamp: now - chrono::Duration::seconds(600), // 10 min ago + }; + + let result = service.provision(req, now).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Clock skew")); +} + +#[tokio::test] +#[allow(clippy::disallowed_methods)] +async fn provision_delegated_agent_fails_when_delegator_not_found() { + let service = make_service(); + let now = Utc::now(); + + let req = ProvisionRequest { + delegator_did: "did:keri:ENotInRegistry".into(), + agent_name: "child-agent".into(), + capabilities: vec!["read".into()], + ttl_seconds: 1800, + max_delegation_depth: None, + signature: String::new(), + timestamp: now, + }; + + let result = service.provision(req, now).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Delegator not found")); +} + +// ── authorize ─────────────────────────────────────────────────────────────── + +#[test] +#[allow(clippy::disallowed_methods)] +fn authorize_grants_matching_capability() { + let registry = Arc::new(AgentRegistry::new()); + let now = Utc::now(); + + let session = AgentSession { + session_id: Uuid::new_v4(), + agent_did: "did:keri:EAgent001".into(), + agent_name: "test-agent".into(), + delegator_did: None, + capabilities: vec!["sign_commit".into(), "sign_release".into()], + status: AgentStatus::Active, + created_at: now, + expires_at: now + chrono::Duration::hours(1), + delegation_depth: 0, + max_delegation_depth: 0, + }; + registry.insert(session); + + let service = make_service_with_registry(registry); + let result = service.authorize("did:keri:EAgent001", "sign_commit", now, now); + + assert!(result.is_ok()); + let resp = result.unwrap(); + assert!(resp.authorized); + assert!(resp.matched_capabilities.contains(&"sign_commit".into())); +} + +#[test] +#[allow(clippy::disallowed_methods)] +fn authorize_grants_wildcard_capability() { + let registry = Arc::new(AgentRegistry::new()); + let now = Utc::now(); + + let session = AgentSession { + session_id: Uuid::new_v4(), + agent_did: "did:keri:EWildcard".into(), + agent_name: "super-agent".into(), + delegator_did: None, + capabilities: vec!["*".into()], + status: AgentStatus::Active, + created_at: now, + expires_at: now + chrono::Duration::hours(1), + delegation_depth: 0, + max_delegation_depth: 0, + }; + registry.insert(session); + + let service = make_service_with_registry(registry); + let result = service.authorize("did:keri:EWildcard", "anything_at_all", now, now); + + assert!(result.is_ok()); + assert!(result.unwrap().authorized); +} + +#[test] +#[allow(clippy::disallowed_methods)] +fn authorize_denies_unmatched_capability() { + let registry = Arc::new(AgentRegistry::new()); + let now = Utc::now(); + + let session = AgentSession { + session_id: Uuid::new_v4(), + agent_did: "did:keri:ELimited".into(), + agent_name: "limited-agent".into(), + delegator_did: None, + capabilities: vec!["sign_commit".into()], + status: AgentStatus::Active, + created_at: now, + expires_at: now + chrono::Duration::hours(1), + delegation_depth: 0, + max_delegation_depth: 0, + }; + registry.insert(session); + + let service = make_service_with_registry(registry); + let result = service.authorize("did:keri:ELimited", "manage_members", now, now); + + assert!(result.is_ok()); + assert!(!result.unwrap().authorized); +} + +#[test] +#[allow(clippy::disallowed_methods)] +fn authorize_rejects_unknown_agent() { + let service = make_service(); + let now = Utc::now(); + + let result = service.authorize("did:keri:ENonexistent", "sign_commit", now, now); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); +} + +#[test] +#[allow(clippy::disallowed_methods)] +fn authorize_rejects_large_clock_skew() { + let service = make_service(); + let now = Utc::now(); + let stale = now - chrono::Duration::seconds(600); + + let result = service.authorize("did:keri:EAgent001", "sign_commit", now, stale); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Clock skew")); +} + +#[test] +#[allow(clippy::disallowed_methods)] +fn authorize_rejects_revoked_agent() { + let registry = Arc::new(AgentRegistry::new()); + let now = Utc::now(); + + let session = AgentSession { + session_id: Uuid::new_v4(), + agent_did: "did:keri:ERevoked".into(), + agent_name: "revoked-agent".into(), + delegator_did: None, + capabilities: vec!["sign_commit".into()], + status: AgentStatus::Revoked, + created_at: now, + expires_at: now + chrono::Duration::hours(1), + delegation_depth: 0, + max_delegation_depth: 0, + }; + registry.insert(session); + + let service = make_service_with_registry(registry); + let result = service.authorize("did:keri:ERevoked", "sign_commit", now, now); + + // Revoked agents should not be found by registry.get() (which filters by is_active) + assert!(result.is_err()); +} + +// ── revoke ────────────────────────────────────────────────────────────────── + +#[tokio::test] +#[allow(clippy::disallowed_methods)] +async fn revoke_marks_agent_as_revoked() { + let registry = Arc::new(AgentRegistry::new()); + let now = Utc::now(); + + let session = AgentSession { + session_id: Uuid::new_v4(), + agent_did: "did:keri:EToRevoke".into(), + agent_name: "doomed-agent".into(), + delegator_did: None, + capabilities: vec!["sign_commit".into()], + status: AgentStatus::Active, + created_at: now, + expires_at: now + chrono::Duration::hours(1), + delegation_depth: 0, + max_delegation_depth: 0, + }; + registry.insert(session); + + let service = make_service_with_registry(registry.clone()); + let result = service.revoke("did:keri:EToRevoke", now).await; + assert!(result.is_ok()); + + // Agent should no longer be findable (revoked) + assert!(registry.get("did:keri:EToRevoke", now).is_none()); +} + +#[tokio::test] +#[allow(clippy::disallowed_methods)] +async fn revoke_cascades_to_children() { + let registry = Arc::new(AgentRegistry::new()); + let now = Utc::now(); + + // Insert parent + registry.insert(AgentSession { + session_id: Uuid::new_v4(), + agent_did: "did:keri:EParent".into(), + agent_name: "parent".into(), + delegator_did: None, + capabilities: vec!["*".into()], + status: AgentStatus::Active, + created_at: now, + expires_at: now + chrono::Duration::hours(1), + delegation_depth: 0, + max_delegation_depth: 2, + }); + + // Insert child delegated by parent + registry.insert(AgentSession { + session_id: Uuid::new_v4(), + agent_did: "did:keri:EChild".into(), + agent_name: "child".into(), + delegator_did: Some("did:keri:EParent".into()), + capabilities: vec!["sign_commit".into()], + status: AgentStatus::Active, + created_at: now, + expires_at: now + chrono::Duration::hours(1), + delegation_depth: 1, + max_delegation_depth: 0, + }); + + let service = make_service_with_registry(registry.clone()); + let result = service.revoke("did:keri:EParent", now).await; + assert!(result.is_ok()); + + // Both parent and child should be revoked + assert!(registry.get("did:keri:EParent", now).is_none()); + assert!(registry.get("did:keri:EChild", now).is_none()); +} + +#[tokio::test] +#[allow(clippy::disallowed_methods)] +async fn revoke_nonexistent_agent_returns_error() { + let service = make_service(); + let now = Utc::now(); + + let result = service.revoke("did:keri:EGhost", now).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); +} diff --git a/crates/auths-sdk/tests/cases/mod.rs b/crates/auths-sdk/tests/cases/mod.rs index b345dfa5..791bac5f 100644 --- a/crates/auths-sdk/tests/cases/mod.rs +++ b/crates/auths-sdk/tests/cases/mod.rs @@ -1,3 +1,4 @@ +mod agents; mod allowed_signers; mod artifact; mod audit; diff --git a/crates/auths-sdk/tests/cases/org.rs b/crates/auths-sdk/tests/cases/org.rs index 036320e8..d37c57f2 100644 --- a/crates/auths-sdk/tests/cases/org.rs +++ b/crates/auths-sdk/tests/cases/org.rs @@ -7,8 +7,10 @@ use auths_id::testing::fakes::FakeRegistryBackend; use auths_sdk::domains::org::error::OrgError; use auths_sdk::testing::fakes::FakeSecureSigner; use auths_sdk::workflows::org::{ - AddMemberCommand, OrgContext, RevokeMemberCommand, Role, UpdateCapabilitiesCommand, - add_organization_member, revoke_organization_member, update_member_capabilities, + AddMemberCommand, OrgContext, OrgIdentifier, RevokeMemberCommand, Role, + UpdateCapabilitiesCommand, UpdateMemberCommand, add_organization_member, + get_organization_member, member_role_order, revoke_organization_member, + update_member_capabilities, update_organization_member, }; use auths_verifier::AttestationBuilder; use auths_verifier::Capability; @@ -570,3 +572,242 @@ fn update_capabilities_fails_with_invalid_capability() { assert!(matches!(result, Err(OrgError::InvalidCapability { .. }))); } + +// ── get_organization_member ───────────────────────────────────────────────── + +#[test] +fn get_member_returns_attestation_when_exists() { + let backend = FakeRegistryBackend::new(); + seed_admin(&backend); + seed_member(&backend); + + let result = get_organization_member(&backend, ORG, MEMBER_DID); + assert!(result.is_ok(), "unexpected error: {:?}", result.err()); + let att = result.unwrap(); + assert_eq!(att.subject.as_str(), MEMBER_DID); +} + +#[test] +fn get_member_returns_not_found_when_missing() { + let backend = FakeRegistryBackend::new(); + seed_admin(&backend); + + let result = get_organization_member(&backend, ORG, "did:key:z6MkNonexistent"); + assert!(matches!(result, Err(OrgError::MemberNotFound { .. }))); +} + +#[test] +fn get_member_returns_admin_too() { + let backend = FakeRegistryBackend::new(); + seed_admin(&backend); + + let result = get_organization_member(&backend, ORG, ADMIN_DID); + assert!(result.is_ok()); + let att = result.unwrap(); + assert_eq!(att.subject.as_str(), ADMIN_DID); +} + +// ── update_organization_member ────────────────────────────────────────────── + +#[test] +fn update_member_changes_role() { + let backend = FakeRegistryBackend::new(); + seed_admin(&backend); + seed_member(&backend); + + let result = update_organization_member( + &backend, + &MockClock(chrono::Utc::now()), + UpdateMemberCommand { + org_prefix: ORG.to_string(), + member_did: MEMBER_DID.to_string(), + role: Some(Role::Readonly), + capabilities: None, + admin_public_key_hex: admin_pubkey_hex(), + }, + ); + + assert!(result.is_ok(), "unexpected error: {:?}", result.err()); + let att = result.unwrap(); + assert_eq!(att.role, Some(Role::Readonly)); + assert!(att.capabilities.contains(&Capability::sign_commit())); +} + +#[test] +fn update_member_changes_capabilities() { + let backend = FakeRegistryBackend::new(); + seed_admin(&backend); + seed_member(&backend); + + let result = update_organization_member( + &backend, + &MockClock(chrono::Utc::now()), + UpdateMemberCommand { + org_prefix: ORG.to_string(), + member_did: MEMBER_DID.to_string(), + role: None, + capabilities: Some(vec!["sign_commit".to_string(), "sign_release".to_string()]), + admin_public_key_hex: admin_pubkey_hex(), + }, + ); + + assert!(result.is_ok(), "unexpected error: {:?}", result.err()); + let att = result.unwrap(); + assert_eq!(att.role, Some(Role::Member)); + assert_eq!(att.capabilities.len(), 2); + assert!(att.capabilities.contains(&Capability::sign_release())); +} + +#[test] +fn update_member_changes_both_role_and_capabilities() { + let backend = FakeRegistryBackend::new(); + seed_admin(&backend); + seed_member(&backend); + + let result = update_organization_member( + &backend, + &MockClock(chrono::Utc::now()), + UpdateMemberCommand { + org_prefix: ORG.to_string(), + member_did: MEMBER_DID.to_string(), + role: Some(Role::Admin), + capabilities: Some(vec![ + "sign_commit".to_string(), + "manage_members".to_string(), + ]), + admin_public_key_hex: admin_pubkey_hex(), + }, + ); + + assert!(result.is_ok(), "unexpected error: {:?}", result.err()); + let att = result.unwrap(); + assert_eq!(att.role, Some(Role::Admin)); + assert!(att.capabilities.contains(&Capability::manage_members())); +} + +#[test] +fn update_member_fails_when_admin_not_found() { + let backend = FakeRegistryBackend::new(); + seed_member(&backend); + + let result = update_organization_member( + &backend, + &MockClock(chrono::Utc::now()), + UpdateMemberCommand { + org_prefix: ORG.to_string(), + member_did: MEMBER_DID.to_string(), + role: Some(Role::Readonly), + capabilities: None, + admin_public_key_hex: admin_pubkey_hex(), + }, + ); + + assert!(matches!(result, Err(OrgError::AdminNotFound { .. }))); +} + +#[test] +fn update_member_fails_when_member_not_found() { + let backend = FakeRegistryBackend::new(); + seed_admin(&backend); + + let result = update_organization_member( + &backend, + &MockClock(chrono::Utc::now()), + UpdateMemberCommand { + org_prefix: ORG.to_string(), + member_did: "did:key:z6MkNonexistent".to_string(), + role: Some(Role::Readonly), + capabilities: None, + admin_public_key_hex: admin_pubkey_hex(), + }, + ); + + assert!(matches!(result, Err(OrgError::MemberNotFound { .. }))); +} + +#[test] +fn update_member_fails_when_already_revoked() { + let backend = FakeRegistryBackend::new(); + seed_admin(&backend); + + let mut att = base_member_attestation(); + att.revoked_at = Some(chrono::Utc::now()); + backend + .store_org_member(ORG, &att) + .expect("seed revoked member"); + + let result = update_organization_member( + &backend, + &MockClock(chrono::Utc::now()), + UpdateMemberCommand { + org_prefix: ORG.to_string(), + member_did: MEMBER_DID.to_string(), + role: Some(Role::Admin), + capabilities: None, + admin_public_key_hex: admin_pubkey_hex(), + }, + ); + + assert!(matches!(result, Err(OrgError::AlreadyRevoked { .. }))); +} + +#[test] +fn update_member_fails_with_invalid_capability() { + let backend = FakeRegistryBackend::new(); + seed_admin(&backend); + seed_member(&backend); + + let result = update_organization_member( + &backend, + &MockClock(chrono::Utc::now()), + UpdateMemberCommand { + org_prefix: ORG.to_string(), + member_did: MEMBER_DID.to_string(), + role: None, + capabilities: Some(vec!["not a valid cap!!!".to_string()]), + admin_public_key_hex: admin_pubkey_hex(), + }, + ); + + assert!(matches!(result, Err(OrgError::InvalidCapability { .. }))); +} + +// ── member_role_order ─────────────────────────────────────────────────────── + +#[test] +fn role_order_admin_before_member_before_readonly_before_none() { + assert!(member_role_order(&Some(Role::Admin)) < member_role_order(&Some(Role::Member))); + assert!(member_role_order(&Some(Role::Member)) < member_role_order(&Some(Role::Readonly))); + assert!(member_role_order(&Some(Role::Readonly)) < member_role_order(&None)); +} + +// ── OrgIdentifier ─────────────────────────────────────────────────────────── + +#[test] +fn org_identifier_parse_bare_prefix() { + let id = OrgIdentifier::parse("EOrg1234567890"); + assert!(matches!(id, OrgIdentifier::Prefix(_))); + assert_eq!(id.prefix(), "EOrg1234567890"); +} + +#[test] +fn org_identifier_parse_full_did() { + let id = OrgIdentifier::parse("did:keri:EOrg1234567890"); + assert!(matches!(id, OrgIdentifier::Did(_))); + assert_eq!(id.prefix(), "EOrg1234567890"); +} + +#[test] +fn org_identifier_from_str_delegates_to_parse() { + let id: OrgIdentifier = "did:keri:EOrg1234567890".into(); + assert_eq!(id.prefix(), "EOrg1234567890"); + + let id2: OrgIdentifier = "EOrg1234567890".into(); + assert_eq!(id2.prefix(), "EOrg1234567890"); +} + +#[test] +fn org_identifier_non_keri_did_falls_back() { + let id = OrgIdentifier::parse("did:web:example.com"); + assert_eq!(id.prefix(), "did:web:example.com"); +} diff --git a/crates/auths-sdk/tests/cases/rotation.rs b/crates/auths-sdk/tests/cases/rotation.rs index 2f218457..02c5bb16 100644 --- a/crates/auths-sdk/tests/cases/rotation.rs +++ b/crates/auths-sdk/tests/cases/rotation.rs @@ -7,7 +7,7 @@ use auths_core::signing::{PassphraseProvider, StorageSigner}; use auths_core::storage::keychain::KeyStorage; use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyRole}; use auths_core::storage::memory::{MEMORY_KEYCHAIN, MemoryKeychainHandle}; -use auths_id::keri::{KeyState, Prefix, Said}; +use auths_id::keri::{CesrKey, KeyState, Prefix, Said, Threshold, VersionString}; use auths_id::ports::registry::RegistryBackend; use auths_id::testing::fakes::FakeRegistryBackend; use auths_sdk::domains::identity::error::RotationError; @@ -201,13 +201,18 @@ fn compute_rotation_event_is_deterministic() { let state = KeyState { prefix: Prefix::new_unchecked("test_prefix_determinism".to_string()), - current_keys: vec!["D_testkey_placeholder".to_string()], - next_commitment: vec!["hash_placeholder".to_string()], + current_keys: vec![CesrKey::new_unchecked("D_testkey_placeholder".to_string())], + next_commitment: vec![Said::new_unchecked("hash_placeholder".to_string())], sequence: 0, last_event_said: Said::new_unchecked("E_prior_said_placeholder".to_string()), is_abandoned: false, - threshold: 1, - next_threshold: 1, + threshold: Threshold::Simple(1), + next_threshold: Threshold::Simple(1), + backers: vec![], + backer_threshold: Threshold::Simple(0), + config_traits: vec![], + is_non_transferable: false, + delegator: None, }; let kp1 = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); @@ -245,13 +250,18 @@ fn apply_rotation_returns_partial_rotation_on_keychain_failure() { // Build a KeyState at sequence 0 so compute_rotation_event produces a seq-1 event let state = KeyState { prefix: prefix.clone(), - current_keys: vec!["D_placeholder".to_string()], - next_commitment: vec!["hash_placeholder".to_string()], + current_keys: vec![CesrKey::new_unchecked("D_placeholder".to_string())], + next_commitment: vec![Said::new_unchecked("hash_placeholder".to_string())], sequence: 0, last_event_said: Said::new_unchecked("E_placeholder_said".to_string()), is_abandoned: false, - threshold: 1, - next_threshold: 1, + threshold: Threshold::Simple(1), + next_threshold: Threshold::Simple(1), + backers: vec![], + backer_threshold: Threshold::Simple(0), + config_traits: vec![], + is_non_transferable: false, + delegator: None, }; let (rot, _bytes) = compute_rotation_event(&state, &next_kp, &new_next_kp, None).unwrap(); @@ -259,17 +269,19 @@ fn apply_rotation_returns_partial_rotation_on_keychain_failure() { // Pre-seed the registry with a fake event at seq 0 so the seq-1 RotEvent is accepted let registry = Arc::new(FakeRegistryBackend::new()); let dummy_rot = auths_id::keri::RotEvent { - v: auths_id::keri::KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::new_unchecked("E_dummy".to_string()), i: prefix.clone(), s: auths_id::keri::KeriSequence::new(0), p: Said::default(), - kt: "1".to_string(), + kt: Threshold::Simple(1), k: vec![], - nt: "1".to_string(), + nt: Threshold::Simple(1), n: vec![], - bt: "0".to_string(), - b: vec![], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: String::new(), }; diff --git a/crates/auths-storage/benches/registry.rs b/crates/auths-storage/benches/registry.rs index 7ea7a6ea..aa37d3d5 100644 --- a/crates/auths-storage/benches/registry.rs +++ b/crates/auths-storage/benches/registry.rs @@ -9,6 +9,7 @@ use auths_id::keri::seal::Seal; use auths_id::keri::types::{Prefix, Said}; use auths_id::keri::validate::{finalize_icp_event, serialize_for_signing}; use auths_id::storage::registry::backend::RegistryBackend; +use auths_keri::{CesrKey, Threshold, VersionString}; use auths_storage::git::{GitRegistryBackend, RegistryConfig}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -30,16 +31,17 @@ fn make_signed_icp() -> (Event, Prefix, Ed25519KeyPair) { let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref()); let icp = IcpEvent { - v: "KERI10JSON".to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], - bt: "0".to_string(), + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; @@ -56,17 +58,17 @@ fn make_signed_icp() -> (Event, Prefix, Ed25519KeyPair) { /// Create a signed IXN event. fn make_signed_ixn(prefix: &Prefix, seq: u64, prev_said: &str, keypair: &Ed25519KeyPair) -> Event { let mut ixn = IxnEvent { - v: "KERI10JSON".to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(seq), p: Said::new_unchecked(prev_said.to_string()), - a: vec![Seal::device_attestation("EBench")], + a: vec![Seal::digest("EBench")], x: String::new(), }; - let json = serde_json::to_vec(&Event::Ixn(ixn.clone())).unwrap(); - ixn.d = compute_said(&json); + let value = serde_json::to_value(Event::Ixn(ixn.clone())).unwrap(); + ixn.d = compute_said(&value).unwrap(); let canonical = serialize_for_signing(&Event::Ixn(ixn.clone())).unwrap(); let sig = keypair.sign(&canonical); diff --git a/crates/auths-storage/src/git/adapter.rs b/crates/auths-storage/src/git/adapter.rs index 051a1d76..abc41778 100644 --- a/crates/auths-storage/src/git/adapter.rs +++ b/crates/auths-storage/src/git/adapter.rs @@ -464,32 +464,33 @@ impl GitRegistryBackend { event: &Event, ) -> Result { match event { - Event::Icp(icp) => { - let threshold = icp.kt.parse::().unwrap_or(1); - let next_threshold = icp.nt.parse::().unwrap_or(1); - Ok(KeyState::from_inception( - icp.i.clone(), - icp.k.clone(), - icp.n.clone(), - threshold, - next_threshold, - icp.d.clone(), - )) - } + Event::Icp(icp) => Ok(KeyState::from_inception( + icp.i.clone(), + icp.k.clone(), + icp.n.clone(), + icp.kt.clone(), + icp.nt.clone(), + icp.d.clone(), + icp.b.clone(), + icp.bt.clone(), + icp.c.clone(), + )), Event::Rot(rot) => { let mut state = current_state.cloned().ok_or_else(|| { RegistryError::Internal("Rotation without prior state".into()) })?; let seq = event.sequence().value(); - let threshold = rot.kt.parse::().unwrap_or(1); - let next_threshold = rot.nt.parse::().unwrap_or(1); state.apply_rotation( rot.k.clone(), rot.n.clone(), - threshold, - next_threshold, + rot.kt.clone(), + rot.nt.clone(), seq, rot.d.clone(), + &rot.br, + &rot.ba, + rot.bt.clone(), + rot.c.clone(), ); Ok(state) } @@ -501,6 +502,20 @@ impl GitRegistryBackend { state.apply_interaction(seq, ixn.d.clone()); Ok(state) } + Event::Dip(dip) => Ok(KeyState::from_inception( + dip.i.clone(), + dip.k.clone(), + dip.n.clone(), + dip.kt.clone(), + dip.nt.clone(), + dip.d.clone(), + dip.b.clone(), + dip.bt.clone(), + dip.c.clone(), + )), + Event::Drt(_) => Err(RegistryError::Internal( + "Delegated rotation not yet supported".into(), + )), } } @@ -647,7 +662,11 @@ impl GitRegistryBackend { if let Some(index) = &self.index { let indexed = auths_index::IndexedIdentity { prefix: auths_keri::Prefix::new_unchecked(prefix_str.clone()), - current_keys: state.current_keys.clone(), + current_keys: state + .current_keys + .iter() + .map(|k| k.as_str().to_string()) + .collect(), sequence: state.sequence, tip_said: state.last_event_said.clone(), updated_at: self.clock.now(), @@ -948,7 +967,11 @@ impl RegistryBackend for GitRegistryBackend { if let Some(index) = &self.index { let indexed = auths_index::IndexedIdentity { prefix: prefix.clone(), - current_keys: new_state.current_keys.clone(), + current_keys: new_state + .current_keys + .iter() + .map(|k| k.as_str().to_string()) + .collect(), sequence: new_state.sequence, tip_said: event.said().clone(), updated_at: self.clock.now(), @@ -1766,7 +1789,11 @@ pub fn rebuild_identities_from_registry( Ok(state) => { let indexed = IndexedIdentity { prefix: state.prefix.clone(), - current_keys: state.current_keys.clone(), + current_keys: state + .current_keys + .iter() + .map(|k| k.as_str().to_string()) + .collect(), sequence: state.sequence, tip_said: state.last_event_said.clone(), updated_at: backend.clock.now(), @@ -2099,8 +2126,8 @@ mod tests { use auths_id::keri::seal::Seal; use auths_id::keri::types::{Prefix, Said}; use auths_id::keri::validate::{compute_event_said, finalize_icp_event, serialize_for_signing}; - use auths_keri::KERI_VERSION; use auths_keri::compute_next_commitment; + use auths_keri::{CesrKey, Threshold, VersionString}; use auths_verifier::AttestationBuilder; use auths_verifier::core::{Ed25519PublicKey, Role}; use base64::Engine; @@ -2131,16 +2158,17 @@ mod tests { let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref()); let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], - bt: "0".to_string(), + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; @@ -2173,17 +2201,19 @@ mod tests { let nn_commitment = compute_next_commitment(nn_keypair.public_key().as_ref()); let mut rot = RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(seq), p: Said::new_unchecked(prev_said.to_string()), - kt: "1".to_string(), - k: vec![new_key_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(new_key_encoded)], + nt: Threshold::Simple(1), n: vec![nn_commitment], - bt: "0".to_string(), - b: vec![], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: String::new(), }; @@ -2205,12 +2235,12 @@ mod tests { keypair: &Ed25519KeyPair, ) -> Event { let mut ixn = IxnEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(seq), p: Said::new_unchecked(prev_said.to_string()), - a: vec![Seal::device_attestation("ETest")], + a: vec![Seal::digest("ETest")], x: String::new(), }; @@ -2226,16 +2256,17 @@ mod tests { /// Create an unsigned ICP event (for tests that check pre-crypto constraints). fn create_unsigned_icp(key: &str, next: &str) -> (Event, Prefix) { let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key.to_string()], - nt: "1".to_string(), - n: vec![next.to_string()], - bt: "0".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key.to_string())], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked(next.to_string())], + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; @@ -2307,17 +2338,19 @@ mod tests { let next_commit = compute_next_commitment(kp.public_key().as_ref()); let mut rot = RotEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(1), p: Said::new_unchecked("EPrev".to_string()), - kt: "1".to_string(), - k: vec![key_enc], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_enc)], + nt: Threshold::Simple(1), n: vec![next_commit], - bt: "0".to_string(), - b: vec![], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: String::new(), }; @@ -2354,12 +2387,12 @@ mod tests { // Create an IXN event with seq 0 — rejected before crypto check let mut ixn = IxnEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(0), p: Said::new_unchecked("EPrev".to_string()), - a: vec![Seal::device_attestation("ETest")], + a: vec![Seal::digest("ETest")], x: String::new(), }; let event = Event::Ixn(ixn.clone()); @@ -2392,16 +2425,17 @@ mod tests { // because for ICP, i == d. So we use the tampered SAID as the prefix. let tampered_said = "ETamperedSaid1234567890"; let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::new_unchecked(tampered_said.to_string()), i: Prefix::new_unchecked(tampered_said.to_string()), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec!["DKey1".to_string()], - nt: "1".to_string(), - n: vec!["ENext1".to_string()], - bt: "0".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked("DKey1".to_string())], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("ENext1".to_string())], + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; @@ -3616,13 +3650,20 @@ mod tests { // Build a modified state: same tip SAID, different current_keys let modified_state = KeyState { prefix: prefix.clone(), - current_keys: vec!["DModifiedKey123456789012345678901234".to_string()], + current_keys: vec![CesrKey::new_unchecked( + "DModifiedKey123456789012345678901234".to_string(), + )], next_commitment: original_state.next_commitment.clone(), sequence: original_state.sequence, last_event_said: original_state.last_event_said.clone(), is_abandoned: false, - threshold: 1, - next_threshold: 1, + threshold: Threshold::Simple(1), + next_threshold: Threshold::Simple(1), + backers: vec![], + backer_threshold: Threshold::Simple(0), + config_traits: vec![], + is_non_transferable: false, + delegator: None, }; backend.write_key_state(&prefix, &modified_state).unwrap(); @@ -3630,7 +3671,7 @@ mod tests { // get_key_state must return the overwritten state (not replay the KEL) let retrieved = backend.get_key_state(&prefix).unwrap(); assert_eq!( - retrieved.current_keys[0], + retrieved.current_keys[0].as_str(), "DModifiedKey123456789012345678901234" ); assert_eq!(retrieved.sequence, original_state.sequence); @@ -3644,11 +3685,14 @@ mod tests { let prefix = Prefix::new_unchecked("EXq5Test1234".to_string()); let state = KeyState::from_inception( prefix.clone(), - vec!["DKey1".to_string()], - vec!["ENext1".to_string()], - 1, - 1, + vec![CesrKey::new_unchecked("DKey1".to_string())], + vec![Said::new_unchecked("ENext1".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), Said::new_unchecked("ESAID12345".to_string()), + vec![], + Threshold::Simple(0), + vec![], ); let result = backend.write_key_state(&prefix, &state); @@ -3668,8 +3712,8 @@ mod index_consistency_tests { use auths_id::keri::types::{Prefix, Said}; use auths_id::keri::validate::{finalize_icp_event, serialize_for_signing}; use auths_id::storage::registry::org_member::MemberFilter; - use auths_keri::KERI_VERSION; use auths_keri::compute_next_commitment; + use auths_keri::{CesrKey, Threshold, VersionString}; use auths_verifier::core::{Ed25519PublicKey, Ed25519Signature, ResourceId}; use auths_verifier::types::CanonicalDid; use base64::Engine; @@ -3698,16 +3742,17 @@ mod index_consistency_tests { let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref()); let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], - bt: "0".to_string(), + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; @@ -3922,8 +3967,8 @@ mod tenant_isolation_tests { use auths_id::keri::event::{IcpEvent, KeriSequence}; use auths_id::keri::types::{Prefix, Said}; use auths_id::keri::validate::{finalize_icp_event, serialize_for_signing}; - use auths_keri::KERI_VERSION; use auths_keri::compute_next_commitment; + use auths_keri::{CesrKey, Threshold, VersionString}; use super::*; use auths_id::storage::registry::backend::TenantIdError; @@ -3955,16 +4000,17 @@ mod tenant_isolation_tests { let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref()); let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], - bt: "0".to_string(), + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; diff --git a/crates/auths-storage/src/git/identity_adapter.rs b/crates/auths-storage/src/git/identity_adapter.rs index 387e7c12..32826014 100644 --- a/crates/auths-storage/src/git/identity_adapter.rs +++ b/crates/auths-storage/src/git/identity_adapter.rs @@ -109,10 +109,11 @@ impl RegistryIdentityStorage { witness_config: Option<&auths_id::witness_config::WitnessConfig>, ) -> Result<(String, auths_id::keri::inception::InceptionResult), InitError> { use auths_id::keri::{ - Event, IcpEvent, InceptionResult, KERI_VERSION, KeriSequence, Prefix, Said, - finalize_icp_event, serialize_for_signing, + Event, IcpEvent, InceptionResult, KeriSequence, Prefix, Said, finalize_icp_event, + serialize_for_signing, }; use auths_keri::compute_next_commitment; + use auths_keri::{CesrKey, Threshold, VersionString}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; @@ -146,24 +147,28 @@ impl RegistryIdentityStorage { // Determine witness fields from config let (bt, b) = match witness_config { Some(cfg) if cfg.is_enabled() => ( - cfg.threshold.to_string(), - cfg.witness_urls.iter().map(|u| u.to_string()).collect(), + Threshold::Simple(cfg.threshold as u64), + cfg.witness_urls + .iter() + .map(|u| Prefix::new_unchecked(u.to_string())) + .collect(), ), - _ => ("0".to_string(), vec![]), + _ => (Threshold::Simple(0), vec![]), }; // Build inception event let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![current_pub_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(current_pub_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], bt, b, + c: vec![], a: vec![], x: String::new(), }; diff --git a/crates/auths-storage/tests/cases/concurrent_batch.rs b/crates/auths-storage/tests/cases/concurrent_batch.rs index 3093ae03..6eeaf7af 100644 --- a/crates/auths-storage/tests/cases/concurrent_batch.rs +++ b/crates/auths-storage/tests/cases/concurrent_batch.rs @@ -4,8 +4,9 @@ use std::thread; use auths_core::crypto::said::compute_next_commitment; use auths_id::keri::event::{Event, IcpEvent, KeriSequence}; use auths_id::keri::types::{Prefix, Said}; -use auths_id::keri::{KERI_VERSION, finalize_icp_event, serialize_for_signing}; +use auths_id::keri::{finalize_icp_event, serialize_for_signing}; use auths_id::ports::registry::{RegistryBackend, RegistryError}; +use auths_keri::{CesrKey, Threshold, VersionString}; use auths_storage::git::{GitRegistryBackend, RegistryConfig}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -26,16 +27,17 @@ fn seeded_inception_event(seed: u8) -> Event { let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref()); let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], - bt: "0".to_string(), + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; diff --git a/crates/auths-storage/tests/cases/concurrent_writes.rs b/crates/auths-storage/tests/cases/concurrent_writes.rs index 553eff3c..c3231436 100644 --- a/crates/auths-storage/tests/cases/concurrent_writes.rs +++ b/crates/auths-storage/tests/cases/concurrent_writes.rs @@ -8,6 +8,7 @@ use auths_id::keri::seal::Seal; use auths_id::keri::types::{Prefix, Said}; use auths_id::keri::validate::{finalize_icp_event, serialize_for_signing}; use auths_id::storage::registry::backend::RegistryBackend; +use auths_keri::{CesrKey, Threshold, VersionString}; use auths_storage::git::{GitRegistryBackend, RegistryConfig}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -26,16 +27,17 @@ fn make_signed_icp() -> (Event, Prefix, Ed25519KeyPair) { let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref()); let icp = IcpEvent { - v: "KERI10JSON".to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], - bt: "0".to_string(), + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; @@ -51,17 +53,17 @@ fn make_signed_icp() -> (Event, Prefix, Ed25519KeyPair) { fn make_signed_ixn(prefix: &Prefix, seq: u64, prev_said: &str, keypair: &Ed25519KeyPair) -> Event { let mut ixn = IxnEvent { - v: "KERI10JSON".to_string(), + v: VersionString::placeholder(), d: Said::default(), i: prefix.clone(), s: KeriSequence::new(seq), p: Said::new_unchecked(prev_said.to_string()), - a: vec![Seal::device_attestation("EConcurrent")], + a: vec![Seal::digest("EConcurrent")], x: String::new(), }; - let json = serde_json::to_vec(&Event::Ixn(ixn.clone())).unwrap(); - ixn.d = compute_said(&json); + let value = serde_json::to_value(Event::Ixn(ixn.clone())).unwrap(); + ixn.d = compute_said(&value).unwrap(); let canonical = serialize_for_signing(&Event::Ixn(ixn.clone())).unwrap(); let sig = keypair.sign(&canonical); diff --git a/crates/auths-storage/tests/cases/mock_ed25519_keypairs.rs b/crates/auths-storage/tests/cases/mock_ed25519_keypairs.rs index e414f2c7..aa69422c 100644 --- a/crates/auths-storage/tests/cases/mock_ed25519_keypairs.rs +++ b/crates/auths-storage/tests/cases/mock_ed25519_keypairs.rs @@ -5,7 +5,8 @@ use auths_core::crypto::said::compute_next_commitment; use auths_id::keri::event::{Event, IcpEvent, KeriSequence}; use auths_id::keri::types::{Prefix, Said}; -use auths_id::keri::{KERI_VERSION, finalize_icp_event, serialize_for_signing}; +use auths_id::keri::{finalize_icp_event, serialize_for_signing}; +use auths_keri::{CesrKey, Threshold, VersionString}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use ring::signature::{Ed25519KeyPair, KeyPair}; @@ -237,16 +238,17 @@ pub fn mock_inception_event(index: usize) -> Event { let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref()); let icp = IcpEvent { - v: KERI_VERSION.to_string(), + v: VersionString::placeholder(), d: Said::default(), i: Prefix::default(), s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), n: vec![next_commitment], - bt: "0".to_string(), + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: String::new(), }; diff --git a/crates/auths-verifier/src/ffi.rs b/crates/auths-verifier/src/ffi.rs index 857202a2..ec73781a 100644 --- a/crates/auths-verifier/src/ffi.rs +++ b/crates/auths-verifier/src/ffi.rs @@ -4,7 +4,7 @@ use crate::types::DeviceDID; use crate::verifier::Verifier; use crate::witness::WitnessVerifyConfig; use auths_crypto::ED25519_PUBLIC_KEY_LEN; -use auths_keri::witness::Receipt; +use auths_keri::witness::SignedReceipt; use log::error; use std::os::raw::c_int; use std::panic; @@ -88,8 +88,8 @@ type WitnessKeys = Vec<(String, Vec)>; fn parse_witness_inputs( receipts_json: &[u8], witness_keys_json: &[u8], -) -> Result<(Vec, WitnessKeys), c_int> { - let receipts: Vec = serde_json::from_slice(receipts_json).map_err(|e| { +) -> Result<(Vec, WitnessKeys), c_int> { + let receipts: Vec = serde_json::from_slice(receipts_json).map_err(|e| { error!("FFI: receipts JSON parse error: {}", e); ERR_VERIFY_WITNESS_PARSE })?; @@ -233,7 +233,7 @@ pub unsafe extern "C" fn ffi_verify_attestation_json( /// # Arguments /// * `chain_json_ptr` / `chain_json_len` - JSON array of attestations /// * `root_pk_ptr` / `root_pk_len` - 32-byte Ed25519 root public key -/// * `receipts_json_ptr` / `receipts_json_len` - JSON array of Receipt objects +/// * `receipts_json_ptr` / `receipts_json_len` - JSON array of SignedReceipt objects /// * `witness_keys_json_ptr` / `witness_keys_json_len` - JSON array of `{"did": "...", "pk_hex": "..."}` /// * `threshold` - Minimum number of valid witness receipts required /// * `result_ptr` / `result_len` - Output buffer for JSON VerificationReport diff --git a/crates/auths-verifier/src/lib.rs b/crates/auths-verifier/src/lib.rs index 3badafb6..15b43374 100644 --- a/crates/auths-verifier/src/lib.rs +++ b/crates/auths-verifier/src/lib.rs @@ -112,7 +112,7 @@ pub use verify::{ }; // Re-export witness types -pub use witness::{WitnessQuorum, WitnessReceiptResult, WitnessVerifyConfig}; +pub use witness::{SignedReceipt, WitnessQuorum, WitnessReceiptResult, WitnessVerifyConfig}; // Re-export KERI types directly from auths-keri pub use auths_keri::{ diff --git a/crates/auths-verifier/src/verify.rs b/crates/auths-verifier/src/verify.rs index c7b72d3d..52209306 100644 --- a/crates/auths-verifier/src/verify.rs +++ b/crates/auths-verifier/src/verify.rs @@ -273,7 +273,9 @@ pub async fn verify_device_link( let current_pk = match key_state.current_keys.first() { Some(encoded) => { - match auths_keri::KeriPublicKey::parse(encoded).map(|k| k.into_bytes().to_vec()) { + match auths_keri::KeriPublicKey::parse(encoded.as_str()) + .map(|k| k.into_bytes().to_vec()) + { Ok(bytes) => bytes, Err(e) => { return DeviceLinkVerification::failure(format!("Invalid current key: {e}")); @@ -302,7 +304,11 @@ pub fn compute_attestation_seal_digest( attestation: &Attestation, ) -> Result { let canonical = canonicalize_attestation_data(&attestation.canonical_data())?; - Ok(compute_said(&canonical).to_string()) + let value: serde_json::Value = serde_json::from_slice(&canonical) + .map_err(|e| AttestationError::SerializationError(e.to_string()))?; + Ok(compute_said(&value) + .map_err(|e| AttestationError::SerializationError(e.to_string()))? + .into_inner()) } // --------------------------------------------------------------------------- @@ -1770,19 +1776,20 @@ mod tests { witness_did: &str, event_said: &str, seq: u64, - ) -> auths_keri::witness::Receipt { - let mut receipt = auths_keri::witness::Receipt { - v: "KERI10JSON000000_".into(), + ) -> auths_keri::witness::SignedReceipt { + let receipt = auths_keri::witness::Receipt { + v: auths_keri::VersionString::placeholder(), t: "rct".into(), - d: Said::new_unchecked(format!("EReceipt_{}", seq)), - i: witness_did.into(), - s: seq, - a: Said::new_unchecked(event_said.to_string()), - sig: vec![], + d: Said::new_unchecked(event_said.to_string()), + i: auths_keri::Prefix::new_unchecked(witness_did.to_string()), + s: auths_keri::KeriSequence::new(seq), }; - let payload = receipt.signing_payload().unwrap(); - receipt.sig = witness_kp.sign(&payload).as_ref().to_vec(); - receipt + let payload = serde_json::to_vec(&receipt).unwrap(); + let sig = witness_kp.sign(&payload).as_ref().to_vec(); + auths_keri::witness::SignedReceipt { + receipt, + signature: sig, + } } #[tokio::test] diff --git a/crates/auths-verifier/src/wasm.rs b/crates/auths-verifier/src/wasm.rs index d129af7b..167b3cd0 100644 --- a/crates/auths-verifier/src/wasm.rs +++ b/crates/auths-verifier/src/wasm.rs @@ -8,7 +8,7 @@ use crate::types::VerificationReport; use crate::verify; use crate::witness::WitnessVerifyConfig; use auths_crypto::{CryptoProvider, ED25519_PUBLIC_KEY_LEN, WebCryptoProvider}; -use auths_keri::witness::Receipt; +use auths_keri::witness::SignedReceipt; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; @@ -305,7 +305,7 @@ async fn verify_chain_with_witnesses_internal( AttestationError::SerializationError(format!("Failed to parse attestations JSON: {}", e)) })?; - let receipts: Vec = serde_json::from_str(receipts_json).map_err(|e| { + let receipts: Vec = serde_json::from_str(receipts_json).map_err(|e| { AttestationError::SerializationError(format!("Failed to parse receipts JSON: {}", e)) })?; diff --git a/crates/auths-verifier/src/witness.rs b/crates/auths-verifier/src/witness.rs index dc25d4ab..e9e01a02 100644 --- a/crates/auths-verifier/src/witness.rs +++ b/crates/auths-verifier/src/witness.rs @@ -17,7 +17,7 @@ //! assert!(quorum.verified >= quorum.required); //! ``` -pub use auths_keri::witness::Receipt; +pub use auths_keri::witness::{Receipt, SignedReceipt}; use auths_crypto::CryptoProvider; use auths_keri::Said; @@ -47,30 +47,30 @@ pub struct WitnessReceiptResult { /// Configuration for witness receipt verification. pub struct WitnessVerifyConfig<'a> { - /// The receipts to verify - pub receipts: &'a [Receipt], + /// The signed receipts to verify + pub receipts: &'a [SignedReceipt], /// Known witness keys: (witness_did, ed25519_public_key_bytes) pub witness_keys: &'a [(String, Vec)], /// Minimum number of valid receipts required pub threshold: usize, } -/// Verify a single receipt's Ed25519 signature against a public key. +/// Verify a single signed receipt's Ed25519 signature against a public key. /// -/// Returns `true` if the signature over the canonical payload (excluding `sig`) +/// Returns `true` if the signature over the canonical receipt body /// is valid for the given public key. pub async fn verify_receipt_signature( - receipt: &Receipt, + signed: &SignedReceipt, pk: &[u8], provider: &dyn CryptoProvider, ) -> bool { - let payload = match receipt.signing_payload() { + let payload = match serde_json::to_vec(&signed.receipt) { Ok(p) => p, Err(_) => return false, }; provider - .verify_ed25519(pk, &payload, &receipt.sig) + .verify_ed25519(pk, &payload, &signed.signature) .await .is_ok() } @@ -87,14 +87,14 @@ pub async fn verify_witness_receipts( let mut results = Vec::with_capacity(config.receipts.len()); let mut verified_count = 0; - for receipt in config.receipts { + for signed in config.receipts { let matching_key = config .witness_keys .iter() - .find(|(did, _)| *did == receipt.i); + .find(|(did, _)| did.as_str() == signed.receipt.i.as_str()); let verified = match matching_key { - Some((_, pk)) => verify_receipt_signature(receipt, pk, provider).await, + Some((_, pk)) => verify_receipt_signature(signed, pk, provider).await, None => false, }; @@ -103,8 +103,8 @@ pub async fn verify_witness_receipts( } results.push(WitnessReceiptResult { - witness_id: receipt.i.clone(), - receipt_said: receipt.d.clone(), + witness_id: signed.receipt.i.to_string(), + receipt_said: signed.receipt.d.clone(), verified, }); } @@ -128,41 +128,42 @@ mod tests { RingCryptoProvider } + use auths_keri::{KeriSequence, Prefix, VersionString}; + fn create_signed_receipt( kp: &Ed25519KeyPair, witness_did: &str, event_said: &str, seq: u64, - ) -> Receipt { - let mut receipt = Receipt { - v: "KERI10JSON000000_".into(), + ) -> SignedReceipt { + let receipt = Receipt { + v: VersionString::placeholder(), t: "rct".into(), - d: Said::new_unchecked(format!("EReceipt_{}", seq)), - i: witness_did.into(), - s: seq, - a: Said::new_unchecked(event_said.into()), - sig: vec![], + d: Said::new_unchecked(event_said.to_string()), + i: Prefix::new_unchecked(witness_did.to_string()), + s: KeriSequence::new(seq), }; - let payload = receipt.signing_payload().unwrap(); - receipt.sig = kp.sign(&payload).as_ref().to_vec(); - receipt + let payload = serde_json::to_vec(&receipt).unwrap(); + let sig = kp.sign(&payload).as_ref().to_vec(); + SignedReceipt { + receipt, + signature: sig, + } } #[test] - fn receipt_signing_payload_excludes_sig() { + fn receipt_body_has_no_sig_field() { let receipt = Receipt { - v: "KERI10JSON000000_".into(), + v: VersionString::placeholder(), t: "rct".into(), - d: Said::new_unchecked("EReceipt123".into()), - i: "did:key:z6MkWitness".into(), - s: 5, - a: Said::new_unchecked("EEvent456".into()), - sig: vec![0xab; 64], + d: Said::new_unchecked("EEvent456".into()), + i: Prefix::new_unchecked("did:key:z6MkWitness".to_string()), + s: KeriSequence::new(5), }; - let payload = receipt.signing_payload().unwrap(); + let payload = serde_json::to_vec(&receipt).unwrap(); let payload_str = String::from_utf8(payload).unwrap(); assert!(!payload_str.contains("sig")); - assert!(payload_str.contains("EReceipt123")); + assert!(payload_str.contains("EEvent456")); assert!(payload_str.contains("did:key:z6MkWitness")); } @@ -185,7 +186,7 @@ mod tests { async fn verify_receipt_tampered_signature() { let (kp, pk) = create_test_keypair(&[10u8; 32]); let mut receipt = create_signed_receipt(&kp, "did:key:z6MkW1", "EEvent1", 1); - receipt.sig[0] ^= 0xFF; + receipt.signature[0] ^= 0xFF; assert!(!verify_receipt_signature(&receipt, &pk, &provider()).await); } @@ -263,20 +264,13 @@ mod tests { #[test] fn wire_compat_with_core_receipt() { - let sig_hex = "ab".repeat(64); - let json = format!( - r#"{{"v": "KERI10JSON000000_", "t": "rct", "d": "EReceipt123", "i": "did:key:z6MkWitness", "s": 5, "a": "EEvent456", "sig": "{}"}}"#, - sig_hex - ); - - let receipt: Receipt = serde_json::from_str(&json).unwrap(); - assert_eq!(receipt.v, "KERI10JSON000000_"); + let json = r#"{"v": "KERI10JSON000000_", "t": "rct", "d": "EEvent456", "i": "did:key:z6MkWitness", "s": "5"}"#; + + let receipt: Receipt = serde_json::from_str(json).unwrap(); assert_eq!(receipt.t, "rct"); - assert_eq!(receipt.d, "EReceipt123"); - assert_eq!(receipt.i, "did:key:z6MkWitness"); - assert_eq!(receipt.s, 5); - assert_eq!(receipt.a, "EEvent456"); - assert_eq!(receipt.sig.len(), 64); + assert_eq!(receipt.d, "EEvent456"); + assert_eq!(receipt.i.as_str(), "did:key:z6MkWitness"); + assert_eq!(receipt.s.value(), 5); let json_out = serde_json::to_string(&receipt).unwrap(); let parsed: Receipt = serde_json::from_str(&json_out).unwrap(); diff --git a/crates/auths-verifier/tests/cases/serialization_pinning.rs b/crates/auths-verifier/tests/cases/serialization_pinning.rs index bbbe289a..f6137f84 100644 --- a/crates/auths-verifier/tests/cases/serialization_pinning.rs +++ b/crates/auths-verifier/tests/cases/serialization_pinning.rs @@ -1,19 +1,25 @@ use auths_keri::{ - Event as KeriEvent, IcpEvent, IxnEvent, KeriSequence, Prefix, RotEvent, Said, Seal, + CesrKey, Event as KeriEvent, IcpEvent, IxnEvent, KeriSequence, Prefix, RotEvent, Said, Seal, + Threshold, VersionString, }; fn make_test_icp() -> IcpEvent { IcpEvent { - v: "KERI10JSON000000_".into(), + v: VersionString::placeholder(), d: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), s: KeriSequence::new(0), - kt: "1".into(), - k: vec!["DTestKey12345678901234567890123456789012".into()], - nt: "1".into(), - n: vec!["ETestNext12345678901234567890123456789012".into()], - bt: "0".into(), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked( + "DTestKey12345678901234567890123456789012".into(), + )], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked( + "ETestNext12345678901234567890123456789012".into(), + )], + bt: Threshold::Simple(0), b: vec![], + c: vec![], a: vec![], x: "".into(), } @@ -21,17 +27,23 @@ fn make_test_icp() -> IcpEvent { fn make_test_rot() -> RotEvent { RotEvent { - v: "KERI10JSON000000_".into(), + v: VersionString::placeholder(), d: Said::new_unchecked("ETestRotSaid23456789012345678901234567890".into()), i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), s: KeriSequence::new(1), p: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), - kt: "1".into(), - k: vec!["DNewKey123456789012345678901234567890123".into()], - nt: "1".into(), - n: vec!["ENewNext12345678901234567890123456789012".into()], - bt: "0".into(), - b: vec![], + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked( + "DNewKey123456789012345678901234567890123".into(), + )], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked( + "ENewNext12345678901234567890123456789012".into(), + )], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], a: vec![], x: "".into(), } @@ -39,14 +51,12 @@ fn make_test_rot() -> RotEvent { fn make_test_ixn() -> IxnEvent { IxnEvent { - v: "KERI10JSON000000_".into(), + v: VersionString::placeholder(), d: Said::new_unchecked("ETestIxnSaid23456789012345678901234567890".into()), i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), s: KeriSequence::new(2), p: Said::new_unchecked("ETestRotSaid23456789012345678901234567890".into()), - a: vec![Seal::device_attestation( - "ESealDigest234567890123456789012345678901", - )], + a: vec![Seal::digest("ESealDigest234567890123456789012345678901")], x: "".into(), } } @@ -78,10 +88,11 @@ fn assert_key_order(json: &str, expected_keys: &[&str]) { fn icp_field_order_is_pinned() { let icp = make_test_icp(); let json = serde_json::to_string(&KeriEvent::Icp(icp)).unwrap(); - // `a` is omitted when empty (canonical auths-keri format) assert_key_order( &json, - &["v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b"], + &[ + "v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b", "c", + ], ); } @@ -93,7 +104,7 @@ fn rot_field_order_is_pinned() { assert_key_order( &json, &[ - "v", "t", "d", "i", "s", "p", "kt", "k", "nt", "n", "bt", "b", + "v", "t", "d", "i", "s", "p", "kt", "k", "nt", "n", "bt", "br", "ba", "c", ], ); } @@ -113,23 +124,26 @@ fn icp_with_x_includes_x_last() { assert_key_order( &json, &[ - "v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b", "x", + "v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b", "c", "x", ], ); } #[test] -fn icp_without_d_omits_d() { +fn icp_with_empty_d_still_includes_d() { let mut icp = make_test_icp(); icp.d = Said::default(); let json = serde_json::to_string(&KeriEvent::Icp(icp)).unwrap(); + // Per spec, all fields are always present (even with empty d) assert!( - !json.contains("\"d\":"), - "d field should be omitted when empty" + json.contains("\"d\":"), + "d field must always be present per spec" ); assert_key_order( &json, - &["v", "t", "i", "s", "kt", "k", "nt", "n", "bt", "b"], + &[ + "v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b", "c", "a", + ], ); } @@ -162,9 +176,9 @@ fn ixn_serialization_roundtrip() { #[test] fn seal_type_is_kebab_case_string() { - let seal = Seal::device_attestation("ETest"); + let seal = Seal::digest("ETest"); let json = serde_json::to_string(&seal).unwrap(); - assert!(json.contains(r#""type":"device-attestation""#)); + assert!(json.contains(r#""d":"ETest""#)); } #[test] diff --git a/packages/auths-node/src/verify.rs b/packages/auths-node/src/verify.rs index f419abec..b9f38e02 100644 --- a/packages/auths-node/src/verify.rs +++ b/packages/auths-node/src/verify.rs @@ -11,7 +11,7 @@ use auths_verifier::verify::{ verify_device_authorization as rust_verify_device_authorization, verify_with_capability as rust_verify_with_capability, verify_with_keys, }; -use auths_verifier::witness::{Receipt, WitnessVerifyConfig}; +use auths_verifier::witness::WitnessVerifyConfig; use chrono::{DateTime, Utc}; use napi_derive::napi; @@ -384,7 +384,7 @@ pub async fn verify_chain_with_witnesses( let root_pk_bytes = decode_pk_hex(&root_pk_hex, "root public key")?; let attestations = parse_attestations(&attestations_json)?; - let receipts: Vec = receipts_json + let receipts: Vec = receipts_json .iter() .enumerate() .map(|(i, json)| { diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index b8746bec..ee039b76 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -3322,6 +3322,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", diff --git a/packages/auths-python/src/verify.rs b/packages/auths-python/src/verify.rs index 3ccb081c..9639fa8a 100644 --- a/packages/auths-python/src/verify.rs +++ b/packages/auths-python/src/verify.rs @@ -10,7 +10,7 @@ use auths_verifier::verify::{ verify_device_authorization as rust_verify_device_authorization, verify_with_capability as rust_verify_with_capability, verify_with_keys, }; -use auths_verifier::witness::{Receipt, WitnessVerifyConfig}; +use auths_verifier::witness::WitnessVerifyConfig; use chrono::{DateTime, Utc}; use pyo3::exceptions::{PyRuntimeError, PyValueError}; use pyo3::prelude::*; @@ -533,7 +533,7 @@ pub fn verify_chain_with_witnesses( }) .collect::>>()?; - let receipts: Vec = receipts_json + let receipts: Vec = receipts_json .iter() .enumerate() .map(|(i, json)| { diff --git a/schemas/keri-icp-v1.json b/schemas/keri-icp-v1.json index 5240935b..8bea5f0d 100644 --- a/schemas/keri-icp-v1.json +++ b/schemas/keri-icp-v1.json @@ -1,10 +1,9 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "IcpEvent", - "description": "Inception event — creates a new KERI identity.\n\nThe inception event establishes the identifier prefix and commits to the first rotation key via the `n` (next) field.\n\nNote: The `t` (type) field is handled by the `Event` enum's serde tag.", + "description": "Inception event — creates a new KERI identity.\n\nThe inception event establishes the identifier prefix and commits to the first rotation key via the `n` (next) field.\n\nSpec field order: `[v, t, d, i, s, kt, k, nt, n, bt, b, c, a]`", "type": "object", "required": [ - "b", "bt", "i", "k", @@ -24,15 +23,28 @@ } }, "b": { - "description": "Witness list (empty)", + "description": "Witness/backer list (ordered AIDs)", + "default": [], "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/Prefix" } }, "bt": { - "description": "Witness threshold: \"0\" (no witnesses)", - "type": "string" + "description": "Witness/backer threshold", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + }, + "c": { + "description": "Configuration traits (e.g., EstablishmentOnly, DoNotDelegate)", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/ConfigTrait" + } }, "d": { "description": "SAID (Self-Addressing Identifier) — Blake3 hash of event", @@ -44,7 +56,7 @@ ] }, "i": { - "description": "Identifier prefix (same as `d` for inception)", + "description": "Identifier prefix (same as `d` for self-addressing inception)", "allOf": [ { "$ref": "#/definitions/Prefix" @@ -52,26 +64,34 @@ ] }, "k": { - "description": "Current public key(s), Base64url encoded with derivation code", + "description": "Current public key(s), CESR-encoded", "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/CesrKey" } }, "kt": { - "description": "Key threshold: \"1\" for single-sig", - "type": "string" + "description": "Key signing threshold (hex integer or fractional weight list)", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] }, "n": { - "description": "Next key commitment(s) — hash of next public key(s)", + "description": "Next key commitment(s) — Blake3 digests of next public key(s)", "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/Said" } }, "nt": { - "description": "Next key threshold: \"1\"", - "type": "string" + "description": "Next key signing threshold", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] }, "s": { "description": "Sequence number (always 0 for inception)", @@ -82,85 +102,79 @@ ] }, "v": { - "description": "Version string: \"KERI10JSON\"", - "type": "string" + "description": "Version string", + "allOf": [ + { + "$ref": "#/definitions/VersionString" + } + ] }, "x": { - "description": "Event signature (Ed25519, base64url-no-pad)", + "description": "Legacy signature field — DEPRECATED. Use `SignedEvent` with externalized signatures. Retained for backwards compatibility with stored events.", "default": "", "type": "string" } }, "definitions": { - "KeriSequence": { + "CesrKey": { + "description": "A CESR-encoded public key (e.g., `D` + base64url Ed25519).\n\nWraps the qualified string form. Use `parse_ed25519()` to extract the raw 32-byte key for cryptographic operations.\n\nUsage: ``` use auths_keri::CesrKey; let key = CesrKey::new_unchecked(\"DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\".into()); assert!(key.parse_ed25519().is_ok()); ```", "type": "string" }, - "Prefix": { - "description": "Strongly-typed KERI identifier prefix (e.g., `\"ETest123...\"`).\n\nA prefix is the self-addressing identifier derived from the inception event's Blake3 hash. Always starts with 'E' (Blake3-256 derivation code).\n\nArgs: * Inner `String` should start with `'E'` (enforced by `new()`, not by serde).\n\nUsage: ```ignore let prefix = Prefix::new(\"ETest123abc\".to_string())?; assert_eq!(prefix.as_str(), \"ETest123abc\"); ```", - "type": "string" - }, - "Said": { - "description": "KERI Self-Addressing Identifier (SAID).\n\nA Blake3 hash that uniquely identifies a KERI event. Creates the hash chain: each event's `p` (previous) field is the prior event's SAID.\n\nStructurally identical to `Prefix` (both start with 'E') but semantically distinct — a prefix identifies an *identity*, a SAID identifies an *event*.\n\nArgs: * Inner `String` should start with `'E'` (enforced by `new()`, not by serde).\n\nUsage: ```ignore let said = Said::new(\"ESAID123\".to_string())?; assert_eq!(said.as_str(), \"ESAID123\"); ```", - "type": "string" - }, - "Seal": { - "description": "A seal anchors external data in a KERI event.\n\nSeals are included in the `a` (anchors) field of KERI events. They contain a digest of the anchored data and a type indicator.", - "type": "object", - "required": [ - "d", - "type" - ], - "properties": { - "d": { - "description": "SAID (digest) of the anchored data", - "allOf": [ - { - "$ref": "#/definitions/Said" - } + "ConfigTrait": { + "description": "KERI configuration trait codes.\n\nThese control identity behavior at inception and may be updated at rotation (for `RB`/`NRB` only). If two conflicting traits appear, the latter supersedes.\n\nUsage: ``` use auths_keri::ConfigTrait; let traits: Vec = serde_json::from_str(r#\"[\"EO\",\"DND\"]\"#).unwrap(); assert!(traits.contains(&ConfigTrait::EstablishmentOnly)); ```", + "oneOf": [ + { + "description": "Establishment-Only: only establishment events in KEL.", + "type": "string", + "enum": [ + "EO" ] }, - "type": { - "description": "Type of anchored data", - "allOf": [ - { - "$ref": "#/definitions/SealType" - } - ] - } - } - }, - "SealType": { - "description": "Type of data anchored by a seal.", - "oneOf": [ { - "description": "Device attestation seal", + "description": "Do-Not-Delegate: cannot act as delegator.", "type": "string", "enum": [ - "device-attestation" + "DND" ] }, { - "description": "Revocation seal", + "description": "Delegate-Is-Delegator: delegated AID treated same as delegator.", "type": "string", "enum": [ - "revocation" + "DID" ] }, { - "description": "Capability delegation seal", + "description": "Registrar Backers: backer list provides registrar backer AIDs.", "type": "string", "enum": [ - "delegation" + "RB" ] }, { - "description": "Identity provider binding seal", + "description": "No Registrar Backers: switch back to witnesses.", "type": "string", "enum": [ - "idp-binding" + "NRB" ] } ] + }, + "KeriSequence": { + "type": "string" + }, + "Prefix": { + "description": "Strongly-typed KERI identifier prefix (e.g., `\"ETest123...\"`, `\"DKey456...\"`).\n\nA prefix is the autonomous identifier (AID) for a KERI identity. For self-addressing AIDs it starts with `E` (Blake3-256 digest of the inception event); for key-based AIDs it starts with `D` (Ed25519 public key) or another CESR derivation code.\n\nArgs: * Inner `String` must start with a valid CESR derivation code (uppercase letter or digit). Enforced by `new()`, not by serde.\n\nUsage: ```ignore let prefix = Prefix::new(\"ETest123abc\".to_string())?; assert_eq!(prefix.as_str(), \"ETest123abc\"); ```", + "type": "string" + }, + "Said": { + "description": "KERI Self-Addressing Identifier (SAID).\n\nA Blake3 hash that uniquely identifies a KERI event. Creates the hash chain: each event's `p` (previous) field is the prior event's SAID.\n\nStructurally identical to `Prefix` (both start with 'E') but semantically distinct — a prefix identifies an *identity*, a SAID identifies an *event*.\n\nArgs: * Inner `String` should start with `'E'` (enforced by `new()`, not by serde).\n\nUsage: ```ignore let said = Said::new(\"ESAID123\".to_string())?; assert_eq!(said.as_str(), \"ESAID123\"); ```", + "type": "string" + }, + "Seal": true, + "Threshold": true, + "VersionString": { + "type": "string" } } }