package com.peachpasswords.vault import android.content.Context import android.content.pm.PackageManager import android.os.Build import android.security.keystore.KeyInfo import android.security.keystore.KeyProperties import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import java.security.KeyStore import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Assume.assumeFalse import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith /** * Real-device acceptance matrix for the self-testing v3 PIN store. Run this class on a * StrongBox phone, a TEE-only phone, and a device without limited-use support. * Tests never treat an emulator or a software key as evidence of hardware * protection. */ @RunWith(AndroidJUnit4::class) class PinUnlockStoreHardwareTest { private val context = ApplicationProvider.getApplicationContext() private lateinit var store: PinUnlockStore private val vaultKey = ByteArray(VaultCrypto.KEY_SIZE) { it.toByte() } private val pin = "2468" @Before fun setUp() { store = PinUnlockStore(context) store.clear() } @After fun tearDown() { store.clear() } @Test fun strongbox_tier_is_used_when_device_advertises_strongbox() { assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) assumeTrue(context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)) assumeTrue(store.isSupported()) assertTrue("v3 PIN configuration failed", store.configure(pin, vaultKey)) assertEquals( setOf(KeyProperties.SECURITY_LEVEL_STRONGBOX), pinKeySecurityLevels(), ) } @Test fun tee_tier_is_used_when_strongbox_is_not_available() { assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) assumeFalse(context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)) assumeTrue(store.isSupported()) assertTrue("v3 PIN configuration failed", store.configure(pin, vaultKey)) assertEquals( setOf(KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT), pinKeySecurityLevels(), ) } @Test fun unsupported_hardware_never_configures_a_pin() { assumeFalse(store.isSupported()) assertFalse(store.configure(pin, vaultKey)) assertFalse(store.isConfigured()) assertTrue(pinKeySecurityLevels().isEmpty()) } @Test fun advertised_limited_use_hardware_enforces_single_use_before_pin_setup() { assumeTrue(store.isSupported()) assertTrue( "device advertises limited-use Keystore support but did not enforce one HMAC use", store.probeLimitedUseEnforcement(), ) } @Test fun wrong_pin_exhaustion_requires_master_password_replenishment() { assumeTrue(store.isSupported()) assertTrue(store.configure(pin, vaultKey)) repeat(16) { if (!store.areAttemptsExhausted()) { assertEquals(null, store.unlockVaultKey("9999")) } } assertTrue("five single-use gates must exhaust", store.areAttemptsExhausted()) assertEquals(null, store.unlockVaultKey(pin)) store.replenishAttemptGates() assertArrayEquals(vaultKey, store.unlockVaultKey(pin)) } @Test fun legacy_v1_preferences_are_removed_without_creating_v2_keys() { val prefs = context.getSharedPreferences("peach_vault_store", Context.MODE_PRIVATE) prefs.edit() .putString("peach_pin_salt", "legacy-salt") .putString("peach_pin_verifier", "legacy-verifier") .putString("peach_pin_wrapped_key", "legacy-wrapped") .putBoolean("peach_pin_preferred", true) .commit() val migrated = PinUnlockStore(context) assertFalse(prefs.contains("peach_pin_salt")) assertFalse(prefs.contains("peach_pin_verifier")) assertFalse(prefs.contains("peach_pin_wrapped_key")) assertFalse(migrated.isConfigured()) assertTrue(pinKeySecurityLevels().isEmpty()) migrated.clear() } @Test fun preProbe_v2_configuration_is_fully_invalidated() { val prefs = context.getSharedPreferences("peach_vault_store", Context.MODE_PRIVATE) prefs.edit() .putInt("peach_pin_format", 2) .putBoolean("peach_pin_preferred_v2", true) .putString("peach_pin_salt_v2", "stale") .commit() val migrated = PinUnlockStore(context) assertFalse(migrated.isConfigured()) assertFalse(migrated.isPinPreferred()) assertFalse(prefs.contains("peach_pin_format")) assertFalse(prefs.contains("peach_pin_salt_v2")) migrated.clear() } @Test fun v3_envelope_survives_store_reinstantiation() { assumeTrue(store.isSupported()) assertTrue(store.configure(pin, vaultKey)) val restartedStore = PinUnlockStore(context) assertArrayEquals(vaultKey, restartedStore.unlockVaultKey(pin)) restartedStore.clear() } private fun pinKeySecurityLevels(): Set { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return emptySet() val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } return keyStore.aliases().toList() .filter { it == "peach_pin_vault_key_v2" || it == "peach_pin_verifier_key_v2" || it.startsWith("peach_pin_attempt_v2_") } .mapNotNull { alias -> val key = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey ?: return@mapNotNull null keySecurityLevel(key) } .toSet() } private fun keySecurityLevel(key: SecretKey): Int = (SecretKeyFactory.getInstance(key.algorithm, "AndroidKeyStore") .getKeySpec(key, KeyInfo::class.java) as KeyInfo).securityLevel }