1186 lines
42 KiB
Rust
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])"
|
|
);
|
|
}
|
|
}
|