Cache TOTP discovery and report progress (#74)
This commit is contained in:
@@ -2,13 +2,20 @@
|
||||
|
||||
mod support;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
};
|
||||
|
||||
use ironstorage::{
|
||||
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
mobile_totp::{MobileTotpService, MobileWatchSnapshotState},
|
||||
mobile_totp::{
|
||||
MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService,
|
||||
MobileWatchSnapshotState,
|
||||
},
|
||||
otp::OtpError,
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{EntryPath, Repository, SecretBytes},
|
||||
repository::{EncryptedEntry, EntryPath, Repository, SecretBytes},
|
||||
};
|
||||
use support::compatibility::{FixtureSet, TestResult};
|
||||
|
||||
@@ -42,6 +49,63 @@ impl SecretProvider for FixtureSecrets {
|
||||
}
|
||||
}
|
||||
|
||||
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()?;
|
||||
@@ -99,6 +163,218 @@ fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestRe
|
||||
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()
|
||||
);
|
||||
|
||||
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 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,
|
||||
|
||||
Reference in New Issue
Block a user