PasskeyCredentialSelectionActivity.kt

24.5 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 authenticatedRequestJson = intent.getStringExtra(EXTRA_REQUEST_JSON)109        val requestJson = selectionRequestJson(110            frameworkJson = option?.requestJson,111            authenticatedJson = authenticatedRequestJson,112        )113        val clientDataHash = selectionClientDataHash(114            frameworkHash = option?.clientDataHash,115            authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH),116        )117        // F1/F2: the origin must be framework-verified (privileged browser118        // allowlist, else the caller's apk-key-hash). Unresolvable callers119        // fail closed — PasskeyAssertion.build refuses a null origin.120        val frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->121            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))122        }123        val origin = selectionOrigin(124            frameworkOrigin = frameworkOrigin,125            authenticatedOrigin = intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN),126        )127        completeAssertion(128            entryId,129            requestJson,130            origin,131            clientDataHash,132            uvRequiredOverride = selectionRequiresUserVerification(133                frameworkJson = option?.requestJson,134                authenticatedJson = authenticatedRequestJson,135            ),136        )137    }138139    private fun showManualChooser() {140        val request = runCatching {141            PendingIntentHandler.retrieveBeginGetCredentialRequest(intent)142        }.getOrNull() ?: intent.getBundleExtra(EXTRA_BEGIN_REQUEST)143            ?.let(BeginGetCredentialRequest::fromBundle)144        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)145        val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) }146            ?: intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN)147        val requestChoices = request?.beginGetCredentialOptions148            ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()149            ?.flatMap { option ->150                PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)151                    .map {152                        ManualChoice(153                            it.entryId,154                            it.displayName,155                            it.username,156                            option.requestJson,157                            option.clientDataHash,158                        )159                    }160            }161            .orEmpty()162        val choices = requestChoices.ifEmpty {163            val requestJson = intent.getStringExtra(EXTRA_REQUEST_JSON)164            val authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH)165            if (requestJson == null) emptyList()166            else PasskeyCredentialPlanner.candidates(vault.getEntries(), requestJson, origin)167                .map {168                    ManualChoice(169                        entryId = it.entryId,170                        displayName = it.displayName,171                        username = it.username,172                        requestJson = requestJson,173                        clientDataHash = authenticatedHash,174                    )175                }176        }177        if (choices.isEmpty()) {178            finish()179            return180        }181        val settings = vault.settings.value182        val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT183        setContent {184            PeachTheme(accent = accent, textSize = settings.textSize) {185                var query by remember { mutableStateOf("") }186                val visible = choices.filter {187                    query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||188                        it.username.contains(query, ignoreCase = true)189                }190                Column(191                    Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),192                ) {193                    Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)194                    androidx.compose.foundation.text.BasicTextField(195                        value = query,196                        onValueChange = { query = it },197                        singleLine = true,198                        textStyle = androidx.compose.ui.text.TextStyle(199                            color = peachColors.T0,200                            fontFamily = PeachType.Mono,201                            fontSize = 14.sp,202                        ),203                        modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)204                            .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),205                        decorationBox = { inner ->206                            if (query.isEmpty()) {207                                Text(208                                    "Search eligible Peach passkeys…",209                                    color = peachColors.T2,210                                    fontFamily = PeachType.Mono,211                                    fontSize = 12.sp,212                                )213                            }214                            inner()215                        },216                    )217                    LazyColumn {218                        items(visible, key = { "${it.entryId}:${it.requestJson.hashCode()}" }) { choice ->219                            Column(220                                Modifier.fillMaxWidth().clickable {221                                    lifecycleScope.launch {222                                        completeAssertion(223                                            choice.entryId,224                                            choice.requestJson,225                                            origin,226                                            choice.clientDataHash,227                                        )228                                    }229                                }.padding(vertical = 12.dp),230                            ) {231                                Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)232                                Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)233                            }234                        }235                    }236                }237            }238        }239    }240241    private suspend fun completeAssertion(242        entryId: String?,243        requestJson: String?,244        origin: String?,245        clientDataHash: ByteArray?,246        uvRequiredOverride: Boolean? = null,247    ) {248        val result = Intent()249        val entry = entryId?.let(vault::getEntry)250        // H-07: WebAuthn's UV flag must reflect actual user verification, not a251        // hard-coded bit. If the relying party demands UV, run the platform252        // biometric / device-credential prompt and only sign with UV=1 on253        // success. If UV is required but verification fails, do NOT sign —254        // fail closed rather than produce a misleading UV=0 assertion.255        val uvRequired = entry != null && requestJson != null &&256            (uvRequiredOverride ?: (userVerificationRequirement(requestJson) == UV_REQUIRED))257        val userVerified = if (uvRequired) {258            runUserVerificationPrompt()259        } else {260            false261        }262        if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {263            PendingIntentHandler.setGetCredentialException(264                result,265                GetCredentialUnsupportedException(266                    "User verification is required but could not be completed.",267                ),268            )269            setResult(RESULT_OK, result)270            finish()271            return272        }273        val assertion = if (entry != null && requestJson != null) {274            PasskeyAssertion.build(275                entry = entry,276                requestJson = requestJson,277                origin = origin,278                clientDataHashOverride = clientDataHash,279                userVerified = userVerified,280            )281        } else {282            null283        }284285        if (entry != null && assertion != null) {286            val passkey = entry.passkey287            val persisted = if (passkey != null) {288                val now = System.currentTimeMillis()289                runCatching {290                    vault.updateEntry(291                        entry.copy(292                            passkey = passkey.copy(signCount = assertion.nextSignCount),293                            signCount = assertion.nextSignCount,294                            lastUsedAt = now,295                        ),296                    )297                }.isSuccess298            } else {299                false300            }301            if (persisted) {302                PendingIntentHandler.setGetCredentialResponse(303                    result,304                    GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),305                )306            } else {307                PendingIntentHandler.setGetCredentialException(308                    result,309                    GetCredentialUnsupportedException("Peach could not update this passkey."),310                )311            }312        } else {313            PendingIntentHandler.setGetCredentialException(314                result,315                GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),316            )317        }318        setResult(RESULT_OK, result)319        finish()320    }321322    private suspend fun completeCreate() {323        val result = Intent()324        val providerRequest = runCatching {325            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)326        }.getOrNull()327        val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest328        val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)329        // F1/F2: request.origin is caller-supplied and therefore untrusted;330        // the only acceptable origins are the framework-verified ones. A null331        // origin makes PasskeyRegistration.build fail closed.332        val origin = providerRequest?.callingAppInfo?.let { info ->333            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))334        }335        // H-07: WebAuthn's UV flag on registration must reflect an actual336        // user-verification ceremony, not a hard-coded bit. Read the RP's337        // requirement from the creation options, run the same platform338        // biometric / device-credential prompt the assertion path uses, and339        // fail closed if UV is required but verification did not succeed.340        val uvRequired = requestJson != null &&341            registrationUserVerificationRequirement(requestJson) == UV_REQUIRED342        // Opportunistic in all cases: mirror assertion semantics for the343        // non-required branch (never hard-code UV=1). Biometrics.isAvailable344        // inside runUserVerificationPrompt already returns false when the345        // device cannot verify, so a missing authenticator yields UV=0.346        val userVerified = runUserVerificationPrompt()347        if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {348            PendingIntentHandler.setCreateCredentialException(349                result,350                CreateCredentialUnsupportedException(351                    "User verification is required but could not be completed.",352                ),353            )354            setResult(RESULT_OK, result)355            finish()356            return357        }358        val registration = requestJson?.let {359            PasskeyRegistration.build(it, origin, userVerified = userVerified)360        }361        val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false362        if (registration != null && stored) {363            PendingIntentHandler.setCreateCredentialResponse(364                result,365                CreatePublicKeyCredentialResponse(registration.responseJson),366            )367        } else {368            PendingIntentHandler.setCreateCredentialException(369                result,370                CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),371            )372        }373        setResult(RESULT_OK, result)374        finish()375    }376377    // Reads the RP's userVerification requirement from a PublicKeyCredential378    // CreationOptions payload. Per WebAuthn, the canonical location is379    // `authenticatorSelection.userVerification`; we fall back to a top-level380    // `userVerification` field for clients that flatten it. Returns null when381    // the request does not specify a value (treated as not-required).382    private fun registrationUserVerificationRequirement(requestJson: String): String? =383        runCatching {384            val root = Json.parseToJsonElement(requestJson).jsonObject385            val fromSelection = root["authenticatorSelection"]?.jsonObject386                ?.get("userVerification")?.jsonPrimitive?.contentOrNull387            fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull388        }.getOrNull()389390    private fun userVerificationRequirement(requestJson: String): String? =391        runCatching {392            Json.parseToJsonElement(requestJson).jsonObject["userVerification"]393                ?.jsonPrimitive?.contentOrNull394        }.getOrNull()395396    private suspend fun runUserVerificationPrompt(): Boolean {397        if (!Biometrics.isAvailable(this)) return false398        return suspendCancellableCoroutine { cont ->399            Biometrics.authenticate(400                activity = this,401                title = "Verify it's you",402                subtitle = "Confirm to use this passkey",403                onSuccess = { if (cont.isActive) cont.resume(true) },404                onFail = { if (cont.isActive) cont.resume(false) },405            )406        }407    }408409    private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {410        val rpId = draft.passkey?.rpId ?: return false411        val target = vault.getEntries().firstOrNull { entry ->412                entry.deletedAt == null &&413                entry.passkey == null &&414                entry.isLogin() &&415                listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())416                    .any { extractDomain(it) == rpId }417        }418        return runCatching {419            if (target != null) {420                vault.updateEntry(421                    target.copy(422                        passkey = draft.passkey,423                        username = target.username ?: draft.username,424                    ),425                )426            } else {427                vault.addEntry(draft)428            }429        }.isSuccess430    }431432    companion object {433        private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"434        private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"435        private const val EXTRA_AUTHENTICATED_ORIGIN =436            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"437        private const val EXTRA_AUTHENTICATED_CLIENT_DATA_HASH =438            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_CLIENT_DATA_HASH"439        private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"440        private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"441        private const val MODE_CREATE = "create"442        private const val MODE_CHOOSE = "choose"443        // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.444        // Only "required" forces a biometric / device-credential prompt here;445        // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.446        private const val UV_REQUIRED = "required"447448        fun pendingIntent(449            context: Context,450            entryId: String,451            requestJson: String,452            authenticatedOrigin: String?,453            authenticatedClientDataHash: ByteArray?,454        ): PendingIntent {455            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)456                .putExtra(EXTRA_ENTRY_ID, entryId)457                .putExtra(EXTRA_REQUEST_JSON, requestJson)458                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)459                .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, authenticatedClientDataHash)460            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0461            return PendingIntent.getActivity(462                context,463                "$entryId:$requestJson".hashCode(),464                intent,465                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,466            )467        }468469        fun createPendingIntent(context: Context, requestJson: String): PendingIntent {470            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)471                .putExtra(EXTRA_MODE, MODE_CREATE)472                .putExtra(EXTRA_REQUEST_JSON, requestJson)473            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0474            return PendingIntent.getActivity(475                context,476                requestJson.hashCode(),477                intent,478                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,479            )480        }481482        fun choosePendingIntent(483            context: Context,484            request: BeginGetCredentialRequest,485            authenticatedOrigin: String?,486        ): PendingIntent {487            val option = request.beginGetCredentialOptions488                .filterIsInstance<BeginGetPublicKeyCredentialOption>()489                .firstOrNull()490            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)491                .putExtra(EXTRA_MODE, MODE_CHOOSE)492                .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))493                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)494                .putExtra(EXTRA_REQUEST_JSON, option?.requestJson)495                .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, option?.clientDataHash)496            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0497            return PendingIntent.getActivity(498                context,499                "choose:${request.hashCode()}".hashCode(),500                intent,501                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,502            )503        }504    }505506    private data class ManualChoice(507        val entryId: String,508        val displayName: String,509        val username: String,510        val requestJson: String,511        val clientDataHash: ByteArray?,512    )513}514515/**516 * The authentication-action response is built only after Peach has verified517 * the caller from the framework's BeginGetCredentialRequest. Some OEM518 * Credential Manager implementations omit the final provider request when a519 * credential row is returned from that nested response, so retain that same520 * verified origin as a fallback instead of turning a valid row into a no-op.521 */522internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =523    frameworkOrigin?.takeIf { it.isNotBlank() }524        ?: authenticatedOrigin?.takeIf { it.isNotBlank() }525526/**527 * Keep the framework-authenticated BeginGet hash whenever it is available.528 * Samsung can omit or replace the final row request depending on whether the529 * row or confirmation button was tapped; both UI paths must sign identical530 * client data.531 */532internal fun selectionClientDataHash(533    frameworkHash: ByteArray?,534    authenticatedHash: ByteArray?,535): ByteArray? = authenticatedHash ?: frameworkHash536537/** Use the final assertion JSON when present; its challenge pairs with the final request. */538internal fun selectionRequestJson(539    frameworkJson: String?,540    authenticatedJson: String?,541): String? = frameworkJson ?: authenticatedJson542543/** Never let an OEM's final row payload weaken BeginGet's UV requirement. */544internal fun selectionRequiresUserVerification(545    frameworkJson: String?,546    authenticatedJson: String?,547): Boolean = listOfNotNull(frameworkJson, authenticatedJson).any { requestJson ->548    runCatching {549        Json.parseToJsonElement(requestJson).jsonObject["userVerification"]550            ?.jsonPrimitive?.contentOrNull == "required"551    }.getOrDefault(false)552}