OverviewSignetSemaForeCuriousLee
Assurance record Published engineering decision

Biometric enrolment session model

The accepted engineering decision describing how biometric-enrolment changes affect Attomus Signet sessions and keys.

AUTH-ADR-0033 — Biometric Enrollment Changes Lock the Session; Keys Are Not Destroyed

Date: 2026-06-12 Status: Accepted Decision authority: Attomus Security Architecture Triggered by: Security review of fix/android-auth-window-insets (Android keystore auth-window change) surfacing a contradiction between the documented invalidation guarantee and what both shipped platforms actually do. Supersedes: The Layer-2 “per-use auth, duration=0” description in AUTH-ADR-0010 and the “permanent destruction / no recovery path” claim in android-security-architecture.md §1.4.


Context

The architecture documents and the product page claim that changing biometric enrollment permanently destroys the wrapping keys, with no recovery path. Neither shipped platform has ever delivered that guarantee:

Android. setInvalidatedByBiometricEnrollment(true) applies only to auth-per-use keys (no positive validity duration). The app has always created account keys with a timed validity window (setUserAuthenticationValidityDurationSeconds(30), later setUserAuthenticationParameters(30, BIOMETRIC_STRONG | DEVICE_CREDENTIAL)), which makes the flag a silent no-op: the Keystore binds timed-window keys to the device credential (Gatekeeper SID), not to the biometric SID set. Enrollment changes never invalidated these keys. Additionally, DEVICE_CREDENTIAL was always an allowed authenticator, so a credential path existed regardless.

iOS. Keychain items use SecAccessControlCreateWithFlags with [.biometryCurrentSet, .or, .devicePasscode]. An enrollment change invalidates the biometric access path, but the device passcode path survives by design. There has always been a recovery path on iOS.

AUTH-ADR-0010 described Layer 2 as per-use auth with duration=0 on Android; the implementation used a timed window from the start. The “permanent destruction” language was therefore a documentation error, never a property the implementation provided.

Two coherent models were considered:

  • Model A — session window, honest docs. Keep the timed auth window and the device-credential fallback. Enrollment changes lock the session and force re-authentication; no key material is destroyed.
  • Model B — true per-use destruction (Android only). Drop the validity window, bind every operation to a CryptoObject cipher. Enrollment changes genuinely throw KeyPermanentlyInvalidatedException. Costs: no live code countdown without a prompt, one authentication per Keystore key during vault load (or a single-wrapping-key redesign), and a permanent platform split — iOS cannot deliver destruction at all while a passcode fallback exists, so the product claim would still be false there.

Decision

Model A. Both platforms implement and document the same behaviour:

Changing your biometrics locks the app. Your codes stay encrypted on the device and you must re-authenticate (new biometric or device credential) before they are accessible again. No key material is destroyed by an enrollment change.

Android implementation details:

  1. setInvalidatedByBiometricEnrollment(true) is removed from account-key generation (it never did anything on these keys). Keys keep setUserAuthenticationRequired(true) with a timed window.
  2. Enrollment changes are detected via a data-less sentinel key (BiometricEnrollmentGuard): an auth-per-use, biometric-bound key carrying the invalidation flag — the one key class where the flag works. Cipher.init on the sentinel throws KeyPermanentlyInvalidatedException immediately (no prompt) after an enrollment change. On detection the app locks the session, surfaces “Device biometrics changed. Authenticate to access your codes.”, and re-baselines the sentinel.
  3. The keystore auth window is 300 seconds, aligned with the longest selectable session idle timeout (AUTH-ADR-0029’s 5-minute option), so the hardware window cannot expire mid-flow underneath a shorter app session. The previous 30-second window caused systematic UserNotAuthenticatedException failures (backup export while typing a passphrase, code refresh after 30s idle). The window should remain the smallest value that reliably covers vault load and the backup flows; if empirical testing on slow devices shows 300s is oversized, it can be reduced — but never below the longest flow it must cover.

Consequences

  • Accepted residual risk: an attacker who can enroll their own biometric on an unlocked device gains an authenticator the app will accept. This is unchanged from prior shipped behaviour (the credential path always existed; the 30s window had the same property). Mitigations that bound the exposure: lock-on-background is always enforced, the session idle ceiling is 5 minutes, the hard session cap is 30 minutes, and an enrollment change itself now forces an immediate lock. Note that enrolling a fingerprint on Android requires the device credential first — an attacker with the credential already has stronger capabilities than this control addresses.
  • The in-app warning before opening biometric enrollment settings now reads that changing biometrics locks Signet (re-authentication required), replacing the false “will require re-provisioning all accounts”.
  • KeyPermanentlyInvalidatedException remains classified and handled (a key can still be destroyed by removing the secure lock screen); its user messaging refers to device security changes, not biometric enrollment.
  • The product page feature card must change from “permanently destroys the wrapping keys / no recovery path” to “biometric enrollment changes lock the vault and require re-authentication”. (Product page lives outside the engineering repos; tracked as a marketing follow-up.)
  • android-security-architecture.md §1.4 (kickoff and design-review-1 copies) carries a superseded banner pointing here; the historical text is preserved as review evidence.
  • Sentinel limitations, accepted: it cannot exist while no strong biometric is enrolled (nothing to guard — the credential path is then the only authenticator anyway), and the none→first-enrollment transition establishes a fresh baseline rather than reporting a change.