1package com.peachpasswords.vault23import android.content.Context4import android.content.pm.PackageManager5import android.os.Build6import android.security.keystore.KeyInfo7import android.security.keystore.KeyProperties8import androidx.test.core.app.ApplicationProvider9import androidx.test.ext.junit.runners.AndroidJUnit410import java.security.KeyStore11import javax.crypto.SecretKey12import javax.crypto.SecretKeyFactory13import org.junit.After14import org.junit.Assert.assertArrayEquals15import org.junit.Assert.assertEquals16import org.junit.Assert.assertFalse17import org.junit.Assert.assertTrue18import org.junit.Assume.assumeFalse19import org.junit.Assume.assumeTrue20import org.junit.Before21import org.junit.Test22import org.junit.runner.RunWith2324252627282930@RunWith(AndroidJUnit4::class)31class 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"3637 @Before38 fun setUp() {39 store = PinUnlockStore(context)40 store.clear()41 }4243 @After44 fun tearDown() {45 store.clear()46 }4748 @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())5354 assertTrue("v3 PIN configuration failed", store.configure(pin, vaultKey))55 assertEquals(56 setOf(KeyProperties.SECURITY_LEVEL_STRONGBOX),57 pinKeySecurityLevels(),58 )59 }6061 @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())6667 assertTrue("v3 PIN configuration failed", store.configure(pin, vaultKey))68 assertEquals(69 setOf(KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT),70 pinKeySecurityLevels(),71 )72 }7374 @Test75 fun unsupported_hardware_never_configures_a_pin() {76 assumeFalse(store.isSupported())7778 assertFalse(store.configure(pin, vaultKey))79 assertFalse(store.isConfigured())80 assertTrue(pinKeySecurityLevels().isEmpty())81 }8283 @Test84 fun advertised_limited_use_hardware_enforces_single_use_before_pin_setup() {85 assumeTrue(store.isSupported())8687 assertTrue(88 "device advertises limited-use Keystore support but did not enforce one HMAC use",89 store.probeLimitedUseEnforcement(),90 )91 }9293 @Test94 fun wrong_pin_exhaustion_requires_master_password_replenishment() {95 assumeTrue(store.isSupported())96 assertTrue(store.configure(pin, vaultKey))9798 repeat(16) {99 if (!store.areAttemptsExhausted()) {100 assertEquals(null, store.unlockVaultKey("9999"))101 }102 }103 assertTrue("five single-use gates must exhaust", store.areAttemptsExhausted())104 assertEquals(null, store.unlockVaultKey(pin))105106 store.replenishAttemptGates()107 assertArrayEquals(vaultKey, store.unlockVaultKey(pin))108 }109110 @Test111 fun legacy_v1_preferences_are_removed_without_creating_v2_keys() {112 val prefs = context.getSharedPreferences("peach_vault_store", Context.MODE_PRIVATE)113 prefs.edit()114 .putString("peach_pin_salt", "legacy-salt")115 .putString("peach_pin_verifier", "legacy-verifier")116 .putString("peach_pin_wrapped_key", "legacy-wrapped")117 .putBoolean("peach_pin_preferred", true)118 .commit()119120 val migrated = PinUnlockStore(context)121122 assertFalse(prefs.contains("peach_pin_salt"))123 assertFalse(prefs.contains("peach_pin_verifier"))124 assertFalse(prefs.contains("peach_pin_wrapped_key"))125 assertFalse(migrated.isConfigured())126 assertTrue(pinKeySecurityLevels().isEmpty())127 migrated.clear()128 }129130 @Test131 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()138139 val migrated = PinUnlockStore(context)140141 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 }147148 @Test149 fun v3_envelope_survives_store_reinstantiation() {150 assumeTrue(store.isSupported())151 assertTrue(store.configure(pin, vaultKey))152153 val restartedStore = PinUnlockStore(context)154155 assertArrayEquals(vaultKey, restartedStore.unlockVaultKey(pin))156 restartedStore.clear()157 }158159 private fun pinKeySecurityLevels(): Set<Int> {160 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return emptySet()161 val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }162 return keyStore.aliases().toList()163 .filter { it == "peach_pin_vault_key_v2" || it == "peach_pin_verifier_key_v2" || it.startsWith("peach_pin_attempt_v2_") }164 .mapNotNull { alias ->165 val key = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey ?: return@mapNotNull null166 keySecurityLevel(key)167 }168 .toSet()169 }170171 private fun keySecurityLevel(key: SecretKey): Int =172 (SecretKeyFactory.getInstance(key.algorithm, "AndroidKeyStore")173 .getKeySpec(key, KeyInfo::class.java) as KeyInfo).securityLevel174}