8160d7880ead

fix: backup exclusions, biometric hardware-tier check, Bouncy Castle upgrade, remove secret logging (audit PB-26-021 through PB-26-024) (v2.14.72)

time
2026-08-31 00:03 UTC · 1 month ago
machine
koh2
agent
GPT-5
session
PEACH_ANDROID_INDEPENDENT_AUDIT_2026-08-30
parent
240e63941c1e
8 files VERSION+1 −1app/build.gradle+1 −1app/src/main/java/com/peachpasswords/ui/lock/BiometricKeyStore.kt+52 −8app/src/main/res/xml/backup_rules.xml+2 −1app/src/main/res/xml/data_extraction_rules.xml+3 −1app/src/test/java/com/peachpasswords/autofill/PeachAutofillLoggingTest.kt+18 −0app/src/test/java/com/peachpasswords/backup/BackupRulesCoverageTest.kt+43 −0app/src/test/java/com/peachpasswords/ui/lock/BiometricKeyStoreCoverageTest.kt+4 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.711+2.14.72

view file

app/build.gradle+1 −1
@@ -186,7 +186,7 @@186186     implementation "androidx.camera:camera-lifecycle:1.3.4"187187     implementation "androidx.camera:camera-view:1.3.4"188188     implementation "com.squareup.okhttp3:okhttp:4.12.0"189-    implementation "org.bouncycastle:bcprov-jdk18on:1.78.1"189+    implementation "org.bouncycastle:bcprov-jdk18on:1.84"190190     // zstd-jni (reference libzstd) — per-section compression for the PeachScript191191     // PCHS binary format (MF-2). Required for cross-platform interop with the192192     // browser's zstd sections. The @aar variant ships the Android jniLibs

view file

