PinUnlockStoreHardwareTest.kt

6.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 self-testing v3 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("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}