PasswordCredentialSelectionActivity.kt

5.3 KB · raw

1package com.peachpasswords.credentials23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.os.Build7import android.os.Bundle8import androidx.credentials.GetCredentialResponse9import androidx.credentials.PasswordCredential10import androidx.credentials.exceptions.GetCredentialUnsupportedException11import androidx.credentials.provider.PendingIntentHandler12import androidx.fragment.app.FragmentActivity13import androidx.lifecycle.lifecycleScope14import com.peachpasswords.BuildConfig15import com.peachpasswords.autofill.appLabel16import com.peachpasswords.autofill.appLinkDomains17import com.peachpasswords.passkeys.PasskeyCallerOrigin18import com.peachpasswords.shared.extractDomain19import com.peachpasswords.ui.components.QaFlags20import com.peachpasswords.ui.components.SecureWindowPolicy21import com.peachpasswords.vault.VaultRepository22import dagger.hilt.android.AndroidEntryPoint23import javax.inject.Inject24import kotlinx.coroutines.launch2526/**27 * Completes a Credential Manager password selection. The vault was already28 * unlocked when the row was offered (the provider never emits rows while29 * locked), and the tap is the release boundary — the same posture as autofill30 * (PEACH-AND-01-002 risk acceptance).31 *32 * Defense in depth: the framework-verified caller is re-checked against the33 * entry before release, so a stale or replayed pending intent cannot pull a34 * credential for a different caller. Fails closed on any doubt.35 */36@AndroidEntryPoint37class PasswordCredentialSelectionActivity : FragmentActivity() {38    @Inject lateinit var vault: VaultRepository39    private var debugSecureLease = false4041    override fun onCreate(savedInstanceState: Bundle?) {42        super.onCreate(savedInstanceState)43        // This window briefly holds credential material on screen. Block44        // screenshots / recents exactly like the passkey selection surface.45        if (!BuildConfig.DEBUG) {46            SecureWindowPolicy.setUserSecure(window, true)47        } else if (!qaSecureCaptureBypass()) {48            SecureWindowPolicy.acquire(window)49            debugSecureLease = true50        }51        lifecycleScope.launch { completeSelection() }52    }5354    override fun onDestroy() {55        if (debugSecureLease) {56            SecureWindowPolicy.release(window)57            debugSecureLease = false58        }59        super.onDestroy()60    }6162    private fun qaSecureCaptureBypass(): Boolean =63        BuildConfig.DEBUG && (64            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||65                QaFlags.disableSecureCaptureForQa66            )6768    private suspend fun completeSelection() {69        val result = Intent()70        val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)71        val entry = entryId?.let(vault::getEntry)72        val providerRequest = runCatching {73            PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)74        }.getOrNull()75        val callerMatches = providerRequest?.callingAppInfo?.let { info ->76            if (entry == null) {77                false78            } else {79                val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)80                val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()81                if (!webOrigin.isNullOrBlank()) {82                    val domain = extractDomain(webOrigin)83                    domain != null && PasswordCredentialPlanner84                        .candidates(listOf(entry), setOf(domain), null)85                        .isNotEmpty()86                } else {87                    PasswordCredentialPlanner.candidates(88                        listOf(entry),89                        appLinkDomains(this, info.packageName).toSet(),90                        appLabel(this, info.packageName),91                    ).isNotEmpty()92                }93            }94        } ?: false95        val username = entry?.username ?: entry?.email96        val password = entry?.password97        if (entry == null || entry.deletedAt != null || !callerMatches ||98            username.isNullOrBlank() || password.isNullOrEmpty()99        ) {100            PendingIntentHandler.setGetCredentialException(101                result,102                GetCredentialUnsupportedException("Peach cannot fill this login."),103            )104        } else {105            runCatching { vault.updateEntry(entry.copy(lastUsedAt = System.currentTimeMillis())) }106            PendingIntentHandler.setGetCredentialResponse(107                result,108                GetCredentialResponse(PasswordCredential(username, password)),109            )110        }111        setResult(RESULT_OK, result)112        finish()113    }114115    companion object {116        private const val EXTRA_ENTRY_ID = "com.peachpasswords.credentials.EXTRA_ENTRY_ID"117118        fun pendingIntent(context: Context, entryId: String): PendingIntent {119            val intent = Intent(context, PasswordCredentialSelectionActivity::class.java)120                .putExtra(EXTRA_ENTRY_ID, entryId)121            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0122            return PendingIntent.getActivity(123                context,124                "cm-password:$entryId".hashCode(),125                intent,126                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,127            )128        }129    }130}