Implement biometric-protected GPG unlock

This commit is contained in:
2026-08-11 18:32:53 +02:00
parent 3295761bcf
commit 873db91204
15 changed files with 2043 additions and 59 deletions

View File

@@ -227,36 +227,7 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
}
let _operation = self.shared.operation()?;
self.shared.expire_if_needed()?;
let now = self.shared.clock.now();
let deadline = now
.checked_add(self.shared.timeout.duration())
.ok_or(AuthenticationError::ClockOverflow)?;
let (generation, was_active) = {
let mut state = self.shared.state()?;
match state.active.as_mut() {
Some(active) => {
active.deadline = deadline;
(active.generation, true)
}
None => {
state.next_generation = state.next_generation.wrapping_add(1);
let generation = state.next_generation;
state.active = Some(ActiveLease {
generation,
deadline,
unlock_material: BTreeMap::new(),
});
(generation, false)
}
}
};
if !was_active && let Err(error) = self.shared.store.unlock() {
self.shared
.revoke_local(generation, RevocationReason::AuthenticationFailed)?;
return Err(error.into());
}
let generation = self.shared.begin_lease()?;
if key.requires_passphrase()
&& let Err(error) = self.shared.cache_key_passphrase(generation, key)
@@ -273,6 +244,40 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
})
}
/// Establish a lease from a manually entered passphrase without persisting it.
pub fn authenticate_with_passphrase(
&self,
key: &KeyInfo,
passphrase: SecretBytes,
) -> Result<AuthenticationHandle<B, C>, AuthenticationError> {
if !key.has_secret() || !key.requires_passphrase() || passphrase.expose().is_empty() {
return Err(AuthenticationError::Locked);
}
let _operation = self.shared.operation()?;
self.shared.expire_if_needed()?;
let generation = self.shared.begin_lease()?;
let fingerprint = key.fingerprint().as_str().to_owned();
self.shared.with_active(generation, |active| {
active.unlock_material.insert(fingerprint, passphrase);
})?;
Ok(AuthenticationHandle {
shared: Arc::clone(&self.shared),
generation,
})
}
/// Remove this key's OS-protected passphrase. A missing item is already disabled.
pub fn delete_key_passphrase(&self, key: &KeyInfo) -> Result<(), AuthenticationError> {
let _operation = self.shared.operation()?;
self.shared.expire_if_needed()?;
self.shared.store.unlock()?;
let reference = SecretReference::openpgp_passphrase(key.fingerprint().as_str())?;
match self.shared.store.delete_openpgp_passphrase(&reference) {
Ok(()) | Err(SecretStoreError::Missing) => Ok(()),
Err(error) => Err(error.into()),
}
}
/// Remaining active time. Calling this from a timer never extends the lease.
pub fn remaining_time(&self) -> Result<Option<Duration>, AuthenticationError> {
let _operation = self.shared.operation()?;
@@ -360,6 +365,25 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
active.deadline.saturating_sub(now)
})
}
/// Persist the passphrase already verified by a successful crypto operation.
pub fn persist_passphrase(&self, key: &KeyInfo) -> Result<(), AuthenticationError> {
let _operation = self.shared.operation()?;
self.shared.expire_if_needed()?;
let fingerprint = key.fingerprint().as_str();
let value = self.shared.with_active(self.generation, |active| {
active
.unlock_material
.get(fingerprint)
.map(|value| SecretBytes::new(value.expose().to_vec()))
})?;
let value = value.ok_or(AuthenticationError::Locked)?;
let reference = SecretReference::openpgp_passphrase(fingerprint)?;
self.shared
.store
.store_openpgp_passphrase(&reference, value)
.map_err(Into::into)
}
}
impl<B: SecretStoreBackend, C: AuthenticationClock> SecretProvider for AuthenticationHandle<B, C> {
@@ -408,6 +432,37 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
}
fn begin_lease(&self) -> Result<u64, AuthenticationError> {
let now = self.clock.now();
let deadline = now
.checked_add(self.timeout.duration())
.ok_or(AuthenticationError::ClockOverflow)?;
let (generation, was_active) = {
let mut state = self.state()?;
match state.active.as_mut() {
Some(active) => {
active.deadline = deadline;
(active.generation, true)
}
None => {
state.next_generation = state.next_generation.wrapping_add(1);
let generation = state.next_generation;
state.active = Some(ActiveLease {
generation,
deadline,
unlock_material: BTreeMap::new(),
});
(generation, false)
}
}
};
if !was_active && let Err(error) = self.store.unlock() {
self.revoke_local(generation, RevocationReason::AuthenticationFailed)?;
return Err(error.into());
}
Ok(generation)
}
fn expire_if_needed(&self) -> Result<bool, AuthenticationError> {
let now = self.clock.now();
let expired = self

View File

@@ -35,6 +35,7 @@ pub struct Config {
editor: Option<EditorCommand>,
clipboard_timeout: ClipboardTimeout,
authentication_timeout: AuthenticationTimeout,
biometric_unlock_enabled: bool,
mobile_tab: MobileTab,
mobile_home_refreshed_at: Option<i64>,
git_remotes: Vec<GitRemote>,
@@ -122,6 +123,10 @@ impl Config {
self.authentication_timeout
}
pub fn biometric_unlock_enabled(&self) -> bool {
self.biometric_unlock_enabled
}
pub fn mobile_tab(&self) -> MobileTab {
self.mobile_tab
}
@@ -203,6 +208,31 @@ impl Config {
validate_config(self.source.clone(), document, raw)?.persist()
}
pub fn update_biometric_unlock(&self, enabled: bool) -> Result<(), ConfigError> {
let mut document = self.document.clone();
let root = document
.as_table_mut()
.ok_or_else(|| ConfigError::Malformed {
path: self.source.clone(),
})?;
let security = root
.entry("security")
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.ok_or(ConfigError::InvalidField { field: "security" })?;
security.insert(
"biometric_unlock_enabled".to_owned(),
toml::Value::Boolean(enabled),
);
let raw = document
.clone()
.try_into::<RawConfig>()
.map_err(|_| ConfigError::Malformed {
path: self.source.clone(),
})?;
validate_config(self.source.clone(), document, raw)?.persist()
}
pub(crate) fn create_mobile_clone(
source: PathBuf,
vault: &Path,
@@ -835,6 +865,7 @@ struct RawConfig {
#[serde(deny_unknown_fields)]
struct RawSecurity {
inactivity_timeout_seconds: Option<u64>,
biometric_unlock_enabled: Option<bool>,
}
#[derive(Default, Deserialize)]
@@ -916,6 +947,7 @@ fn validate_config(
.map_err(|_| ConfigError::InvalidField {
field: "security.inactivity_timeout_seconds",
})?;
let biometric_unlock_enabled = raw.security.biometric_unlock_enabled.unwrap_or(false);
let mobile_tab = raw
.ui
.selected_mobile_tab
@@ -943,6 +975,7 @@ fn validate_config(
editor,
clipboard_timeout,
authentication_timeout,
biometric_unlock_enabled,
mobile_tab,
mobile_home_refreshed_at,
git_remotes,
@@ -1113,7 +1146,11 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
let security = security.as_table().ok_or_else(|| ConfigError::Malformed {
path: source.to_owned(),
})?;
validate_table(security, "security", &["inactivity_timeout_seconds"])?;
validate_table(
security,
"security",
&["inactivity_timeout_seconds", "biometric_unlock_enabled"],
)?;
}
if let Some(ui) = root.get("ui") {
let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed {

View File

@@ -407,6 +407,27 @@ impl KeyStore {
Ok(actual == expected)
}
/// Return the secret keys named by a pass entry's PKESK packets.
pub fn decrypting_keys(
&self,
ciphertext: &EncryptedEntry,
) -> Result<Vec<KeyInfo>, CryptoError> {
let message = Message::from_bytes(Cursor::new(ciphertext.as_bytes()))
.map_err(|_| CryptoError::CorruptMessage)?;
if !message.is_encrypted() {
return Err(CryptoError::CorruptMessage);
}
let keys = self
.decrypting_material(&message)
.into_iter()
.map(|(_, material)| key_info(material))
.collect::<Vec<_>>();
if keys.is_empty() {
return Err(CryptoError::MissingSecretKey);
}
Ok(keys)
}
/// Decrypt a pass entry with only the secret keys named by its PKESK packets.
pub fn decrypt(
&self,
@@ -418,16 +439,7 @@ impl KeyStore {
if !message.is_encrypted() {
return Err(CryptoError::CorruptMessage);
}
let candidates = self
.keys
.iter()
.filter(|(_, material)| {
material
.secret
.as_ref()
.is_some_and(|secret| message_matches_secret(&message, secret))
})
.collect::<Vec<_>>();
let candidates = self.decrypting_material(&message);
if candidates.is_empty() {
return Err(CryptoError::MissingSecretKey);
}
@@ -491,6 +503,21 @@ impl KeyStore {
}
}
fn decrypting_material<'a>(
&'a self,
message: &Message<'_>,
) -> Vec<(&'a KeyFingerprint, &'a KeyMaterial)> {
self.keys
.iter()
.filter(|(_, material)| {
material
.secret
.as_ref()
.is_some_and(|secret| message_matches_secret(message, secret))
})
.collect()
}
pub fn sign(
&self,
data: &[u8],

View File

@@ -15,6 +15,7 @@ pub mod generate;
pub mod git;
pub mod kdbx;
pub mod mobile;
pub mod mobile_authentication;
pub mod mobile_home;
pub mod mobile_onboarding;
pub mod mobile_passwords;

View File

@@ -0,0 +1,433 @@
//! Shared mobile authentication state; Swift only supplies input and presents results.
use std::{error::Error, fmt, sync::Mutex};
use crate::{
authentication::{
AuthenticationError, NativeAuthenticationHandle, NativeAuthenticationSession,
},
config::{Config, ConfigError},
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
repository::{EntryPath, Repository, RepositoryError, SecretBytes},
secret_store::{SecretProtectionPolicy, SecretStoreError},
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileAuthenticationErrorKind {
PassphraseRequired,
InvalidPassphrase,
Cancelled,
BiometryUnavailable,
Configuration,
KeyMaterial,
Entry,
SecureStorage,
Expired,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileAuthenticationError {
kind: MobileAuthenticationErrorKind,
title: String,
detail: String,
}
impl MobileAuthenticationError {
pub fn kind(&self) -> MobileAuthenticationErrorKind {
self.kind
}
pub fn title(&self) -> &str {
&self.title
}
pub fn detail(&self) -> &str {
&self.detail
}
fn new(kind: MobileAuthenticationErrorKind, title: &str, detail: impl Into<String>) -> Self {
Self {
kind,
title: title.to_owned(),
detail: detail.into(),
}
}
fn passphrase_required(detail: impl Into<String>) -> Self {
Self::new(
MobileAuthenticationErrorKind::PassphraseRequired,
"Passphrase Required",
detail,
)
}
fn authentication(error: AuthenticationError) -> Self {
match error {
AuthenticationError::Cancelled => Self::new(
MobileAuthenticationErrorKind::Cancelled,
"Authentication Cancelled",
"No protected content was unlocked.",
),
AuthenticationError::Expired => Self::new(
MobileAuthenticationErrorKind::Expired,
"IronStorage Locked",
"The authentication lease expired.",
),
AuthenticationError::SecretStore(SecretStoreError::Missing) => {
Self::passphrase_required("Enter the GPG key passphrase to continue.")
}
AuthenticationError::SecretStore(
SecretStoreError::Denied
| SecretStoreError::UnsupportedProtection
| SecretStoreError::Unavailable,
) => Self::new(
MobileAuthenticationErrorKind::BiometryUnavailable,
"Biometric Unlock Unavailable",
"Enter the GPG key passphrase to recover or continue without biometric unlock.",
),
error => Self::new(
MobileAuthenticationErrorKind::SecureStorage,
"Secure Unlock Failed",
error.to_string(),
),
}
}
}
impl fmt::Display for MobileAuthenticationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.title, self.detail)
}
}
impl Error for MobileAuthenticationError {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MobileAuthenticationState {
unlocked: bool,
biometric_unlock_enabled: bool,
remaining_seconds: u64,
}
impl MobileAuthenticationState {
pub fn unlocked(self) -> bool {
self.unlocked
}
pub fn biometric_unlock_enabled(self) -> bool {
self.biometric_unlock_enabled
}
pub fn remaining_seconds(self) -> u64 {
self.remaining_seconds
}
}
struct ActiveMobileLease {
handle: NativeAuthenticationHandle,
key: KeyInfo,
}
struct MobileAuthenticationStatus {
biometric_unlock_enabled: bool,
active: Option<ActiveMobileLease>,
}
/// One process-wide mobile authentication lease shared by every tab and viewer.
pub struct MobileAuthentication {
config: Config,
repository: Repository,
keys: KeyStore,
session: NativeAuthenticationSession,
status: Mutex<MobileAuthenticationStatus>,
}
impl MobileAuthentication {
pub fn load() -> Result<Self, MobileAuthenticationError> {
let config = Config::load(None).map_err(|error| {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Configuration,
"Authentication Is Unavailable",
error.to_string(),
)
})?;
let repository = Repository::open(config.vault()).map_err(entry_error)?;
let keys = KeyStore::load(config.key_material()).map_err(key_error)?;
let session = NativeAuthenticationSession::system(
SecretProtectionPolicy::current_biometry_for_openpgp(),
config.authentication_timeout(),
)
.map_err(MobileAuthenticationError::authentication)?;
Ok(Self {
status: Mutex::new(MobileAuthenticationStatus {
biometric_unlock_enabled: config.biometric_unlock_enabled(),
active: None,
}),
config,
repository,
keys,
session,
})
}
/// Validate an entry unlock and retain only the key passphrase for the shared lease.
pub fn unlock_entry(
&self,
path: &str,
passphrase: Option<SecretBytes>,
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
let entry = EntryPath::parse(path).map_err(entry_error)?;
let ciphertext = self.repository.read_entry(&entry).map_err(entry_error)?;
let candidates = self.keys.decrypting_keys(&ciphertext).map_err(key_error)?;
if let Some(active) = self.take_active()? {
let mut provider = KeyOnlyProvider::new(active.handle.clone(), &active.key);
if self.keys.decrypt(&ciphertext, &mut provider).is_ok() {
self.restore_active(active)?;
return self.state();
}
self.session
.manual_lock()
.map_err(MobileAuthenticationError::authentication)?;
}
let biometric_enabled = self.status()?.biometric_unlock_enabled;
match passphrase {
Some(passphrase) => {
for key in &candidates {
let candidate = SecretBytes::new(passphrase.expose().to_vec());
let handle = self
.session
.authenticate_with_passphrase(key, candidate)
.map_err(MobileAuthenticationError::authentication)?;
let mut provider = KeyOnlyProvider::new(handle.clone(), key);
match self.keys.decrypt(&ciphertext, &mut provider) {
Ok(plaintext) => {
drop(plaintext);
if biometric_enabled {
handle
.persist_passphrase(key)
.map_err(MobileAuthenticationError::authentication)?;
}
self.set_active(handle, key.clone())?;
return self.state();
}
Err(CryptoError::DecryptionFailed) => {
self.session
.manual_lock()
.map_err(MobileAuthenticationError::authentication)?;
}
Err(error) => return Err(key_error(error)),
}
}
Err(MobileAuthenticationError::new(
MobileAuthenticationErrorKind::InvalidPassphrase,
"Incorrect Passphrase",
"The GPG key could not be unlocked.",
))
}
None if biometric_enabled => {
for key in &candidates {
let handle = match self.session.authenticate(key) {
Ok(handle) => handle,
Err(AuthenticationError::Cancelled) => {
return Err(MobileAuthenticationError::authentication(
AuthenticationError::Cancelled,
));
}
Err(_) => continue,
};
let mut provider = KeyOnlyProvider::new(handle.clone(), key);
if let Ok(plaintext) = self.keys.decrypt(&ciphertext, &mut provider) {
drop(plaintext);
self.set_active(handle, key.clone())?;
return self.state();
}
let _ = self.session.delete_key_passphrase(key);
let _ = self.session.manual_lock();
}
Err(MobileAuthenticationError::passphrase_required(
"Biometric unlock could not restore this key. Enter its GPG passphrase to recover.",
))
}
None => Err(MobileAuthenticationError::passphrase_required(
"Enter the GPG key passphrase to continue.",
)),
}
}
pub fn set_biometric_unlock(
&self,
enabled: bool,
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
if enabled {
let status = self.status()?;
if status.biometric_unlock_enabled {
drop(status);
return self.state();
}
let active = status.active.as_ref().ok_or_else(|| {
MobileAuthenticationError::passphrase_required(
"Unlock a password entry before enabling biometric unlock.",
)
})?;
active
.handle
.persist_passphrase(&active.key)
.map_err(MobileAuthenticationError::authentication)?;
drop(status);
if let Err(error) = self.config.update_biometric_unlock(true) {
let _ = self.delete_all_key_passphrases();
let _ = self.manual_lock();
return Err(config_error(error));
}
self.status()?.biometric_unlock_enabled = true;
} else {
self.delete_all_key_passphrases()?;
self.config
.update_biometric_unlock(false)
.map_err(config_error)?;
self.session
.manual_lock()
.map_err(MobileAuthenticationError::authentication)?;
let mut status = self.status()?;
status.biometric_unlock_enabled = false;
status.active = None;
}
self.state()
}
pub fn state(&self) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
let remaining = self
.session
.remaining_time()
.map_err(MobileAuthenticationError::authentication)?;
let mut status = self.status()?;
if remaining.is_none() {
status.active = None;
}
Ok(MobileAuthenticationState {
unlocked: status.active.is_some(),
biometric_unlock_enabled: status.biometric_unlock_enabled,
remaining_seconds: remaining.map_or(0, |duration| duration.as_secs()),
})
}
pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationError> {
let status = self.status()?;
let active = status.active.as_ref().ok_or_else(|| {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Expired,
"IronStorage Locked",
"Authenticate before using protected content.",
)
})?;
active
.handle
.touch_user_activity()
.map_err(MobileAuthenticationError::authentication)
}
pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> {
self.session
.manual_lock()
.map_err(MobileAuthenticationError::authentication)?;
self.status()?.active = None;
Ok(())
}
pub fn cancel(&self) -> Result<(), MobileAuthenticationError> {
self.session
.cancel()
.map_err(MobileAuthenticationError::authentication)?;
self.status()?.active = None;
Ok(())
}
fn delete_all_key_passphrases(&self) -> Result<(), MobileAuthenticationError> {
for key in self.keys.infos().filter(KeyInfo::has_secret) {
self.session
.delete_key_passphrase(&key)
.map_err(MobileAuthenticationError::authentication)?;
}
Ok(())
}
fn status(
&self,
) -> Result<std::sync::MutexGuard<'_, MobileAuthenticationStatus>, MobileAuthenticationError>
{
self.status.lock().map_err(|_| {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::SecureStorage,
"Authentication Is Unavailable",
"The shared authentication state is unavailable.",
)
})
}
fn take_active(&self) -> Result<Option<ActiveMobileLease>, MobileAuthenticationError> {
Ok(self.status()?.active.take())
}
fn restore_active(&self, active: ActiveMobileLease) -> Result<(), MobileAuthenticationError> {
self.status()?.active = Some(active);
Ok(())
}
fn set_active(
&self,
handle: NativeAuthenticationHandle,
key: KeyInfo,
) -> Result<(), MobileAuthenticationError> {
self.status()?.active = Some(ActiveMobileLease { handle, key });
Ok(())
}
}
struct KeyOnlyProvider<'a> {
handle: NativeAuthenticationHandle,
fingerprint: &'a str,
}
impl<'a> KeyOnlyProvider<'a> {
fn new(handle: NativeAuthenticationHandle, key: &'a KeyInfo) -> Self {
Self {
handle,
fingerprint: key.fingerprint().as_str(),
}
}
}
impl SecretProvider for KeyOnlyProvider<'_> {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
if key.fingerprint().as_str() != self.fingerprint {
return Err(SecretProviderError::Missing);
}
self.handle.secret_for(key)
}
}
fn config_error(error: ConfigError) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Configuration,
"Preference Was Not Saved",
error.to_string(),
)
}
fn entry_error(error: RepositoryError) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Entry,
"Password Entry Is Unavailable",
error.to_string(),
)
}
fn key_error(error: CryptoError) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::KeyMaterial,
"GPG Key Unlock Failed",
error.to_string(),
)
}

