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
↓ peach-android-31ab5646.tar.gzatlas at this save
files changed4 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 −15app/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)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 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 }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