AutofillIntents.kt

9.2 KB · raw

1package com.peachpasswords.autofill23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.content.IntentSender7import android.os.Build89/**10 * PendingIntents the autofill surfaces hand to the framework: a no-op11 * attribution target the inline chip requires, the authentication12 * IntentSender that opens the branded [AutofillEntryPickerActivity], and the13 * "open new entry" launcher that the picker fires when the user taps the14 * "+ Create new entry" affordance.15 */16object AutofillIntents {17    const val EXTRA_IDENTITY_ID = "peach.autofill.identityId"18    const val EXTRA_PASSWORD_ID = "peach.autofill.passwordId"19    const val EXTRA_OTP_ID = "peach.autofill.otpId"20    const val EXTRA_OTP_DIGIT_BOX_IDS = "peach.autofill.otpDigitBoxIds"21    const val EXTRA_ORIGIN = "peach.autofill.origin"22    const val EXTRA_IS_WEB_ORIGIN = "peach.autofill.isWebOrigin"23    /** Authenticated caller package so the picker can re-resolve web-origin domains in-process. */24    const val EXTRA_CALLER_PACKAGE = "peach.autofill.callerPackage"2526    const val EXTRA_CARD_NUMBER_ID = "peach.autofill.cardNumberId"27    const val EXTRA_CARD_EXP_MONTH_ID = "peach.autofill.cardExpMonthId"28    const val EXTRA_CARD_EXP_YEAR_ID = "peach.autofill.cardExpYearId"29    const val EXTRA_CARD_EXP_DATE_ID = "peach.autofill.cardExpDateId"30    const val EXTRA_CARD_CVC_ID = "peach.autofill.cardCvcId"31    const val EXTRA_CARD_NAME_ID = "peach.autofill.cardNameId"3233    /** Host-typed username captured by the autofill framework — used to prefill the new-entry editor. */34    const val EXTRA_TYPED_USERNAME = "peach.autofill.typedUsername"35    /** Host-typed email captured by the autofill framework — used to prefill the new-entry editor. */36    const val EXTRA_TYPED_EMAIL = "peach.autofill.typedEmail"3738    /**39     * Sent by [AutofillEntryPickerActivity] to launch MainActivity on the "+ Create40     * new entry" path. The picker cancels itself and starts MainActivity with the41     * origin + typed identity so the user can save a brand-new entry pre-filled with42     * what they had typed on the host page. Carries the same key/value shape as the43     * update notification's [com.peachpasswords.updates.EXTRA_OPEN_UPDATE].44     */45    const val EXTRA_OPEN_NEW_ENTRY = "peach.autofill.openNewEntry"46    const val EXTRA_NEW_ENTRY_URL = "peach.autofill.newEntryUrl"47    const val EXTRA_NEW_ENTRY_USERNAME = "peach.autofill.newEntryUsername"48    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"5657    private const val REQ_CHIP = 0xC0DE58    private const val REQ_PICKER = 0xBEEF59    private const val REQ_PICKER_CHIP = 0xBEE060    private const val REQ_NEW_ENTRY_CHIP = 0xBEE16162    @Volatile private var pendingNewEntryToken: String? = null6364    /** F3: the picker calls this once per launch; the token lives only in memory. */65    fun issueNewEntryToken(): String {66        val token = java.util.UUID.randomUUID().toString()67        pendingNewEntryToken = token68        return token69    }7071    /**72     * F3: single-use check. A wrong token does NOT burn the pending one, so a73     * racing spoofed intent can only fail, not DoS the real handoff.74     */75    fun consumeNewEntryToken(token: String?): Boolean {76        val expected = pendingNewEntryToken ?: return false77        if (token != expected) return false78        pendingNewEntryToken = null79        return true80    }8182    /** Required attribution for an inline suggestion; tapping it is a no-op. */83    fun noopChipIntent(context: Context): PendingIntent {84        val intent = Intent(context, AutofillEntryPickerActivity::class.java)85        return PendingIntent.getActivity(context, REQ_CHIP, intent, immutableFlags())86    }8788    /**89     * Inline chip PendingIntent for the "Search Peach…" picker chip: tapping it opens90     * the picker activity with the parsed structure so the user can browse the91     * full vault. The system menu row for the same dataset uses [pickerIntentSender]92     * (an IntentSender routed through the framework auth path).93     */94    fun pickerChipPendingIntent(context: Context, parsed: ParsedStructure): PendingIntent {95        val intent = Intent(context, AutofillEntryPickerActivity::class.java).apply {96            putExtra(EXTRA_IDENTITY_ID, parsed.identityId)97            putExtra(EXTRA_PASSWORD_ID, parsed.passwordId)98            putExtra(EXTRA_OTP_ID, parsed.otpId)99            putParcelableArrayListExtra(EXTRA_OTP_DIGIT_BOX_IDS, ArrayList(parsed.otpDigitBoxIds))100            putExtra(EXTRA_ORIGIN, parsed.origin)101            putExtra(EXTRA_IS_WEB_ORIGIN, parsed.webDomain != null)102            putExtra(EXTRA_CALLER_PACKAGE, parsed.packageName)103            putExtra(EXTRA_CARD_NUMBER_ID, parsed.cardNumberId)104            putExtra(EXTRA_CARD_EXP_MONTH_ID, parsed.cardExpMonthId)105            putExtra(EXTRA_CARD_EXP_YEAR_ID, parsed.cardExpYearId)106            putExtra(EXTRA_CARD_EXP_DATE_ID, parsed.cardExpDateId)107            putExtra(EXTRA_CARD_CVC_ID, parsed.cardCvcId)108            putExtra(EXTRA_CARD_NAME_ID, parsed.cardNameId)109            parsed.usernameValue?.trim()?.takeIf { it.isNotBlank() }?.let {110                putExtra(EXTRA_TYPED_USERNAME, it)111            }112            parsed.emailValue?.trim()?.takeIf { it.isNotBlank() }?.let {113                putExtra(EXTRA_TYPED_EMAIL, it)114            }115        }116        return PendingIntent.getActivity(context, REQ_PICKER_CHIP, intent, immutableFlags())117    }118119    /**120     * Inline chip PendingIntent for the "+ Save to Peach" create chip: tapping it121     * opens MainActivity on the new-entry editor pre-filled with the typed122     * identity and origin. F3 single-use token gates the launch: MainActivity123     * is exported, so without a fresh token any app could pre-fill the new-entry124     * editor with attacker-chosen url/username/email.125     */126    fun newEntryChipPendingIntent(127        context: Context,128        parsed: ParsedStructure,129        origin: String?,130    ): PendingIntent {131        val intent = Intent(context, com.peachpasswords.MainActivity::class.java).apply {132            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)133            putExtra(EXTRA_OPEN_NEW_ENTRY, true)134            putExtra(EXTRA_NEW_ENTRY_TOKEN, issueNewEntryToken())135            origin?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_NEW_ENTRY_URL, it) }136            parsed.usernameValue?.trim()?.takeIf { it.isNotBlank() }?.let {137                putExtra(EXTRA_NEW_ENTRY_USERNAME, it)138            }139            parsed.emailValue?.trim()?.takeIf { it.isNotBlank() }?.let {140                putExtra(EXTRA_NEW_ENTRY_EMAIL, it)141            }142        }143        return PendingIntent.getActivity(context, REQ_NEW_ENTRY_CHIP, intent, immutableFlags())144    }145146    /** Authentication target for the "Search Peach…" dataset. */147    fun pickerIntentSender(context: Context, parsed: ParsedStructure): IntentSender {148        val intent = Intent(context, AutofillEntryPickerActivity::class.java).apply {149            putExtra(EXTRA_IDENTITY_ID, parsed.identityId)150            putExtra(EXTRA_PASSWORD_ID, parsed.passwordId)151            putExtra(EXTRA_OTP_ID, parsed.otpId)152            putParcelableArrayListExtra(EXTRA_OTP_DIGIT_BOX_IDS, ArrayList(parsed.otpDigitBoxIds))153            putExtra(EXTRA_ORIGIN, parsed.origin)154            putExtra(EXTRA_IS_WEB_ORIGIN, parsed.webDomain != null)155            // The picker re-resolves origin domains/app title in-process from156            // the authenticated caller package.157            putExtra(EXTRA_CALLER_PACKAGE, parsed.packageName)158            putExtra(EXTRA_CARD_NUMBER_ID, parsed.cardNumberId)159            putExtra(EXTRA_CARD_EXP_MONTH_ID, parsed.cardExpMonthId)160            putExtra(EXTRA_CARD_EXP_YEAR_ID, parsed.cardExpYearId)161            putExtra(EXTRA_CARD_EXP_DATE_ID, parsed.cardExpDateId)162            putExtra(EXTRA_CARD_CVC_ID, parsed.cardCvcId)163            putExtra(EXTRA_CARD_NAME_ID, parsed.cardNameId)164            // Mirror the browser's preUsername/preEmail capture so the "+ Create165            // new entry" path can hand the typed identity to the new-entry editor.166            // Empty/blank values are dropped — the picker reads the typed username167            // from the identity field the host anchored on.168            parsed.usernameValue?.trim()?.takeIf { it.isNotBlank() }?.let {169                putExtra(EXTRA_TYPED_USERNAME, it)170            }171            parsed.emailValue?.trim()?.takeIf { it.isNotBlank() }?.let {172                putExtra(EXTRA_TYPED_EMAIL, it)173            }174        }175        // Freezing the sender prevents the host app from rewriting origin176        // extras before the private picker receives them. The picker re-resolves177        // the origin from raw authenticated inputs in-process.178        return PendingIntent.getActivity(context, REQ_PICKER, intent, immutableFlags()).intentSender179    }180181    private fun immutableFlags(): Int =182        PendingIntent.FLAG_UPDATE_CURRENT or183            if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0184185}