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 For Apple project changes, also run `xcodegen generate` from `apple/` and build
the affected simulator targets. 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 ### Simulator biometric validation
Before testing biometric unlock, enable Biometric Unlock once in the iPhone For initial setup only, select **Features > Face ID > Enrolled** before enabling
app so its protected passphrase is enrolled. In Simulator, use **Features > Biometric Unlock in the iPhone app. The user must then enable Biometric Unlock
Face ID > Enrolled**, trigger an IronStorage unlock, then choose **Matching and enter the GPG passphrase once so the protected passphrase is enrolled.
Face**. Verify the app changes from locked to unlocked. Use **Non-matching
Face** to test rejection, and clear **Enrolled** to test unavailable or changed For normal validation, do not toggle **Enrolled** again. Tap the entry's
biometric enrollment. **Unlock** control first so an authentication request is active, then select
Always tap the entry's **Unlock** control before **Features > Face ID > Matching Face**; a simulated match does nothing without an active authentication request. **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); let kind = semantic_field_kind(name);
return EntryFieldMetadata { return EntryFieldMetadata {
kind, kind,
sensitivity: EntrySensitivity::Ordinary, sensitivity: if name.eq_ignore_ascii_case("pin") {
EntrySensitivity::Sensitive
} else {
EntrySensitivity::Ordinary
},
name: Some(name.to_owned()), name: Some(name.to_owned()),
otp: None, otp: None,
diagnostic: std::str::from_utf8(&line[value_start..]) 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, | EntryFieldKind::Field => MobileEntryEditorFieldKind::Field,
} }
}; };
let sensitive = matches!( let sensitive = metadata.sensitivity() == EntrySensitivity::Sensitive;
kind,
MobileEntryEditorFieldKind::Password | MobileEntryEditorFieldKind::OtpUri
);
MobileEntryEditorField { MobileEntryEditorField {
id: field.id().value(), id: field.id().value(),
kind, kind,

View File

@@ -277,6 +277,53 @@ fn mobile_projection_marks_an_empty_first_line_without_a_reveal_control() -> Tes
Ok(()) 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] #[test]
fn named_multiline_fields_are_one_lossless_logical_field() -> TestResult { fn named_multiline_fields_are_one_lossless_logical_field() -> TestResult {
let fixture = FixtureSet::load()?; let fixture = FixtureSet::load()?;