51ddbe3f3f3a

fix: autofill requires verified origin before TOTP release, remove pre-selection clipboard write (audit PB-26-019) (v2.14.69)

time
2026-08-30 23:50 UTC · 1 month ago
machine
koh2
agent
GPT-5
session
PEACH_ANDROID_INDEPENDENT_AUDIT_2026-08-30
parent
4bd9f6daf016
12 files VERSION+1 −1app/build.gradle+1 −0app/src/androidTest/AndroidManifest.xml+3 −0app/src/androidTest/java/com/peachpasswords/qa/QaFormActivity.java+3 −0app/src/androidTest/java/com/peachpasswords/qa/QaFormTwoFactorChipTest.kt+24 −38app/src/androidTest/java/com/peachpasswords/qa/QaSpoofedWebViewActivity.java+28 −0app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+3 −4app/src/main/java/com/peachpasswords/autofill/AutofillOrigin.kt+10 −9app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+3 −3app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+0 −26app/src/main/java/com/peachpasswords/passkeys/PasskeyCallerOrigin.kt+52 −0app/src/test/java/com/peachpasswords/passkeys/PasskeyCallerOriginTest.kt+23 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.681+2.14.69

view file

app/build.gradle+1 −0
@@ -205,6 +205,7 @@205205     androidTestImplementation "androidx.test.ext:junit:1.2.1"206206     androidTestImplementation "androidx.test:core:1.6.1"207207     androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1"208+    androidTestImplementation "androidx.test.uiautomator:uiautomator:2.3.0"208209     androidTestImplementation "androidx.compose.ui:ui-test-junit4"209210 210211     testImplementation "junit:junit:4.13.2"

view file

app/src/androidTest/AndroidManifest.xml+3 −0
@@ -4,5 +4,8 @@44         <activity55             android:name="com.peachpasswords.qa.QaFormActivity"66             android:exported="true" />7+        <activity8+            android:name="com.peachpasswords.qa.QaSpoofedWebViewActivity"9+            android:exported="true" />710     </application>811 </manifest>

view file

app/src/androidTest/java/com/peachpasswords/qa/QaFormActivity.java+3 −0
@@ -22,12 +22,14 @@2222         username.setId(android.R.id.edit);2323         username.setHint("Username");2424         username.setAutofillHints("username");25+        username.setContentDescription("Username field");2526         username.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES);2627 2728         EditText password = new EditText(this);2829         password.setHint("Password");2930         password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);3031         password.setAutofillHints("password");32+        password.setContentDescription("Password field");3133         password.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES);3234 3335         // 2FA field for the OTP chip QA path (E2-fix). The hint goes via@@ -37,6 +39,7 @@3739         otp.setHint("2FA Code");3840         otp.setInputType(InputType.TYPE_CLASS_NUMBER);3941         otp.setAutofillHints("otp");42+        otp.setContentDescription("2FA code field");4043         otp.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES);4144 4245         LinearLayout layout = new LinearLayout(this);

view file

