package com.peachpasswords.credentials import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle import androidx.credentials.CreatePasswordResponse import androidx.credentials.GetCredentialResponse import androidx.credentials.PasswordCredential import androidx.credentials.exceptions.CreateCredentialUnsupportedException import androidx.credentials.exceptions.GetCredentialUnsupportedException import androidx.credentials.provider.CallingAppInfo import androidx.credentials.provider.PendingIntentHandler import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import com.peachpasswords.BuildConfig import com.peachpasswords.autofill.AutofillSaveDraft import com.peachpasswords.autofill.AutofillSavePlan import com.peachpasswords.autofill.appLinkDomains import com.peachpasswords.autofill.planAutofillSave import com.peachpasswords.passkeys.PasskeyCallerOrigin import com.peachpasswords.shared.extractDomain import com.peachpasswords.ui.components.QaFlags import com.peachpasswords.ui.components.SecureWindowPolicy import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch /** * Completes Credential Manager password selections. The vault was already * unlocked when rows were offered (the provider never emits rows while * locked), and the tap is the release boundary — the same posture as autofill * (PEACH-AND-01-002 risk acceptance). * * Get mode releases the credential; defense in depth re-checks the * framework-verified caller against the entry first, so a stale or replayed * pending intent cannot pull a credential for a different caller. Create mode * stores the new credential via the same add/update planner as the autofill * save flow (planAutofillSave). Both fail closed on any doubt. */ @AndroidEntryPoint class PasswordCredentialSelectionActivity : FragmentActivity() { @Inject lateinit var vault: VaultRepository private var debugSecureLease = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // This window briefly holds credential material on screen. Block // screenshots / recents exactly like the passkey selection surface. if (!BuildConfig.DEBUG) { SecureWindowPolicy.setUserSecure(window, true) } else if (!qaSecureCaptureBypass()) { SecureWindowPolicy.acquire(window) debugSecureLease = true } lifecycleScope.launch { completeSelection() } } override fun onDestroy() { if (debugSecureLease) { SecureWindowPolicy.release(window) debugSecureLease = false } super.onDestroy() } private fun qaSecureCaptureBypass(): Boolean = BuildConfig.DEBUG && ( intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) || QaFlags.disableSecureCaptureForQa ) private suspend fun completeSelection() { if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) { completeCreate() return } val result = Intent() val entryId = intent.getStringExtra(EXTRA_ENTRY_ID) val entry = entryId?.let(vault::getEntry) val providerRequest = runCatching { PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) }.getOrNull() val callerMatches = providerRequest?.callingAppInfo?.let { info -> if (entry == null) { false } else { val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this) val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull() if (!webOrigin.isNullOrBlank()) { val domain = extractDomain(webOrigin) domain != null && PasswordCredentialPlanner .candidates(listOf(entry), setOf(domain), null) .isNotEmpty() } else { PasswordCredentialPlanner.candidates( listOf(entry), appLinkDomains(this, info.packageName).toSet(), packageName = info.packageName, ).isNotEmpty() } } } ?: false val username = entry?.username ?: entry?.email val password = entry?.password if (entry == null || entry.deletedAt != null || !callerMatches || username.isNullOrBlank() || password.isNullOrEmpty() ) { PendingIntentHandler.setGetCredentialException( result, GetCredentialUnsupportedException("Peach cannot fill this login."), ) } else { runCatching { vault.updateEntry(entry.copy(lastUsedAt = System.currentTimeMillis())) } PendingIntentHandler.setGetCredentialResponse( result, GetCredentialResponse(PasswordCredential(username, password)), ) } setResult(RESULT_OK, result) finish() } private suspend fun completeCreate() { val result = Intent() val id = intent.getStringExtra(EXTRA_CREATE_ID) val password = intent.getStringExtra(EXTRA_CREATE_PASSWORD) val providerRequest = runCatching { PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) }.getOrNull() val draft = buildSaveDraft(providerRequest?.callingAppInfo, id, password) if (draft == null) { PendingIntentHandler.setCreateCredentialException( result, CreateCredentialUnsupportedException("Peach cannot save this password."), ) } else { val saved = runCatching { when (val plan = planAutofillSave(vault.getEntries(), draft)) { is AutofillSavePlan.Add -> vault.addEntry(plan.entry) is AutofillSavePlan.Update -> vault.updateEntry(plan.entry) } }.isSuccess if (saved) { PendingIntentHandler.setCreateCredentialResponse(result, CreatePasswordResponse()) } else { PendingIntentHandler.setCreateCredentialException( result, CreateCredentialUnsupportedException("Peach could not save this password."), ) } } setResult(RESULT_OK, result) finish() } /** * The save draft for a CM create request. Web callers save against their * verified origin; native callers save against their package name — the * same origin model the autofill save flow uses, so a credential saved * here is attributable to an immutable identity rather than to a display * label any app can choose (PAI-2026-001). No caller info -> null: * a credential with no attributable origin would be unmatchable filler. */ private fun buildSaveDraft(info: CallingAppInfo?, id: String?, password: String?): AutofillSaveDraft? { if (info == null || id.isNullOrBlank() || password.isNullOrEmpty()) return null val identity = id.trim() val identityIsEmail = identity.contains("@") val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this) val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull() if (!webOrigin.isNullOrBlank()) { return AutofillSaveDraft(webOrigin, true, identity, identityIsEmail, password, null) } val packageName = info.packageName?.trim().takeUnless { it.isNullOrEmpty() } ?: return null return AutofillSaveDraft(packageName, false, identity, identityIsEmail, password, null) } companion object { private const val EXTRA_ENTRY_ID = "com.peachpasswords.credentials.EXTRA_ENTRY_ID" private const val EXTRA_MODE = "com.peachpasswords.credentials.EXTRA_MODE" private const val MODE_CREATE = "create" private const val EXTRA_CREATE_ID = "com.peachpasswords.credentials.EXTRA_CREATE_ID" private const val EXTRA_CREATE_PASSWORD = "com.peachpasswords.credentials.EXTRA_CREATE_PASSWORD" fun pendingIntent(context: Context, entryId: String): PendingIntent { val intent = Intent(context, PasswordCredentialSelectionActivity::class.java) .putExtra(EXTRA_ENTRY_ID, entryId) val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 return PendingIntent.getActivity( context, "cm-password:$entryId".hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag, ) } fun createPendingIntent(context: Context, id: String, password: String): PendingIntent { val intent = Intent(context, PasswordCredentialSelectionActivity::class.java) .putExtra(EXTRA_MODE, MODE_CREATE) .putExtra(EXTRA_CREATE_ID, id) .putExtra(EXTRA_CREATE_PASSWORD, password) val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 return PendingIntent.getActivity( context, "cm-password-create:$id".hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag, ) } } }