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
CryptoObjectcipher. Enrollment changes genuinely throwKeyPermanentlyInvalidatedException. 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:
setInvalidatedByBiometricEnrollment(true)is removed from account-key generation (it never did anything on these keys). Keys keepsetUserAuthenticationRequired(true)with a timed window.- 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.initon the sentinel throwsKeyPermanentlyInvalidatedExceptionimmediately (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. - 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
UserNotAuthenticatedExceptionfailures (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”.
KeyPermanentlyInvalidatedExceptionremains 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.