Show TOTP codes in password details (#75)

This commit is contained in:
hugo committed 2026-08-11 23:40:24 +02:00
1 parent 9759162eff
commit 2026637a0e
9 files changed
+640 -110

No files matched your search

+113 -27
View File
@@ -20,11 +20,14 @@ use sha2::{Digest as _, Sha256};
use crate::{
crypto::{KeyStore, SecretProvider},
document::EntryDocument,
otp::{OtpError, OtpKind, OtpService},
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
};
const CACHE_VERSION: u32 = 1;
// 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 {
@@ -266,6 +269,25 @@ impl fmt::Debug for MobileTotpDetail {
}
}
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,
@@ -328,13 +350,6 @@ impl<'a> MobileTotpService<'a> {
let store = store_identity(self.repository.root_path());
let (cached, mut cache_notice) = load_cache(cache_path, &store);
let mut checkpoint = CachedCatalog {
version: CACHE_VERSION,
store: store.clone(),
entries: cached
.as_ref()
.map_or_else(BTreeMap::new, |catalog| catalog.entries.clone()),
};
let mut records = BTreeMap::new();
let mut rows = Vec::new();
let mut unavailable_entries = 0_u32;
@@ -371,10 +386,9 @@ impl<'a> MobileTotpService<'a> {
operation.update(|progress| progress.matches = progress.matches.saturating_add(1));
}
if changed {
checkpoint.entries.insert(path_text.clone(), record.clone());
if let Err(error) = save_cache(cache_path, &checkpoint) {
cache_notice = Some(error.to_string());
}
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));
@@ -429,6 +443,38 @@ impl<'a> MobileTotpService<'a> {
})
}
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());
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,
@@ -441,25 +487,33 @@ impl<'a> MobileTotpService<'a> {
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()),
})
detail_for_uri(&path, uri, unix_seconds, shared)
}
}
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,
@@ -545,7 +599,39 @@ fn load_cache(path: &Path, store: &str) -> (Option<CachedCatalog>, Option<String
}
}
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)?;