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
↓ peach-android-51ddbe3f.tar.gzatlas at this save
files changed12 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 −0app/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"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>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);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 }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+}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,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(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()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 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: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 }