android: gate the Keystore passphrase key on device unlock, and migrate old installs #191

Merged
sirius merged 1 commit from onionwire/t_a84365da-android-gate-the-keystore-passphrase-key into main 2026-10-23 02:20:40 +00:00
Owner

Follows the card: gate the passphrase-wrapping key on device unlock, and migrate installs that predate the flag.

What the flag is, and what it is not

setUnlockedDeviceRequired(true) is set at key generation on API 28+ (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P). Below that the pre-gate path runs unchanged and the branch that ran is logged (OnionWireKeystore).

setUserAuthenticationRequired(false) is untouched — no fingerprint prompt, so an unattended unlock on an unlocked device still works. The gate narrows when the key works, not who is awake: while the lock screen is up the key refuses to decrypt, so a blob lifted off a powered-on-but-locked device is useless to the app's own process too. open_does_not_require_user_authentication is unchanged and stays green.

Migration: two aliases, five ordered steps

A Keystore key cannot be retrofitted and cannot be re-aliased, so the gated key needs its own alias:

alias who creates it
onionwire.store.unlock-gated this class, with the flag — the only key a fresh install ever creates
onionwire.store (ALIAS) the app before this card; kept only as the pre-gate key to migrate from

Lock deviation, stated rather than hidden: the card says one key per app under ALIAS = "onionwire.store". Two aliases exist only in the migration window; after step 5 exactly one key remains, and on a fresh install ALIAS is never created. ALIAS also cannot be reused for the gated key because a blob wrapped by it already exists on upgraded installs — the two must coexist for the length of one save.

Order (in StoredPassphraseStore, driven from save() and from a successful load(), never from open() — open() still generates nothing):

  1. seal() under the unlock-gated key, creating it if needed
  2. write the blob to store-passphrase.bin.tmp
  3. read those bytes back, open them with the gated key alone, compare to the passphrase in hand — on failure: temp file dropped, SecretUnavailable thrown with "the previous blob and key were kept", nothing else touched
  4. rename the temp file over the blob
  5. only now delete the pre-gate alias

Crash before 4 → previous blob and previous key intact. Crash between 4 and 5 → a working gated blob plus a stale alias that the next save/unlock retires. open() tries the gated key first and falls back to the pre-gate key, so that window still unlocks; a lock-screen refusal is reported as "the device is locked — the passphrase key is only usable while the device is unlocked" instead of a GCM tag failure. forget() deletes both aliases.

Reporting — including what cannot be reported

KeyInfo on this API range exposes isUserAuthenticationRequired but has no getter for the unlock requirement, so nothing can ask the Keystore whether the flag stuck. KeystorePassphraseCipher.unlockGateReport() therefore reports our own bookkeeping, and says so:

UnlockGateReport(supported, sdkInt, keyIsUnlockGated, legacyKeyPending, backing,
                 gateRefusal, migrationFailure, platformReportsGate = false)

describe() renders one honest sentence — key: tee-backing, unusable while the device is locked, or NOT unlock-gated — pre-gate key, migrates on the next unlock, or NOT unlock-gated — the Keystore refused the flag. A device whose Keystore refuses the flag falls back to the ungated alias and reports the refusal; nothing claims a gate it does not have.

Verification

Committed HEAD (d64114c) built locally on this host, JDK 21, NDK 28.2.13676358, arm64-v8a:

> Task :app:compileDebugKotlin        (executed, not cached)
> Task :app:assembleDebug
BUILD SUCCESSFUL in 6m 13s
62 actionable tasks: 35 executed, 27 from cache
app/build/outputs/apk/debug/app-debug.apk  (40,784,907 bytes)

> Task :app:compileDebugAndroidTestKotlin   (executed)
> Task :app:testDebugUnitTest
BUILD SUCCESSFUL in 37s
test-results: 14 suites, 213 tests, 0 failures, 0 errors
  StoreSecretTest 11, StoredPassphraseStoreTest 9

The JVM store tests (JvmKeyCipher) implement PassphraseCipher only, so the migration capability is absent for them and their contract is unchanged — StoredPassphraseStore asks for it with a safe cast (PassphraseKeyMigration), which is why no existing fake had to grow a method and PassphraseCipher was not edited.

Instrumentation — written, compiled, not run

a_pre_flag_key_is_migrated_to_the_unlock_gated_alias_on_unlock
a_failed_re_wrap_keeps_the_pre_gate_key_and_the_previous_blob
the_gated_proof_refuses_a_blob_wrapped_by_the_pre_gate_key
a_blob_wrapped_by_the_pre_gate_key_still_opens_while_both_keys_exist

Not verified on device: no device/emulator attached on this host (adb devices → empty), and CI never runs instrumentation (android-ci.yml only assembles :sdk + :app). They compile but are unproven until someone runs ./gradlew :app:connectedDebugAndroidTest. The migration tests log manufacturer=… model=… sdk=… branch=… so an OEM refusal is diagnosable from the run.

