diff --git a/README.md b/README.md index 10776533..1cef4e2d 100644 --- a/README.md +++ b/README.md @@ -628,6 +628,17 @@ soroban contract deploy \ --network testnet ``` +## Data Migration + +The `data_migration` payload library provides format-agnostic snapshot export/import capabilities across all contracts. + +### Import Replay Protection + +When importing payloads, the contracts use a `MigrationTracker` to prevent duplicate imports and replay attacks. +- Payload Identity is cryptographically bound to a `(checksum, version)` tuple. +- The state tracker persists this tuple alongside the ingestion `timestamp_ms` to outright reject replayed or copied payloads using `MigrationError::DuplicateImport`. +- Restores are safely guarded against double spending or overriding the active state multiple times. + ## Operational Limits ID and record-count operating limits (including `u32` overflow analysis and monitoring alerts) are documented in the **Operational Limits and Monitoring** section of [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/data_migration/src/lib.rs b/data_migration/src/lib.rs index e91e007b..82e723c8 100644 --- a/data_migration/src/lib.rs +++ b/data_migration/src/lib.rs @@ -185,6 +185,8 @@ pub enum MigrationError { InvalidFormat(String), ValidationFailed(String), DeserializeError(String), + /// Indicates that the payload has already been imported. + DuplicateImport, } impl std::fmt::Display for MigrationError { @@ -201,12 +203,48 @@ impl std::fmt::Display for MigrationError { MigrationError::InvalidFormat(s) => write!(f, "invalid format: {}", s), MigrationError::ValidationFailed(s) => write!(f, "validation failed: {}", s), MigrationError::DeserializeError(s) => write!(f, "deserialize error: {}", s), + MigrationError::DuplicateImport => write!(f, "duplicate payload import detected"), } } } impl std::error::Error for MigrationError {} +/// Tracks imported migration payloads to prevent replay attacks and duplicate restores. +/// +/// Binds payload identity to a `(checksum, version)` tuple. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MigrationTracker { + /// Stores the set of imported payloads, keyed by their checksum and version. + /// Tracks the timestamp when it was imported. + imported_payloads: HashMap<(String, u32), u64>, +} + +impl MigrationTracker { + pub fn new() -> Self { + Self { + imported_payloads: HashMap::new(), + } + } + + /// Mark a payload as imported. + /// Returns an error if it was already imported, preventing replay attacks. + pub fn mark_imported(&mut self, snapshot: &ExportSnapshot, timestamp_ms: u64) -> Result<(), MigrationError> { + let identity = (snapshot.header.checksum.clone(), snapshot.header.version); + if self.imported_payloads.contains_key(&identity) { + return Err(MigrationError::DuplicateImport); + } + self.imported_payloads.insert(identity, timestamp_ms); + Ok(()) + } + + /// Check if a snapshot has already been imported. + pub fn is_imported(&self, snapshot: &ExportSnapshot) -> bool { + let identity = (snapshot.header.checksum.clone(), snapshot.header.version); + self.imported_payloads.contains_key(&identity) + } +} + /// Export snapshot to JSON bytes. pub fn export_to_json(snapshot: &ExportSnapshot) -> Result, MigrationError> { serde_json::to_vec_pretty(snapshot).map_err(|e| MigrationError::DeserializeError(e.to_string())) @@ -260,19 +298,29 @@ pub fn import_from_encrypted_payload(encoded: &str) -> Result, Migration .map_err(|e| MigrationError::InvalidFormat(e.to_string())) } -/// Import snapshot from JSON bytes with validation. -pub fn import_from_json(bytes: &[u8]) -> Result { +/// Import snapshot from JSON bytes with validation and replay protection. +pub fn import_from_json( + bytes: &[u8], + tracker: &mut MigrationTracker, + timestamp_ms: u64, +) -> Result { let snapshot: ExportSnapshot = serde_json::from_slice(bytes) .map_err(|e| MigrationError::DeserializeError(e.to_string()))?; snapshot.validate_for_import()?; + tracker.mark_imported(&snapshot, timestamp_ms)?; Ok(snapshot) } -/// Import snapshot from binary bytes with validation. -pub fn import_from_binary(bytes: &[u8]) -> Result { +/// Import snapshot from binary bytes with validation and replay protection. +pub fn import_from_binary( + bytes: &[u8], + tracker: &mut MigrationTracker, + timestamp_ms: u64, +) -> Result { let snapshot: ExportSnapshot = bincode::deserialize(bytes).map_err(|e| MigrationError::DeserializeError(e.to_string()))?; snapshot.validate_for_import()?; + tracker.mark_imported(&snapshot, timestamp_ms)?; Ok(snapshot) } @@ -371,7 +419,8 @@ mod tests { }); let snapshot = ExportSnapshot::new(payload, ExportFormat::Json); let bytes = export_to_json(&snapshot).unwrap(); - let loaded = import_from_json(&bytes).unwrap(); + let mut tracker = MigrationTracker::new(); + let loaded = import_from_json(&bytes, &mut tracker, 123456).unwrap(); assert_eq!(loaded.header.version, SCHEMA_VERSION); assert!(loaded.verify_checksum()); } @@ -387,10 +436,34 @@ mod tests { }); let snapshot = ExportSnapshot::new(payload, ExportFormat::Binary); let bytes = export_to_binary(&snapshot).unwrap(); - let loaded = import_from_binary(&bytes).unwrap(); + let mut tracker = MigrationTracker::new(); + let loaded = import_from_binary(&bytes, &mut tracker, 123456).unwrap(); assert!(loaded.verify_checksum()); } + #[test] + fn test_import_replay_protection_prevents_duplicates() { + let payload = SnapshotPayload::RemittanceSplit(RemittanceSplitExport { + owner: "GREPLAY".into(), + spending_percent: 50, + savings_percent: 30, + bills_percent: 10, + insurance_percent: 10, + }); + let snapshot = ExportSnapshot::new(payload, ExportFormat::Json); + let bytes = export_to_json(&snapshot).unwrap(); + + let mut tracker = MigrationTracker::new(); + + // First import should succeed + let loaded1 = import_from_json(&bytes, &mut tracker, 1000).unwrap(); + assert!(tracker.is_imported(&loaded1)); + + // Second import of the exact same snapshot should fail + let result2 = import_from_json(&bytes, &mut tracker, 2000); + assert_eq!(result2.unwrap_err(), MigrationError::DuplicateImport); + } + #[test] fn test_checksum_mismatch_import_fails() { let payload = SnapshotPayload::RemittanceSplit(RemittanceSplitExport { diff --git a/orchestrator/src/test.rs b/orchestrator/src/test.rs index cd0ece6f..0107a71c 100644 --- a/orchestrator/src/test.rs +++ b/orchestrator/src/test.rs @@ -18,7 +18,6 @@ impl MockFamilyWallet { } } -/// Mock Remittance Split contract for testing #[contract] pub struct MockRemittanceSplit;