Implement complete Android settings and recovery controls (#104)
Android ARM verification / rust (push) Failing after 46m46s
Android ARM verification / android (push) Failing after 7s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-10 12:07:28 +02:00
1 parent 06575363a2
commit 43f660ace3
34 files changed
+2720 -64

No files matched your search

+1 -1
View File
@@ -181,7 +181,7 @@ impl os::AndroidKeystore for KeystoreAdapter {
self.0.cancel_pending();
}
}
pub(crate) struct RustSecretBackend(pub os::AndroidSecretBackend);
pub(crate) struct RustSecretBackend(pub Arc<os::AndroidSecretBackend>);
impl TryFrom<MobileSecretLocator> for SecretLocator {
type Error = MobileSecretError;
fn try_from(value: MobileSecretLocator) -> Result<Self, Self::Error> {
+53
View File
@@ -857,6 +857,12 @@ pub struct MobileGitIdentity {
pub email: String,
}
#[derive(Clone, uniffi::Record)]
pub struct MobileTotpCacheStatus {
pub entries: u32,
pub detail: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileMutationAction {
Move,
@@ -1629,6 +1635,21 @@ pub struct MobileKeyTransferImport {
#[uniffi::export]
impl MobileKeyTransferImport {
pub fn add_image(
&self,
image: Vec<u8>,
) -> Result<MobileKeyTransferProgress, MobileKeyTransferFfiError> {
self.importer
.lock()
.map_err(|_| MobileKeyTransferFfiError::Failed {
kind: MobileKeyTransferErrorKind::Failed,
message: "the key-transfer session is unavailable".to_owned(),
})?
.add_image(ironstorage::repository::SecretBytes::new(image))
.map(Into::into)
.map_err(Into::into)
}
pub fn add_frame(
&self,
payload: String,
@@ -1712,6 +1733,32 @@ pub struct MobileAuthentication {
#[uniffi::export]
impl MobileAuthentication {
pub fn set_authentication_timeout_text(
&self,
seconds: String,
) -> Result<(), MobileAuthenticationFfiError> {
self.authentication
.set_authentication_timeout_text(&seconds)
.map_err(Into::into)
}
pub fn totp_cache_status(&self) -> Result<MobileTotpCacheStatus, MobileAuthenticationFfiError> {
self.authentication
.totp_cache_status()
.map(|(entries, detail)| MobileTotpCacheStatus { entries, detail })
.map_err(Into::into)
}
pub fn rebuild_totp_cache(
&self,
operation: Arc<MobileTotpOperation>,
) -> Result<MobileTotpPage, MobileAuthenticationFfiError> {
self.authentication
.rebuild_totp_cache(&operation.operation)
.map(Into::into)
.map_err(Into::into)
}
pub fn state(&self) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
self.authentication
.state()
@@ -1760,6 +1807,12 @@ impl MobileAuthentication {
.map_err(Into::into)
}
pub fn set_default_key(&self, fingerprint: String) -> Result<(), MobileAuthenticationFfiError> {
self.authentication
.set_default_key(&fingerprint)
.map_err(Into::into)
}
pub fn set_git_identity(
&self,
name: String,
@@ -170,6 +170,10 @@ impl MobilePrivateRuntime {
pub fn clear_disposable_cache(&self) -> Result<(), MobilePrivateStorageFfiError> {
self.storage.clear_disposable_cache().map_err(Into::into)
}
pub fn key_material_changed(&self) -> Result<(), crate::MobileAuthenticationFfiError> {
self.storage.key_material_changed().map_err(Into::into)
}
pub fn authentication(
&self,
backend: Arc<dyn MobileSecretBackend>,
+17
View File
@@ -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()?;
+122 -18
View File
@@ -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());
}
+2 -4
View File
@@ -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();
+136 -4
View File
@@ -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"),
+59 -9
View File
@@ -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(&current));
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) {
+53 -1
View File
@@ -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>,
+67
View File
@@ -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,
+222 -4
View File
@@ -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();
+72 -1
View File
@@ -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,
+12
View File
@@ -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)?;