PasskeyCredentialSelectionActivity.kt

22.1 KB · raw

1package com.peachpasswords.passkeys23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.os.Build7import android.os.Bundle8import androidx.activity.compose.setContent9import androidx.compose.foundation.background10import androidx.compose.foundation.clickable11import androidx.compose.foundation.layout.Column12import androidx.compose.foundation.layout.fillMaxSize13import androidx.compose.foundation.layout.fillMaxWidth14import androidx.compose.foundation.layout.padding15import androidx.compose.foundation.lazy.LazyColumn16import androidx.compose.foundation.lazy.items17import androidx.compose.foundation.shape.RoundedCornerShape18import androidx.compose.material3.Text19import androidx.compose.runtime.getValue20import androidx.compose.runtime.mutableStateOf21import androidx.compose.runtime.remember22import androidx.compose.runtime.setValue23import androidx.compose.ui.Modifier24import androidx.compose.ui.text.font.FontWeight25import androidx.compose.ui.unit.dp26import androidx.compose.ui.unit.sp27import androidx.credentials.GetCredentialResponse28import androidx.credentials.GetPublicKeyCredentialOption29import androidx.credentials.CreatePublicKeyCredentialRequest30import androidx.credentials.CreatePublicKeyCredentialResponse31import androidx.credentials.PublicKeyCredential32import androidx.credentials.exceptions.CreateCredentialUnsupportedException33import androidx.credentials.exceptions.GetCredentialUnsupportedException34import androidx.credentials.provider.PendingIntentHandler35import androidx.credentials.provider.BeginGetCredentialRequest36import androidx.credentials.provider.BeginGetPublicKeyCredentialOption37import androidx.fragment.app.FragmentActivity38import androidx.lifecycle.lifecycleScope39import com.peachpasswords.BuildConfig40import com.peachpasswords.shared.extractDomain41import com.peachpasswords.ui.lock.Biometrics42import com.peachpasswords.ui.components.QaFlags43import com.peachpasswords.ui.components.SecureWindowPolicy44import com.peachpasswords.ui.theme.ACCENT_COLORS45import com.peachpasswords.ui.theme.DEFAULT_ACCENT46import com.peachpasswords.ui.theme.PeachTheme47import com.peachpasswords.ui.theme.PeachType48import com.peachpasswords.ui.theme.peachColors49import com.peachpasswords.vault.VaultRepository50import com.peachpasswords.vault.isLogin51import dagger.hilt.android.AndroidEntryPoint52import javax.inject.Inject53import kotlinx.coroutines.launch54import kotlinx.coroutines.suspendCancellableCoroutine55import kotlinx.serialization.json.Json56import kotlinx.serialization.json.contentOrNull57import kotlinx.serialization.json.jsonObject58import kotlinx.serialization.json.jsonPrimitive59import kotlin.coroutines.resume6061@AndroidEntryPoint62class PasskeyCredentialSelectionActivity : FragmentActivity() {63    @Inject lateinit var vault: VaultRepository64    private var debugSecureLease = false6566    override fun onCreate(savedInstanceState: Bundle?) {67        super.onCreate(savedInstanceState)68        // H-05: separate window from MainActivity — sign assertion and registration69        // operate on passkey material. Block screenshots / recents in every build70        // unless an explicit debug QA bypass was placed on this activity's intent.71        if (!BuildConfig.DEBUG) {72            SecureWindowPolicy.setUserSecure(window, true)73        } else if (!qaSecureCaptureBypass()) {74            SecureWindowPolicy.acquire(window)75            debugSecureLease = true76        }77        if (intent.getStringExtra(EXTRA_MODE) == MODE_CHOOSE) showManualChooser()78        else lifecycleScope.launch { completeSelection() }79    }8081    override fun onDestroy() {82        if (debugSecureLease) {83            SecureWindowPolicy.release(window)84            debugSecureLease = false85        }86        super.onDestroy()87    }8889    private fun qaSecureCaptureBypass(): Boolean =90        BuildConfig.DEBUG && (91            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||92                QaFlags.disableSecureCaptureForQa93            )9495    private suspend fun completeSelection() {96        if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {97            completeCreate()98            return99        }100        val result = Intent()101        val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)102        val providerRequest = runCatching {103            PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)104        }.getOrNull()105        val option = providerRequest?.credentialOptions106            ?.filterIsInstance<GetPublicKeyCredentialOption>()107            ?.firstOrNull()108        val requestJson = option?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)109        val clientDataHash = selectionClientDataHash(110            frameworkHash = option?.clientDataHash,111            authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH),112        )113        // F1/F2: the origin must be framework-verified (privileged browser114        // allowlist, else the caller's apk-key-hash). Unresolvable callers115        // fail closed — PasskeyAssertion.build refuses a null origin.116        val frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->117            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))118        }119        val origin = selectionOrigin(120            frameworkOrigin = frameworkOrigin,121            authenticatedOrigin = intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN),122        )123        completeAssertion(entryId, requestJson, origin, clientDataHash)124    }125126    private fun showManualChooser() {127        val request = runCatching {128            PendingIntentHandler.retrieveBeginGetCredentialRequest(intent)129        }.getOrNull() ?: intent.getBundleExtra(EXTRA_BEGIN_REQUEST)130            ?.let(BeginGetCredentialRequest::fromBundle)131        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)132        val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) }133            ?: intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN)134        val choices = request?.beginGetCredentialOptions135            ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()136            ?.flatMap { option ->137                PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)138                    .map { ManualChoice(it.entryId, it.displayName, it.username, option) }139            }140            .orEmpty()141        if (choices.isEmpty()) {142            finish()143            return144        }145        val settings = vault.settings.value146        val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT147        setContent {148            PeachTheme(accent = accent, textSize = settings.textSize) {149                var query by remember { mutableStateOf("") }150                val visible = choices.filter {151                    query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||152                        it.username.contains(query, ignoreCase = true)153                }154                Column(155                    Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),156                ) {157                    Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)158                    androidx.compose.foundation.text.BasicTextField(159                        value = query,160                        onValueChange = { query = it },161                        singleLine = true,162                        textStyle = androidx.compose.ui.text.TextStyle(163                            color = peachColors.T0,164                            fontFamily = PeachType.Mono,165                            fontSize = 14.sp,166                        ),167                        modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)168                            .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),169                        decorationBox = { inner ->170                            if (query.isEmpty()) {171                                Text(172                                    "Search eligible Peach passkeys…",173                                    color = peachColors.T2,174                                    fontFamily = PeachType.Mono,175                                    fontSize = 12.sp,176                                )177                            }178                            inner()179                        },180                    )181                    LazyColumn {182                        items(visible, key = { "${it.entryId}:${it.option.requestJson.hashCode()}" }) { choice ->183                            Column(184                                Modifier.fillMaxWidth().clickable {185                                    lifecycleScope.launch {186                                        completeAssertion(187                                            choice.entryId,188                                            choice.option.requestJson,189                                            origin,190                                            choice.option.clientDataHash,191                                        )192                                    }193                                }.padding(vertical = 12.dp),194                            ) {195                                Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)196                                Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)197                            }198                        }199                    }200                }201            }202        }203    }204205    private suspend fun completeAssertion(206        entryId: String?,207        requestJson: String?,208        origin: String?,209        clientDataHash: ByteArray?,210    ) {211        val result = Intent()212        val entry = entryId?.let(vault::getEntry)213        // H-07: WebAuthn's UV flag must reflect actual user verification, not a214        // hard-coded bit. If the relying party demands UV, run the platform215        // biometric / device-credential prompt and only sign with UV=1 on216        // success. If UV is required but verification fails, do NOT sign —217        // fail closed rather than produce a misleading UV=0 assertion.218        val uvRequired = entry != null && requestJson != null &&219            userVerificationRequirement(requestJson) == UV_REQUIRED220        val userVerified = if (uvRequired) {221            runUserVerificationPrompt()222        } else {223            false224        }225        if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {226            PendingIntentHandler.setGetCredentialException(227                result,228                GetCredentialUnsupportedException(229                    "User verification is required but could not be completed.",230                ),231            )232            setResult(RESULT_OK, result)233            finish()234            return235        }236        val assertion = if (entry != null && requestJson != null) {237            PasskeyAssertion.build(238                entry = entry,239                requestJson = requestJson,240                origin = origin,241                clientDataHashOverride = clientDataHash,242                userVerified = userVerified,243            )244        } else {245            null246        }247248        if (entry != null && assertion != null) {249            val passkey = entry.passkey250            val persisted = if (passkey != null) {251                val now = System.currentTimeMillis()252                runCatching {253                    vault.updateEntry(254                        entry.copy(255                            passkey = passkey.copy(signCount = assertion.nextSignCount),256                            signCount = assertion.nextSignCount,257                            lastUsedAt = now,258                        ),259                    )260                }.isSuccess261            } else {262                false263            }264            if (persisted) {265                PendingIntentHandler.setGetCredentialResponse(266                    result,267                    GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),268                )269            } else {270                PendingIntentHandler.setGetCredentialException(271                    result,272                    GetCredentialUnsupportedException("Peach could not update this passkey."),273                )274            }275        } else {276            PendingIntentHandler.setGetCredentialException(277                result,278                GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),279            )280        }281        setResult(RESULT_OK, result)282        finish()283    }284285    private suspend fun completeCreate() {286        val result = Intent()287        val providerRequest = runCatching {288            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)289        }.getOrNull()290        val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest291        val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)292        // F1/F2: request.origin is caller-supplied and therefore untrusted;293        // the only acceptable origins are the framework-verified ones. A null294        // origin makes PasskeyRegistration.build fail closed.295        val origin = providerRequest?.callingAppInfo?.let { info ->296            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))297        }298        // H-07: WebAuthn's UV flag on registration must reflect an actual299        // user-verification ceremony, not a hard-coded bit. Read the RP's300        // requirement from the creation options, run the same platform301        // biometric / device-credential prompt the assertion path uses, and302        // fail closed if UV is required but verification did not succeed.303        val uvRequired = requestJson != null &&304            registrationUserVerificationRequirement(requestJson) == UV_REQUIRED305        // Opportunistic in all cases: mirror assertion semantics for the306        // non-required branch (never hard-code UV=1). Biometrics.isAvailable307        // inside runUserVerificationPrompt already returns false when the308        // device cannot verify, so a missing authenticator yields UV=0.309        val userVerified = runUserVerificationPrompt()310        if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {311            PendingIntentHandler.setCreateCredentialException(312                result,313                CreateCredentialUnsupportedException(314                    "User verification is required but could not be completed.",315                ),316            )317            setResult(RESULT_OK, result)318            finish()319            return320        }321        val registration = requestJson?.let {322            PasskeyRegistration.build(it, origin, userVerified = userVerified)323        }324        val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false325        if (registration != null && stored) {326            PendingIntentHandler.setCreateCredentialResponse(327                result,328                CreatePublicKeyCredentialResponse(registration.responseJson),329            )330        } else {331            PendingIntentHandler.setCreateCredentialException(332                result,333                CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),334            )335        }336        setResult(RESULT_OK, result)337        finish()338    }339340    // Reads the RP's userVerification requirement from a PublicKeyCredential341    // CreationOptions payload. Per WebAuthn, the canonical location is342    // `authenticatorSelection.userVerification`; we fall back to a top-level343    // `userVerification` field for clients that flatten it. Returns null when344    // the request does not specify a value (treated as not-required).345    private fun registrationUserVerificationRequirement(requestJson: String): String? =346        runCatching {347            val root = Json.parseToJsonElement(requestJson).jsonObject348            val fromSelection = root["authenticatorSelection"]?.jsonObject349                ?.get("userVerification")?.jsonPrimitive?.contentOrNull350            fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull351        }.getOrNull()352353    private fun userVerificationRequirement(requestJson: String): String? =354        runCatching {355            Json.parseToJsonElement(requestJson).jsonObject["userVerification"]356                ?.jsonPrimitive?.contentOrNull357        }.getOrNull()358359    private suspend fun runUserVerificationPrompt(): Boolean {360        if (!Biometrics.isAvailable(this)) return false361        return suspendCancellableCoroutine { cont ->362            Biometrics.authenticate(363                activity = this,364                title = "Verify it's you",365                subtitle = "Confirm to use this passkey",366                onSuccess = { if (cont.isActive) cont.resume(true) },367                onFail = { if (cont.isActive) cont.resume(false) },368            )369        }370    }371372    private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {373        val rpId = draft.passkey?.rpId ?: return false374        val target = vault.getEntries().firstOrNull { entry ->375                entry.deletedAt == null &&376                entry.passkey == null &&377                entry.isLogin() &&378                listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())379                    .any { extractDomain(it) == rpId }380        }381        return runCatching {382            if (target != null) {383                vault.updateEntry(384                    target.copy(385                        passkey = draft.passkey,386                        username = target.username ?: draft.username,387                    ),388                )389            } else {390                vault.addEntry(draft)391            }392        }.isSuccess393    }394395    companion object {396        private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"397        private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"398        private const val EXTRA_AUTHENTICATED_ORIGIN =399            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"400        private const val EXTRA_AUTHENTICATED_CLIENT_DATA_HASH =401            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_CLIENT_DATA_HASH"402        private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"403        private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"404        private const val MODE_CREATE = "create"405        private const val MODE_CHOOSE = "choose"406        // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.407        // Only "required" forces a biometric / device-credential prompt here;408        // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.409        private const val UV_REQUIRED = "required"410411        fun pendingIntent(412            context: Context,413            entryId: String,414            requestJson: String,415            authenticatedOrigin: String?,416            authenticatedClientDataHash: ByteArray?,417        ): PendingIntent {418            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)419                .putExtra(EXTRA_ENTRY_ID, entryId)420                .putExtra(EXTRA_REQUEST_JSON, requestJson)421                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)422                .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, authenticatedClientDataHash)423            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0424            return PendingIntent.getActivity(425                context,426                "$entryId:$requestJson".hashCode(),427                intent,428                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,429            )430        }431432        fun createPendingIntent(context: Context, requestJson: String): PendingIntent {433            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)434                .putExtra(EXTRA_MODE, MODE_CREATE)435                .putExtra(EXTRA_REQUEST_JSON, requestJson)436            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0437            return PendingIntent.getActivity(438                context,439                requestJson.hashCode(),440                intent,441                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,442            )443        }444445        fun choosePendingIntent(446            context: Context,447            request: BeginGetCredentialRequest,448            authenticatedOrigin: String?,449        ): PendingIntent {450            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)451                .putExtra(EXTRA_MODE, MODE_CHOOSE)452                .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))453                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)454            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0455            return PendingIntent.getActivity(456                context,457                "choose:${request.hashCode()}".hashCode(),458                intent,459                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,460            )461        }462    }463464    private data class ManualChoice(465        val entryId: String,466        val displayName: String,467        val username: String,468        val option: BeginGetPublicKeyCredentialOption,469    )470}471472/**473 * The authentication-action response is built only after Peach has verified474 * the caller from the framework's BeginGetCredentialRequest. Some OEM475 * Credential Manager implementations omit the final provider request when a476 * credential row is returned from that nested response, so retain that same477 * verified origin as a fallback instead of turning a valid row into a no-op.478 */479internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =480    frameworkOrigin?.takeIf { it.isNotBlank() }481        ?: authenticatedOrigin?.takeIf { it.isNotBlank() }482483/**484 * Prefer request data attached by Credential Manager to the selected row. Some485 * OEM confirmation-button paths omit that final request, so retain the same486 * framework-authenticated hash that arrived in the BeginGet request.487 */488internal fun selectionClientDataHash(489    frameworkHash: ByteArray?,490    authenticatedHash: ByteArray?,491): ByteArray? = frameworkHash ?: authenticatedHash