Implement biometric-protected GPG unlock
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
433
crates/storage/src/mobile_authentication.rs
Normal file
433
crates/storage/src/mobile_authentication.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user