PinUnlockStoreHardwareTest.kt

5.1 KB · raw

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.RunWith2324/**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)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}