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

@@ -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(())
}