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
↓ peach-android-76e044ae.tar.gzatlas at this save
files changed5 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 −0app/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 {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) {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 }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 }