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

@@ -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(())
}