PasskeyCredentialSelectionActivity.kt

23.7 KB · raw

1package com.peachpasswords.passkeys23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.os.Build7import android.os.Bundle8import android.util.Log9import androidx.activity.compose.setContent10import androidx.compose.foundation.background11import androidx.compose.foundation.clickable12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.fillMaxSize14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.padding16import androidx.compose.foundation.lazy.LazyColumn17import androidx.compose.foundation.lazy.items18import androidx.compose.foundation.shape.RoundedCornerShape19import androidx.compose.material3.Text20import androidx.compose.runtime.getValue21import androidx.compose.runtime.mutableStateOf22import androidx.compose.runtime.remember23import androidx.compose.runtime.setValue24import androidx.compose.ui.Modifier25import androidx.compose.ui.text.font.FontWeight26import androidx.compose.ui.unit.dp27import androidx.compose.ui.unit.sp28import androidx.credentials.GetCredentialResponse29import androidx.credentials.GetPublicKeyCredentialOption30import androidx.credentials.CreatePublicKeyCredentialRequest31import androidx.credentials.CreatePublicKeyCredentialResponse32import androidx.credentials.PublicKeyCredential33import androidx.credentials.exceptions.CreateCredentialUnsupportedException34import androidx.credentials.exceptions.GetCredentialUnsupportedException35import androidx.credentials.provider.PendingIntentHandler36import androidx.credentials.provider.BeginGetCredentialRequest37import androidx.credentials.provider.BeginGetPublicKeyCredentialOption38import androidx.fragment.app.FragmentActivity39import androidx.lifecycle.lifecycleScope40import com.peachpasswords.BuildConfig41import com.peachpasswords.shared.extractDomain42import com.peachpasswords.ui.lock.Biometrics43import com.peachpasswords.ui.components.QaFlags44import com.peachpasswords.ui.components.SecureWindowPolicy45import com.peachpasswords.ui.theme.ACCENT_COLORS46import com.peachpasswords.ui.theme.DEFAULT_ACCENT47import com.peachpasswords.ui.theme.PeachTheme48import com.peachpasswords.ui.theme.PeachType49import com.peachpasswords.ui.theme.peachColors50import com.peachpasswords.vault.VaultRepository51import com.peachpasswords.vault.isLogin52import dagger.hilt.android.AndroidEntryPoint53import javax.inject.Inject54import kotlinx.coroutines.launch55import kotlinx.coroutines.suspendCancellableCoroutine56import kotlinx.serialization.json.Json57import kotlinx.serialization.json.contentOrNull58import kotlinx.serialization.json.jsonObject59import kotlinx.serialization.json.jsonPrimitive60import kotlin.coroutines.resume6162@AndroidEntryPoint63class PasskeyCredentialSelectionActivity : FragmentActivity() {64    @Inject lateinit var vault: VaultRepository65    private var debugSecureLease = false6667    override fun onCreate(savedInstanceState: Bundle?) {68        super.onCreate(savedInstanceState)69        // H-05: separate window from MainActivity — sign assertion and registration70        // operate on passkey material. Block screenshots / recents in every build71        // unless an explicit debug QA bypass was placed on this activity's intent.72        if (!BuildConfig.DEBUG) {73            SecureWindowPolicy.setUserSecure(window, true)74        } else if (!qaSecureCaptureBypass()) {75            SecureWindowPolicy.acquire(window)76            debugSecureLease = true77        }78        if (intent.getStringExtra(EXTRA_MODE) == MODE_CHOOSE) showManualChooser()79        else lifecycleScope.launch { completeSelection() }80    }8182    override fun onDestroy() {83        if (debugSecureLease) {84            SecureWindowPolicy.release(window)85            debugSecureLease = false86        }87        super.onDestroy()88    }8990    private fun qaSecureCaptureBypass(): Boolean =91        BuildConfig.DEBUG && (92            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||93                QaFlags.disableSecureCaptureForQa94            )9596    private suspend fun completeSelection() {97        if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {98            completeCreate()99            return100        }101        val result = Intent()102        val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)103        val providerRequest = runCatching {104            PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)105        }.getOrNull()106        val option = providerRequest?.credentialOptions107            ?.singleOrNull() as? GetPublicKeyCredentialOption108        if (providerRequest == null || option == null) {109            Log.w(TAG, "flow=row stage=final_request_invalid")110            failGet("Peach did not receive one complete passkey request from Android.")111            return112        }113        val frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->114            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))115        }116        val biometricResult = providerRequest.biometricPromptResult117        val route = passkeyVerificationRoute(118            verifiedByUnlock = intent.getBooleanExtra(EXTRA_VERIFIED_BY_UNLOCK, false),119            hasEmbeddedResult = biometricResult != null,120            embeddedSucceeded = biometricResult?.isSuccessful == true,121        )122        if (route == PasskeyVerificationRoute.REJECT) {123            Log.w(TAG, "flow=row stage=embedded_verification_rejected")124            failGet("Android could not verify the user for this passkey.")125            return126        }127        val userVerified = when (route) {128            PasskeyVerificationRoute.ACCEPT -> true129            PasskeyVerificationRoute.PROMPT_IN_PEACH -> runUserVerificationPrompt()130            PasskeyVerificationRoute.REJECT -> false131        }132        if (!userVerified) {133            Log.w(TAG, "flow=row stage=verification_incomplete")134            failGet("User verification is required but could not be completed.")135            return136        }137        Log.i(TAG, "flow=row stage=assertion_started verification=${route.name.lowercase()}")138        completeAssertion(139            entryId,140            option.requestJson,141            frameworkOrigin,142            option.clientDataHash,143            verifiedUser = true,144        )145    }146147    private fun showManualChooser() {148        val request = runCatching {149            PendingIntentHandler.retrieveBeginGetCredentialRequest(intent)150        }.getOrNull() ?: intent.getBundleExtra(EXTRA_BEGIN_REQUEST)151            ?.let(BeginGetCredentialRequest::fromBundle)152        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)153        val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) }154        val requestChoices = request?.beginGetCredentialOptions155            ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()156            ?.flatMap { option ->157                PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)158                    .map {159                        ManualChoice(160                            it.entryId,161                            it.displayName,162                            it.username,163                            option.requestJson,164                            option.clientDataHash,165                        )166                    }167            }168            .orEmpty()169        val choices = requestChoices170        if (choices.isEmpty()) {171            Log.w(TAG, "flow=manual stage=begin_request_invalid")172            failGet("Peach did not receive an eligible passkey request from Android.")173            return174        }175        val settings = vault.settings.value176        val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT177        setContent {178            PeachTheme(accent = accent, textSize = settings.textSize) {179                var query by remember { mutableStateOf("") }180                val visible = choices.filter {181                    query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||182                        it.username.contains(query, ignoreCase = true)183                }184                Column(185                    Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),186                ) {187                    Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)188                    androidx.compose.foundation.text.BasicTextField(189                        value = query,190                        onValueChange = { query = it },191                        singleLine = true,192                        textStyle = androidx.compose.ui.text.TextStyle(193                            color = peachColors.T0,194                            fontFamily = PeachType.Mono,195                            fontSize = 14.sp,196                        ),197                        modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)198                            .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),199                        decorationBox = { inner ->200                            if (query.isEmpty()) {201                                Text(202                                    "Search eligible Peach passkeys…",203                                    color = peachColors.T2,204                                    fontFamily = PeachType.Mono,205                                    fontSize = 12.sp,206                                )207                            }208                            inner()209                        },210                    )211                    LazyColumn {212                        items(visible, key = { "${it.entryId}:${it.requestJson.hashCode()}" }) { choice ->213                            Column(214                                Modifier.fillMaxWidth().clickable {215                                    lifecycleScope.launch {216                                        val verifiedByUnlock = intent.getBooleanExtra(217                                            EXTRA_VERIFIED_BY_UNLOCK,218                                            false,219                                        )220                                        val userVerified = verifiedByUnlock || runUserVerificationPrompt()221                                        if (!userVerified) {222                                            Log.w(TAG, "flow=manual stage=verification_incomplete")223                                            failGet("User verification is required but could not be completed.")224                                            return@launch225                                        }226                                        Log.i(TAG, "flow=manual stage=assertion_started")227                                        completeAssertion(228                                            choice.entryId,229                                            choice.requestJson,230                                            origin,231                                            choice.clientDataHash,232                                            verifiedUser = true,233                                        )234                                    }235                                }.padding(vertical = 12.dp),236                            ) {237                                Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)238                                Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)239                            }240                        }241                    }242                }243            }244        }245    }246247    private suspend fun completeAssertion(248        entryId: String?,249        requestJson: String?,250        origin: String?,251        clientDataHash: ByteArray?,252        uvRequiredOverride: Boolean? = null,253        verifiedUser: Boolean? = null,254    ) {255        val result = Intent()256        val entry = entryId?.let(vault::getEntry)257        // H-07: WebAuthn's UV flag must reflect actual user verification, not a258        // hard-coded bit. If the relying party demands UV, run the platform259        // biometric / device-credential prompt and only sign with UV=1 on260        // success. If UV is required but verification fails, do NOT sign —261        // fail closed rather than produce a misleading UV=0 assertion.262        val uvRequired = entry != null && requestJson != null &&263            (uvRequiredOverride ?: (userVerificationRequirement(requestJson) == UV_REQUIRED))264        val userVerified = verifiedUser ?: if (uvRequired) {265            runUserVerificationPrompt()266        } else {267            false268        }269        if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {270            PendingIntentHandler.setGetCredentialException(271                result,272                GetCredentialUnsupportedException(273                    "User verification is required but could not be completed.",274                ),275            )276            setResult(RESULT_OK, result)277            finish()278            return279        }280        val assertionBuild = if (entry != null && requestJson != null) {281            PasskeyAssertion.buildDetailed(282                entry = entry,283                requestJson = requestJson,284                origin = origin,285                clientDataHashOverride = clientDataHash,286                userVerified = userVerified,287            )288        } else null289        val assertion = (assertionBuild as? PasskeyAssertionBuild.Success)?.result290        if (assertionBuild is PasskeyAssertionBuild.Failure) {291            Log.w(TAG, "flow=assertion stage=build_failed reason=${assertionBuild.reason.name.lowercase()}")292        }293294        if (entry != null && assertion != null) {295            val passkey = entry.passkey296            val persisted = if (passkey != null) {297                val now = System.currentTimeMillis()298                runCatching {299                    vault.updateEntry(300                        entry.copy(301                            passkey = passkey.copy(signCount = assertion.nextSignCount),302                            signCount = assertion.nextSignCount,303                            lastUsedAt = now,304                        ),305                    )306                }.isSuccess307            } else {308                false309            }310            if (persisted) {311                Log.i(TAG, "flow=assertion stage=response_ready")312                PendingIntentHandler.setGetCredentialResponse(313                    result,314                    GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),315                )316            } else {317                Log.w(TAG, "flow=assertion stage=persist_failed")318                PendingIntentHandler.setGetCredentialException(319                    result,320                    GetCredentialUnsupportedException("Peach could not update this passkey."),321                )322            }323        } else {324            if (assertionBuild == null) Log.w(TAG, "flow=assertion stage=build_failed reason=missing_input")325            PendingIntentHandler.setGetCredentialException(326                result,327                GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),328            )329        }330        setResult(RESULT_OK, result)331        finish()332    }333334    private fun failGet(message: String) {335        val result = Intent()336        PendingIntentHandler.setGetCredentialException(337            result,338            GetCredentialUnsupportedException(message),339        )340        setResult(RESULT_OK, result)341        finish()342    }343344    private suspend fun completeCreate() {345        val result = Intent()346        val providerRequest = runCatching {347            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)348        }.getOrNull()349        val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest350        val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)351        // F1/F2: request.origin is caller-supplied and therefore untrusted;352        // the only acceptable origins are the framework-verified ones. A null353        // origin makes PasskeyRegistration.build fail closed.354        val origin = providerRequest?.callingAppInfo?.let { info ->355            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))356        }357        // H-07: WebAuthn's UV flag on registration must reflect an actual358        // user-verification ceremony, not a hard-coded bit. Read the RP's359        // requirement from the creation options, run the same platform360        // biometric / device-credential prompt the assertion path uses, and361        // fail closed if UV is required but verification did not succeed.362        val uvRequired = requestJson != null &&363            registrationUserVerificationRequirement(requestJson) == UV_REQUIRED364        // Opportunistic in all cases: mirror assertion semantics for the365        // non-required branch (never hard-code UV=1). Biometrics.isAvailable366        // inside runUserVerificationPrompt already returns false when the367        // device cannot verify, so a missing authenticator yields UV=0.368        val userVerified = runUserVerificationPrompt()369        if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {370            PendingIntentHandler.setCreateCredentialException(371                result,372                CreateCredentialUnsupportedException(373                    "User verification is required but could not be completed.",374                ),375            )376            setResult(RESULT_OK, result)377            finish()378            return379        }380        val registration = requestJson?.let {381            PasskeyRegistration.build(it, origin, userVerified = userVerified)382        }383        val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false384        if (registration != null && stored) {385            PendingIntentHandler.setCreateCredentialResponse(386                result,387                CreatePublicKeyCredentialResponse(registration.responseJson),388            )389        } else {390            PendingIntentHandler.setCreateCredentialException(391                result,392                CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),393            )394        }395        setResult(RESULT_OK, result)396        finish()397    }398399    // Reads the RP's userVerification requirement from a PublicKeyCredential400    // CreationOptions payload. Per WebAuthn, the canonical location is401    // `authenticatorSelection.userVerification`; we fall back to a top-level402    // `userVerification` field for clients that flatten it. Returns null when403    // the request does not specify a value (treated as not-required).404    private fun registrationUserVerificationRequirement(requestJson: String): String? =405        runCatching {406            val root = Json.parseToJsonElement(requestJson).jsonObject407            val fromSelection = root["authenticatorSelection"]?.jsonObject408                ?.get("userVerification")?.jsonPrimitive?.contentOrNull409            fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull410        }.getOrNull()411412    private fun userVerificationRequirement(requestJson: String): String? =413        runCatching {414            Json.parseToJsonElement(requestJson).jsonObject["userVerification"]415                ?.jsonPrimitive?.contentOrNull416        }.getOrNull()417418    private suspend fun runUserVerificationPrompt(): Boolean {419        if (!Biometrics.isAvailable(this)) return false420        return suspendCancellableCoroutine { cont ->421            Biometrics.authenticate(422                activity = this,423                title = "Verify it's you",424                subtitle = "Confirm to use this passkey",425                onSuccess = { if (cont.isActive) cont.resume(true) },426                onFail = { if (cont.isActive) cont.resume(false) },427            )428        }429    }430431    private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {432        val rpId = draft.passkey?.rpId ?: return false433        val target = vault.getEntries().firstOrNull { entry ->434                entry.deletedAt == null &&435                entry.passkey == null &&436                entry.isLogin() &&437                listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())438                    .any { extractDomain(it) == rpId }439        }440        return runCatching {441            if (target != null) {442                vault.updateEntry(443                    target.copy(444                        passkey = draft.passkey,445                        username = target.username ?: draft.username,446                    ),447                )448            } else {449                vault.addEntry(draft)450            }451        }.isSuccess452    }453454    companion object {455        private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"456        private const val EXTRA_VERIFIED_BY_UNLOCK =457            "com.peachpasswords.passkeys.EXTRA_VERIFIED_BY_UNLOCK"458        private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"459        private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"460        private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"461        private const val MODE_CREATE = "create"462        private const val MODE_CHOOSE = "choose"463        // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.464        // Only "required" forces a biometric / device-credential prompt here;465        // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.466        private const val UV_REQUIRED = "required"467        private const val TAG = "PeachPasskey"468469        fun pendingIntent(470            context: Context,471            entryId: String,472            verifiedByUnlock: Boolean = false,473        ): PendingIntent {474            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)475                .putExtra(EXTRA_ENTRY_ID, entryId)476                .putExtra(EXTRA_VERIFIED_BY_UNLOCK, verifiedByUnlock)477            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0478            return PendingIntent.getActivity(479                context,480                PasskeyPendingIntentIds.next(),481                intent,482                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT or mutableFlag,483            )484        }485486        fun createPendingIntent(context: Context, requestJson: String): PendingIntent {487            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)488                .putExtra(EXTRA_MODE, MODE_CREATE)489                .putExtra(EXTRA_REQUEST_JSON, requestJson)490            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0491            return PendingIntent.getActivity(492                context,493                PasskeyPendingIntentIds.next(),494                intent,495                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT or mutableFlag,496            )497        }498499        fun choosePendingIntent(500            context: Context,501            request: BeginGetCredentialRequest,502            verifiedByUnlock: Boolean = false,503        ): PendingIntent {504            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)505                .putExtra(EXTRA_MODE, MODE_CHOOSE)506                .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))507                .putExtra(EXTRA_VERIFIED_BY_UNLOCK, verifiedByUnlock)508            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0509            return PendingIntent.getActivity(510                context,511                PasskeyPendingIntentIds.next(),512                intent,513                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT or mutableFlag,514            )515        }516    }517518    private data class ManualChoice(519        val entryId: String,520        val displayName: String,521        val username: String,522        val requestJson: String,523        val clientDataHash: ByteArray?,524    )525}