Integrate Android biometrics with the shared Rust lease (#98)
Android ARM verification / rust (push) Failing after 47m23s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 23s

This commit is contained in:
hugo committed 2026-10-09 16:32:01 +02:00
1 parent 5c933a99a3
commit 647dc1bb4b
21 files changed
+1473 -118

No files matched your search

@@ -98,6 +98,7 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co
struct BackendState {
values: BTreeMap<SecretLocator, SecretBytes>,
fault: Option<SecretStoreError>,
protected_fault: Option<SecretStoreError>,
retrieves: usize,
locks: usize,
unlocks: usize,
@@ -112,6 +113,10 @@ impl MemoryBackend {
self.0.lock().expect("test mutex").fault = Some(error);
}
fn fail_protected(&self, error: SecretStoreError) {
self.0.lock().unwrap().protected_fault = Some(error);
}
fn retrieves(&self) -> usize {
self.0.lock().expect("test mutex").retrieves
}
@@ -145,6 +150,11 @@ impl SecretStoreBackend for MemoryBackend {
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
if matches!(locator, SecretLocator::OpenPgpPassphrase { .. })
&& let Some(error) = state.protected_fault.take()
{
return Err(error);
}
state.last_protection = Some(protection);
if state.values.contains_key(locator) {
return Err(SecretStoreError::AlreadyExists);
@@ -179,6 +189,11 @@ impl SecretStoreBackend for MemoryBackend {
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
if matches!(locator, SecretLocator::OpenPgpPassphrase { .. })
&& let Some(error) = state.protected_fault.take()
{
return Err(error);
}
state.last_protection = Some(protection);
let existing = state
.values
@@ -529,3 +544,283 @@ fn verified_manual_recovery_enrolls_current_biometry_and_reestablishes_the_lease
));
Ok(())
}
#[test]
fn mobile_enrollment_proves_the_key_commits_only_after_protection_and_recovers() -> TestResult {
use ironstorage::{
config::Config,
mobile_authentication::{MobileAuthentication, MobileAuthenticationErrorKind},
};
let fixture = FixtureSet::load()?;
let vault = fixture.materialize_store("basic")?;
let root = tempfile::tempdir()?;
let source = root.path().join("config.toml");
std::fs::write(
&source,
format!(
"vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n",
vault.path().to_str().unwrap(),
fixture.path("keys").to_str().unwrap(),
fixture.key("alice")?.primary_fingerprint
),
)?;
let backend = MemoryBackend::default();
let clock = ManualClock::default();
let make = || -> TestResult<MobileAuthentication> {
Ok(MobileAuthentication::with_platform(
Config::load(Some(&source))?,
Arc::new(backend.clone()),
Arc::new(clock.clone()),
)?)
};
let authentication = make()?;
let password =
|| SecretBytes::new(fixture.key("alice").unwrap().passphrase.as_bytes().to_vec());
assert_eq!(
authentication
.enroll_biometric_unlock(SecretBytes::new(b"incorrect".to_vec()))
.unwrap_err()
.kind(),
MobileAuthenticationErrorKind::InvalidPassphrase
);
assert!(backend.0.lock().unwrap().values.is_empty());
for (fault, kind) in [
(
SecretStoreError::Cancelled,
MobileAuthenticationErrorKind::Cancelled,
),
(
SecretStoreError::UnsupportedProtection,
MobileAuthenticationErrorKind::BiometryUnavailable,
),
] {
backend.fail_protected(fault);
assert_eq!(
authentication
.enroll_biometric_unlock(password())
.unwrap_err()
.kind(),
kind
);
assert!(!authentication.state()?.unlocked());
assert!(!Config::load(Some(&source))?.biometric_unlock_enabled());
assert!(backend.0.lock().unwrap().values.is_empty());
}
let state = authentication.enroll_biometric_unlock(password())?;
assert!(state.unlocked() && state.biometric_unlock_enabled());
assert_eq!(
backend.last_protection(),
Some(SecretProtection::BiometryCurrentSet)
);
authentication.manual_lock()?;
assert!(
authentication
.unlock_entry("email/personal", None)?
.unlocked()
);
let reads = backend.retrieves();
authentication.unlock_entry("email/personal", None)?;
assert_eq!(
reads,
backend.retrieves(),
"an active shared lease needs no second prompt"
);
authentication.manual_lock()?;
backend.fail_protected(SecretStoreError::Cancelled);
assert_eq!(
authentication
.unlock_entry("email/personal", Some(password()))
.unwrap_err()
.kind(),
MobileAuthenticationErrorKind::Cancelled
);
let cancelled = authentication.state()?;
assert!(!cancelled.unlocked());
assert_eq!(cancelled.remaining_seconds(), 0);
for (fault, kind) in [
(
SecretStoreError::Lockout,
MobileAuthenticationErrorKind::BiometryUnavailable,
),
(
SecretStoreError::Denied,
MobileAuthenticationErrorKind::BiometryUnavailable,
),
(
SecretStoreError::Cancelled,
MobileAuthenticationErrorKind::Cancelled,
),
(
SecretStoreError::Invalidated,
MobileAuthenticationErrorKind::PassphraseRequired,
),
(
SecretStoreError::DeviceKeyMissing,
MobileAuthenticationErrorKind::PassphraseRequired,
),
] {
backend.fail_next(fault);
assert_eq!(
authentication
.unlock_entry("email/personal", None)
.unwrap_err()
.kind(),
kind
);
assert!(!authentication.state()?.unlocked());
}
backend.invalidate_enrollment();
assert_eq!(
authentication
.unlock_entry("email/personal", None)
.unwrap_err()
.kind(),
MobileAuthenticationErrorKind::PassphraseRequired
);
assert!(
authentication
.enroll_biometric_unlock(password())?
.unlocked()
);
authentication.manual_lock()?;
drop(authentication);
let recreated = make()?;
assert!(
!recreated.state()?.unlocked(),
"process recreation cannot restore a lease"
);
assert!(recreated.unlock_entry("email/personal", None)?.unlocked());
recreated.set_biometric_unlock(false)?;
assert!(!recreated.state()?.unlocked());
assert!(backend.0.lock().unwrap().values.is_empty());
assert!(!Config::load(Some(&source))?.biometric_unlock_enabled());
Ok(())
}
#[test]
fn mobile_expiry_and_user_input_never_reveal_old_editor_handles() -> TestResult {
use ironstorage::{config::Config, mobile_authentication::MobileAuthentication};
let fixture = FixtureSet::load()?;
let vault = fixture.materialize_store("basic")?;
let root = tempfile::tempdir()?;
let source = root.path().join("config.toml");
std::fs::write(
&source,
format!(
"vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n",
vault.path().to_str().unwrap(),
fixture.path("keys").to_str().unwrap(),
fixture.key("alice")?.primary_fingerprint
),
)?;
let backend = MemoryBackend::default();
let clock = ManualClock::default();
let authentication = MobileAuthentication::with_platform(
Config::load(Some(&source))?,
Arc::new(backend),
Arc::new(clock.clone()),
)?;
authentication.set_authentication_timeout(2)?;
authentication.unlock_entry(
"email/personal",
Some(SecretBytes::new(
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
)),
)?;
let editor = authentication.begin_entry_editor("email/personal")?;
clock.advance(Duration::from_secs(1));
assert_eq!(authentication.state()?.remaining_seconds(), 1);
authentication.touch_user_activity()?;
assert_eq!(authentication.state()?.remaining_seconds(), 2);
clock.advance(Duration::from_secs(2));
assert!(!authentication.state()?.unlocked());
assert!(authentication.entry_editor(editor.id()).is_err());
assert!(authentication.entry_page("email/personal", 0).is_err());
assert!(authentication.touch_user_activity().is_err());
Ok(())
}
#[test]
fn locking_interrupts_an_os_prompt_before_waiting_for_the_lease_mutex() -> TestResult {
struct PromptBackend {
inner: MemoryBackend,
started: std::sync::mpsc::Sender<()>,
cancelled: (Mutex<bool>, std::sync::Condvar),
}
impl SecretStoreBackend for PromptBackend {
fn create(
&self,
locator: &SecretLocator,
protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
self.inner.create(locator, protection, value)
}
fn replace(
&self,
locator: &SecretLocator,
protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
self.inner.replace(locator, protection, value)
}
fn delete(
&self,
locator: &SecretLocator,
protection: SecretProtection,
) -> Result<(), SecretStoreError> {
self.inner.delete(locator, protection)
}
fn retrieve(
&self,
_: &SecretLocator,
_: SecretProtection,
) -> Result<SecretBytes, SecretStoreError> {
self.started.send(()).unwrap();
let cancelled = self.cancelled.0.lock().unwrap();
let (cancelled, wait) = self
.cancelled
.1
.wait_timeout_while(cancelled, Duration::from_secs(3), |cancelled| !*cancelled)
.unwrap();
assert!(
!wait.timed_out() && *cancelled,
"lock must interrupt the OS prompt"
);
Err(SecretStoreError::Cancelled)
}
fn lock(&self) -> Result<(), SecretStoreError> {
*self.cancelled.0.lock().unwrap() = true;
self.cancelled.1.notify_all();
self.inner.lock()
}
fn unlock(&self) -> Result<(), SecretStoreError> {
self.inner.unlock()
}
}
let fixture = FixtureSet::load()?;
let keys = KeyStore::load(fixture.path("keys"))?;
let alice = fixture_key(&fixture, &keys, "alice")?;
let (started, ready) = std::sync::mpsc::channel();
let backend = Arc::new(PromptBackend {
inner: MemoryBackend::default(),
started,
cancelled: (Mutex::new(false), std::sync::Condvar::new()),
});
let session = AuthenticationSession::with_clock(
backend,
SecretProtectionPolicy::current_biometry_for_openpgp(),
AuthenticationTimeout::default(),
ManualClock::default(),
);
let worker_session = session.clone();
let worker = std::thread::spawn(move || worker_session.authenticate(&alice));
ready.recv_timeout(Duration::from_secs(2))?;
session.manual_lock()?;
assert!(matches!(
worker.join().unwrap(),
Err(AuthenticationError::Cancelled)
));
assert_eq!(session.remaining_time()?, None);
Ok(())
}