c0543fb5cc99

prove single-use Keystore HMAC enforcement before PIN setup (v2.14.75)

PinUnlockStore previously trusted the device's FEATURE_KEYSTORE_LIMITED_USE_KEY / SINGLE_USE_KEY flag, so a device that advertises single-use keys but silently allows reuse would have had an unlimited offline-capable attempt budget. configure() now probes the claim: generate a max-usage-1 HMAC key, MAC once, then require that the key is either gone or rejects a second MAC before any PIN may be set. Format version rises to 3 and pre-probe v2 configurations are fully cleared on store init rather than reused, because a v2 install was created without ever checking enforcement. The probe alias prefix joins the Keystore cleanup sweep. Verification: PinUnlockStoreTest 2/0/0; full JVM suite 1015 tests / 0 failures / 0 errors / 2 skipped, matching the v2.14.74 baseline; lintDebug clean; assembleDebug and debug androidTest sources compile.

time
2026-08-31 13:39 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
pin store device verification
parent
7d9532ed3a3b
4 files VERSION+1 −1app/src/androidTest/java/com/peachpasswords/vault/PinUnlockStoreHardwareTest.kt+32 −4app/src/main/java/com/peachpasswords/vault/PinUnlockStore.kt+36 −3app/src/test/java/com/peachpasswords/vault/PinUnlockStoreTest.kt+2 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.741+2.14.75

view file

