31ab56461db7

H-05: FLAG_SECURE no longer cleared in release; separate secret activities set it themselves

time
2026-07-29 13:17 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
77e71ef26cba
4 files app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+12 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+10 −0app/src/main/java/com/peachpasswords/ui/components/SecureFlagEffect.kt+11 −2app/src/test/java/com/peachpasswords/security/SecureFlagCoverageTest.kt+75 −15
app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+12 −0
@@ -2,6 +2,7 @@22 33 import android.content.Intent44 import android.os.Bundle5+import android.view.WindowManager56 import android.view.autofill.AutofillId67 import android.view.autofill.AutofillManager78 import androidx.activity.compose.setContent@@ -44,6 +45,7 @@4445 import androidx.compose.ui.unit.sp4546 import androidx.fragment.app.FragmentActivity4647 import androidx.lifecycle.lifecycleScope48+import com.peachpasswords.BuildConfig4749 import com.peachpasswords.MainActivity4850 import com.peachpasswords.shared.extractDomain4951 import com.peachpasswords.ui.lock.BiometricKeyStore@@ -92,6 +94,16 @@9294     @OptIn(ExperimentalMaterial3Api::class)9395     override fun onCreate(savedInstanceState: Bundle?) {9496         super.onCreate(savedInstanceState)97+98+        // H-05: separate window from MainActivity, so it does NOT inherit the99+        // release-wide FLAG_SECURE set there. Show vault metadata and accept the100+        // master password — protect against screenshots / recents thumbnails.101+        if (!BuildConfig.DEBUG) {102+            window.setFlags(103+                WindowManager.LayoutParams.FLAG_SECURE,104+                WindowManager.LayoutParams.FLAG_SECURE,105+            )106+        }95107 96108         val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN)97109         val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false)

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+10 −0
@@ -5,6 +5,7 @@55 import android.content.Intent66 import android.os.Build77 import android.os.Bundle8+import android.view.WindowManager89 import androidx.activity.ComponentActivity910 import androidx.credentials.GetCredentialResponse1011 import androidx.credentials.GetPublicKeyCredentialOption@@ -14,6 +15,7 @@1415 import androidx.credentials.exceptions.CreateCredentialUnsupportedException1516 import androidx.credentials.exceptions.GetCredentialUnsupportedException1617 import androidx.credentials.provider.PendingIntentHandler18+import com.peachpasswords.BuildConfig1719 import com.peachpasswords.shared.extractDomain1820 import androidx.lifecycle.lifecycleScope1921 import com.peachpasswords.vault.VaultRepository@@ -28,6 +30,14 @@2830 2931     override fun onCreate(savedInstanceState: Bundle?) {3032         super.onCreate(savedInstanceState)33+        // H-05: separate window from MainActivity — sign assertion and registration34+        // operate on passkey material. Block screenshots / recents in release.35+        if (!BuildConfig.DEBUG) {36+            window.setFlags(37+                WindowManager.LayoutParams.FLAG_SECURE,38+                WindowManager.LayoutParams.FLAG_SECURE,39+            )40+        }3141         lifecycleScope.launch { completeSelection() }3242     }3343 

view file

app/src/main/java/com/peachpasswords/ui/components/SecureFlagEffect.kt+11 −2
@@ -26,7 +26,16 @@2626             return@DisposableEffect onDispose { }2727         }2828         val window = (context as? FragmentActivity)?.window29-        window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)30-        onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }29+        if (BuildConfig.DEBUG) {30+            // Debug: MainActivity does not set the flag, so the effect owns it31+            // for the lifetime of the secret-bearing composable.32+            window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)33+        }34+        // Release: MainActivity already set FLAG_SECURE release-wide. The effect35+        // must not clear it on dispose — H-05 found that clearing stripped the36+        // activity-wide protection from every screen visited afterward.37+        onDispose {38+            if (BuildConfig.DEBUG) window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)39+        }3140     }3241 }

view file

app/src/test/java/com/peachpasswords/security/SecureFlagCoverageTest.kt+75 −15
@@ -1,28 +1,88 @@11 package com.peachpasswords.security22 3-import java.io.File3+import javax.xml.parsers.DocumentBuilderFactory44 import org.junit.Assert.assertTrue55 import org.junit.Test6+import org.w3c.dom.Element67 78 class SecureFlagCoverageTest {8-    @Test fun secret_surfaces_keep_flag_secure_coverage() {9-        val protectedScreens = listOf(10-            "app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt",11-            "app/src/main/java/com/peachpasswords/ui/vault/EntryEditScreen.kt",12-            "app/src/main/java/com/peachpasswords/ui/vault/NotesModeScreen.kt",13-            "app/src/main/java/com/peachpasswords/ui/vault/ExportScreen.kt",14-            "app/src/main/java/com/peachpasswords/ui/vault/RecoveryWordsScreen.kt",15-            "app/src/main/java/com/peachpasswords/ui/recovery/RecoveryWordEntryScreen.kt",16-            "app/src/main/java/com/peachpasswords/ui/codex/CodexGenerateScreen.kt",17-        )189 19-        protectedScreens.forEach { relativePath ->20-            val file = File(relativePath).takeIf { it.exists() } ?: File("../$relativePath")10+    // Compose screens that surface secret material. They live in the MainActivity11+    // window, where FLAG_SECURE is already set release-wide, but they must also12+    // enforce it during the lifetime of the screen in debug builds — which is13+    // what SecureFlagEffect() does. (H-05.)14+    private val secretScreens = listOf(15+        "app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt",16+        "app/src/main/java/com/peachpasswords/ui/vault/EntryEditScreen.kt",17+        "app/src/main/java/com/peachpasswords/ui/vault/NotesModeScreen.kt",18+        "app/src/main/java/com/peachpasswords/ui/vault/ExportScreen.kt",19+        "app/src/main/java/com/peachpasswords/ui/vault/RecoveryWordsScreen.kt",20+        "app/src/main/java/com/peachpasswords/ui/recovery/RecoveryWordEntryScreen.kt",21+        "app/src/main/java/com/peachpasswords/ui/codex/CodexGenerateScreen.kt",22+    )23+24+    // Activities whose own window is separate from MainActivity (overlay / system25+    // API surfaces). They do NOT inherit FLAG_SECURE from MainActivity, so each26+    // one must set it directly in its own onCreate. (H-05.)27+    private val secretActivities = listOf(28+        "com.peachpasswords.autofill.AutofillEntryPickerActivity",29+        "com.peachpasswords.passkeys.PasskeyCredentialSelectionActivity",30+    )31+32+    @Test fun secret_screens_keep_flag_secure_coverage() {33+        secretScreens.forEach { relativePath ->34+            val file = java.io.File(relativePath).takeIf { it.exists() }35+                ?: java.io.File("../$relativePath")2136             val source = file.readText()2237             assertTrue(2338                 "$relativePath must protect screenshots while visible",24-                source.contains("SecureFlagEffect()") || source.contains("WindowManager.LayoutParams.FLAG_SECURE"),39+                source.contains("SecureFlagEffect()") ||40+                    source.contains("WindowManager.LayoutParams.FLAG_SECURE"),2541             )2642         }2743     }28-}44+45+    @Test fun secret_bearing_activities_set_flag_secure() {46+        // Every secret-bearing activity listed in the manifest must set47+        // FLAG_SECURE itself — MainActivity's release-wide set does not reach48+        // these windows. (H-05.)49+        val manifestActivities = parseActivityNames(50+            "app/src/main/AndroidManifest.xml",51+        )52+        secretActivities.forEach { fqcn ->53+            assertTrue(54+                "$fqcn must be declared in the manifest",55+                manifestActivities.contains(fqcn),56+            )57+            val relativePath = fqcnToSourcePath(fqcn)58+            val file = java.io.File(relativePath).takeIf { it.exists() }59+                ?: java.io.File("../$relativePath")60+            val source = file.readText()61+            assertTrue(62+                "$fqcn must set WindowManager.LayoutParams.FLAG_SECURE " +63+                    "(separate window from MainActivity)",64+                source.contains("WindowManager.LayoutParams.FLAG_SECURE"),65+            )66+        }67+    }68+69+    private fun parseActivityNames(relativePath: String): Set<String> {70+        val file = java.io.File(relativePath).takeIf { it.exists() }71+            ?: java.io.File("../$relativePath")72+        val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()73+            .parse(file)74+        val activities = doc.getElementsByTagName("activity")75+        return (0 until activities.length)76+            .mapNotNull { (activities.item(it) as? Element)?.getAttribute("android:name") }77+            .toSet()78+    }79+80+    // Activities in the manifest are declared as ".Foo" (relative to the81+    // application package). The activity source lives at the matching package82+    // path under app/src/main/java.83+    private fun fqcnToSourcePath(fqcn: String): String {84+        val pkg = fqcn.substringBeforeLast('.')85+        val cls = fqcn.substringAfterLast('.')86+        return "app/src/main/java/" + pkg.replace('.', '/') + "/$cls.kt"87+    }88+}\ No newline at end of file

view file