519 lines
18 KiB
Rust
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,
|
|
},
|
|
mobile_watch::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(())
|
|
}
|