Out of scope here (both files are outside this card)

  • ui/UnlockScreen.kt — unlockGateReport().describe() is not surfaced yet; the unlock screen still shows only KeyBacking. The report exists so a later card is a one-line Text.
  • WireViewModel.kt:118 still says the key lives under KeystorePassphraseCipher.ALIAS; that comment is now stale for fresh installs.
Follows the card: gate the passphrase-wrapping key on device unlock, and migrate installs that predate the flag. ## What the flag is, and what it is not `setUnlockedDeviceRequired(true)` is set at key generation on **API 28+** (`Build.VERSION.SDK_INT >= Build.VERSION_CODES.P`). Below that the pre-gate path runs unchanged and the branch that ran is logged (`OnionWireKeystore`). `setUserAuthenticationRequired(false)` is **untouched** — no fingerprint prompt, so an unattended unlock on an unlocked device still works. The gate narrows *when* the key works, not who is awake: while the lock screen is up the key refuses to decrypt, so a blob lifted off a powered-on-but-locked device is useless to the app's own process too. `open_does_not_require_user_authentication` is unchanged and stays green. ## Migration: two aliases, five ordered steps A Keystore key cannot be retrofitted and cannot be re-aliased, so the gated key needs its own alias: | alias | who creates it | |---|---| | `onionwire.store.unlock-gated` | this class, with the flag — the only key a fresh install ever creates | | `onionwire.store` (`ALIAS`) | the app before this card; kept **only** as the pre-gate key to migrate from | **Lock deviation, stated rather than hidden:** the card says one key per app under `ALIAS = "onionwire.store"`. Two aliases exist only in the migration window; after step 5 exactly one key remains, and on a fresh install `ALIAS` is never created. `ALIAS` also cannot be reused for the gated key because a blob wrapped by it already exists on upgraded installs — the two must coexist for the length of one save. Order (in `StoredPassphraseStore`, driven from `save()` and from a successful `load()`, never from `open()` — `open()` still generates nothing): 1. `seal()` under the unlock-gated key, creating it if needed 2. write the blob to `store-passphrase.bin.tmp` 3. read those bytes back, open them **with the gated key alone**, compare to the passphrase in hand — on failure: temp file dropped, `SecretUnavailable` thrown with "the previous blob and key were kept", nothing else touched 4. rename the temp file over the blob 5. only now delete the pre-gate alias Crash before 4 → previous blob and previous key intact. Crash between 4 and 5 → a working gated blob plus a stale alias that the next save/unlock retires. `open()` tries the gated key first and falls back to the pre-gate key, so that window still unlocks; a lock-screen refusal is reported as "the device is locked — the passphrase key is only usable while the device is unlocked" instead of a GCM tag failure. `forget()` deletes both aliases. ## Reporting — including what cannot be reported `KeyInfo` on this API range exposes `isUserAuthenticationRequired` but has **no getter for the unlock requirement**, so nothing can ask the Keystore whether the flag stuck. `KeystorePassphraseCipher.unlockGateReport()` therefore reports our own bookkeeping, and says so: ```kotlin UnlockGateReport(supported, sdkInt, keyIsUnlockGated, legacyKeyPending, backing, gateRefusal, migrationFailure, platformReportsGate = false) ``` `describe()` renders one honest sentence — `key: tee-backing, unusable while the device is locked`, or `NOT unlock-gated — pre-gate key, migrates on the next unlock`, or `NOT unlock-gated — the Keystore refused the flag`. A device whose Keystore refuses the flag falls back to the ungated alias and reports the refusal; nothing claims a gate it does not have. ## Verification Committed HEAD (`d64114c`) built locally on this host, JDK 21, NDK 28.2.13676358, arm64-v8a: ``` > Task :app:compileDebugKotlin (executed, not cached) > Task :app:assembleDebug BUILD SUCCESSFUL in 6m 13s 62 actionable tasks: 35 executed, 27 from cache app/build/outputs/apk/debug/app-debug.apk (40,784,907 bytes) > Task :app:compileDebugAndroidTestKotlin (executed) > Task :app:testDebugUnitTest BUILD SUCCESSFUL in 37s test-results: 14 suites, 213 tests, 0 failures, 0 errors StoreSecretTest 11, StoredPassphraseStoreTest 9 ``` The JVM store tests (`JvmKeyCipher`) implement `PassphraseCipher` only, so the migration capability is absent for them and their contract is unchanged — `StoredPassphraseStore` asks for it with a safe cast (`PassphraseKeyMigration`), which is why no existing fake had to grow a method and `PassphraseCipher` was not edited. ### Instrumentation — written, compiled, **not run** ``` a_pre_flag_key_is_migrated_to_the_unlock_gated_alias_on_unlock a_failed_re_wrap_keeps_the_pre_gate_key_and_the_previous_blob the_gated_proof_refuses_a_blob_wrapped_by_the_pre_gate_key a_blob_wrapped_by_the_pre_gate_key_still_opens_while_both_keys_exist ``` Not verified on device: no device/emulator attached on this host (`adb devices` → empty), and CI never runs instrumentation (`android-ci.yml` only assembles `:sdk` + `:app`). They compile but are unproven until someone runs `./gradlew :app:connectedDebugAndroidTest`. The migration tests log `manufacturer=… model=… sdk=… branch=…` so an OEM refusal is diagnosable from the run. ## Out of scope here (both files are outside this card) - `ui/UnlockScreen.kt` — `unlockGateReport().describe()` is not surfaced yet; the unlock screen still shows only `KeyBacking`. The report exists so a later card is a one-line `Text`. - `WireViewModel.kt:118` still says the key lives under `KeystorePassphraseCipher.ALIAS`; that comment is now stale for fresh installs.
android: gate the Keystore passphrase key on device unlock, and migrate old installs
All checks were successful
android-ci / android (pull_request) Successful in 11m41s
d64114ca1c
`setUnlockedDeviceRequired(true)` is now set on the key that wraps the store
passphrase (API 28+; below that the pre-gate path is unchanged and logs which
branch ran). `setUserAuthenticationRequired(false)` is untouched: the gate only
refuses while the lock screen is up, it is not a biometric prompt, so an
unattended unlock on an unlocked device still works.

