448 lines
16 KiB
Rust
448 lines
16 KiB
Rust
#![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<dyn std::error::Error>>;
|
|
|
|
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 reset_journal = directory.path().join("reset-watch-snapshot.toml");
|
|
let mut reset_sender = WatchSnapshotSender::load(reset_journal);
|
|
let reset_revocation = reset_sender.prepare("paired-watch-a", Vec::new())?;
|
|
assert_eq!(reset_revocation.revision(), 1);
|
|
assert_eq!(
|
|
receiver.apply(SecretBytes::new(
|
|
reset_revocation.snapshot().expose().to_vec()
|
|
))?,
|
|
WatchSnapshotApply::Stale
|
|
);
|
|
assert_eq!(
|
|
receiver.replace_authoritative(SecretBytes::new(
|
|
reset_revocation.snapshot().expose().to_vec()
|
|
))?,
|
|
WatchSnapshotApply::Revoked
|
|
);
|
|
assert!(
|
|
receiver
|
|
.current()
|
|
.expect("reset revocation")
|
|
.is_revocation()
|
|
);
|
|
let after_reset = reset_sender.prepare(
|
|
"paired-watch-a",
|
|
vec![entry("otp/reset", "Acme", "reset", b"reset-secret")],
|
|
)?;
|
|
assert_eq!(after_reset.revision(), 2);
|
|
assert_eq!(
|
|
receiver.apply(SecretBytes::new(after_reset.snapshot().expose().to_vec()))?,
|
|
WatchSnapshotApply::Replaced
|
|
);
|
|
|
|
let changed_watch = sender.prepare(
|
|
"paired-watch-b",
|
|
vec![entry("otp/carol", "Acme", "carol", b"third-secret")],
|
|
)?;
|
|
assert_eq!(changed_watch.revision(), 1);
|
|
assert_eq!(
|
|
receiver.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
|
|
WatchSnapshotApply::Stale
|
|
);
|
|
assert_eq!(
|
|
receiver
|
|
.replace_authoritative(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
|
|
WatchSnapshotApply::PairingChanged
|
|
);
|
|
assert_eq!(receiver.current().expect("new pairing").revision(), 1);
|
|
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_eq!(
|
|
fresh_watch
|
|
.current()
|
|
.expect("last valid snapshot")
|
|
.entries()[0]
|
|
.account(),
|
|
"carol"
|
|
);
|
|
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::Duplicate
|
|
);
|
|
|
|
let persisted = fs::read_to_string(journal)?;
|
|
for forbidden in [
|
|
"first-secret",
|
|
"second-secret",
|
|
"third-secret",
|
|
"reset-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 journal_upgrade_starts_a_new_generation_at_revision_one() -> TestResult {
|
|
let directory = tempfile::tempdir()?;
|
|
let journal = directory.path().join("watch-snapshot.toml");
|
|
let entries = || vec![entry("otp/alice", "Acme", "alice", b"secret")];
|
|
WatchSnapshotSender::load(journal.clone()).prepare("paired-watch", entries())?;
|
|
|
|
let legacy = fs::read_to_string(&journal)?
|
|
.lines()
|
|
.filter(|line| !line.starts_with("generation = "))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
.replace("revision = 1", "revision = 42");
|
|
fs::write(&journal, legacy)?;
|
|
|
|
let reset = WatchSnapshotSender::load(journal.clone()).prepare("paired-watch", entries())?;
|
|
assert_eq!(reset.revision(), 1);
|
|
let reloaded = WatchSnapshotSender::load(journal).prepare("paired-watch", entries())?;
|
|
assert_eq!(reloaded.revision(), 1);
|
|
assert_eq!(reset.snapshot().expose(), reloaded.snapshot().expose());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_delivery_after_watch_restart_recovers_a_lost_acknowledgement() -> TestResult {
|
|
let directory = tempfile::tempdir()?;
|
|
let mut sender = WatchSnapshotSender::load(directory.path().join("watch-snapshot.toml"));
|
|
let transfer = sender.prepare(
|
|
"paired-watch",
|
|
vec![entry("otp/alice", "Acme", "alice", b"secret")],
|
|
)?;
|
|
let persisted_snapshot = transfer.snapshot().expose().to_vec();
|
|
|
|
let mut interrupted_runtime = WatchRuntime::default();
|
|
let applied = interrupted_runtime.apply_snapshot(persisted_snapshot.clone())?;
|
|
assert_eq!(applied.persistence(), WatchPersistenceAction::Replace);
|
|
assert_eq!(sender.status().state(), MobileWatchSnapshotState::Pending);
|
|
|
|
let mut restarted_runtime = WatchRuntime::default();
|
|
restarted_runtime.apply_snapshot(persisted_snapshot.clone())?;
|
|
let redelivered = restarted_runtime.apply_snapshot(persisted_snapshot)?;
|
|
assert_eq!(redelivered.apply(), WatchSnapshotApply::Duplicate);
|
|
assert_eq!(redelivered.persistence(), WatchPersistenceAction::Keep);
|
|
assert_eq!(
|
|
sender.acknowledge(redelivered.receipt())?.state(),
|
|
MobileWatchSnapshotState::Current
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn confirmed_unchanged_snapshot_stays_current_when_republished() -> TestResult {
|
|
let directory = tempfile::tempdir()?;
|
|
let journal = directory.path().join("watch-snapshot.toml");
|
|
let entries = || vec![entry("otp/alice", "Acme", "alice", b"secret")];
|
|
let mut sender = WatchSnapshotSender::load(journal.clone());
|
|
let first = sender.prepare("paired-watch", entries())?;
|
|
let receipt = WatchRuntime::default()
|
|
.apply_snapshot(first.snapshot().expose().to_vec())?
|
|
.receipt()
|
|
.to_vec();
|
|
assert_eq!(
|
|
sender.acknowledge(&receipt)?.state(),
|
|
MobileWatchSnapshotState::Current
|
|
);
|
|
|
|
assert_eq!(sender.prepare("paired-watch", entries())?.revision(), 1);
|
|
assert_eq!(sender.status().state(), MobileWatchSnapshotState::Current);
|
|
let mut reloaded = WatchSnapshotSender::load(journal);
|
|
assert_eq!(reloaded.status().state(), MobileWatchSnapshotState::Current);
|
|
reloaded.prepare("paired-watch", entries())?;
|
|
assert_eq!(reloaded.status().state(), MobileWatchSnapshotState::Current);
|
|
assert_eq!(reloaded.prepare("paired-watch", Vec::new())?.revision(), 2);
|
|
assert_eq!(reloaded.status().state(), MobileWatchSnapshotState::Pending);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn authoritative_context_switches_sender_generation_and_rejects_delayed_packets() -> TestResult {
|
|
let directory = tempfile::tempdir()?;
|
|
let mut old_sender = WatchSnapshotSender::load(directory.path().join("old.toml"));
|
|
old_sender.prepare(
|
|
"paired-watch",
|
|
vec![entry("otp/alice", "Acme", "alice", b"old-secret")],
|
|
)?;
|
|
for revision in 2..5 {
|
|
old_sender.prepare(
|
|
"paired-watch",
|
|
vec![entry(
|
|
"otp/alice",
|
|
"Acme",
|
|
&format!("alice-{revision}"),
|
|
b"old-secret",
|
|
)],
|
|
)?;
|
|
}
|
|
let old = old_sender.prepare(
|
|
"paired-watch",
|
|
vec![entry("otp/alice", "Acme", "alice-5", b"old-secret")],
|
|
)?;
|
|
assert_eq!(old.revision(), 5);
|
|
|
|
let mut new_sender = WatchSnapshotSender::load(directory.path().join("new.toml"));
|
|
let new = new_sender.prepare(
|
|
"paired-watch",
|
|
vec![entry("otp/bob", "Acme", "bob", b"new-secret")],
|
|
)?;
|
|
assert_eq!(new.revision(), 1);
|
|
|
|
let mut runtime = WatchRuntime::default();
|
|
runtime.apply_snapshot(old.snapshot().expose().to_vec())?;
|
|
let queued_new = runtime.apply_snapshot(new.snapshot().expose().to_vec())?;
|
|
assert_eq!(queued_new.apply(), WatchSnapshotApply::Stale);
|
|
assert_eq!(
|
|
runtime.presentation_at(59)?.state(),
|
|
WatchPresentationState::Ready
|
|
);
|
|
assert_eq!(runtime.records_at(59)?[0].account(), "alice-5");
|
|
|
|
let replaced = runtime.apply_authoritative_snapshot(new.snapshot().expose().to_vec())?;
|
|
assert_eq!(replaced.apply(), WatchSnapshotApply::PairingChanged);
|
|
assert_eq!(replaced.persistence(), WatchPersistenceAction::Replace);
|
|
assert_eq!(runtime.records_at(59)?[0].account(), "bob");
|
|
assert_eq!(
|
|
new_sender.acknowledge(replaced.receipt())?.state(),
|
|
MobileWatchSnapshotState::Current
|
|
);
|
|
|
|
let delayed_old = runtime.apply_snapshot(old.snapshot().expose().to_vec())?;
|
|
assert_eq!(delayed_old.apply(), WatchSnapshotApply::Stale);
|
|
assert_eq!(runtime.records_at(59)?[0].account(), "bob");
|
|
|
|
let newer = new_sender.prepare(
|
|
"paired-watch",
|
|
vec![entry("otp/carol", "Acme", "carol", b"newer-secret")],
|
|
)?;
|
|
runtime.apply_authoritative_snapshot(newer.snapshot().expose().to_vec())?;
|
|
let delayed_context = runtime.apply_authoritative_snapshot(new.snapshot().expose().to_vec())?;
|
|
assert_eq!(delayed_context.apply(), WatchSnapshotApply::Stale);
|
|
assert_eq!(runtime.records_at(59)?[0].account(), "carol");
|
|
|
|
assert!(runtime.apply_snapshot(vec![0; 64]).is_err());
|
|
assert_eq!(runtime.records_at(59)?[0].account(), "carol");
|
|
assert_eq!(
|
|
runtime.presentation_at(59)?.state(),
|
|
WatchPresentationState::Stale
|
|
);
|
|
runtime.sync_unavailable();
|
|
assert_eq!(runtime.records_at(59)?[0].account(), "carol");
|
|
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::Empty
|
|
);
|
|
assert!(runtime.apply_snapshot(vec![0; 64]).is_err());
|
|
assert_eq!(
|
|
runtime.presentation_at(59)?.state(),
|
|
WatchPresentationState::Stale
|
|
);
|
|
Ok(())
|
|
}
|