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
↓ peach-android-8160d788.tar.gzatlas at this save
files changed8 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 −0app/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 jniLibsapp/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)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>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>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+}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+}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 }