PasskeyCredentialSelectionActivity.kt

13.0 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.installRelease(window)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        val origin = providerRequest?.callingAppInfo?.let { info ->84            runCatching { info.getOrigin(info.packageName) }.getOrNull()85        }86        // H-07: WebAuthn's UV flag must reflect actual user verification, not a87        // hard-coded bit. If the relying party demands UV, run the platform88        // biometric / device-credential prompt and only sign with UV=1 on89        // success. If UV is required but verification fails, do NOT sign —90        // fail closed rather than produce a misleading UV=0 assertion.91        val uvRequired = entry != null && requestJson != null &&92            userVerificationRequirement(requestJson) == UV_REQUIRED93        val userVerified = if (uvRequired) {94            runUserVerificationPrompt()95        } else {96            false97        }98        if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {99            PendingIntentHandler.setGetCredentialException(100                result,101                GetCredentialUnsupportedException(102                    "User verification is required but could not be completed.",103                ),104            )105            setResult(RESULT_OK, result)106            finish()107            return108        }109        val assertion = if (entry != null && requestJson != null) {110            PasskeyAssertion.build(111                entry = entry,112                requestJson = requestJson,113                origin = origin,114                clientDataHashOverride = option?.clientDataHash,115                userVerified = userVerified,116            )117        } else {118            null119        }120121        if (entry != null && assertion != null) {122            val passkey = entry.passkey123            val persisted = if (passkey != null) {124                val now = System.currentTimeMillis()125                runCatching {126                    vault.updateEntry(127                        entry.copy(128                            passkey = passkey.copy(signCount = assertion.nextSignCount),129                            signCount = assertion.nextSignCount,130                            lastUsedAt = now,131                        ),132                    )133                }.isSuccess134            } else {135                false136            }137            if (persisted) {138                PendingIntentHandler.setGetCredentialResponse(139                    result,140                    GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),141                )142            } else {143                PendingIntentHandler.setGetCredentialException(144                    result,145                    GetCredentialUnsupportedException("Peach could not update this passkey."),146                )147            }148        } else {149            PendingIntentHandler.setGetCredentialException(150                result,151                GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),152            )153        }154        setResult(RESULT_OK, result)155        finish()156    }157158    private suspend fun completeCreate() {159        val result = Intent()160        val providerRequest = runCatching {161            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)162        }.getOrNull()163        val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest164        val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)165        val origin = request?.origin ?: providerRequest?.callingAppInfo?.let { info ->166            runCatching { info.getOrigin(info.packageName) }.getOrNull()167        }168        // H-07: WebAuthn's UV flag on registration must reflect an actual169        // user-verification ceremony, not a hard-coded bit. Read the RP's170        // requirement from the creation options, run the same platform171        // biometric / device-credential prompt the assertion path uses, and172        // fail closed if UV is required but verification did not succeed.173        val uvRequired = requestJson != null &&174            registrationUserVerificationRequirement(requestJson) == UV_REQUIRED175        // Opportunistic in all cases: mirror assertion semantics for the176        // non-required branch (never hard-code UV=1). Biometrics.isAvailable177        // inside runUserVerificationPrompt already returns false when the178        // device cannot verify, so a missing authenticator yields UV=0.179        val userVerified = runUserVerificationPrompt()180        if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {181            PendingIntentHandler.setCreateCredentialException(182                result,183                CreateCredentialUnsupportedException(184                    "User verification is required but could not be completed.",185                ),186            )187            setResult(RESULT_OK, result)188            finish()189            return190        }191        val registration = requestJson?.let {192            PasskeyRegistration.build(it, origin, userVerified = userVerified)193        }194        val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false195        if (registration != null && stored) {196            PendingIntentHandler.setCreateCredentialResponse(197                result,198                CreatePublicKeyCredentialResponse(registration.responseJson),199            )200        } else {201            PendingIntentHandler.setCreateCredentialException(202                result,203                CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),204            )205        }206        setResult(RESULT_OK, result)207        finish()208    }209210    // Reads the RP's userVerification requirement from a PublicKeyCredential211    // CreationOptions payload. Per WebAuthn, the canonical location is212    // `authenticatorSelection.userVerification`; we fall back to a top-level213    // `userVerification` field for clients that flatten it. Returns null when214    // the request does not specify a value (treated as not-required).215    private fun registrationUserVerificationRequirement(requestJson: String): String? =216        runCatching {217            val root = Json.parseToJsonElement(requestJson).jsonObject218            val fromSelection = root["authenticatorSelection"]?.jsonObject219                ?.get("userVerification")?.jsonPrimitive?.contentOrNull220            fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull221        }.getOrNull()222223    private fun userVerificationRequirement(requestJson: String): String? =224        runCatching {225            Json.parseToJsonElement(requestJson).jsonObject["userVerification"]226                ?.jsonPrimitive?.contentOrNull227        }.getOrNull()228229    private suspend fun runUserVerificationPrompt(): Boolean {230        if (!Biometrics.isAvailable(this)) return false231        return suspendCancellableCoroutine { cont ->232            Biometrics.authenticate(233                activity = this,234                title = "Verify it's you",235                subtitle = "Confirm to use this passkey",236                onSuccess = { if (cont.isActive) cont.resume(true) },237                onFail = { if (cont.isActive) cont.resume(false) },238            )239        }240    }241242    private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {243        val rpId = draft.passkey?.rpId ?: return false244        val target = vault.getEntries().firstOrNull { entry ->245                entry.deletedAt == null &&246                entry.passkey == null &&247                entry.isLogin() &&248                listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())249                    .any { extractDomain(it) == rpId }250        }251        return runCatching {252            if (target != null) {253                vault.updateEntry(254                    target.copy(255                        passkey = draft.passkey,256                        username = target.username ?: draft.username,257                    ),258                )259            } else {260                vault.addEntry(draft)261            }262        }.isSuccess263    }264265    companion object {266        private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"267        private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"268        private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"269        private const val MODE_CREATE = "create"270        // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.271        // Only "required" forces a biometric / device-credential prompt here;272        // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.273        private const val UV_REQUIRED = "required"274275        fun pendingIntent(context: Context, entryId: String, requestJson: String): PendingIntent {276            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)277                .putExtra(EXTRA_ENTRY_ID, entryId)278                .putExtra(EXTRA_REQUEST_JSON, requestJson)279            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0280            return PendingIntent.getActivity(281                context,282                "$entryId:$requestJson".hashCode(),283                intent,284                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,285            )286        }287288        fun createPendingIntent(context: Context, requestJson: String): PendingIntent {289            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)290                .putExtra(EXTRA_MODE, MODE_CREATE)291                .putExtra(EXTRA_REQUEST_JSON, requestJson)292            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0293            return PendingIntent.getActivity(294                context,295                requestJson.hashCode(),296                intent,297                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,298            )299        }300    }301}