app/src/androidTest/java/com/peachpasswords/qa/QaFormTwoFactorChipTest.kt+24 −38
@@ -5,7 +5,9 @@55 import android.content.Context66 import androidx.test.ext.junit.runners.AndroidJUnit477 import androidx.test.platform.app.InstrumentationRegistry8-import org.junit.Assert.assertEquals8+import androidx.test.uiautomator.By9+import androidx.test.uiautomator.UiDevice10+import androidx.test.uiautomator.Until911 import java.io.BufferedReader1012 import java.io.InputStreamReader1113 import org.junit.Assert.assertTrue@@ -29,8 +31,7 @@2931         shell("am force-stop com.peachpasswords.debug.test")3032         shell("am start -n com.peachpasswords.debug.test/com.peachpasswords.qa.QaFormActivity")3133         Thread.sleep(2000)32-        // Username field: top of screen after the 96px top padding.33-        shell("input tap 360 135")34+        focusField("Username field")3435         Thread.sleep(4000)3536         val dump = shell("dumpsys autofill")3637         val mentions = dump.lineSequence()@@ -44,41 +45,20 @@4445         )4546     }4647 47-    /**48-     * E2-fix followup: when Peach autofills a login whose entry carries a49-     * TOTP, the freshly-generated code is also copied to the clipboard so50-     * the user has a "long-press the 2FA field -> Paste" safety net even51-     * when the OTP chip fails to surface (custom labels, spec-slot52-     * mismatches, autofill lifecycle gaps).53-     *54-     * v2.14.6: @Ignore'd -- the test process and the autofill-service55-     * process own separate VaultRepository instances, so unlocking the56-     * vault in the test process does NOT unlock it in the autofill57-     * service process that runs the onFillRequest handler. The clipboard58-     * copy is gated on `if (unlocked)` inside the service, so it never59-     * fires from a connected test today. A real fix needs an in-process60-     * debug-only IPC or running the autofill service inside the test61-     * process -- separate scope from the picker fix and tracked in the62-     * v2.14.7 backlog.63-     */64-    @Test @org.junit.Ignore("cross-process vault unlock; deferred to v2.14.7")65-    fun totp_code_copied_to_clipboard_after_autofill() {48+    /** A spoofed web domain must not cause any pre-selection clipboard write. */49+    @Test50+    fun malicious_companion_spoofed_web_domain_does_not_copy_totp() {6651         shell("am force-stop com.peachpasswords.debug.test")67-        shell("am start -n com.peachpasswords.debug.test/com.peachpasswords.qa.QaFormActivity")52+        val clipboard = InstrumentationRegistry.getInstrumentation().targetContext53+            .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager54+        clipboard.setPrimaryClip(ClipData.newPlainText("sentinel", "user-owned-clipboard"))55+        shell("am start -n com.peachpasswords.debug.test/com.peachpasswords.qa.QaSpoofedWebViewActivity")6856         Thread.sleep(2000)69-        shell("input tap 360 135")57+        focusField("Spoofed username field")7058         Thread.sleep(4000)71-        val clip = InstrumentationRegistry.getInstrumentation().targetContext72-            .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager73-        val text = clip.primaryClip?.getItemAt(0)?.text?.toString()74-        assertEquals(75-            "Peach 2FA",76-            clip.primaryClip?.description?.label?.toString(),77-        )78-        // TOTP code is 6 digits; assert the clipboard carries exactly that.7959         assertTrue(80-            "clipboard value is not a 6-digit TOTP code: \"$text\"",81-            text != null && text.matches(Regex("\\d{6}")),60+            "malicious autofill origin must not overwrite the user's clipboard",61+            clipboard.primaryClip?.getItemAt(0)?.text?.toString() == "user-owned-clipboard",8262         )8363     }8464 @@ -102,10 +82,9 @@10282         shell("am force-stop com.peachpasswords.debug.test")10383         shell("am start -n com.peachpasswords.debug.test/com.peachpasswords.qa.QaFormActivity")10484         Thread.sleep(2000)105-        // 2FA field is the third EditText in QaFormActivity's LinearLayout106-        // (96px top padding, ~120px per field). Tap its center to focus107-        // only the 2FA field rather than the username field.108-        shell("input tap 360 440")85+        // Select the field by its stable semantic description so layout changes86+        // cannot make this probe tap the wrong control.87+        focusField("2FA code field")10988         Thread.sleep(4000)11089         val dump = shell("dumpsys autofill")11190         val mentions = dump.lineSequence()@@ -117,5 +96,12 @@11796                 "dumpsys excerpt:\n$mentions",11897             mentions.isNotEmpty(),11998         )99+    }100+101+    private fun focusField(contentDescription: String) {102+        val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())103+        val selector = By.desc(contentDescription)104+        assertTrue("field not found: $contentDescription", device.wait(Until.hasObject(selector), 5_000))105+        device.findObject(selector).click()120106     }121107 }

view file

app/src/androidTest/java/com/peachpasswords/qa/QaSpoofedWebViewActivity.java+28 −0
@@ -0,0 +1,28 @@1+package com.peachpasswords.qa;2+3+import android.app.Activity;4+import android.os.Bundle;5+import android.view.View;6+import android.webkit.WebView;7+8+/** Companion app page that claims a victim domain without being a verified browser. */9+public class QaSpoofedWebViewActivity extends Activity {10+    @Override11+    protected void onCreate(Bundle savedInstanceState) {12+        super.onCreate(savedInstanceState);13+        WebView webView = new WebView(this);14+        webView.setContentDescription("Spoofed victim-domain page");15+        webView.getSettings().setJavaScriptEnabled(true);16+        webView.loadDataWithBaseURL(17+                "https://github.com/login",18+                "<form><input aria-label='Spoofed username field' autocomplete='username' autofocus>"19+                        + "<input aria-label='Spoofed password field' type='password' autocomplete='current-password'>"20+                        + "<input aria-label='Spoofed 2FA field' autocomplete='one-time-code'></form>",21+                "text/html",22+                "UTF-8",23+                null24+        );25+        webView.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES);26+        setContentView(webView);27+    }28+}

view file

