Files
IronStorage/crates/storage/src/secret_store/android.rs
T
hugo 1abd75282e
Dependency security audit / rustsec (push) Failing after 23s
Implement Rust-owned Android Keystore secure storage (#93)
2026-10-08 16:22:57 +02:00

1186 lines
42 KiB
Rust

//! 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<u8>,
pub bytes: Vec<u8>,
}
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<AndroidDeviceState, AndroidKeystoreError>;
fn create_key(
&self,
specification: &AndroidKeySpecification,
) -> Result<(), AndroidKeystoreError>;
fn encrypt(
&self,
alias: &str,
authorization: AndroidAuthorization,
aad: &[u8],
value: &SecretBytes,
) -> Result<AndroidCiphertext, AndroidKeystoreError>;
fn decrypt(
&self,
alias: &str,
authorization: AndroidAuthorization,
aad: &[u8],
value: &AndroidCiphertext,
) -> Result<SecretBytes, AndroidKeystoreError>;
fn delete_key(&self, alias: &str) -> Result<(), AndroidKeystoreError>;
fn aliases(&self) -> Result<Vec<String>, AndroidKeystoreError>;
fn cancel_pending(&self);
}
impl From<AndroidKeystoreError> 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<dyn AndroidKeystore>,
locked: AtomicBool,
generation: AtomicU64,
}
impl AndroidSecretBackend {
pub(crate) fn open(
directory: Dir,
keystore: Arc<dyn AndroidKeystore>,
) -> Result<Self, SecretStoreError> {
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<std::fs::File, SecretStoreError> {
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<Option<Envelope>, 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::<u128>());
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<SecretBytes, 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);
}
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<String, SecretStoreError> {
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<u8> {
[
MAGIC,
id.as_bytes(),
&[protection_tag(protection)],
alias.as_bytes(),
]
.concat()
}
fn encode(envelope: &Envelope) -> Vec<u8> {
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<Envelope, SecretStoreError> {
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<String, Key>,
failure: Option<AndroidKeystoreError>,
strongbox_failed: bool,
locked_after_cipher: bool,
}
struct Os {
state: Mutex<State>,
gate: Mutex<Option<Arc<Barrier>>>,
}
impl Os {
fn new() -> Arc<Self> {
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<Vec<u8>, 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<AndroidDeviceState, AndroidKeystoreError> {
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<AndroidCiphertext, AndroidKeystoreError> {
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<SecretBytes, AndroidKeystoreError> {
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<Vec<String>, AndroidKeystoreError> {
Ok(self.state.lock().unwrap().keys.keys().cloned().collect())
}
fn cancel_pending(&self) {}
}
fn setup() -> (tempfile::TempDir, Arc<Os>, 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])"
);
}
}