//! Rust-owned Android envelopes and lifecycle; Keystore keys/ciphers stay in OS callbacks. use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError}; use crate::{atomic_file::TempFile, repository::SecretBytes}; use cap_std::fs::{Dir, OpenOptions}; use sha2::{Digest, Sha256}; use std::{ collections::BTreeSet, fmt, io::{Read, Write}, sync::{ Arc, atomic::{AtomicBool, AtomicU64, Ordering}, }, }; const PREFIX: &str = "de.rfc1437.ironstorage.sealed.v1."; const MAGIC: &[u8] = b"IRONSTORAGE-SEALED\0\x01"; const MAX_PAYLOAD: usize = 16 * 1024; const MAX_RECORDS: usize = 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct AndroidDeviceState { pub api: u32, pub locked: bool, pub secure: bool, pub strong_biometric: bool, pub strongbox: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AndroidAuthorization { None, StrongBiometric, UserPresence, } #[derive(Clone, Eq, PartialEq)] pub struct AndroidKeySpecification { pub alias: String, pub authorization: AndroidAuthorization, pub unlocked_device: bool, pub invalidate_on_enrollment: bool, pub strongbox: bool, } impl fmt::Debug for AndroidKeySpecification { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("AndroidKeySpecification([REDACTED])") } } pub struct AndroidCiphertext { pub nonce: Vec, pub bytes: Vec, } impl fmt::Debug for AndroidCiphertext { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("AndroidCiphertext([REDACTED])") } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AndroidKeystoreError { MissingKey, Invalidated, Lockout, Locked, Denied, Cancelled, Corrupted, UnsupportedProtection, StrongboxUnavailable, AlreadyExists, Unavailable, } /// OS facts, key generation, authenticated cipher operations and cancellation only. pub trait AndroidKeystore: Send + Sync { fn device_state(&self) -> Result; fn create_key( &self, specification: &AndroidKeySpecification, ) -> Result<(), AndroidKeystoreError>; fn encrypt( &self, alias: &str, authorization: AndroidAuthorization, aad: &[u8], value: &SecretBytes, ) -> Result; fn decrypt( &self, alias: &str, authorization: AndroidAuthorization, aad: &[u8], value: &AndroidCiphertext, ) -> Result; fn delete_key(&self, alias: &str) -> Result<(), AndroidKeystoreError>; fn aliases(&self) -> Result, AndroidKeystoreError>; fn cancel_pending(&self); } impl From for SecretStoreError { fn from(error: AndroidKeystoreError) -> Self { match error { AndroidKeystoreError::MissingKey => Self::DeviceKeyMissing, AndroidKeystoreError::Invalidated => Self::Invalidated, AndroidKeystoreError::Lockout => Self::Lockout, AndroidKeystoreError::Locked => Self::Locked, AndroidKeystoreError::Denied => Self::Denied, AndroidKeystoreError::Cancelled => Self::Cancelled, AndroidKeystoreError::Corrupted => Self::Corrupted, AndroidKeystoreError::UnsupportedProtection => Self::UnsupportedProtection, AndroidKeystoreError::AlreadyExists => Self::AlreadyExists, AndroidKeystoreError::StrongboxUnavailable | AndroidKeystoreError::Unavailable => { Self::Unavailable } } } } struct Envelope { protection: SecretProtection, alias: String, ciphertext: AndroidCiphertext, } pub struct AndroidSecretBackend { directory: Dir, keystore: Arc, locked: AtomicBool, generation: AtomicU64, } impl AndroidSecretBackend { pub(crate) fn open( directory: Dir, keystore: Arc, ) -> Result { let backend = Self { directory, keystore, locked: AtomicBool::new(true), generation: AtomicU64::new(0), }; let _lock = backend.transaction()?; backend.cleanup_orphan_keys()?; drop(_lock); Ok(backend) } fn transaction(&self) -> Result { let mut options = OpenOptions::new(); options.read(true).write(true).create(true); if let Ok(metadata) = self.directory.symlink_metadata("records.lock") && (!metadata.is_file() || metadata.file_type().is_symlink()) { return Err(SecretStoreError::Corrupted); } let file = self .directory .open_with("records.lock", &options) .map_err(io_error)?; crate::mobile_private_storage::private_permissions(&file, false) .map_err(|_| SecretStoreError::Unavailable)?; let file = file.into_std(); crate::file_lock::try_lock(&file).map_err(io_error)?; Ok(file) } fn authorization( &self, protection: SecretProtection, ) -> Result<(AndroidDeviceState, AndroidAuthorization), SecretStoreError> { if self.locked.load(Ordering::Acquire) { return Err(SecretStoreError::Locked); } let state = self .keystore .device_state() .map_err(SecretStoreError::from)?; if state.locked { return Err(SecretStoreError::Locked); } let authorization = match protection { SecretProtection::DeviceUnlocked => AndroidAuthorization::None, SecretProtection::BiometryCurrentSet if state.secure && state.strong_biometric => { AndroidAuthorization::StrongBiometric } SecretProtection::RequireUserPresence if state.secure && state.api >= 30 => { AndroidAuthorization::UserPresence } _ => return Err(SecretStoreError::UnsupportedProtection), }; // Older Android unlocked-key bugs require a secure lock screen, or explicit recovery. if state.api < 26 || ((28..35).contains(&state.api) && !state.secure) { return Err(SecretStoreError::UnsupportedProtection); } Ok((state, authorization)) } fn after_operation( &self, generation: u64, protection: SecretProtection, ) -> Result<(), SecretStoreError> { if self.generation.load(Ordering::Acquire) != generation { return Err(SecretStoreError::Cancelled); } self.authorization(protection).map(|_| ()) } fn read(&self, id: &str) -> Result, SecretStoreError> { let name = format!("{id}.record"); 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)), }; if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > (MAX_PAYLOAD + 1024) as u64 { return Err(SecretStoreError::Corrupted); } 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(); file.take((MAX_PAYLOAD + 1025) as u64) .read_to_end(&mut bytes) .map_err(io_error)?; decode(id, &bytes).map(Some) } fn cleanup_orphan_keys(&self) -> Result<(), SecretStoreError> { #[cfg(unix)] let _creation = crate::file_lock::directory(&self.directory).map_err(io_error)?; let mut live = BTreeSet::new(); let entries = self.directory.entries().map_err(io_error)?; for (index, entry) in entries.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 crate::atomic_file::is_temporary(name) { let metadata = self.directory.symlink_metadata(name).map_err(io_error)?; if !metadata.is_file() || metadata.file_type().is_symlink() { return Err(SecretStoreError::Corrupted); } let file = self.directory.open(name).map_err(io_error)?.into_std(); match crate::file_lock::try_lock(&file) { Ok(()) => self.directory.remove_file(name).map_err(io_error)?, Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} Err(error) => return Err(io_error(error)), } } if let Some(id) = name.strip_suffix(".record") { if !valid_id(id) { return Err(SecretStoreError::Corrupted); } live.insert(self.read(id)?.ok_or(SecretStoreError::Corrupted)?.alias); } } let aliases = self.keystore.aliases().map_err(SecretStoreError::from)?; if aliases.len() > 4096 { return Err(SecretStoreError::Unavailable); } for alias in aliases { if owned_alias(&alias) && !live.contains(&alias) { self.keystore .delete_key(&alias) .map_err(SecretStoreError::from)?; } } Ok(()) } fn write( &self, locator: &SecretLocator, protection: SecretProtection, value: &[u8], replace: bool, ) -> Result<(), SecretStoreError> { validate_protection(locator, protection)?; if value.is_empty() || value.len() > MAX_PAYLOAD { return Err(SecretStoreError::InvalidReference); } let id = identity(locator)?; let _lock = self.transaction()?; let previous = self.read(&id)?; if !replace && previous.is_some() { return Err(SecretStoreError::AlreadyExists); } if replace && previous.is_none() { return Err(SecretStoreError::Missing); } let generation = self.generation.load(Ordering::Acquire); let (state, authorization) = self.authorization(protection)?; let alias = format!("{PREFIX}{id}.{:032x}", rand::random::()); let mut specification = AndroidKeySpecification { alias: alias.clone(), authorization, unlocked_device: state.api >= 28, invalidate_on_enrollment: protection == SecretProtection::BiometryCurrentSet, strongbox: state.strongbox, }; match self.keystore.create_key(&specification) { Err(AndroidKeystoreError::StrongboxUnavailable) if specification.strongbox => { specification.strongbox = false; self.keystore .create_key(&specification) .map_err(SecretStoreError::from)?; } result => result.map_err(SecretStoreError::from)?, } let mut installed = false; let result = (|| { let clear = SecretBytes::new(value.to_vec()); let aad = additional_data(&id, protection, &alias); let ciphertext = self .keystore .encrypt(&alias, authorization, &aad, &clear) .map_err(SecretStoreError::from)?; if ciphertext.nonce.len() != 12 || ciphertext.bytes.len() != value.len() + 16 { return Err(SecretStoreError::Corrupted); } self.after_operation(generation, protection)?; let envelope = Envelope { protection, alias: alias.clone(), ciphertext, }; let mut temporary = TempFile::new(&self.directory).map_err(io_error)?; crate::mobile_private_storage::private_permissions(temporary.as_file(), false) .map_err(|_| SecretStoreError::Unavailable)?; temporary.write_all(&encode(&envelope)).map_err(io_error)?; temporary.as_file().sync_all().map_err(io_error)?; temporary .replace(format!("{id}.record")) .map_err(io_error)?; installed = true; self.directory .open(".") .and_then(|file| file.sync_all()) .map_err(io_error)?; Ok(()) })(); if !installed { let _ = self.keystore.delete_key(&alias); } result?; if let Some(previous) = previous { self.keystore .delete_key(&previous.alias) .map_err(SecretStoreError::from)?; } Ok(()) } } impl SecretStoreBackend for AndroidSecretBackend { fn create( &self, locator: &SecretLocator, protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { self.write(locator, protection, value, false) } fn replace( &self, locator: &SecretLocator, protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { self.write(locator, protection, value, true) } fn retrieve( &self, locator: &SecretLocator, protection: SecretProtection, ) -> Result { validate_protection(locator, protection)?; let id = identity(locator)?; let _lock = self.transaction()?; let envelope = self.read(&id)?.ok_or(SecretStoreError::Missing)?; if envelope.protection != protection { return Err(SecretStoreError::UnsupportedProtection); } let generation = self.generation.load(Ordering::Acquire); let (_, authorization) = self.authorization(protection)?; let clear = self .keystore .decrypt( &envelope.alias, authorization, &additional_data(&id, protection, &envelope.alias), &envelope.ciphertext, ) .map_err(SecretStoreError::from)?; self.after_operation(generation, protection)?; if clear.expose().is_empty() || clear.expose().len() > MAX_PAYLOAD || clear.expose().len() + 16 != envelope.ciphertext.bytes.len() { return Err(SecretStoreError::Corrupted); } Ok(clear) } fn delete( &self, locator: &SecretLocator, protection: SecretProtection, ) -> Result<(), SecretStoreError> { validate_protection(locator, protection)?; let id = identity(locator)?; let _lock = self.transaction()?; let envelope = self.read(&id)?.ok_or(SecretStoreError::Missing)?; if envelope.protection != protection { return Err(SecretStoreError::UnsupportedProtection); } self.directory .remove_file(format!("{id}.record")) .map_err(io_error)?; let durability = self .directory .open(".") .and_then(|file| file.sync_all()) .map_err(io_error); let deleted = self .keystore .delete_key(&envelope.alias) .map_err(SecretStoreError::from); durability.and(deleted) } fn lock(&self) -> Result<(), SecretStoreError> { self.locked.store(true, Ordering::Release); self.generation.fetch_add(1, Ordering::AcqRel); self.keystore.cancel_pending(); Ok(()) } fn unlock(&self) -> Result<(), SecretStoreError> { if self .keystore .device_state() .map_err(SecretStoreError::from)? .locked { return Err(SecretStoreError::Locked); } self.generation.fetch_add(1, Ordering::AcqRel); self.locked.store(false, Ordering::Release); Ok(()) } } fn io_error(_: std::io::Error) -> SecretStoreError { SecretStoreError::Unavailable } fn validate_protection( locator: &SecretLocator, protection: SecretProtection, ) -> Result<(), SecretStoreError> { let allowed = match locator { SecretLocator::OpenPgpPassphrase { .. } => matches!( protection, SecretProtection::BiometryCurrentSet | SecretProtection::RequireUserPresence ), SecretLocator::ApplicationBinding | SecretLocator::HttpsGitCredential { .. } | SecretLocator::SshKeyPassphrase { .. } => protection == SecretProtection::DeviceUnlocked, }; if allowed { Ok(()) } else { Err(SecretStoreError::UnsupportedProtection) } } fn protection_tag(protection: SecretProtection) -> u8 { match protection { SecretProtection::DeviceUnlocked => 0, SecretProtection::RequireUserPresence => 1, SecretProtection::BiometryCurrentSet => 2, } } fn valid_id(value: &str) -> bool { value.len() == 64 && value .bytes() .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) } fn owned_alias(value: &str) -> bool { value .strip_prefix(PREFIX) .and_then(|suffix| suffix.split_once('.')) .is_some_and(|(id, generation)| { valid_id(id) && generation.len() == 32 && generation .bytes() .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) }) } fn identity(locator: &SecretLocator) -> Result { let normalized; let locator = if let SecretLocator::OpenPgpPassphrase { fingerprint } = locator { normalized = super::SecretReference::openpgp_passphrase(fingerprint.clone())?.locator(); &normalized } else { locator }; match locator { SecretLocator::OpenPgpPassphrase { fingerprint } => { super::SecretReference::openpgp_passphrase(fingerprint.clone())?; } SecretLocator::HttpsGitCredential { server_id, application_id, } => { super::validate_identifier(server_id)?; super::validate_identifier(application_id)?; } SecretLocator::ApplicationBinding | SecretLocator::SshKeyPassphrase { .. } => {} } let (service, user) = locator.service_and_user(); let mut digest = Sha256::new(); for field in [service.as_bytes(), user.as_bytes()] { digest.update((field.len() as u64).to_be_bytes()); digest.update(field); } Ok(data_encoding::HEXLOWER.encode(&digest.finalize())) } fn additional_data(id: &str, protection: SecretProtection, alias: &str) -> Vec { [ MAGIC, id.as_bytes(), &[protection_tag(protection)], alias.as_bytes(), ] .concat() } fn encode(envelope: &Envelope) -> Vec { let mut bytes = MAGIC.to_vec(); bytes.push(protection_tag(envelope.protection)); bytes.extend_from_slice(&(envelope.alias.len() as u16).to_be_bytes()); bytes.extend_from_slice(envelope.alias.as_bytes()); bytes.extend_from_slice(&envelope.ciphertext.nonce); bytes.extend_from_slice(&envelope.ciphertext.bytes); bytes } fn decode(id: &str, bytes: &[u8]) -> Result { let bytes = bytes .strip_prefix(MAGIC) .ok_or(SecretStoreError::Corrupted)?; if bytes.len() < 3 { return Err(SecretStoreError::Corrupted); } let protection = match bytes[0] { 0 => SecretProtection::DeviceUnlocked, 1 => SecretProtection::RequireUserPresence, 2 => SecretProtection::BiometryCurrentSet, _ => return Err(SecretStoreError::Corrupted), }; let length = u16::from_be_bytes([bytes[1], bytes[2]]) as usize; if bytes.len() < 3 + length + 12 + 17 || bytes.len() > MAX_PAYLOAD + 1024 { return Err(SecretStoreError::Corrupted); } let alias = std::str::from_utf8(&bytes[3..3 + length]) .map_err(|_| SecretStoreError::Corrupted)? .to_owned(); if !owned_alias(&alias) || !alias.starts_with(&format!("{PREFIX}{id}.")) { return Err(SecretStoreError::Corrupted); } let ciphertext = AndroidCiphertext { nonce: bytes[3 + length..3 + length + 12].to_vec(), bytes: bytes[3 + length + 12..].to_vec(), }; if ciphertext.bytes.len() > MAX_PAYLOAD + 16 { return Err(SecretStoreError::Corrupted); } Ok(Envelope { protection, alias, ciphertext, }) } #[cfg(test)] mod tests { use super::*; use aes_gcm::{ Aes256Gcm, KeyInit, Nonce, aead::{Aead, Payload}, }; use cap_std::ambient_authority; use std::{ collections::BTreeMap, sync::{Barrier, Mutex}, }; struct Key { bytes: SecretBytes, specification: AndroidKeySpecification, } struct State { device: AndroidDeviceState, keys: BTreeMap, failure: Option, strongbox_failed: bool, locked_after_cipher: bool, } struct Os { state: Mutex, gate: Mutex>>, } impl Os { fn new() -> Arc { Arc::new(Self { state: Mutex::new(State { device: AndroidDeviceState { api: 37, locked: false, secure: true, strong_biometric: true, strongbox: false, }, keys: BTreeMap::new(), failure: None, strongbox_failed: false, locked_after_cipher: false, }), gate: Mutex::new(None), }) } fn crypt( &self, alias: &str, authorization: AndroidAuthorization, aad: &[u8], nonce: &[u8], bytes: &[u8], encrypt: bool, ) -> Result, AndroidKeystoreError> { if let Some(gate) = self.gate.lock().unwrap().clone() { gate.wait(); gate.wait(); } let mut state = self.state.lock().unwrap(); if let Some(error) = state.failure { return Err(error); } let key = state .keys .get(alias) .ok_or(AndroidKeystoreError::MissingKey)?; if key.specification.authorization != authorization { return Err(AndroidKeystoreError::UnsupportedProtection); } let cipher = Aes256Gcm::new_from_slice(key.bytes.expose()).unwrap(); let nonce: [u8; 12] = nonce .try_into() .map_err(|_| AndroidKeystoreError::Corrupted)?; let nonce = Nonce::from(nonce); let data = Payload { msg: bytes, aad }; let result = if encrypt { cipher.encrypt(&nonce, data) } else { cipher.decrypt(&nonce, data) } .map_err(|_| AndroidKeystoreError::Corrupted); if state.locked_after_cipher { state.device.locked = true; } result } } impl AndroidKeystore for Os { fn device_state(&self) -> Result { Ok(self.state.lock().unwrap().device) } fn create_key(&self, spec: &AndroidKeySpecification) -> Result<(), AndroidKeystoreError> { let mut state = self.state.lock().unwrap(); if spec.strongbox && state.strongbox_failed { return Err(AndroidKeystoreError::StrongboxUnavailable); } if state.keys.contains_key(&spec.alias) { return Err(AndroidKeystoreError::AlreadyExists); } state.keys.insert( spec.alias.clone(), Key { bytes: SecretBytes::new(rand::random::<[u8; 32]>().to_vec()), specification: spec.clone(), }, ); Ok(()) } fn encrypt( &self, alias: &str, authorization: AndroidAuthorization, aad: &[u8], value: &SecretBytes, ) -> Result { let nonce = rand::random::<[u8; 12]>().to_vec(); let bytes = self.crypt(alias, authorization, aad, &nonce, value.expose(), true)?; Ok(AndroidCiphertext { nonce, bytes }) } fn decrypt( &self, alias: &str, authorization: AndroidAuthorization, aad: &[u8], value: &AndroidCiphertext, ) -> Result { self.crypt(alias, authorization, aad, &value.nonce, &value.bytes, false) .map(SecretBytes::new) } fn delete_key(&self, alias: &str) -> Result<(), AndroidKeystoreError> { self.state.lock().unwrap().keys.remove(alias); Ok(()) } fn aliases(&self) -> Result, AndroidKeystoreError> { Ok(self.state.lock().unwrap().keys.keys().cloned().collect()) } fn cancel_pending(&self) {} } fn setup() -> (tempfile::TempDir, Arc, AndroidSecretBackend) { let root = tempfile::tempdir().unwrap(); let os = Os::new(); let backend = AndroidSecretBackend::open( Dir::open_ambient_dir(root.path(), ambient_authority()).unwrap(), os.clone(), ) .unwrap(); backend.unlock().unwrap(); (root, os, backend) } fn git() -> SecretLocator { SecretLocator::HttpsGitCredential { server_id: "server".into(), application_id: "app".into(), } } const DEVICE: SecretProtection = SecretProtection::DeviceUnlocked; #[test] fn roundtrip_duplicate_replace_delete_and_no_cleartext_on_disk() { let (root, os, backend) = setup(); let locator = git(); backend .create(&locator, DEVICE, b"not-a-settings-token") .unwrap(); assert_eq!( backend.create(&locator, DEVICE, b"duplicate"), Err(SecretStoreError::AlreadyExists) ); assert_eq!( backend.retrieve(&locator, DEVICE).unwrap().expose(), b"not-a-settings-token" ); let first = os.aliases().unwrap(); backend .replace(&locator, DEVICE, b"replacement-token") .unwrap(); assert_eq!(os.aliases().unwrap().len(), 1); assert_ne!(first, os.aliases().unwrap()); for entry in std::fs::read_dir(root.path()).unwrap() { let bytes = std::fs::read(entry.unwrap().path()).unwrap(); assert!(!bytes.windows(17).any(|b| b == b"replacement-token")); } assert_eq!( backend.retrieve(&locator, DEVICE).unwrap().expose(), b"replacement-token" ); backend.delete(&locator, DEVICE).unwrap(); assert!(os.aliases().unwrap().is_empty()); assert!(matches!( backend.retrieve(&locator, DEVICE), Err(SecretStoreError::Missing) )); assert_eq!( backend.replace(&locator, DEVICE, b"missing"), Err(SecretStoreError::Missing) ); assert_eq!( backend.delete(&locator, DEVICE), Err(SecretStoreError::Missing) ); } #[test] fn shared_secret_store_encodes_git_accounts_and_uses_current_biometry_for_passphrases() { let (_root, os, backend) = setup(); let store = super::super::SecretStore::new( backend, super::super::SecretCachePolicy::Disabled, super::super::SecretProtectionPolicy::current_biometry_for_openpgp(), ); store.unlock().unwrap(); let git = super::super::SecretReference::https_git_credential("server", "app", "alice").unwrap(); store .create(&git, SecretBytes::new(b"public-token".to_vec())) .unwrap(); assert_eq!(store.retrieve(&git).unwrap().expose(), b"public-token"); assert_eq!( os.state .lock() .unwrap() .keys .values() .next() .unwrap() .specification .authorization, AndroidAuthorization::None ); let pgp = super::super::SecretReference::openpgp_passphrase( "0123456789ABCDEF0123456789ABCDEF01234567", ) .unwrap(); store .create(&pgp, SecretBytes::new(b"public-passphrase".to_vec())) .unwrap(); assert!( os.state .lock() .unwrap() .keys .values() .any(|k| k.specification.authorization == AndroidAuthorization::StrongBiometric) ); store.lock().unwrap(); assert!(matches!( store.retrieve(&git), Err(SecretStoreError::Locked) )); store.unlock().unwrap(); store.delete(&pgp).unwrap(); assert_eq!(os.aliases().unwrap().len(), 1); } #[test] fn disabling_mobile_biometry_deletes_the_actual_sealed_passphrase_and_keystore_key() { use crate::{ authentication::SystemAuthenticationClock, config::Config, mobile_authentication::MobileAuthentication, }; let (root, os, backend) = setup(); let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compatibility"); let generated: toml::Value = toml::from_str(&std::fs::read_to_string(fixtures.join("generated.toml")).unwrap()) .unwrap(); let fingerprint = generated["keys"][0]["primary_fingerprint"] .as_str() .unwrap(); let source = root.path().join("config.toml"); std::fs::write( &source, format!( "vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n", fixtures.join("stores/basic").to_str().unwrap(), fixtures.join("keys").to_str().unwrap(), fingerprint ), ) .unwrap(); let authentication = MobileAuthentication::with_platform( Config::load(Some(&source)).unwrap(), Arc::new(backend), Arc::new(SystemAuthenticationClock::default()), ) .unwrap(); authentication .unlock_entry( "email/personal", Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), ) .unwrap(); assert!( authentication .set_biometric_unlock(true) .unwrap() .biometric_unlock_enabled() ); assert_eq!(os.aliases().unwrap().len(), 1); authentication.manual_lock().unwrap(); assert!( authentication .unlock_entry("email/personal", None) .unwrap() .unlocked() ); assert!( !authentication .set_biometric_unlock(false) .unwrap() .unlocked() ); assert!(os.aliases().unwrap().is_empty()); assert!( !std::fs::read_to_string(source) .unwrap() .contains("fixture-alice-passphrase") ); assert!(std::fs::read_dir(root.path()).unwrap().all(|e| { !e.unwrap() .file_name() .to_string_lossy() .ends_with(".record") })); } #[test] fn failure_never_replaces_the_old_record_and_candidate_keys_are_removed() { let (_root, os, backend) = setup(); backend.create(&git(), DEVICE, b"original").unwrap(); for (source, expected) in [ (AndroidKeystoreError::Cancelled, SecretStoreError::Cancelled), (AndroidKeystoreError::Denied, SecretStoreError::Denied), (AndroidKeystoreError::Lockout, SecretStoreError::Lockout), ( AndroidKeystoreError::Invalidated, SecretStoreError::Invalidated, ), ( AndroidKeystoreError::Unavailable, SecretStoreError::Unavailable, ), ] { os.state.lock().unwrap().failure = Some(source); assert_eq!(backend.replace(&git(), DEVICE, b"new"), Err(expected)); assert_eq!(os.aliases().unwrap().len(), 1); assert!(matches!(backend.retrieve(&git(), DEVICE), Err(e) if e == expected)); } os.state.lock().unwrap().failure = None; assert_eq!( backend.retrieve(&git(), DEVICE).unwrap().expose(), b"original" ); } #[test] fn policies_are_exact_and_no_biometric_fallback_is_permitted() { let (_root, os, backend) = setup(); let fingerprint = "0123456789ABCDEF0123456789ABCDEF01234567"; let pgp = SecretLocator::OpenPgpPassphrase { fingerprint: fingerprint.into(), }; backend .create(&pgp, SecretProtection::BiometryCurrentSet, b"passphrase") .unwrap(); let state = os.state.lock().unwrap(); let key = state.keys.values().next().unwrap(); assert_eq!( key.specification.authorization, AndroidAuthorization::StrongBiometric ); assert!(key.specification.invalidate_on_enrollment && key.specification.unlocked_device); drop(state); let lower = SecretLocator::OpenPgpPassphrase { fingerprint: fingerprint.to_lowercase(), }; assert_eq!( backend .retrieve(&lower, SecretProtection::BiometryCurrentSet) .unwrap() .expose(), b"passphrase" ); assert!(matches!( backend.retrieve(&pgp, DEVICE), Err(SecretStoreError::UnsupportedProtection) )); os.state.lock().unwrap().device.strong_biometric = false; assert!(matches!( backend.retrieve(&pgp, SecretProtection::BiometryCurrentSet), Err(SecretStoreError::UnsupportedProtection) )); backend .delete(&pgp, SecretProtection::BiometryCurrentSet) .unwrap(); backend .create(&pgp, SecretProtection::RequireUserPresence, b"passphrase") .unwrap(); assert_eq!( os.state .lock() .unwrap() .keys .values() .next() .unwrap() .specification .authorization, AndroidAuthorization::UserPresence ); } #[test] fn old_android_and_lock_state_fail_closed_and_strongbox_falls_back_only_when_absent() { let (_root, os, backend) = setup(); for api in [28, 30, 31, 34] { let mut state = os.state.lock().unwrap(); state.device.api = api; state.device.secure = false; drop(state); assert_eq!( backend.create(&git(), DEVICE, b"value"), Err(SecretStoreError::UnsupportedProtection) ); } { let mut state = os.state.lock().unwrap(); state.device.api = 37; state.device.strongbox = true; state.strongbox_failed = true; } backend.create(&git(), DEVICE, b"value").unwrap(); assert!( !os.state .lock() .unwrap() .keys .values() .next() .unwrap() .specification .strongbox ); backend.lock().unwrap(); assert!(matches!( backend.retrieve(&git(), DEVICE), Err(SecretStoreError::Locked) )); backend.unlock().unwrap(); os.state.lock().unwrap().device.locked = true; assert_eq!(backend.unlock(), Err(SecretStoreError::Locked)); assert!(matches!( backend.retrieve(&git(), DEVICE), Err(SecretStoreError::Locked) )); } #[test] fn tampering_alias_swaps_restores_and_corrupt_headers_are_typed() { let (root, os, backend) = setup(); backend.create(&git(), DEVICE, b"value").unwrap(); let path = root .path() .join(format!("{}.record", identity(&git()).unwrap())); let original = std::fs::read(&path).unwrap(); let mut bytes = original.clone(); let last = bytes.len() - 1; bytes[last] ^= 1; std::fs::write(&path, &bytes).unwrap(); assert!(matches!( backend.retrieve(&git(), DEVICE), Err(SecretStoreError::Corrupted) )); bytes = original.clone(); bytes[0] ^= 1; std::fs::write(&path, &bytes).unwrap(); assert!(matches!( backend.retrieve(&git(), DEVICE), Err(SecretStoreError::Corrupted) )); std::fs::write(&path, &original).unwrap(); os.state.lock().unwrap().keys.clear(); assert!(matches!( backend.retrieve(&git(), DEVICE), Err(SecretStoreError::DeviceKeyMissing) )); let other = SecretLocator::ApplicationBinding; std::fs::write( root.path() .join(format!("{}.record", identity(&other).unwrap())), &original, ) .unwrap(); assert!(matches!( backend.retrieve(&other, DEVICE), Err(SecretStoreError::Corrupted) )); } #[test] fn startup_cleans_only_owned_orphans_and_preserves_keys_for_corrupt_records() { let (root, os, backend) = setup(); backend.create(&git(), DEVICE, b"value").unwrap(); let live = os.aliases().unwrap()[0].clone(); let orphan = format!("{PREFIX}{}.{}", "0".repeat(64), "1".repeat(32)); for alias in [&orphan, "other.application"] { os.create_key(&AndroidKeySpecification { alias: alias.into(), authorization: AndroidAuthorization::None, unlocked_device: true, invalidate_on_enrollment: false, strongbox: false, }) .unwrap(); } let temp = ".ironstorage-atomic-00000000000000000000000000000000"; std::fs::write(root.path().join(temp), b"orphan").unwrap(); drop(backend); let dir = || Dir::open_ambient_dir(root.path(), ambient_authority()).unwrap(); let backend = AndroidSecretBackend::open(dir(), os.clone()).unwrap(); assert_eq!( os.aliases().unwrap(), vec![live.clone(), "other.application".into()] ); assert!(!root.path().join(temp).exists()); std::fs::write( root.path() .join(format!("{}.record", identity(&git()).unwrap())), b"corrupt", ) .unwrap(); drop(backend); assert!(matches!( AndroidSecretBackend::open(dir(), os.clone()), Err(SecretStoreError::Corrupted) )); assert!(os.aliases().unwrap().contains(&live)); } #[test] fn device_lock_after_decryption_discards_plaintext_and_background_cancels_operations() { let (_root, os, backend) = setup(); backend.create(&git(), DEVICE, b"value").unwrap(); os.state.lock().unwrap().locked_after_cipher = true; assert!(matches!( backend.retrieve(&git(), DEVICE), Err(SecretStoreError::Locked) )); { let mut state = os.state.lock().unwrap(); state.locked_after_cipher = false; state.device.locked = false; } let backend = Arc::new(backend); let gate = Arc::new(Barrier::new(2)); *os.gate.lock().unwrap() = Some(gate.clone()); let worker = { let backend = backend.clone(); std::thread::spawn(move || backend.retrieve(&git(), DEVICE)) }; gate.wait(); backend.lock().unwrap(); gate.wait(); assert!(matches!( worker.join().unwrap(), Err(SecretStoreError::Cancelled) )); } #[test] fn limits_and_redaction_reject_invalid_input_before_os_mutation() { let (_root, os, backend) = setup(); assert_eq!( backend.create(&git(), DEVICE, b""), Err(SecretStoreError::InvalidReference) ); assert_eq!( backend.create(&git(), DEVICE, &vec![1; MAX_PAYLOAD + 1]), Err(SecretStoreError::InvalidReference) ); let invalid = SecretLocator::HttpsGitCredential { server_id: "../".into(), application_id: "app".into(), }; assert_eq!( backend.create(&invalid, DEVICE, b"value"), Err(SecretStoreError::InvalidReference) ); assert!(os.aliases().unwrap().is_empty()); let passphrase = SecretLocator::OpenPgpPassphrase { fingerprint: "0123456789ABCDEF0123456789ABCDEF01234567".into(), }; assert_eq!( backend.create(&passphrase, DEVICE, b"value"), Err(SecretStoreError::UnsupportedProtection) ); assert_eq!( backend.create(&git(), SecretProtection::BiometryCurrentSet, b"value"), Err(SecretStoreError::UnsupportedProtection) ); assert_eq!( backend.create( &SecretLocator::ApplicationBinding, SecretProtection::RequireUserPresence, b"value" ), Err(SecretStoreError::UnsupportedProtection) ); assert!(os.aliases().unwrap().is_empty()); assert_eq!( format!( "{:?}", AndroidCiphertext { nonce: b"nonce".to_vec(), bytes: b"secret".to_vec() } ), "AndroidCiphertext([REDACTED])" ); } }