app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+3 −4
@@ -9,10 +9,9 @@99 * The autofillable fields discovered in one [AssistStructure], plus the origin1010 * Android authenticated for ranking vault entries.1111  *12- * Origin verification was removed (queue #3, June 2026-08-02): matches are13- * ranked by [originDomains] (the page domain, or a native app's app-link14- * domains) and [appTitle] (a native app's display label), with no caller-trust15- * gate. [originDomains]/[appTitle] are populated by the service, not the parser.12+ * [webDomain] is untrusted form metadata. The service may use it only after13+ * verifying the framework-authenticated package and its signing identity.14+ * [originDomains]/[appTitle] are populated by the service, not the parser.1615 */1716 data class ParsedStructure(1817     val usernameId: AutofillId? = null,

view file

app/src/main/java/com/peachpasswords/autofill/AutofillOrigin.kt+10 −9
@@ -5,28 +5,29 @@55 import android.content.pm.verify.domain.DomainVerificationUserState66 import android.os.Build77 import com.peachpasswords.shared.extractDomain8+import com.peachpasswords.passkeys.PasskeyCallerOrigin89 import com.peachpasswords.vault.VaultEntry910 1011 /**1112  * Origin resolution for autofill matching.1213  *13- * Origin *verification* (browser certificate pinning + caller-trust gating) was14- * removed (queue #3, June 2026-08-02) — it flagged nearly every request as15- * "unverified" and withheld matches, adding friction without value. Matches are16- * now ranked purely by content: the page domain for web origins, and a native17- * app's app-link domains + display label for app origins. The caller package is18- * still the framework-authenticated identity (never a caller-supplied extra).14+ * The caller package comes from the framework-owned activity component. A raw15+ * ViewNode.webDomain is only accepted when that package is a signed browser in16+ * the same allowlist used by passkeys; otherwise the request fails closed.1917  */2018 2119 /**2220  * Populate [ParsedStructure.originDomains] and [ParsedStructure.appTitle] from23- * the authenticated origin. Web origins match on the page domain; native apps24- * match on their app-link domains and their display label. No trust25- * classification — every origin is eligible to see its matching entries.21+ * the authenticated origin. Verified browsers match on the page domain; native22+ * apps match on their app-link domains and display label. Unverified web claims23+ * produce no origin matches.2624  */2725 internal fun resolveAutofillOrigin(context: Context, parsed: ParsedStructure): ParsedStructure {2826     val webDomain = parsed.webDomain2927     if (webDomain != null) {28+        if (!PasskeyCallerOrigin.isVerifiedBrowser(context, parsed.packageName)) {29+            return parsed.copy(webDomain = null, originDomains = emptyList(), appTitle = null)30+        }3031         return parsed.copy(originDomains = listOfNotNull(extractDomain(webDomain)), appTitle = null)3132     }3233     return parsed.copy(

view file

app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+3 −3
@@ -46,9 +46,9 @@4646     }4747 4848     /**49-     * Rank entries for an origin with no caller-trust gate (origin verification50-     * removed, queue #3). Web origins match by the page domain; native apps match51-     * by their app-link domains or display title. Blank origins match nothing.49+     * Rank entries for the already-resolved origin. The service verifies web50+     * caller identity before populating web domains; native apps use their51+     * verified app-link domains or display title. Blank origins match nothing.5252      */5353     fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {5454         val domains = parsed.originDomains.mapNotNull(::extractDomain).toSet()

view file

app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+0 −26
@@ -10,8 +10,6 @@1010 import android.service.autofill.SaveInfo1111 import android.service.autofill.SaveRequest1212 import com.peachpasswords.vault.VaultRepository13-import com.peachpasswords.shared.PeachClipboard14-import com.peachpasswords.vault.TOTPGenerator1513 import com.peachpasswords.vault.isCard1614 import dagger.hilt.android.AndroidEntryPoint1715 import kotlinx.coroutines.CoroutineScope@@ -111,30 +109,6 @@111109                         datasetCount++112110                     }113111                 }114-            }115-            // Clipboard safety net: when ANY matched entry has a TOTP, copy116-            // its freshly-generated code to the clipboard so the user can117-            // long-press the 2FA field → Paste even if the OTP chip fails118-            // to surface (custom labels, spec-slot mismatches, lifecycle119-            // gaps). PeachClipboard.copy marks the clip IS_SENSITIVE and120-            // auto-clears after 30s — never stomps a later user copy.121-            // Wrapped in runCatching so a clipboard-service hiccup can122-            // never crash the FillRequest handler (which would leave the123-            // user with no chips at all).124-            runCatching {125-                matches.firstOrNull { !it.totp.isNullOrBlank() }?.let { entry ->126-                    TOTPGenerator.generateCode(entry.totp!!)?.let { code ->127-                        PeachClipboard.copy(context, label = "Peach 2FA", value = code, hint = true)128-                        if (com.peachpasswords.BuildConfig.DEBUG) {129-                            android.util.Log.d(130-                                "PeachAutofill",131-                                "2FA clipboard copy fired (entry=" + entry.title + ", code=" + code + ")",132-                            )133-                        }134-                    }135-                }136-            }.onFailure { t ->137-                android.util.Log.w("PeachAutofill", "2FA clipboard copy failed", t)138112             }139113         }140114 

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCallerOrigin.kt+52 −0
@@ -1,10 +1,12 @@11 package com.peachpasswords.passkeys22 33 import android.content.Context4+import android.content.pm.PackageManager45 import android.os.Build56 import androidx.credentials.provider.CallingAppInfo67 import com.peachpasswords.R78 import com.peachpasswords.shared.extractDomain9+import org.json.JSONObject810 import java.net.URI911 import java.security.MessageDigest1012 import java.util.Base64@@ -36,6 +38,56 @@3638         cachedAllowlist = loaded3739         return loaded3840     }41+42+    /**43+     * Autofill has no CallingAppInfo object, so its framework-authenticated44+     * AssistStructure activity package must be checked against the same signed45+     * browser allowlist used by passkeys before a ViewNode webDomain is useful.46+     */47+    fun isVerifiedBrowser(context: Context, packageName: String?): Boolean {48+        if (Build.VERSION.SDK_INT < 28 || packageName.isNullOrBlank()) return false49+        val signingInfo = runCatching {50+            context.packageManager.getPackageInfo(51+                packageName,52+                PackageManager.GET_SIGNING_CERTIFICATES,53+            ).signingInfo54+        }.getOrNull() ?: return false55+        val signers = if (signingInfo.hasMultipleSigners()) {56+            signingInfo.apkContentsSigners57+        } else {58+            signingInfo.signingCertificateHistory59+        }60+        return isAllowlistedBrowser(61+            packageName = packageName,62+            certificateFingerprints = signers.map { certificateFingerprint(it.toByteArray()) }.toSet(),63+            allowlistJson = privilegedAllowlist(context),64+        )65+    }66+67+    /** Pure allowlist predicate so the package-and-signer boundary is unit-testable. */68+    internal fun isAllowlistedBrowser(69+        packageName: String,70+        certificateFingerprints: Set<String>,71+        allowlistJson: String,72+    ): Boolean {73+        val apps = runCatching { JSONObject(allowlistJson).optJSONArray("apps") }.getOrNull() ?: return false74+        for (index in 0 until apps.length()) {75+            val app = apps.optJSONObject(index)?.optJSONObject("info") ?: continue76+            if (app.optString("package_name") != packageName) continue77+            val signatures = app.optJSONArray("signatures") ?: return false78+            return (0 until signatures.length()).any { signatureIndex ->79+                val expected = signatures.optJSONObject(signatureIndex)80+                    ?.optString("cert_fingerprint_sha256")81+                    ?.uppercase(Locale.ROOT)82+                expected != null && certificateFingerprints.any { it.uppercase(Locale.ROOT) == expected }83+            }84+        }85+        return false86+    }87+88+    internal fun certificateFingerprint(certificateBytes: ByteArray): String =89+        MessageDigest.getInstance("SHA-256").digest(certificateBytes)90+            .joinToString(":") { byte -> "%02X".format(Locale.ROOT, byte.toInt() and 0xFF) }3991 4092     /**4193      * The verified origin of a credential-provider caller:

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyCallerOriginTest.kt+23 −0
@@ -58,4 +58,27 @@5858         // Legacy credentials are never released to native-app callers.5959         assertFalse(PasskeyCallerOrigin.canServeCredential(null, "example.com", appA))6060     }61+62+    @Test fun browser_allowlist_requires_both_package_and_signing_certificate() {63+        val fingerprint = PasskeyCallerOrigin.certificateFingerprint("browser-cert".toByteArray())64+        val allowlist = """65+            {"apps":[{"info":{"package_name":"com.example.browser","signatures":[{"cert_fingerprint_sha256":"$fingerprint"}]}}]}66+        """.trimIndent()67+68+        assertTrue(69+            PasskeyCallerOrigin.isAllowlistedBrowser(70+                "com.example.browser", setOf(fingerprint.lowercase()), allowlist,71+            ),72+        )73+        assertFalse(74+            PasskeyCallerOrigin.isAllowlistedBrowser(75+                "com.example.malicious", setOf(fingerprint), allowlist,76+            ),77+        )78+        assertFalse(79+            PasskeyCallerOrigin.isAllowlistedBrowser(80+                "com.example.browser", setOf("00:11:22"), allowlist,81+            ),82+        )83+    }6184 }

view file