900 lines
28 KiB
Rust
900 lines
28 KiB
Rust
//! Storage-owned TOTP catalog and detail state for native mobile frontends.
|
|
|
|
use std::{
|
|
collections::{BTreeMap, BTreeSet},
|
|
error::Error,
|
|
fmt, fs,
|
|
io::{Read as _, Write as _},
|
|
path::Path,
|
|
sync::{
|
|
Mutex,
|
|
atomic::{AtomicBool, Ordering},
|
|
},
|
|
};
|
|
|
|
use crate::atomic_file::TempFile;
|
|
use cap_std::{ambient_authority, fs::Dir};
|
|
use data_encoding::HEXLOWER;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest as _, Sha256};
|
|
|
|
use crate::{
|
|
crypto::{KeyStore, SecretProvider},
|
|
document::EntryDocument,
|
|
mobile_watch::{MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotEntry},
|
|
otp::{OtpError, OtpKind, OtpService},
|
|
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
|
|
};
|
|
|
|
const CACHE_VERSION: u32 = 1;
|
|
const MAX_CATALOG_BYTES: u64 = 32 * 1024 * 1024;
|
|
// ponytail: cache writes are rare; use per-cache locks only if profiling shows contention.
|
|
static CACHE_WRITE_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum MobileTotpDiscoveryPhase {
|
|
Preparing,
|
|
Inspecting,
|
|
Saving,
|
|
Complete,
|
|
Cancelled,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct MobileTotpDiscoveryProgress {
|
|
phase: MobileTotpDiscoveryPhase,
|
|
total: u32,
|
|
inspected: u32,
|
|
cache_hits: u32,
|
|
matches: u32,
|
|
unavailable: u32,
|
|
}
|
|
|
|
impl MobileTotpDiscoveryProgress {
|
|
pub fn phase(&self) -> MobileTotpDiscoveryPhase {
|
|
self.phase
|
|
}
|
|
|
|
pub fn total(&self) -> u32 {
|
|
self.total
|
|
}
|
|
|
|
pub fn inspected(&self) -> u32 {
|
|
self.inspected
|
|
}
|
|
|
|
pub fn cache_hits(&self) -> u32 {
|
|
self.cache_hits
|
|
}
|
|
|
|
pub fn matches(&self) -> u32 {
|
|
self.matches
|
|
}
|
|
|
|
pub fn unavailable(&self) -> u32 {
|
|
self.unavailable
|
|
}
|
|
}
|
|
|
|
pub struct MobileTotpOperation {
|
|
cancelled: AtomicBool,
|
|
progress: Mutex<MobileTotpDiscoveryProgress>,
|
|
}
|
|
|
|
impl Default for MobileTotpOperation {
|
|
fn default() -> Self {
|
|
Self {
|
|
cancelled: AtomicBool::new(false),
|
|
progress: Mutex::new(MobileTotpDiscoveryProgress {
|
|
phase: MobileTotpDiscoveryPhase::Preparing,
|
|
total: 0,
|
|
inspected: 0,
|
|
cache_hits: 0,
|
|
matches: 0,
|
|
unavailable: 0,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MobileTotpOperation {
|
|
pub fn cancel(&self) {
|
|
self.cancelled.store(true, Ordering::Relaxed);
|
|
self.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Cancelled);
|
|
}
|
|
|
|
pub fn progress(&self) -> MobileTotpDiscoveryProgress {
|
|
self.progress.lock().map_or_else(
|
|
|poisoned| poisoned.into_inner().clone(),
|
|
|value| value.clone(),
|
|
)
|
|
}
|
|
|
|
fn check_cancelled(&self) -> Result<(), MobileTotpError> {
|
|
if self.cancelled.load(Ordering::Relaxed) {
|
|
Err(OtpError::Cancelled.into())
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn update(&self, update: impl FnOnce(&mut MobileTotpDiscoveryProgress)) {
|
|
match self.progress.lock() {
|
|
Ok(mut progress) => update(&mut progress),
|
|
Err(poisoned) => update(&mut poisoned.into_inner()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
cache_notice: Option<String>,
|
|
watch: MobileWatchSnapshotStatus,
|
|
}
|
|
|
|
impl MobileTotpPage {
|
|
pub fn rows(&self) -> &[MobileTotpRow] {
|
|
&self.rows
|
|
}
|
|
|
|
pub fn unavailable_entries(&self) -> u32 {
|
|
self.unavailable_entries
|
|
}
|
|
|
|
pub fn cache_notice(&self) -> Option<&str> {
|
|
self.cache_notice.as_deref()
|
|
}
|
|
|
|
pub fn watch(&self) -> &MobileWatchSnapshotStatus {
|
|
&self.watch
|
|
}
|
|
|
|
pub(crate) fn set_watch(&mut self, watch: MobileWatchSnapshotStatus) {
|
|
self.watch = 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
|
|
}
|
|
|
|
pub(crate) fn set_watch(&mut self, watch: MobileWatchSnapshotStatus) {
|
|
self.watch = watch;
|
|
}
|
|
}
|
|
|
|
pub struct WatchSnapshotSelection {
|
|
entries: Vec<WatchSnapshotEntry>,
|
|
retained: BTreeSet<EntryPath>,
|
|
}
|
|
|
|
impl WatchSnapshotSelection {
|
|
pub fn into_parts(self) -> (Vec<WatchSnapshotEntry>, BTreeSet<EntryPath>) {
|
|
(self.entries, self.retained)
|
|
}
|
|
}
|
|
|
|
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 MobileTotpReconciliation {
|
|
detail: Option<MobileTotpDetail>,
|
|
cache_notice: Option<String>,
|
|
}
|
|
|
|
impl MobileTotpReconciliation {
|
|
pub fn detail(&self) -> Option<&MobileTotpDetail> {
|
|
self.detail.as_ref()
|
|
}
|
|
|
|
pub fn cache_notice(&self) -> Option<&str> {
|
|
self.cache_notice.as_deref()
|
|
}
|
|
|
|
pub fn into_parts(self) -> (Option<MobileTotpDetail>, Option<String>) {
|
|
(self.detail, self.cache_notice)
|
|
}
|
|
}
|
|
|
|
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(_) => {}
|
|
}
|
|
}
|
|
sort_rows(&mut rows);
|
|
let selected = rows.iter().filter(|row| row.shared_with_watch).count();
|
|
Ok(MobileTotpPage {
|
|
rows,
|
|
unavailable_entries,
|
|
cache_notice: None,
|
|
watch: snapshot_status(selected),
|
|
})
|
|
}
|
|
|
|
pub fn discover(
|
|
&self,
|
|
shared: &BTreeSet<EntryPath>,
|
|
provider: &mut impl SecretProvider,
|
|
cache_path: &Path,
|
|
operation: &MobileTotpOperation,
|
|
) -> Result<MobileTotpPage, MobileTotpError> {
|
|
operation.check_cancelled()?;
|
|
let initial_inventory = inventory(self.repository)?;
|
|
operation.update(|progress| {
|
|
progress.phase = MobileTotpDiscoveryPhase::Inspecting;
|
|
progress.total = u32::try_from(initial_inventory.len()).unwrap_or(u32::MAX);
|
|
});
|
|
|
|
let store = store_identity(self.repository.root_path(), cache_path);
|
|
let (cached, mut cache_notice) = load_cache(cache_path, &store);
|
|
let mut records = BTreeMap::new();
|
|
let mut rows = Vec::new();
|
|
let mut unavailable_entries = 0_u32;
|
|
for (path, encrypted, ciphertext_hash) in &initial_inventory {
|
|
operation.check_cancelled()?;
|
|
let path_text = path.to_string();
|
|
let cached_record = cached
|
|
.as_ref()
|
|
.and_then(|catalog| catalog.entries.get(&path_text))
|
|
.filter(|record| record.ciphertext_hash == *ciphertext_hash);
|
|
let (record, changed) = if let Some(record) = cached_record {
|
|
operation
|
|
.update(|progress| progress.cache_hits = progress.cache_hits.saturating_add(1));
|
|
(record.clone(), false)
|
|
} else {
|
|
match inspect_entry(self.repository, self.keys, path, encrypted, provider) {
|
|
Ok(record) => (record.with_ciphertext_hash(ciphertext_hash.clone()), true),
|
|
Err(OtpError::Crypto(_)) => {
|
|
unavailable_entries = unavailable_entries.saturating_add(1);
|
|
operation.update(|progress| {
|
|
progress.unavailable = progress.unavailable.saturating_add(1)
|
|
});
|
|
operation.update(|progress| {
|
|
progress.inspected = progress.inspected.saturating_add(1)
|
|
});
|
|
continue;
|
|
}
|
|
Err(OtpError::Repository(error)) => return Err(error.into()),
|
|
Err(error) => return Err(error.into()),
|
|
}
|
|
};
|
|
if record.is_totp {
|
|
rows.push(cached_row(path, shared.contains(path)));
|
|
operation.update(|progress| progress.matches = progress.matches.saturating_add(1));
|
|
}
|
|
if changed {
|
|
cache_notice =
|
|
upsert_cache_record(cache_path, &store, path_text.clone(), record.clone())
|
|
.or(cache_notice);
|
|
}
|
|
records.insert(path_text, record);
|
|
operation.update(|progress| progress.inspected = progress.inspected.saturating_add(1));
|
|
}
|
|
|
|
operation.check_cancelled()?;
|
|
if initial_inventory != inventory(self.repository)? {
|
|
return Err(MobileTotpError::ConcurrentModification);
|
|
}
|
|
operation.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Saving);
|
|
let catalog = CachedCatalog {
|
|
version: CACHE_VERSION,
|
|
store,
|
|
entries: records,
|
|
};
|
|
if let Err(error) = save_cache(cache_path, &catalog) {
|
|
cache_notice = Some(error.to_string());
|
|
}
|
|
|
|
sort_rows(&mut rows);
|
|
let selected = rows.iter().filter(|row| row.shared_with_watch).count();
|
|
operation.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Complete);
|
|
Ok(MobileTotpPage {
|
|
rows,
|
|
unavailable_entries,
|
|
cache_notice,
|
|
watch: snapshot_status(selected),
|
|
})
|
|
}
|
|
|
|
pub fn cached_page(
|
|
&self,
|
|
shared: &BTreeSet<EntryPath>,
|
|
cache_path: &Path,
|
|
) -> Option<MobileTotpPage> {
|
|
self.search_cached_page(shared, cache_path, "")
|
|
}
|
|
|
|
pub fn search_cached_page(
|
|
&self,
|
|
shared: &BTreeSet<EntryPath>,
|
|
cache_path: &Path,
|
|
query: &str,
|
|
) -> Option<MobileTotpPage> {
|
|
let store = store_identity(self.repository.root_path(), cache_path);
|
|
let (catalog, _) = load_cache(cache_path, &store);
|
|
let query = query.trim().to_lowercase();
|
|
let mut rows: Vec<_> = catalog?
|
|
.entries
|
|
.into_iter()
|
|
.filter(|(_, record)| record.is_totp)
|
|
.filter_map(|(path, record)| {
|
|
let path = EntryPath::parse(&path).ok()?;
|
|
let ciphertext = self.repository.read_entry(&path).ok()?;
|
|
(digest(ciphertext.as_bytes()) == record.ciphertext_hash).then_some(path)
|
|
})
|
|
.filter(|path| query.is_empty() || path.to_string().to_lowercase().contains(&query))
|
|
.map(|path| cached_row(&path, shared.contains(&path)))
|
|
.collect();
|
|
sort_rows(&mut rows);
|
|
let selected = rows.iter().filter(|row| row.shared_with_watch).count();
|
|
Some(MobileTotpPage {
|
|
rows,
|
|
unavailable_entries: 0,
|
|
cache_notice: None,
|
|
watch: snapshot_status(selected),
|
|
})
|
|
}
|
|
|
|
pub fn reconcile_document(
|
|
&self,
|
|
document: &EntryDocument,
|
|
ciphertext: &EncryptedEntry,
|
|
unix_seconds: Option<u64>,
|
|
shared: &BTreeSet<EntryPath>,
|
|
cache_path: &Path,
|
|
) -> Result<MobileTotpReconciliation, MobileTotpError> {
|
|
let uri = document
|
|
.otp_uri()
|
|
.ok()
|
|
.flatten()
|
|
.filter(|uri| uri.kind() == OtpKind::Totp);
|
|
let record = CachedRecord {
|
|
ciphertext_hash: digest(ciphertext.as_bytes()),
|
|
is_totp: uri.is_some(),
|
|
};
|
|
let store = store_identity(self.repository.root_path(), cache_path);
|
|
let cache_notice =
|
|
upsert_cache_record(cache_path, &store, document.path().to_string(), record);
|
|
let detail = match (uri, unix_seconds) {
|
|
(Some(uri), Some(unix_seconds)) => {
|
|
Some(detail_for_uri(document.path(), uri, unix_seconds, shared)?)
|
|
}
|
|
_ => None,
|
|
};
|
|
Ok(MobileTotpReconciliation {
|
|
detail,
|
|
cache_notice,
|
|
})
|
|
}
|
|
|
|
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());
|
|
}
|
|
detail_for_uri(&path, uri, unix_seconds, shared)
|
|
}
|
|
|
|
pub fn watch_snapshot_selection(
|
|
&self,
|
|
selected: &BTreeSet<EntryPath>,
|
|
provider: &mut impl SecretProvider,
|
|
) -> Result<WatchSnapshotSelection, MobileTotpError> {
|
|
let mut entries = Vec::with_capacity(selected.len());
|
|
let mut retained = BTreeSet::new();
|
|
for path in selected {
|
|
let uri = match OtpService::new(self.repository, self.keys)
|
|
.uri(&path.to_string(), provider)
|
|
{
|
|
Ok(uri) if uri.kind() == OtpKind::Totp => uri,
|
|
Ok(_) => continue,
|
|
Err(OtpError::Repository(RepositoryError::NotFound { .. }))
|
|
| Err(OtpError::MissingUri { .. } | OtpError::AmbiguousUri { .. }) => continue,
|
|
Err(OtpError::Crypto(error)) => return Err(OtpError::Crypto(error).into()),
|
|
Err(OtpError::Repository(error)) => return Err(error.into()),
|
|
Err(_) => continue,
|
|
};
|
|
let period = uri.period().ok_or(OtpError::NotTotp)?;
|
|
retained.insert(path.clone());
|
|
entries.push(WatchSnapshotEntry::new(
|
|
path.to_string(),
|
|
uri.issuer().map(str::to_owned),
|
|
uri.account().to_owned(),
|
|
uri.algorithm(),
|
|
uri.digits(),
|
|
period,
|
|
uri.watch_secret(),
|
|
));
|
|
}
|
|
Ok(WatchSnapshotSelection { entries, retained })
|
|
}
|
|
}
|
|
|
|
fn detail_for_uri(
|
|
path: &EntryPath,
|
|
uri: crate::otp::OtpUri,
|
|
unix_seconds: u64,
|
|
shared: &BTreeSet<EntryPath>,
|
|
) -> Result<MobileTotpDetail, MobileTotpError> {
|
|
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)?;
|
|
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: shared.contains(path),
|
|
watch: snapshot_status(shared.len()),
|
|
})
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
struct CachedRecord {
|
|
ciphertext_hash: String,
|
|
is_totp: bool,
|
|
}
|
|
|
|
impl CachedRecord {
|
|
fn with_ciphertext_hash(mut self, ciphertext_hash: String) -> Self {
|
|
self.ciphertext_hash = ciphertext_hash;
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
struct CachedCatalog {
|
|
version: u32,
|
|
store: String,
|
|
entries: BTreeMap<String, CachedRecord>,
|
|
}
|
|
|
|
fn inventory(
|
|
repository: &Repository,
|
|
) -> Result<Vec<(EntryPath, EncryptedEntry, String)>, RepositoryError> {
|
|
repository
|
|
.snapshot()?
|
|
.entries()
|
|
.map(|entry| {
|
|
let encrypted = repository.read_entry(entry.path())?;
|
|
let ciphertext_hash = digest(encrypted.as_bytes());
|
|
Ok((entry.path().clone(), encrypted, ciphertext_hash))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn inspect_entry(
|
|
repository: &Repository,
|
|
keys: &KeyStore,
|
|
path: &EntryPath,
|
|
encrypted: &EncryptedEntry,
|
|
provider: &mut impl SecretProvider,
|
|
) -> Result<CachedRecord, OtpError> {
|
|
let is_totp = match OtpService::new(repository, keys).uri_encrypted(path, encrypted, provider) {
|
|
Ok(uri) => uri.kind() == OtpKind::Totp,
|
|
Err(OtpError::MissingUri { .. } | OtpError::AmbiguousUri { .. }) => false,
|
|
Err(OtpError::Crypto(error)) => return Err(OtpError::Crypto(error)),
|
|
Err(OtpError::Repository(error)) => return Err(OtpError::Repository(error)),
|
|
Err(_) => false,
|
|
};
|
|
Ok(CachedRecord {
|
|
ciphertext_hash: String::new(),
|
|
is_totp,
|
|
})
|
|
}
|
|
|
|
fn load_cache(path: &Path, store: &str) -> (Option<CachedCatalog>, Option<String>) {
|
|
if let Ok(metadata) = fs::symlink_metadata(path)
|
|
&& (metadata.file_type().is_symlink()
|
|
|| !metadata.is_file()
|
|
|| metadata.len() > MAX_CATALOG_BYTES)
|
|
{
|
|
return (
|
|
None,
|
|
Some("The TOTP cache was not a regular file and was rebuilt.".to_owned()),
|
|
);
|
|
}
|
|
let contents = match fs::read_to_string(path) {
|
|
Ok(contents) => contents,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return (None, None),
|
|
Err(_) => {
|
|
return (
|
|
None,
|
|
Some("The TOTP cache could not be read and was rebuilt.".to_owned()),
|
|
);
|
|
}
|
|
};
|
|
let catalog = toml::from_str::<CachedCatalog>(&contents)
|
|
.ok()
|
|
.filter(|catalog| catalog.version == CACHE_VERSION && catalog.store == store);
|
|
match catalog {
|
|
Some(catalog) => (Some(catalog), None),
|
|
None => (
|
|
None,
|
|
Some("The TOTP cache was outdated or damaged and was rebuilt.".to_owned()),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn validate_private_catalog(
|
|
root: &Path,
|
|
path: &Path,
|
|
) -> Result<bool, crate::mobile_private_storage::PrivateStorageError> {
|
|
use crate::mobile_private_storage::PrivateStorageError;
|
|
let _lock = CACHE_WRITE_LOCK
|
|
.lock()
|
|
.map_err(|_| PrivateStorageError::Io)?;
|
|
let metadata = match fs::symlink_metadata(path) {
|
|
Ok(metadata) => metadata,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
|
Err(error) => return Err(error.into()),
|
|
};
|
|
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
|
return Err(PrivateStorageError::UnsafePath);
|
|
}
|
|
let store = store_identity(root, path);
|
|
let parent = Dir::open_ambient_dir(
|
|
path.parent().ok_or(PrivateStorageError::UnsafePath)?,
|
|
ambient_authority(),
|
|
)?;
|
|
let name = path.file_name().ok_or(PrivateStorageError::UnsafePath)?;
|
|
let file = parent.open(name)?;
|
|
crate::mobile_private_storage::private_permissions(&file, false)?;
|
|
let mut bytes = Vec::new();
|
|
if metadata.len() <= MAX_CATALOG_BYTES {
|
|
file.take(MAX_CATALOG_BYTES + 1).read_to_end(&mut bytes)?;
|
|
}
|
|
let source = std::str::from_utf8(&bytes).ok();
|
|
let valid = metadata.len() <= MAX_CATALOG_BYTES
|
|
&& bytes.len() as u64 <= MAX_CATALOG_BYTES
|
|
&& source
|
|
.and_then(|source| toml::from_str::<CachedCatalog>(source).ok())
|
|
.is_some_and(|catalog| {
|
|
catalog.version == CACHE_VERSION
|
|
&& catalog.store == store
|
|
&& catalog.entries.iter().all(|(entry, record)| {
|
|
EntryPath::parse(entry).is_ok()
|
|
&& record.ciphertext_hash.len() == 64
|
|
&& record
|
|
.ciphertext_hash
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_hexdigit())
|
|
})
|
|
});
|
|
if valid {
|
|
return Ok(false);
|
|
}
|
|
parent.remove_file(name)?;
|
|
parent.open(".")?.sync_all()?;
|
|
Ok(true)
|
|
}
|
|
|
|
fn upsert_cache_record(
|
|
path: &Path,
|
|
store: &str,
|
|
entry: String,
|
|
record: CachedRecord,
|
|
) -> Option<String> {
|
|
let _write = CACHE_WRITE_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let (catalog, notice) = load_cache(path, store);
|
|
let mut catalog = catalog.unwrap_or_else(|| CachedCatalog {
|
|
version: CACHE_VERSION,
|
|
store: store.to_owned(),
|
|
entries: BTreeMap::new(),
|
|
});
|
|
if catalog.entries.get(&entry) == Some(&record) {
|
|
return notice;
|
|
}
|
|
catalog.entries.insert(entry, record);
|
|
save_cache_unlocked(path, &catalog)
|
|
.err()
|
|
.map(|error| error.to_string())
|
|
.or(notice)
|
|
}
|
|
|
|
fn save_cache(path: &Path, catalog: &CachedCatalog) -> Result<(), MobileTotpCacheError> {
|
|
let _write = CACHE_WRITE_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
save_cache_unlocked(path, catalog)
|
|
}
|
|
|
|
fn save_cache_unlocked(path: &Path, catalog: &CachedCatalog) -> Result<(), MobileTotpCacheError> {
|
|
let serialized = toml::to_string(catalog).map_err(|_| MobileTotpCacheError::Encode)?;
|
|
let parent = path.parent().ok_or(MobileTotpCacheError::Write)?;
|
|
let name = path.file_name().ok_or(MobileTotpCacheError::Write)?;
|
|
let directory = Dir::open_ambient_dir(parent, ambient_authority())
|
|
.map_err(|_| MobileTotpCacheError::Write)?;
|
|
if let Ok(metadata) = directory.symlink_metadata(name)
|
|
&& (metadata.file_type().is_symlink() || !metadata.is_file())
|
|
{
|
|
return Err(MobileTotpCacheError::Write);
|
|
}
|
|
let mut temporary = TempFile::new(&directory).map_err(|_| MobileTotpCacheError::Write)?;
|
|
set_private_permissions(&temporary)?;
|
|
temporary
|
|
.write_all(serialized.as_bytes())
|
|
.and_then(|()| temporary.as_file().sync_all())
|
|
.and_then(|()| temporary.replace(name))
|
|
.and_then(|()| directory.open(".").and_then(|file| file.sync_all()))
|
|
.map_err(|_| MobileTotpCacheError::Write)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn set_private_permissions(temporary: &TempFile<'_>) -> Result<(), MobileTotpCacheError> {
|
|
use cap_std::fs::{Permissions, PermissionsExt as _};
|
|
|
|
temporary
|
|
.as_file()
|
|
.set_permissions(Permissions::from_mode(0o600))
|
|
.map_err(|_| MobileTotpCacheError::Write)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), MobileTotpCacheError> {
|
|
Ok(())
|
|
}
|
|
|
|
fn store_identity(root: &Path, cache_path: &Path) -> String {
|
|
let cache_parent = cache_path
|
|
.parent()
|
|
.and_then(|parent| fs::canonicalize(parent).ok());
|
|
let stable_root = cache_parent
|
|
.as_deref()
|
|
.and_then(|parent| root.strip_prefix(parent).ok())
|
|
.unwrap_or(root);
|
|
digest(stable_root.to_string_lossy().as_bytes())
|
|
}
|
|
|
|
fn digest(bytes: &[u8]) -> String {
|
|
HEXLOWER.encode(&Sha256::digest(bytes))
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum MobileTotpCacheError {
|
|
Encode,
|
|
Write,
|
|
}
|
|
|
|
impl fmt::Display for MobileTotpCacheError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Encode => formatter.write_str("The TOTP cache could not be encoded."),
|
|
Self::Write => formatter.write_str("The TOTP cache could not be saved."),
|
|
}
|
|
}
|
|
}
|
|
|
|
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 cached_row(path: &EntryPath, shared_with_watch: bool) -> MobileTotpRow {
|
|
let path = path.to_string();
|
|
let title = path.rsplit('/').next().unwrap_or(&path).to_owned();
|
|
MobileTotpRow {
|
|
path: path.clone(),
|
|
issuer: None,
|
|
account: title.clone(),
|
|
title,
|
|
detail: path,
|
|
shared_with_watch,
|
|
}
|
|
}
|
|
|
|
fn sort_rows(rows: &mut [MobileTotpRow]) {
|
|
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))
|
|
});
|
|
}
|
|
|
|
fn snapshot_status(selected: usize) -> MobileWatchSnapshotStatus {
|
|
if selected == 0 {
|
|
MobileWatchSnapshotStatus {
|
|
state: MobileWatchSnapshotState::Unavailable,
|
|
revision: None,
|
|
detail: "No TOTP codes are selected for Apple Watch.".to_owned(),
|
|
}
|
|
} else {
|
|
MobileWatchSnapshotStatus {
|
|
state: MobileWatchSnapshotState::Pending,
|
|
revision: None,
|
|
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),
|
|
ConcurrentModification,
|
|
}
|
|
|
|
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),
|
|
Self::ConcurrentModification => formatter
|
|
.write_str("the password store changed while TOTP discovery was in progress"),
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|