Rust and WASM did-method-plc tools and structures
1//! Validation logic for operations and operation chains
2
3use crate::crypto::VerifyingKey;
4use crate::document::{PlcState, MAX_VERIFICATION_METHODS};
5use crate::error::{PlcError, Result};
6use crate::operations::Operation;
7use chrono::{DateTime, Duration, Utc};
8use std::collections::HashMap;
9
10/// Recovery window duration (72 hours)
11const RECOVERY_WINDOW_HOURS: i64 = 72;
12
13/// Represents an operation with its server-assigned timestamp
14#[derive(Debug, Clone)]
15pub struct OperationWithTimestamp {
16 /// The operation itself
17 pub operation: Operation,
18 /// Server-assigned timestamp when the operation was received
19 pub timestamp: DateTime<Utc>,
20}
21
22/// Represents a fork point where multiple operations reference the same prev CID
23#[derive(Debug)]
24struct ForkPoint {
25 /// The prev CID that multiple operations reference
26 #[allow(dead_code)]
27 prev_cid: String,
28 /// Competing operations at this fork point, with their timestamps and signing key indices
29 operations: Vec<(Operation, DateTime<Utc>, usize)>,
30}
31
32impl ForkPoint {
33 /// Create a new fork point
34 fn new(prev_cid: String) -> Self {
35 Self {
36 prev_cid,
37 operations: Vec::new(),
38 }
39 }
40
41 /// Add an operation to this fork point
42 fn add_operation(&mut self, operation: Operation, timestamp: DateTime<Utc>, key_index: usize) {
43 self.operations.push((operation, timestamp, key_index));
44 }
45
46 /// Resolve this fork point and return the canonical operation
47 ///
48 /// Resolution algorithm:
49 /// 1. Sort operations by timestamp (first-received wins by default)
50 /// 2. Check if any later-received operation with higher priority can invalidate within 72 hours
51 /// 3. Return the winning operation
52 fn resolve(&self) -> Result<Operation> {
53 if self.operations.is_empty() {
54 return Err(PlcError::ForkResolutionError(
55 "Fork point has no operations".to_string(),
56 ));
57 }
58
59 // If only one operation, it wins by default
60 if self.operations.len() == 1 {
61 return Ok(self.operations[0].0.clone());
62 }
63
64 // Sort by timestamp - first-received wins by default
65 let mut sorted = self.operations.clone();
66 sorted.sort_by_key(|(_, timestamp, _)| *timestamp);
67
68 // The first operation in chronological order is the default winner
69 let (mut canonical_op, mut canonical_ts, mut canonical_key_idx) = sorted[0].clone();
70
71 // Check if any later-received operation can invalidate the current canonical
72 for (competing_op, competing_ts, competing_key_idx) in &sorted[1..] {
73 // Can only invalidate if the competing operation has higher priority (lower index)
74 if *competing_key_idx < canonical_key_idx {
75 // Check if within recovery window from the canonical operation
76 let time_diff = *competing_ts - canonical_ts;
77 if time_diff <= Duration::hours(RECOVERY_WINDOW_HOURS)
78 && time_diff >= Duration::zero()
79 {
80 // This higher-priority operation invalidates the canonical one
81 // Update the canonical to this operation
82 canonical_op = competing_op.clone();
83 canonical_ts = *competing_ts;
84 canonical_key_idx = *competing_key_idx;
85 }
86 }
87 }
88
89 Ok(canonical_op)
90 }
91}
92
93/// Builder for constructing the canonical chain from a set of operations with timestamps
94struct CanonicalChainBuilder {
95 /// All operations with timestamps, in the order they were received
96 operations: Vec<OperationWithTimestamp>,
97}
98
99impl CanonicalChainBuilder {
100 /// Create a new builder
101 fn new(operations: Vec<OperationWithTimestamp>) -> Self {
102 Self { operations }
103 }
104
105 /// Build the canonical chain by detecting and resolving forks
106 ///
107 /// Algorithm:
108 /// 1. Build a graph of operations by CID
109 /// 2. Detect fork points (multiple operations with same prev CID)
110 /// 3. Resolve each fork using rotation key priority and recovery window
111 /// 4. Build the canonical chain from genesis to tip
112 fn build(&self) -> Result<Vec<Operation>> {
113 if self.operations.is_empty() {
114 return Err(PlcError::EmptyChain);
115 }
116
117 // Build a map of CID -> (operation, timestamp) for quick lookup
118 let mut operation_map: HashMap<String, (Operation, DateTime<Utc>)> = HashMap::new();
119 for op_with_ts in &self.operations {
120 let cid = op_with_ts.operation.cid()?;
121 operation_map.insert(cid, (op_with_ts.operation.clone(), op_with_ts.timestamp));
122 }
123
124 // Find the genesis operation (prev = None)
125 let genesis = self
126 .operations
127 .iter()
128 .find(|op| op.operation.is_genesis())
129 .ok_or_else(|| PlcError::FirstOperationNotGenesis)?;
130
131 // Detect fork points - group operations by their prev CID
132 let mut prev_to_operations: HashMap<String, Vec<(Operation, DateTime<Utc>)>> =
133 HashMap::new();
134
135 for op_with_ts in &self.operations {
136 if let Some(prev_cid) = op_with_ts.operation.prev() {
137 prev_to_operations
138 .entry(prev_cid.to_string())
139 .or_default()
140 .push((op_with_ts.operation.clone(), op_with_ts.timestamp));
141 }
142 }
143
144 // Build fork points for any prev CID with multiple operations
145 let mut fork_points: HashMap<String, ForkPoint> = HashMap::new();
146 for (prev_cid, operations) in &prev_to_operations {
147 if operations.len() > 1 {
148 // This is a fork point - multiple operations reference the same prev
149 let mut fork_point = ForkPoint::new(prev_cid.clone());
150
151 // For each operation, determine which rotation key signed it
152 // We need to look at the state at the prev operation
153 for (operation, timestamp) in operations {
154 // Get the state at the prev operation to find rotation keys
155 let rotation_keys = if let Some((prev_op, _)) = operation_map.get(prev_cid) {
156 // Build state up to prev operation to get its rotation keys
157 self.get_state_at_operation(prev_op)?
158 } else {
159 PlcState::new()
160 };
161
162 // Find which rotation key signed this operation
163 let key_index = self.find_signing_key_index(operation, &rotation_keys)?;
164 fork_point.add_operation(operation.clone(), *timestamp, key_index);
165 }
166
167 fork_points.insert(prev_cid.clone(), fork_point);
168 }
169 }
170
171 // Build the canonical chain by starting from genesis and following the canonical path
172 let mut canonical_chain = vec![genesis.operation.clone()];
173 let mut current_cid = genesis.operation.cid()?;
174
175 // Follow the chain until we reach the tip
176 loop {
177 // Check if there's a fork at this point
178 if let Some(fork_point) = fork_points.get(¤t_cid) {
179 // Resolve the fork and get the canonical operation
180 let canonical_op = fork_point.resolve()?;
181 let next_cid = canonical_op.cid()?;
182 canonical_chain.push(canonical_op);
183 current_cid = next_cid;
184 } else if let Some(operations) = prev_to_operations.get(¤t_cid) {
185 // No fork, just a single operation
186 if operations.len() == 1 {
187 let (operation, _) = &operations[0];
188 let next_cid = operation.cid()?;
189 canonical_chain.push(operation.clone());
190 current_cid = next_cid;
191 } else {
192 // This shouldn't happen - we should have detected this as a fork
193 return Err(PlcError::ForkResolutionError(
194 "Unexpected multiple operations without fork point".to_string(),
195 ));
196 }
197 } else {
198 // No more operations - we've reached the tip
199 break;
200 }
201 }
202
203 Ok(canonical_chain)
204 }
205
206 /// Find the index of the rotation key that signed this operation
207 fn find_signing_key_index(
208 &self,
209 operation: &Operation,
210 state: &PlcState,
211 ) -> Result<usize> {
212 if state.rotation_keys.is_empty() {
213 return Err(PlcError::InvalidRotationKeys(
214 "No rotation keys available for verification".to_string(),
215 ));
216 }
217
218 // Try each rotation key and return the index of the first one that verifies
219 for (index, key_did) in state.rotation_keys.iter().enumerate() {
220 let verifying_key = VerifyingKey::from_did_key(key_did)?;
221 if operation.verify(&[verifying_key]).is_ok() {
222 return Ok(index);
223 }
224 }
225
226 // No key verified the signature
227 Err(PlcError::SignatureVerificationFailed)
228 }
229
230 /// Get the state at a specific operation
231 ///
232 /// This reconstructs the state by applying all operations from genesis up to
233 /// and including the specified operation.
234 fn get_state_at_operation(&self, target_operation: &Operation) -> Result<PlcState> {
235 let mut state = PlcState::new();
236 let target_cid = target_operation.cid()?;
237
238 // Build the chain up to the target operation
239 // We need to traverse from genesis to the target
240 for op_with_ts in &self.operations {
241 let op = &op_with_ts.operation;
242
243 // Apply this operation to the state
244 match op {
245 Operation::PlcOperation {
246 rotation_keys,
247 verification_methods,
248 also_known_as,
249 services,
250 ..
251 } => {
252 state.rotation_keys = rotation_keys.clone();
253 state.verification_methods = verification_methods.clone();
254 state.also_known_as = also_known_as.clone();
255 state.services = services.clone();
256 }
257 Operation::PlcTombstone { .. } => {
258 state = PlcState::new();
259 }
260 Operation::LegacyCreate { .. } => {
261 return Err(PlcError::InvalidOperationType(
262 "Legacy create operations not fully supported".to_string(),
263 ));
264 }
265 }
266
267 // Check if this is the target operation
268 if op.cid()? == target_cid {
269 break;
270 }
271 }
272
273 Ok(state)
274 }
275}
276
277/// Operation chain validator
278pub struct OperationChainValidator;
279
280impl OperationChainValidator {
281 /// Validate a complete operation chain and return the final state
282 ///
283 /// # Errors
284 ///
285 /// Returns errors if:
286 /// - Chain is empty
287 /// - First operation is not genesis
288 /// - Any operation has invalid prev reference
289 /// - Any signature is invalid
290 /// - Any operation violates constraints
291 pub fn validate_chain(operations: &[Operation]) -> Result<PlcState> {
292 if operations.is_empty() {
293 return Err(PlcError::EmptyChain);
294 }
295
296 // First operation must be genesis
297 if !operations[0].is_genesis() {
298 return Err(PlcError::FirstOperationNotGenesis);
299 }
300
301 let mut current_state = PlcState::new();
302 let mut prev_cid: Option<String> = None;
303
304 for (i, operation) in operations.iter().enumerate() {
305 // Verify prev field matches expected CID
306 if i == 0 {
307 // Genesis operation must have prev = None
308 if operation.prev().is_some() {
309 return Err(PlcError::InvalidPrev(
310 "Genesis operation must have prev = null".to_string(),
311 ));
312 }
313 } else {
314 // Non-genesis operations must reference previous CID
315 let expected_prev = prev_cid.as_ref().ok_or_else(|| {
316 PlcError::ChainValidationFailed("Missing previous CID".to_string())
317 })?;
318
319 let actual_prev = operation.prev().ok_or_else(|| {
320 PlcError::InvalidPrev("Non-genesis operation must have prev field".to_string())
321 })?;
322
323 if actual_prev != expected_prev {
324 return Err(PlcError::InvalidPrev(format!(
325 "Expected prev = {}, got {}",
326 expected_prev, actual_prev
327 )));
328 }
329 }
330
331 // Verify signature using current rotation keys
332 if !current_state.rotation_keys.is_empty() {
333 let verifying_keys: Result<Vec<VerifyingKey>> = current_state
334 .rotation_keys
335 .iter()
336 .map(|k| VerifyingKey::from_did_key(k))
337 .collect();
338
339 let verifying_keys = verifying_keys?;
340 operation.verify(&verifying_keys)?;
341 } else if i > 0 {
342 // After genesis, we must have rotation keys
343 return Err(PlcError::InvalidRotationKeys(
344 "No rotation keys available for verification".to_string(),
345 ));
346 }
347
348 // Apply operation to state
349 match operation {
350 Operation::PlcOperation {
351 rotation_keys,
352 verification_methods,
353 also_known_as,
354 services,
355 ..
356 } => {
357 current_state.rotation_keys = rotation_keys.clone();
358 current_state.verification_methods = verification_methods.clone();
359 current_state.also_known_as = also_known_as.clone();
360 current_state.services = services.clone();
361
362 // Validate the state
363 current_state.validate()?;
364 }
365 Operation::PlcTombstone { .. } => {
366 // Tombstone marks the DID as deleted
367 // Clear all state
368 current_state = PlcState::new();
369 }
370 Operation::LegacyCreate { .. } => {
371 // Legacy create format - convert to modern format
372 // This is for backwards compatibility
373 return Err(PlcError::InvalidOperationType(
374 "Legacy create operations not fully supported".to_string(),
375 ));
376 }
377 }
378
379 // Update prev CID for next iteration
380 prev_cid = Some(operation.cid()?);
381 }
382
383 Ok(current_state)
384 }
385
386 /// Validate a chain with fork resolution
387 ///
388 /// This handles the recovery mechanism where operations signed by higher-priority
389 /// rotation keys can invalidate later operations if submitted within 72 hours.
390 ///
391 /// # Arguments
392 ///
393 /// * `operations` - All operations in the audit log (may contain forks)
394 /// * `timestamps` - Server-assigned timestamps for each operation
395 ///
396 /// # Returns
397 ///
398 /// The final state after applying the canonical chain (after fork resolution)
399 ///
400 /// # Errors
401 ///
402 /// Returns errors if:
403 /// - Operations and timestamps arrays have different lengths
404 /// - Chain is empty
405 /// - First operation is not genesis
406 /// - Fork resolution fails
407 /// - Any signature is invalid
408 /// - Any operation violates constraints
409 pub fn validate_chain_with_forks(
410 operations: &[Operation],
411 timestamps: &[DateTime<Utc>],
412 ) -> Result<PlcState> {
413 if operations.len() != timestamps.len() {
414 return Err(PlcError::ChainValidationFailed(
415 "Operations and timestamps length mismatch".to_string(),
416 ));
417 }
418
419 if operations.is_empty() {
420 return Err(PlcError::EmptyChain);
421 }
422
423 // Build operations with timestamps
424 let operations_with_timestamps: Vec<OperationWithTimestamp> = operations
425 .iter()
426 .zip(timestamps.iter())
427 .map(|(op, ts)| OperationWithTimestamp {
428 operation: op.clone(),
429 timestamp: *ts,
430 })
431 .collect();
432
433 // Use the canonical chain builder to resolve forks
434 let builder = CanonicalChainBuilder::new(operations_with_timestamps);
435 let canonical_chain = builder.build()?;
436
437 // Validate the canonical chain
438 Self::validate_chain(&canonical_chain)
439 }
440
441 /// Check if an operation is within the recovery window relative to another operation
442 ///
443 /// Returns true if the time difference is less than 72 hours
444 pub fn is_within_recovery_window(
445 fork_timestamp: DateTime<Utc>,
446 current_timestamp: DateTime<Utc>,
447 ) -> bool {
448 let diff = current_timestamp - fork_timestamp;
449 diff < Duration::hours(RECOVERY_WINDOW_HOURS) && diff >= Duration::zero()
450 }
451}
452
453/// Validate rotation keys
454///
455/// # Errors
456///
457/// Returns errors if:
458/// - Not 1-5 keys
459/// - Contains duplicates
460/// - Invalid did:key format
461/// - Unsupported key type
462pub fn validate_rotation_keys(keys: &[String]) -> Result<()> {
463 if keys.is_empty() {
464 return Err(PlcError::InvalidRotationKeys(
465 "At least one rotation key is required".to_string(),
466 ));
467 }
468
469 if keys.len() > 5 {
470 return Err(PlcError::TooManyEntries {
471 field: "rotation_keys".to_string(),
472 max: 5,
473 actual: keys.len(),
474 });
475 }
476
477 // Check for duplicates
478 let mut seen = std::collections::HashSet::new();
479 for key in keys {
480 if !seen.insert(key) {
481 return Err(PlcError::DuplicateEntry {
482 field: "rotation_keys".to_string(),
483 value: key.clone(),
484 });
485 }
486
487 // Validate format
488 if !key.starts_with("did:key:") {
489 return Err(PlcError::InvalidRotationKeys(format!(
490 "Rotation key must be in did:key format: {}",
491 key
492 )));
493 }
494
495 // Try to parse to ensure it's valid
496 VerifyingKey::from_did_key(key)?;
497 }
498
499 Ok(())
500}
501
502/// Validate verification methods
503///
504/// # Errors
505///
506/// Returns errors if:
507/// - More than 10 methods
508/// - Invalid did:key format
509pub fn validate_verification_methods(
510 methods: &std::collections::HashMap<String, String>,
511) -> Result<()> {
512 if methods.len() > MAX_VERIFICATION_METHODS {
513 return Err(PlcError::TooManyEntries {
514 field: "verification_methods".to_string(),
515 max: MAX_VERIFICATION_METHODS,
516 actual: methods.len(),
517 });
518 }
519
520 for (name, key) in methods {
521 if !key.starts_with("did:key:") {
522 return Err(PlcError::InvalidVerificationMethods(format!(
523 "Verification method '{}' must be in did:key format: {}",
524 name, key
525 )));
526 }
527
528 // Try to parse to ensure it's valid
529 VerifyingKey::from_did_key(key)?;
530 }
531
532 Ok(())
533}
534
535/// Validate also-known-as URIs
536///
537/// # Errors
538///
539/// Returns errors if any URI is invalid
540pub fn validate_also_known_as(uris: &[String]) -> Result<()> {
541 for uri in uris {
542 if uri.is_empty() {
543 return Err(PlcError::InvalidAlsoKnownAs(
544 "URI cannot be empty".to_string(),
545 ));
546 }
547
548 // Basic URI validation - should start with a scheme
549 if !uri.contains(':') {
550 return Err(PlcError::InvalidAlsoKnownAs(format!(
551 "URI must contain a scheme: {}",
552 uri
553 )));
554 }
555 }
556
557 Ok(())
558}
559
560/// Validate service endpoints
561///
562/// # Errors
563///
564/// Returns errors if any service is invalid
565pub fn validate_services(
566 services: &std::collections::HashMap<String, crate::document::ServiceEndpoint>,
567) -> Result<()> {
568 for (name, service) in services {
569 if name.is_empty() {
570 return Err(PlcError::InvalidService(
571 "Service name cannot be empty".to_string(),
572 ));
573 }
574
575 service.validate()?;
576 }
577
578 Ok(())
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use crate::crypto::SigningKey;
585 use crate::document::ServiceEndpoint;
586 use std::collections::HashMap;
587
588 #[test]
589 fn test_validate_rotation_keys() {
590 let key1 = SigningKey::generate_p256();
591 let key2 = SigningKey::generate_k256();
592
593 let keys = vec![key1.to_did_key(), key2.to_did_key()];
594 assert!(validate_rotation_keys(&keys).is_ok());
595
596 // Empty keys
597 assert!(validate_rotation_keys(&[]).is_err());
598
599 // Too many keys
600 let many_keys: Vec<String> = (0..6).map(|_| SigningKey::generate_p256().to_did_key()).collect();
601 assert!(validate_rotation_keys(&many_keys).is_err());
602
603 // Duplicate keys
604 let dup_key = key1.to_did_key();
605 let dup_keys = vec![dup_key.clone(), dup_key];
606 assert!(validate_rotation_keys(&dup_keys).is_err());
607 }
608
609 #[test]
610 fn test_validate_verification_methods() {
611 let mut methods = HashMap::new();
612 let key = SigningKey::generate_p256();
613 methods.insert("atproto".to_string(), key.to_did_key());
614
615 assert!(validate_verification_methods(&methods).is_ok());
616
617 // Too many methods
618 let mut many_methods = HashMap::new();
619 for i in 0..11 {
620 let key = SigningKey::generate_p256();
621 many_methods.insert(format!("key{}", i), key.to_did_key());
622 }
623 assert!(validate_verification_methods(&many_methods).is_err());
624 }
625
626 #[test]
627 fn test_validate_also_known_as() {
628 let uris = vec![
629 "at://alice.example.com".to_string(),
630 "https://example.com".to_string(),
631 ];
632 assert!(validate_also_known_as(&uris).is_ok());
633
634 // Empty URI
635 assert!(validate_also_known_as(&[String::new()]).is_err());
636
637 // Invalid URI (no scheme)
638 assert!(validate_also_known_as(&["not-a-uri".to_string()]).is_err());
639 }
640
641 #[test]
642 fn test_recovery_window() {
643 let base = Utc::now();
644 let within = base + Duration::hours(24);
645 let outside = base + Duration::hours(100);
646
647 assert!(OperationChainValidator::is_within_recovery_window(base, within));
648 assert!(!OperationChainValidator::is_within_recovery_window(base, outside));
649 }
650
651 #[test]
652 fn test_validate_chain_genesis() {
653 let key = SigningKey::generate_p256();
654 let did_key = key.to_did_key();
655
656 let unsigned = Operation::new_genesis(
657 vec![did_key],
658 HashMap::new(),
659 vec![],
660 HashMap::new(),
661 );
662
663 let signed = unsigned.sign(&key).unwrap();
664
665 // Single genesis operation should validate
666 let state = OperationChainValidator::validate_chain(&[signed]).unwrap();
667 assert_eq!(state.rotation_keys.len(), 1);
668 }
669
670 #[test]
671 fn test_validate_chain_empty() {
672 assert!(OperationChainValidator::validate_chain(&[]).is_err());
673 }
674
675 // ========================================================================
676 // Fork Resolution Tests
677 // ========================================================================
678
679 /// Test simple fork resolution where higher priority key wins within recovery window
680 #[test]
681 fn test_fork_resolution_priority_within_window() {
682 // Create two rotation keys with different priorities
683 let primary_key = SigningKey::generate_p256(); // Index 0 - highest priority
684 let backup_key = SigningKey::generate_k256(); // Index 1 - lower priority
685
686 let rotation_keys = vec![primary_key.to_did_key(), backup_key.to_did_key()];
687
688 // Create genesis operation
689 let genesis = Operation::new_genesis(
690 rotation_keys.clone(),
691 HashMap::new(),
692 vec![],
693 HashMap::new(),
694 )
695 .sign(&primary_key)
696 .unwrap();
697
698 let genesis_cid = genesis.cid().unwrap();
699 let genesis_time = Utc::now();
700
701 // Create two competing operations that both reference genesis
702 // Operation A: signed by backup key (lower priority)
703 let mut services_a = HashMap::new();
704 services_a.insert(
705 "pds".to_string(),
706 ServiceEndpoint {
707 service_type: "AtprotoPersonalDataServer".to_string(),
708 endpoint: "https://pds-a.example.com".to_string(),
709 },
710 );
711
712 let op_a = Operation::new_update(
713 rotation_keys.clone(),
714 HashMap::new(),
715 vec![],
716 services_a,
717 genesis_cid.clone(),
718 )
719 .sign(&backup_key)
720 .unwrap();
721
722 let op_a_time = genesis_time + Duration::hours(1);
723
724 // Operation B: signed by primary key (higher priority), arrives 24 hours after A
725 let mut services_b = HashMap::new();
726 services_b.insert(
727 "pds".to_string(),
728 ServiceEndpoint {
729 service_type: "AtprotoPersonalDataServer".to_string(),
730 endpoint: "https://pds-b.example.com".to_string(),
731 },
732 );
733
734 let op_b = Operation::new_update(
735 rotation_keys.clone(),
736 HashMap::new(),
737 vec![],
738 services_b,
739 genesis_cid,
740 )
741 .sign(&primary_key)
742 .unwrap();
743
744 let op_b_time = op_a_time + Duration::hours(24); // Within 72-hour window
745
746 // Build operations and timestamps arrays
747 let operations = vec![genesis.clone(), op_a.clone(), op_b.clone()];
748 let timestamps = vec![genesis_time, op_a_time, op_b_time];
749
750 // Validate with fork resolution
751 let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps);
752 assert!(result.is_ok());
753
754 let state = result.unwrap();
755
756 // Operation B should win because it has higher priority (lower index)
757 // and was received within the 72-hour recovery window
758 assert_eq!(
759 state.services.get("pds").unwrap().endpoint,
760 "https://pds-b.example.com"
761 );
762 }
763
764 /// Test fork resolution where lower priority operation wins after recovery window expires
765 #[test]
766 fn test_fork_resolution_priority_outside_window() {
767 let primary_key = SigningKey::generate_p256(); // Index 0
768 let backup_key = SigningKey::generate_k256(); // Index 1
769
770 let rotation_keys = vec![primary_key.to_did_key(), backup_key.to_did_key()];
771
772 let genesis = Operation::new_genesis(
773 rotation_keys.clone(),
774 HashMap::new(),
775 vec![],
776 HashMap::new(),
777 )
778 .sign(&primary_key)
779 .unwrap();
780
781 let genesis_cid = genesis.cid().unwrap();
782 let genesis_time = Utc::now();
783
784 // Operation A: signed by backup key (lower priority)
785 let mut services_a = HashMap::new();
786 services_a.insert(
787 "pds".to_string(),
788 ServiceEndpoint {
789 service_type: "AtprotoPersonalDataServer".to_string(),
790 endpoint: "https://pds-a.example.com".to_string(),
791 },
792 );
793
794 let op_a = Operation::new_update(
795 rotation_keys.clone(),
796 HashMap::new(),
797 vec![],
798 services_a,
799 genesis_cid.clone(),
800 )
801 .sign(&backup_key)
802 .unwrap();
803
804 let op_a_time = genesis_time + Duration::hours(1);
805
806 // Operation B: signed by primary key, arrives 100 hours after A (outside 72-hour window)
807 let mut services_b = HashMap::new();
808 services_b.insert(
809 "pds".to_string(),
810 ServiceEndpoint {
811 service_type: "AtprotoPersonalDataServer".to_string(),
812 endpoint: "https://pds-b.example.com".to_string(),
813 },
814 );
815
816 let op_b = Operation::new_update(
817 rotation_keys.clone(),
818 HashMap::new(),
819 vec![],
820 services_b,
821 genesis_cid,
822 )
823 .sign(&primary_key)
824 .unwrap();
825
826 let op_b_time = op_a_time + Duration::hours(100); // Outside 72-hour window
827
828 let operations = vec![genesis.clone(), op_a.clone(), op_b.clone()];
829 let timestamps = vec![genesis_time, op_a_time, op_b_time];
830
831 let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps);
832 assert!(result.is_ok());
833
834 let state = result.unwrap();
835
836 // Operation A should win because even though B has higher priority,
837 // it arrived outside the 72-hour recovery window
838 assert_eq!(
839 state.services.get("pds").unwrap().endpoint,
840 "https://pds-a.example.com"
841 );
842 }
843
844 /// Test multiple forks at different points in the chain
845 #[test]
846 fn test_fork_resolution_multiple_forks() {
847 let key1 = SigningKey::generate_p256();
848 let key2 = SigningKey::generate_k256();
849 let key3 = SigningKey::generate_p256();
850
851 let rotation_keys = vec![key1.to_did_key(), key2.to_did_key(), key3.to_did_key()];
852
853 // Genesis
854 let genesis = Operation::new_genesis(
855 rotation_keys.clone(),
856 HashMap::new(),
857 vec![],
858 HashMap::new(),
859 )
860 .sign(&key1)
861 .unwrap();
862
863 let genesis_cid = genesis.cid().unwrap();
864 let genesis_time = Utc::now();
865
866 // First fork at genesis
867 let mut services_1a = HashMap::new();
868 services_1a.insert(
869 "pds".to_string(),
870 ServiceEndpoint {
871 service_type: "AtprotoPersonalDataServer".to_string(),
872 endpoint: "https://fork1a.example.com".to_string(),
873 },
874 );
875
876 let op_1a = Operation::new_update(
877 rotation_keys.clone(),
878 HashMap::new(),
879 vec![],
880 services_1a,
881 genesis_cid.clone(),
882 )
883 .sign(&key2)
884 .unwrap();
885
886 let op_1a_time = genesis_time + Duration::hours(1);
887
888 // Second operation at same fork (higher priority, within window)
889 let mut services_1b = HashMap::new();
890 services_1b.insert(
891 "pds".to_string(),
892 ServiceEndpoint {
893 service_type: "AtprotoPersonalDataServer".to_string(),
894 endpoint: "https://fork1b.example.com".to_string(),
895 },
896 );
897
898 let op_1b = Operation::new_update(
899 rotation_keys.clone(),
900 HashMap::new(),
901 vec![],
902 services_1b,
903 genesis_cid,
904 )
905 .sign(&key1)
906 .unwrap();
907
908 let op_1b_time = op_1a_time + Duration::hours(2);
909
910 // Next operation continues from op_1b (the winner)
911 let op_1b_cid = op_1b.cid().unwrap();
912
913 let mut services_2 = HashMap::new();
914 services_2.insert(
915 "pds".to_string(),
916 ServiceEndpoint {
917 service_type: "AtprotoPersonalDataServer".to_string(),
918 endpoint: "https://continuation.example.com".to_string(),
919 },
920 );
921
922 let op_2 = Operation::new_update(
923 rotation_keys.clone(),
924 HashMap::new(),
925 vec![],
926 services_2,
927 op_1b_cid,
928 )
929 .sign(&key1)
930 .unwrap();
931
932 let op_2_time = op_1b_time + Duration::hours(1);
933
934 let operations = vec![
935 genesis.clone(),
936 op_1a.clone(),
937 op_1b.clone(),
938 op_2.clone(),
939 ];
940 let timestamps = vec![genesis_time, op_1a_time, op_1b_time, op_2_time];
941
942 let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps);
943 assert!(result.is_ok());
944
945 let state = result.unwrap();
946
947 // The final state should be from op_2, which continues from op_1b
948 assert_eq!(
949 state.services.get("pds").unwrap().endpoint,
950 "https://continuation.example.com"
951 );
952 }
953
954 /// Test fork with three competing operations
955 #[test]
956 fn test_fork_resolution_three_way_fork() {
957 let key1 = SigningKey::generate_p256(); // Highest priority
958 let key2 = SigningKey::generate_k256(); // Medium priority
959 let key3 = SigningKey::generate_p256(); // Lowest priority
960
961 let rotation_keys = vec![key1.to_did_key(), key2.to_did_key(), key3.to_did_key()];
962
963 let genesis = Operation::new_genesis(
964 rotation_keys.clone(),
965 HashMap::new(),
966 vec![],
967 HashMap::new(),
968 )
969 .sign(&key1)
970 .unwrap();
971
972 let genesis_cid = genesis.cid().unwrap();
973 let genesis_time = Utc::now();
974
975 // Three competing operations
976 let mut services_a = HashMap::new();
977 services_a.insert(
978 "pds".to_string(),
979 ServiceEndpoint {
980 service_type: "AtprotoPersonalDataServer".to_string(),
981 endpoint: "https://op-a.example.com".to_string(),
982 },
983 );
984
985 let op_a = Operation::new_update(
986 rotation_keys.clone(),
987 HashMap::new(),
988 vec![],
989 services_a,
990 genesis_cid.clone(),
991 )
992 .sign(&key3)
993 .unwrap(); // Lowest priority, first to arrive
994
995 let op_a_time = genesis_time + Duration::hours(1);
996
997 let mut services_b = HashMap::new();
998 services_b.insert(
999 "pds".to_string(),
1000 ServiceEndpoint {
1001 service_type: "AtprotoPersonalDataServer".to_string(),
1002 endpoint: "https://op-b.example.com".to_string(),
1003 },
1004 );
1005
1006 let op_b = Operation::new_update(
1007 rotation_keys.clone(),
1008 HashMap::new(),
1009 vec![],
1010 services_b,
1011 genesis_cid.clone(),
1012 )
1013 .sign(&key2)
1014 .unwrap(); // Medium priority
1015
1016 let op_b_time = op_a_time + Duration::hours(2);
1017
1018 let mut services_c = HashMap::new();
1019 services_c.insert(
1020 "pds".to_string(),
1021 ServiceEndpoint {
1022 service_type: "AtprotoPersonalDataServer".to_string(),
1023 endpoint: "https://op-c.example.com".to_string(),
1024 },
1025 );
1026
1027 let op_c = Operation::new_update(
1028 rotation_keys.clone(),
1029 HashMap::new(),
1030 vec![],
1031 services_c,
1032 genesis_cid,
1033 )
1034 .sign(&key1)
1035 .unwrap(); // Highest priority, within window
1036
1037 let op_c_time = op_a_time + Duration::hours(10);
1038
1039 let operations = vec![
1040 genesis.clone(),
1041 op_a.clone(),
1042 op_b.clone(),
1043 op_c.clone(),
1044 ];
1045 let timestamps = vec![genesis_time, op_a_time, op_b_time, op_c_time];
1046
1047 let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps);
1048 assert!(result.is_ok());
1049
1050 let state = result.unwrap();
1051
1052 // Operation C should win (highest priority, within window)
1053 assert_eq!(
1054 state.services.get("pds").unwrap().endpoint,
1055 "https://op-c.example.com"
1056 );
1057 }
1058
1059 /// Test no fork - linear chain should work as before
1060 #[test]
1061 fn test_fork_resolution_no_fork() {
1062 let key = SigningKey::generate_p256();
1063 let rotation_keys = vec![key.to_did_key()];
1064
1065 let genesis = Operation::new_genesis(
1066 rotation_keys.clone(),
1067 HashMap::new(),
1068 vec![],
1069 HashMap::new(),
1070 )
1071 .sign(&key)
1072 .unwrap();
1073
1074 let genesis_cid = genesis.cid().unwrap();
1075 let genesis_time = Utc::now();
1076
1077 let mut services = HashMap::new();
1078 services.insert(
1079 "pds".to_string(),
1080 ServiceEndpoint {
1081 service_type: "AtprotoPersonalDataServer".to_string(),
1082 endpoint: "https://pds.example.com".to_string(),
1083 },
1084 );
1085
1086 let op1 = Operation::new_update(
1087 rotation_keys.clone(),
1088 HashMap::new(),
1089 vec![],
1090 services.clone(),
1091 genesis_cid,
1092 )
1093 .sign(&key)
1094 .unwrap();
1095
1096 let op1_time = genesis_time + Duration::hours(1);
1097
1098 let operations = vec![genesis.clone(), op1.clone()];
1099 let timestamps = vec![genesis_time, op1_time];
1100
1101 let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps);
1102 assert!(result.is_ok());
1103
1104 let state = result.unwrap();
1105 assert_eq!(
1106 state.services.get("pds").unwrap().endpoint,
1107 "https://pds.example.com"
1108 );
1109 }
1110
1111 /// Test fork resolution with rotation key changes
1112 #[test]
1113 fn test_fork_resolution_with_key_rotation() {
1114 let key1 = SigningKey::generate_p256();
1115 let key2 = SigningKey::generate_k256();
1116 let key3 = SigningKey::generate_p256();
1117
1118 // Initial rotation keys
1119 let rotation_keys_v1 = vec![key1.to_did_key(), key2.to_did_key()];
1120
1121 let genesis = Operation::new_genesis(
1122 rotation_keys_v1.clone(),
1123 HashMap::new(),
1124 vec![],
1125 HashMap::new(),
1126 )
1127 .sign(&key1)
1128 .unwrap();
1129
1130 let genesis_cid = genesis.cid().unwrap();
1131 let genesis_time = Utc::now();
1132
1133 // Update rotation keys in first operation
1134 let rotation_keys_v2 = vec![key1.to_did_key(), key3.to_did_key()];
1135
1136 let op1 = Operation::new_update(
1137 rotation_keys_v2.clone(),
1138 HashMap::new(),
1139 vec![],
1140 HashMap::new(),
1141 genesis_cid,
1142 )
1143 .sign(&key1)
1144 .unwrap();
1145
1146 let op1_cid = op1.cid().unwrap();
1147 let op1_time = genesis_time + Duration::hours(1);
1148
1149 // Create a fork at op1 - both operations use the new rotation keys
1150 let mut services_a = HashMap::new();
1151 services_a.insert(
1152 "pds".to_string(),
1153 ServiceEndpoint {
1154 service_type: "AtprotoPersonalDataServer".to_string(),
1155 endpoint: "https://op-a.example.com".to_string(),
1156 },
1157 );
1158
1159 let op_a = Operation::new_update(
1160 rotation_keys_v2.clone(),
1161 HashMap::new(),
1162 vec![],
1163 services_a,
1164 op1_cid.clone(),
1165 )
1166 .sign(&key3)
1167 .unwrap(); // Index 1 in new keys
1168
1169 let op_a_time = op1_time + Duration::hours(1);
1170
1171 let mut services_b = HashMap::new();
1172 services_b.insert(
1173 "pds".to_string(),
1174 ServiceEndpoint {
1175 service_type: "AtprotoPersonalDataServer".to_string(),
1176 endpoint: "https://op-b.example.com".to_string(),
1177 },
1178 );
1179
1180 let op_b = Operation::new_update(
1181 rotation_keys_v2.clone(),
1182 HashMap::new(),
1183 vec![],
1184 services_b,
1185 op1_cid,
1186 )
1187 .sign(&key1)
1188 .unwrap(); // Index 0 in new keys (higher priority)
1189
1190 let op_b_time = op_a_time + Duration::hours(2);
1191
1192 let operations = vec![
1193 genesis.clone(),
1194 op1.clone(),
1195 op_a.clone(),
1196 op_b.clone(),
1197 ];
1198 let timestamps = vec![genesis_time, op1_time, op_a_time, op_b_time];
1199
1200 let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps);
1201 assert!(result.is_ok());
1202
1203 let state = result.unwrap();
1204
1205 // Operation B should win (signed by key1 which is index 0 in the new rotation keys)
1206 assert_eq!(
1207 state.services.get("pds").unwrap().endpoint,
1208 "https://op-b.example.com"
1209 );
1210 }
1211
1212 /// Test that operations with mismatched timestamps and operations fail
1213 #[test]
1214 fn test_fork_resolution_mismatched_lengths() {
1215 let key = SigningKey::generate_p256();
1216 let rotation_keys = vec![key.to_did_key()];
1217
1218 let genesis = Operation::new_genesis(
1219 rotation_keys,
1220 HashMap::new(),
1221 vec![],
1222 HashMap::new(),
1223 )
1224 .sign(&key)
1225 .unwrap();
1226
1227 let operations = vec![genesis];
1228 let timestamps = vec![Utc::now(), Utc::now()]; // Different length
1229
1230 let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps);
1231 assert!(result.is_err());
1232 }
1233
1234 /// Test recovery window boundary (exactly 72 hours)
1235 #[test]
1236 fn test_fork_resolution_recovery_window_boundary() {
1237 let primary_key = SigningKey::generate_p256();
1238 let backup_key = SigningKey::generate_k256();
1239
1240 let rotation_keys = vec![primary_key.to_did_key(), backup_key.to_did_key()];
1241
1242 let genesis = Operation::new_genesis(
1243 rotation_keys.clone(),
1244 HashMap::new(),
1245 vec![],
1246 HashMap::new(),
1247 )
1248 .sign(&primary_key)
1249 .unwrap();
1250
1251 let genesis_cid = genesis.cid().unwrap();
1252 let genesis_time = Utc::now();
1253
1254 // Operation A: signed by backup key
1255 let mut services_a = HashMap::new();
1256 services_a.insert(
1257 "pds".to_string(),
1258 ServiceEndpoint {
1259 service_type: "AtprotoPersonalDataServer".to_string(),
1260 endpoint: "https://op-a.example.com".to_string(),
1261 },
1262 );
1263
1264 let op_a = Operation::new_update(
1265 rotation_keys.clone(),
1266 HashMap::new(),
1267 vec![],
1268 services_a,
1269 genesis_cid.clone(),
1270 )
1271 .sign(&backup_key)
1272 .unwrap();
1273
1274 let op_a_time = genesis_time + Duration::hours(1);
1275
1276 // Operation B: exactly at 72-hour boundary (should still be within window)
1277 let mut services_b = HashMap::new();
1278 services_b.insert(
1279 "pds".to_string(),
1280 ServiceEndpoint {
1281 service_type: "AtprotoPersonalDataServer".to_string(),
1282 endpoint: "https://op-b.example.com".to_string(),
1283 },
1284 );
1285
1286 let op_b = Operation::new_update(
1287 rotation_keys.clone(),
1288 HashMap::new(),
1289 vec![],
1290 services_b,
1291 genesis_cid,
1292 )
1293 .sign(&primary_key)
1294 .unwrap();
1295
1296 // Exactly 72 hours after op_a
1297 let op_b_time = op_a_time + Duration::hours(72);
1298
1299 let operations = vec![genesis.clone(), op_a.clone(), op_b.clone()];
1300 let timestamps = vec![genesis_time, op_a_time, op_b_time];
1301
1302 let result = OperationChainValidator::validate_chain_with_forks(&operations, ×tamps);
1303 assert!(result.is_ok());
1304
1305 let state = result.unwrap();
1306
1307 // At exactly 72 hours, the higher priority operation should still win
1308 assert_eq!(
1309 state.services.get("pds").unwrap().endpoint,
1310 "https://op-b.example.com"
1311 );
1312 }
1313}