PasskeyCredentialSelectionActivity.kt

13.5 KB · raw

1package com.peachpasswords.passkeys23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.os.Build7import android.os.Bundle8import androidx.credentials.GetCredentialResponse9import androidx.credentials.GetPublicKeyCredentialOption10import androidx.credentials.CreatePublicKeyCredentialRequest11import androidx.credentials.CreatePublicKeyCredentialResponse12import androidx.credentials.PublicKeyCredential13import androidx.credentials.exceptions.CreateCredentialUnsupportedException14import androidx.credentials.exceptions.GetCredentialUnsupportedException15import androidx.credentials.provider.PendingIntentHandler16import androidx.fragment.app.FragmentActivity17import androidx.lifecycle.lifecycleScope18import com.peachpasswords.BuildConfig19import com.peachpasswords.shared.extractDomain20import com.peachpasswords.ui.lock.Biometrics21import com.peachpasswords.ui.components.QaFlags22import com.peachpasswords.ui.components.SecureWindowPolicy23import com.peachpasswords.vault.VaultRepository24import com.peachpasswords.vault.isLogin25import dagger.hilt.android.AndroidEntryPoint26import javax.inject.Inject27import kotlinx.coroutines.launch28import kotlinx.coroutines.suspendCancellableCoroutine29import kotlinx.serialization.json.Json30import kotlinx.serialization.json.contentOrNull31import kotlinx.serialization.json.jsonObject32import kotlinx.serialization.json.jsonPrimitive33import kotlin.coroutines.resume3435@AndroidEntryPoint36class PasskeyCredentialSelectionActivity : FragmentActivity() {37    @Inject lateinit var vault: VaultRepository38    private var debugSecureLease = false3940    override fun onCreate(savedInstanceState: Bundle?) {41        super.onCreate(savedInstanceState)42        // H-05: separate window from MainActivity — sign assertion and registration43        // operate on passkey material. Block screenshots / recents in every build44        // unless an explicit debug QA bypass was placed on this activity's intent.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        if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {70            completeCreate()71            return72        }73        val result = Intent()74        val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)75        val providerRequest = runCatching {76            PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)77        }.getOrNull()78        val option = providerRequest?.credentialOptions79            ?.filterIsInstance<GetPublicKeyCredentialOption>()80            ?.firstOrNull()81        val requestJson = option?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)82        val entry = entryId?.let(vault::getEntry)83        // F1/F2: the origin must be framework-verified (privileged browser84        // allowlist, else the caller's apk-key-hash). Unresolvable callers85        // fail closed — PasskeyAssertion.build refuses a null origin.86        val origin = providerRequest?.callingAppInfo?.let { info ->87            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))88        }89        // H-07: WebAuthn's UV flag must reflect actual user verification, not a90        // hard-coded bit. If the relying party demands UV, run the platform91        // biometric / device-credential prompt and only sign with UV=1 on92        // success. If UV is required but verification fails, do NOT sign —93        // fail closed rather than produce a misleading UV=0 assertion.94        val uvRequired = entry != null && requestJson != null &&95            userVerificationRequirement(requestJson) == UV_REQUIRED96        val userVerified = if (uvRequired) {97            runUserVerificationPrompt()98        } else {99            false100        }101        if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {102            PendingIntentHandler.setGetCredentialException(103                result,104                GetCredentialUnsupportedException(105                    "User verification is required but could not be completed.",106                ),107            )108            setResult(RESULT_OK, result)109            finish()110            return111        }112        val assertion = if (entry != null && requestJson != null) {113            PasskeyAssertion.build(114                entry = entry,115                requestJson = requestJson,116                origin = origin,117                clientDataHashOverride = option?.clientDataHash,118                userVerified = userVerified,119            )120        } else {121            null122        }123124        if (entry != null && assertion != null) {125            val passkey = entry.passkey126            val persisted = if (passkey != null) {127                val now = System.currentTimeMillis()128                runCatching {129                    vault.updateEntry(130                        entry.copy(131                            passkey = passkey.copy(signCount = assertion.nextSignCount),132                            signCount = assertion.nextSignCount,133                            lastUsedAt = now,134                        ),135                    )136                }.isSuccess137            } else {138                false139            }140            if (persisted) {141                PendingIntentHandler.setGetCredentialResponse(142                    result,143                    GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),144                )145            } else {146                PendingIntentHandler.setGetCredentialException(147                    result,148                    GetCredentialUnsupportedException("Peach could not update this passkey."),149                )150            }151        } else {152            PendingIntentHandler.setGetCredentialException(153                result,154                GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),155            )156        }157        setResult(RESULT_OK, result)158        finish()159    }160161    private suspend fun completeCreate() {162        val result = Intent()163        val providerRequest = runCatching {164            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)165        }.getOrNull()166        val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest167        val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)168        // F1/F2: request.origin is caller-supplied and therefore untrusted;169        // the only acceptable origins are the framework-verified ones. A null170        // origin makes PasskeyRegistration.build fail closed.171        val origin = providerRequest?.callingAppInfo?.let { info ->172            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))173        }174        // H-07: WebAuthn's UV flag on registration must reflect an actual175        // user-verification ceremony, not a hard-coded bit. Read the RP's176        // requirement from the creation options, run the same platform177        // biometric / device-credential prompt the assertion path uses, and178        // fail closed if UV is required but verification did not succeed.179        val uvRequired = requestJson != null &&180            registrationUserVerificationRequirement(requestJson) == UV_REQUIRED181        // Opportunistic in all cases: mirror assertion semantics for the182        // non-required branch (never hard-code UV=1). Biometrics.isAvailable183        // inside runUserVerificationPrompt already returns false when the184        // device cannot verify, so a missing authenticator yields UV=0.185        val userVerified = runUserVerificationPrompt()186        if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {187            PendingIntentHandler.setCreateCredentialException(188                result,189                CreateCredentialUnsupportedException(190                    "User verification is required but could not be completed.",191                ),192            )193            setResult(RESULT_OK, result)194            finish()195            return196        }197        val registration = requestJson?.let {198            PasskeyRegistration.build(it, origin, userVerified = userVerified)199        }200        val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false201        if (registration != null && stored) {202            PendingIntentHandler.setCreateCredentialResponse(203                result,204                CreatePublicKeyCredentialResponse(registration.responseJson),205            )206        } else {207            PendingIntentHandler.setCreateCredentialException(208                result,209                CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),210            )211        }212        setResult(RESULT_OK, result)213        finish()214    }215216    // Reads the RP's userVerification requirement from a PublicKeyCredential217    // CreationOptions payload. Per WebAuthn, the canonical location is218    // `authenticatorSelection.userVerification`; we fall back to a top-level219    // `userVerification` field for clients that flatten it. Returns null when220    // the request does not specify a value (treated as not-required).221    private fun registrationUserVerificationRequirement(requestJson: String): String? =222        runCatching {223            val root = Json.parseToJsonElement(requestJson).jsonObject224            val fromSelection = root["authenticatorSelection"]?.jsonObject225                ?.get("userVerification")?.jsonPrimitive?.contentOrNull226            fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull227        }.getOrNull()228229    private fun userVerificationRequirement(requestJson: String): String? =230        runCatching {231            Json.parseToJsonElement(requestJson).jsonObject["userVerification"]232                ?.jsonPrimitive?.contentOrNull233        }.getOrNull()234235    private suspend fun runUserVerificationPrompt(): Boolean {236        if (!Biometrics.isAvailable(this)) return false237        return suspendCancellableCoroutine { cont ->238            Biometrics.authenticate(239                activity = this,240                title = "Verify it's you",241                subtitle = "Confirm to use this passkey",242                onSuccess = { if (cont.isActive) cont.resume(true) },243                onFail = { if (cont.isActive) cont.resume(false) },244            )245        }246    }247248    private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {249        val rpId = draft.passkey?.rpId ?: return false250        val target = vault.getEntries().firstOrNull { entry ->251                entry.deletedAt == null &&252                entry.passkey == null &&253                entry.isLogin() &&254                listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())255                    .any { extractDomain(it) == rpId }256        }257        return runCatching {258            if (target != null) {259                vault.updateEntry(260                    target.copy(261                        passkey = draft.passkey,262                        username = target.username ?: draft.username,263                    ),264                )265            } else {266                vault.addEntry(draft)267            }268        }.isSuccess269    }270271    companion object {272        private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"273        private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"274        private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"275        private const val MODE_CREATE = "create"276        // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.277        // Only "required" forces a biometric / device-credential prompt here;278        // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.279        private const val UV_REQUIRED = "required"280281        fun pendingIntent(context: Context, entryId: String, requestJson: String): PendingIntent {282            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)283                .putExtra(EXTRA_ENTRY_ID, entryId)284                .putExtra(EXTRA_REQUEST_JSON, requestJson)285            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0286            return PendingIntent.getActivity(287                context,288                "$entryId:$requestJson".hashCode(),289                intent,290                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,291            )292        }293294        fun createPendingIntent(context: Context, requestJson: String): PendingIntent {295            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)296                .putExtra(EXTRA_MODE, MODE_CREATE)297                .putExtra(EXTRA_REQUEST_JSON, requestJson)298            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0299            return PendingIntent.getActivity(300                context,301                requestJson.hashCode(),302                intent,303                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,304            )305        }306    }307}