app/src/androidTest/java/com/peachpasswords/vault/PinUnlockStoreHardwareTest.kt+32 −4
@@ -22,7 +22,7 @@2222 import org.junit.runner.RunWith2323 2424 /**25- * Real-device acceptance matrix for the v2 PIN store. Run this class on a25+ * Real-device acceptance matrix for the self-testing v3 PIN store. Run this class on a2626  * StrongBox phone, a TEE-only phone, and a device without limited-use support.2727  * Tests never treat an emulator or a software key as evidence of hardware2828  * protection.@@ -51,7 +51,7 @@5151         assumeTrue(context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE))5252         assumeTrue(store.isSupported())5353 54-        assertTrue("v2 PIN configuration failed", store.configure(pin, vaultKey))54+        assertTrue("v3 PIN configuration failed", store.configure(pin, vaultKey))5555         assertEquals(5656             setOf(KeyProperties.SECURITY_LEVEL_STRONGBOX),5757             pinKeySecurityLevels(),@@ -64,7 +64,7 @@6464         assumeFalse(context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE))6565         assumeTrue(store.isSupported())6666 67-        assertTrue("v2 PIN configuration failed", store.configure(pin, vaultKey))67+        assertTrue("v3 PIN configuration failed", store.configure(pin, vaultKey))6868         assertEquals(6969             setOf(KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT),7070             pinKeySecurityLevels(),@@ -81,6 +81,16 @@8181     }8282 8383     @Test84+    fun advertised_limited_use_hardware_enforces_single_use_before_pin_setup() {85+        assumeTrue(store.isSupported())86+87+        assertTrue(88+            "device advertises limited-use Keystore support but did not enforce one HMAC use",89+            store.probeLimitedUseEnforcement(),90+        )91+    }92+93+    @Test8494     fun wrong_pin_exhaustion_requires_master_password_replenishment() {8595         assumeTrue(store.isSupported())8696         assertTrue(store.configure(pin, vaultKey))@@ -118,7 +128,25 @@118128     }119129 120130     @Test121-    fun v2_envelope_survives_store_reinstantiation() {131+    fun preProbe_v2_configuration_is_fully_invalidated() {132+        val prefs = context.getSharedPreferences("peach_vault_store", Context.MODE_PRIVATE)133+        prefs.edit()134+            .putInt("peach_pin_format", 2)135+            .putBoolean("peach_pin_preferred_v2", true)136+            .putString("peach_pin_salt_v2", "stale")137+            .commit()138+139+        val migrated = PinUnlockStore(context)140+141+        assertFalse(migrated.isConfigured())142+        assertFalse(migrated.isPinPreferred())143+        assertFalse(prefs.contains("peach_pin_format"))144+        assertFalse(prefs.contains("peach_pin_salt_v2"))145+        migrated.clear()146+    }147+148+    @Test149+    fun v3_envelope_survives_store_reinstantiation() {122150         assumeTrue(store.isSupported())123151         assertTrue(store.configure(pin, vaultKey))124152 

view file

app/src/main/java/com/peachpasswords/vault/PinUnlockStore.kt+36 −3
@@ -52,7 +52,11 @@5252         // Remove it as soon as the security-remediated app opens the store; users5353         // can still unlock with the master password and configure the new format.5454         clearLegacyConfiguration()55-        if (prefs.getInt(KEY_FORMAT, 0) != FORMAT_VERSION) clearKnownKeystoreAliases()55+        when (prefs.getInt(KEY_FORMAT, 0)) {56+            FORMAT_VERSION -> Unit57+            0 -> clearKnownKeystoreAliases()58+            else -> clear()59+        }5660     }5761 5862     /** New PIN setup is safe only where Android reports hardware limited-use keys. */@@ -81,6 +85,7 @@8185 8286     fun configure(pin: String, vaultKey: ByteArray): Boolean {8387         if (!isSupported() || pinValidationError(pin) != null || vaultKey.size != VaultCrypto.KEY_SIZE) return false88+        if (!probeLimitedUseEnforcement()) return false8489         clear()8590 8691         var salt: ByteArray? = null@@ -167,6 +172,30 @@167172     fun areAttemptsExhausted(): Boolean = isConfigured() &&168173         loadAttemptAliases().none { existingSecretKey(it) != null }169174 175+    /**176+     * Prove this device actually enforces a one-use hardware HMAC key instead177+     * of trusting its feature flag. PIN setup proceeds only when the first MAC178+     * succeeds and the same key is then unavailable or rejects a second MAC.179+     */180+    internal fun probeLimitedUseEnforcement(): Boolean {181+        if (!isSupported()) return false182+        val alias = "$PROBE_ALIAS_PREFIX${UUID.randomUUID()}"183+        val createdAliases = mutableListOf<String>()184+        return try {185+            val key = generateHmacKey(alias, limitedUse = true, createdAliases)186+            hmac(key, ATTEMPT_CHALLENGE).let(VaultCrypto::zero)187+            val remainingKey = existingSecretKey(alias) ?: return true188+            val secondUseSucceeded = runCatching {189+                hmac(remainingKey, ATTEMPT_CHALLENGE).let(VaultCrypto::zero)190+            }.isSuccess191+            !secondUseSucceeded192+        } catch (_: Exception) {193+            false194+        } finally {195+            deleteKey(alias)196+        }197+    }198+170199     /** Regenerate the hardware attempt budget after a master-password unlock. */171200     fun replenishAttemptGates() {172201         if (!isConfigured()) return@@ -329,7 +358,10 @@329358     private fun clearKnownKeystoreAliases() {330359         runCatching {331360             KeyStore.getInstance(KEYSTORE).apply { load(null) }.aliases().toList()332-                .filter { it == VAULT_ALIAS || it == VERIFIER_ALIAS || it.startsWith(ATTEMPT_ALIAS_PREFIX) }361+                .filter {362+                    it == VAULT_ALIAS || it == VERIFIER_ALIAS ||363+                        it.startsWith(ATTEMPT_ALIAS_PREFIX) || it.startsWith(PROBE_ALIAS_PREFIX)364+                }333365                 .forEach(::deleteKey)334366         }335367     }@@ -347,7 +379,7 @@347379     private fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)348380 349381     private companion object {350-        const val FORMAT_VERSION = 2382+        const val FORMAT_VERSION = 3351383         const val PREFS = "peach_vault_store"352384         const val KEY_FORMAT = "peach_pin_format"353385         const val KEY_SALT = "peach_pin_salt_v2"@@ -360,6 +392,7 @@360392         const val VAULT_ALIAS = "peach_pin_vault_key_v2"361393         const val VERIFIER_ALIAS = "peach_pin_verifier_key_v2"362394         const val ATTEMPT_ALIAS_PREFIX = "peach_pin_attempt_v2_"395+        const val PROBE_ALIAS_PREFIX = "peach_pin_probe_v3_"363396         const val ATTEMPT_GATE_COUNT = 5364397         const val TRANSFORM = "AES/GCM/NoPadding"365398         const val TAG_BITS = 128

view file

app/src/test/java/com/peachpasswords/vault/PinUnlockStoreTest.kt+2 −0
@@ -22,6 +22,8 @@2222         assertTrue(source.contains("AndroidKeyStore"))2323         assertTrue(source.contains("KeyProperties.KEY_ALGORITHM_HMAC_SHA256"))2424         assertTrue(source.contains("setMaxUsageCount(1)"))25+        assertTrue(source.contains("probeLimitedUseEnforcement()"))26+        assertTrue(source.contains("const val FORMAT_VERSION = 3"))2527         assertTrue(source.contains("setIsStrongBoxBacked(true)"))2628         assertTrue(source.contains("StrongBoxUnavailableException"))2729         assertTrue(source.contains("KeyInfo"))

view file