Implement complete Android settings and recovery controls (#104)
This commit is contained in:
1 parent
06575363a2
commit
43f660ace3
34 files changed
+2720
-64
No files matched your search
@@ -289,6 +289,23 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn delete_key_passphrases_transaction(
|
||||
&self,
|
||||
keys: impl Iterator<Item = KeyInfo>,
|
||||
commit: &mut dyn FnMut() -> Result<(), SecretStoreError>,
|
||||
) -> Result<(), AuthenticationError> {
|
||||
let _operation = self.shared.operation()?;
|
||||
self.shared.expire_if_needed()?;
|
||||
self.shared.store.unlock()?;
|
||||
let references = keys
|
||||
.map(|key| SecretReference::openpgp_passphrase(key.fingerprint().as_str()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
self.shared
|
||||
.store
|
||||
.delete_openpgp_transaction(&references, commit)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Remaining active time. Calling this from a timer never extends the lease.
|
||||
pub fn remaining_time(&self) -> Result<Option<Duration>, AuthenticationError> {
|
||||
let _operation = self.shared.operation()?;
|
||||
|
||||
@@ -653,12 +653,21 @@ impl MobileAuthentication {
|
||||
handle: &MobileAuthenticationHandle,
|
||||
key: &KeyInfo,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
let current = Config::load(Some(self.config.source())).map_err(config_error)?;
|
||||
handle
|
||||
.persist_passphrase(key)
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
if current.biometric_unlock_enabled() {
|
||||
self.status()?.biometric_unlock_enabled = true;
|
||||
return Ok(());
|
||||
}
|
||||
if let Err(error) = self.config.update_biometric_unlock(true) {
|
||||
let _ = self.delete_all_key_passphrases();
|
||||
let _ = self.manual_lock();
|
||||
if matches!(error, ConfigError::DurabilityUncertain { .. }) {
|
||||
self.status()?.biometric_unlock_enabled = true;
|
||||
} else {
|
||||
let _ = self.session.delete_key_passphrase(key);
|
||||
let _ = self.manual_lock();
|
||||
}
|
||||
return Err(config_error(error));
|
||||
}
|
||||
self.status()?.biometric_unlock_enabled = true;
|
||||
@@ -685,10 +694,36 @@ impl MobileAuthentication {
|
||||
drop(status);
|
||||
self.persist_biometric_unlock(&handle, &key)?;
|
||||
} else {
|
||||
self.delete_all_key_passphrases()?;
|
||||
self.config
|
||||
.update_biometric_unlock(false)
|
||||
.map_err(config_error)?;
|
||||
// Validate the live document before removing any protected item.
|
||||
Config::load(Some(self.config.source())).map_err(config_error)?;
|
||||
let mut configuration_error = None;
|
||||
let mut commit = || match self.config.update_biometric_unlock(false) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error @ ConfigError::DurabilityUncertain { .. }) => {
|
||||
configuration_error = Some(error);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
configuration_error = Some(error);
|
||||
Err(SecretStoreError::Unavailable)
|
||||
}
|
||||
};
|
||||
let removed = self.session.delete_key_passphrases_transaction(
|
||||
self.keys.infos().filter(KeyInfo::has_secret),
|
||||
&mut commit,
|
||||
);
|
||||
if let Err(error) = removed {
|
||||
if Config::load(Some(self.config.source()))
|
||||
.is_ok_and(|config| !config.biometric_unlock_enabled())
|
||||
{
|
||||
self.manual_lock()?;
|
||||
self.status()?.biometric_unlock_enabled = false;
|
||||
}
|
||||
return Err(configuration_error.map_or_else(
|
||||
|| MobileAuthenticationError::authentication(error),
|
||||
config_error,
|
||||
));
|
||||
}
|
||||
self.session
|
||||
.manual_lock()
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
@@ -696,6 +731,9 @@ impl MobileAuthentication {
|
||||
status.biometric_unlock_enabled = false;
|
||||
status.active = None;
|
||||
status.editors.clear();
|
||||
if let Some(error) = configuration_error {
|
||||
return Err(config_error(error));
|
||||
}
|
||||
}
|
||||
self.state()
|
||||
}
|
||||
@@ -744,6 +782,24 @@ impl MobileAuthentication {
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
pub fn set_default_key(&self, fingerprint: &str) -> Result<(), MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let key = self
|
||||
.keys
|
||||
.infos()
|
||||
.find(|key| key.fingerprint().as_str() == fingerprint && key.has_secret())
|
||||
.ok_or_else(|| key_error(CryptoError::MissingSecretKey))?;
|
||||
let current = Config::load(Some(self.config.source())).map_err(config_error)?;
|
||||
let mut settings = current.settings();
|
||||
settings.set_default_key(key.fingerprint().as_str().to_owned());
|
||||
current
|
||||
.with_settings(settings)
|
||||
.and_then(|candidate| candidate.persist())
|
||||
.map_err(config_error)?;
|
||||
self.manual_lock()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn preferences(
|
||||
&self,
|
||||
watch_supported: bool,
|
||||
@@ -828,6 +884,17 @@ impl MobileAuthentication {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_authentication_timeout_text(
|
||||
&self,
|
||||
seconds: &str,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
let seconds = seconds
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|_| config_detail("Choose an inactivity timeout from 1 to 86400 seconds."))?;
|
||||
self.set_authentication_timeout(seconds)
|
||||
}
|
||||
|
||||
pub fn set_authentication_timeout(
|
||||
&self,
|
||||
seconds: u64,
|
||||
@@ -1160,6 +1227,27 @@ impl MobileAuthentication {
|
||||
pub fn totp_page(
|
||||
&self,
|
||||
operation: &MobileTotpOperation,
|
||||
) -> Result<MobileTotpPage, MobileAuthenticationError> {
|
||||
self.discover_totp(operation, false)
|
||||
}
|
||||
|
||||
pub fn rebuild_totp_cache(
|
||||
&self,
|
||||
operation: &MobileTotpOperation,
|
||||
) -> Result<MobileTotpPage, MobileAuthenticationError> {
|
||||
self.discover_totp(operation, true)
|
||||
}
|
||||
|
||||
pub fn totp_cache_status(&self) -> Result<(u32, String), MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
Ok(MobileTotpService::new(&self.repository, &self.keys)
|
||||
.cache_status(&self.config.source().with_file_name("totp-catalog.toml")))
|
||||
}
|
||||
|
||||
fn discover_totp(
|
||||
&self,
|
||||
operation: &MobileTotpOperation,
|
||||
rebuild: bool,
|
||||
) -> Result<MobileTotpPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
self.ensure_active()?;
|
||||
@@ -1180,9 +1268,13 @@ impl MobileAuthentication {
|
||||
};
|
||||
let cache_path = self.config.source().with_file_name("totp-catalog.toml");
|
||||
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||
let mut page = MobileTotpService::new(&self.repository, &self.keys)
|
||||
.discover(&shared, &mut provider, &cache_path, operation)
|
||||
.map_err(totp_error)?;
|
||||
let service = MobileTotpService::new(&self.repository, &self.keys);
|
||||
let mut page = if rebuild {
|
||||
service.rebuild(&shared, &mut provider, &cache_path, operation)
|
||||
} else {
|
||||
service.discover(&shared, &mut provider, &cache_path, operation)
|
||||
}
|
||||
.map_err(totp_error)?;
|
||||
page.set_watch(self.watch_snapshot_status()?);
|
||||
Ok(page)
|
||||
}
|
||||
@@ -1826,15 +1918,6 @@ impl MobileAuthentication {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_all_key_passphrases(&self) -> Result<(), MobileAuthenticationError> {
|
||||
for key in self.keys.infos().filter(KeyInfo::has_secret) {
|
||||
self.session
|
||||
.delete_key_passphrase(&key)
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn status(
|
||||
&self,
|
||||
) -> Result<std::sync::MutexGuard<'_, MobileAuthenticationStatus>, MobileAuthenticationError>
|
||||
@@ -2399,6 +2482,27 @@ mod tests {
|
||||
);
|
||||
authentication.manual_lock().unwrap();
|
||||
assert!(authentication.restore_biometric_lease().unwrap().unlocked());
|
||||
let config = private.configuration().unwrap().unwrap();
|
||||
let original = std::fs::read(config.source()).unwrap();
|
||||
std::fs::write(config.source(), b"damaged settings [").unwrap();
|
||||
assert!(authentication.set_biometric_unlock(false).is_err());
|
||||
assert!(authentication.state().unwrap().biometric_unlock_enabled());
|
||||
std::fs::write(config.source(), original).unwrap();
|
||||
authentication.manual_lock().unwrap();
|
||||
assert!(authentication.restore_biometric_lease().unwrap().unlocked());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let parent = config.source().parent().unwrap();
|
||||
let permissions = std::fs::metadata(parent).unwrap().permissions();
|
||||
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
let result = authentication.set_biometric_unlock(false);
|
||||
std::fs::set_permissions(parent, permissions).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert!(authentication.state().unwrap().biometric_unlock_enabled());
|
||||
authentication.manual_lock().unwrap();
|
||||
assert!(authentication.restore_biometric_lease().unwrap().unlocked());
|
||||
}
|
||||
authentication.set_biometric_unlock(false).unwrap();
|
||||
assert!(authentication.restore_biometric_lease().is_err());
|
||||
}
|
||||
|
||||
@@ -575,10 +575,8 @@ struct MobileHomeStorage {
|
||||
impl MobileHomeStorage {
|
||||
fn load(private: Option<&PrivateHomeContext>) -> Result<Self, MobileHomeError> {
|
||||
let (config, backend, lock) = if let Some(private) = private {
|
||||
private
|
||||
.storage
|
||||
.verify_secure_storage(private.backend.as_ref())
|
||||
.map_err(MobileHomeError::from_private)?;
|
||||
// with_private verified binding through the shared authentication owner; repeating
|
||||
// it here would lock that owner's active entry/settings lease.
|
||||
let root = cap_std::fs::Dir::open_ambient_dir(
|
||||
private.storage.root(),
|
||||
cap_std::ambient_authority(),
|
||||
|
||||
@@ -42,6 +42,17 @@ fn operation(private: &Arc<PrivateStorage>, backend: &Arc<Backend>) -> MobileHom
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_home_reads_do_not_lock_the_shared_settings_authentication_backend() {
|
||||
let (_temp, private, backend) = connected();
|
||||
let home = operation(&private, &backend);
|
||||
let locks = backend.locks.load(Ordering::Relaxed);
|
||||
assert!(home.identity().is_ok());
|
||||
assert!(home.cached().is_ok());
|
||||
assert!(operation(&private, &backend).cached().is_ok());
|
||||
assert_eq!(backend.locks.load(Ordering::Relaxed), locks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_home_keeps_transient_private_storage_retry_guidance() {
|
||||
let (_temp, private, backend) = connected();
|
||||
|
||||
@@ -217,6 +217,15 @@ impl MobileKeyTransferImport {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_image(
|
||||
&mut self,
|
||||
image: SecretBytes,
|
||||
) -> Result<MobileKeyTransferProgress, MobileKeyTransferError> {
|
||||
let payload =
|
||||
QrMatrix::decode_image(&image).map_err(|_| MobileKeyTransferError::InvalidFrame)?;
|
||||
self.add_frame(payload)
|
||||
}
|
||||
|
||||
pub fn add_frame(
|
||||
&mut self,
|
||||
payload: SecretBytes,
|
||||
@@ -352,6 +361,32 @@ impl MobileKeyTransferImport {
|
||||
return Err(MobileKeyTransferError::ExistingKey);
|
||||
}
|
||||
|
||||
let current = Config::load(Some(config.source()))?;
|
||||
let candidate = if make_default {
|
||||
let mut settings = current.settings();
|
||||
settings.set_default_key(key.fingerprint.clone());
|
||||
Some(current.with_settings(settings)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let target = if config.key_material().is_dir() {
|
||||
let suffix = if key.kind == MobileKeyTransferKind::Private {
|
||||
"secret"
|
||||
} else {
|
||||
"public"
|
||||
};
|
||||
config.key_material().join(format!(
|
||||
"{}-{suffix}.asc",
|
||||
key.fingerprint.to_ascii_lowercase()
|
||||
))
|
||||
} else {
|
||||
config.key_material().to_owned()
|
||||
};
|
||||
let backup = match fs::read(&target) {
|
||||
Ok(value) => Some(SecretBytes::new(value)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(_) => return Err(MobileKeyTransferError::Write),
|
||||
};
|
||||
persist_armored_key(
|
||||
config.key_material(),
|
||||
&key.fingerprint,
|
||||
@@ -359,10 +394,21 @@ impl MobileKeyTransferImport {
|
||||
armor.expose(),
|
||||
overwrite,
|
||||
)?;
|
||||
if make_default {
|
||||
let mut settings = config.settings();
|
||||
settings.set_default_key(key.fingerprint.clone());
|
||||
config.with_settings(settings)?.persist()?;
|
||||
if let Some(candidate) = candidate
|
||||
&& let Err(error) = candidate.persist()
|
||||
{
|
||||
if !matches!(error, ConfigError::DurabilityUncertain { .. }) {
|
||||
let parent = target.parent().ok_or(MobileKeyTransferError::Write)?;
|
||||
let name = Path::new(target.file_name().ok_or(MobileKeyTransferError::Write)?);
|
||||
let restored = match backup {
|
||||
Some(ref value) => atomic_replace(parent, name, value.expose()),
|
||||
None => fs::remove_file(&target)
|
||||
.and_then(|()| fs::File::open(parent)?.sync_all())
|
||||
.map_err(|_| MobileKeyTransferError::Write),
|
||||
};
|
||||
restored.map_err(|_| MobileKeyTransferError::RecoveryRequired)?;
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
let kind = if key.kind == MobileKeyTransferKind::Private {
|
||||
"private"
|
||||
@@ -421,12 +467,14 @@ pub enum MobileKeyTransferError {
|
||||
IncorrectPassphrase,
|
||||
QrPayload,
|
||||
Write,
|
||||
RecoveryRequired,
|
||||
LocalSetup(crate::mobile_onboarding::MobileOnboardingError),
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileKeyTransferError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::RecoveryRequired => "key import could not be rolled back; retain the key material and recover configuration before retrying",
|
||||
Self::Configuration => "the IronStorage configuration is unavailable",
|
||||
Self::KeyUnavailable => "the selected GPG key is unavailable",
|
||||
Self::InvalidFrame => "this is not a valid IronStorage key-transfer QR code",
|
||||
@@ -666,6 +714,35 @@ mod tests {
|
||||
include_bytes!("../tests/fixtures/compatibility/keys/alice-secret.asc");
|
||||
const ALICE_FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
||||
|
||||
#[test]
|
||||
fn qr_images_reconstruct_the_same_key_and_reject_invalid_images()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let fixture = configured_public_key()?;
|
||||
let mut importer = MobileKeyTransferImport::new(fixture.config);
|
||||
assert_eq!(
|
||||
importer.add_image(SecretBytes::new(b"not an image".to_vec())),
|
||||
Err(MobileKeyTransferError::InvalidFrame)
|
||||
);
|
||||
for frame in encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)? {
|
||||
let matrix = frame.matrix();
|
||||
let size = u32::try_from(matrix.width() + 8)? * 4;
|
||||
let image = image::GrayImage::from_fn(size, size, |x, y| {
|
||||
let x = (x / 4) as usize;
|
||||
let y = (y / 4) as usize;
|
||||
let dark = x >= 4 && y >= 4 && matrix.is_dark(x - 4, y - 4).unwrap_or(false);
|
||||
image::Luma([if dark { 0 } else { 255 }])
|
||||
});
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image.write_to(&mut png, image::ImageFormat::Png)?;
|
||||
importer.add_image(SecretBytes::new(png.into_inner()))?;
|
||||
}
|
||||
assert_eq!(
|
||||
importer.complete.as_ref().map(SecretBytes::expose),
|
||||
Some(ALICE_SECRET)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_frames_reconstruct_exact_armor_out_of_order()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -773,11 +850,23 @@ mod tests {
|
||||
Err(MobileKeyTransferError::IncorrectPassphrase)
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(&fixture.key_path)?.count(), 1);
|
||||
fixture
|
||||
.config
|
||||
.update_git_identity(&crate::git::GitIdentity::new(
|
||||
"Changed since QR opened",
|
||||
"changed@example.test",
|
||||
)?)?;
|
||||
importer.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
false,
|
||||
)?;
|
||||
assert_eq!(
|
||||
Config::load(Some(fixture.config.source()))?
|
||||
.git_identity()
|
||||
.name(),
|
||||
"Changed since QR opened"
|
||||
);
|
||||
let keys = KeyStore::load(&fixture.key_path)?;
|
||||
assert!(keys.infos().any(|key| key.has_secret()));
|
||||
assert_eq!(
|
||||
@@ -815,6 +904,49 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn default_key_import_rolls_back_when_config_cannot_be_replaced()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let fixture = configured_public_key()?;
|
||||
let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
|
||||
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
||||
let before = std::fs::read(fixture.config.source())?;
|
||||
let parent = fixture.config.source().parent().unwrap();
|
||||
let permissions = std::fs::metadata(parent)?.permissions();
|
||||
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o500))?;
|
||||
let result = importer.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
std::fs::set_permissions(parent, permissions)?;
|
||||
assert_eq!(result, Err(MobileKeyTransferError::Configuration));
|
||||
assert_eq!(std::fs::read(fixture.config.source())?, before);
|
||||
assert_eq!(std::fs::read_dir(fixture.key_path)?.count(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_configuration_does_not_install_an_imported_key()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let fixture = configured_public_key()?;
|
||||
let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
|
||||
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
||||
std::fs::write(fixture.config.source(), b"not a TOML document [")?;
|
||||
assert_eq!(
|
||||
importer.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
false
|
||||
),
|
||||
Err(MobileKeyTransferError::Configuration)
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(fixture.key_path)?.count(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloned_store_with_empty_key_directory_can_import_its_private_key()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -912,8 +912,13 @@ pub(crate) mod tests {
|
||||
pub(crate) struct Backend {
|
||||
records: Mutex<BTreeMap<SecretLocator, Vec<u8>>>,
|
||||
fail_tokens: AtomicBool,
|
||||
pub(crate) locks: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
impl SecretStoreBackend for Backend {
|
||||
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||
self.locks.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
fn create(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
@@ -1537,10 +1542,13 @@ pub(crate) mod tests {
|
||||
use crate::mobile_home::{MobileHomeActionKind, MobileHomeFreshness, MobileHomePhase};
|
||||
let (_temp, private, backend) = home_fixture();
|
||||
let config = private.configuration().unwrap().unwrap();
|
||||
config
|
||||
.update_git_identity(
|
||||
&GitIdentity::new("Android fixture", "fixture@example.test").unwrap(),
|
||||
private
|
||||
.authentication(
|
||||
backend.clone(),
|
||||
Arc::new(crate::authentication::SystemAuthenticationClock::default()),
|
||||
)
|
||||
.unwrap()
|
||||
.set_git_identity("Android fixture".into(), "fixture@example.test".into())
|
||||
.unwrap();
|
||||
fs::write(
|
||||
config.vault().join("仕事.gpg"),
|
||||
|
||||
@@ -140,6 +140,7 @@ pub struct PrivateStorage {
|
||||
cache: Dir,
|
||||
status: Mutex<PrivateStorageStatus>,
|
||||
authentication: Mutex<Option<Arc<MobileAuthentication>>>,
|
||||
android_backend: Mutex<Option<Arc<crate::secret_store::android::AndroidSecretBackend>>>,
|
||||
}
|
||||
|
||||
impl PrivateStorage {
|
||||
@@ -233,6 +234,7 @@ impl PrivateStorage {
|
||||
let root = roots.no_backup.join("ironstorage");
|
||||
let mut storage = Self {
|
||||
authentication: Mutex::new(None),
|
||||
android_backend: Mutex::new(None),
|
||||
root,
|
||||
cache_path: roots.cache.join("ironstorage"),
|
||||
data,
|
||||
@@ -283,10 +285,31 @@ impl PrivateStorage {
|
||||
pub fn android_secret_backend(
|
||||
&self,
|
||||
keystore: std::sync::Arc<dyn crate::secret_store::android::AndroidKeystore>,
|
||||
) -> Result<crate::secret_store::android::AndroidSecretBackend, PrivateStorageError> {
|
||||
) -> Result<Arc<crate::secret_store::android::AndroidSecretBackend>, PrivateStorageError> {
|
||||
let mut owner = self
|
||||
.android_backend
|
||||
.lock()
|
||||
.map_err(|_| PrivateStorageError::Busy)?;
|
||||
let enabled = self
|
||||
.configuration()
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none_or(|config| config.biometric_unlock_enabled());
|
||||
if let Some(backend) = owner.as_ref() {
|
||||
backend
|
||||
.verify_key_inventory(enabled)
|
||||
.map_err(secret_error)?;
|
||||
return Ok(Arc::clone(backend));
|
||||
}
|
||||
let directory = private_directory(&self.data, "secrets")?;
|
||||
crate::secret_store::android::AndroidSecretBackend::open(directory, keystore)
|
||||
.map_err(secret_error)
|
||||
let backend = Arc::new(
|
||||
crate::secret_store::android::AndroidSecretBackend::open_with_biometry(
|
||||
directory, keystore, enabled,
|
||||
)
|
||||
.map_err(secret_error)?,
|
||||
);
|
||||
*owner = Some(Arc::clone(&backend));
|
||||
Ok(backend)
|
||||
}
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
@@ -304,26 +327,53 @@ impl PrivateStorage {
|
||||
error.detail(),
|
||||
)
|
||||
};
|
||||
self.verify_secure_storage(backend.as_ref())
|
||||
.map_err(recovery)?;
|
||||
let config = self
|
||||
.configuration()
|
||||
.map_err(recovery)?
|
||||
.ok_or_else(|| recovery(PrivateStorageError::ConfigurationMissingWithData))?;
|
||||
// Retain the native backend's encrypted transactions across the FFI callback adapter.
|
||||
let backend: Arc<dyn SecretStoreBackend> = match self
|
||||
.android_backend
|
||||
.lock()
|
||||
.map_err(|_| recovery(PrivateStorageError::Busy))?
|
||||
.as_ref()
|
||||
{
|
||||
Some(native) => native.clone(),
|
||||
None => backend,
|
||||
};
|
||||
let mut owner = self
|
||||
.authentication
|
||||
.lock()
|
||||
.map_err(|_| recovery(PrivateStorageError::Busy))?;
|
||||
let config = self
|
||||
.configuration()
|
||||
.map_err(recovery)?
|
||||
.ok_or_else(|| recovery(PrivateStorageError::ConfigurationMissingWithData))?;
|
||||
if let Some(current) = owner.as_ref() {
|
||||
if current.matches_configuration(&config)? {
|
||||
return Ok(Arc::clone(current));
|
||||
}
|
||||
current.manual_lock()?;
|
||||
}
|
||||
// Binding verification locks the backend, so run it only without a live lease.
|
||||
self.verify_secure_storage(backend.as_ref())
|
||||
.map_err(recovery)?;
|
||||
let current = Arc::new(MobileAuthentication::with_platform(config, backend, clock)?);
|
||||
*owner = Some(Arc::clone(¤t));
|
||||
Ok(current)
|
||||
}
|
||||
/// Imported/replaced keys must revoke the old owner before the next configuration load.
|
||||
pub fn key_material_changed(&self) -> Result<(), MobileAuthenticationError> {
|
||||
let mut owner = self.authentication.lock().map_err(|_| {
|
||||
MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::SecureStorage,
|
||||
"Authentication Is Unavailable",
|
||||
"The authentication owner could not be refreshed.",
|
||||
)
|
||||
})?;
|
||||
if let Some(current) = owner.as_ref() {
|
||||
current.lifecycle(crate::mobile_platform::MobileLifecycleEvent::ProcessRecreated)?;
|
||||
}
|
||||
*owner = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every existing default-path API now uses this explicit OS context, never HOME/cwd.
|
||||
pub fn activate(&self) -> Result<(), PrivateStorageError> {
|
||||
if crate::config::ConfigLoader::activate_private_root(&self.root) {
|
||||
|
||||
@@ -344,6 +344,27 @@ impl<'a> MobileTotpService<'a> {
|
||||
provider: &mut impl SecretProvider,
|
||||
cache_path: &Path,
|
||||
operation: &MobileTotpOperation,
|
||||
) -> Result<MobileTotpPage, MobileTotpError> {
|
||||
self.discover_with_cache(shared, provider, cache_path, operation, true)
|
||||
}
|
||||
|
||||
pub fn rebuild(
|
||||
&self,
|
||||
shared: &BTreeSet<EntryPath>,
|
||||
provider: &mut impl SecretProvider,
|
||||
cache_path: &Path,
|
||||
operation: &MobileTotpOperation,
|
||||
) -> Result<MobileTotpPage, MobileTotpError> {
|
||||
self.discover_with_cache(shared, provider, cache_path, operation, false)
|
||||
}
|
||||
|
||||
fn discover_with_cache(
|
||||
&self,
|
||||
shared: &BTreeSet<EntryPath>,
|
||||
provider: &mut impl SecretProvider,
|
||||
cache_path: &Path,
|
||||
operation: &MobileTotpOperation,
|
||||
reuse_cache: bool,
|
||||
) -> Result<MobileTotpPage, MobileTotpError> {
|
||||
operation.check_cancelled()?;
|
||||
let initial_inventory = inventory(self.repository)?;
|
||||
@@ -353,7 +374,10 @@ impl<'a> MobileTotpService<'a> {
|
||||
});
|
||||
|
||||
let store = store_identity(self.repository.root_path(), cache_path);
|
||||
let (cached, mut cache_notice) = load_cache(cache_path, &store);
|
||||
let (mut cached, mut cache_notice) = load_cache(cache_path, &store);
|
||||
if !reuse_cache {
|
||||
cached = None;
|
||||
}
|
||||
let mut records = BTreeMap::new();
|
||||
let mut rows = Vec::new();
|
||||
let mut unavailable_entries = 0_u32;
|
||||
@@ -423,6 +447,34 @@ impl<'a> MobileTotpService<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cache_status(&self, cache_path: &Path) -> (u32, String) {
|
||||
let store = store_identity(self.repository.root_path(), cache_path);
|
||||
let (catalog, notice) = load_cache(cache_path, &store);
|
||||
if let Some(notice) = notice {
|
||||
return (0, notice);
|
||||
}
|
||||
match catalog {
|
||||
None => (
|
||||
0,
|
||||
"No TOTP discovery cache. Unlock and rebuild to inspect the store.".to_owned(),
|
||||
),
|
||||
Some(catalog) => {
|
||||
let count = self
|
||||
.cached_page(&BTreeSet::new(), cache_path)
|
||||
.map_or(0, |page| {
|
||||
u32::try_from(page.rows().len()).unwrap_or(u32::MAX)
|
||||
});
|
||||
(
|
||||
count,
|
||||
format!(
|
||||
"{count} current TOTP entries; {} passwords inspected. Rebuild to check changed or new entries.",
|
||||
catalog.entries.len()
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cached_page(
|
||||
&self,
|
||||
shared: &BTreeSet<EntryPath>,
|
||||
|
||||
@@ -368,6 +368,41 @@ pub trait SecretStoreBackend: Send + Sync {
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<(), SecretStoreError>;
|
||||
/// Remove a set of protected records together with a storage-owned setting.
|
||||
/// Keep rollback material inside the backend; never return it to a frontend.
|
||||
fn delete_transaction(
|
||||
&self,
|
||||
locators: &[SecretLocator],
|
||||
protection: SecretProtection,
|
||||
commit: &mut dyn FnMut() -> Result<(), SecretStoreError>,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let mut backup = Vec::new();
|
||||
for locator in locators {
|
||||
match self.retrieve(locator, protection) {
|
||||
Ok(value) => backup.push((locator, value)),
|
||||
Err(SecretStoreError::Missing) => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
let result = (|| {
|
||||
for (locator, _) in &backup {
|
||||
self.delete(locator, protection)?;
|
||||
}
|
||||
commit()
|
||||
})();
|
||||
if result.is_err() {
|
||||
for (locator, value) in &backup {
|
||||
match self.replace(locator, protection, value.expose()) {
|
||||
Ok(()) => {}
|
||||
Err(SecretStoreError::Missing) => {
|
||||
self.create(locator, protection, value.expose())?
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn platform_https_git_credential(
|
||||
&self,
|
||||
_endpoint: &url::Url,
|
||||
@@ -413,6 +448,14 @@ impl<T: SecretStoreBackend + ?Sized> SecretStoreBackend for std::sync::Arc<T> {
|
||||
) -> Result<(), SecretStoreError> {
|
||||
(**self).delete(locator, protection)
|
||||
}
|
||||
fn delete_transaction(
|
||||
&self,
|
||||
locators: &[SecretLocator],
|
||||
protection: SecretProtection,
|
||||
commit: &mut dyn FnMut() -> Result<(), SecretStoreError>,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
(**self).delete_transaction(locators, protection, commit)
|
||||
}
|
||||
fn platform_https_git_credential(
|
||||
&self,
|
||||
endpoint: &url::Url,
|
||||
@@ -723,6 +766,30 @@ impl<B: SecretStoreBackend> SecretStore<B> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn delete_openpgp_transaction(
|
||||
&self,
|
||||
references: &[SecretReference],
|
||||
commit: &mut dyn FnMut() -> Result<(), SecretStoreError>,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.unlocked_state()?;
|
||||
let mut locators = Vec::with_capacity(references.len());
|
||||
for reference in references {
|
||||
if !matches!(
|
||||
reference.kind,
|
||||
SecretReferenceKind::OpenPgpPassphrase { .. }
|
||||
) {
|
||||
return Err(SecretStoreError::InvalidReference);
|
||||
}
|
||||
locators.push(reference.locator());
|
||||
}
|
||||
self.backend
|
||||
.delete_transaction(&locators, self.protections.openpgp, commit)?;
|
||||
for locator in locators {
|
||||
state.cache.remove(&locator);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retrieve_git_record(
|
||||
&self,
|
||||
server: &ServerId,
|
||||
|
||||
@@ -124,9 +124,14 @@ pub struct AndroidSecretBackend {
|
||||
generation: AtomicU64,
|
||||
}
|
||||
impl AndroidSecretBackend {
|
||||
pub(crate) fn open(
|
||||
#[cfg(test)]
|
||||
fn open(directory: Dir, keystore: Arc<dyn AndroidKeystore>) -> Result<Self, SecretStoreError> {
|
||||
Self::open_with_biometry(directory, keystore, true)
|
||||
}
|
||||
pub(crate) fn open_with_biometry(
|
||||
directory: Dir,
|
||||
keystore: Arc<dyn AndroidKeystore>,
|
||||
biometric_enabled: bool,
|
||||
) -> Result<Self, SecretStoreError> {
|
||||
let backend = Self {
|
||||
directory,
|
||||
@@ -135,10 +140,19 @@ impl AndroidSecretBackend {
|
||||
generation: AtomicU64::new(0),
|
||||
};
|
||||
let _lock = backend.transaction()?;
|
||||
backend.recover_biometric_removal(biometric_enabled)?;
|
||||
backend.cleanup_orphan_keys()?;
|
||||
drop(_lock);
|
||||
Ok(backend)
|
||||
}
|
||||
pub(crate) fn verify_key_inventory(
|
||||
&self,
|
||||
biometric_enabled: bool,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let _lock = self.transaction()?;
|
||||
self.recover_biometric_removal(biometric_enabled)?;
|
||||
self.cleanup_orphan_keys()
|
||||
}
|
||||
fn transaction(&self) -> Result<std::fs::File, SecretStoreError> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true).write(true).create(true);
|
||||
@@ -198,8 +212,10 @@ impl AndroidSecretBackend {
|
||||
self.authorization(protection).map(|_| ())
|
||||
}
|
||||
fn read(&self, id: &str) -> Result<Option<Envelope>, SecretStoreError> {
|
||||
let name = format!("{id}.record");
|
||||
let metadata = match self.directory.symlink_metadata(&name) {
|
||||
self.read_named(id, &format!("{id}.record"))
|
||||
}
|
||||
fn read_named(&self, id: &str, name: &str) -> Result<Option<Envelope>, SecretStoreError> {
|
||||
let metadata = match self.directory.symlink_metadata(name) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_error(error)),
|
||||
@@ -210,7 +226,7 @@ impl AndroidSecretBackend {
|
||||
{
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
}
|
||||
let file = self.directory.open(&name).map_err(io_error)?;
|
||||
let file = self.directory.open(name).map_err(io_error)?;
|
||||
crate::mobile_private_storage::private_permissions(&file, false)
|
||||
.map_err(|_| SecretStoreError::Unavailable)?;
|
||||
let mut bytes = Vec::new();
|
||||
@@ -219,6 +235,41 @@ impl AndroidSecretBackend {
|
||||
.map_err(io_error)?;
|
||||
decode(id, &bytes).map(Some)
|
||||
}
|
||||
fn recover_biometric_removal(&self, enabled: bool) -> Result<(), SecretStoreError> {
|
||||
for (index, entry) in self.directory.entries().map_err(io_error)?.enumerate() {
|
||||
if index > MAX_RECORDS {
|
||||
return Err(SecretStoreError::Unavailable);
|
||||
}
|
||||
let name = entry.map_err(io_error)?.file_name();
|
||||
let name = name.to_str().ok_or(SecretStoreError::Corrupted)?;
|
||||
if let Some(id) = name.strip_suffix(".removing") {
|
||||
if !valid_id(id) {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
}
|
||||
let envelope = self
|
||||
.read_named(id, name)?
|
||||
.ok_or(SecretStoreError::Corrupted)?;
|
||||
if envelope.protection != SecretProtection::BiometryCurrentSet {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
}
|
||||
let original = format!("{id}.record");
|
||||
if self.directory.symlink_metadata(&original).is_ok() {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
}
|
||||
if enabled {
|
||||
self.directory
|
||||
.rename(name, &self.directory, original)
|
||||
.map_err(io_error)?;
|
||||
} else {
|
||||
self.directory.remove_file(name).map_err(io_error)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.directory
|
||||
.open(".")
|
||||
.and_then(|file| file.sync_all())
|
||||
.map_err(io_error)
|
||||
}
|
||||
fn cleanup_orphan_keys(&self) -> Result<(), SecretStoreError> {
|
||||
#[cfg(unix)]
|
||||
let _creation = crate::file_lock::directory(&self.directory).map_err(io_error)?;
|
||||
@@ -420,6 +471,70 @@ impl SecretStoreBackend for AndroidSecretBackend {
|
||||
.map_err(SecretStoreError::from);
|
||||
durability.and(deleted)
|
||||
}
|
||||
fn delete_transaction(
|
||||
&self,
|
||||
locators: &[SecretLocator],
|
||||
protection: SecretProtection,
|
||||
commit: &mut dyn FnMut() -> Result<(), SecretStoreError>,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
if protection != SecretProtection::BiometryCurrentSet {
|
||||
return Err(SecretStoreError::UnsupportedProtection);
|
||||
}
|
||||
let _lock = self.transaction()?;
|
||||
let mut names = Vec::new();
|
||||
for locator in locators {
|
||||
validate_protection(locator, protection)?;
|
||||
let id = identity(locator)?;
|
||||
if self
|
||||
.directory
|
||||
.symlink_metadata(format!("{id}.removing"))
|
||||
.is_ok()
|
||||
{
|
||||
return Err(SecretStoreError::Unavailable);
|
||||
}
|
||||
if let Some(envelope) = self.read(&id)? {
|
||||
if envelope.protection != protection {
|
||||
return Err(SecretStoreError::UnsupportedProtection);
|
||||
}
|
||||
names.push((format!("{id}.record"), format!("{id}.removing")));
|
||||
}
|
||||
}
|
||||
// The live Rust setting decides rollback or completion after process death.
|
||||
let result = (|| {
|
||||
for (original, pending) in &names {
|
||||
self.directory
|
||||
.rename(original, &self.directory, pending)
|
||||
.map_err(io_error)?;
|
||||
}
|
||||
self.directory
|
||||
.open(".")
|
||||
.and_then(|file| file.sync_all())
|
||||
.map_err(io_error)?;
|
||||
commit()
|
||||
})();
|
||||
if result.is_err() {
|
||||
for (original, pending) in &names {
|
||||
if self.directory.symlink_metadata(pending).is_ok() {
|
||||
self.directory
|
||||
.rename(pending, &self.directory, original)
|
||||
.map_err(io_error)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (_, pending) in &names {
|
||||
self.directory.remove_file(pending).map_err(io_error)?;
|
||||
}
|
||||
}
|
||||
self.directory
|
||||
.open(".")
|
||||
.and_then(|file| file.sync_all())
|
||||
.map_err(io_error)?;
|
||||
if result.is_ok() {
|
||||
// No ciphertext remains for these aliases; startup retries their OS cleanup.
|
||||
let _ = self.cleanup_orphan_keys();
|
||||
}
|
||||
result
|
||||
}
|
||||
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||
self.locked.store(true, Ordering::Release);
|
||||
self.generation.fetch_add(1, Ordering::AcqRel);
|
||||
@@ -728,6 +843,109 @@ mod tests {
|
||||
}
|
||||
}
|
||||
const DEVICE: SecretProtection = SecretProtection::DeviceUnlocked;
|
||||
#[test]
|
||||
fn interrupted_biometric_removal_recovers_from_the_committed_setting() {
|
||||
for enabled in [true, false] {
|
||||
let (root, os, backend) = setup();
|
||||
let protection = SecretProtection::BiometryCurrentSet;
|
||||
let locator = SecretLocator::OpenPgpPassphrase {
|
||||
fingerprint: "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30".into(),
|
||||
};
|
||||
backend
|
||||
.create(&locator, protection, b"public interruption fixture")
|
||||
.unwrap();
|
||||
let aliases = os.aliases().unwrap();
|
||||
let interrupted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _ = backend.delete_transaction(
|
||||
std::slice::from_ref(&locator),
|
||||
protection,
|
||||
&mut || panic!("simulated process interruption"),
|
||||
);
|
||||
}));
|
||||
assert!(interrupted.is_err());
|
||||
os.state.lock().unwrap().failure = Some(AndroidKeystoreError::Invalidated);
|
||||
backend.verify_key_inventory(enabled).unwrap();
|
||||
assert_eq!(
|
||||
backend
|
||||
.read(&identity(&locator).unwrap())
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
enabled
|
||||
);
|
||||
let reopened = AndroidSecretBackend::open_with_biometry(
|
||||
Dir::open_ambient_dir(root.path(), ambient_authority()).unwrap(),
|
||||
os.clone(),
|
||||
enabled,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
reopened
|
||||
.read(&identity(&locator).unwrap())
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
enabled
|
||||
);
|
||||
assert_eq!(
|
||||
os.aliases().unwrap(),
|
||||
if enabled { aliases } else { Vec::new() }
|
||||
);
|
||||
assert!(
|
||||
!root
|
||||
.path()
|
||||
.join(format!("{}.removing", identity(&locator).unwrap()))
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biometric_deletion_rolls_back_envelopes_without_authenticating_invalidated_keys() {
|
||||
let (root, os, backend) = setup();
|
||||
let protection = SecretProtection::BiometryCurrentSet;
|
||||
let locators = [
|
||||
"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30",
|
||||
"B37027B56FC406BD3F6A622B2AC03492B992D06F",
|
||||
]
|
||||
.map(|fingerprint| SecretLocator::OpenPgpPassphrase {
|
||||
fingerprint: fingerprint.into(),
|
||||
});
|
||||
for locator in &locators {
|
||||
backend
|
||||
.create(locator, protection, b"protected test passphrase")
|
||||
.unwrap();
|
||||
}
|
||||
let aliases = os.aliases().unwrap();
|
||||
os.state.lock().unwrap().failure = Some(AndroidKeystoreError::Invalidated);
|
||||
assert_eq!(
|
||||
backend.delete_transaction(&locators, protection, &mut || Err(
|
||||
SecretStoreError::Unavailable
|
||||
)),
|
||||
Err(SecretStoreError::Unavailable)
|
||||
);
|
||||
assert_eq!(os.aliases().unwrap(), aliases);
|
||||
for locator in &locators {
|
||||
assert!(backend.read(&identity(locator).unwrap()).unwrap().is_some());
|
||||
}
|
||||
let reopened = AndroidSecretBackend::open(
|
||||
Dir::open_ambient_dir(root.path(), ambient_authority()).unwrap(),
|
||||
os.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(os.aliases().unwrap(), aliases);
|
||||
reopened
|
||||
.delete_transaction(&locators, protection, &mut || Ok(()))
|
||||
.unwrap();
|
||||
assert!(os.aliases().unwrap().is_empty());
|
||||
for locator in &locators {
|
||||
assert!(
|
||||
reopened
|
||||
.read(&identity(locator).unwrap())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_duplicate_replace_delete_and_no_cleartext_on_disk() {
|
||||
let (root, os, backend) = setup();
|
||||
|
||||
@@ -35,6 +35,11 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co
|
||||
};
|
||||
let fixture = FixtureSet::load()?;
|
||||
let vault = fixture.materialize_store("basic")?;
|
||||
let repository = Repository::open(vault.path())?;
|
||||
ironstorage::git::GitRepository::init(
|
||||
&repository,
|
||||
ironstorage::git::GitIdentity::new("Original", "original@example.test")?,
|
||||
)?;
|
||||
let root = tempfile::tempdir()?;
|
||||
let source = root.path().join("config.toml");
|
||||
std::fs::write(
|
||||
@@ -69,7 +74,22 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co
|
||||
1,
|
||||
"preferences must use the injected backend"
|
||||
);
|
||||
authentication.set_authentication_timeout(30)?;
|
||||
let original = std::fs::read(&source)?;
|
||||
for value in ["not a number", "-1", "0", "86401", "18446744073709551616"] {
|
||||
assert!(
|
||||
authentication
|
||||
.set_authentication_timeout_text(value)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(std::fs::read(&source)?, original);
|
||||
}
|
||||
assert!(
|
||||
authentication
|
||||
.set_git_identity("valid".to_owned(), "invalid\nemail".to_owned())
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(std::fs::read(&source)?, original);
|
||||
authentication.set_authentication_timeout_text(" 30 ")?;
|
||||
authentication.unlock_entry(
|
||||
"email/personal",
|
||||
Some(SecretBytes::new(
|
||||
@@ -108,6 +128,57 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co
|
||||
assert!(authentication.entry_editor(editor.id()).is_err());
|
||||
}
|
||||
let baseline_locks = backend.locks();
|
||||
let identity = authentication.set_git_identity(
|
||||
"Android settings author".to_owned(),
|
||||
"android-settings@example.test".to_owned(),
|
||||
)?;
|
||||
authentication.unlock_entry(
|
||||
"email/personal",
|
||||
Some(SecretBytes::new(
|
||||
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
|
||||
)),
|
||||
)?;
|
||||
let editor = authentication.begin_entry_editor("email/personal")?;
|
||||
let id = editor.page().fields()[0].id();
|
||||
authentication.discard_entry_editor(editor.id())?;
|
||||
authentication.replace_entry_field(
|
||||
"email/personal",
|
||||
id,
|
||||
"public-settings-password".to_owned(),
|
||||
)?;
|
||||
let git = ironstorage::git::GitRepository::open(&repository, identity)?;
|
||||
let commits = git.log(Some(1))?;
|
||||
assert_eq!(commits[0].author_name(), "Android settings author");
|
||||
assert_eq!(commits[0].author_email(), "android-settings@example.test");
|
||||
let store = SecretStore::new(
|
||||
backend.clone(),
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
);
|
||||
store.unlock()?;
|
||||
let configured = Config::load(Some(&source))?;
|
||||
let (server, application) = configured.git_remotes()[0].https_credentials().unwrap();
|
||||
store.store_https_git_credential(
|
||||
server,
|
||||
application,
|
||||
"public-settings-account",
|
||||
SecretBytes::new(b"public-settings-token".to_vec()),
|
||||
)?;
|
||||
store.lock()?;
|
||||
assert_eq!(
|
||||
authentication
|
||||
.preferences(false, false, false)?
|
||||
.application_account(),
|
||||
Some("public-settings-account")
|
||||
);
|
||||
authentication.remove_application_token()?;
|
||||
assert_eq!(
|
||||
authentication
|
||||
.preferences(false, false, false)?
|
||||
.application_account(),
|
||||
None
|
||||
);
|
||||
authentication.remove_application_token()?;
|
||||
drop(authentication);
|
||||
assert!(
|
||||
backend.locks() > baseline_locks,
|
||||
|
||||
@@ -228,6 +228,16 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu
|
||||
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.cache_status(&cache).0 as usize, page.rows().len());
|
||||
let rebuild = MobileTotpOperation::default();
|
||||
let mut rebuild_secrets = CountingSecrets::all(&fixture);
|
||||
let rebuilt = service.rebuild(&BTreeSet::new(), &mut rebuild_secrets, &cache, &rebuild)?;
|
||||
assert_eq!(rebuilt.rows(), page.rows());
|
||||
assert_eq!(rebuild.progress().cache_hits(), 0);
|
||||
assert!(
|
||||
rebuild_secrets.requests > 0,
|
||||
"explicit rebuild must inspect ciphertext again"
|
||||
);
|
||||
assert_eq!(
|
||||
service
|
||||
.cached_page(&BTreeSet::new(), &cache)
|
||||
@@ -328,6 +338,8 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu
|
||||
);
|
||||
|
||||
fs::write(&cache, "not a TOTP catalog")?;
|
||||
assert_eq!(service.cache_status(&cache).0, 0);
|
||||
assert!(service.cache_status(&cache).1.contains("damaged"));
|
||||
let recovery = MobileTotpOperation::default();
|
||||
let mut recovery_secrets = CountingSecrets::all(&fixture);
|
||||
let recovered = service.discover(&BTreeSet::new(), &mut recovery_secrets, &cache, &recovery)?;
|
||||
|
||||
Reference in new issue
Block a user