Files
IronStorage/crates/storage/tests/mobile_totp.rs

519 lines
18 KiB
Rust

#![forbid(unsafe_code)]
mod support;
use std::{
collections::{BTreeMap, BTreeSet},
fs,
};
use ironstorage::{
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
document::EntryDocumentService,
mobile_totp::{
MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService,
MobileWatchSnapshotState,
},
otp::OtpError,
recipient::RecipientPolicyManager,
repository::{EncryptedEntry, EntryPath, Repository, SecretBytes},
};
use support::compatibility::{FixtureSet, TestResult};
struct FixtureSecrets(BTreeMap<String, Vec<u8>>);
impl FixtureSecrets {
fn all(fixture: &FixtureSet) -> Self {
Self(
fixture
.generated
.keys
.iter()
.map(|key| {
(
key.primary_fingerprint.clone(),
key.passphrase.as_bytes().to_vec(),
)
})
.collect(),
)
}
}
impl SecretProvider for FixtureSecrets {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
self.0
.get(key.fingerprint().as_str())
.cloned()
.map(SecretBytes::new)
.ok_or(SecretProviderError::Unavailable)
}
}
struct CountingSecrets {
inner: FixtureSecrets,
requests: usize,
}
impl CountingSecrets {
fn all(fixture: &FixtureSet) -> Self {
Self {
inner: FixtureSecrets::all(fixture),
requests: 0,
}
}
}
impl SecretProvider for CountingSecrets {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
self.requests += 1;
self.inner.secret_for(key)
}
}
struct CancellingSecrets<'a> {
inner: FixtureSecrets,
operation: &'a MobileTotpOperation,
requests: usize,
}
struct MutatingSecrets<'a> {
inner: FixtureSecrets,
repository: &'a Repository,
path: EntryPath,
replacement: Option<EncryptedEntry>,
}
impl SecretProvider for MutatingSecrets<'_> {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
let secret = self.inner.secret_for(key)?;
if let Some(replacement) = self.replacement.take() {
self.repository
.write_entry(&self.path, &replacement)
.expect("fixture mutation succeeds");
}
Ok(secret)
}
}
impl SecretProvider for CancellingSecrets<'_> {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
self.requests += 1;
let secret = self.inner.secret_for(key)?;
if self.requests == 1 {
self.operation.cancel();
}
Ok(secret)
}
}
#[test]
fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
write_plaintext(
&repository,
&keys,
"otp/alice",
b"password\notpauth://totp/Acme:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme&digits=8&period=30\n",
)?;
write_plaintext(
&repository,
&keys,
"otp/counter",
b"otpauth://hotp/Counter?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0\n",
)?;
write_plaintext(&repository, &keys, "ordinary", b"password\nlogin: alice\n")?;
let service = MobileTotpService::new(&repository, &keys);
let mut secrets = FixtureSecrets::all(&fixture);
let page = service.page(&BTreeSet::new(), &mut secrets)?;
let alice = page
.rows()
.iter()
.find(|row| row.path() == "otp/alice")
.expect("inserted TOTP row");
assert_eq!(alice.issuer(), Some("Acme"));
assert_eq!(alice.account(), "alice@example.com");
assert!(!alice.shared_with_watch());
assert!(page.rows().iter().all(|row| row.path() != "otp/counter"));
assert_eq!(page.watch().state(), MobileWatchSnapshotState::Unavailable);
assert!(!format!("{page:?}").contains("94287082"));
let detail = service.detail("otp/alice", 59, &BTreeSet::new(), &mut secrets)?;
assert_eq!(detail.code().expose(), b"94287082");
assert_eq!(detail.valid_until(), 60);
assert_eq!(detail.period(), 30);
assert!(!format!("{detail:?}").contains("94287082"));
let selected = BTreeSet::from([EntryPath::parse("otp/alice")?]);
let page = service.page(&selected, &mut secrets)?;
assert!(
page.rows()
.iter()
.find(|row| row.path() == "otp/alice")
.expect("selected TOTP row")
.shared_with_watch()
);
assert_eq!(page.watch().state(), MobileWatchSnapshotState::Pending);
let detail = service.detail("otp/alice", 60, &selected, &mut secrets)?;
assert!(detail.shared_with_watch());
assert_eq!(detail.valid_until(), 90);
Ok(())
}
#[test]
fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
write_plaintext(
&repository,
&keys,
"otp/alice",
b"password\notpauth://totp/Acme:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme\n",
)?;
for path in ["team-a", "team-a/shared", "team-b/shared"] {
write_plaintext(
&repository,
&keys,
path,
b"otpauth://totp/Shared?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n",
)?;
}
write_plaintext(&repository, &keys, "ordinary", b"password\nlogin: alice\n")?;
let cache_directory = tempfile::tempdir()?;
let cache = cache_directory.path().join("totp-catalog.toml");
let service = MobileTotpService::new(&repository, &keys);
let cold = MobileTotpOperation::default();
let mut cold_secrets = CountingSecrets::all(&fixture);
let page = service.discover(&BTreeSet::new(), &mut cold_secrets, &cache, &cold)?;
assert!(page.rows().iter().any(|row| row.path() == "otp/alice"));
assert!(page.rows().iter().any(|row| row.path() == "team-a"));
assert!(page.rows().iter().any(|row| row.path() == "team-a/shared"));
assert!(page.rows().iter().any(|row| row.path() == "team-b/shared"));
assert_eq!(cold.progress().phase(), MobileTotpDiscoveryPhase::Complete);
assert_eq!(cold.progress().inspected(), cold.progress().total());
assert!(cold_secrets.requests > 0);
let encoded = fs::read_to_string(&cache)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(fs::metadata(&cache)?.permissions().mode() & 0o777, 0o600);
}
assert!(encoded.contains("is_totp = true"));
assert!(encoded.contains("ciphertext_hash ="));
assert!(encoded.contains("otp/alice"));
for secret in [
"otpauth://",
"secret=",
"alice@example.com",
"Acme",
"password",
] {
assert!(!encoded.contains(secret), "cache leaked {secret}");
}
let warm = MobileTotpOperation::default();
let mut warm_secrets = CountingSecrets::all(&fixture);
let warm_page = service.discover(&BTreeSet::new(), &mut warm_secrets, &cache, &warm)?;
assert_eq!(warm_secrets.requests, 0);
assert_eq!(warm.progress().cache_hits(), warm.progress().total());
assert_eq!(warm_page.rows(), page.rows());
assert_eq!(
service
.cached_page(&BTreeSet::new(), &cache)
.expect("created cache")
.rows(),
page.rows()
);
assert_eq!(
service
.search_cached_page(&BTreeSet::new(), &cache, " TEAM-A ")
.expect("created cache")
.rows()
.iter()
.map(|row| row.path())
.collect::<Vec<_>>(),
["team-a/shared", "team-a"]
);
assert!(
service
.search_cached_page(&BTreeSet::new(), &cache, "missing")
.expect("created cache")
.rows()
.is_empty()
);
write_plaintext(
&repository,
&keys,
"ordinary",
b"otpauth://totp/New:new@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=New\n",
)?;
let changed = MobileTotpOperation::default();
let mut changed_secrets = CountingSecrets::all(&fixture);
let changed_page =
service.discover(&BTreeSet::new(), &mut changed_secrets, &cache, &changed)?;
assert_eq!(changed_secrets.requests, 1);
assert_eq!(
changed.progress().cache_hits() + 1,
changed.progress().total()
);
assert!(
changed_page
.rows()
.iter()
.any(|row| row.path() == "ordinary")
);
repository.remove_entry(&EntryPath::parse("otp/alice")?)?;
let deleted = MobileTotpOperation::default();
let mut deleted_secrets = CountingSecrets::all(&fixture);
let deleted_page =
service.discover(&BTreeSet::new(), &mut deleted_secrets, &cache, &deleted)?;
assert_eq!(deleted_secrets.requests, 0);
assert!(
deleted_page
.rows()
.iter()
.all(|row| row.path() != "otp/alice")
);
assert!(
service
.cached_page(&BTreeSet::new(), &cache)
.expect("updated cache")
.rows()
.iter()
.all(|row| row.path() != "otp/alice")
);
let cancelled = MobileTotpOperation::default();
cancelled.cancel();
let mut cancelled_secrets = CountingSecrets::all(&fixture);
assert!(matches!(
service.discover(&BTreeSet::new(), &mut cancelled_secrets, &cache, &cancelled,),
Err(MobileTotpError::Otp(OtpError::Cancelled))
));
assert_eq!(
cancelled.progress().phase(),
MobileTotpDiscoveryPhase::Cancelled
);
fs::write(&cache, "not a TOTP catalog")?;
let recovery = MobileTotpOperation::default();
let mut recovery_secrets = CountingSecrets::all(&fixture);
let recovered = service.discover(&BTreeSet::new(), &mut recovery_secrets, &cache, &recovery)?;
assert!(recovered.cache_notice().is_some());
assert!(service.cached_page(&BTreeSet::new(), &cache).is_some());
let other_store = fixture.materialize_store("nested")?;
let other_repository = Repository::open(other_store.path())?;
assert!(
MobileTotpService::new(&other_repository, &keys)
.cached_page(&BTreeSet::new(), &cache)
.is_none()
);
Ok(())
}
#[test]
fn totp_cache_survives_application_container_relocation() -> TestResult {
let fixture = FixtureSet::load()?;
let application = tempfile::tempdir()?;
let installed = application.path().join("installed");
fs::create_dir(&installed)?;
let vault = installed.join("vault");
fs::rename(fixture.materialize_store("basic")?.keep(), &vault)?;
let cache = installed.join("totp-catalog.toml");
let keys = KeyStore::load(fixture.path("keys"))?;
let expected = {
let repository = Repository::open(&vault)?;
let service = MobileTotpService::new(&repository, &keys);
let operation = MobileTotpOperation::default();
let mut secrets = CountingSecrets::all(&fixture);
service.discover(&BTreeSet::new(), &mut secrets, &cache, &operation)?
};
let updated = application.path().join("updated");
fs::rename(&installed, &updated)?;
let repository = Repository::open(updated.join("vault"))?;
let service = MobileTotpService::new(&repository, &keys);
let relocated_cache = updated.join("totp-catalog.toml");
assert_eq!(
service
.cached_page(&BTreeSet::new(), &relocated_cache)
.expect("relocated cache remains valid")
.rows(),
expected.rows()
);
let operation = MobileTotpOperation::default();
let mut secrets = CountingSecrets::all(&fixture);
service.discover(&BTreeSet::new(), &mut secrets, &relocated_cache, &operation)?;
assert_eq!(secrets.requests, 0);
assert_eq!(
operation.progress().cache_hits(),
operation.progress().total()
);
Ok(())
}
#[test]
fn decrypted_entry_details_reconcile_totp_cache_without_persisting_secrets() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let path = EntryPath::parse("otp/detail")?;
write_plaintext(
&repository,
&keys,
"otp/detail",
b"password\notpauth://totp/Acme:detail@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme&digits=8&period=30\n",
)?;
let cache_directory = tempfile::tempdir()?;
let cache = cache_directory.path().join("totp-catalog.toml");
let service = MobileTotpService::new(&repository, &keys);
let mut secrets = FixtureSecrets::all(&fixture);
let document =
EntryDocumentService::new(&repository, &keys).open("otp/detail", &mut secrets)?;
let ciphertext = repository.read_entry(&path)?;
let reconciliation =
service.reconcile_document(&document, &ciphertext, Some(59), &BTreeSet::new(), &cache)?;
let detail = reconciliation.detail().expect("valid TOTP detail");
assert_eq!(detail.code().expose(), b"94287082");
assert_eq!(detail.valid_until(), 60);
assert!(
service
.cached_page(&BTreeSet::new(), &cache)
.expect("detail created cache")
.rows()
.iter()
.any(|row| row.path() == "otp/detail")
);
let encoded = fs::read_to_string(&cache)?;
assert!(encoded.contains("otp/detail"));
assert!(encoded.contains("is_totp = true"));
for secret in ["otpauth://", "secret=", "detail@example.com", "password"] {
assert!(!encoded.contains(secret), "cache leaked {secret}");
}
write_plaintext(
&repository,
&keys,
"otp/detail",
b"password\nlogin: detail\n",
)?;
let mut secrets = FixtureSecrets::all(&fixture);
let document =
EntryDocumentService::new(&repository, &keys).open("otp/detail", &mut secrets)?;
let ciphertext = repository.read_entry(&path)?;
let reconciliation =
service.reconcile_document(&document, &ciphertext, Some(59), &BTreeSet::new(), &cache)?;
assert!(reconciliation.detail().is_none());
assert!(
service
.cached_page(&BTreeSet::new(), &cache)
.expect("detail updated cache")
.rows()
.iter()
.all(|row| row.path() != "otp/detail")
);
assert!(fs::read_to_string(cache)?.contains("is_totp = false"));
Ok(())
}
#[test]
fn cancelled_discovery_checkpoints_completed_entries() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let cache_directory = tempfile::tempdir()?;
let cache = cache_directory.path().join("totp-catalog.toml");
let service = MobileTotpService::new(&repository, &keys);
let interrupted = MobileTotpOperation::default();
let mut secrets = CancellingSecrets {
inner: FixtureSecrets::all(&fixture),
operation: &interrupted,
requests: 0,
};
assert!(matches!(
service.discover(&BTreeSet::new(), &mut secrets, &cache, &interrupted,),
Err(MobileTotpError::Otp(OtpError::Cancelled))
));
assert!(cache.is_file());
let resumed = MobileTotpOperation::default();
let mut resumed_secrets = CountingSecrets::all(&fixture);
service.discover(&BTreeSet::new(), &mut resumed_secrets, &cache, &resumed)?;
assert!(resumed.progress().cache_hits() >= 1);
assert!(resumed_secrets.requests < resumed.progress().total() as usize);
Ok(())
}
#[test]
fn discovery_rejects_a_result_when_ciphertext_changes_mid_scan() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let path = EntryPath::parse("ordinary")?;
write_plaintext(&repository, &keys, "ordinary", b"password\n")?;
let recipients =
RecipientPolicyManager::new(&repository, &keys).resolve_for_entry(&path, None)?;
let replacement = keys.encrypt(
SecretBytes::new(b"changed password\n".to_vec()),
recipients.recipients(),
)?;
let cache_directory = tempfile::tempdir()?;
let cache = cache_directory.path().join("totp-catalog.toml");
let operation = MobileTotpOperation::default();
let mut secrets = MutatingSecrets {
inner: FixtureSecrets::all(&fixture),
repository: &repository,
path,
replacement: Some(replacement),
};
assert!(matches!(
MobileTotpService::new(&repository, &keys).discover(
&BTreeSet::new(),
&mut secrets,
&cache,
&operation,
),
Err(MobileTotpError::ConcurrentModification)
));
assert_ne!(
operation.progress().phase(),
MobileTotpDiscoveryPhase::Complete
);
Ok(())
}
fn write_plaintext(
repository: &Repository,
keys: &KeyStore,
path: &str,
plaintext: &[u8],
) -> TestResult {
let path = EntryPath::parse(path)?;
let recipients =
RecipientPolicyManager::new(repository, keys).resolve_for_entry(&path, None)?;
let encrypted = keys.encrypt(
SecretBytes::new(plaintext.to_vec()),
recipients.recipients(),
)?;
repository.write_entry(&path, &encrypted)?;
Ok(())
}