#![forbid(unsafe_code)] use std::fs; use ironstorage::{ mobile_watch::{ MobileWatchSnapshotState, WatchPersistenceAction, WatchPresentationState, WatchRuntime, WatchSnapshotApply, WatchSnapshotEntry, WatchSnapshotReceiver, WatchSnapshotSender, }, otp::OtpAlgorithm, repository::{EntryPath, SecretBytes}, }; type TestResult = Result<(), Box>; fn entry(path: &str, issuer: &str, account: &str, secret: &[u8]) -> WatchSnapshotEntry { WatchSnapshotEntry::new( EntryPath::parse(path).expect("fixture path").to_string(), Some(issuer.to_owned()), account.to_owned(), OtpAlgorithm::Sha256, 8, 30, SecretBytes::new(secret.to_vec()), ) } #[test] fn replacement_snapshots_reject_replays_conflicts_and_pairing_changes() -> TestResult { let directory = tempfile::tempdir()?; let journal = directory.path().join("watch-snapshot.toml"); let mut sender = WatchSnapshotSender::load(journal.clone()); let first = sender.prepare( "paired-watch-a", vec![entry("otp/alice", "Acme", "alice", b"first-secret")], )?; assert_eq!(first.revision(), 1); assert_eq!(sender.status().state(), MobileWatchSnapshotState::Pending); let mut receiver = WatchSnapshotReceiver::default(); assert_eq!( receiver.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?, WatchSnapshotApply::Replaced ); assert_eq!(receiver.current().expect("snapshot").entries().len(), 1); assert_eq!( sender.acknowledge(first.delivered_receipt())?.state(), MobileWatchSnapshotState::Delivered ); assert_eq!( sender .acknowledge(&receiver.current_receipt().expect("accepted receipt"))? .state(), MobileWatchSnapshotState::Current ); let duplicate = sender.prepare( "paired-watch-a", vec![entry("otp/alice", "Acme", "alice", b"first-secret")], )?; assert_eq!(duplicate.revision(), 1); assert_eq!(duplicate.snapshot().expose(), first.snapshot().expose()); assert_eq!( receiver.apply(SecretBytes::new(duplicate.snapshot().expose().to_vec()))?, WatchSnapshotApply::Duplicate ); let replacement = sender.prepare( "paired-watch-a", vec![entry("otp/bob", "Acme", "bob", b"second-secret")], )?; assert_eq!(replacement.revision(), 2); assert_eq!( receiver.apply(SecretBytes::new(replacement.snapshot().expose().to_vec()))?, WatchSnapshotApply::Replaced ); assert_eq!( receiver.current().expect("replacement").entries()[0].account(), "bob" ); assert_eq!( receiver.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?, WatchSnapshotApply::Stale ); assert_eq!( receiver.current().expect("stale ignored").entries()[0].account(), "bob" ); let revoked = sender.prepare("paired-watch-a", Vec::new())?; assert_eq!(revoked.revision(), 3); assert_eq!( receiver.apply(SecretBytes::new(revoked.snapshot().expose().to_vec()))?, WatchSnapshotApply::Revoked ); assert!( receiver .current() .expect("revocation marker") .is_revocation() ); assert_eq!( receiver.apply(SecretBytes::new(replacement.snapshot().expose().to_vec()))?, WatchSnapshotApply::Stale ); let changed_watch = sender.prepare( "paired-watch-b", vec![entry("otp/carol", "Acme", "carol", b"third-secret")], )?; assert_eq!(changed_watch.revision(), 4); assert_eq!( receiver.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, WatchSnapshotApply::PairingChanged ); assert_eq!(receiver.current().expect("new pairing").revision(), 4); let mut fresh_watch = WatchSnapshotReceiver::default(); assert_eq!( fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, WatchSnapshotApply::Replaced ); let mut damaged = changed_watch.snapshot().expose().to_vec(); damaged[20] ^= 0x55; assert!(fresh_watch.apply(SecretBytes::new(damaged)).is_err()); assert!(fresh_watch.current().is_none()); assert_eq!( fresh_watch.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?, WatchSnapshotApply::Stale ); assert_eq!( fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, WatchSnapshotApply::Replaced ); let persisted = fs::read_to_string(journal)?; for forbidden in [ "first-secret", "second-secret", "third-secret", "otpauth://", "94287082", ] { assert!(!persisted.contains(forbidden), "journal leaked {forbidden}"); } Ok(()) } #[test] fn journal_keeps_revisions_monotonic_across_sender_reloads() -> TestResult { let directory = tempfile::tempdir()?; let journal = directory.path().join("watch-snapshot.toml"); let first = WatchSnapshotSender::load(journal.clone()).prepare( "paired-watch", vec![entry("otp/alice", "Acme", "alice", b"secret")], )?; assert_eq!(first.revision(), 1); let same = WatchSnapshotSender::load(journal.clone()).prepare( "paired-watch", vec![entry("otp/alice", "Acme", "alice", b"secret")], )?; assert_eq!(same.revision(), 1); let changed = WatchSnapshotSender::load(journal).prepare("paired-watch", Vec::new())?; assert_eq!(changed.revision(), 2); Ok(()) } #[test] fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> TestResult { let directory = tempfile::tempdir()?; let mut sender = WatchSnapshotSender::load(directory.path().join("watch-snapshot.toml")); let snapshot = sender.prepare( "paired-watch", vec![WatchSnapshotEntry::new( "otp/alice".to_owned(), Some("Acme".to_owned()), "alice".to_owned(), OtpAlgorithm::Sha1, 8, 30, SecretBytes::new(b"12345678901234567890".to_vec()), )], )?; let mut runtime = WatchRuntime::default(); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Syncing ); runtime.sync_finished(); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Empty ); runtime.sync_started(); runtime.sync_unavailable(); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Unavailable ); runtime.sync_started(); let update = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?; assert_eq!(update.apply(), WatchSnapshotApply::Replaced); assert_eq!(update.persistence(), WatchPersistenceAction::Replace); assert_eq!(update.selected_entries(), 1); assert!(!update.receipt().is_empty()); let records = runtime.records_at(59)?; assert_eq!(records.len(), 1); assert_eq!(records[0].path(), "otp/alice"); assert_eq!(records[0].code().expose(), b"94287082"); assert_eq!(records[0].valid_until(), 60); assert_eq!(records[0].remaining_at(59), 1); let presentation = runtime.presentation_at(59)?; assert_eq!(presentation.state(), WatchPresentationState::Ready); assert_eq!(presentation.records()[0].remaining(), 1); runtime.protected_data_unavailable(); assert!(runtime.records_at(59).is_err()); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Locked ); let restored = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?; assert_eq!(restored.persistence(), WatchPersistenceAction::Replace); assert_eq!(runtime.records_at(59)?[0].code().expose(), b"94287082"); let revocation = sender.prepare("paired-watch", Vec::new())?; let revoked = runtime.apply_snapshot(revocation.snapshot().expose().to_vec())?; assert_eq!(revoked.apply(), WatchSnapshotApply::Revoked); assert_eq!(revoked.persistence(), WatchPersistenceAction::Delete); assert!(runtime.records_at(59)?.is_empty()); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Empty ); runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?; assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Stale ); assert!(runtime.apply_snapshot(vec![0; 64]).is_err()); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Error ); Ok(()) }