feat(android): add structured entry editor and encrypted draft recovery (#103)
This commit is contained in:
1 parent
d22d71eb5e
commit
06575363a2
23 files changed
+2330
-21
No files matched your search
@@ -6,7 +6,7 @@ use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::{
|
||||
command::EditRequest,
|
||||
crypto::{KeyStore, SecretProvider},
|
||||
crypto::{CryptoError, KeyHandle, KeyStore, SecretProvider},
|
||||
otp::{OtpAlgorithm, OtpError, OtpKind, OtpUri},
|
||||
recipient::SigningPolicy,
|
||||
repository::{EncryptedEntry, EntryPath, Repository, SecretBytes},
|
||||
@@ -240,7 +240,95 @@ pub struct EntryDocument {
|
||||
modified: bool,
|
||||
}
|
||||
|
||||
/// An editor checkpoint contains ciphertext only and is never written to a repository.
|
||||
pub(crate) struct SealedEntryDocument {
|
||||
path: EntryPath,
|
||||
fields: Vec<(EntryFieldId, Range<usize>, Vec<u8>)>,
|
||||
next_id: u64,
|
||||
conflict_token: DocumentConflictToken,
|
||||
original_ciphertext: Option<EncryptedEntry>,
|
||||
original_plaintext: Range<usize>,
|
||||
ciphertext: EncryptedEntry,
|
||||
modified: bool,
|
||||
}
|
||||
|
||||
impl SealedEntryDocument {
|
||||
pub(crate) fn probe(&self) -> &EncryptedEntry {
|
||||
&self.ciphertext
|
||||
}
|
||||
|
||||
pub(crate) fn restore(
|
||||
&self,
|
||||
keys: &KeyStore,
|
||||
provider: &mut impl SecretProvider,
|
||||
) -> Result<EntryDocument, CryptoError> {
|
||||
let decrypted = keys.decrypt(&self.ciphertext, provider)?;
|
||||
let read = |range: &Range<usize>| -> Result<SecretBytes, CryptoError> {
|
||||
decrypted
|
||||
.expose()
|
||||
.get(range.clone())
|
||||
.map(|bytes| SecretBytes::new(bytes.to_vec()))
|
||||
.ok_or(CryptoError::CorruptMessage)
|
||||
};
|
||||
let plaintext = read(&self.original_plaintext)?;
|
||||
let mut fields = self
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(id, contents, ending)| {
|
||||
Ok(EntryField {
|
||||
id: *id,
|
||||
contents: read(contents)?,
|
||||
ending: ending.clone(),
|
||||
metadata: empty_note_metadata(),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, CryptoError>>()?;
|
||||
classify_all(&mut fields);
|
||||
Ok(EntryDocument {
|
||||
path: self.path.clone(),
|
||||
fields,
|
||||
next_id: self.next_id,
|
||||
conflict_token: self.conflict_token,
|
||||
session: EditSession::restored(
|
||||
self.path.clone(),
|
||||
self.original_ciphertext.clone(),
|
||||
plaintext,
|
||||
),
|
||||
modified: self.modified,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl EntryDocument {
|
||||
pub(crate) fn seal(
|
||||
&self,
|
||||
keys: &KeyStore,
|
||||
recipient: &KeyHandle,
|
||||
) -> Result<SealedEntryDocument, CryptoError> {
|
||||
let mut body = self.session.plaintext().expose().to_vec();
|
||||
let original_plaintext = 0..body.len();
|
||||
let fields = self
|
||||
.fields
|
||||
.iter()
|
||||
.map(|field| {
|
||||
let start = body.len();
|
||||
body.extend_from_slice(field.contents.expose());
|
||||
(field.id, start..body.len(), field.ending.clone())
|
||||
})
|
||||
.collect();
|
||||
let ciphertext = keys.encrypt(SecretBytes::new(body), std::slice::from_ref(recipient))?;
|
||||
Ok(SealedEntryDocument {
|
||||
path: self.path.clone(),
|
||||
fields,
|
||||
next_id: self.next_id,
|
||||
conflict_token: self.conflict_token,
|
||||
original_ciphertext: self.session.original_ciphertext().cloned(),
|
||||
original_plaintext,
|
||||
ciphertext,
|
||||
modified: self.modified,
|
||||
})
|
||||
}
|
||||
|
||||
fn from_session(session: EditSession) -> Self {
|
||||
let path = session.path().clone();
|
||||
let conflict_token = conflict_token(&session);
|
||||
|
||||
@@ -16,7 +16,9 @@ use crate::{
|
||||
},
|
||||
config::{Config, ConfigError, GitRemote, MobileAppearance},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
|
||||
document::{
|
||||
DocumentError, EntryDocument, EntryDocumentService, EntryFieldId, SealedEntryDocument,
|
||||
},
|
||||
git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity},
|
||||
mobile_entry::{
|
||||
MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
|
||||
@@ -306,12 +308,37 @@ struct MobileAuthenticationStatus {
|
||||
active: Option<ActiveMobileLease>,
|
||||
next_editor_id: u64,
|
||||
editors: BTreeMap<u64, MobileEntryDraft>,
|
||||
editor_checkpoints: BTreeMap<u64, EditorCheckpoint>,
|
||||
mutation_active: bool,
|
||||
repository_operation_active: bool,
|
||||
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
|
||||
watch_snapshot: WatchSnapshotSender,
|
||||
}
|
||||
|
||||
struct SealedEditorInput {
|
||||
id: u64,
|
||||
name: Option<std::ops::Range<usize>>,
|
||||
value: Option<std::ops::Range<usize>>,
|
||||
}
|
||||
struct SealedEditorInputs {
|
||||
fields: Vec<SealedEditorInput>,
|
||||
ciphertext: EncryptedEntry,
|
||||
}
|
||||
struct EditorCheckpoint {
|
||||
document: SealedEntryDocument,
|
||||
creating: bool,
|
||||
recipient: crate::crypto::KeyHandle,
|
||||
field_ids: std::collections::BTreeSet<u64>,
|
||||
version: u64,
|
||||
inputs: Option<SealedEditorInputs>,
|
||||
}
|
||||
|
||||
/// Only released after the checkpoint's encryption key has an active authentication lease.
|
||||
pub struct MobileEntryEditorRecovery {
|
||||
pub session: MobileEntryEditorSession,
|
||||
pub inputs: Option<Vec<MobileEntryEditorInput>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum MobileRepositoryOperation {
|
||||
Commit,
|
||||
@@ -424,6 +451,7 @@ impl MobileAuthentication {
|
||||
active: None,
|
||||
next_editor_id: 0,
|
||||
editors: BTreeMap::new(),
|
||||
editor_checkpoints: BTreeMap::new(),
|
||||
mutation_active: false,
|
||||
repository_operation_active: false,
|
||||
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
|
||||
@@ -1584,11 +1612,173 @@ impl MobileAuthentication {
|
||||
return Err(document_error(error));
|
||||
}
|
||||
self.reconcile_saved_document(draft.document());
|
||||
self.status()?.editor_checkpoints.remove(&editor);
|
||||
Ok(MobileEntryPage::from_document(draft.document()))
|
||||
}
|
||||
|
||||
/// Seal the lossless document, original revision and stable IDs in memory only.
|
||||
pub fn checkpoint_entry_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
version: u64,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let key = &status.active.as_ref().ok_or_else(locked_error)?.key;
|
||||
let recipient = self
|
||||
.keys
|
||||
.resolve(key.fingerprint().as_str())
|
||||
.map_err(key_error)?;
|
||||
let draft = status.editors.get(&editor).ok_or_else(editor_missing)?;
|
||||
let checkpoint = EditorCheckpoint {
|
||||
document: draft
|
||||
.document()
|
||||
.seal(&self.keys, &recipient)
|
||||
.map_err(key_error)?,
|
||||
creating: draft.creating(),
|
||||
recipient,
|
||||
field_ids: draft
|
||||
.document()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| field.id().value())
|
||||
.collect(),
|
||||
version,
|
||||
inputs: None,
|
||||
};
|
||||
status.editor_checkpoints.insert(editor, checkpoint);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept an already collected input even if background revocation won the race.
|
||||
/// This only encrypts caller-supplied values; it cannot read or publish protected data.
|
||||
pub fn checkpoint_entry_editor_inputs(
|
||||
&self,
|
||||
editor: u64,
|
||||
version: u64,
|
||||
inputs: Vec<MobileEntryEditorInput>,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
let mut status = self.status()?;
|
||||
let checkpoint = status
|
||||
.editor_checkpoints
|
||||
.get_mut(&editor)
|
||||
.ok_or_else(editor_missing)?;
|
||||
if version <= checkpoint.version {
|
||||
return Ok(());
|
||||
}
|
||||
let inputs = inputs
|
||||
.into_iter()
|
||||
.map(MobileEntryEditorInput::into_parts)
|
||||
.collect::<Vec<_>>();
|
||||
let ids = inputs
|
||||
.iter()
|
||||
.map(|input| input.0)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
if ids != checkpoint.field_ids || ids.len() != inputs.len() {
|
||||
return Err(editor_error(MobileEntryEditorError::FieldSetChanged));
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
let mut append = |text: Option<String>| {
|
||||
text.map(|text| {
|
||||
let start = body.len();
|
||||
let bytes = SecretBytes::new(text.into_bytes());
|
||||
body.extend_from_slice(bytes.expose());
|
||||
start..body.len()
|
||||
})
|
||||
};
|
||||
let fields = inputs
|
||||
.into_iter()
|
||||
.map(|(id, name, value)| SealedEditorInput {
|
||||
id,
|
||||
name: append(name),
|
||||
value: append(value),
|
||||
})
|
||||
.collect();
|
||||
let ciphertext = self
|
||||
.keys
|
||||
.encrypt(
|
||||
SecretBytes::new(body),
|
||||
std::slice::from_ref(&checkpoint.recipient),
|
||||
)
|
||||
.map_err(key_error)?;
|
||||
checkpoint.inputs = Some(SealedEditorInputs { fields, ciphertext });
|
||||
checkpoint.version = version;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resume_entry_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
passphrase: Option<SecretBytes>,
|
||||
) -> Result<MobileEntryEditorRecovery, MobileAuthenticationError> {
|
||||
let probe = self
|
||||
.status()?
|
||||
.editor_checkpoints
|
||||
.get(&editor)
|
||||
.ok_or_else(editor_missing)?
|
||||
.document
|
||||
.probe()
|
||||
.clone();
|
||||
self.unlock_ciphertext(&probe, passphrase)?;
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let active = status.active.as_ref().ok_or_else(locked_error)?;
|
||||
let handle = active.handle.clone();
|
||||
let mut provider = KeyOnlyProvider::new(handle.clone(), &active.key);
|
||||
let checkpoint = status
|
||||
.editor_checkpoints
|
||||
.get(&editor)
|
||||
.ok_or_else(editor_missing)?;
|
||||
let document = checkpoint
|
||||
.document
|
||||
.restore(&self.keys, &mut provider)
|
||||
.map_err(key_error)?;
|
||||
let inputs = checkpoint
|
||||
.inputs
|
||||
.as_ref()
|
||||
.map(|inputs| {
|
||||
let plaintext = self
|
||||
.keys
|
||||
.decrypt(&inputs.ciphertext, &mut provider)
|
||||
.map_err(key_error)?;
|
||||
let read =
|
||||
|range: &std::ops::Range<usize>| -> Result<String, MobileAuthenticationError> {
|
||||
let bytes = plaintext
|
||||
.expose()
|
||||
.get(range.clone())
|
||||
.ok_or_else(|| key_error(CryptoError::CorruptMessage))?;
|
||||
String::from_utf8(bytes.to_vec())
|
||||
.map_err(|_| editor_error(MobileEntryEditorError::NonUtf8))
|
||||
};
|
||||
inputs
|
||||
.fields
|
||||
.iter()
|
||||
.map(|input| {
|
||||
Ok(MobileEntryEditorInput::new(
|
||||
input.id,
|
||||
input.name.as_ref().map(&read).transpose()?,
|
||||
input.value.as_ref().map(&read).transpose()?,
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, MobileAuthenticationError>>()
|
||||
})
|
||||
.transpose()?;
|
||||
handle
|
||||
.ensure_active()
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
let draft = MobileEntryDraft::new(document, checkpoint.creating);
|
||||
let page = draft.page();
|
||||
status.editors.insert(editor, draft);
|
||||
Ok(MobileEntryEditorRecovery {
|
||||
session: MobileEntryEditorSession::new(editor, page),
|
||||
inputs,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn discard_entry_editor(&self, editor: u64) -> Result<(), MobileAuthenticationError> {
|
||||
self.status()?.editors.remove(&editor);
|
||||
let mut status = self.status()?;
|
||||
status.editors.remove(&editor);
|
||||
status.editor_checkpoints.remove(&editor);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1611,8 +1801,11 @@ impl MobileAuthentication {
|
||||
MobileLifecycleEvent::Foreground => {}
|
||||
MobileLifecycleEvent::Background
|
||||
| MobileLifecycleEvent::DeviceLocked
|
||||
| MobileLifecycleEvent::MemoryPressure
|
||||
| MobileLifecycleEvent::ProcessRecreated => self.manual_lock()?,
|
||||
| MobileLifecycleEvent::MemoryPressure => self.manual_lock()?,
|
||||
MobileLifecycleEvent::ProcessRecreated => {
|
||||
self.manual_lock()?;
|
||||
self.status()?.editor_checkpoints.clear();
|
||||
}
|
||||
MobileLifecycleEvent::UserActivity => {
|
||||
if self.state()?.unlocked() {
|
||||
self.touch_user_activity()?;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
use crate::mobile::MobileIcon;
|
||||
|
||||
pub fn editor_process_recreated_notice() -> &'static str {
|
||||
"An unsaved draft was discarded after the application process restarted. Unlock the entry to edit it again."
|
||||
}
|
||||
|
||||
use std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize};
|
||||
|
||||
use crate::document::{
|
||||
@@ -258,6 +262,9 @@ pub struct MobileEntryEditorInput {
|
||||
}
|
||||
|
||||
impl MobileEntryEditorInput {
|
||||
pub fn into_parts(self) -> (u64, Option<String>, Option<String>) {
|
||||
(self.id, self.name, self.value)
|
||||
}
|
||||
pub fn new(id: u64, name: Option<String>, value: Option<String>) -> Self {
|
||||
Self { id, name, value }
|
||||
}
|
||||
@@ -273,6 +280,10 @@ impl MobileEntryDraft {
|
||||
Self { document, creating }
|
||||
}
|
||||
|
||||
pub(crate) fn creating(&self) -> bool {
|
||||
self.creating
|
||||
}
|
||||
|
||||
pub fn page(&self) -> MobileEntryEditorPage {
|
||||
let fields = self
|
||||
.document
|
||||
@@ -640,7 +651,8 @@ fn editor_field(index: usize, field: &EntryField) -> MobileEntryEditorField {
|
||||
sensitive,
|
||||
multiline: matches!(kind, MobileEntryEditorFieldKind::Note)
|
||||
|| field.value().contains(&b'\n'),
|
||||
name_editable: matches!(kind, MobileEntryEditorFieldKind::Field),
|
||||
name_editable: matches!(kind, MobileEntryEditorFieldKind::Field)
|
||||
&& metadata.diagnostic() != Some(EntryFieldDiagnostic::NonUtf8Value),
|
||||
removable: index > 0,
|
||||
reorderable: index > 0,
|
||||
}
|
||||
|
||||
@@ -173,6 +173,17 @@ pub struct EditSession {
|
||||
}
|
||||
|
||||
impl EditSession {
|
||||
pub(crate) fn restored(
|
||||
path: EntryPath,
|
||||
original_ciphertext: Option<EncryptedEntry>,
|
||||
plaintext: SecretBytes,
|
||||
) -> Self {
|
||||
Self {
|
||||
path,
|
||||
original_ciphertext,
|
||||
plaintext,
|
||||
}
|
||||
}
|
||||
pub fn path(&self) -> &EntryPath {
|
||||
&self.path
|
||||
}
|
||||
|
||||
@@ -919,3 +919,271 @@ fn locking_interrupts_an_os_prompt_before_waiting_for_the_lease_mutex() -> TestR
|
||||
assert_eq!(session.remaining_time()?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_editor_checkpoint_retains_inputs_ids_and_conflict_base_across_lock() -> TestResult {
|
||||
use ironstorage::{
|
||||
config::Config,
|
||||
mobile_authentication::MobileAuthentication,
|
||||
mobile_entry::{MobileEntryEditorFieldKind, MobileEntryEditorInput},
|
||||
mobile_platform::MobileLifecycleEvent,
|
||||
};
|
||||
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 backend = MemoryBackend::default();
|
||||
let make = || {
|
||||
MobileAuthentication::with_platform(
|
||||
Config::load(Some(&source)).unwrap(),
|
||||
Arc::new(backend.clone()),
|
||||
Arc::new(clock.clone()),
|
||||
)
|
||||
};
|
||||
let authentication = make()?;
|
||||
let password =
|
||||
|| SecretBytes::new(fixture.key("alice").unwrap().passphrase.as_bytes().to_vec());
|
||||
authentication.create_directory("", "checkpoint")?;
|
||||
let session = authentication.begin_create_entry("checkpoint", "entry", Some(password()))?;
|
||||
let id = session.id();
|
||||
for (kind, name, value) in [
|
||||
(
|
||||
MobileEntryEditorFieldKind::Field,
|
||||
Some("custom".to_owned()),
|
||||
"first",
|
||||
),
|
||||
(
|
||||
MobileEntryEditorFieldKind::Field,
|
||||
Some("custom".to_owned()),
|
||||
"second",
|
||||
),
|
||||
(
|
||||
MobileEntryEditorFieldKind::Field,
|
||||
Some("pin".to_owned()),
|
||||
"123493",
|
||||
),
|
||||
(
|
||||
MobileEntryEditorFieldKind::Note,
|
||||
None,
|
||||
"first note\nsecond note",
|
||||
),
|
||||
(
|
||||
MobileEntryEditorFieldKind::OtpUri,
|
||||
None,
|
||||
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example",
|
||||
),
|
||||
] {
|
||||
authentication.add_entry_editor_field(id, kind, name, value.to_owned())?;
|
||||
}
|
||||
let page = authentication.entry_editor(id)?;
|
||||
let ids = page
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| field.id())
|
||||
.collect::<Vec<_>>();
|
||||
let inputs = |text: &str| {
|
||||
page.fields()
|
||||
.iter()
|
||||
.map(|field| {
|
||||
MobileEntryEditorInput::new(
|
||||
field.id(),
|
||||
field.name().map(str::to_owned),
|
||||
if field.kind() == MobileEntryEditorFieldKind::Password {
|
||||
Some(text.to_owned())
|
||||
} else {
|
||||
field.value().map(str::to_owned)
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
authentication.checkpoint_entry_editor(id, 1)?;
|
||||
authentication.checkpoint_entry_editor_inputs(id, 3, inputs("latest draft"))?;
|
||||
authentication.checkpoint_entry_editor_inputs(id, 2, inputs("stale input"))?;
|
||||
authentication.lifecycle(MobileLifecycleEvent::Background)?;
|
||||
assert!(!authentication.state()?.unlocked());
|
||||
assert!(authentication.entry_editor(id).is_err());
|
||||
// A queued input may finish after the cover/lease revocation; it only encrypts.
|
||||
authentication.checkpoint_entry_editor_inputs(id, 4, inputs("background-safe draft"))?;
|
||||
assert!(
|
||||
authentication
|
||||
.resume_entry_editor(id, Some(SecretBytes::new(b"incorrect".to_vec())))
|
||||
.is_err()
|
||||
);
|
||||
let recovered = authentication.resume_entry_editor(id, Some(password()))?;
|
||||
assert_eq!(recovered.session.id(), id);
|
||||
assert_eq!(
|
||||
recovered
|
||||
.session
|
||||
.page()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| field.id())
|
||||
.collect::<Vec<_>>(),
|
||||
ids
|
||||
);
|
||||
let recovered_inputs = recovered.inputs.unwrap();
|
||||
assert_eq!(
|
||||
recovered_inputs
|
||||
.first()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_parts()
|
||||
.2
|
||||
.as_deref(),
|
||||
Some("background-safe draft")
|
||||
);
|
||||
// Validation failures leave both the document and encrypted checkpoint available.
|
||||
let invalid = page
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| {
|
||||
MobileEntryEditorInput::new(
|
||||
field.id(),
|
||||
if field.name_editable() {
|
||||
Some("bad\nname".to_owned())
|
||||
} else {
|
||||
field.name().map(str::to_owned)
|
||||
},
|
||||
field.value().map(str::to_owned),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
authentication.checkpoint_entry_editor_inputs(id, 5, invalid)?;
|
||||
let invalid = authentication
|
||||
.resume_entry_editor(id, None)?
|
||||
.inputs
|
||||
.unwrap();
|
||||
assert!(authentication.save_entry_editor(id, invalid).is_err());
|
||||
assert!(authentication.entry_editor(id).is_ok());
|
||||
authentication.save_entry_editor(id, recovered_inputs)?;
|
||||
assert!(
|
||||
authentication.resume_entry_editor(id, None).is_err(),
|
||||
"successful save deletes the checkpoint"
|
||||
);
|
||||
let opened = authentication.begin_entry_editor("checkpoint/entry")?;
|
||||
authentication.checkpoint_entry_editor(opened.id(), 6)?;
|
||||
let other = make()?;
|
||||
other.unlock_entry("checkpoint/entry", Some(password()))?;
|
||||
other.replace_entry_field("checkpoint/entry", ids[0], "concurrent change".to_owned())?;
|
||||
authentication.manual_lock()?;
|
||||
let recovered = authentication.resume_entry_editor(opened.id(), Some(password()))?;
|
||||
let fields = recovered
|
||||
.session
|
||||
.page()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| {
|
||||
MobileEntryEditorInput::new(
|
||||
field.id(),
|
||||
field.name().map(str::to_owned),
|
||||
field.value().map(str::to_owned),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
authentication
|
||||
.save_entry_editor(opened.id(), fields)
|
||||
.is_err(),
|
||||
"checkpoint must preserve its original conflict base"
|
||||
);
|
||||
authentication.discard_entry_editor(opened.id())?;
|
||||
assert!(
|
||||
authentication
|
||||
.resume_entry_editor(opened.id(), None)
|
||||
.is_err()
|
||||
);
|
||||
let opened = authentication.begin_entry_editor("checkpoint/entry")?;
|
||||
authentication.checkpoint_entry_editor(opened.id(), 7)?;
|
||||
authentication.set_authentication_timeout(1)?;
|
||||
clock.advance(Duration::from_secs(2));
|
||||
assert!(!authentication.state()?.unlocked());
|
||||
assert!(
|
||||
authentication
|
||||
.resume_entry_editor(opened.id(), Some(password()))
|
||||
.is_ok()
|
||||
);
|
||||
authentication.lifecycle(MobileLifecycleEvent::ProcessRecreated)?;
|
||||
assert!(!authentication.state()?.unlocked());
|
||||
assert!(
|
||||
authentication
|
||||
.resume_entry_editor(opened.id(), Some(password()))
|
||||
.is_err()
|
||||
);
|
||||
let repository = Repository::open(vault.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let path = ironstorage::repository::EntryPath::parse("checkpoint/binary")?;
|
||||
let recipients = ironstorage::recipient::RecipientPolicyManager::new(&repository, &keys)
|
||||
.resolve_for_entry(&path, None)?;
|
||||
let bytes = b"opaque password\nunknown: \xff\nPIN: 123493\n";
|
||||
let encrypted = keys.encrypt(SecretBytes::new(bytes.to_vec()), recipients.recipients())?;
|
||||
repository.write_entry(&path, &encrypted)?;
|
||||
authentication.unlock_entry("checkpoint/binary", Some(password()))?;
|
||||
let editor = authentication.begin_entry_editor("checkpoint/binary")?;
|
||||
let opaque = editor
|
||||
.page()
|
||||
.fields()
|
||||
.iter()
|
||||
.find(|field| field.value().is_none())
|
||||
.unwrap();
|
||||
assert!(!opaque.name_editable());
|
||||
authentication.checkpoint_entry_editor(editor.id(), 8)?;
|
||||
authentication.manual_lock()?;
|
||||
let recovered = authentication.resume_entry_editor(editor.id(), Some(password()))?;
|
||||
let fields = recovered
|
||||
.session
|
||||
.page()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| {
|
||||
MobileEntryEditorInput::new(
|
||||
field.id(),
|
||||
field.name().map(str::to_owned),
|
||||
if field.name() == Some("PIN") {
|
||||
Some("123494".to_owned())
|
||||
} else {
|
||||
field.value().map(str::to_owned)
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
authentication.save_entry_editor(editor.id(), fields)?;
|
||||
struct FixtureSecret {
|
||||
fingerprint: String,
|
||||
password: Vec<u8>,
|
||||
}
|
||||
impl ironstorage::crypto::SecretProvider for FixtureSecret {
|
||||
fn secret_for(
|
||||
&mut self,
|
||||
key: &KeyInfo,
|
||||
) -> Result<SecretBytes, ironstorage::crypto::SecretProviderError> {
|
||||
if key.fingerprint().as_str() != self.fingerprint {
|
||||
return Err(ironstorage::crypto::SecretProviderError::Unavailable);
|
||||
}
|
||||
Ok(SecretBytes::new(self.password.clone()))
|
||||
}
|
||||
}
|
||||
let decrypted = keys.decrypt(
|
||||
&repository.read_entry(&path)?,
|
||||
&mut FixtureSecret {
|
||||
fingerprint: fixture.key("alice")?.primary_fingerprint.clone(),
|
||||
password: fixture.key("alice")?.passphrase.as_bytes().to_vec(),
|
||||
},
|
||||
)?;
|
||||
assert_eq!(
|
||||
decrypted.expose(),
|
||||
b"opaque password\nunknown: \xff\nPIN: 123494\n",
|
||||
"opaque non-UTF8 fields must round-trip losslessly"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user