PasskeyCredentialSelectionActivity.kt

22.6 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            hasEmbeddedResult = biometricResult != null,119            embeddedSucceeded = biometricResult?.isSuccessful == true,120        )121        if (route == PasskeyVerificationRoute.REJECT) {122            Log.w(TAG, "flow=row stage=embedded_verification_rejected")123            failGet("Android could not verify the user for this passkey.")124            return125        }126        val userVerified = when (route) {127            PasskeyVerificationRoute.ACCEPT -> true128            PasskeyVerificationRoute.PROMPT_IN_PEACH -> runUserVerificationPrompt()129            PasskeyVerificationRoute.REJECT -> false130        }131        if (!userVerified) {132            Log.w(TAG, "flow=row stage=verification_incomplete")133            failGet("User verification is required but could not be completed.")134            return135        }136        Log.i(TAG, "flow=row stage=assertion_started verification=${route.name.lowercase()}")137        completeAssertion(138            entryId,139            option.requestJson,140            frameworkOrigin,141            option.clientDataHash,142            verifiedUser = true,143        )144    }145146    private fun showManualChooser() {147        val request = runCatching {148            PendingIntentHandler.retrieveBeginGetCredentialRequest(intent)149        }.getOrNull() ?: intent.getBundleExtra(EXTRA_BEGIN_REQUEST)150            ?.let(BeginGetCredentialRequest::fromBundle)151        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)152        val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) }153        val requestChoices = request?.beginGetCredentialOptions154            ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()155            ?.flatMap { option ->156                PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)157                    .map {158                        ManualChoice(159                            it.entryId,160                            it.displayName,161                            it.username,162                            option.requestJson,163                            option.clientDataHash,164                        )165                    }166            }167            .orEmpty()168        val choices = requestChoices169        if (choices.isEmpty()) {170            Log.w(TAG, "flow=manual stage=begin_request_invalid")171            failGet("Peach did not receive an eligible passkey request from Android.")172            return173        }174        val settings = vault.settings.value175        val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT176        setContent {177            PeachTheme(accent = accent, textSize = settings.textSize) {178                var query by remember { mutableStateOf("") }179                val visible = choices.filter {180                    query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||181                        it.username.contains(query, ignoreCase = true)182                }183                Column(184                    Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),185                ) {186                    Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)187                    androidx.compose.foundation.text.BasicTextField(188                        value = query,189                        onValueChange = { query = it },190                        singleLine = true,191                        textStyle = androidx.compose.ui.text.TextStyle(192                            color = peachColors.T0,193                            fontFamily = PeachType.Mono,194                            fontSize = 14.sp,195                        ),196                        modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)197                            .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),198                        decorationBox = { inner ->199                            if (query.isEmpty()) {200                                Text(201                                    "Search eligible Peach passkeys…",202                                    color = peachColors.T2,203                                    fontFamily = PeachType.Mono,204                                    fontSize = 12.sp,205                                )206                            }207                            inner()208                        },209                    )210                    LazyColumn {211                        items(visible, key = { "${it.entryId}:${it.requestJson.hashCode()}" }) { choice ->212                            Column(213                                Modifier.fillMaxWidth().clickable {214                                    lifecycleScope.launch {215                                        val userVerified = runUserVerificationPrompt()216                                        if (!userVerified) {217                                            Log.w(TAG, "flow=manual stage=verification_incomplete")218                                            failGet("User verification is required but could not be completed.")219                                            return@launch220                                        }221                                        Log.i(TAG, "flow=manual stage=assertion_started")222                                        completeAssertion(223                                            choice.entryId,224                                            choice.requestJson,225                                            origin,226                                            choice.clientDataHash,227                                            verifiedUser = true,228                                        )229                                    }230                                }.padding(vertical = 12.dp),231                            ) {232                                Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)233                                Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)234                            }235                        }236                    }237                }238            }239        }240    }241242    private suspend fun completeAssertion(243        entryId: String?,244        requestJson: String?,245        origin: String?,246        clientDataHash: ByteArray?,247        uvRequiredOverride: Boolean? = null,248        verifiedUser: Boolean? = null,249    ) {250        val result = Intent()251        val entry = entryId?.let(vault::getEntry)252        // H-07: WebAuthn's UV flag must reflect actual user verification, not a253        // hard-coded bit. If the relying party demands UV, run the platform254        // biometric / device-credential prompt and only sign with UV=1 on255        // success. If UV is required but verification fails, do NOT sign —256        // fail closed rather than produce a misleading UV=0 assertion.257        val uvRequired = entry != null && requestJson != null &&258            (uvRequiredOverride ?: (userVerificationRequirement(requestJson) == UV_REQUIRED))259        val userVerified = verifiedUser ?: if (uvRequired) {260            runUserVerificationPrompt()261        } else {262            false263        }264        if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {265            PendingIntentHandler.setGetCredentialException(266                result,267                GetCredentialUnsupportedException(268                    "User verification is required but could not be completed.",269                ),270            )271            setResult(RESULT_OK, result)272            finish()273            return274        }275        val assertion = if (entry != null && requestJson != null) {276            PasskeyAssertion.build(277                entry = entry,278                requestJson = requestJson,279                origin = origin,280                clientDataHashOverride = clientDataHash,281                userVerified = userVerified,282            )283        } else {284            null285        }286287        if (entry != null && assertion != null) {288            val passkey = entry.passkey289            val persisted = if (passkey != null) {290                val now = System.currentTimeMillis()291                runCatching {292                    vault.updateEntry(293                        entry.copy(294                            passkey = passkey.copy(signCount = assertion.nextSignCount),295                            signCount = assertion.nextSignCount,296                            lastUsedAt = now,297                        ),298                    )299                }.isSuccess300            } else {301                false302            }303            if (persisted) {304                Log.i(TAG, "flow=assertion stage=response_ready")305                PendingIntentHandler.setGetCredentialResponse(306                    result,307                    GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),308                )309            } else {310                Log.w(TAG, "flow=assertion stage=persist_failed")311                PendingIntentHandler.setGetCredentialException(312                    result,313                    GetCredentialUnsupportedException("Peach could not update this passkey."),314                )315            }316        } else {317            Log.w(TAG, "flow=assertion stage=build_failed")318            PendingIntentHandler.setGetCredentialException(319                result,320                GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),321            )322        }323        setResult(RESULT_OK, result)324        finish()325    }326327    private fun failGet(message: String) {328        val result = Intent()329        PendingIntentHandler.setGetCredentialException(330            result,331            GetCredentialUnsupportedException(message),332        )333        setResult(RESULT_OK, result)334        finish()335    }336337    private suspend fun completeCreate() {338        val result = Intent()339        val providerRequest = runCatching {340            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)341        }.getOrNull()342        val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest343        val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)344        // F1/F2: request.origin is caller-supplied and therefore untrusted;345        // the only acceptable origins are the framework-verified ones. A null346        // origin makes PasskeyRegistration.build fail closed.347        val origin = providerRequest?.callingAppInfo?.let { info ->348            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))349        }350        // H-07: WebAuthn's UV flag on registration must reflect an actual351        // user-verification ceremony, not a hard-coded bit. Read the RP's352        // requirement from the creation options, run the same platform353        // biometric / device-credential prompt the assertion path uses, and354        // fail closed if UV is required but verification did not succeed.355        val uvRequired = requestJson != null &&356            registrationUserVerificationRequirement(requestJson) == UV_REQUIRED357        // Opportunistic in all cases: mirror assertion semantics for the358        // non-required branch (never hard-code UV=1). Biometrics.isAvailable359        // inside runUserVerificationPrompt already returns false when the360        // device cannot verify, so a missing authenticator yields UV=0.361        val userVerified = runUserVerificationPrompt()362        if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {363            PendingIntentHandler.setCreateCredentialException(364                result,365                CreateCredentialUnsupportedException(366                    "User verification is required but could not be completed.",367                ),368            )369            setResult(RESULT_OK, result)370            finish()371            return372        }373        val registration = requestJson?.let {374            PasskeyRegistration.build(it, origin, userVerified = userVerified)375        }376        val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false377        if (registration != null && stored) {378            PendingIntentHandler.setCreateCredentialResponse(379                result,380                CreatePublicKeyCredentialResponse(registration.responseJson),381            )382        } else {383            PendingIntentHandler.setCreateCredentialException(384                result,385                CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),386            )387        }388        setResult(RESULT_OK, result)389        finish()390    }391392    // Reads the RP's userVerification requirement from a PublicKeyCredential393    // CreationOptions payload. Per WebAuthn, the canonical location is394    // `authenticatorSelection.userVerification`; we fall back to a top-level395    // `userVerification` field for clients that flatten it. Returns null when396    // the request does not specify a value (treated as not-required).397    private fun registrationUserVerificationRequirement(requestJson: String): String? =398        runCatching {399            val root = Json.parseToJsonElement(requestJson).jsonObject400            val fromSelection = root["authenticatorSelection"]?.jsonObject401                ?.get("userVerification")?.jsonPrimitive?.contentOrNull402            fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull403        }.getOrNull()404405    private fun userVerificationRequirement(requestJson: String): String? =406        runCatching {407            Json.parseToJsonElement(requestJson).jsonObject["userVerification"]408                ?.jsonPrimitive?.contentOrNull409        }.getOrNull()410411    private suspend fun runUserVerificationPrompt(): Boolean {412        if (!Biometrics.isAvailable(this)) return false413        return suspendCancellableCoroutine { cont ->414            Biometrics.authenticate(415                activity = this,416                title = "Verify it's you",417                subtitle = "Confirm to use this passkey",418                onSuccess = { if (cont.isActive) cont.resume(true) },419                onFail = { if (cont.isActive) cont.resume(false) },420            )421        }422    }423424    private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {425        val rpId = draft.passkey?.rpId ?: return false426        val target = vault.getEntries().firstOrNull { entry ->427                entry.deletedAt == null &&428                entry.passkey == null &&429                entry.isLogin() &&430                listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())431                    .any { extractDomain(it) == rpId }432        }433        return runCatching {434            if (target != null) {435                vault.updateEntry(436                    target.copy(437                        passkey = draft.passkey,438                        username = target.username ?: draft.username,439                    ),440                )441            } else {442                vault.addEntry(draft)443            }444        }.isSuccess445    }446447    companion object {448        private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"449        private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"450        private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"451        private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"452        private const val MODE_CREATE = "create"453        private const val MODE_CHOOSE = "choose"454        // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.455        // Only "required" forces a biometric / device-credential prompt here;456        // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.457        private const val UV_REQUIRED = "required"458        private const val TAG = "PeachPasskey"459460        fun pendingIntent(461            context: Context,462            entryId: String,463        ): PendingIntent {464            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)465                .putExtra(EXTRA_ENTRY_ID, entryId)466            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0467            return PendingIntent.getActivity(468                context,469                PasskeyPendingIntentIds.next(),470                intent,471                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,472            )473        }474475        fun createPendingIntent(context: Context, requestJson: String): PendingIntent {476            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)477                .putExtra(EXTRA_MODE, MODE_CREATE)478                .putExtra(EXTRA_REQUEST_JSON, requestJson)479            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0480            return PendingIntent.getActivity(481                context,482                PasskeyPendingIntentIds.next(),483                intent,484                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,485            )486        }487488        fun choosePendingIntent(489            context: Context,490            request: BeginGetCredentialRequest,491        ): PendingIntent {492            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)493                .putExtra(EXTRA_MODE, MODE_CHOOSE)494                .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))495            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0496            return PendingIntent.getActivity(497                context,498                PasskeyPendingIntentIds.next(),499                intent,500                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,501            )502        }503    }504505    private data class ManualChoice(506        val entryId: String,507        val displayName: String,508        val username: String,509        val requestJson: String,510        val clientDataHash: ByteArray?,511    )512}