A Keystore key cannot be retrofitted, so the gate needs a second alias:
`onionwire.store.unlock-gated` is generated with the flag and is the only key a
fresh install creates; `onionwire.store` is the pre-gate key and exists only on
installs that stored a passphrase earlier. Migration runs the next time the
passphrase is in hand (save(), or a successful load() — never inside open(),
which must not generate keys):

  1. seal under the unlock-gated key (created here if needed)
  2. write the blob to `…bin.tmp`
  3. read those bytes back, open them with the gated key alone, compare to the
     passphrase in hand — failure drops the temp file, throws SecretUnavailable
     with the reason, and leaves the previous blob and key untouched
  4. rename the temp file over the blob
  5. only now delete the pre-gate alias

A crash before 4 leaves the old blob and key intact; a crash between 4 and 5
leaves a working gated blob plus a stale alias the next save/unlock retires.
`open()` tries the gated key first and falls back to the pre-gate key, so that
window is harmless. `forget()` deletes both aliases.

Reporting is honest about a platform limit: `KeyInfo` exposes
`isUserAuthenticationRequired` but has no getter for the unlock requirement, so
nothing can ask the Keystore whether the flag stuck.
`KeystorePassphraseCipher.unlockGateReport()` reports our own bookkeeping (which
alias the live key was generated under) plus `platformReportsGate = false`, and
a device whose Keystore refuses the flag falls back to the ungated alias and
says so instead of claiming the gate. A locked device that makes the gated key
unusable is reported as "the device is locked", not as a GCM tag failure.

Instrumentation: migration happy path (gated key appears, pre-gate alias gone
only after the on-disk blob opens under the new key), a failed re-wrap (old key
and blob byte-identical, no temp file), the gated proof refusing a pre-gate
blob, and the crash window still unlocking. Logcat lines print
MANUFACTURER/MODEL/SDK_INT and the branch taken. Not run here: no device or
emulator on this host. JVM suite still 213 tests / 0 failures; androidTest
sources compile.
Author
Owner

One more out-of-scope drift, for whoever takes the docs card: android/README.md:131-153 still says the wrapping key lives under a single alias onionwire.store with setUserAuthenticationRequired(false). After this PR the live key on a fresh install is onionwire.store.unlock-gated (with setUnlockedDeviceRequired(true) on API 28+), onionwire.store is the pre-gate key that only exists on upgraded installs until the migration retires it, and forget deletes both. android/README.md is outside this card's allowed file list, so it is left untouched rather than edited quietly.

One more out-of-scope drift, for whoever takes the docs card: `android/README.md:131-153` still says the wrapping key lives under a single alias `onionwire.store` with `setUserAuthenticationRequired(false)`. After this PR the live key on a fresh install is `onionwire.store.unlock-gated` (with `setUnlockedDeviceRequired(true)` on API 28+), `onionwire.store` is the pre-gate key that only exists on upgraded installs until the migration retires it, and `forget` deletes both. `android/README.md` is outside this card's allowed file list, so it is left untouched rather than edited quietly.
sirius merged commit 1b562ca149 into main 2026-10-23 02:20:40 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
sirius/onionwire!191
No description provided.