Implement structured Android entry viewer (#99)
This commit is contained in:
1 parent
647dc1bb4b
commit
c0ba6d43d8
29 files changed
+1768
-78
No files matched your search
@@ -21,7 +21,7 @@ use crate::{
|
||||
mobile_entry::{
|
||||
MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
|
||||
MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage,
|
||||
MobileEntryValueError, field_value,
|
||||
MobileEntryValueError, document_revision, field_value,
|
||||
},
|
||||
mobile_mutation::{
|
||||
MobileMutationAction, MobileMutationError, MobileMutationOutcome, MobileMutationPlan,
|
||||
@@ -949,6 +949,98 @@ impl MobileAuthentication {
|
||||
field_value(&document, field).map_err(value_error)
|
||||
}
|
||||
|
||||
/// Field IDs are scoped to the exact document that the viewer presented.
|
||||
pub fn reveal_presented_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
field_id: u64,
|
||||
revision: &str,
|
||||
) -> Result<String, MobileAuthenticationError> {
|
||||
let document = self.open_presented_document(path, revision)?;
|
||||
field_value(&document, field_id).map_err(value_error)
|
||||
}
|
||||
|
||||
pub fn copy_presented_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
field_id: u64,
|
||||
revision: &str,
|
||||
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
|
||||
Ok(MobileEntryCopy {
|
||||
value: self.reveal_presented_entry_field(path, field_id, revision)?,
|
||||
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_entry_presentation(
|
||||
&self,
|
||||
path: &str,
|
||||
revision: &str,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileEntryPresentation, MobileAuthenticationError> {
|
||||
let presentation = self.entry_page(path, unix_seconds)?;
|
||||
self.check_document_revision(presentation.page().revision(), revision)?;
|
||||
Ok(presentation)
|
||||
}
|
||||
|
||||
fn open_presented_document(
|
||||
&self,
|
||||
path: &str,
|
||||
revision: &str,
|
||||
) -> Result<EntryDocument, MobileAuthenticationError> {
|
||||
let document = self.open_active_document(path)?;
|
||||
self.check_document_revision(&document_revision(&document), revision)?;
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
fn check_document_revision(
|
||||
&self,
|
||||
current: &str,
|
||||
expected: &str,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
if current != expected {
|
||||
self.manual_lock()?;
|
||||
return Err(MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Conflict,
|
||||
"Entry Changed",
|
||||
"Refresh and unlock the entry again before using its fields.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn copy_presented_totp_code(
|
||||
&self,
|
||||
path: &str,
|
||||
revision: &str,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
|
||||
let presentation = self.refresh_entry_presentation(path, revision, unix_seconds)?;
|
||||
let detail = presentation.totp().ok_or_else(|| {
|
||||
entry_detail(
|
||||
"TOTP Code Is Unavailable",
|
||||
"The entry has no current TOTP code.",
|
||||
)
|
||||
})?;
|
||||
Ok(MobileEntryCopy {
|
||||
value: String::from_utf8(detail.code().expose().to_vec())
|
||||
.map_err(|_| entry_detail("TOTP Code Is Unavailable", "The code is not UTF-8."))?,
|
||||
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepare_presented_entry_mutation(
|
||||
&self,
|
||||
path: &str,
|
||||
revision: &str,
|
||||
action: MobileMutationAction,
|
||||
) -> Result<MobileMutationPlan, MobileAuthenticationError> {
|
||||
self.open_presented_document(path, revision)?;
|
||||
let plan = self.prepare_entry_mutation(path, action)?;
|
||||
self.open_presented_document(path, revision)?;
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
pub fn copy_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
@@ -1234,17 +1326,42 @@ impl MobileAuthentication {
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let mut document = self.open_active_document(path)?;
|
||||
self.save_replaced_entry_field(&mut document, field, value)
|
||||
}
|
||||
|
||||
pub fn replace_presented_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
field_id: u64,
|
||||
revision: &str,
|
||||
value: String,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let mut document = self.open_presented_document(path, revision)?;
|
||||
self.save_replaced_entry_field(&mut document, field_id, value)
|
||||
}
|
||||
|
||||
fn save_replaced_entry_field(
|
||||
&self,
|
||||
document: &mut EntryDocument,
|
||||
field_id: u64,
|
||||
value: String,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
document
|
||||
.replace_field_value(EntryFieldId::from_value(field), value.into_bytes())
|
||||
.replace_field_value(EntryFieldId::from_value(field_id), value.into_bytes())
|
||||
.map_err(document_error)?;
|
||||
let identity = self.git_identity()?;
|
||||
let mut committer = AutomaticEntryCommitter::for_entry(&self.repository, path, identity)
|
||||
.map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?;
|
||||
let mut committer = AutomaticEntryCommitter::for_entry(
|
||||
&self.repository,
|
||||
&document.path().to_string(),
|
||||
identity,
|
||||
)
|
||||
.map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?;
|
||||
EntryDocumentService::new(&self.repository, &self.keys)
|
||||
.save_recoverable(&document, None, &mut committer)
|
||||
.save_recoverable(document, None, &mut committer)
|
||||
.map_err(document_error)?;
|
||||
self.reconcile_saved_document(&document);
|
||||
Ok(MobileEntryPage::from_document(&document))
|
||||
self.reconcile_saved_document(document);
|
||||
Ok(MobileEntryPage::from_document(document))
|
||||
}
|
||||
|
||||
pub fn begin_entry_editor(
|
||||
|
||||
@@ -104,6 +104,7 @@ impl MobileEntrySection {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileEntryPage {
|
||||
revision: String,
|
||||
id: String,
|
||||
title: String,
|
||||
sections: Vec<MobileEntrySection>,
|
||||
@@ -518,11 +519,16 @@ impl MobileEntryPage {
|
||||
}
|
||||
Self {
|
||||
id: format!("entry:{}", document.path()),
|
||||
revision: document_revision(document),
|
||||
title,
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> &str {
|
||||
&self.revision
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
@@ -559,6 +565,10 @@ impl From<DocumentError> for MobileEntryValueError {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn document_revision(document: &EntryDocument) -> String {
|
||||
data_encoding::HEXLOWER.encode(document.conflict_token().as_bytes())
|
||||
}
|
||||
|
||||
pub fn field_value(document: &EntryDocument, id: u64) -> Result<String, MobileEntryValueError> {
|
||||
let value = document.copy_field_value(EntryFieldId::from_value(id))?;
|
||||
String::from_utf8(value.expose().to_vec()).map_err(|_| MobileEntryValueError::NonUtf8)
|
||||
|
||||
@@ -198,6 +198,7 @@ pub struct MobileTotpDetail {
|
||||
account: String,
|
||||
code: SecretBytes,
|
||||
valid_until: u64,
|
||||
remaining_seconds: u64,
|
||||
period: u64,
|
||||
shared_with_watch: bool,
|
||||
watch: MobileWatchSnapshotStatus,
|
||||
@@ -228,6 +229,10 @@ impl MobileTotpDetail {
|
||||
self.period
|
||||
}
|
||||
|
||||
pub fn remaining_seconds(&self) -> u64 {
|
||||
self.remaining_seconds
|
||||
}
|
||||
|
||||
pub fn shared_with_watch(&self) -> bool {
|
||||
self.shared_with_watch
|
||||
}
|
||||
@@ -556,6 +561,7 @@ fn detail_for_uri(
|
||||
account: uri.account().to_owned(),
|
||||
code: uri.code_at(unix_seconds)?,
|
||||
valid_until,
|
||||
remaining_seconds: valid_until - unix_seconds,
|
||||
period,
|
||||
shared_with_watch: shared.contains(path),
|
||||
watch: snapshot_status(shared.len()),
|
||||
|
||||
@@ -147,9 +147,30 @@ impl<B: ClipboardBackend> ClipboardManager<B> {
|
||||
return Err(ClipboardError::EmptySecret);
|
||||
}
|
||||
let previous = self.backend.read()?;
|
||||
self.copy_and_cleanup(value, &previous, wait)
|
||||
}
|
||||
|
||||
/// Copy without retaining or later restoring previous clipboard contents.
|
||||
pub fn copy_clearing_with(
|
||||
&mut self,
|
||||
value: &SecretBytes,
|
||||
wait: impl FnOnce(Duration) -> ClipboardWait,
|
||||
) -> Result<ClipboardDisposition, ClipboardError> {
|
||||
if value.expose().is_empty() {
|
||||
return Err(ClipboardError::EmptySecret);
|
||||
}
|
||||
self.copy_and_cleanup(value, &ClipboardContent::EmptyOrNonText, wait)
|
||||
}
|
||||
|
||||
fn copy_and_cleanup(
|
||||
&mut self,
|
||||
value: &SecretBytes,
|
||||
previous: &ClipboardContent,
|
||||
wait: impl FnOnce(Duration) -> ClipboardWait,
|
||||
) -> Result<ClipboardDisposition, ClipboardError> {
|
||||
self.backend.write(value)?;
|
||||
let wait_result = wait(self.timeout.duration());
|
||||
let disposition = self.cleanup(value, &previous)?;
|
||||
let disposition = self.cleanup(value, previous)?;
|
||||
if wait_result == ClipboardWait::Cancelled {
|
||||
return Err(ClipboardError::Cancelled);
|
||||
}
|
||||
|
||||
@@ -740,6 +740,79 @@ fn mobile_expiry_and_user_input_never_reveal_old_editor_handles() -> TestResult
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presented_field_ids_reject_replaced_documents_and_ticks_do_not_renew_the_lease() -> TestResult {
|
||||
use ironstorage::{
|
||||
config::Config,
|
||||
mobile_authentication::{MobileAuthentication, MobileAuthenticationErrorKind},
|
||||
};
|
||||
let fixture = FixtureSet::load()?;
|
||||
let vault = fixture.materialize_store("basic")?;
|
||||
let root = tempfile::tempdir()?;
|
||||
let source = root.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&source,
|
||||
format!(
|
||||
"vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n",
|
||||
vault.path().to_str().unwrap(),
|
||||
fixture.path("keys").to_str().unwrap(),
|
||||
fixture.key("alice")?.primary_fingerprint
|
||||
),
|
||||
)?;
|
||||
let clock = ManualClock::default();
|
||||
let authentication = MobileAuthentication::with_platform(
|
||||
Config::load(Some(&source))?,
|
||||
Arc::new(MemoryBackend::default()),
|
||||
Arc::new(clock.clone()),
|
||||
)?;
|
||||
authentication.set_authentication_timeout(2)?;
|
||||
authentication.unlock_entry(
|
||||
"email/personal",
|
||||
Some(SecretBytes::new(
|
||||
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
|
||||
)),
|
||||
)?;
|
||||
let page = authentication.entry_page("email/personal", 59)?;
|
||||
let id = page.page().sections()[0].fields()[0].id();
|
||||
assert!(
|
||||
!authentication
|
||||
.reveal_presented_entry_field("email/personal", id, page.page().revision())?
|
||||
.is_empty()
|
||||
);
|
||||
clock.advance(Duration::from_secs(1));
|
||||
authentication.refresh_entry_presentation("email/personal", page.page().revision(), 60)?;
|
||||
assert_eq!(authentication.state()?.remaining_seconds(), 1);
|
||||
authentication.replace_entry_field("email/personal", id, "changed password".to_owned())?;
|
||||
let error = authentication
|
||||
.replace_presented_entry_field(
|
||||
"email/personal",
|
||||
id,
|
||||
page.page().revision(),
|
||||
"must not overwrite".to_owned(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), MobileAuthenticationErrorKind::Conflict);
|
||||
assert!(!authentication.state()?.unlocked());
|
||||
authentication.unlock_entry(
|
||||
"email/personal",
|
||||
Some(SecretBytes::new(
|
||||
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
|
||||
)),
|
||||
)?;
|
||||
assert_eq!(
|
||||
authentication.reveal_entry_field("email/personal", id)?,
|
||||
"changed password"
|
||||
);
|
||||
clock.advance(Duration::from_secs(2));
|
||||
assert!(
|
||||
authentication
|
||||
.refresh_entry_presentation("email/personal", page.page().revision(), 61)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!authentication.state()?.unlocked());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locking_interrupts_an_os_prompt_before_waiting_for_the_lease_mutex() -> TestResult {
|
||||
struct PromptBackend {
|
||||
|
||||
@@ -145,6 +145,7 @@ fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestRe
|
||||
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.remaining_seconds(), 1);
|
||||
assert_eq!(detail.period(), 30);
|
||||
assert!(!format!("{detail:?}").contains("94287082"));
|
||||
|
||||
@@ -161,6 +162,7 @@ fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestRe
|
||||
let detail = service.detail("otp/alice", 60, &selected, &mut secrets)?;
|
||||
assert!(detail.shared_with_watch());
|
||||
assert_eq!(detail.valid_until(), 90);
|
||||
assert_eq!(detail.remaining_seconds(), 30);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,32 @@ fn clipboard_races_and_cancellation_never_overwrite_newer_user_content() -> Test
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_clipboard_never_reads_or_restores_previous_secrets() -> TestResult {
|
||||
let backend = MemoryClipboard::with_text(b"previous secret");
|
||||
let mut manager = ClipboardManager::new(backend.clone(), ClipboardTimeout::default());
|
||||
let secret = SecretBytes::new(b"copied secret".to_vec());
|
||||
assert_eq!(
|
||||
manager.copy_clearing_with(&secret, |_| ClipboardWait::Elapsed)?,
|
||||
ClipboardDisposition::Cleared
|
||||
);
|
||||
assert_eq!(backend.text(), None);
|
||||
assert_eq!(
|
||||
manager.copy_clearing_with(&secret, |_| {
|
||||
backend.set_text(b"newer value");
|
||||
ClipboardWait::Elapsed
|
||||
})?,
|
||||
ClipboardDisposition::PreservedNewer
|
||||
);
|
||||
assert_eq!(backend.text().as_deref(), Some(b"newer value".as_slice()));
|
||||
assert_eq!(
|
||||
manager.copy_clearing_with(&secret, |_| ClipboardWait::Cancelled),
|
||||
Err(ClipboardError::Cancelled)
|
||||
);
|
||||
assert_eq!(backend.text(), None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_validation_and_cleanup_failures_are_typed_and_redacted() -> TestResult {
|
||||
assert_eq!(
|
||||
|
||||
Reference in new issue
Block a user