266eb1358cb7
test: validate v2 PIN store on StrongBox/TEE/unsupported hardware tiers (audit PB-26-018) (v2.14.70)
- time
- 2026-08-30 23:54 UTC · 1 month ago
- machine
- koh2
- agent
- GPT-5
- session
- PEACH_ANDROID_INDEPENDENT_AUDIT_2026-08-30
- parent
- 51ddbe3f3f3a
↓ peach-android-266eb135.tar.gzatlas at this save
files changed2 files
VERSION+1 −1app/src/androidTest/java/com/peachpasswords/vault/PinUnlockStoreHardwareTest.kt+146 −0app/src/androidTest/java/com/peachpasswords/vault/PinUnlockStoreHardwareTest.kt+146 −0
@@ -0,0 +1,146 @@1+package com.peachpasswords.vault2+3+import android.content.Context4+import android.content.pm.PackageManager5+import android.os.Build6+import android.security.keystore.KeyInfo7+import android.security.keystore.KeyProperties8+import androidx.test.core.app.ApplicationProvider9+import androidx.test.ext.junit.runners.AndroidJUnit410+import java.security.KeyStore11+import javax.crypto.SecretKey12+import javax.crypto.SecretKeyFactory13+import org.junit.After14+import org.junit.Assert.assertArrayEquals15+import org.junit.Assert.assertEquals16+import org.junit.Assert.assertFalse17+import org.junit.Assert.assertTrue18+import org.junit.Assume.assumeFalse19+import org.junit.Assume.assumeTrue20+import org.junit.Before21+import org.junit.Test22+import org.junit.runner.RunWith23+24+/**25+ * Real-device acceptance matrix for the v2 PIN store. Run this class on a26+ * StrongBox phone, a TEE-only phone, and a device without limited-use support.27+ * Tests never treat an emulator or a software key as evidence of hardware28+ * protection.29+ */30+@RunWith(AndroidJUnit4::class)31+class PinUnlockStoreHardwareTest {32+ private val context = ApplicationProvider.getApplicationContext<Context>()33+ private lateinit var store: PinUnlockStore34+ private val vaultKey = ByteArray(VaultCrypto.KEY_SIZE) { it.toByte() }35+ private val pin = "2468"36+37+ @Before38+ fun setUp() {39+ store = PinUnlockStore(context)40+ store.clear()41+ }42+43+ @After44+ fun tearDown() {45+ store.clear()46+ }47+48+ @Test49+ fun strongbox_tier_is_used_when_device_advertises_strongbox() {50+ assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)51+ assumeTrue(context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE))52+ assumeTrue(store.isSupported())53+54+ assertTrue("v2 PIN configuration failed", store.configure(pin, vaultKey))55+ assertEquals(56+ setOf(KeyProperties.SECURITY_LEVEL_STRONGBOX),57+ pinKeySecurityLevels(),58+ )59+ }60+61+ @Test62+ fun tee_tier_is_used_when_strongbox_is_not_available() {63+ assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)64+ assumeFalse(context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE))65+ assumeTrue(store.isSupported())66+67+ assertTrue("v2 PIN configuration failed", store.configure(pin, vaultKey))68+ assertEquals(69+ setOf(KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT),70+ pinKeySecurityLevels(),71+ )72+ }73+74+ @Test75+ fun unsupported_hardware_never_configures_a_pin() {76+ assumeFalse(store.isSupported())77+78+ assertFalse(store.configure(pin, vaultKey))79+ assertFalse(store.isConfigured())80+ assertTrue(pinKeySecurityLevels().isEmpty())81+ }82+83+ @Test84+ fun wrong_pin_exhaustion_requires_master_password_replenishment() {85+ assumeTrue(store.isSupported())86+ assertTrue(store.configure(pin, vaultKey))87+88+ repeat(16) {89+ if (!store.areAttemptsExhausted()) {90+ assertEquals(null, store.unlockVaultKey("9999"))91+ }92+ }93+ assertTrue("five single-use gates must exhaust", store.areAttemptsExhausted())94+ assertEquals(null, store.unlockVaultKey(pin))95+96+ store.replenishAttemptGates()97+ assertArrayEquals(vaultKey, store.unlockVaultKey(pin))98+ }99+100+ @Test101+ fun legacy_v1_preferences_are_removed_without_creating_v2_keys() {102+ val prefs = context.getSharedPreferences("peach_vault_store", Context.MODE_PRIVATE)103+ prefs.edit()104+ .putString("peach_pin_salt", "legacy-salt")105+ .putString("peach_pin_verifier", "legacy-verifier")106+ .putString("peach_pin_wrapped_key", "legacy-wrapped")107+ .putBoolean("peach_pin_preferred", true)108+ .commit()109+110+ val migrated = PinUnlockStore(context)111+112+ assertFalse(prefs.contains("peach_pin_salt"))113+ assertFalse(prefs.contains("peach_pin_verifier"))114+ assertFalse(prefs.contains("peach_pin_wrapped_key"))115+ assertFalse(migrated.isConfigured())116+ assertTrue(pinKeySecurityLevels().isEmpty())117+ migrated.clear()118+ }119+120+ @Test121+ fun v2_envelope_survives_store_reinstantiation() {122+ assumeTrue(store.isSupported())123+ assertTrue(store.configure(pin, vaultKey))124+125+ val restartedStore = PinUnlockStore(context)126+127+ assertArrayEquals(vaultKey, restartedStore.unlockVaultKey(pin))128+ restartedStore.clear()129+ }130+131+ private fun pinKeySecurityLevels(): Set<Int> {132+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return emptySet()133+ val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }134+ return keyStore.aliases().toList()135+ .filter { it == "peach_pin_vault_key_v2" || it == "peach_pin_verifier_key_v2" || it.startsWith("peach_pin_attempt_v2_") }136+ .mapNotNull { alias ->137+ val key = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey ?: return@mapNotNull null138+ keySecurityLevel(key)139+ }140+ .toSet()141+ }142+143+ private fun keySecurityLevel(key: SecretKey): Int =144+ (SecretKeyFactory.getInstance(key.algorithm, "AndroidKeyStore")145+ .getKeySpec(key, KeyInfo::class.java) as KeyInfo).securityLevel146+}