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
2 files VERSION+1 −1app/src/androidTest/java/com/peachpasswords/vault/PinUnlockStoreHardwareTest.kt+146 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.691+2.14.70

view file

app/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+}

view file