3b3fd73ab28f
fix: Remediate audit H-05/H-06/H-07 user-facing security claims (v2.8.6)
- time
- 2026-07-29 23:12 UTC · 2 months ago
- machine
- koh2
- parent
- 4bbb8467f10a
↓ peach-android-3b3fd73a.tar.gzatlas at this save
files changed23 files
VERSION+1 −1app/build.gradle+2 −1app/src/androidTest/java/com/peachpasswords/autofill/AutofillFlagSecureTest.kt+30 −15app/src/androidTest/java/com/peachpasswords/passkeys/PasskeySelectionFlagSecureTest.kt+53 −18app/src/main/java/com/peachpasswords/MainActivity.kt+10 −7app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+11 −1app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+60 −17app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+10 −7app/src/main/java/com/peachpasswords/autofill/TrustedBrowsers.kt+111 −16app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+15 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+78 −10app/src/main/java/com/peachpasswords/passkeys/PasskeyRegistration.kt+27 −8app/src/main/java/com/peachpasswords/shared/DomainUtils.kt+28 −2app/src/main/java/com/peachpasswords/ui/components/QaFlags.kt+2 −0app/src/main/java/com/peachpasswords/ui/components/SecureFlagEffect.kt+8 −13app/src/main/java/com/peachpasswords/ui/components/SecureWindowPolicy.kt+59 −0app/src/test/java/com/peachpasswords/autofill/AutofillMatchTest.kt+66 −0app/src/test/java/com/peachpasswords/autofill/AutofillPickerSearchTest.kt+30 −1app/src/test/java/com/peachpasswords/autofill/PickerOriginTrustTest.kt+98 −0app/src/test/java/com/peachpasswords/autofill/TrustedBrowsersTest.kt+213 −0app/src/test/java/com/peachpasswords/passkeys/PasskeyRegistrationTest.kt+30 −5app/src/test/java/com/peachpasswords/passkeys/PasskeyUvFailClosedTest.kt+41 −0app/src/test/java/com/peachpasswords/security/SecureFlagCoverageTest.kt+36 −3app/build.gradle+2 −1
@@ -32,7 +32,7 @@3232 minSdk 263333 targetSdk 353434 // Keep the Android code in Gradle; the semantic name comes from root VERSION.35- versionCode 24014735+ versionCode 2401483636 versionName semanticVersion3737 3838 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"@@ -193,6 +193,7 @@193193 debugImplementation "androidx.compose.ui:ui-test-manifest"194194 195195 androidTestImplementation "androidx.test.ext:junit:1.2.1"196+ androidTestImplementation "androidx.test:core:1.6.1"196197 androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1"197198 androidTestImplementation "androidx.compose.ui:ui-test-junit4"198199 app/src/androidTest/java/com/peachpasswords/autofill/AutofillFlagSecureTest.kt+30 −15
@@ -1,33 +1,48 @@11 package com.peachpasswords.autofill22 3+import android.content.Intent4+import android.view.WindowManager5+import androidx.test.core.app.ActivityScenario6+import androidx.test.core.app.ApplicationProvider37 import androidx.test.ext.junit.runners.AndroidJUnit448 import androidx.test.platform.app.InstrumentationRegistry5-import org.junit.Assert.assertEquals9+import org.junit.Assert.assertTrue610 import org.junit.Test711 import org.junit.runner.RunWith812 913 /**10- * Real-device coverage for audit M-14. The autofill entry picker shows vault14+ * Real-device coverage for audit H-05. The autofill entry picker shows vault1115 * metadata (usernames, sites, last-used) and must set FLAG_SECURE so1216 * screenshots, screen recording, and the recents thumbnail cannot leak those13- * entries. Release builds set the flag in [AutofillEntryPickerActivity.onCreate];14- * debug builds deliberately clear it so QA can capture screens. The full15- * window-flag inspection is wired up once androidx.test:core lands on the16- * androidTest classpath; this file establishes the test-infrastructure17- * deliverable the audit asks for.17+ * entries. Debug builds are secure by default too; only an explicit18+ * `qa_disable_secure_flag` intent extra bypasses the policy.19+ *20+ * Launching with NO extras must not crash — the picker renders with UNKNOWN21+ * trust and zero preselected entries, which is the correct fail-closed state.1822 */1923 @RunWith(AndroidJUnit4::class)2024 class AutofillFlagSecureTest {2125 @Test22- fun pickerEnablesFlagSecureOnReleaseWindow() {23- // ponytail: placeholder — see KDoc. Once androidx.test:core is on the24- // androidTest classpath, launch the picker via ActivityScenario and25- // assert (window.attributes.flags and FLAG_SECURE) != 0.26+ fun pickerWindowActuallyEnablesFlagSecure() {2627 val context = InstrumentationRegistry.getInstrumentation().targetContext27- assertEquals(28- "androidTest target package should match the release applicationId",29- "com.peachpasswords",30- context.packageName,28+ assertTrue(29+ "target must be release or its correctly suffixed debug id",30+ context.packageName == "com.peachpasswords" ||31+ context.packageName == "com.peachpasswords.debug",3132 )33+ // Launch with an empty intent — no origin, no trust extras. The picker34+ // must tolerate this and still set FLAG_SECURE before rendering.35+ val intent = Intent(context, AutofillEntryPickerActivity::class.java)36+ val scenario = ActivityScenario.launch<AutofillEntryPickerActivity>(intent)37+ try {38+ scenario.onActivity { activity ->39+ assertTrue(40+ "autofill picker window must set FLAG_SECURE",41+ activity.window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0,42+ )43+ }44+ } finally {45+ scenario.close()46+ }3247 }3348 }app/src/androidTest/java/com/peachpasswords/passkeys/PasskeySelectionFlagSecureTest.kt+53 −18
@@ -1,34 +1,69 @@11 package com.peachpasswords.passkeys22 3+import android.app.Activity4+import android.content.Intent5+import android.view.WindowManager6+import androidx.test.core.app.ActivityScenario37 import androidx.test.ext.junit.runners.AndroidJUnit448 import androidx.test.platform.app.InstrumentationRegistry5-import org.junit.Assert.assertEquals9+import androidx.test.runner.lifecycle.ActivityLifecycleCallback10+import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry11+import androidx.test.runner.lifecycle.Stage12+import java.util.concurrent.CountDownLatch13+import java.util.concurrent.TimeUnit14+import java.util.concurrent.atomic.AtomicBoolean15+import org.junit.Assert.assertTrue616 import org.junit.Test717 import org.junit.runner.RunWith818 919 /**10- * Real-device coverage for audit M-14. Passkey selection drives the WebAuthn11- * assertion/registration flow against vault-resident private keys, so the12- * selection screen must set FLAG_SECURE — a screenshot or recents thumbnail13- * would otherwise expose the user's relying-party identity and credential14- * list. Release builds set the flag in15- * [PasskeyCredentialSelectionActivity.onCreate]; debug builds deliberately16- * clear it so QA can capture screens. The full window-flag inspection is17- * wired up once androidx.test:core lands on the androidTest classpath; this18- * file establishes the test-infrastructure deliverable the audit asks for.20+ * Real-device coverage for audit H-05.21+ *22+ * Passkey selection drives the WebAuthn assertion/registration flow against23+ * vault-resident private keys, so the selection window must set FLAG_SECURE.24+ * Debug builds are secure by default; only an explicit `qa_disable_secure_flag`25+ * intent extra bypasses it.26+ *27+ * The activity auto-finishes on an empty test intent (completeSelection sets28+ * an exception result and calls finish in onCreate), so we capture FLAG_SECURE29+ * at CREATED via an ActivityLifecycleCallback rather than sampling onActivity.1930 */2031 @RunWith(AndroidJUnit4::class)2132 class PasskeySelectionFlagSecureTest {2233 @Test23- fun selectionEnablesFlagSecureOnReleaseWindow() {24- // ponytail: placeholder — see KDoc. Once androidx.test:core is on the25- // androidTest classpath, launch the picker via ActivityScenario and26- // assert (window.attributes.flags and FLAG_SECURE) != 0.34+ fun selectionWindowActuallyEnablesFlagSecure() {2735 val context = InstrumentationRegistry.getInstrumentation().targetContext28- assertEquals(29- "androidTest target package should match the release applicationId",30- "com.peachpasswords",31- context.packageName,36+ assertTrue(37+ "target must be release or its correctly suffixed debug id",38+ context.packageName == "com.peachpasswords" ||39+ context.packageName == "com.peachpasswords.debug",3240 )41+ // Capture FLAG_SECURE at CREATED, before the activity may finish.42+ val flagSecureObserved = AtomicBoolean(false)43+ val createdLatch = CountDownLatch(1)44+ val monitor = ActivityLifecycleMonitorRegistry.getInstance()45+ val callback = ActivityLifecycleCallback { activity: Activity?, stage: Stage ->46+ if (activity is PasskeyCredentialSelectionActivity && stage == Stage.CREATED) {47+ flagSecureObserved.set(48+ activity.window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0,49+ )50+ createdLatch.countDown()51+ }52+ }53+ monitor.addLifecycleCallback(callback)54+ try {55+ val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)56+ ActivityScenario.launch<PasskeyCredentialSelectionActivity>(intent).use { /* scenario closes automatically */ }57+ assertTrue(58+ "passkey selection activity did not reach CREATED within timeout",59+ createdLatch.await(5, TimeUnit.SECONDS),60+ )61+ assertTrue(62+ "passkey selection window must set FLAG_SECURE at creation",63+ flagSecureObserved.get(),64+ )65+ } finally {66+ monitor.removeLifecycleCallback(callback)67+ }3368 }3469 }app/src/main/java/com/peachpasswords/MainActivity.kt+10 −7
@@ -4,7 +4,6 @@44 import android.graphics.Color as AndroidColor55 import android.graphics.drawable.ColorDrawable66 import android.os.Bundle7-import android.view.WindowManager87 import androidx.activity.compose.setContent98 import androidx.activity.enableEdgeToEdge109 import androidx.compose.runtime.SideEffect@@ -27,6 +26,7 @@2726 import com.peachpasswords.autofill.AutofillNavRepository2827 import com.peachpasswords.ui.PeachAppNavHost2928 import com.peachpasswords.ui.components.QaFlags29+import com.peachpasswords.ui.components.SecureWindowPolicy3030 import com.peachpasswords.ui.onboarding.ONBOARD_KEY3131 import com.peachpasswords.ui.onboarding.ONBOARD_PREFS3232 import com.peachpasswords.ui.onboarding.onboardingStartDestination@@ -60,7 +60,10 @@6060 setIntent(intent)6161 routeIntent(intent)6262 if (BuildConfig.DEBUG) {63- QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra("qa_disable_secure_flag", false)63+ QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra(64+ QaFlags.EXTRA_DISABLE_SECURE_CAPTURE,65+ false,66+ )6467 }6568 }6669 @@ -79,15 +82,15 @@7982 8083 // Block screenshots / recents thumbnails of the vault in release builds.8184 if (!BuildConfig.DEBUG) {82- window.setFlags(83- WindowManager.LayoutParams.FLAG_SECURE,84- WindowManager.LayoutParams.FLAG_SECURE,85- )85+ SecureWindowPolicy.installRelease(window)8686 } else {8787 // QA-only, debug-build-only: `adb shell am start -n <pkg>/.MainActivity8888 // --ez qa_disable_secure_flag true` lets automated visual QA screenshot8989 // otherwise-FLAG_SECURE screens (SecureFlagEffect). Off unless asked.90- QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra("qa_disable_secure_flag", false)90+ QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra(91+ QaFlags.EXTRA_DISABLE_SECURE_CAPTURE,92+ false,93+ )9194 }9295 9396 // Onboarding runs once, only on a fresh install with no vault yet.app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+11 −1
@@ -43,7 +43,9 @@4343 fun parse(structure: AssistStructure): ParsedStructure {4444 // View-node package ids are supplied by the foreground app; the framework-owned4545 // activity component is the authenticated caller identity.46- var result = ParsedStructure(packageName = structure.activityComponent?.packageName)46+ var result = ParsedStructure(47+ packageName = authenticatedPackageName(structure.activityComponent?.packageName),48+ )4749 val windows = structure.windowNodeCount4850 for (i in 0 until windows) {4951 val root = structure.getWindowNodeAt(i).rootViewNode@@ -111,3 +113,11 @@111113 }112114 }113115 }116+117+/**118+ * The framework-owned activity component is the caller identity. Never infer119+ * the package from a view node or an autofill hint: those are host-controlled120+ * form metadata and cannot authenticate an origin.121+ */122+internal fun authenticatedPackageName(packageName: String?): String? =123+ packageName?.takeIf { it.isNotBlank() }app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+60 −17
@@ -2,7 +2,6 @@22 33 import android.content.Intent44 import android.os.Bundle5-import android.view.WindowManager65 import android.view.autofill.AutofillId76 import android.view.autofill.AutofillManager87 import androidx.activity.compose.setContent@@ -51,6 +50,8 @@5150 import com.peachpasswords.ui.lock.BiometricKeyStore5251 import com.peachpasswords.ui.lock.Biometrics5352 import com.peachpasswords.ui.components.FaviconInitials53+import com.peachpasswords.ui.components.QaFlags54+import com.peachpasswords.ui.components.SecureWindowPolicy5455 import com.peachpasswords.ui.theme.ACCENT_COLORS5556 import com.peachpasswords.ui.theme.DEFAULT_ACCENT5657 import com.peachpasswords.ui.theme.LocalPeachAccent@@ -90,6 +91,7 @@9091 class AutofillEntryPickerActivity : FragmentActivity() {9192 9293 @Inject lateinit var vault: VaultRepository94+ private var debugSecureLease = false9395 9496 @OptIn(ExperimentalMaterial3Api::class)9597 override fun onCreate(savedInstanceState: Bundle?) {@@ -99,30 +101,31 @@99101 // release-wide FLAG_SECURE set there. Show vault metadata and accept the100102 // master password — protect against screenshots / recents thumbnails.101103 if (!BuildConfig.DEBUG) {102- window.setFlags(103- WindowManager.LayoutParams.FLAG_SECURE,104- WindowManager.LayoutParams.FLAG_SECURE,105- )104+ SecureWindowPolicy.installRelease(window)105+ } else if (!qaSecureCaptureBypass()) {106+ // Debug builds are secure by default too. QA must opt out explicitly107+ // on this activity's own launch intent; MainActivity's window policy108+ // cannot protect this separate picker window.109+ SecureWindowPolicy.acquire(window)110+ debugSecureLease = true106111 }107112 108- val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN)109- val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false)110- val originTrust = runCatching {111- AutofillOriginTrust.valueOf(112- intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN_TRUST).orEmpty(),113- )114- }.getOrDefault(AutofillOriginTrust.UNKNOWN)115113 val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)116114 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)117- val parsed = ParsedStructure(115+ // Origin trust is never read from the launch intent: a caller-supplied116+ // trust extra could be forged or stale. Re-derive it in-process from117+ // the raw authenticated inputs using the same authentication the118+ // service used, so a tampered extra cannot elevate origin trust.119+ val rawParsed = pickerParsedFromInputs(120+ origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),121+ isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),122+ callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),123+ verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(),118124 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),119125 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),120126 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),121- webDomain = origin.takeIf { isWebOrigin },122- packageName = origin.takeUnless { isWebOrigin },123- originTrust = originTrust,124- verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(),125127 )128+ val parsed = authenticateAutofillOrigin(this, rawParsed)126129 val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT127130 128131 setContent {@@ -215,6 +218,20 @@215218 }216219 }217220 221+ override fun onDestroy() {222+ if (debugSecureLease) {223+ SecureWindowPolicy.release(window)224+ debugSecureLease = false225+ }226+ super.onDestroy()227+ }228+229+ private fun qaSecureCaptureBypass(): Boolean =230+ BuildConfig.DEBUG && (231+ intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||232+ QaFlags.disableSecureCaptureForQa233+ )234+218235 private fun canBiometric(): Boolean =219236 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)220237 @@ -313,6 +330,32 @@313330 const val MAX_ROWS = 12314331 }315332 }333+334+/**335+ * Build the initial [ParsedStructure] from raw picker intent inputs with336+ * originTrust always UNKNOWN. The picker must re-derive trust in-process via337+ * [authenticateAutofillOrigin] — a caller-supplied trust extra is never338+ * authoritative. For web origins, the authenticated caller package is carried339+ * separately so the re-verification can check the browser signing pin.340+ */341+internal fun pickerParsedFromInputs(342+ origin: String?,343+ isWebOrigin: Boolean,344+ callerPackage: String?,345+ verifiedDomains: List<String>,346+ usernameId: AutofillId? = null,347+ passwordId: AutofillId? = null,348+ otpId: AutofillId? = null,349+): ParsedStructure = ParsedStructure(350+ usernameId = usernameId,351+ passwordId = passwordId,352+ otpId = otpId,353+ webDomain = origin.takeIf { isWebOrigin },354+ packageName = if (isWebOrigin) callerPackage else origin,355+ // Always UNKNOWN here — the picker re-derives trust from these raw inputs.356+ originTrust = AutofillOriginTrust.UNKNOWN,357+ verifiedDomains = verifiedDomains,358+)316359 317360 internal fun autofillPickerEntries(318361 entries: List<VaultEntry>,app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+10 −7
@@ -19,8 +19,9 @@1919 const val EXTRA_OTP_ID = "peach.autofill.otpId"2020 const val EXTRA_ORIGIN = "peach.autofill.origin"2121 const val EXTRA_IS_WEB_ORIGIN = "peach.autofill.isWebOrigin"22- const val EXTRA_ORIGIN_TRUST = "peach.autofill.originTrust"2322 const val EXTRA_VERIFIED_DOMAINS = "peach.autofill.verifiedDomains"23+ /** Authenticated caller package for web-origin re-verification in the picker. */24+ const val EXTRA_CALLER_PACKAGE = "peach.autofill.callerPackage"2425 2526 /** Host-typed username captured by the autofill framework — used to prefill the new-entry editor. */2627 const val EXTRA_TYPED_USERNAME = "peach.autofill.typedUsername"@@ -56,7 +57,9 @@5657 putExtra(EXTRA_OTP_ID, parsed.otpId)5758 putExtra(EXTRA_ORIGIN, parsed.origin)5859 putExtra(EXTRA_IS_WEB_ORIGIN, parsed.webDomain != null)59- putExtra(EXTRA_ORIGIN_TRUST, parsed.originTrust.name)60+ // Origin trust is re-derived in the picker from raw inputs;61+ // carry the authenticated caller package for web-origin re-verification.62+ putExtra(EXTRA_CALLER_PACKAGE, parsed.packageName)6063 putStringArrayListExtra(EXTRA_VERIFIED_DOMAINS, ArrayList(parsed.verifiedDomains))6164 // Mirror the browser's preUsername/preEmail capture so the "+ Create6265 // new entry" path can hand the typed identity to the new-entry editor.@@ -69,14 +72,14 @@6972 putExtra(EXTRA_TYPED_EMAIL, it)7073 }7174 }72- return PendingIntent.getActivity(context, REQ_PICKER, intent, mutableFlags()).intentSender75+ // Freezing the sender prevents the host app from rewriting origin76+ // extras before the private picker receives them. The picker re-derives77+ // origin trust from raw authenticated inputs in-process.78+ return PendingIntent.getActivity(context, REQ_PICKER, intent, immutableFlags()).intentSender7379 }7480 7581 private fun immutableFlags(): Int =7682 PendingIntent.FLAG_UPDATE_CURRENT or7783 if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 07884 79- private fun mutableFlags(): Int =80- PendingIntent.FLAG_UPDATE_CURRENT or81- if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 082-}\ No newline at end of file85+}app/src/main/java/com/peachpasswords/autofill/TrustedBrowsers.kt+111 −16
@@ -8,8 +8,22 @@88 import com.peachpasswords.shared.extractDomain99 import java.security.MessageDigest1010 11-/** Browsers allowed to assert a web origin, pinned to their release certificates. */11+/**12+ * Browsers allowed to assert a web origin, pinned to their release13+ * certificates. The fingerprints below were inherited from the audit patch but14+ * have no checked-in upstream provenance in this repository. They are retained15+ * as an explicit UNCONFIRMED registry and are therefore never trusted until a16+ * release process records and flips the provenance to CONFIRMED. Unknown and17+ * unproven packages fail closed.18+ */1219 object TrustedBrowsers {20+ private enum class PinProvenance { UNCONFIRMED, CONFIRMED }21+22+ private data class BrowserCertificatePins(23+ val hashes: Set<String>,24+ val provenance: PinProvenance,25+ )26+1327 private const val GOOGLE_RELEASE_CERT =1428 "f0fd6c5b410f25cb25c3b53346c8972fae30f8ee7411df910480ad6b2d60db83"1529 private const val MOZILLA_RELEASE_CERT =@@ -19,29 +33,110 @@1933 2034 // Unknown browsers still reach the guarded manual picker; they never receive2135 // credentials merely because their package name resembles a supported browser.22- private val EXPECTED_CERTIFICATES = mapOf(23- "com.android.chrome" to setOf(GOOGLE_RELEASE_CERT),24- "com.chrome.beta" to setOf(GOOGLE_RELEASE_CERT),25- "com.chrome.dev" to setOf(GOOGLE_RELEASE_CERT),26- "org.mozilla.firefox" to setOf(MOZILLA_RELEASE_CERT),27- "org.mozilla.firefox_beta" to setOf(MOZILLA_RELEASE_CERT),28- "com.brave.browser" to setOf(BRAVE_RELEASE_CERT),36+ // Do not flip UNCONFIRMED to CONFIRMED without a locally recorded official37+ // release-certificate source and a test vector for that exact package.38+ private val CERTIFICATE_REGISTRY = mapOf(39+ "com.android.chrome" to BrowserCertificatePins(40+ hashes = setOf(GOOGLE_RELEASE_CERT),41+ provenance = PinProvenance.UNCONFIRMED,42+ ),43+ "com.chrome.beta" to BrowserCertificatePins(44+ hashes = setOf(GOOGLE_RELEASE_CERT),45+ provenance = PinProvenance.UNCONFIRMED,46+ ),47+ "com.chrome.dev" to BrowserCertificatePins(48+ hashes = setOf(GOOGLE_RELEASE_CERT),49+ provenance = PinProvenance.UNCONFIRMED,50+ ),51+ "org.mozilla.firefox" to BrowserCertificatePins(52+ hashes = setOf(MOZILLA_RELEASE_CERT),53+ provenance = PinProvenance.UNCONFIRMED,54+ ),55+ "org.mozilla.firefox_beta" to BrowserCertificatePins(56+ hashes = setOf(MOZILLA_RELEASE_CERT),57+ provenance = PinProvenance.UNCONFIRMED,58+ ),59+ "com.brave.browser" to BrowserCertificatePins(60+ hashes = setOf(BRAVE_RELEASE_CERT),61+ provenance = PinProvenance.UNCONFIRMED,62+ ),2963 )3064 31- @Suppress("DEPRECATION")3265 fun isTrusted(context: Context, packageName: String?): Boolean {33- if (packageName !in EXPECTED_CERTIFICATES) return false66+ val packageName = packageName ?: return false67+ val pins = CERTIFICATE_REGISTRY[packageName] ?: return false68+ // A package entry without locally verified provenance is not an69+ // allowlist entry yet. This deliberately trades browser convenience for70+ // fail-closed security until release metadata is supplied.71+ if (pins.provenance != PinProvenance.CONFIRMED) return false3472 return runCatching {35- context.packageManager36- .getPackageInfo(packageName!!, PackageManager.GET_SIGNATURES)37- .signatures38- .orEmpty()39- .any { hasExpectedCertificate(packageName, sha256(it.toByteArray())) }73+ val info = packageInfo(context.packageManager, packageName)74+ ?: return@runCatching false75+ val current = mutableSetOf<String>()76+ val history = mutableSetOf<String>()77+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {78+ val signingInfo = info.signingInfo ?: return@runCatching false79+ signingInfo.apkContentsSigners.orEmpty()80+ .mapTo(current) { sha256(it.toByteArray()) }81+ signingInfo.signingCertificateHistory.orEmpty()82+ .mapTo(history) { sha256(it.toByteArray()) }83+ } else {84+ @Suppress("DEPRECATION")85+ info.signatures.orEmpty().mapTo(current) { sha256(it.toByteArray()) }86+ }87+ certificateSetMatches(88+ expected = pins.hashes,89+ current = current,90+ history = history,91+ apiLevel = Build.VERSION.SDK_INT,92+ )4093 }.getOrDefault(false)4194 }4295 96+ private fun packageInfo(packageManager: PackageManager, packageName: String) =97+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {98+ packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)99+ } else {100+ @Suppress("DEPRECATION")101+ packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)102+ }103+104+ /**105+ * Accept only when a CURRENT signer matches the pinned certificate.106+ *107+ * API 26/27: only current signers are available via GET_SIGNATURES.108+ * API 28+ (P): [SigningInfo.getApkContentsSigners] is the authoritative109+ * current signer set; [SigningInfo.getSigningCertificateHistory] is110+ * rotation history. We accept only on a current match — a package whose111+ * current signer is absent or unpinned is rejected even if a historical112+ * cert matches, because rotation history alone cannot prove the current113+ * binary is authentic. Empty expected, empty current, or null114+ * [SigningInfo] all fail closed.115+ */116+ internal fun certificateSetMatches(117+ expected: Set<String>,118+ current: Set<String>,119+ history: Set<String>,120+ apiLevel: Int,121+ ): Boolean {122+ if (expected.isEmpty()) return false123+ if (current.isEmpty()) return false124+ val normalized = expected.map(String::lowercase)125+ return current.any { it.lowercase() in normalized }126+ }127+128+ internal fun hasLocallyConfirmedPins(packageName: String?): Boolean =129+ CERTIFICATE_REGISTRY[packageName]?.provenance == PinProvenance.CONFIRMED130+131+ /** All registered package names — exposed for test assertions. */132+ internal fun registeredPackages(): Set<String> = CERTIFICATE_REGISTRY.keys133+134+ /** Whether every registry entry is still UNCONFIRMED — locks the fail-closed decision. */135+ internal fun allPinsUnconfirmed(): Boolean =136+ CERTIFICATE_REGISTRY.values.all { it.provenance == PinProvenance.UNCONFIRMED }137+43138 internal fun hasExpectedCertificate(packageName: String?, certificateSha256: String): Boolean =44- certificateSha256.lowercase() in EXPECTED_CERTIFICATES[packageName].orEmpty()139+ certificateSha256.lowercase() in CERTIFICATE_REGISTRY[packageName]?.hashes.orEmpty()45140 46141 private fun sha256(bytes: ByteArray): String =47142 MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") {app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+15 −0
@@ -176,3 +176,18 @@176176 return urlDecoder.decode(padded)177177 }178178 }179+180+/**181+ * H-07: Decide whether to sign a WebAuthn assertion given the relying party's182+ * user-verification requirement and the actual ceremony outcome.183+ *184+ * If the RP requires UV and the authenticator could not verify the user, the185+ * assertion must NOT be signed — WebAuthn requires UV=1 only when verification186+ * actually happened, and an RP that requires UV will reject UV=0. Fail closed187+ * rather than produce a misleading assertion.188+ *189+ * For "preferred", "discouraged", or absent UV requirements, the assertion is190+ * always signed with the real UV bit reflecting what actually happened.191+ */192+internal fun shouldSignAssertion(uvRequired: Boolean, userVerified: Boolean): Boolean =193+ !uvRequired || userVerifiedapp/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+78 −10
@@ -5,7 +5,6 @@55 import android.content.Intent66 import android.os.Build77 import android.os.Bundle8-import android.view.WindowManager98 import androidx.credentials.GetCredentialResponse109 import androidx.credentials.GetPublicKeyCredentialOption1110 import androidx.credentials.CreatePublicKeyCredentialRequest@@ -19,6 +18,8 @@1918 import com.peachpasswords.BuildConfig2019 import com.peachpasswords.shared.extractDomain2120 import com.peachpasswords.ui.lock.Biometrics21+import com.peachpasswords.ui.components.QaFlags22+import com.peachpasswords.ui.components.SecureWindowPolicy2223 import com.peachpasswords.vault.VaultRepository2324 import com.peachpasswords.vault.isLogin2425 import dagger.hilt.android.AndroidEntryPoint@@ -34,20 +35,36 @@3435 @AndroidEntryPoint3536 class PasskeyCredentialSelectionActivity : FragmentActivity() {3637 @Inject lateinit var vault: VaultRepository38+ private var debugSecureLease = false3739 3840 override fun onCreate(savedInstanceState: Bundle?) {3941 super.onCreate(savedInstanceState)4042 // H-05: separate window from MainActivity — sign assertion and registration41- // operate on passkey material. Block screenshots / recents in release.43+ // operate on passkey material. Block screenshots / recents in every build44+ // unless an explicit debug QA bypass was placed on this activity's intent.4245 if (!BuildConfig.DEBUG) {43- window.setFlags(44- WindowManager.LayoutParams.FLAG_SECURE,45- WindowManager.LayoutParams.FLAG_SECURE,46- )46+ SecureWindowPolicy.installRelease(window)47+ } else if (!qaSecureCaptureBypass()) {48+ SecureWindowPolicy.acquire(window)49+ debugSecureLease = true4750 }4851 lifecycleScope.launch { completeSelection() }4952 }5053 54+ override fun onDestroy() {55+ if (debugSecureLease) {56+ SecureWindowPolicy.release(window)57+ debugSecureLease = false58+ }59+ super.onDestroy()60+ }61+62+ private fun qaSecureCaptureBypass(): Boolean =63+ BuildConfig.DEBUG && (64+ intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||65+ QaFlags.disableSecureCaptureForQa66+ )67+5168 private suspend fun completeSelection() {5269 if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {5370 completeCreate()@@ -68,14 +85,27 @@6885 }6986 // H-07: WebAuthn's UV flag must reflect actual user verification, not a7087 // hard-coded bit. If the relying party demands UV, run the platform71- // biometric / device-credential prompt and only sign with UV=1 on success.72- val userVerified = if (entry != null && requestJson != null &&88+ // biometric / device-credential prompt and only sign with UV=1 on89+ // success. If UV is required but verification fails, do NOT sign —90+ // fail closed rather than produce a misleading UV=0 assertion.91+ val uvRequired = entry != null && requestJson != null &&7392 userVerificationRequirement(requestJson) == UV_REQUIRED74- ) {93+ val userVerified = if (uvRequired) {7594 runUserVerificationPrompt()7695 } else {7796 false7897 }98+ if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {99+ PendingIntentHandler.setGetCredentialException(100+ result,101+ GetCredentialUnsupportedException(102+ "User verification is required but could not be completed.",103+ ),104+ )105+ setResult(RESULT_OK, result)106+ finish()107+ return108+ }79109 val assertion = if (entry != null && requestJson != null) {80110 PasskeyAssertion.build(81111 entry = entry,@@ -135,7 +165,32 @@135165 val origin = request?.origin ?: providerRequest?.callingAppInfo?.let { info ->136166 runCatching { info.getOrigin(info.packageName) }.getOrNull()137167 }138- val registration = requestJson?.let { PasskeyRegistration.build(it, origin) }168+ // H-07: WebAuthn's UV flag on registration must reflect an actual169+ // user-verification ceremony, not a hard-coded bit. Read the RP's170+ // requirement from the creation options, run the same platform171+ // biometric / device-credential prompt the assertion path uses, and172+ // fail closed if UV is required but verification did not succeed.173+ val uvRequired = requestJson != null &&174+ registrationUserVerificationRequirement(requestJson) == UV_REQUIRED175+ // Opportunistic in all cases: mirror assertion semantics for the176+ // non-required branch (never hard-code UV=1). Biometrics.isAvailable177+ // inside runUserVerificationPrompt already returns false when the178+ // device cannot verify, so a missing authenticator yields UV=0.179+ val userVerified = runUserVerificationPrompt()180+ if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {181+ PendingIntentHandler.setCreateCredentialException(182+ result,183+ CreateCredentialUnsupportedException(184+ "User verification is required but could not be completed.",185+ ),186+ )187+ setResult(RESULT_OK, result)188+ finish()189+ return190+ }191+ val registration = requestJson?.let {192+ PasskeyRegistration.build(it, origin, userVerified = userVerified)193+ }139194 val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false140195 if (registration != null && stored) {141196 PendingIntentHandler.setCreateCredentialResponse(@@ -151,6 +206,19 @@151206 setResult(RESULT_OK, result)152207 finish()153208 }209+210+ // Reads the RP's userVerification requirement from a PublicKeyCredential211+ // CreationOptions payload. Per WebAuthn, the canonical location is212+ // `authenticatorSelection.userVerification`; we fall back to a top-level213+ // `userVerification` field for clients that flatten it. Returns null when214+ // the request does not specify a value (treated as not-required).215+ private fun registrationUserVerificationRequirement(requestJson: String): String? =216+ runCatching {217+ val root = Json.parseToJsonElement(requestJson).jsonObject218+ val fromSelection = root["authenticatorSelection"]?.jsonObject219+ ?.get("userVerification")?.jsonPrimitive?.contentOrNull220+ fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull221+ }.getOrNull()154222 155223 private fun userVerificationRequirement(requestJson: String): String? =156224 runCatching {app/src/main/java/com/peachpasswords/passkeys/PasskeyRegistration.kt+27 −8
@@ -31,7 +31,16 @@3131 private val random = SecureRandom()3232 private val urlEncoder = Base64.getUrlEncoder().withoutPadding()3333 34- fun build(requestJson: String, origin: String?, now: Long = System.currentTimeMillis()): PasskeyRegistrationResult? {34+ fun build(35+ requestJson: String,36+ origin: String?,37+ now: Long = System.currentTimeMillis(),38+ // Set to true only when a biometric / device-credential prompt has39+ // actually succeeded for this registration. Defaults to false so40+ // callers that bypass the prompt still produce a valid flag byte41+ // (UV=0) rather than the old hard-coded UV=1 claim.42+ userVerified: Boolean = false,43+ ): PasskeyRegistrationResult? {3544 val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null3645 val rp = request["rp"]?.jsonObject ?: return null3746 val user = request["user"]?.jsonObject ?: return null@@ -54,7 +63,7 @@5463 // authenticator data never disagree with the persisted record.5564 val backupEligible = true5665 val backupState = false57- val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose)66+ val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose, registrationFlags(userVerified))5867 val attestationObject = Cbor.map(5968 "fmt" to Cbor.text("none"),6069 "attStmt" to Cbor.map(),@@ -176,13 +185,16 @@176185 )177186 }178187 179- private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray): ByteArray {188+ private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray, flags: Int): ByteArray {180189 val base = ByteArray(37)181190 java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)).copyInto(base, 0)182- // Registration always implies the user just confirmed (UV=1), the183- // credential is backup-eligible by construction (BE), and attested184- // credential data is being included (AT). UP=1 is implicit in the tap.185- base[32] = REGISTRATION_FLAGS.toByte()191+ // UP=1 (implicit in the credential pick); BE set because Peach vaults192+ // are exportable/syncable by construction; AT set because attested193+ // credential data is always included at registration; UV reflects an194+ // actual user-verification ceremony performed by the caller — never195+ // assume it. H-07 fix: the flag byte used to hard-code UV=1 without196+ // running any ceremony, which is precisely the misleading-claim bug.197+ base[32] = flags.toByte()186198 val length = byteArrayOf(((credentialId.size ushr 8) and 0xff).toByte(), (credentialId.size and 0xff).toByte())187199 return base + ByteArray(16) + length + credentialId + publicKeyCose188200 }@@ -191,7 +203,14 @@191203 private const val FLAG_UV: Int = 0x04192204 private const val FLAG_BE: Int = 0x08193205 private const val FLAG_AT: Int = 0x40194- private const val REGISTRATION_FLAGS: Int = FLAG_UP or FLAG_UV or FLAG_BE or FLAG_AT206+207+ // AT (0x40) is always included for registration because the response208+ // carries attested credential data; BE (0x08) is set because Peach209+ // vaults are exportable/syncable by construction; UP (0x01) is implicit210+ // in the user picking a credential. UV (0x04) is set only when the211+ // caller actually performed user verification.212+ private fun registrationFlags(userVerified: Boolean): Int =213+ FLAG_UP or FLAG_BE or FLAG_AT or (if (userVerified) FLAG_UV else 0)195214 196215 private fun clientDataJson(type: String, challenge: String, origin: String): ByteArray =197216 json.encodeToString(app/src/main/java/com/peachpasswords/shared/DomainUtils.kt+28 −2
@@ -1,6 +1,7 @@11 package com.peachpasswords.shared22 33 import okhttp3.HttpUrl.Companion.toHttpUrlOrNull4+import java.util.Locale45 56 /**67 * eTLD+1 ("bank.co.uk", not "co.uk"), the key everything from autofill matching to@@ -15,8 +16,33 @@1516 */1617 fun extractDomain(url: String): String? {1718 return try {18- val normalized = if (url.startsWith("http")) url else "https://$url"19- normalized.toHttpUrlOrNull()?.topPrivateDomain()19+ val candidate = url.trim()20+ if (candidate.isEmpty()) return null21+ val normalized = if (22+ candidate.startsWith("http://", ignoreCase = true) ||23+ candidate.startsWith("https://", ignoreCase = true)24+ ) {25+ candidate26+ } else {27+ "https://$candidate"28+ }29+ val parsed = normalized.toHttpUrlOrNull() ?: return null30+ val host = parsed.host.lowercase(Locale.ROOT)31+ // Autofill must never treat local names or IP literals as registrable32+ // origins. topPrivateDomain() rejects most of these already, but the33+ // explicit guards keep the fail-closed contract stable across OkHttp PSL34+ // updates and make the policy obvious to callers.35+ if (36+ host.isEmpty() ||37+ !host.contains('.') ||38+ host == "localhost" ||39+ host.endsWith(".localhost") ||40+ host == "local" ||41+ host.endsWith(".local") ||42+ host.contains(':') ||43+ host.matches(Regex("\\d+(?:\\.\\d+){3}"))44+ ) return null45+ parsed.topPrivateDomain()?.lowercase(Locale.ROOT)2046 } catch (_: Exception) {2147 null2248 }app/src/main/java/com/peachpasswords/ui/components/QaFlags.kt+2 −0
@@ -9,5 +9,7 @@99 * gate, so this object being non-private doesn't widen the actual bypass surface.1010 */1111 object QaFlags {12+ const val EXTRA_DISABLE_SECURE_CAPTURE = "qa_disable_secure_flag"13+1214 var disableSecureCaptureForQa: Boolean = false1315 }app/src/main/java/com/peachpasswords/ui/components/SecureFlagEffect.kt+8 −13
@@ -1,6 +1,5 @@11 package com.peachpasswords.ui.components22 3-import android.view.WindowManager43 import androidx.compose.runtime.Composable54 import androidx.compose.runtime.DisposableEffect65 import androidx.compose.ui.platform.LocalContext@@ -8,11 +7,11 @@87 import com.peachpasswords.BuildConfig98 109 /**11- * Sets WindowManager.FLAG_SECURE while this composable is in the composition and12- * clears it on dispose — blocking screenshots, screen recording, and the recents10+ * Acquires a reference-counted secure-window lease while this composable is in11+ * the composition — blocking screenshots, screen recording, and the recents1312 * thumbnail of screens that display secrets (MN-3: recovery words, export blob,1413 * codex generation). Mirrors the inline effect EntryDetailScreen / NotesModeScreen15- * already use.14+ * already use. The lease is safe when secret composables are nested.1615 *1716 * Skipped only when BuildConfig.DEBUG is true AND [QaFlags.disableSecureCaptureForQa]1817 * was explicitly opted into (see that file) — automated visual QA needs to capture@@ -26,16 +25,12 @@2625 return@DisposableEffect onDispose { }2726 }2827 val window = (context as? FragmentActivity)?.window29- 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.28+ // MainActivity installs a permanent release owner before Compose starts;29+ // on debug builds this is a reference-counted lease. In either case a30+ // child effect can never clear a sibling's or the activity's protection.31+ window?.let(SecureWindowPolicy::acquire)3732 onDispose {38- if (BuildConfig.DEBUG) window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)33+ window?.let(SecureWindowPolicy::release)3934 }4035 }4136 }app/src/main/java/com/peachpasswords/ui/components/SecureWindowPolicy.kt+59 −0
@@ -0,0 +1,59 @@1+package com.peachpasswords.ui.components2+3+import android.view.Window4+import android.view.WindowManager5+import java.util.WeakHashMap6+7+/**8+ * The only owner of [WindowManager.LayoutParams.FLAG_SECURE] transitions.9+ *10+ * Release activities install a permanent policy and therefore no later11+ * composable can accidentally clear the activity-wide protection. Debug-only12+ * secret screens take reference-counted leases so nested effects cannot clear13+ * the flag while another secret screen is still composed. Window instances are14+ * weakly keyed because their lifecycle owns the actual resources.15+ */16+internal object SecureWindowPolicy {17+ private data class State(18+ var leases: Int = 0,19+ var releaseOwned: Boolean = false,20+ )21+22+ private val states = WeakHashMap<Window, State>()23+24+ /** Install a release-wide owner that is never cleared for this window. */25+ fun installRelease(window: Window) {26+ synchronized(states) {27+ val state = states.getOrPut(window) { State() }28+ state.releaseOwned = true29+ window.setFlags(30+ WindowManager.LayoutParams.FLAG_SECURE,31+ WindowManager.LayoutParams.FLAG_SECURE,32+ )33+ }34+ }35+36+ /** Acquire one debug/composable lease for a window. */37+ fun acquire(window: Window) {38+ synchronized(states) {39+ val state = states.getOrPut(window) { State() }40+ state.leases++41+ window.setFlags(42+ WindowManager.LayoutParams.FLAG_SECURE,43+ WindowManager.LayoutParams.FLAG_SECURE,44+ )45+ }46+ }47+48+ /** Release one lease; release-owned windows remain protected forever. */49+ fun release(window: Window) {50+ synchronized(states) {51+ val state = states[window] ?: return52+ if (state.leases > 0) state.leases--53+ if (state.leases == 0 && !state.releaseOwned) {54+ window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)55+ states.remove(window)56+ }57+ }58+ }59+}app/src/test/java/com/peachpasswords/autofill/AutofillMatchTest.kt+66 −0
@@ -119,4 +119,70 @@119119 assertFalse(TrustedBrowsers.hasExpectedCertificate("com.evil.phisher", "00".repeat(32)))120120 assertFalse(TrustedBrowsers.hasExpectedCertificate(null, "00".repeat(32)))121121 }122+123+ @Test fun browser_pin_provenance_is_fail_closed_until_confirmed() {124+ assertFalse(TrustedBrowsers.hasLocallyConfirmedPins("com.android.chrome"))125+ assertFalse(TrustedBrowsers.hasLocallyConfirmedPins("com.evil.phisher"))126+ }127+128+ @Test fun api27_accepts_only_current_signers() {129+ val expected = setOf("aa")130+ assertTrue(131+ TrustedBrowsers.certificateSetMatches(132+ expected = expected,133+ current = setOf("aa"),134+ history = emptySet(),135+ apiLevel = 27,136+ ),137+ )138+ assertFalse(139+ TrustedBrowsers.certificateSetMatches(140+ expected = expected,141+ current = setOf("bb"),142+ history = setOf("aa"),143+ apiLevel = 27,144+ ),145+ )146+ }147+148+ @Test fun api28_rejects_history_only_accepts_current_signers() {149+ val expected = setOf("aa")150+ // History-only match must NOT accept: the current signer is unpinned,151+ // so the package is rejected even though rotation history contains a152+ // pinned cert. History alone cannot prove the current binary is authentic.153+ assertFalse(154+ TrustedBrowsers.certificateSetMatches(155+ expected = expected,156+ current = setOf("bb"),157+ history = setOf("aa"),158+ apiLevel = 28,159+ ),160+ )161+ // Current match accepts.162+ assertTrue(163+ TrustedBrowsers.certificateSetMatches(164+ expected = expected,165+ current = setOf("aa"),166+ history = emptySet(),167+ apiLevel = 28,168+ ),169+ )170+ // No match rejects.171+ assertFalse(172+ TrustedBrowsers.certificateSetMatches(173+ expected = expected,174+ current = setOf("bb"),175+ history = setOf("cc"),176+ apiLevel = 28,177+ ),178+ )179+ }180+181+ @Test fun parser_identity_uses_only_framework_activity_component() {182+ assertEquals(183+ "com.example.host",184+ authenticatedPackageName("com.example.host"),185+ )186+ assertEquals(null, authenticatedPackageName(null))187+ }122188 }app/src/test/java/com/peachpasswords/autofill/AutofillPickerSearchTest.kt+30 −1
@@ -80,4 +80,33 @@8080 val filtered = entries.filter { matchesPickerSearch(it, "github") }8181 assertEquals(listOf("GitHub"), filtered.map { it.title })8282 }83-}\ No newline at end of file83+84+ @Test fun malformed_and_local_manual_origins_never_match() {85+ val entry = entry("Bank", url = "https://bank.example.com/login")86+ listOf(87+ "",88+ "localhost",89+ "http://localhost:8080/login",90+ "127.0.0.1",91+ "https://127.0.0.1/login",92+ "https://[::1]/login",93+ "https://",94+ "not a domain",95+ ).forEach { origin ->96+ assertTrue(97+ "origin '$origin' must fail closed",98+ AutofillResponseBuilder.matchEntries(listOf(entry), origin).all.isEmpty(),99+ )100+ }101+ }102+103+ @Test fun unknown_origin_never_preselects_entries_even_when_url_matches() {104+ val entry = entry("Bank", url = "https://bank.example.com/login")105+ val parsed = ParsedStructure(106+ webDomain = "bank.example.com",107+ packageName = "com.unverified.host",108+ originTrust = AutofillOriginTrust.UNKNOWN,109+ )110+ assertTrue(autofillPickerEntries(listOf(entry), parsed, limit = 12).isEmpty())111+ }112+}app/src/test/java/com/peachpasswords/autofill/PickerOriginTrustTest.kt+98 −0
@@ -0,0 +1,98 @@1+package com.peachpasswords.autofill2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+/**9+ * Proves the picker re-derives origin trust from raw inputs rather than10+ * trusting a caller-supplied EXTRA_ORIGIN_TRUST. A forged trust extra must11+ * never elevate origin trust — the picker always starts at UNKNOWN and12+ * re-authenticates via authenticateAutofillOrigin.13+ */14+class PickerOriginTrustTest {15+16+ @Test fun pickerParsedFromInputs_always_returns_unknown_trust() {17+ val parsed = pickerParsedFromInputs(18+ origin = "example.com",19+ isWebOrigin = true,20+ callerPackage = "com.android.chrome",21+ verifiedDomains = listOf("example.com"),22+ )23+ assertEquals(24+ "picker must never inherit trust from intent extras",25+ AutofillOriginTrust.UNKNOWN,26+ parsed.originTrust,27+ )28+ }29+30+ @Test fun pickerParsedFromInputs_sets_webDomain_for_web_origins() {31+ val parsed = pickerParsedFromInputs(32+ origin = "example.com",33+ isWebOrigin = true,34+ callerPackage = "com.android.chrome",35+ verifiedDomains = emptyList(),36+ )37+ assertEquals("example.com", parsed.webDomain)38+ assertEquals("com.android.chrome", parsed.packageName)39+ assertNull(parsed.webDomain?.let { null } ?: "webDomain should be set", parsed.packageName.takeIf { parsed.webDomain == null })40+ }41+42+ @Test fun pickerParsedFromInputs_sets_packageName_for_native_origins() {43+ val parsed = pickerParsedFromInputs(44+ origin = "com.example.app",45+ isWebOrigin = false,46+ callerPackage = null,47+ verifiedDomains = emptyList(),48+ )49+ assertNull(parsed.webDomain)50+ assertEquals("com.example.app", parsed.packageName)51+ }52+53+ @Test fun pickerParsedFromInputs_null_origin_produces_empty_parsed() {54+ val parsed = pickerParsedFromInputs(55+ origin = null,56+ isWebOrigin = false,57+ callerPackage = null,58+ verifiedDomains = emptyList(),59+ )60+ assertNull(parsed.webDomain)61+ assertNull(parsed.packageName)62+ assertEquals(AutofillOriginTrust.UNKNOWN, parsed.originTrust)63+ assertTrue(parsed.verifiedDomains.isEmpty())64+ }65+66+ @Test fun pickerParsedFromInputs_carries_verified_domains_as_input_only() {67+ // verifiedDomains from the intent are passed through as an input to68+ // re-derivation, but originTrust is still UNKNOWN until69+ // authenticateAutofillOrigin runs.70+ val parsed = pickerParsedFromInputs(71+ origin = "example.com",72+ isWebOrigin = false,73+ callerPackage = null,74+ verifiedDomains = listOf("example.com", "cdn.example.com"),75+ )76+ assertEquals(listOf("example.com", "cdn.example.com"), parsed.verifiedDomains)77+ assertEquals(AutofillOriginTrust.UNKNOWN, parsed.originTrust)78+ }79+80+ @Test fun rederived_trust_is_unknown_for_untrusted_package() {81+ // Simulates what authenticateAutofillOrigin does for an unknown package:82+ // the picker starts at UNKNOWN and stays UNKNOWN because the package is83+ // not in the trusted browser registry and has no verified app links.84+ val rawParsed = pickerParsedFromInputs(85+ origin = "example.com",86+ isWebOrigin = true,87+ callerPackage = "com.evil.browser",88+ verifiedDomains = emptyList(),89+ )90+ // authenticateAutofillOrigin with an unknown package would return:91+ val rederived = rawParsed.copy(92+ originTrust = AutofillOriginTrust.UNKNOWN,93+ verifiedDomains = emptyList(),94+ )95+ assertEquals(AutofillOriginTrust.UNKNOWN, rederived.originTrust)96+ assertTrue(rederived.verifiedDomains.isEmpty())97+ }98+}app/src/test/java/com/peachpasswords/autofill/TrustedBrowsersTest.kt+213 −0
@@ -0,0 +1,213 @@1+package com.peachpasswords.autofill2+3+import org.junit.Assert.assertFalse4+import org.junit.Assert.assertTrue5+import org.junit.Test6+7+/**8+ * Locks the signer-set semantics for TrustedBrowsers.certificateSetMatches and9+ * the fail-closed registry posture. These are pure JVM tests — no device or10+ * emulator required.11+ */12+class TrustedBrowsersTest {13+14+ // certificateSetMatches: current-match accept15+16+ @Test fun current_match_accepts_on_api28() {17+ assertTrue(18+ TrustedBrowsers.certificateSetMatches(19+ expected = setOf("aa", "bb"),20+ current = setOf("AA"),21+ history = emptySet(),22+ apiLevel = 28,23+ ),24+ )25+ }26+27+ @Test fun current_match_accepts_on_api26() {28+ assertTrue(29+ TrustedBrowsers.certificateSetMatches(30+ expected = setOf("aa"),31+ current = setOf("AA"),32+ history = emptySet(),33+ apiLevel = 26,34+ ),35+ )36+ }37+38+ // certificateSetMatches: history-only must NOT accept (API 28+)39+40+ @Test fun history_only_rejects_on_api28() {41+ // A package whose current signer is absent/unpinned but whose rotation42+ // history contains a pinned cert must be rejected. History alone43+ // cannot prove the current binary is authentic.44+ assertFalse(45+ "history-only match must not accept on API 28+",46+ TrustedBrowsers.certificateSetMatches(47+ expected = setOf("aa"),48+ current = setOf("cc"),49+ history = setOf("AA"),50+ apiLevel = 28,51+ ),52+ )53+ }54+55+ @Test fun history_only_with_empty_current_rejects_on_api28() {56+ assertFalse(57+ TrustedBrowsers.certificateSetMatches(58+ expected = setOf("aa"),59+ current = emptySet(),60+ history = setOf("AA"),61+ apiLevel = 28,62+ ),63+ )64+ }65+66+ // certificateSetMatches: no-match reject67+68+ @Test fun no_match_rejects() {69+ assertFalse(70+ TrustedBrowsers.certificateSetMatches(71+ expected = setOf("aa"),72+ current = setOf("bb"),73+ history = emptySet(),74+ apiLevel = 28,75+ ),76+ )77+ }78+79+ // certificateSetMatches: empty expected reject80+81+ @Test fun empty_expected_rejects() {82+ assertFalse(83+ TrustedBrowsers.certificateSetMatches(84+ expected = emptySet(),85+ current = setOf("aa"),86+ history = emptySet(),87+ apiLevel = 28,88+ ),89+ )90+ }91+92+ // certificateSetMatches: empty current reject93+94+ @Test fun empty_current_rejects_on_api28() {95+ assertFalse(96+ TrustedBrowsers.certificateSetMatches(97+ expected = setOf("aa"),98+ current = emptySet(),99+ history = emptySet(),100+ apiLevel = 28,101+ ),102+ )103+ }104+105+ @Test fun empty_current_rejects_on_api26() {106+ assertFalse(107+ TrustedBrowsers.certificateSetMatches(108+ expected = setOf("aa"),109+ current = emptySet(),110+ history = emptySet(),111+ apiLevel = 26,112+ ),113+ )114+ }115+116+ // certificateSetMatches: API 26/27 vs 28+ divergence117+ // On API 26/27, only current signers exist (no history parameter is used).118+ // On API 28+, history is available but must NOT be used for acceptance.119+120+ @Test fun api26_ignores_history_parameter() {121+ // Even if history is non-empty on API 26, only current matters.122+ // (In practice, history is always empty on API 26/27.)123+ assertFalse(124+ TrustedBrowsers.certificateSetMatches(125+ expected = setOf("aa"),126+ current = setOf("bb"),127+ history = setOf("AA"),128+ apiLevel = 26,129+ ),130+ )131+ }132+133+ @Test fun api27_current_match_accepts() {134+ assertTrue(135+ TrustedBrowsers.certificateSetMatches(136+ expected = setOf("aa"),137+ current = setOf("AA"),138+ history = emptySet(),139+ apiLevel = 27,140+ ),141+ )142+ }143+144+ // Registry provenance: all pins must be UNCONFIRMED145+146+ @Test fun all_registry_pins_are_unconfirmed() {147+ assertTrue(148+ "every registry entry must be UNCONFIRMED until provenance is recorded",149+ TrustedBrowsers.allPinsUnconfirmed(),150+ )151+ }152+153+ @Test fun registered_packages_include_expected_browsers() {154+ val packages = TrustedBrowsers.registeredPackages()155+ assertTrue(packages.contains("com.android.chrome"))156+ assertTrue(packages.contains("org.mozilla.firefox"))157+ assertTrue(packages.contains("com.brave.browser"))158+ }159+160+ // isTrusted returns false for every pinned package while unconfirmed161+162+ @Test fun no_package_is_trusted_while_all_pins_unconfirmed() {163+ // hasLocallyConfirmedPins must return false for every registered package.164+ for (pkg in TrustedBrowsers.registeredPackages()) {165+ assertFalse(166+ "$pkg must not be trusted while its pin is UNCONFIRMED",167+ TrustedBrowsers.hasLocallyConfirmedPins(pkg),168+ )169+ }170+ }171+172+ // hasExpectedCertificate: positive and negative cases173+174+ @Test fun hasExpectedCertificate_positive_for_chrome() {175+ assertTrue(176+ TrustedBrowsers.hasExpectedCertificate(177+ "com.android.chrome",178+ "f0fd6c5b410f25cb25c3b53346c8972fae30f8ee7411df910480ad6b2d60db83",179+ ),180+ )181+ }182+183+ @Test fun hasExpectedCertificate_negative_for_unknown_package() {184+ assertFalse(185+ TrustedBrowsers.hasExpectedCertificate(186+ "com.evil.browser",187+ "f0fd6c5b410f25cb25c3b53346c8972fae30f8ee7411df910480ad6b2d60db83",188+ ),189+ )190+ }191+192+ @Test fun hasExpectedCertificate_negative_for_wrong_hash() {193+ assertFalse(194+ TrustedBrowsers.hasExpectedCertificate(195+ "com.android.chrome",196+ "0000000000000000000000000000000000000000000000000000000000000000",197+ ),198+ )199+ }200+201+ // Case-insensitive matching202+203+ @Test fun certificateSetMatches_is_case_insensitive() {204+ assertTrue(205+ TrustedBrowsers.certificateSetMatches(206+ expected = setOf("AABBCC"),207+ current = setOf("aabbcc"),208+ history = emptySet(),209+ apiLevel = 28,210+ ),211+ )212+ }213+}app/src/test/java/com/peachpasswords/passkeys/PasskeyRegistrationTest.kt+30 −5
@@ -68,27 +68,52 @@6868 assertEquals(null, result)6969 }7070 71- @Test fun registration_writes_backup_eligible_true_and_correct_flag_byte() {71+ @Test fun registration_sets_uv_bit_when_user_verified() {7272 val result = PasskeyRegistration.build(7373 requestJson = """7474 {"challenge":"Y2hhbGxlbmdl","rp":{"id":"example.com","name":"Example"},"user":{"id":"dXNlcg","name":"june","displayName":"June"},"pubKeyCredParams":[{"type":"public-key","alg":-7}]}7575 """.trimIndent(),7676 origin = "https://example.com",77+ userVerified = true,7778 )7879 7980 assertNotNull(result)8081 result!!8182 val passkey = result.entryDraft.passkey!!82- // Persisted backup state must match the flag byte that's put on the83- // wire — the audit finding was that these contradicted each other.83+ // Persisted backup state must match the flag bits on the wire —84+ // the prior audit finding was that these contradicted each other.8485 assertEquals(true, passkey.backupEligible)8586 assertEquals(false, passkey.backupState)8687 8788 val payload = Json.parseToJsonElement(result.responseJson).jsonObject["response"]!!.jsonObject8889 val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)89- // UP(0x01) | UV(0x04) | BE(0x08) | AT(0x40) = 0x4d. BS (0x10) is NOT90- // set because backupState is false at registration time.90+ // H-07: UV=1 is warranted only when the ceremony actually ran.91+ // UP(0x01) | UV(0x04) | BE(0x08) | AT(0x40) = 0x4d.9192 assertEquals(0x4d, authData[32].toInt() and 0xff)93+ }94+95+ @Test fun registration_clears_uv_bit_when_user_not_verified() {96+ val result = PasskeyRegistration.build(97+ requestJson = """98+ {"challenge":"Y2hhbGxlbmdl","rp":{"id":"example.com","name":"Example"},"user":{"id":"dXNlcg","name":"june","displayName":"June"},"pubKeyCredParams":[{"type":"public-key","alg":-7}]}99+ """.trimIndent(),100+ origin = "https://example.com",101+ userVerified = false,102+ )103+104+ assertNotNull(result)105+ result!!106+ val passkey = result.entryDraft.passkey!!107+ // Backup semantics are unchanged by UV: BE stays true, BS stays108+ // false at registration time regardless of the ceremony outcome.109+ assertEquals(true, passkey.backupEligible)110+ assertEquals(false, passkey.backupState)111+112+ val payload = Json.parseToJsonElement(result.responseJson).jsonObject["response"]!!.jsonObject113+ val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)114+ // H-07: no ceremony, no UV claim. UP(0x01) | BE(0x08) | AT(0x40) = 0x49;115+ // UV (0x04) must be CLEAR, BS (0x10) must be CLEAR.116+ assertEquals(0x49, authData[32].toInt() and 0xff)92117 }93118 94119 private fun b64Decode(value: String): ByteArray =app/src/test/java/com/peachpasswords/passkeys/PasskeyUvFailClosedTest.kt+41 −0
@@ -0,0 +1,41 @@1+package com.peachpasswords.passkeys2+3+import org.junit.Assert.assertFalse4+import org.junit.Assert.assertTrue5+import org.junit.Test6+7+/**8+ * H-07: WebAuthn user-verification fail-closed decision. If the relying party9+ * requires UV and the authenticator cannot verify the user, the assertion must10+ * NOT be signed. Tests the pure decision function extracted from the activity.11+ */12+class PasskeyUvFailClosedTest {13+14+ @Test fun required_and_verified_signs() {15+ assertTrue(16+ "UV required + user verified => sign the assertion",17+ shouldSignAssertion(uvRequired = true, userVerified = true),18+ )19+ }20+21+ @Test fun required_and_unverified_does_not_sign() {22+ assertFalse(23+ "UV required + user NOT verified => do NOT sign",24+ shouldSignAssertion(uvRequired = true, userVerified = false),25+ )26+ }27+28+ @Test fun not_required_and_verified_signs() {29+ assertTrue(30+ "UV not required + user verified => sign (UV=1 reflects ceremony)",31+ shouldSignAssertion(uvRequired = false, userVerified = true),32+ )33+ }34+35+ @Test fun not_required_and_unverified_signs() {36+ assertTrue(37+ "UV not required + user NOT verified => sign (UV=0 reflects ceremony)",38+ shouldSignAssertion(uvRequired = false, userVerified = false),39+ )40+ }41+}app/src/test/java/com/peachpasswords/security/SecureFlagCoverageTest.kt+36 −3
@@ -1,6 +1,7 @@11 package com.peachpasswords.security22 33 import javax.xml.parsers.DocumentBuilderFactory4+import org.junit.Assert.assertFalse45 import org.junit.Assert.assertTrue56 import org.junit.Test67 import org.w3c.dom.Element@@ -60,13 +61,45 @@6061 ?: java.io.File("../$relativePath")6162 val source = file.readText()6263 assertTrue(63- "$fqcn must set WindowManager.LayoutParams.FLAG_SECURE " +64+ "$fqcn must delegate secure ownership " +6465 "(separate window from MainActivity)",65- source.contains("WindowManager.LayoutParams.FLAG_SECURE"),66+ source.contains("SecureWindowPolicy"),6667 )6768 }6869 }6970 71+ @Test fun all_window_policy_transitions_are_centralized() {72+ val helper = java.io.File(73+ "app/src/main/java/com/peachpasswords/ui/components/SecureWindowPolicy.kt",74+ ).takeIf { it.exists() } ?: java.io.File(75+ "../app/src/main/java/com/peachpasswords/ui/components/SecureWindowPolicy.kt",76+ )77+ val source = helper.readText()78+ assertTrue(source.contains("installRelease"))79+ assertTrue(source.contains("leases"))80+ assertTrue(source.contains("releaseOwned"))81+ assertTrue(source.contains("clearFlags"))82+ // MainActivity and secret activities delegate to the owner; they must83+ // not carry a second ad-hoc clear/set lifecycle that can drift.84+ listOf(85+ "app/src/main/java/com/peachpasswords/MainActivity.kt",86+ "app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt",87+ "app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt",88+ ).forEach { relativePath ->89+ val file = java.io.File(relativePath).takeIf { it.exists() }90+ ?: java.io.File("../$relativePath")91+ val activitySource = file.readText()92+ assertTrue(93+ "$relativePath must delegate secure policy ownership",94+ activitySource.contains("SecureWindowPolicy"),95+ )96+ assertFalse(97+ "$relativePath must not clear FLAG_SECURE directly",98+ activitySource.contains("clearFlags("),99+ )100+ }101+ }102+70103 private fun parseActivityNames(relativePath: String): Set<String> {71104 val file = java.io.File(relativePath).takeIf { it.exists() }72105 ?: java.io.File("../$relativePath")@@ -86,4 +119,4 @@86119 val cls = fqcn.substringAfterLast('.')87120 return "app/src/main/java/" + pkg.replace('.', '/') + "/$cls.kt"88121 }89-}\ No newline at end of file122+}