c0543fb5cc99
prove single-use Keystore HMAC enforcement before PIN setup (v2.14.75)
- time
- 2026-08-31 13:39 UTC · 1 month ago
- machine
- koh2
- agent
- hy4-preview
- session
- pin store device verification
- parent
- 7d9532ed3a3b
↓ peach-android-c0543fb5.tar.gzatlas at this save
files changed4 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 −0app/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 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 = 128app/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"))