6eb341d43801
fix: authenticate the picker→MainActivity new-entry handoff (v2.10.17)
- time
- 2026-08-03 23:43 UTC · 2 months ago
- machine
- koh2
- parent
- 0413521f37b3
↓ peach-android-6eb341d4.tar.gzatlas at this save
files changed5 files
VERSION+1 −1app/src/main/java/com/peachpasswords/MainActivity.kt+3 −0app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+1 −0app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+27 −0app/src/test/java/com/peachpasswords/autofill/AutofillNewEntryTokenTest.kt+36 −0app/src/main/java/com/peachpasswords/MainActivity.kt+3 −0
@@ -162,6 +162,9 @@162162 */163163 private fun MainActivity.forwardNewEntryRequest(intent: Intent) {164164 if (!intent.getBooleanExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, false)) return165+ // F3: MainActivity is exported; only accept the prefill payload when the166+ // one-time token proves the launch came from the in-process picker.167+ if (!AutofillIntents.consumeNewEntryToken(intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN))) return165168 autofillNav.requestNewEntry(166169 url = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL),167170 username = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME),app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+1 −0
@@ -363,6 +363,7 @@363363 val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin364364 val intent = Intent(this, MainActivity::class.java).apply {365365 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)366+ putExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN, AutofillIntents.issueNewEntryToken())366367 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)367368 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)368369 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+27 −0
@@ -46,9 +46,36 @@4646 const val EXTRA_NEW_ENTRY_URL = "peach.autofill.newEntryUrl"4747 const val EXTRA_NEW_ENTRY_USERNAME = "peach.autofill.newEntryUsername"4848 const val EXTRA_NEW_ENTRY_EMAIL = "peach.autofill.newEntryEmail"49+ /**50+ * F3: one-time token proving a new-entry launch really came from the51+ * in-process picker. MainActivity is exported (launcher), so without this52+ * any app could pre-fill the new-entry editor with attacker-chosen53+ * url/username/email.54+ */55+ const val EXTRA_NEW_ENTRY_TOKEN = "peach.autofill.newEntryToken"4956 5057 private const val REQ_CHIP = 0xC0DE5158 private const val REQ_PICKER = 0xBEEF59+60+ @Volatile private var pendingNewEntryToken: String? = null61+62+ /** F3: the picker calls this once per launch; the token lives only in memory. */63+ fun issueNewEntryToken(): String {64+ val token = java.util.UUID.randomUUID().toString()65+ pendingNewEntryToken = token66+ return token67+ }68+69+ /**70+ * F3: single-use check. A wrong token does NOT burn the pending one, so a71+ * racing spoofed intent can only fail, not DoS the real handoff.72+ */73+ fun consumeNewEntryToken(token: String?): Boolean {74+ val expected = pendingNewEntryToken ?: return false75+ if (token != expected) return false76+ pendingNewEntryToken = null77+ return true78+ }5279 5380 /** Required attribution for an inline suggestion; tapping it is a no-op. */5481 fun noopChipIntent(context: Context): PendingIntent {app/src/test/java/com/peachpasswords/autofill/AutofillNewEntryTokenTest.kt+36 −0
@@ -0,0 +1,36 @@1+package com.peachpasswords.autofill2+3+import org.junit.Assert.assertFalse4+import org.junit.Assert.assertNotEquals5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+/**9+ * F3: the picker→MainActivity new-entry handoff is authenticated with a10+ * one-time in-memory token. MainActivity is exported, so any app can raise11+ * EXTRA_OPEN_NEW_ENTRY; only the token holder's payload may pre-fill.12+ */13+class AutofillNewEntryTokenTest {14+15+ @Test fun issued_token_is_accepted_exactly_once() {16+ val token = AutofillIntents.issueNewEntryToken()17+ assertTrue(AutofillIntents.consumeNewEntryToken(token))18+ assertFalse("token must be single-use", AutofillIntents.consumeNewEntryToken(token))19+ }20+21+ @Test fun wrong_or_missing_tokens_are_rejected_without_burning_the_pending_one() {22+ val token = AutofillIntents.issueNewEntryToken()23+ assertFalse(AutofillIntents.consumeNewEntryToken(null))24+ assertFalse(AutofillIntents.consumeNewEntryToken("attacker-guess"))25+ // The real handoff still succeeds after spoofed attempts.26+ assertTrue(AutofillIntents.consumeNewEntryToken(token))27+ }28+29+ @Test fun fresh_issue_supersedes_the_previous_token() {30+ val first = AutofillIntents.issueNewEntryToken()31+ val second = AutofillIntents.issueNewEntryToken()32+ assertNotEquals(first, second)33+ assertFalse(AutofillIntents.consumeNewEntryToken(first))34+ assertTrue(AutofillIntents.consumeNewEntryToken(second))35+ }36+}