Implement iPhone TOTP tab and Watch sharing

This commit is contained in:
2026-08-11 20:54:34 +02:00
parent ae64ce47a3
commit b01cc8bb6d
10 changed files with 1980 additions and 5 deletions

View File

@@ -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",
],
)?;
}

View File

@@ -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;

View File

@@ -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",

View 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)
}
}

View File

@@ -1,6 +1,6 @@
#![forbid(unsafe_code)]
use std::{error::Error, ffi::OsStr, fs, path::Path, time::Duration};
use std::{collections::BTreeSet, error::Error, ffi::OsStr, fs, path::Path, time::Duration};
use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT;
use ironstorage::{
@@ -8,6 +8,7 @@ use ironstorage::{
config::{ConfigError, ConfigLoader, EditorSource},
desktop::DesktopStorage,
mobile::MobileTab,
repository::EntryPath,
};
use tempfile::TempDir;
@@ -64,7 +65,6 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
let config = fixture
.loader()
.load(Some(Path::new("config/config.toml")))?;
assert_eq!(config.source(), fs::canonicalize(fixture.explicit_path())?);
assert_eq!(
config.vault(),
@@ -98,6 +98,33 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
Ok(())
}
#[test]
fn watch_totp_selection_persists_only_in_application_configuration() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit(fixture.valid_contents())?;
let config = fixture
.loader()
.load(Some(Path::new("config/config.toml")))?;
let stale = fixture
.loader()
.load(Some(Path::new("config/config.toml")))?;
let selected = BTreeSet::from([
EntryPath::parse("otp/personal")?,
EntryPath::parse("otp/work")?,
]);
config.update_watch_shared_totp_entries(&selected)?;
stale.update_mobile_tab(MobileTab::Totp)?;
let reloaded = fixture
.loader()
.load(Some(Path::new("config/config.toml")))?;
assert_eq!(reloaded.watch_shared_totp_entries(), &selected);
assert_eq!(reloaded.mobile_tab(), MobileTab::Totp);
assert!(!fixture.temporary.path().join("cwd/vault").exists());
Ok(())
}
#[test]
fn authentication_timeout_defaults_overrides_and_rejects_invalid_values() -> TestResult {
let fixture = ConfigurationFixture::new()?;

View File

@@ -0,0 +1,117 @@
#![forbid(unsafe_code)]
mod support;
use std::collections::{BTreeMap, BTreeSet};
use ironstorage::{
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
mobile_totp::{MobileTotpService, MobileWatchSnapshotState},
recipient::RecipientPolicyManager,
repository::{EntryPath, Repository, SecretBytes},
};
use support::compatibility::{FixtureSet, TestResult};
struct FixtureSecrets(BTreeMap<String, Vec<u8>>);
impl FixtureSecrets {
fn all(fixture: &FixtureSet) -> Self {
Self(
fixture
.generated
.keys
.iter()
.map(|key| {
(
key.primary_fingerprint.clone(),
key.passphrase.as_bytes().to_vec(),
)
})
.collect(),
)
}
}
impl SecretProvider for FixtureSecrets {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
self.0
.get(key.fingerprint().as_str())
.cloned()
.map(SecretBytes::new)
.ok_or(SecretProviderError::Unavailable)
}
}
#[test]
fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
write_plaintext(
&repository,
&keys,
"otp/alice",
b"password\notpauth://totp/Acme:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme&digits=8&period=30\n",
)?;
write_plaintext(
&repository,
&keys,
"otp/counter",
b"otpauth://hotp/Counter?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0\n",
)?;
write_plaintext(&repository, &keys, "ordinary", b"password\nlogin: alice\n")?;
let service = MobileTotpService::new(&repository, &keys);
let mut secrets = FixtureSecrets::all(&fixture);
let page = service.page(&BTreeSet::new(), &mut secrets)?;
let alice = page
.rows()
.iter()
.find(|row| row.path() == "otp/alice")
.expect("inserted TOTP row");
assert_eq!(alice.issuer(), Some("Acme"));
assert_eq!(alice.account(), "alice@example.com");
assert!(!alice.shared_with_watch());
assert!(page.rows().iter().all(|row| row.path() != "otp/counter"));
assert_eq!(page.watch().state(), MobileWatchSnapshotState::Unavailable);
assert!(!format!("{page:?}").contains("94287082"));
let detail = service.detail("otp/alice", 59, &BTreeSet::new(), &mut secrets)?;
assert_eq!(detail.code().expose(), b"94287082");
assert_eq!(detail.valid_until(), 60);
assert_eq!(detail.period(), 30);
assert!(!format!("{detail:?}").contains("94287082"));
let selected = BTreeSet::from([EntryPath::parse("otp/alice")?]);
let page = service.page(&selected, &mut secrets)?;
assert!(
page.rows()
.iter()
.find(|row| row.path() == "otp/alice")
.expect("selected TOTP row")
.shared_with_watch()
);
assert_eq!(page.watch().state(), MobileWatchSnapshotState::Pending);
let detail = service.detail("otp/alice", 60, &selected, &mut secrets)?;
assert!(detail.shared_with_watch());
assert_eq!(detail.valid_until(), 90);
Ok(())
}
fn write_plaintext(
repository: &Repository,
keys: &KeyStore,
path: &str,
plaintext: &[u8],
) -> TestResult {
let path = EntryPath::parse(path)?;
let recipients =
RecipientPolicyManager::new(repository, keys).resolve_for_entry(&path, None)?;
let encrypted = keys.encrypt(
SecretBytes::new(plaintext.to_vec()),
recipients.recipients(),
)?;
repository.write_entry(&path, &encrypted)?;
Ok(())
}