Something went wrong. Try again.
Rust and WASM did-method-plc tools and structures
Something went wrong. Try again.
44 kB · 1313 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314//! Validation logic for operations and operation chains
use crate::crypto::VerifyingKey;use crate::document::{PlcState, MAX_VERIFICATION_METHODS};use crate::error::{PlcError, Result};use crate::operations::Operation;use chrono::{DateTime, Duration, Utc};use std::collections::HashMap;
/// Recovery window duration (72 hours)const RECOVERY_WINDOW_HOURS: i64 = 72;
/// Represents an operation with its server-assigned timestamp#[derive(Debug, Clone)]pub struct OperationWithTimestamp { /// The operation itself pub operation: Operation, /// Server-assigned timestamp when the operation was received pub timestamp: DateTime<Utc>,}
/// Represents a fork point where multiple operations reference the same prev CID#[derive(Debug)]struct ForkPoint { /// The prev CID that multiple operations reference #[allow(dead_code)] prev_cid: String, /// Competing operations at this fork point, with their timestamps and signing key indices operations: Vec<(Operation, DateTime<Utc>, usize)>,}
impl ForkPoint { /// Create a new fork point fn new(prev_cid: String) -> Self { Self { prev_cid, operations: Vec::new(), } }
/// Add an operation to this fork point fn add_operation(&mut self, operation: Operation, timestamp: DateTime<Utc>, key_index: usize) { self.operations.push((operation, timestamp, key_index)); }
/// Resolve this fork point and return the canonical operation /// /// Resolution algorithm: /// 1. Sort operations by timestamp (first-received wins by default) /// 2. Check if any later-received operation with higher priority can invalidate within 72 hours /// 3. Return the winning operation fn resolve(&self) -> Result<Operation> { if self.operations.is_empty() { return Err(PlcError::ForkResolutionError( "Fork point has no operations".to_string(), )); }
// If only one operation, it wins by default if self.operations.len() == 1 { return Ok(self.operations[0].0.clone()); }
// Sort by timestamp - first-received wins by default let mut sorted = self.operations.clone(); sorted.sort_by_key(|(_, timestamp, _)| *timestamp);
// The first operation in chronological order is the default winner let (mut canonical_op, mut canonical_ts, mut canonical_key_idx) = sorted[0].clone();
// Check if any later-received operation can invalidate the current canonical for (competing_op, competing_ts, competing_key_idx) in &sorted[1..] { // Can only invalidate if the competing operation has higher priority (lower index) if *competing_key_idx < canonical_key_idx { // Check if within recovery window from the canonical operation let time_diff = *competing_ts - canonical_ts; if time_diff <= Duration::hours(RECOVERY_WINDOW_HOURS) && time_diff >= Duration::zero() { // This higher-priority operation invalidates the canonical one // Update the canonical to this operation canonical_op = competing_op.clone(); canonical_ts = *competing_ts; canonical_key_idx = *competing_key_idx; } } }
Ok(canonical_op) }}
/// Builder for constructing the canonical chain from a set of operations with timestampsstruct CanonicalChainBuilder { /// All operations with timestamps, in the order they were received operations: Vec<OperationWithTimestamp>,}
impl CanonicalChainBuilder { /// Create a new builder fn new(operations: Vec<OperationWithTimestamp>) -> Self { Self { operations } }
/// Build the canonical chain by detecting and resolving forks /// /// Algorithm: /// 1. Build a graph of operations by CID /// 2. Detect fork points (multiple operations with same prev CID) /// 3. Resolve each fork using rotation key priority and recovery window /// 4. Build the canonical chain from genesis to tip fn build(&self) -> Result<Vec<Operation>> { if self.operations.is_empty() { return Err(PlcError::EmptyChain); }
// Build a map of CID -> (operation, timestamp) for quick lookup let mut operation_map: HashMap<String, (Operation, DateTime<Utc>)> = HashMap::new(); for op_with_ts in &self.operations { let cid = op_with_ts.operation.cid()?; operation_map.insert(cid, (op_with_ts.operation.clone(), op_with_ts.timestamp)); }
// Find the genesis operation (prev = None) let genesis = self .operations .iter() .find(|op| op.operation.is_genesis()) .ok_or_else(|| PlcError::FirstOperationNotGenesis)?;
// Detect fork points - group operations by their prev CID let mut prev_to_operations: HashMap<String, Vec<(Operation, DateTime<Utc>)>> = HashMap::new();
for op_with_ts in &self.operations { if let Some(prev_cid) = op_with_ts.operation.prev() { prev_to_operations .entry(prev_cid.to_string()) .or_default() .push((op_with_ts.operation.clone(), op_with_ts.timestamp)); } }
// Build fork points for any prev CID with multiple operations let mut fork_points: HashMap<String, ForkPoint> = HashMap::new(); for (prev_cid, operations) in &prev_to_operations { if operations.len() > 1 { // This is a fork point - multiple operations reference the same prev let mut fork_point = ForkPoint::new(prev_cid.clone());
// For each operation, determine which rotation key signed it // We need to look at the state at the prev operation for (operation, timestamp) in operations { // Get the state at the prev operation to find rotation keys let rotation_keys = if let Some((prev_op, _)) = operation_map.get(prev_cid) { // Build state up to prev operation to get its rotation keys self.get_state_at_operation(prev_op)? } else { PlcState::new() };
// Find which rotation key signed this operation let key_index = self.find_signing_key_index(operation, &rotation_keys)?; fork_point.add_operation(operation.clone(), *timestamp, key_index); }
fork_points.insert(prev_cid.clone(), fork_point); } }
// Build the canonical chain by starting from genesis and following the canonical path let mut canonical_chain = vec![genesis.operation.clone()]; let mut current_cid = genesis.operation.cid()?;
// Follow the chain until we reach the tip loop { // Check if there's a fork at this point if let Some(fork_point) = fork_points.get(¤t_cid) { // Resolve the fork and get the canonical operation let canonical_op = fork_point.resolve()?; let next_cid = canonical_op.cid()?; canonical_chain.push(canonical_op); current_cid = next_cid; } else if let Some(operations) = prev_to_operations.get(¤t_cid) { // No fork, just a single operation if operations.len() == 1 { let (operation, _) = &operations[0]; let next_cid = operation.cid()?; canonical_chain.push(operation.clone()); current_cid = next_cid; } else { // This shouldn't happen - we should have detected this as a fork return Err(PlcError::ForkResolutionError( "Unexpected multiple operations without fork point".to_string(), )); } } else { // No more operations - we've reached the tip break; } }
Ok(canonical_chain) }
/// Find the index of the rotation key that signed this operation fn find_signing_key_index( &self, operation: &Operation, state: &PlcState, ) -> Result<usize> { if state.rotation_keys.is_empty() { return Err(PlcError::InvalidRotationKeys( "No rotation keys available for verification".to_string(), )); }
// Try each rotation key and return the index of the first one that verifies for (index, key_did) in state.rotation_keys.iter().enumerate() { let verifying_key = VerifyingKey::from_did_key(key_did)?; if operation.verify(&[verifying_key]).is_ok() { return Ok(index); } }
// No key verified the signature Err(PlcError::SignatureVerificationFailed) }
/// Get the state at a specific operation /// /// This reconstructs the state by applying all operations from genesis up to /// and including the specified operation. fn get_state_at_operation(&self, target_operation: &Operation) -> Result<PlcState> { let mut state = PlcState::new(); let target_cid = target_operation.cid()?;
// Build the chain up to the target operation // We need to traverse from genesis to the target for op_with_ts in &self.operations { let op = &op_with_ts.operation;
// Apply this operation to the state match op { Operation::PlcOperation { rotation_keys, verification_methods, also_known_as, services, .. } => { state.rotation_keys = rotation_keys.clone(); state.verification_methods = verification_methods.clone(); state.also_known_as = also_known_as.clone(); state.services = services.clone(); } Operation::PlcTombstone { .. } => { state = PlcState::new(); } Operation::LegacyCreate { .. } => { return Err(PlcError::InvalidOperationType( "Legacy create operations not fully supported".to_string(), )); } }
// Check if this is the target operation if op.cid()? == target_cid { break; } }
Ok(state) }}
/// Operation chain validatorpub struct OperationChainValidator;
impl OperationChainValidator { /// Validate a complete operation chain and return the final state /// /// # Errors /// /// Returns errors if: /// - Chain is empty /// - First operation is not genesis /// - Any operation has invalid prev reference /// - Any signature is invalid /// - Any operation violates constraints pub fn validate_chain(operations: &[Operation]) -> Result<PlcState> { if operations.is_empty() { return Err(PlcError::EmptyChain); }
// First operation must be genesis if !operations[0].is_genesis() { return Err(PlcError::FirstOperationNotGenesis); }
let mut current_state = PlcState::new(); let mut prev_cid: Option<String> = None;
for (i, operation) in operations.iter().enumerate() { // Verify prev field matches expected CID if i == 0 { // Genesis operation must have prev = None if operation.prev().is_some() { return Err(PlcError::InvalidPrev( "Genesis operation must have prev = null".to_string(), )); } } else { // Non-genesis operations must reference previous CID let expected_prev = prev_cid.as_ref().ok_or_else(|| { PlcError::ChainValidationFailed("Missing previous CID".to_string()) })?;
let actual_prev = operation.prev().ok_or_else(|| { PlcError::InvalidPrev("Non-genesis operation must have prev field".to_string()) })?;
if actual_prev != expected_prev { return Err(PlcError::InvalidPrev(format!( "Expected prev = {}, got {}", expected_prev, actual_prev ))); } }
// Verify signature using current rotation keys if !current_state.rotation_keys.is_empty() { let verifying_keys: Result<Vec<VerifyingKey>> = current_state .rotation_keys .iter() .map(|k| VerifyingKey::from_did_key(k)) .collect();
let verifying_keys = verifying_keys?; operation.verify(&verifying_keys)?; } else if i > 0 { // After genesis, we must have rotation keys return Err(PlcError::InvalidRotationKeys( "No rotation keys available for verification".to_string(), )); }
// Apply operation to state match operation { Operation::PlcOperation { rotation_keys, verification_methods, also_known_as, services, .. } => { current_state.rotation_keys = rotation_keys.clone(); current_state.verification_methods = verification_methods.clone(); current_state.also_known_as = also_known_as.clone(); current_state.services = services.clone();
// Validate the state current_state.validate()?; } Operation::PlcTombstone { .. } => { // Tombstone marks the DID as deleted // Clear all state current_state = PlcState::new(); } Operation::LegacyCreate { .. } => { // Legacy create format - convert to modern format // This is for backwards compatibility return Err(PlcError::InvalidOperationType( "Legacy create operations not fully supported".to_string(), )); } }
// Update prev CID for next iteration prev_cid = Some(operation.cid()?); }
Ok(current_state) }
/// Validate a chain with fork resolution /// /// This handles the recovery mechanism where operations signed by higher-priority /// rotation keys can invalidate later operations if submitted within 72 hours. /// /// # Arguments /// /// * `operations` - All operations in the audit log (may contain forks) /// * `timestamps` - Server-assigned timestamps for each operation /// /// # Returns /// /// The final state after applying the canonical chain (after fork resolution) /// /// # Errors /// /// Returns errors if: /// - Operations and timestamps arrays have different lengths /// - Chain is empty /// - First operation is not genesis /// - Fork resolution fails /// - Any signature is invalid /// - Any operation violates constraints pub fn validate_chain_with_forks( operations: &[Operation], timestamps: &[DateTime<Utc>], ) -> Result<PlcState> { if operations.len() != timestamps.len() { return Err(PlcError::ChainValidationFailed( "Operations and timestamps length mismatch".to_string(), )); }
if operations.is_empty() { return Err(PlcError::EmptyChain); }
// Build operations with timestamps let operations_with_timestamps: Vec<OperationWithTimestamp> = operations .iter() .zip(timestamps.iter()) .map(|(op, ts)| OperationWithTimestamp { operation: op.clone(), timestamp: *ts, }) .collect();
// Use the canonical chain builder to resolve forks let builder = CanonicalChainBuilder::new(operations_with_timestamps); let canonical_chain = builder.build()?;
// Validate the canonical chain Self::validate_chain(&canonical_chain) }
/// Check if an operation is within the recovery window relative to another operation /// /// Returns true if the time difference is less than 72 hours pub fn is_within_recovery_window( fork_timestamp: DateTime<Utc>, current_timestamp: DateTime<Utc>, ) -> bool { let diff = current_timestamp - fork_timestamp; diff < Duration::hours(RECOVERY_WINDOW_HOURS) && diff >= Duration::zero() }}
/// Validate rotation keys////// # Errors////// Returns errors if:/// - Not 1-5 keys/// - Contains duplicates/// - Invalid did:key format/// - Unsupported key typepub fn validate_rotation_keys(keys: &[String]) -> Result<()> { if keys.is_empty() { return Err(PlcError::InvalidRotationKeys( "At least one rotation key is required".to_string(), )); }
if keys.len() > 5 { return Err(PlcError::TooManyEntries { field: "rotation_keys".to_string(), max: 5, actual: keys.len(), }); }
// Check for duplicates let mut seen = std::collections::HashSet::new(); for key in keys { if !seen.insert(key) { return Err(PlcError::DuplicateEntry { field: "rotation_keys".to_string(), value: key.clone(), }); }
// Validate format if !key.starts_with("did:key:") { return Err(PlcError::InvalidRotationKeys(format!( "Rotation key must be in did:key format: {}", key ))); }
// Try to parse to ensure it's valid VerifyingKey::from_did_key(key)?; }
Ok(())}
/// Validate verification methods////// # Errors////// Returns errors if:/// - More than 10 methods/// - Invalid did:key formatpub fn validate_verification_methods( methods: &std::collections::HashMap<String, String>,) -> Result<()> { if methods.len() > MAX_VERIFICATION_METHODS { return Err(PlcError::TooManyEntries { field: "verification_methods".to_string(), max: MAX_VERIFICATION_METHODS, actual: methods.len(), }); }
for (name, key) in methods { if !key.starts_with("did:key:") { return Err(PlcError::InvalidVerificationMethods(format!( "Verification method '{}' must be in did:key format: {}", name, key ))); }
// Try to parse to ensure it's valid VerifyingKey::from_did_key(key)?; }
Ok(())}
/// Validate also-known-as URIs////// # Errors////// Returns errors if any URI is invalidpub fn validate_also_known_as(uris: &[String]) -> Result<()> { for uri in uris { if uri.is_empty() { return Err(PlcError::InvalidAlsoKnownAs( "URI cannot be empty".to_string(), )); }
// Basic URI validation - should start with a scheme if !uri.contains(':') { return Err(PlcError::InvalidAlsoKnownAs(format!( "URI must contain a scheme: {}", uri ))); } }
Ok(())}
/// Validate service endpoints////// # Errors////// Returns errors if any service is invalidpub fn validate_services( services: &std::collections::HashMap<String, crate::document::ServiceEndpoint>,) -> Result<()> { for (name, service) in services { if name.is_empty() { return Err(PlcError::InvalidService( "Service name cannot be empty".to_string(), )); }
service.validate()?; }
Ok(())}
#[cfg(test)]mod tests { use super::*; use crate::crypto::SigningKey; use crate::document::ServiceEndpoint; use std::collections::HashMap;
#[test] fn test_validate_rotation_keys() { let key1 = SigningKey::generate_p256(); let key2 = SigningKey::generate_k256();
let keys = vec![key1.to_did_key(), key2.to_did_key()]; assert!(validate_rotation_keys(&keys).is_ok());
// Empty keys assert!(validate_rotation_keys(&[]).is_err());
// Too many keys let many_keys: Vec<String> = (0..6).map(|_| SigningKey::generate_p256().to_did_key()).collect(); assert!(validate_rotation_keys(&many_keys).is_err());
// Duplicate keys let dup_key = key1.to_did_key(); let dup_keys = vec![dup_key.clone(), dup_key]; assert!(validate_rotation_keys(&dup_keys).is_err()); }
#[test] fn test_validate_verification_methods() { let mut methods = HashMap::new(); let key = SigningKey::generate_p256(); methods.insert("atproto".to_string(), key.to_did_key());
assert!(validate_verification_methods(&methods).is_ok());
// Too many methods let mut many_methods = HashMap::new(); for i in 0..11 { let key = SigningKey::generate_p256(); many_methods.insert(format!("key{}", i), key.to_did_key()); } assert!(validate_verification_methods(&many_methods).is_err()); }
#[test] fn test_validate_also_known_as() { let uris = vec![ "at://alice.example.com".to_string(), "https://example.com".to_string(), ]; assert!(validate_also_known_as(&uris).is_ok());
// Empty URI assert!(validate_also_known_as(&[String::new()]).is_err());
// Invalid URI (no scheme) assert!(validate_also_known_as(&["not-a-uri".to_string()]).is_err()); }
#[test] fn test_recovery_window() { let base = Utc::now(); let within = base + Duration::hours(24); let outside = base + Duration::hours(100);
assert!(OperationChainValidator::is_within_recovery_window(base, within)); assert!(!OperationChainValidator::is_within_recovery_window(base, outside)); }
#[test] fn test_validate_chain_genesis() { let key = SigningKey::generate_p256(); let did_key = key.to_did_key();
let unsigned = Operation::new_genesis( vec![did_key], HashMap::new(), vec![], HashMap::new(), );
let signed = unsigned.sign(&key).unwrap();
// Single genesis operation should validate let state = OperationChainValidator::validate_chain(&[signed]).unwrap(); assert_eq!(state.rotation_keys.len(), 1); }
#[test] fn test_validate_chain_empty() { assert!(OperationChainValidator::validate_chain(&[]).is_err()); }
// ======================================================================== // Fork Resolution Tests // ========================================================================
/// Test simple fork resolution where higher priority key wins within recovery window #[test] fn test_fork_resolution_priority_within_window() { // Create two rotation keys with different priorities let primary_key = SigningKey::generate_p256(); // Index 0 - highest priority let backup_key = SigningKey::generate_k256(); // Index 1 - lower priority
let rotation_keys = vec![primary_key.to_did_key(), backup_key.to_did_key()];
// Create genesis operation let genesis = Operation::new_genesis( rotation_keys.clone(), HashMap::new(), vec![], HashMap::new(), ) .sign(&primary_key) .unwrap();
let genesis_cid = genesis.cid().unwrap(); let genesis_time = Utc::now();
// Create two competing operations that both reference genesis // Operation A: signed by backup key (lower priority) let mut services_a = HashMap::new(); services_a.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://pds-a.example.com".to_string(), }, );
let op_a = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services_a, genesis_cid.clone(), ) .sign(&backup_key) .unwrap();
let op_a_time = genesis_time + Duration::hours(1);
// Operation B: signed by primary key (higher priority), arrives 24 hours after A let mut services_b = HashMap::new(); services_b.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://pds-b.example.com".to_string(), }, );
let op_b = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services_b, genesis_cid, ) .sign(&primary_key) .unwrap();
let op_b_time = op_a_time + Duration::hours(24); // Within 72-hour window
// Build operations and timestamps arrays let operations = vec![genesis.clone(), op_a.clone(), op_b.clone()]; let timestamps = vec![genesis_time, op_a_time, op_b_time];
// Validate with fork resolution let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps); assert!(result.is_ok());
let state = result.unwrap();
// Operation B should win because it has higher priority (lower index) // and was received within the 72-hour recovery window assert_eq!( state.services.get("pds").unwrap().endpoint, "https://pds-b.example.com" ); }
/// Test fork resolution where lower priority operation wins after recovery window expires #[test] fn test_fork_resolution_priority_outside_window() { let primary_key = SigningKey::generate_p256(); // Index 0 let backup_key = SigningKey::generate_k256(); // Index 1
let rotation_keys = vec![primary_key.to_did_key(), backup_key.to_did_key()];
let genesis = Operation::new_genesis( rotation_keys.clone(), HashMap::new(), vec![], HashMap::new(), ) .sign(&primary_key) .unwrap();
let genesis_cid = genesis.cid().unwrap(); let genesis_time = Utc::now();
// Operation A: signed by backup key (lower priority) let mut services_a = HashMap::new(); services_a.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://pds-a.example.com".to_string(), }, );
let op_a = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services_a, genesis_cid.clone(), ) .sign(&backup_key) .unwrap();
let op_a_time = genesis_time + Duration::hours(1);
// Operation B: signed by primary key, arrives 100 hours after A (outside 72-hour window) let mut services_b = HashMap::new(); services_b.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://pds-b.example.com".to_string(), }, );
let op_b = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services_b, genesis_cid, ) .sign(&primary_key) .unwrap();
let op_b_time = op_a_time + Duration::hours(100); // Outside 72-hour window
let operations = vec![genesis.clone(), op_a.clone(), op_b.clone()]; let timestamps = vec![genesis_time, op_a_time, op_b_time];
let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps); assert!(result.is_ok());
let state = result.unwrap();
// Operation A should win because even though B has higher priority, // it arrived outside the 72-hour recovery window assert_eq!( state.services.get("pds").unwrap().endpoint, "https://pds-a.example.com" ); }
/// Test multiple forks at different points in the chain #[test] fn test_fork_resolution_multiple_forks() { let key1 = SigningKey::generate_p256(); let key2 = SigningKey::generate_k256(); let key3 = SigningKey::generate_p256();
let rotation_keys = vec![key1.to_did_key(), key2.to_did_key(), key3.to_did_key()];
// Genesis let genesis = Operation::new_genesis( rotation_keys.clone(), HashMap::new(), vec![], HashMap::new(), ) .sign(&key1) .unwrap();
let genesis_cid = genesis.cid().unwrap(); let genesis_time = Utc::now();
// First fork at genesis let mut services_1a = HashMap::new(); services_1a.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://fork1a.example.com".to_string(), }, );
let op_1a = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services_1a, genesis_cid.clone(), ) .sign(&key2) .unwrap();
let op_1a_time = genesis_time + Duration::hours(1);
// Second operation at same fork (higher priority, within window) let mut services_1b = HashMap::new(); services_1b.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://fork1b.example.com".to_string(), }, );
let op_1b = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services_1b, genesis_cid, ) .sign(&key1) .unwrap();
let op_1b_time = op_1a_time + Duration::hours(2);
// Next operation continues from op_1b (the winner) let op_1b_cid = op_1b.cid().unwrap();
let mut services_2 = HashMap::new(); services_2.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://continuation.example.com".to_string(), }, );
let op_2 = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services_2, op_1b_cid, ) .sign(&key1) .unwrap();
let op_2_time = op_1b_time + Duration::hours(1);
let operations = vec![ genesis.clone(), op_1a.clone(), op_1b.clone(), op_2.clone(), ]; let timestamps = vec![genesis_time, op_1a_time, op_1b_time, op_2_time];
let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps); assert!(result.is_ok());
let state = result.unwrap();
// The final state should be from op_2, which continues from op_1b assert_eq!( state.services.get("pds").unwrap().endpoint, "https://continuation.example.com" ); }
/// Test fork with three competing operations #[test] fn test_fork_resolution_three_way_fork() { let key1 = SigningKey::generate_p256(); // Highest priority let key2 = SigningKey::generate_k256(); // Medium priority let key3 = SigningKey::generate_p256(); // Lowest priority
let rotation_keys = vec![key1.to_did_key(), key2.to_did_key(), key3.to_did_key()];
let genesis = Operation::new_genesis( rotation_keys.clone(), HashMap::new(), vec![], HashMap::new(), ) .sign(&key1) .unwrap();
let genesis_cid = genesis.cid().unwrap(); let genesis_time = Utc::now();
// Three competing operations let mut services_a = HashMap::new(); services_a.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://op-a.example.com".to_string(), }, );
let op_a = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services_a, genesis_cid.clone(), ) .sign(&key3) .unwrap(); // Lowest priority, first to arrive
let op_a_time = genesis_time + Duration::hours(1);
let mut services_b = HashMap::new(); services_b.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://op-b.example.com".to_string(), }, );
let op_b = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services_b, genesis_cid.clone(), ) .sign(&key2) .unwrap(); // Medium priority
let op_b_time = op_a_time + Duration::hours(2);
let mut services_c = HashMap::new(); services_c.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://op-c.example.com".to_string(), }, );
let op_c = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services_c, genesis_cid, ) .sign(&key1) .unwrap(); // Highest priority, within window
let op_c_time = op_a_time + Duration::hours(10);
let operations = vec![ genesis.clone(), op_a.clone(), op_b.clone(), op_c.clone(), ]; let timestamps = vec![genesis_time, op_a_time, op_b_time, op_c_time];
let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps); assert!(result.is_ok());
let state = result.unwrap();
// Operation C should win (highest priority, within window) assert_eq!( state.services.get("pds").unwrap().endpoint, "https://op-c.example.com" ); }
/// Test no fork - linear chain should work as before #[test] fn test_fork_resolution_no_fork() { let key = SigningKey::generate_p256(); let rotation_keys = vec![key.to_did_key()];
let genesis = Operation::new_genesis( rotation_keys.clone(), HashMap::new(), vec![], HashMap::new(), ) .sign(&key) .unwrap();
let genesis_cid = genesis.cid().unwrap(); let genesis_time = Utc::now();
let mut services = HashMap::new(); services.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://pds.example.com".to_string(), }, );
let op1 = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services.clone(), genesis_cid, ) .sign(&key) .unwrap();
let op1_time = genesis_time + Duration::hours(1);
let operations = vec![genesis.clone(), op1.clone()]; let timestamps = vec![genesis_time, op1_time];
let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps); assert!(result.is_ok());
let state = result.unwrap(); assert_eq!( state.services.get("pds").unwrap().endpoint, "https://pds.example.com" ); }
/// Test fork resolution with rotation key changes #[test] fn test_fork_resolution_with_key_rotation() { let key1 = SigningKey::generate_p256(); let key2 = SigningKey::generate_k256(); let key3 = SigningKey::generate_p256();
// Initial rotation keys let rotation_keys_v1 = vec![key1.to_did_key(), key2.to_did_key()];
let genesis = Operation::new_genesis( rotation_keys_v1.clone(), HashMap::new(), vec![], HashMap::new(), ) .sign(&key1) .unwrap();
let genesis_cid = genesis.cid().unwrap(); let genesis_time = Utc::now();
// Update rotation keys in first operation let rotation_keys_v2 = vec![key1.to_did_key(), key3.to_did_key()];
let op1 = Operation::new_update( rotation_keys_v2.clone(), HashMap::new(), vec![], HashMap::new(), genesis_cid, ) .sign(&key1) .unwrap();
let op1_cid = op1.cid().unwrap(); let op1_time = genesis_time + Duration::hours(1);
// Create a fork at op1 - both operations use the new rotation keys let mut services_a = HashMap::new(); services_a.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://op-a.example.com".to_string(), }, );
let op_a = Operation::new_update( rotation_keys_v2.clone(), HashMap::new(), vec![], services_a, op1_cid.clone(), ) .sign(&key3) .unwrap(); // Index 1 in new keys
let op_a_time = op1_time + Duration::hours(1);
let mut services_b = HashMap::new(); services_b.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://op-b.example.com".to_string(), }, );
let op_b = Operation::new_update( rotation_keys_v2.clone(), HashMap::new(), vec![], services_b, op1_cid, ) .sign(&key1) .unwrap(); // Index 0 in new keys (higher priority)
let op_b_time = op_a_time + Duration::hours(2);
let operations = vec![ genesis.clone(), op1.clone(), op_a.clone(), op_b.clone(), ]; let timestamps = vec![genesis_time, op1_time, op_a_time, op_b_time];
let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps); assert!(result.is_ok());
let state = result.unwrap();
// Operation B should win (signed by key1 which is index 0 in the new rotation keys) assert_eq!( state.services.get("pds").unwrap().endpoint, "https://op-b.example.com" ); }
/// Test that operations with mismatched timestamps and operations fail #[test] fn test_fork_resolution_mismatched_lengths() { let key = SigningKey::generate_p256(); let rotation_keys = vec![key.to_did_key()];
let genesis = Operation::new_genesis( rotation_keys, HashMap::new(), vec![], HashMap::new(), ) .sign(&key) .unwrap();
let operations = vec![genesis]; let timestamps = vec![Utc::now(), Utc::now()]; // Different length
let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps); assert!(result.is_err()); }
/// Test recovery window boundary (exactly 72 hours) #[test] fn test_fork_resolution_recovery_window_boundary() { let primary_key = SigningKey::generate_p256(); let backup_key = SigningKey::generate_k256();
let rotation_keys = vec![primary_key.to_did_key(), backup_key.to_did_key()];
let genesis = Operation::new_genesis( rotation_keys.clone(), HashMap::new(), vec![], HashMap::new(), ) .sign(&primary_key) .unwrap();
let genesis_cid = genesis.cid().unwrap(); let genesis_time = Utc::now();
// Operation A: signed by backup key let mut services_a = HashMap::new(); services_a.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://op-a.example.com".to_string(), }, );
let op_a = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services_a, genesis_cid.clone(), ) .sign(&backup_key) .unwrap();
let op_a_time = genesis_time + Duration::hours(1);
// Operation B: exactly at 72-hour boundary (should still be within window) let mut services_b = HashMap::new(); services_b.insert( "pds".to_string(), ServiceEndpoint { service_type: "AtprotoPersonalDataServer".to_string(), endpoint: "https://op-b.example.com".to_string(), }, );
let op_b = Operation::new_update( rotation_keys.clone(), HashMap::new(), vec![], services_b, genesis_cid, ) .sign(&primary_key) .unwrap();
// Exactly 72 hours after op_a let op_b_time = op_a_time + Duration::hours(72);
let operations = vec![genesis.clone(), op_a.clone(), op_b.clone()]; let timestamps = vec![genesis_time, op_a_time, op_b_time];
let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps); assert!(result.is_ok());
let state = result.unwrap();
// At exactly 72 hours, the higher priority operation should still win assert_eq!( state.services.get("pds").unwrap().endpoint, "https://op-b.example.com" ); }}