Show TOTP codes in password details (#75)
This commit is contained in:
@@ -7,9 +7,9 @@ use sha2::{Digest as _, Sha256};
|
||||
use crate::{
|
||||
command::EditRequest,
|
||||
crypto::{KeyStore, SecretProvider},
|
||||
otp::{OtpAlgorithm, OtpKind, OtpUri},
|
||||
otp::{OtpAlgorithm, OtpError, OtpKind, OtpUri},
|
||||
recipient::SigningPolicy,
|
||||
repository::{EntryPath, Repository, SecretBytes},
|
||||
repository::{EncryptedEntry, EntryPath, Repository, SecretBytes},
|
||||
write::{EditSession, EntryCommitter, VaultWriter, WriteError, WriteOutcome},
|
||||
};
|
||||
|
||||
@@ -424,6 +424,15 @@ impl EntryDocument {
|
||||
SecretBytes::new(output)
|
||||
}
|
||||
|
||||
pub(crate) fn original_ciphertext(&self) -> Option<&EncryptedEntry> {
|
||||
self.session.original_ciphertext()
|
||||
}
|
||||
|
||||
pub(crate) fn otp_uri(&self) -> Result<Option<OtpUri>, OtpError> {
|
||||
let plaintext = self.serialize();
|
||||
crate::otp::find_uri(&plaintext, &self.path).map(|found| found.map(|(_, uri)| uri))
|
||||
}
|
||||
|
||||
fn has_final_newline(&self) -> bool {
|
||||
self.fields
|
||||
.last()
|
||||
|
||||
@@ -145,6 +145,30 @@ impl MobileEntryCopy {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MobileEntryPresentation {
|
||||
page: MobileEntryPage,
|
||||
totp: Option<MobileTotpDetail>,
|
||||
cache_notice: Option<String>,
|
||||
}
|
||||
|
||||
impl MobileEntryPresentation {
|
||||
pub fn page(&self) -> &MobileEntryPage {
|
||||
&self.page
|
||||
}
|
||||
|
||||
pub fn totp(&self) -> Option<&MobileTotpDetail> {
|
||||
self.totp.as_ref()
|
||||
}
|
||||
|
||||
pub fn cache_notice(&self) -> Option<&str> {
|
||||
self.cache_notice.as_deref()
|
||||
}
|
||||
|
||||
pub fn into_parts(self) -> (MobileEntryPage, Option<MobileTotpDetail>, Option<String>) {
|
||||
(self.page, self.totp, self.cache_notice)
|
||||
}
|
||||
}
|
||||
|
||||
impl MobileAuthenticationState {
|
||||
pub fn unlocked(self) -> bool {
|
||||
self.unlocked
|
||||
@@ -385,9 +409,32 @@ impl MobileAuthentication {
|
||||
.map_err(MobileAuthenticationError::authentication)
|
||||
}
|
||||
|
||||
pub fn entry_page(&self, path: &str) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
pub fn entry_page(
|
||||
&self,
|
||||
path: &str,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileEntryPresentation, MobileAuthenticationError> {
|
||||
let document = self.open_active_document(path)?;
|
||||
Ok(MobileEntryPage::from_document(&document))
|
||||
let ciphertext = document
|
||||
.original_ciphertext()
|
||||
.ok_or_else(|| entry_detail("Password Entry Is Unavailable", "entry does not exist"))?;
|
||||
let shared = self.status()?.watch_shared_totp_entries.clone();
|
||||
let cache_path = self.config.source().with_file_name("totp-catalog.toml");
|
||||
let reconciliation = MobileTotpService::new(&self.repository, &self.keys)
|
||||
.reconcile_document(
|
||||
&document,
|
||||
ciphertext,
|
||||
Some(unix_seconds),
|
||||
&shared,
|
||||
&cache_path,
|
||||
)
|
||||
.map_err(totp_error)?;
|
||||
let (totp, cache_notice) = reconciliation.into_parts();
|
||||
Ok(MobileEntryPresentation {
|
||||
page: MobileEntryPage::from_document(&document),
|
||||
totp,
|
||||
cache_notice,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reveal_entry_field(
|
||||
@@ -591,6 +638,7 @@ impl MobileAuthentication {
|
||||
EntryDocumentService::new(&self.repository, &self.keys)
|
||||
.save_recoverable(&document, None, &mut committer)
|
||||
.map_err(document_error)?;
|
||||
self.reconcile_saved_document(&document);
|
||||
Ok(MobileEntryPage::from_document(&document))
|
||||
}
|
||||
|
||||
@@ -764,6 +812,7 @@ impl MobileAuthentication {
|
||||
self.restore_editor(editor, draft)?;
|
||||
return Err(document_error(error));
|
||||
}
|
||||
self.reconcile_saved_document(draft.document());
|
||||
Ok(MobileEntryPage::from_document(draft.document()))
|
||||
}
|
||||
|
||||
@@ -954,6 +1003,25 @@ impl MobileAuthentication {
|
||||
.open(path, &mut provider)
|
||||
.map_err(document_error)
|
||||
}
|
||||
|
||||
fn reconcile_saved_document(&self, document: &EntryDocument) {
|
||||
let Ok(ciphertext) = self.repository.read_entry(document.path()) else {
|
||||
return;
|
||||
};
|
||||
let Ok(status) = self.status() else {
|
||||
return;
|
||||
};
|
||||
let shared = status.watch_shared_totp_entries.clone();
|
||||
drop(status);
|
||||
let cache_path = self.config.source().with_file_name("totp-catalog.toml");
|
||||
let _ = MobileTotpService::new(&self.repository, &self.keys).reconcile_document(
|
||||
document,
|
||||
&ciphertext,
|
||||
None,
|
||||
&shared,
|
||||
&cache_path,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyOnlyProvider<'a> {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -1213,7 +1213,7 @@ where
|
||||
Ok(digest)
|
||||
}
|
||||
|
||||
fn find_uri(
|
||||
pub(crate) fn find_uri(
|
||||
plaintext: &SecretBytes,
|
||||
entry: &EntryPath,
|
||||
) -> Result<Option<(Range<usize>, OtpUri)>, OtpError> {
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::{
|
||||
|
||||
use ironstorage::{
|
||||
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
document::EntryDocumentService,
|
||||
mobile_totp::{
|
||||
MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService,
|
||||
MobileWatchSnapshotState,
|
||||
@@ -305,6 +306,72 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypted_entry_details_reconcile_totp_cache_without_persisting_secrets() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let store = fixture.materialize_store("basic")?;
|
||||
let repository = Repository::open(store.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let path = EntryPath::parse("otp/detail")?;
|
||||
write_plaintext(
|
||||
&repository,
|
||||
&keys,
|
||||
"otp/detail",
|
||||
b"password\notpauth://totp/Acme:detail@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme&digits=8&period=30\n",
|
||||
)?;
|
||||
let cache_directory = tempfile::tempdir()?;
|
||||
let cache = cache_directory.path().join("totp-catalog.toml");
|
||||
let service = MobileTotpService::new(&repository, &keys);
|
||||
let mut secrets = FixtureSecrets::all(&fixture);
|
||||
let document =
|
||||
EntryDocumentService::new(&repository, &keys).open("otp/detail", &mut secrets)?;
|
||||
let ciphertext = repository.read_entry(&path)?;
|
||||
|
||||
let reconciliation =
|
||||
service.reconcile_document(&document, &ciphertext, Some(59), &BTreeSet::new(), &cache)?;
|
||||
let detail = reconciliation.detail().expect("valid TOTP detail");
|
||||
assert_eq!(detail.code().expose(), b"94287082");
|
||||
assert_eq!(detail.valid_until(), 60);
|
||||
assert!(
|
||||
service
|
||||
.cached_page(&BTreeSet::new(), &cache)
|
||||
.expect("detail created cache")
|
||||
.rows()
|
||||
.iter()
|
||||
.any(|row| row.path() == "otp/detail")
|
||||
);
|
||||
let encoded = fs::read_to_string(&cache)?;
|
||||
assert!(encoded.contains("otp/detail"));
|
||||
assert!(encoded.contains("is_totp = true"));
|
||||
for secret in ["otpauth://", "secret=", "detail@example.com", "password"] {
|
||||
assert!(!encoded.contains(secret), "cache leaked {secret}");
|
||||
}
|
||||
|
||||
write_plaintext(
|
||||
&repository,
|
||||
&keys,
|
||||
"otp/detail",
|
||||
b"password\nlogin: detail\n",
|
||||
)?;
|
||||
let mut secrets = FixtureSecrets::all(&fixture);
|
||||
let document =
|
||||
EntryDocumentService::new(&repository, &keys).open("otp/detail", &mut secrets)?;
|
||||
let ciphertext = repository.read_entry(&path)?;
|
||||
let reconciliation =
|
||||
service.reconcile_document(&document, &ciphertext, Some(59), &BTreeSet::new(), &cache)?;
|
||||
assert!(reconciliation.detail().is_none());
|
||||
assert!(
|
||||
service
|
||||
.cached_page(&BTreeSet::new(), &cache)
|
||||
.expect("detail updated cache")
|
||||
.rows()
|
||||
.iter()
|
||||
.all(|row| row.path() != "otp/detail")
|
||||
);
|
||||
assert!(fs::read_to_string(cache)?.contains("is_totp = false"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_discovery_checkpoints_completed_entries() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
|
||||
Reference in New Issue
Block a user