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)
|
||||
}
|
||||
}
|
||||
@@ -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()?;
|
||||
|
||||
117
crates/storage/tests/mobile_totp.rs
Normal file
117
crates/storage/tests/mobile_totp.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user