app/src/main/java/com/peachpasswords/ui/lock/BiometricKeyStore.kt+52 −8
@@ -3,13 +3,17 @@33 import android.content.Context44 import android.os.Build55 import android.security.keystore.KeyGenParameterSpec6+import android.security.keystore.KeyInfo67 import android.security.keystore.KeyPermanentlyInvalidatedException78 import android.security.keystore.KeyProperties9+import android.security.keystore.StrongBoxUnavailableException810 import java.security.KeyStore11+import java.security.GeneralSecurityException912 import java.util.Base641013 import javax.crypto.Cipher1114 import javax.crypto.KeyGenerator1215 import javax.crypto.SecretKey16+import javax.crypto.SecretKeyFactory1317 import javax.crypto.spec.GCMParameterSpec1418 1519 /**@@ -39,7 +43,8 @@3943         context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)4044 4145     fun hasWrappedKey(context: Context): Boolean =42-        prefs(context).contains(K_WRAPPED) && prefs(context).contains(K_IV)46+        prefs(context).contains(K_WRAPPED) && prefs(context).contains(K_IV) &&47+            (runCatching { existingKey()?.let(::isHardwareBacked) == true }.getOrDefault(false))4348 4449     fun clear(context: Context) {4550         prefs(context).edit().remove(K_WRAPPED).remove(K_IV).apply()@@ -62,7 +67,7 @@6267     fun decryptCipher(context: Context): Cipher? {6368         val iv = loadIv(context) ?: return null6469         return try {65-            val key = existingKey() ?: return null70+            val key = existingKey()?.takeIf(::isHardwareBacked) ?: return null6671             Cipher.getInstance(TRANSFORM).apply { init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv)) }6772         } catch (_: KeyPermanentlyInvalidatedException) {6873             clear(context)@@ -90,13 +95,12 @@9095     private fun loadIv(context: Context): ByteArray? =9196         prefs(context).getString(K_IV, null)?.let(::unb64)9297 93-    private fun existingKey(): SecretKey? {94-        val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }95-        return (ks.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey96-    }97-98+    @Suppress("NewApi")9899     private fun getOrCreateKey(): SecretKey {99-        existingKey()?.let { return it }100+        existingKey()?.let { key ->101+            if (isHardwareBacked(key)) return key102+            deleteKey()103+        }100104         val builder = KeyGenParameterSpec.Builder(101105             KEY_ALIAS,102106             KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,@@ -109,9 +113,49 @@109113         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {110114             builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)111115         }116+        val key = try {117+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {118+                builder.setIsStrongBoxBacked(true)119+            }120+            generateKey(builder)121+        } catch (_: StrongBoxUnavailableException) {122+            generateKey(builder, strongBox = false)123+        }124+        if (!isHardwareBacked(key)) {125+            deleteKey()126+            throw GeneralSecurityException("biometric_key_not_hardware_backed")127+        }128+        return key129+    }130+131+    private fun generateKey(builder: KeyGenParameterSpec.Builder, strongBox: Boolean = true): SecretKey {132+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !strongBox) {133+            builder.setIsStrongBoxBacked(false)134+        }112135         val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)113136         gen.init(builder.build())114137         return gen.generateKey()138+    }139+140+    private fun existingKey(): SecretKey? {141+        val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }142+        return (ks.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey143+    }144+145+    private fun isHardwareBacked(key: SecretKey): Boolean = runCatching {146+        val info = SecretKeyFactory.getInstance(key.algorithm, KEYSTORE)147+            .getKeySpec(key, KeyInfo::class.java) as KeyInfo148+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {149+            info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ||150+                info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX151+        } else {152+            @Suppress("DEPRECATION")153+            info.isInsideSecureHardware154+        }155+    }.getOrDefault(false)156+157+    private fun deleteKey() {158+        runCatching { KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS) }115159     }116160 117161     private fun b64(b: ByteArray) = Base64.getEncoder().encodeToString(b)

view file

app/src/main/res/xml/backup_rules.xml+2 −1
@@ -22,4 +22,5 @@2222     <exclude domain="sharedpref" path="peach_sync_outcome.xml" />2323     <exclude domain="sharedpref" path="peach_paired_devices.xml" />2424     <exclude domain="sharedpref" path="peach_sync_alerts.xml" />25-</full-backup-content>\ No newline at end of file25+    <exclude domain="sharedpref" path="peach_locked_sync_spool_v1.xml" />26+</full-backup-content>

view file

app/src/main/res/xml/data_extraction_rules.xml+3 −1
@@ -23,6 +23,7 @@2323         <exclude domain="sharedpref" path="peach_sync_outcome.xml" />2424         <exclude domain="sharedpref" path="peach_paired_devices.xml" />2525         <exclude domain="sharedpref" path="peach_sync_alerts.xml" />26+        <exclude domain="sharedpref" path="peach_locked_sync_spool_v1.xml" />2627     </cloud-backup>2728     <device-transfer>2829         <exclude domain="sharedpref" path="peach_vault_store.xml" />@@ -42,5 +43,6 @@4243         <exclude domain="sharedpref" path="peach_sync_outcome.xml" />4344         <exclude domain="sharedpref" path="peach_paired_devices.xml" />4445         <exclude domain="sharedpref" path="peach_sync_alerts.xml" />46+        <exclude domain="sharedpref" path="peach_locked_sync_spool_v1.xml" />4547     </device-transfer>46-</data-extraction-rules>\ No newline at end of file48+</data-extraction-rules>

view file

app/src/test/java/com/peachpasswords/autofill/PeachAutofillLoggingTest.kt+18 −0
@@ -0,0 +1,18 @@1+package com.peachpasswords.autofill2+3+import java.io.File4+import org.junit.Assert.assertFalse5+import org.junit.Test6+7+class PeachAutofillLoggingTest {8+    @Test9+    fun service_logs_do_not_include_totp_or_entry_identity() {10+        val relativePath = "app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt"11+        val source = (File(relativePath).takeIf { it.exists() } ?: File("../$relativePath")).readText()12+13+        assertFalse(source.contains("PeachClipboard"))14+        assertFalse(source.contains("TOTPGenerator"))15+        assertFalse(source.contains("entry.title"))16+        assertFalse(source.contains("code="))17+    }18+}

view file

app/src/test/java/com/peachpasswords/backup/BackupRulesCoverageTest.kt+43 −0
@@ -0,0 +1,43 @@1+package com.peachpasswords.backup2+3+import java.io.File4+import javax.xml.parsers.DocumentBuilderFactory5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+/** Every declared SharedPreferences file must be excluded from both transfer paths. */9+class BackupRulesCoverageTest {10+    @Test11+    fun every_shared_preferences_filename_is_excluded_from_both_rule_files() {12+        val sourceRoot = projectFile("app/src/main/java")13+        val preferences = sourceRoot.walkTopDown()14+            .filter { it.isFile && it.extension == "kt" }15+            .flatMap { file -> PREFERENCE_DECLARATION.findAll(file.readText()).map { it.groupValues[1] } }16+            .map { "$it.xml" }17+            .toSet()18+        assertTrue("no SharedPreferences declarations were found", preferences.isNotEmpty())19+20+        val backup = excludedPaths(projectFile("app/src/main/res/xml/backup_rules.xml"))21+        val extraction = excludedPaths(projectFile("app/src/main/res/xml/data_extraction_rules.xml"))22+        preferences.forEach { filename ->23+            assertTrue("$filename is missing from backup_rules.xml", filename in backup)24+            assertTrue("$filename is missing from data_extraction_rules.xml", filename in extraction)25+        }26+    }27+28+    private fun excludedPaths(file: File): Set<String> {29+        val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)30+        return (0 until document.getElementsByTagName("exclude").length)31+            .map { index -> document.getElementsByTagName("exclude").item(index).attributes.getNamedItem("path").nodeValue }32+            .toSet()33+    }34+35+    private fun projectFile(relativePath: String): File =36+        File(relativePath).takeIf { it.exists() } ?: File("../$relativePath")37+38+    private companion object {39+        val PREFERENCE_DECLARATION = Regex(40+            "(?m)^\\s*(?:private\\s+|internal\\s+|public\\s+)?(?:const\\s+)?val\\s+\\w*PREFS\\w*\\s*=\\s*\"([^\"]+)\"",41+        )42+    }43+}

view file

app/src/test/java/com/peachpasswords/ui/lock/BiometricKeyStoreCoverageTest.kt+4 −0
@@ -14,5 +14,9 @@1414         assertTrue(source.contains(".setUserAuthenticationRequired(true)"))1515         assertTrue(source.contains(".setInvalidatedByBiometricEnrollment(true)"))1616         assertTrue(source.contains("KeyProperties.AUTH_BIOMETRIC_STRONG"))17+        assertTrue(source.contains("setIsStrongBoxBacked(true)"))18+        assertTrue(source.contains("KeyInfo"))19+        assertTrue(source.contains("securityLevel"))20+        assertTrue(source.contains("biometric_key_not_hardware_backed"))1721     }1822 }

view file