feat(android): add structured entry editor and encrypted draft recovery (#103)
Android ARM verification / rust (push) Failing after 46m30s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-09 22:11:37 +02:00
1 parent d22d71eb5e
commit 06575363a2
23 files changed
+2330 -21

No files matched your search

+89 -1
View File
@@ -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);
+197 -4
View File
@@ -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()?;
+13 -1
View File
@@ -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,
}
+11
View File
@@ -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(())
}