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("v2 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("v2 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 wrong_pin_exhaustion_requires_master_password_replenishment() {85 assumeTrue(store.isSupported())86 assertTrue(store.configure(pin, vaultKey))8788 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))9596 store.replenishAttemptGates()97 assertArrayEquals(vaultKey, store.unlockVaultKey(pin))98 }99100 @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()109110 val migrated = PinUnlockStore(context)111112 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 }119120 @Test121 fun v2_envelope_survives_store_reinstantiation() {122 assumeTrue(store.isSupported())123 assertTrue(store.configure(pin, vaultKey))124125 val restartedStore = PinUnlockStore(context)126127 assertArrayEquals(vaultKey, restartedStore.unlockVaultKey(pin))128 restartedStore.clear()129 }130131 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 }142143 private fun keySecurityLevel(key: SecretKey): Int =144 (SecretKeyFactory.getInstance(key.algorithm, "AndroidKeyStore")145 .getKeySpec(key, KeyInfo::class.java) as KeyInfo).securityLevel146}