PasswordCredentialSelectionActivity.kt

9.3 KB · raw

1package com.peachpasswords.credentials23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.os.Build7import android.os.Bundle8import androidx.credentials.CreatePasswordResponse9import androidx.credentials.GetCredentialResponse10import androidx.credentials.PasswordCredential11import androidx.credentials.exceptions.CreateCredentialUnsupportedException12import androidx.credentials.exceptions.GetCredentialUnsupportedException13import androidx.credentials.provider.CallingAppInfo14import androidx.credentials.provider.PendingIntentHandler15import androidx.fragment.app.FragmentActivity16import androidx.lifecycle.lifecycleScope17import com.peachpasswords.BuildConfig18import com.peachpasswords.autofill.AutofillSaveDraft19import com.peachpasswords.autofill.AutofillSavePlan20import com.peachpasswords.autofill.appLabel21import com.peachpasswords.autofill.appLinkDomains22import com.peachpasswords.autofill.planAutofillSave23import com.peachpasswords.passkeys.PasskeyCallerOrigin24import com.peachpasswords.shared.extractDomain25import com.peachpasswords.ui.components.QaFlags26import com.peachpasswords.ui.components.SecureWindowPolicy27import com.peachpasswords.vault.VaultRepository28import dagger.hilt.android.AndroidEntryPoint29import javax.inject.Inject30import kotlinx.coroutines.launch3132/**33 * Completes Credential Manager password selections. The vault was already34 * unlocked when rows were offered (the provider never emits rows while35 * locked), and the tap is the release boundary — the same posture as autofill36 * (PEACH-AND-01-002 risk acceptance).37 *38 * Get mode releases the credential; defense in depth re-checks the39 * framework-verified caller against the entry first, so a stale or replayed40 * pending intent cannot pull a credential for a different caller. Create mode41 * stores the new credential via the same add/update planner as the autofill42 * save flow (planAutofillSave). Both fail closed on any doubt.43 */44@AndroidEntryPoint45class PasswordCredentialSelectionActivity : FragmentActivity() {46    @Inject lateinit var vault: VaultRepository47    private var debugSecureLease = false4849    override fun onCreate(savedInstanceState: Bundle?) {50        super.onCreate(savedInstanceState)51        // This window briefly holds credential material on screen. Block52        // screenshots / recents exactly like the passkey selection surface.53        if (!BuildConfig.DEBUG) {54            SecureWindowPolicy.setUserSecure(window, true)55        } else if (!qaSecureCaptureBypass()) {56            SecureWindowPolicy.acquire(window)57            debugSecureLease = true58        }59        lifecycleScope.launch { completeSelection() }60    }6162    override fun onDestroy() {63        if (debugSecureLease) {64            SecureWindowPolicy.release(window)65            debugSecureLease = false66        }67        super.onDestroy()68    }6970    private fun qaSecureCaptureBypass(): Boolean =71        BuildConfig.DEBUG && (72            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||73                QaFlags.disableSecureCaptureForQa74            )7576    private suspend fun completeSelection() {77        if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {78            completeCreate()79            return80        }81        val result = Intent()82        val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)83        val entry = entryId?.let(vault::getEntry)84        val providerRequest = runCatching {85            PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)86        }.getOrNull()87        val callerMatches = providerRequest?.callingAppInfo?.let { info ->88            if (entry == null) {89                false90            } else {91                val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)92                val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()93                if (!webOrigin.isNullOrBlank()) {94                    val domain = extractDomain(webOrigin)95                    domain != null && PasswordCredentialPlanner96                        .candidates(listOf(entry), setOf(domain), null)97                        .isNotEmpty()98                } else {99                    PasswordCredentialPlanner.candidates(100                        listOf(entry),101                        appLinkDomains(this, info.packageName).toSet(),102                        appLabel(this, info.packageName),103                    ).isNotEmpty()104                }105            }106        } ?: false107        val username = entry?.username ?: entry?.email108        val password = entry?.password109        if (entry == null || entry.deletedAt != null || !callerMatches ||110            username.isNullOrBlank() || password.isNullOrEmpty()111        ) {112            PendingIntentHandler.setGetCredentialException(113                result,114                GetCredentialUnsupportedException("Peach cannot fill this login."),115            )116        } else {117            runCatching { vault.updateEntry(entry.copy(lastUsedAt = System.currentTimeMillis())) }118            PendingIntentHandler.setGetCredentialResponse(119                result,120                GetCredentialResponse(PasswordCredential(username, password)),121            )122        }123        setResult(RESULT_OK, result)124        finish()125    }126127    private suspend fun completeCreate() {128        val result = Intent()129        val id = intent.getStringExtra(EXTRA_CREATE_ID)130        val password = intent.getStringExtra(EXTRA_CREATE_PASSWORD)131        val providerRequest = runCatching {132            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)133        }.getOrNull()134        val draft = buildSaveDraft(providerRequest?.callingAppInfo, id, password)135        if (draft == null) {136            PendingIntentHandler.setCreateCredentialException(137                result,138                CreateCredentialUnsupportedException("Peach cannot save this password."),139            )140        } else {141            val saved = runCatching {142                when (val plan = planAutofillSave(vault.getEntries(), draft)) {143                    is AutofillSavePlan.Add -> vault.addEntry(plan.entry)144                    is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)145                }146            }.isSuccess147            if (saved) {148                PendingIntentHandler.setCreateCredentialResponse(result, CreatePasswordResponse())149            } else {150                PendingIntentHandler.setCreateCredentialException(151                    result,152                    CreateCredentialUnsupportedException("Peach could not save this password."),153                )154            }155        }156        setResult(RESULT_OK, result)157        finish()158    }159160    /**161     * The save draft for a CM create request. Web callers save against their162     * verified origin; native callers save against their display label — the163     * same origin model the autofill save flow uses. No caller info -> null:164     * a credential with no attributable origin would be unmatchable filler.165     */166    private fun buildSaveDraft(info: CallingAppInfo?, id: String?, password: String?): AutofillSaveDraft? {167        if (info == null || id.isNullOrBlank() || password.isNullOrEmpty()) return null168        val identity = id.trim()169        val identityIsEmail = identity.contains("@")170        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)171        val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()172        if (!webOrigin.isNullOrBlank()) {173            return AutofillSaveDraft(webOrigin, true, identity, identityIsEmail, password, null)174        }175        val title = appLabel(this, info.packageName) ?: return null176        return AutofillSaveDraft(title, false, identity, identityIsEmail, password, null)177    }178179    companion object {180        private const val EXTRA_ENTRY_ID = "com.peachpasswords.credentials.EXTRA_ENTRY_ID"181        private const val EXTRA_MODE = "com.peachpasswords.credentials.EXTRA_MODE"182        private const val MODE_CREATE = "create"183        private const val EXTRA_CREATE_ID = "com.peachpasswords.credentials.EXTRA_CREATE_ID"184        private const val EXTRA_CREATE_PASSWORD = "com.peachpasswords.credentials.EXTRA_CREATE_PASSWORD"185186        fun pendingIntent(context: Context, entryId: String): PendingIntent {187            val intent = Intent(context, PasswordCredentialSelectionActivity::class.java)188                .putExtra(EXTRA_ENTRY_ID, entryId)189            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0190            return PendingIntent.getActivity(191                context,192                "cm-password:$entryId".hashCode(),193                intent,194                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,195            )196        }197198        fun createPendingIntent(context: Context, id: String, password: String): PendingIntent {199            val intent = Intent(context, PasswordCredentialSelectionActivity::class.java)200                .putExtra(EXTRA_MODE, MODE_CREATE)201                .putExtra(EXTRA_CREATE_ID, id)202                .putExtra(EXTRA_CREATE_PASSWORD, password)203            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0204            return PendingIntent.getActivity(205                context,206                "cm-password-create:$id".hashCode(),207                intent,208                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,209            )210        }211    }212}