android: gate the Keystore passphrase key on device unlock, and migrate old installs #191
Loading…
Reference in a new issue
No description provided.
Delete branch "onionwire/t_a84365da-android-gate-the-keystore-passphrase-key"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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_authenticationis 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:
onionwire.store.unlock-gatedonionwire.store(ALIAS)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 installALIASis never created.ALIASalso 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 fromsave()and from a successfulload(), never fromopen()—open()still generates nothing):seal()under the unlock-gated key, creating it if neededstore-passphrase.bin.tmpSecretUnavailablethrown with "the previous blob and key were kept", nothing else touchedCrash 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
KeyInfoon this API range exposesisUserAuthenticationRequiredbut 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:describe()renders one honest sentence —key: tee-backing, unusable while the device is locked, orNOT unlock-gated — pre-gate key, migrates on the next unlock, orNOT 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:The JVM store tests (
JvmKeyCipher) implementPassphraseCipheronly, so the migration capability is absent for them and their contract is unchanged —StoredPassphraseStoreasks for it with a safe cast (PassphraseKeyMigration), which is why no existing fake had to grow a method andPassphraseCipherwas not edited.Instrumentation — written, compiled, not run
Not verified on device: no device/emulator attached on this host (
adb devices→ empty), and CI never runs instrumentation (android-ci.ymlonly assembles:sdk+:app). They compile but are unproven until someone runs./gradlew :app:connectedDebugAndroidTest. The migration tests logmanufacturer=… 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 onlyKeyBacking. The report exists so a later card is a one-lineText.WireViewModel.kt:118still says the key lives underKeystorePassphraseCipher.ALIAS; that comment is now stale for fresh installs.`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.One more out-of-scope drift, for whoever takes the docs card:
android/README.md:131-153still says the wrapping key lives under a single aliasonionwire.storewithsetUserAuthenticationRequired(false). After this PR the live key on a fresh install isonionwire.store.unlock-gated(withsetUnlockedDeviceRequired(true)on API 28+),onionwire.storeis the pre-gate key that only exists on upgraded installs until the migration retires it, andforgetdeletes both.android/README.mdis outside this card's allowed file list, so it is left untouched rather than edited quietly.