76e044ae90a1

fix: move PIN unlock behind hardware-backed Keystore envelope (v2.14.66)

time
2026-08-30 11:47 UTC · 1 month ago
machine
koh2
parent
78158337dc01
5 files VERSION+1 −1app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+12 −1app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+1 −0app/src/main/java/com/peachpasswords/vault/PinUnlockStore.kt+287 −31app/src/test/java/com/peachpasswords/vault/PinUnlockStoreTest.kt+17 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.651+2.14.66

view file

app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+12 −1
@@ -210,6 +210,7 @@210210                                     vault.clearUiGate()211211                                     foregroundSync.triggerForegroundSync()212212                                     pinUnlock.markPasswordUsed()213+                                    pinUnlock.replenishAttemptGates()213214                                     _state.update { it.copy(loading = false, unlocked = true, pinPreferred = false) }214215                                 }215216                                 is UnlockResult.Error -> _state.update {@@ -252,7 +253,17 @@252253                 pinUnlock.unlockVaultKey(pin)253254             }254255             if (key == null) {255-                _state.update { it.copy(loading = false, error = "incorrect PIN", shakeNonce = it.shakeNonce + 1) }256+                _state.update {257+                    it.copy(258+                        loading = false,259+                        error = if (pinUnlock.areAttemptsExhausted()) {260+                            "PIN locked — use master password"261+                        } else {262+                            "incorrect PIN"263+                        },264+                        shakeNonce = it.shakeNonce + 1,265+                    )266+                }256267                 return@launch257268             }258269             try {

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+1 −0
@@ -341,6 +341,7 @@341341     }342342 343343     suspend fun savePin(masterPassword: String, pin: String): String? {344+        if (!pinUnlock.isSupported()) return "PIN unlock requires Android hardware limited-use key support"344345         if (masterPassword.isBlank()) return "enter your master password"345346         pinValidationError(pin)?.let { return it }346347         val masterMatches = withContext(Dispatchers.Default) {

view file

app/src/main/java/com/peachpasswords/vault/PinUnlockStore.kt+287 −31
@@ -1,8 +1,24 @@11 package com.peachpasswords.vault22 33 import android.content.Context4+import android.content.pm.PackageManager5+import android.os.Build6+import android.security.keystore.KeyGenParameterSpec7+import android.security.keystore.KeyInfo8+import android.security.keystore.KeyPermanentlyInvalidatedException9+import android.security.keystore.KeyProperties10+import android.security.keystore.StrongBoxUnavailableException411 import dagger.hilt.android.qualifiers.ApplicationContext12+import java.security.KeyStore13+import java.security.MessageDigest514 import java.util.Base6415+import java.util.UUID16+import javax.crypto.Cipher17+import javax.crypto.KeyGenerator18+import javax.crypto.Mac19+import javax.crypto.SecretKey20+import javax.crypto.SecretKeyFactory21+import javax.crypto.spec.GCMParameterSpec622 import javax.inject.Inject723 import javax.inject.Singleton824 @@ -15,15 +31,40 @@1531     else -> null1632 }1733 18-/** Device-local Argon2id PIN key and AES-GCM-wrapped copy of the live vault key. */34+/**35+ * Device-local PIN gate for a vault key held by Android Keystore.36+ *37+ * The PIN is never used to encrypt the vault key. A hardware-backed AES key38+ * encrypts the vault key directly, while a separate hardware-backed HMAC key39+ * verifies Argon2id(PIN). Every attempt first consumes one single-use HMAC gate40+ * in Keystore. The salt, verifier, IV, and ciphertext in SharedPreferences are41+ * therefore useless for offline guessing without the device's Keystore keys.42+ */1943 @Singleton2044 class PinUnlockStore @Inject constructor(2145     @ApplicationContext context: Context,2246 ) {23-    private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)47+    private val appContext = context.applicationContext48+    private val prefs = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)2449 50+    init {51+        // The v1 wrapper was directly brute-forceable from a copied prefs file.52+        // Remove it as soon as the security-remediated app opens the store; users53+        // can still unlock with the master password and configure the new format.54+        clearLegacyConfiguration()55+    }56+57+    /** New PIN setup is safe only where Android reports hardware limited-use keys. */58+    fun isSupported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&59+        (appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_KEYSTORE_LIMITED_USE_KEY) ||60+            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_KEYSTORE_SINGLE_USE_KEY))61+2562     fun isConfigured(): Boolean =26-        prefs.contains(KEY_SALT) && prefs.contains(KEY_WRAPPED_KEY)63+        prefs.getInt(KEY_FORMAT, 0) == FORMAT_VERSION &&64+            prefs.contains(KEY_SALT) &&65+            prefs.contains(KEY_VERIFIER) &&66+            prefs.contains(KEY_WRAPPED_KEY) &&67+            prefs.contains(KEY_WRAPPED_IV)2768 2869     fun isPinPreferred(): Boolean = prefs.getBoolean(KEY_PREFERRED, false)2970 @@ -36,58 +77,273 @@3677     }3778 3879     fun configure(pin: String, vaultKey: ByteArray): Boolean {39-        if (pinValidationError(pin) != null) return false40-        val salt = VaultCrypto.generateSalt()41-        val pinKey = VaultCrypto.deriveKey(pin, salt)80+        if (!isSupported() || pinValidationError(pin) != null || vaultKey.size != VaultCrypto.KEY_SIZE) return false81+        clear()82+83+        var salt: ByteArray? = null84+        var pinKey: ByteArray? = null85+        var verifier: ByteArray? = null86+        var wrapped: ByteArray? = null87+        val createdAliases = mutableListOf<String>()4288         return try {43-            val wrapped = wrap(vaultKey, pinKey) ?: return false44-            prefs.edit()45-                .putString(KEY_SALT, Base64.getEncoder().withoutPadding().encodeToString(salt))46-                .putString(KEY_WRAPPED_KEY, wrapped)89+            val vaultKeystoreKey = generateAesKey(VAULT_ALIAS, createdAliases)90+            val verifierKeystoreKey = generateHmacKey(VERIFIER_ALIAS, limitedUse = false, createdAliases)91+            salt = VaultCrypto.generateSalt()92+            pinKey = VaultCrypto.deriveKey(pin, salt)93+            verifier = hmac(verifierKeystoreKey, pinKey)94+95+            val cipher = Cipher.getInstance(TRANSFORM).apply { init(Cipher.ENCRYPT_MODE, vaultKeystoreKey) }96+            wrapped = cipher.doFinal(vaultKey)97+            createAttemptGates(createdAliases)98+99+            val saved = prefs.edit()100+                .putInt(KEY_FORMAT, FORMAT_VERSION)101+                .putString(KEY_SALT, b64(salt!!))102+                .putString(KEY_VERIFIER, b64(verifier!!))103+                .putString(KEY_WRAPPED_KEY, b64(wrapped!!))104+                .putString(KEY_WRAPPED_IV, b64(cipher.iv))105+                .putBoolean(KEY_PREFERRED, false)47106                 .commit()107+            if (!saved) throw java.io.IOException("pin_storage_commit_failed")108+            true109+        } catch (_: Exception) {110+            clear()111+            false48112         } finally {49-            VaultCrypto.zero(salt)50-            VaultCrypto.zero(pinKey)113+            salt?.let(VaultCrypto::zero)114+            pinKey?.let(VaultCrypto::zero)115+            verifier?.let(VaultCrypto::zero)116+            wrapped?.let(VaultCrypto::zero)51117         }52118     }53119 120+    /**121+     * Returns the vault key only after a hardware gate has been consumed and the122+     * hardware HMAC verifier accepts the Argon2id-derived PIN key.123+     */54124     fun unlockVaultKey(pin: String): ByteArray? {55-        if (pinValidationError(pin) != null) return null125+        if (pinValidationError(pin) != null || !isConfigured() || !consumeAttemptGate()) return null56126         val salt = prefs.getString(KEY_SALT, null)?.let(::decode) ?: return null57-        val blob = prefs.getString(KEY_WRAPPED_KEY, null)?.let(::decode) ?: return null58-        val pinKey = VaultCrypto.deriveKey(pin, salt)127+        val expected = prefs.getString(KEY_VERIFIER, null)?.let(::decode) ?: return null128+        var pinKey: ByteArray? = null129+        var actual: ByteArray? = null59130         return try {60-            unwrap(blob, pinKey)131+            val verifierKey = existingSecretKey(VERIFIER_ALIAS) ?: return null132+            pinKey = VaultCrypto.deriveKey(pin, salt)133+            actual = hmac(verifierKey, pinKey)134+            if (!MessageDigest.isEqual(expected, actual)) return null135+136+            val wrapped = prefs.getString(KEY_WRAPPED_KEY, null)?.let(::decode) ?: return null137+            val iv = prefs.getString(KEY_WRAPPED_IV, null)?.let(::decode) ?: return null138+            val vaultKey = Cipher.getInstance(TRANSFORM).run {139+                init(Cipher.DECRYPT_MODE, existingSecretKey(VAULT_ALIAS) ?: return null, GCMParameterSpec(TAG_BITS, iv))140+                doFinal(wrapped)141+            }142+            // A successful PIN proves possession of the PIN; replenish the143+            // hardware attempt budget for the next lock/unlock cycle.144+            replenishAttemptGates()145+            vaultKey146+        } catch (_: Exception) {147+            null61148         } finally {62149             VaultCrypto.zero(salt)63-            VaultCrypto.zero(blob)64-            VaultCrypto.zero(pinKey)150+            VaultCrypto.zero(expected)151+            pinKey?.let(VaultCrypto::zero)152+            actual?.let(VaultCrypto::zero)65153         }66154     }67155 156+    fun areAttemptsExhausted(): Boolean = isConfigured() && loadAttemptAliases().isEmpty()157+158+    /** Regenerate the hardware attempt budget after a master-password unlock. */159+    fun replenishAttemptGates() {160+        if (!isConfigured()) return161+        val oldAliases = loadAttemptAliases()162+        val newAliases = mutableListOf<String>()163+        try {164+            createAttemptGates(newAliases)165+            if (!prefs.edit().putString(KEY_ATTEMPT_ALIASES, newAliases.joinToString(DELIMITER)).commit()) {166+                throw java.io.IOException("pin_attempt_gate_commit_failed")167+            }168+            oldAliases.forEach(::deleteKey)169+        } catch (_: Exception) {170+            newAliases.forEach(::deleteKey)171+        }172+    }173+68174     fun clear() {69-        prefs.edit().remove(KEY_SALT).remove(KEY_WRAPPED_KEY).remove(KEY_PREFERRED).commit()175+        loadAttemptAliases().forEach(::deleteKey)176+        listOf(VAULT_ALIAS, VERIFIER_ALIAS).forEach(::deleteKey)177+        clearLegacyConfiguration()178+        prefs.edit()179+            .remove(KEY_FORMAT)180+            .remove(KEY_SALT)181+            .remove(KEY_VERIFIER)182+            .remove(KEY_WRAPPED_KEY)183+            .remove(KEY_WRAPPED_IV)184+            .remove(KEY_ATTEMPT_ALIASES)185+            .remove(KEY_PREFERRED)186+            .commit()70187     }71188 72-    private fun wrap(vaultKey: ByteArray, pinKey: ByteArray): String? = runCatching {73-        val blob = VaultCrypto.encrypt(vaultKey, pinKey, AAD)74-        try {75-            Base64.getEncoder().withoutPadding().encodeToString(blob)76-        } finally {77-            VaultCrypto.zero(blob)189+    private fun consumeAttemptGate(): Boolean {190+        val aliases = loadAttemptAliases()191+        for (alias in aliases) {192+            val key = existingSecretKey(alias) ?: continue193+            try {194+                // The output is intentionally discarded; the operation exists to195+                // consume the one-use hardware authorization before KDF work.196+                hmac(key, ATTEMPT_CHALLENGE)197+                deleteKey(alias)198+                prefs.edit().putString(199+                    KEY_ATTEMPT_ALIASES,200+                    aliases.filterNot { it == alias }.joinToString(DELIMITER),201+                ).commit()202+                return true203+            } catch (_: KeyPermanentlyInvalidatedException) {204+                deleteKey(alias)205+            } catch (_: Exception) {206+                deleteKey(alias)207+            }78208         }209+        return false210+    }211+212+    private fun createAttemptGates(createdAliases: MutableList<String>) {213+        repeat(ATTEMPT_GATE_COUNT) {214+            val alias = "$ATTEMPT_ALIAS_PREFIX${UUID.randomUUID()}"215+            generateHmacKey(alias, limitedUse = true, createdAliases)216+        }217+        val aliases = createdAliases.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }218+        if (aliases.size != ATTEMPT_GATE_COUNT) throw java.io.IOException("pin_attempt_gate_count")219+        prefs.edit().putString(KEY_ATTEMPT_ALIASES, aliases.joinToString(DELIMITER)).commit()220+    }221+222+    @Suppress("NewApi")223+    private fun generateAesKey(alias: String, createdAliases: MutableList<String>): SecretKey {224+        existingSecretKey(alias)?.let { key ->225+            if (isHardwareBacked(key)) return key226+            deleteKey(alias)227+        }228+        val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&229+            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)230+        val key = try {231+            generateAesKey(alias, strongBoxPreferred)232+        } catch (_: StrongBoxUnavailableException) {233+            deleteKey(alias)234+            generateAesKey(alias, false)235+        }236+        if (!isHardwareBacked(key)) {237+            deleteKey(alias)238+            throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")239+        }240+        createdAliases += alias241+        return key242+    }243+244+    @Suppress("NewApi")245+    private fun generateHmacKey(alias: String, limitedUse: Boolean, createdAliases: MutableList<String>): SecretKey {246+        val strongBoxPreferred = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&247+            appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)248+        val key = try {249+            generateHmacKey(alias, limitedUse, strongBoxPreferred)250+        } catch (_: StrongBoxUnavailableException) {251+            deleteKey(alias)252+            generateHmacKey(alias, limitedUse, false)253+        }254+        if (!isHardwareBacked(key)) {255+            deleteKey(alias)256+            throw java.security.GeneralSecurityException("pin_key_not_hardware_backed")257+        }258+        createdAliases += alias259+        return key260+    }261+262+    private fun generateAesKey(alias: String, strongBox: Boolean): SecretKey {263+        val builder = KeyGenParameterSpec.Builder(264+            alias,265+            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,266+        )267+            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)268+            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)269+            .setKeySize(256)270+        if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)271+        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)272+        generator.init(builder.build())273+        return generator.generateKey()274+    }275+276+    private fun generateHmacKey(alias: String, limitedUse: Boolean, strongBox: Boolean): SecretKey {277+        val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)278+            .setDigests(KeyProperties.DIGEST_SHA256)279+            .setKeySize(256)280+        if (limitedUse && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) builder.setMaxUsageCount(1)281+        if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)282+        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE)283+        generator.init(builder.build())284+        return generator.generateKey()285+    }286+287+    private fun hmac(key: SecretKey, input: ByteArray): ByteArray =288+        Mac.getInstance("HmacSHA256").apply { init(key) }.doFinal(input)289+290+    private fun existingSecretKey(alias: String): SecretKey? = runCatching {291+        val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }292+        (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey79293     }.getOrNull()80294 81-    private fun unwrap(blob: ByteArray, pinKey: ByteArray): ByteArray? =82-        runCatching { VaultCrypto.decrypt(blob, pinKey, AAD) }.getOrNull()295+    private fun isHardwareBacked(key: SecretKey): Boolean = runCatching {296+        val factory = SecretKeyFactory.getInstance(key.algorithm, KEYSTORE)297+        val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo298+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {299+            info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ||300+                info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX301+        } else {302+            @Suppress("DEPRECATION")303+            info.isInsideSecureHardware304+        }305+    }.getOrDefault(false)83306 307+    private fun loadAttemptAliases(): List<String> =308+        prefs.getString(KEY_ATTEMPT_ALIASES, null)309+            ?.split(DELIMITER)310+            ?.filter { it.startsWith(ATTEMPT_ALIAS_PREFIX) }311+            ?: emptyList()312+313+    private fun deleteKey(alias: String) {314+        runCatching { KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(alias) }315+    }316+317+    private fun clearLegacyConfiguration() {318+        prefs.edit()319+            .remove("peach_pin_salt")320+            .remove("peach_pin_verifier")321+            .remove("peach_pin_wrapped_key")322+            .remove("peach_pin_preferred")323+            .commit()324+    }325+326+    private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)84327     private fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)85328 86329     private companion object {330+        const val FORMAT_VERSION = 287331         const val PREFS = "peach_vault_store"88-        const val KEY_SALT = "peach_pin_salt"89-        const val KEY_WRAPPED_KEY = "peach_pin_wrapped_key"90-        const val KEY_PREFERRED = "peach_pin_preferred"91-        const val AAD = "pin_unlock_v1"332+        const val KEY_FORMAT = "peach_pin_format"333+        const val KEY_SALT = "peach_pin_salt_v2"334+        const val KEY_VERIFIER = "peach_pin_verifier_v2"335+        const val KEY_WRAPPED_KEY = "peach_pin_wrapped_key_v2"336+        const val KEY_WRAPPED_IV = "peach_pin_wrapped_iv_v2"337+        const val KEY_ATTEMPT_ALIASES = "peach_pin_attempt_aliases_v2"338+        const val KEY_PREFERRED = "peach_pin_preferred_v2"339+        const val KEYSTORE = "AndroidKeyStore"340+        const val VAULT_ALIAS = "peach_pin_vault_key_v2"341+        const val VERIFIER_ALIAS = "peach_pin_verifier_key_v2"342+        const val ATTEMPT_ALIAS_PREFIX = "peach_pin_attempt_v2_"343+        const val ATTEMPT_GATE_COUNT = 5344+        const val TRANSFORM = "AES/GCM/NoPadding"345+        const val TAG_BITS = 128346+        const val DELIMITER = ","347+        val ATTEMPT_CHALLENGE = "peach_pin_attempt_gate_v2".toByteArray(Charsets.UTF_8)92348     }93349 }

view file

app/src/test/java/com/peachpasswords/vault/PinUnlockStoreTest.kt+17 −0
@@ -1,7 +1,10 @@11 package com.peachpasswords.vault22 3+import java.io.File34 import org.junit.Assert.assertEquals5+import org.junit.Assert.assertFalse46 import org.junit.Assert.assertNull7+import org.junit.Assert.assertTrue58 import org.junit.Test69 710 class PinUnlockStoreTest {@@ -10,5 +13,19 @@1013         assertEquals("PIN must contain digits only", pinValidationError("12a4"))1114         assertNull(pinValidationError("1234"))1215         assertNull(pinValidationError("123456789012"))16+    }17+18+    @Test fun pin_store_uses_hardware_envelope_and_limited_use_attempt_gates() {19+        val relativePath = "app/src/main/java/com/peachpasswords/vault/PinUnlockStore.kt"20+        val source = (File(relativePath).takeIf { it.exists() } ?: File("../$relativePath")).readText()21+22+        assertTrue(source.contains("AndroidKeyStore"))23+        assertTrue(source.contains("KeyProperties.KEY_ALGORITHM_HMAC_SHA256"))24+        assertTrue(source.contains("setMaxUsageCount(1)"))25+        assertTrue(source.contains("setIsStrongBoxBacked(true)"))26+        assertTrue(source.contains("StrongBoxUnavailableException"))27+        assertTrue(source.contains("KeyInfo"))28+        assertFalse(source.contains("VaultCrypto.encrypt(vaultKey, pinKey"))29+        assertFalse(source.contains("wrap(vaultKey, pinKey)"))1330     }1431 }

view file