Files
IronStorage/crates/storage/src/mobile_authentication.rs

536 lines
18 KiB
Rust

//! 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},
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
git::{AutomaticEntryCommitter, GitIdentity},
mobile_entry::{MobileEntryPage, MobileEntryValueError, field_value},
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,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileEntryCopy {
value: String,
timeout_seconds: u64,
}
impl MobileEntryCopy {
pub fn value(&self) -> &str {
&self.value
}
pub fn timeout_seconds(&self) -> u64 {
self.timeout_seconds
}
}
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 entry_page(&self, path: &str) -> Result<MobileEntryPage, MobileAuthenticationError> {
let document = self.open_active_document(path)?;
Ok(MobileEntryPage::from_document(&document))
}
pub fn reveal_entry_field(
&self,
path: &str,
field: u64,
) -> Result<String, MobileAuthenticationError> {
let document = self.open_active_document(path)?;
field_value(&document, field).map_err(value_error)
}
pub fn copy_entry_field(
&self,
path: &str,
field: u64,
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
Ok(MobileEntryCopy {
value: self.reveal_entry_field(path, field)?,
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
})
}
pub fn replace_entry_field(
&self,
path: &str,
field: u64,
value: String,
) -> Result<MobileEntryPage, MobileAuthenticationError> {
let mut document = self.open_active_document(path)?;
document
.replace_field_value(EntryFieldId::from_value(field), value.into_bytes())
.map_err(document_error)?;
let mut committer =
AutomaticEntryCommitter::for_entry(&self.repository, path, GitIdentity::ironstorage())
.map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?;
EntryDocumentService::new(&self.repository, &self.keys)
.save_recoverable(&document, None, &mut committer)
.map_err(document_error)?;
Ok(MobileEntryPage::from_document(&document))
}
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(())
}
fn open_active_document(&self, path: &str) -> Result<EntryDocument, MobileAuthenticationError> {
let (handle, key) = {
let status = self.status()?;
let active = status.active.as_ref().ok_or_else(locked_error)?;
(active.handle.clone(), active.key.clone())
};
handle
.ensure_active()
.map_err(MobileAuthenticationError::authentication)?;
let mut provider = KeyOnlyProvider::new(handle, &key);
EntryDocumentService::new(&self.repository, &self.keys)
.open(path, &mut provider)
.map_err(document_error)
}
}
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(),
)
}
fn locked_error() -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Expired,
"IronStorage Locked",
"Authenticate before using protected content.",
)
}
fn document_error(error: DocumentError) -> MobileAuthenticationError {
entry_detail("Password Entry Is Unavailable", error)
}
fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError {
entry_detail("Field Value Is Unavailable", error)
}
fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Entry,
title,
error.to_string(),
)
}