Implement iPhone TOTP tab and Watch sharing
This commit is contained in:
@@ -19,6 +19,7 @@ use url::Url;
|
||||
use crate::authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT};
|
||||
use crate::mobile::MobileTab;
|
||||
use crate::presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT};
|
||||
use crate::repository::EntryPath;
|
||||
|
||||
const APPLICATION_DIRECTORY: &str = "ironstorage";
|
||||
const CONFIG_FILE: &str = "config.toml";
|
||||
@@ -38,6 +39,7 @@ pub struct Config {
|
||||
biometric_unlock_enabled: bool,
|
||||
mobile_tab: MobileTab,
|
||||
mobile_home_refreshed_at: Option<i64>,
|
||||
watch_shared_totp_entries: BTreeSet<EntryPath>,
|
||||
git_remotes: Vec<GitRemote>,
|
||||
}
|
||||
|
||||
@@ -135,6 +137,10 @@ impl Config {
|
||||
self.mobile_home_refreshed_at
|
||||
}
|
||||
|
||||
pub fn watch_shared_totp_entries(&self) -> &BTreeSet<EntryPath> {
|
||||
&self.watch_shared_totp_entries
|
||||
}
|
||||
|
||||
pub fn git_remotes(&self) -> &[GitRemote] {
|
||||
&self.git_remotes
|
||||
}
|
||||
@@ -154,7 +160,7 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn update_mobile_tab(&self, tab: MobileTab) -> Result<(), ConfigError> {
|
||||
let mut document = self.document.clone();
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
@@ -178,13 +184,46 @@ impl Config {
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
pub fn update_watch_shared_totp_entries(
|
||||
&self,
|
||||
entries: &BTreeSet<EntryPath>,
|
||||
) -> Result<(), ConfigError> {
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let ui = root
|
||||
.entry("ui")
|
||||
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
|
||||
.as_table_mut()
|
||||
.ok_or(ConfigError::InvalidField { field: "ui" })?;
|
||||
ui.insert(
|
||||
"watch_shared_totp_entries".to_owned(),
|
||||
toml::Value::Array(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| toml::Value::String(entry.to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
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 update_mobile_home_refresh(&self, unix_seconds: i64) -> Result<(), ConfigError> {
|
||||
if unix_seconds <= 0 {
|
||||
return Err(ConfigError::InvalidField {
|
||||
field: "ui.home_remote_refreshed_at_unix_seconds",
|
||||
});
|
||||
}
|
||||
let mut document = self.document.clone();
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
@@ -209,7 +248,7 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn update_biometric_unlock(&self, enabled: bool) -> Result<(), ConfigError> {
|
||||
let mut document = self.document.clone();
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
@@ -233,6 +272,10 @@ impl Config {
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
fn current_document(&self) -> Result<toml::Value, ConfigError> {
|
||||
Self::load(Some(&self.source)).map(|config| config.document)
|
||||
}
|
||||
|
||||
pub(crate) fn create_mobile_clone(
|
||||
source: PathBuf,
|
||||
vault: &Path,
|
||||
@@ -873,6 +916,8 @@ struct RawSecurity {
|
||||
struct RawUi {
|
||||
selected_mobile_tab: Option<String>,
|
||||
home_remote_refreshed_at_unix_seconds: Option<i64>,
|
||||
#[serde(default)]
|
||||
watch_shared_totp_entries: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -964,6 +1009,16 @@ fn validate_config(
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let watch_shared_totp_entries = raw
|
||||
.ui
|
||||
.watch_shared_totp_entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
EntryPath::parse(&entry).map_err(|_| ConfigError::InvalidField {
|
||||
field: "ui.watch_shared_totp_entries",
|
||||
})
|
||||
})
|
||||
.collect::<Result<BTreeSet<_>, _>>()?;
|
||||
let git_remotes = validate_remotes(raw.git.remotes)?;
|
||||
|
||||
Ok(Config {
|
||||
@@ -978,6 +1033,7 @@ fn validate_config(
|
||||
biometric_unlock_enabled,
|
||||
mobile_tab,
|
||||
mobile_home_refreshed_at,
|
||||
watch_shared_totp_entries,
|
||||
git_remotes,
|
||||
})
|
||||
}
|
||||
@@ -1162,6 +1218,7 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
|
||||
&[
|
||||
"selected_mobile_tab",
|
||||
"home_remote_refreshed_at_unix_seconds",
|
||||
"watch_shared_totp_entries",
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod mobile_entry;
|
||||
pub mod mobile_home;
|
||||
pub mod mobile_onboarding;
|
||||
pub mod mobile_passwords;
|
||||
pub mod mobile_totp;
|
||||
pub mod mutation;
|
||||
pub mod otp;
|
||||
pub mod presentation;
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage,
|
||||
MobileEntryValueError, field_value,
|
||||
},
|
||||
mobile_totp::{MobileTotpDetail, MobileTotpError, MobileTotpPage, MobileTotpService},
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{
|
||||
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
|
||||
@@ -161,6 +162,7 @@ struct MobileAuthenticationStatus {
|
||||
active: Option<ActiveMobileLease>,
|
||||
next_editor_id: u64,
|
||||
editors: BTreeMap<u64, MobileEntryDraft>,
|
||||
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
|
||||
}
|
||||
|
||||
/// One process-wide mobile authentication lease shared by every tab and viewer.
|
||||
@@ -194,6 +196,7 @@ impl MobileAuthentication {
|
||||
active: None,
|
||||
next_editor_id: 0,
|
||||
editors: BTreeMap::new(),
|
||||
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
|
||||
}),
|
||||
config,
|
||||
repository,
|
||||
@@ -396,6 +399,95 @@ impl MobileAuthentication {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn unlock_totp(
|
||||
&self,
|
||||
passphrase: Option<SecretBytes>,
|
||||
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
|
||||
let path = self
|
||||
.repository
|
||||
.snapshot()
|
||||
.map_err(entry_error)?
|
||||
.entries()
|
||||
.next()
|
||||
.map(|entry| entry.path().clone())
|
||||
.ok_or_else(|| entry_detail("TOTP Is Unavailable", "the password store is empty"))?;
|
||||
let ciphertext = self.repository.read_entry(&path).map_err(entry_error)?;
|
||||
self.unlock_ciphertext(&ciphertext, passphrase)
|
||||
}
|
||||
|
||||
pub fn totp_page(&self) -> Result<MobileTotpPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let (handle, key, shared) = {
|
||||
let status = self.status()?;
|
||||
let active = status.active.as_ref().ok_or_else(locked_error)?;
|
||||
(
|
||||
active.handle.clone(),
|
||||
active.key.clone(),
|
||||
status.watch_shared_totp_entries.clone(),
|
||||
)
|
||||
};
|
||||
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||
MobileTotpService::new(&self.repository, &self.keys)
|
||||
.page(&shared, &mut provider)
|
||||
.map_err(totp_error)
|
||||
}
|
||||
|
||||
pub fn totp_detail(
|
||||
&self,
|
||||
path: &str,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileTotpDetail, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let (handle, key, shared) = {
|
||||
let status = self.status()?;
|
||||
let active = status.active.as_ref().ok_or_else(locked_error)?;
|
||||
(
|
||||
active.handle.clone(),
|
||||
active.key.clone(),
|
||||
status.watch_shared_totp_entries.clone(),
|
||||
)
|
||||
};
|
||||
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||
MobileTotpService::new(&self.repository, &self.keys)
|
||||
.detail(path, unix_seconds, &shared, &mut provider)
|
||||
.map_err(totp_error)
|
||||
}
|
||||
|
||||
pub fn copy_totp_code(
|
||||
&self,
|
||||
path: &str,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
|
||||
let detail = self.totp_detail(path, unix_seconds)?;
|
||||
let value = String::from_utf8(detail.code().expose().to_vec())
|
||||
.map_err(|_| entry_detail("TOTP Code Is Unavailable", "the code is not UTF-8"))?;
|
||||
Ok(MobileEntryCopy {
|
||||
value,
|
||||
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_totp_watch_shared(
|
||||
&self,
|
||||
path: &str,
|
||||
shared: bool,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileTotpDetail, MobileAuthenticationError> {
|
||||
self.totp_detail(path, unix_seconds)?;
|
||||
let entry = EntryPath::parse(path).map_err(entry_error)?;
|
||||
let mut selected = self.status()?.watch_shared_totp_entries.clone();
|
||||
if shared {
|
||||
selected.insert(entry);
|
||||
} else {
|
||||
selected.remove(&entry);
|
||||
}
|
||||
self.config
|
||||
.update_watch_shared_totp_entries(&selected)
|
||||
.map_err(config_error)?;
|
||||
self.status()?.watch_shared_totp_entries = selected;
|
||||
self.totp_detail(path, unix_seconds)
|
||||
}
|
||||
|
||||
pub fn replace_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
@@ -786,6 +878,10 @@ fn editor_error(error: MobileEntryEditorError) -> MobileAuthenticationError {
|
||||
entry_detail("Entry Draft Is Invalid", error)
|
||||
}
|
||||
|
||||
fn totp_error(error: MobileTotpError) -> MobileAuthenticationError {
|
||||
entry_detail("TOTP Is Unavailable", error)
|
||||
}
|
||||
|
||||
fn editor_missing() -> MobileAuthenticationError {
|
||||
entry_detail(
|
||||
"Entry Draft Is Unavailable",
|
||||
|
||||
301
crates/storage/src/mobile_totp.rs
Normal file
301
crates/storage/src/mobile_totp.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
//! Storage-owned TOTP catalog and detail state for native mobile frontends.
|
||||
|
||||
use std::{collections::BTreeSet, error::Error, fmt};
|
||||
|
||||
use crate::{
|
||||
crypto::{KeyStore, SecretProvider},
|
||||
otp::{OtpError, OtpKind, OtpService},
|
||||
repository::{EntryPath, Repository, RepositoryError, SecretBytes},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileWatchSnapshotState {
|
||||
Unavailable,
|
||||
Pending,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileWatchSnapshotStatus {
|
||||
state: MobileWatchSnapshotState,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl MobileWatchSnapshotStatus {
|
||||
pub fn state(&self) -> MobileWatchSnapshotState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn detail(&self) -> &str {
|
||||
&self.detail
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileTotpRow {
|
||||
path: String,
|
||||
issuer: Option<String>,
|
||||
account: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
shared_with_watch: bool,
|
||||
}
|
||||
|
||||
impl MobileTotpRow {
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn issuer(&self) -> Option<&str> {
|
||||
self.issuer.as_deref()
|
||||
}
|
||||
|
||||
pub fn account(&self) -> &str {
|
||||
&self.account
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn detail(&self) -> &str {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn shared_with_watch(&self) -> bool {
|
||||
self.shared_with_watch
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileTotpPage {
|
||||
rows: Vec<MobileTotpRow>,
|
||||
unavailable_entries: u32,
|
||||
watch: MobileWatchSnapshotStatus,
|
||||
}
|
||||
|
||||
impl MobileTotpPage {
|
||||
pub fn rows(&self) -> &[MobileTotpRow] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
pub fn unavailable_entries(&self) -> u32 {
|
||||
self.unavailable_entries
|
||||
}
|
||||
|
||||
pub fn watch(&self) -> &MobileWatchSnapshotStatus {
|
||||
&self.watch
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MobileTotpDetail {
|
||||
path: String,
|
||||
issuer: Option<String>,
|
||||
account: String,
|
||||
code: SecretBytes,
|
||||
valid_until: u64,
|
||||
period: u64,
|
||||
shared_with_watch: bool,
|
||||
watch: MobileWatchSnapshotStatus,
|
||||
}
|
||||
|
||||
impl MobileTotpDetail {
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn issuer(&self) -> Option<&str> {
|
||||
self.issuer.as_deref()
|
||||
}
|
||||
|
||||
pub fn account(&self) -> &str {
|
||||
&self.account
|
||||
}
|
||||
|
||||
pub fn code(&self) -> &SecretBytes {
|
||||
&self.code
|
||||
}
|
||||
|
||||
pub fn valid_until(&self) -> u64 {
|
||||
self.valid_until
|
||||
}
|
||||
|
||||
pub fn period(&self) -> u64 {
|
||||
self.period
|
||||
}
|
||||
|
||||
pub fn shared_with_watch(&self) -> bool {
|
||||
self.shared_with_watch
|
||||
}
|
||||
|
||||
pub fn watch(&self) -> &MobileWatchSnapshotStatus {
|
||||
&self.watch
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for MobileTotpDetail {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("MobileTotpDetail")
|
||||
.field("path", &self.path)
|
||||
.field("issuer", &self.issuer)
|
||||
.field("account", &self.account)
|
||||
.field("code", &"[REDACTED]")
|
||||
.field("valid_until", &self.valid_until)
|
||||
.field("period", &self.period)
|
||||
.field("shared_with_watch", &self.shared_with_watch)
|
||||
.field("watch", &self.watch)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MobileTotpService<'a> {
|
||||
repository: &'a Repository,
|
||||
keys: &'a KeyStore,
|
||||
}
|
||||
|
||||
impl<'a> MobileTotpService<'a> {
|
||||
pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self {
|
||||
Self { repository, keys }
|
||||
}
|
||||
|
||||
pub fn page(
|
||||
&self,
|
||||
shared: &BTreeSet<EntryPath>,
|
||||
provider: &mut impl SecretProvider,
|
||||
) -> Result<MobileTotpPage, MobileTotpError> {
|
||||
let snapshot = self.repository.snapshot()?;
|
||||
let mut rows = Vec::new();
|
||||
let mut unavailable_entries = 0_u32;
|
||||
for entry in snapshot.entries() {
|
||||
match OtpService::new(self.repository, self.keys)
|
||||
.uri(&entry.path().to_string(), provider)
|
||||
{
|
||||
Ok(uri) if uri.kind() == OtpKind::Totp => rows.push(row(
|
||||
entry.path(),
|
||||
uri.issuer(),
|
||||
uri.account(),
|
||||
shared.contains(entry.path()),
|
||||
)),
|
||||
Ok(_) | Err(OtpError::MissingUri { .. } | OtpError::AmbiguousUri { .. }) => {}
|
||||
Err(OtpError::Crypto(_)) => {
|
||||
unavailable_entries = unavailable_entries.saturating_add(1);
|
||||
}
|
||||
Err(OtpError::Repository(error)) => return Err(error.into()),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
rows.sort_by(|left, right| {
|
||||
left.title
|
||||
.to_lowercase()
|
||||
.cmp(&right.title.to_lowercase())
|
||||
.then_with(|| {
|
||||
left.account
|
||||
.to_lowercase()
|
||||
.cmp(&right.account.to_lowercase())
|
||||
})
|
||||
.then_with(|| left.path.cmp(&right.path))
|
||||
});
|
||||
let selected = rows.iter().filter(|row| row.shared_with_watch).count();
|
||||
Ok(MobileTotpPage {
|
||||
rows,
|
||||
unavailable_entries,
|
||||
watch: snapshot_status(selected),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn detail(
|
||||
&self,
|
||||
entry: &str,
|
||||
unix_seconds: u64,
|
||||
shared: &BTreeSet<EntryPath>,
|
||||
provider: &mut impl SecretProvider,
|
||||
) -> Result<MobileTotpDetail, MobileTotpError> {
|
||||
let path = EntryPath::parse(entry)?;
|
||||
let uri = OtpService::new(self.repository, self.keys).uri(entry, provider)?;
|
||||
if uri.kind() != OtpKind::Totp {
|
||||
return Err(OtpError::NotTotp.into());
|
||||
}
|
||||
let period = uri.period().ok_or(OtpError::NotTotp)?;
|
||||
let valid_until = (unix_seconds / period)
|
||||
.checked_add(1)
|
||||
.and_then(|counter| counter.checked_mul(period))
|
||||
.ok_or(OtpError::CounterOverflow)?;
|
||||
let shared_with_watch = shared.contains(&path);
|
||||
Ok(MobileTotpDetail {
|
||||
path: path.to_string(),
|
||||
issuer: uri.issuer().map(str::to_owned),
|
||||
account: uri.account().to_owned(),
|
||||
code: uri.code_at(unix_seconds)?,
|
||||
valid_until,
|
||||
period,
|
||||
shared_with_watch,
|
||||
watch: snapshot_status(shared.len()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn row(
|
||||
path: &EntryPath,
|
||||
issuer: Option<&str>,
|
||||
account: &str,
|
||||
shared_with_watch: bool,
|
||||
) -> MobileTotpRow {
|
||||
MobileTotpRow {
|
||||
path: path.to_string(),
|
||||
issuer: issuer.map(str::to_owned),
|
||||
account: account.to_owned(),
|
||||
title: issuer.unwrap_or(account).to_owned(),
|
||||
detail: issuer.map_or_else(|| path.to_string(), |_| account.to_owned()),
|
||||
shared_with_watch,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_status(selected: usize) -> MobileWatchSnapshotStatus {
|
||||
if selected == 0 {
|
||||
MobileWatchSnapshotStatus {
|
||||
state: MobileWatchSnapshotState::Unavailable,
|
||||
detail: "No TOTP codes are selected for Apple Watch.".to_owned(),
|
||||
}
|
||||
} else {
|
||||
MobileWatchSnapshotStatus {
|
||||
state: MobileWatchSnapshotState::Pending,
|
||||
detail: format!(
|
||||
"{selected} selected TOTP {} pending Apple Watch synchronization.",
|
||||
if selected == 1 {
|
||||
"code is"
|
||||
} else {
|
||||
"codes are"
|
||||
}
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MobileTotpError {
|
||||
Repository(RepositoryError),
|
||||
Otp(OtpError),
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileTotpError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Repository(error) => error.fmt(formatter),
|
||||
Self::Otp(error) => error.fmt(formatter),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for MobileTotpError {}
|
||||
|
||||
impl From<RepositoryError> for MobileTotpError {
|
||||
fn from(error: RepositoryError) -> Self {
|
||||
Self::Repository(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OtpError> for MobileTotpError {
|
||||
fn from(error: OtpError) -> Self {
|
||||
Self::Otp(error)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user