PasswordCredentialSelectionActivity.kt

9.4 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.appLinkDomains21import com.peachpasswords.autofill.planAutofillSave22import com.peachpasswords.passkeys.PasskeyCallerOrigin23import com.peachpasswords.shared.extractDomain24import com.peachpasswords.ui.components.QaFlags25import com.peachpasswords.ui.components.SecureWindowPolicy26import com.peachpasswords.vault.VaultRepository27import dagger.hilt.android.AndroidEntryPoint28import javax.inject.Inject29import kotlinx.coroutines.launch3031/**32 * Completes Credential Manager password selections. The vault was already33 * unlocked when rows were offered (the provider never emits rows while34 * locked), and the tap is the release boundary — the same posture as autofill35 * (PEACH-AND-01-002 risk acceptance).36 *37 * Get mode releases the credential; defense in depth re-checks the38 * framework-verified caller against the entry first, so a stale or replayed39 * pending intent cannot pull a credential for a different caller. Create mode40 * stores the new credential via the same add/update planner as the autofill41 * save flow (planAutofillSave). Both fail closed on any doubt.42 */43@AndroidEntryPoint44class PasswordCredentialSelectionActivity : FragmentActivity() {45    @Inject lateinit var vault: VaultRepository46    private var debugSecureLease = false4748    override fun onCreate(savedInstanceState: Bundle?) {49        super.onCreate(savedInstanceState)50        // This window briefly holds credential material on screen. Block51        // screenshots / recents exactly like the passkey selection surface.52        if (!BuildConfig.DEBUG) {53            SecureWindowPolicy.setUserSecure(window, true)54        } else if (!qaSecureCaptureBypass()) {55            SecureWindowPolicy.acquire(window)56            debugSecureLease = true57        }58        lifecycleScope.launch { completeSelection() }59    }6061    override fun onDestroy() {62        if (debugSecureLease) {63            SecureWindowPolicy.release(window)64            debugSecureLease = false65        }66        super.onDestroy()67    }6869    private fun qaSecureCaptureBypass(): Boolean =70        BuildConfig.DEBUG && (71            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||72                QaFlags.disableSecureCaptureForQa73            )7475    private suspend fun completeSelection() {76        if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {77            completeCreate()78            return79        }80        val result = Intent()81        val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)82        val entry = entryId?.let(vault::getEntry)83        val providerRequest = runCatching {84            PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)85        }.getOrNull()86        val callerMatches = providerRequest?.callingAppInfo?.let { info ->87            if (entry == null) {88                false89            } else {90                val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)91                val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()92                if (!webOrigin.isNullOrBlank()) {93                    val domain = extractDomain(webOrigin)94                    domain != null && PasswordCredentialPlanner95                        .candidates(listOf(entry), setOf(domain), null)96                        .isNotEmpty()97                } else {98                    PasswordCredentialPlanner.candidates(99                        listOf(entry),100                        appLinkDomains(this, info.packageName).toSet(),101                        packageName = info.packageName,102                    ).isNotEmpty()103                }104            }105        } ?: false106        val username = entry?.username ?: entry?.email107        val password = entry?.password108        if (entry == null || entry.deletedAt != null || !callerMatches ||109            username.isNullOrBlank() || password.isNullOrEmpty()110        ) {111            PendingIntentHandler.setGetCredentialException(112                result,113                GetCredentialUnsupportedException("Peach cannot fill this login."),114            )115        } else {116            runCatching { vault.updateEntry(entry.copy(lastUsedAt = System.currentTimeMillis())) }117            PendingIntentHandler.setGetCredentialResponse(118                result,119                GetCredentialResponse(PasswordCredential(username, password)),120            )121        }122        setResult(RESULT_OK, result)123        finish()124    }125126    private suspend fun completeCreate() {127        val result = Intent()128        val id = intent.getStringExtra(EXTRA_CREATE_ID)129        val password = intent.getStringExtra(EXTRA_CREATE_PASSWORD)130        val providerRequest = runCatching {131            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)132        }.getOrNull()133        val draft = buildSaveDraft(providerRequest?.callingAppInfo, id, password)134        if (draft == null) {135            PendingIntentHandler.setCreateCredentialException(136                result,137                CreateCredentialUnsupportedException("Peach cannot save this password."),138            )139        } else {140            val saved = runCatching {141                when (val plan = planAutofillSave(vault.getEntries(), draft)) {142                    is AutofillSavePlan.Add -> vault.addEntry(plan.entry)143                    is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)144                }145            }.isSuccess146            if (saved) {147                PendingIntentHandler.setCreateCredentialResponse(result, CreatePasswordResponse())148            } else {149                PendingIntentHandler.setCreateCredentialException(150                    result,151                    CreateCredentialUnsupportedException("Peach could not save this password."),152                )153            }154        }155        setResult(RESULT_OK, result)156        finish()157    }158159    /**160     * The save draft for a CM create request. Web callers save against their161     * verified origin; native callers save against their package name — the162     * same origin model the autofill save flow uses, so a credential saved163     * here is attributable to an immutable identity rather than to a display164     * label any app can choose (PAI-2026-001). No caller info -> null:165     * a credential with no attributable origin would be unmatchable filler.166     */167    private fun buildSaveDraft(info: CallingAppInfo?, id: String?, password: String?): AutofillSaveDraft? {168        if (info == null || id.isNullOrBlank() || password.isNullOrEmpty()) return null169        val identity = id.trim()170        val identityIsEmail = identity.contains("@")171        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)172        val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()173        if (!webOrigin.isNullOrBlank()) {174            return AutofillSaveDraft(webOrigin, true, identity, identityIsEmail, password, null)175        }176        val packageName = info.packageName?.trim().takeUnless { it.isNullOrEmpty() } ?: return null177        return AutofillSaveDraft(packageName, false, identity, identityIsEmail, password, null)178    }179180    companion object {181        private const val EXTRA_ENTRY_ID = "com.peachpasswords.credentials.EXTRA_ENTRY_ID"182        private const val EXTRA_MODE = "com.peachpasswords.credentials.EXTRA_MODE"183        private const val MODE_CREATE = "create"184        private const val EXTRA_CREATE_ID = "com.peachpasswords.credentials.EXTRA_CREATE_ID"185        private const val EXTRA_CREATE_PASSWORD = "com.peachpasswords.credentials.EXTRA_CREATE_PASSWORD"186187        fun pendingIntent(context: Context, entryId: String): PendingIntent {188            val intent = Intent(context, PasswordCredentialSelectionActivity::class.java)189                .putExtra(EXTRA_ENTRY_ID, entryId)190            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0191            return PendingIntent.getActivity(192                context,193                "cm-password:$entryId".hashCode(),194                intent,195                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,196            )197        }198199        fun createPendingIntent(context: Context, id: String, password: String): PendingIntent {200            val intent = Intent(context, PasswordCredentialSelectionActivity::class.java)201                .putExtra(EXTRA_MODE, MODE_CREATE)202                .putExtra(EXTRA_CREATE_ID, id)203                .putExtra(EXTRA_CREATE_PASSWORD, password)204            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0205            return PendingIntent.getActivity(206                context,207                "cm-password-create:$id".hashCode(),208                intent,209                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,210            )211        }212    }213}