55414a0ad11b

fix: harden PIN storage against copied-app offline brute force (v2.14.68)

time
2026-08-30 11:59 UTC · 1 month ago
machine
koh2
parent
715c2bf666ce
2 files VERSION+1 −1app/src/main/java/com/peachpasswords/vault/PinUnlockStore.kt+29 −11
VERSION+1 −1
@@ -1 +1 @@1-2.14.671+2.14.68

view file

app/src/main/java/com/peachpasswords/vault/PinUnlockStore.kt+29 −11
@@ -52,6 +52,7 @@5252         // Remove it as soon as the security-remediated app opens the store; users5353         // can still unlock with the master password and configure the new format.5454         clearLegacyConfiguration()55+        if (prefs.getInt(KEY_FORMAT, 0) != FORMAT_VERSION) clearKnownKeystoreAliases()5556     }5657 5758     /** New PIN setup is safe only where Android reports hardware limited-use keys. */@@ -64,7 +65,9 @@6465             prefs.contains(KEY_SALT) &&6566             prefs.contains(KEY_VERIFIER) &&6667             prefs.contains(KEY_WRAPPED_KEY) &&67-            prefs.contains(KEY_WRAPPED_IV)68+            prefs.contains(KEY_WRAPPED_IV) &&69+            existingSecretKey(VAULT_ALIAS) != null &&70+            existingSecretKey(VERIFIER_ALIAS) != null6871 6972     fun isPinPreferred(): Boolean = prefs.getBoolean(KEY_PREFERRED, false)7073 @@ -125,21 +128,25 @@125128      */126129     fun unlockVaultKey(pin: String): ByteArray? {127130         if (pinValidationError(pin) != null || !isConfigured() || !consumeAttemptGate()) return null128-        val salt = prefs.getString(KEY_SALT, null)?.let(::decode) ?: return null129-        val expected = prefs.getString(KEY_VERIFIER, null)?.let(::decode) ?: return null131+        var salt: ByteArray? = null132+        var expected: ByteArray? = null130133         var pinKey: ByteArray? = null131134         var actual: ByteArray? = null135+        var wrapped: ByteArray? = null136+        var iv: ByteArray? = null132137         return try {138+            salt = prefs.getString(KEY_SALT, null)?.let(::decode) ?: return null139+            expected = prefs.getString(KEY_VERIFIER, null)?.let(::decode) ?: return null133140             val verifierKey = existingSecretKey(VERIFIER_ALIAS) ?: return null134-            pinKey = VaultCrypto.deriveKey(pin, salt)141+            pinKey = VaultCrypto.deriveKey(pin, salt!!)135142             actual = hmac(verifierKey, pinKey)136143             if (!MessageDigest.isEqual(expected, actual)) return null137144 138-            val wrapped = prefs.getString(KEY_WRAPPED_KEY, null)?.let(::decode) ?: return null139-            val iv = prefs.getString(KEY_WRAPPED_IV, null)?.let(::decode) ?: return null145+            wrapped = prefs.getString(KEY_WRAPPED_KEY, null)?.let(::decode) ?: return null146+            iv = prefs.getString(KEY_WRAPPED_IV, null)?.let(::decode) ?: return null140147             val vaultKey = Cipher.getInstance(TRANSFORM).run {141-                init(Cipher.DECRYPT_MODE, existingSecretKey(VAULT_ALIAS) ?: return null, GCMParameterSpec(TAG_BITS, iv))142-                doFinal(wrapped)148+                init(Cipher.DECRYPT_MODE, existingSecretKey(VAULT_ALIAS) ?: return null, GCMParameterSpec(TAG_BITS, iv!!))149+                doFinal(wrapped!!)143150             }144151             // A successful PIN proves possession of the PIN; replenish the145152             // hardware attempt budget for the next lock/unlock cycle.@@ -148,14 +155,17 @@148155         } catch (_: Exception) {149156             null150157         } finally {151-            VaultCrypto.zero(salt)152-            VaultCrypto.zero(expected)158+            salt?.let(VaultCrypto::zero)159+            expected?.let(VaultCrypto::zero)153160             pinKey?.let(VaultCrypto::zero)154161             actual?.let(VaultCrypto::zero)162+            wrapped?.let(VaultCrypto::zero)163+            iv?.let(VaultCrypto::zero)155164         }156165     }157166 158-    fun areAttemptsExhausted(): Boolean = isConfigured() && loadAttemptAliases().isEmpty()167+    fun areAttemptsExhausted(): Boolean = isConfigured() &&168+        loadAttemptAliases().none { existingSecretKey(it) != null }159169 160170     /** Regenerate the hardware attempt budget after a master-password unlock. */161171     fun replenishAttemptGates() {@@ -314,6 +324,14 @@314324 315325     private fun deleteKey(alias: String) {316326         runCatching { KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(alias) }327+    }328+329+    private fun clearKnownKeystoreAliases() {330+        runCatching {331+            KeyStore.getInstance(KEYSTORE).apply { load(null) }.aliases().toList()332+                .filter { it == VAULT_ALIAS || it == VERIFIER_ALIAS || it.startsWith(ATTEMPT_ALIAS_PREFIX) }333+                .forEach(::deleteKey)334+        }317335     }318336 319337     private fun clearLegacyConfiguration() {

view file