Compare commits

..

2 Commits

Author SHA1 Message Date
b5571fcc5b Treat PIN fields as sensitive (#81) 2026-08-12 20:11:05 +02:00
00a22e9968 Document safe simulator biometric workflow 2026-08-12 20:07:09 +02:00
4 changed files with 82 additions and 12 deletions

View File

@@ -46,12 +46,34 @@ cargo test --workspace
For Apple project changes, also run `xcodegen generate` from `apple/` and build
the affected simulator targets.
### Updating the Simulator app
Treat the booted simulator and its app container as persistent test state. To
update IronStorage, build for that simulator and install the new `.app` over
the existing installation with `xcrun simctl install <device-udid> <app-path>`.
Keep the same device UDID and bundle identifier.
Do not uninstall IronStorage, erase or recreate the simulator, reset its
keychain, install app-data packages, or delete its app container during an app
update. Those are destructive reset operations, not update steps, and can
remove preferences, repositories, credentials, and biometric enrollment state.
### Simulator biometric validation
Before testing biometric unlock, enable Biometric Unlock once in the iPhone
app so its protected passphrase is enrolled. In Simulator, use **Features >
Face ID > Enrolled**, trigger an IronStorage unlock, then choose **Matching
Face**. Verify the app changes from locked to unlocked. Use **Non-matching
Face** to test rejection, and clear **Enrolled** to test unavailable or changed
biometric enrollment.
Always tap the entry's **Unlock** control before **Features > Face ID > Matching Face**; a simulated match does nothing without an active authentication request.
For initial setup only, select **Features > Face ID > Enrolled** before enabling
Biometric Unlock in the iPhone app. The user must then enable Biometric Unlock
and enter the GPG passphrase once so the protected passphrase is enrolled.
For normal validation, do not toggle **Enrolled** again. Tap the entry's
**Unlock** control first so an authentication request is active, then select
**Features > Face ID > Matching Face** and verify the app changes from locked to
unlocked. A matching face presented before **Unlock** does nothing. Use
**Non-matching Face** to test rejection without changing enrollment.
Apple's `biometryCurrentSet` access control invalidates a protected Keychain
item when Face ID is re-enrolled. Clearing or toggling **Enrolled** is therefore
a destructive negative test, not part of the normal unlock sequence. Only do it
when the issue explicitly requires changed-enrollment coverage and the user is
available to enter the GPG passphrase again. When deliberately returning to a
manual baseline, disable Biometric Unlock in IronStorage while the protected
record is still valid, then clear **Enrolled**.

View File

@@ -684,7 +684,11 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
let kind = semantic_field_kind(name);
return EntryFieldMetadata {
kind,
sensitivity: EntrySensitivity::Ordinary,
sensitivity: if name.eq_ignore_ascii_case("pin") {
EntrySensitivity::Sensitive
} else {
EntrySensitivity::Ordinary
},
name: Some(name.to_owned()),
otp: None,
diagnostic: std::str::from_utf8(&line[value_start..])

View File

@@ -575,10 +575,7 @@ fn editor_field(index: usize, field: &EntryField) -> MobileEntryEditorField {
| EntryFieldKind::Field => MobileEntryEditorFieldKind::Field,
}
};
let sensitive = matches!(
kind,
MobileEntryEditorFieldKind::Password | MobileEntryEditorFieldKind::OtpUri
);
let sensitive = metadata.sensitivity() == EntrySensitivity::Sensitive;
MobileEntryEditorField {
id: field.id().value(),
kind,

View File

@@ -277,6 +277,53 @@ fn mobile_projection_marks_an_empty_first_line_without_a_reveal_control() -> Tes
Ok(())
}
#[test]
fn pin_fields_are_sensitive_case_insensitively() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture);
write_plaintext(
&repository,
&keys,
"documents/pins",
b"password\nPIN: 1234\npin: 5678\nPiN: 9012\n",
)?;
let document =
EntryDocumentService::new(&repository, &keys).open("documents/pins", &mut secrets)?;
let pins = &document.fields()[1..];
assert!(pins.iter().all(|field| {
field.metadata().kind() == EntryFieldKind::Field
&& field.metadata().sensitivity() == EntrySensitivity::Sensitive
}));
let page = MobileEntryPage::from_document(&document);
let projected = page
.sections()
.iter()
.flat_map(|section| section.fields())
.filter(|field| field.id() != 0)
.collect::<Vec<_>>();
assert_eq!(projected.len(), 3);
assert!(projected.iter().all(|field| {
field.sensitive()
&& field.value().is_none()
&& field.masked_value() == "Hidden"
&& !field.selectable()
}));
assert_eq!(field_value(&document, pins[0].id().value())?, "1234");
let editor = MobileEntryDraft::new(document, false).page();
assert!(
editor.fields()[1..]
.iter()
.all(|field| field.sensitive() && field.masked_value() == "Hidden")
);
Ok(())
}
#[test]
fn named_multiline_fields_are_one_lossless_logical_field() -> TestResult {
let fixture = FixtureSet::load()?;