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
@@ -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