View File

@@ -170,6 +170,7 @@ impl SecretLocator {
pub enum SecretProtection {
DeviceUnlocked,
RequireUserPresence,
BiometryCurrentSet,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -197,6 +198,13 @@ impl SecretProtectionPolicy {
)
}
pub const fn current_biometry_for_openpgp() -> Self {
Self::new(
SecretProtection::BiometryCurrentSet,
SecretProtection::DeviceUnlocked,
)
}
fn for_reference(self, reference: &SecretReference) -> SecretProtection {
match &reference.kind {
SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp,
@@ -474,6 +482,40 @@ impl<B: SecretStoreBackend> SecretStore<B> {
Ok(())
}
/// Persist a verified OpenPGP passphrase without first reading the old item.
/// This lets Apple replace an item invalidated by biometric enrollment changes.
pub(crate) fn store_openpgp_passphrase(
&self,
reference: &SecretReference,
value: SecretBytes,
) -> Result<(), SecretStoreError> {
if !matches!(
reference.kind,
SecretReferenceKind::OpenPgpPassphrase { .. }
) {
return Err(SecretStoreError::InvalidReference);
}
validate_secret(&value)?;
let locator = reference.locator();
let encoded = encode_record(reference, &value)?;
let mut state = self.unlocked_state()?;
match self.backend.replace(
&locator,
self.protections.for_reference(reference),
encoded.expose(),
) {
Ok(()) => {}
Err(SecretStoreError::Missing) => self.backend.create(
&locator,
self.protections.for_reference(reference),
encoded.expose(),
)?,
Err(error) => return Err(error),
}
self.cache_insert(&mut state, locator, encoded);
Ok(())
}
/// Create or replace one HTTPS Git account/token record without exposing
/// the previously stored account or token to a frontend.
pub fn store_https_git_credential(
@@ -525,6 +567,24 @@ impl<B: SecretStoreBackend> SecretStore<B> {
Ok(())
}
pub(crate) fn delete_openpgp_passphrase(
&self,
reference: &SecretReference,
) -> Result<(), SecretStoreError> {
if !matches!(
reference.kind,
SecretReferenceKind::OpenPgpPassphrase { .. }
) {
return Err(SecretStoreError::InvalidReference);
}
let locator = reference.locator();
let mut state = self.unlocked_state()?;
self.backend
.delete(&locator, self.protections.for_reference(reference))?;
state.cache.remove(&locator);
Ok(())
}
fn retrieve_git_record(
&self,
server: &ServerId,

View File

@@ -11,6 +11,15 @@ use std::sync::Arc;
#[cfg(any(target_os = "ios", target_os = "macos"))]
use std::collections::HashMap;
#[cfg(any(target_os = "ios", target_os = "macos"))]
use security_framework::{
access_control::{ProtectionMode, SecAccessControl},
passwords::{
AccessControlOptions, PasswordOptions, delete_generic_password_options, generic_password,
set_generic_password_options,
},
};
use keyring_core::{CredentialStore, Entry};
use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError};
@@ -77,7 +86,7 @@ impl NativeSecretBackend {
let (service, user) = locator.service_and_user();
#[cfg(any(target_os = "linux", target_os = "windows"))]
{
if protection == SecretProtection::RequireUserPresence {
if protection != SecretProtection::DeviceUnlocked {
return Err(SecretStoreError::UnsupportedProtection);
}
self.store.build(service, &user, None).map_err(map_error)
@@ -101,6 +110,7 @@ impl NativeSecretBackend {
.build(service, &user, modifiers.as_ref())
.map_err(map_error)
}
SecretProtection::BiometryCurrentSet => Err(SecretStoreError::Unavailable),
}
}
#[cfg(not(any(
@@ -123,6 +133,10 @@ impl SecretStoreBackend for NativeSecretBackend {
protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
if protection == SecretProtection::BiometryCurrentSet {
return strict_set(locator, value);
}
let entry = self.entry(locator, protection)?;
match entry.get_secret() {
Ok(existing) => {
@@ -139,6 +153,12 @@ impl SecretStoreBackend for NativeSecretBackend {
locator: &SecretLocator,
protection: SecretProtection,
) -> Result<SecretBytes, SecretStoreError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
if protection == SecretProtection::BiometryCurrentSet {
return generic_password(strict_query(locator))
.map(SecretBytes::new)
.map_err(map_security_error);
}
self.entry(locator, protection)?
.get_secret()
.map(SecretBytes::new)
@@ -151,6 +171,11 @@ impl SecretStoreBackend for NativeSecretBackend {
protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
if protection == SecretProtection::BiometryCurrentSet {
delete_generic_password_options(strict_query(locator)).map_err(map_security_error)?;
return strict_set(locator, value);
}
let entry = self.entry(locator, protection)?;
let existing = entry.get_secret().map_err(map_error)?;
drop(SecretBytes::new(existing));
@@ -162,6 +187,11 @@ impl SecretStoreBackend for NativeSecretBackend {
locator: &SecretLocator,
protection: SecretProtection,
) -> Result<(), SecretStoreError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
if protection == SecretProtection::BiometryCurrentSet {
return delete_generic_password_options(strict_query(locator))
.map_err(map_security_error);
}
self.entry(locator, protection)?
.delete_credential()
.map_err(map_error)
@@ -175,6 +205,43 @@ fn presence_modifiers(protection: SecretProtection) -> Option<HashMap<&'static s
SecretProtection::RequireUserPresence => {
Some(HashMap::from([("access-policy", "require-user-presence")]))
}
SecretProtection::BiometryCurrentSet => None,
}
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn strict_query(locator: &SecretLocator) -> PasswordOptions {
let (service, account) = locator.service_and_user();
let mut options = PasswordOptions::new_generic_password(service, &account);
options.use_protected_keychain();
options
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn strict_create_options(locator: &SecretLocator) -> Result<PasswordOptions, SecretStoreError> {
let access = SecAccessControl::create_with_protection(
Some(ProtectionMode::AccessibleWhenPasscodeSetThisDeviceOnly),
AccessControlOptions::BIOMETRY_CURRENT_SET.bits(),
)
.map_err(map_security_error)?;
let mut options = strict_query(locator);
options.set_access_control(access);
Ok(options)
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn strict_set(locator: &SecretLocator, value: &[u8]) -> Result<(), SecretStoreError> {
set_generic_password_options(value, strict_create_options(locator)?).map_err(map_security_error)
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn map_security_error(error: security_framework::base::Error) -> SecretStoreError {
match error.code() {
-25300 => SecretStoreError::Missing,
-128 => SecretStoreError::Cancelled,
-25293 | -25308 => SecretStoreError::Denied,
-50 => SecretStoreError::UnsupportedProtection,
_ => SecretStoreError::Unavailable,
}
}

View File

@@ -33,6 +33,7 @@ struct BackendState {
retrieves: usize,
locks: usize,
unlocks: usize,
last_protection: Option<SecretProtection>,
}
#[derive(Clone, Default)]
@@ -51,6 +52,14 @@ impl MemoryBackend {
self.0.lock().expect("test mutex").locks
}
fn last_protection(&self) -> Option<SecretProtection> {
self.0.lock().expect("test mutex").last_protection
}
fn invalidate_enrollment(&self) {
self.0.lock().expect("test mutex").values.clear();
}
fn take_fault(state: &mut BackendState) -> Result<(), SecretStoreError> {
match state.fault.take() {
Some(error) => Err(error),
@@ -63,11 +72,12 @@ impl SecretStoreBackend for MemoryBackend {
fn create(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.last_protection = Some(protection);
if state.values.contains_key(locator) {
return Err(SecretStoreError::AlreadyExists);
}
@@ -80,10 +90,11 @@ impl SecretStoreBackend for MemoryBackend {
fn retrieve(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
protection: SecretProtection,
) -> Result<SecretBytes, SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.last_protection = Some(protection);
state.retrieves += 1;
state
.values
@@ -95,11 +106,12 @@ impl SecretStoreBackend for MemoryBackend {
fn replace(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.last_protection = Some(protection);
let existing = state
.values
.get_mut(locator)
@@ -111,10 +123,11 @@ impl SecretStoreBackend for MemoryBackend {
fn delete(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
protection: SecretProtection,
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.last_protection = Some(protection);
state
.values
.remove(locator)
@@ -356,3 +369,71 @@ fn exact_deadline_races_are_serialized_and_timeout_validation_is_bounded() -> Te
assert_eq!(backend.locks(), baseline_locks + 1, "expiry relocks once");
Ok(())
}
#[test]
fn verified_manual_recovery_enrolls_current_biometry_and_reestablishes_the_lease() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let alice = fixture_key(&fixture, &keys, "alice")?;
let backend = MemoryBackend::default();
let clock = ManualClock::default();
let session = AuthenticationSession::with_clock(
backend.clone(),
SecretProtectionPolicy::current_biometry_for_openpgp(),
AuthenticationTimeout::new(Duration::from_secs(30))?,
clock.clone(),
);
let reader = VaultReader::new(&repository, &keys);
assert!(matches!(
session.authenticate(&alice),
Err(AuthenticationError::SecretStore(SecretStoreError::Missing))
));
let mut manual = session.authenticate_with_passphrase(
&alice,
SecretBytes::new(fixture.key("alice")?.passphrase.as_bytes().to_vec()),
)?;
assert!(matches!(
reader.show(Some("email/personal"), &mut manual)?,
ShowResult::Entry(_)
));
manual.persist_passphrase(&alice)?;
assert_eq!(
backend.last_protection(),
Some(SecretProtection::BiometryCurrentSet)
);
session.manual_lock()?;
let mut biometric = session.authenticate(&alice)?;
assert!(matches!(
reader.show(Some("email/personal"), &mut biometric)?,
ShowResult::Entry(_)
));
clock.advance(Duration::from_secs(30));
assert!(session.expire()?);
assert_eq!(biometric.ensure_active(), Err(AuthenticationError::Expired));
backend.invalidate_enrollment();
assert!(matches!(
session.authenticate(&alice),
Err(AuthenticationError::SecretStore(SecretStoreError::Missing))
));
let mut recovered = session.authenticate_with_passphrase(
&alice,
SecretBytes::new(fixture.key("alice")?.passphrase.as_bytes().to_vec()),
)?;
assert!(matches!(
reader.show(Some("email/personal"), &mut recovered)?,
ShowResult::Entry(_)
));
recovered.persist_passphrase(&alice)?;
session.delete_key_passphrase(&alice)?;
session.manual_lock()?;
assert!(matches!(
session.authenticate(&alice),
Err(AuthenticationError::SecretStore(SecretStoreError::Missing))
));
Ok(())
}

View File

@@ -210,6 +210,31 @@ fn mobile_tab_defaults_and_persists_through_storage_configuration() -> TestResul
Ok(())
}
#[test]
fn biometric_preference_is_secret_free_and_defaults_to_disabled() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
fixture.write_explicit(fixture.valid_contents())?;
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
assert!(!config.biometric_unlock_enabled());
config.update_biometric_unlock(true)?;
let contents = fs::read_to_string(fixture.explicit_path())?;
assert!(contents.contains("biometric_unlock_enabled = true"));
assert!(!contents.to_ascii_lowercase().contains("passphrase"));
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
assert!(reloaded.biometric_unlock_enabled());
reloaded.update_biometric_unlock(false)?;
assert!(
!fixture
.loader()
.load(Some(&fixture.explicit_path()))?
.biometric_unlock_enabled()
);
Ok(())
}
#[test]
fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult {
let fixture = ConfigurationFixture::new()?;