PasskeyCredentialSelectionActivity.kt

22.9 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 assertionBuild = if (entry != null && requestJson != null) {276            PasskeyAssertion.buildDetailed(277                entry = entry,278                requestJson = requestJson,279                origin = origin,280                clientDataHashOverride = clientDataHash,281                userVerified = userVerified,282            )283        } else null284        val assertion = (assertionBuild as? PasskeyAssertionBuild.Success)?.result285        if (assertionBuild is PasskeyAssertionBuild.Failure) {286            Log.w(TAG, "flow=assertion stage=build_failed reason=${assertionBuild.reason.name.lowercase()}")287        }288289        if (entry != null && assertion != null) {290            val passkey = entry.passkey291            val persisted = if (passkey != null) {292                val now = System.currentTimeMillis()293                runCatching {294                    vault.updateEntry(295                        entry.copy(296                            passkey = passkey.copy(signCount = assertion.nextSignCount),297                            signCount = assertion.nextSignCount,298                            lastUsedAt = now,299                        ),300                    )301                }.isSuccess302            } else {303                false304            }305            if (persisted) {306                Log.i(TAG, "flow=assertion stage=response_ready")307                PendingIntentHandler.setGetCredentialResponse(308                    result,309                    GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),310                )311            } else {312                Log.w(TAG, "flow=assertion stage=persist_failed")313                PendingIntentHandler.setGetCredentialException(314                    result,315                    GetCredentialUnsupportedException("Peach could not update this passkey."),316                )317            }318        } else {319            if (assertionBuild == null) Log.w(TAG, "flow=assertion stage=build_failed reason=missing_input")320            PendingIntentHandler.setGetCredentialException(321                result,322                GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),323            )324        }325        setResult(RESULT_OK, result)326        finish()327    }328329    private fun failGet(message: String) {330        val result = Intent()331        PendingIntentHandler.setGetCredentialException(332            result,333            GetCredentialUnsupportedException(message),334        )335        setResult(RESULT_OK, result)336        finish()337    }338339    private suspend fun completeCreate() {340        val result = Intent()341        val providerRequest = runCatching {342            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)343        }.getOrNull()344        val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest345        val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)346        // F1/F2: request.origin is caller-supplied and therefore untrusted;347        // the only acceptable origins are the framework-verified ones. A null348        // origin makes PasskeyRegistration.build fail closed.349        val origin = providerRequest?.callingAppInfo?.let { info ->350            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))351        }352        // H-07: WebAuthn's UV flag on registration must reflect an actual353        // user-verification ceremony, not a hard-coded bit. Read the RP's354        // requirement from the creation options, run the same platform355        // biometric / device-credential prompt the assertion path uses, and356        // fail closed if UV is required but verification did not succeed.357        val uvRequired = requestJson != null &&358            registrationUserVerificationRequirement(requestJson) == UV_REQUIRED359        // Opportunistic in all cases: mirror assertion semantics for the360        // non-required branch (never hard-code UV=1). Biometrics.isAvailable361        // inside runUserVerificationPrompt already returns false when the362        // device cannot verify, so a missing authenticator yields UV=0.363        val userVerified = runUserVerificationPrompt()364        if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {365            PendingIntentHandler.setCreateCredentialException(366                result,367                CreateCredentialUnsupportedException(368                    "User verification is required but could not be completed.",369                ),370            )371            setResult(RESULT_OK, result)372            finish()373            return374        }375        val registration = requestJson?.let {376            PasskeyRegistration.build(it, origin, userVerified = userVerified)377        }378        val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false379        if (registration != null && stored) {380            PendingIntentHandler.setCreateCredentialResponse(381                result,382                CreatePublicKeyCredentialResponse(registration.responseJson),383            )384        } else {385            PendingIntentHandler.setCreateCredentialException(386                result,387                CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),388            )389        }390        setResult(RESULT_OK, result)391        finish()392    }393394    // Reads the RP's userVerification requirement from a PublicKeyCredential395    // CreationOptions payload. Per WebAuthn, the canonical location is396    // `authenticatorSelection.userVerification`; we fall back to a top-level397    // `userVerification` field for clients that flatten it. Returns null when398    // the request does not specify a value (treated as not-required).399    private fun registrationUserVerificationRequirement(requestJson: String): String? =400        runCatching {401            val root = Json.parseToJsonElement(requestJson).jsonObject402            val fromSelection = root["authenticatorSelection"]?.jsonObject403                ?.get("userVerification")?.jsonPrimitive?.contentOrNull404            fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull405        }.getOrNull()406407    private fun userVerificationRequirement(requestJson: String): String? =408        runCatching {409            Json.parseToJsonElement(requestJson).jsonObject["userVerification"]410                ?.jsonPrimitive?.contentOrNull411        }.getOrNull()412413    private suspend fun runUserVerificationPrompt(): Boolean {414        if (!Biometrics.isAvailable(this)) return false415        return suspendCancellableCoroutine { cont ->416            Biometrics.authenticate(417                activity = this,418                title = "Verify it's you",419                subtitle = "Confirm to use this passkey",420                onSuccess = { if (cont.isActive) cont.resume(true) },421                onFail = { if (cont.isActive) cont.resume(false) },422            )423        }424    }425426    private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {427        val rpId = draft.passkey?.rpId ?: return false428        val target = vault.getEntries().firstOrNull { entry ->429                entry.deletedAt == null &&430                entry.passkey == null &&431                entry.isLogin() &&432                listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())433                    .any { extractDomain(it) == rpId }434        }435        return runCatching {436            if (target != null) {437                vault.updateEntry(438                    target.copy(439                        passkey = draft.passkey,440                        username = target.username ?: draft.username,441                    ),442                )443            } else {444                vault.addEntry(draft)445            }446        }.isSuccess447    }448449    companion object {450        private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"451        private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"452        private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"453        private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"454        private const val MODE_CREATE = "create"455        private const val MODE_CHOOSE = "choose"456        // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.457        // Only "required" forces a biometric / device-credential prompt here;458        // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.459        private const val UV_REQUIRED = "required"460        private const val TAG = "PeachPasskey"461462        fun pendingIntent(463            context: Context,464            entryId: String,465        ): PendingIntent {466            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)467                .putExtra(EXTRA_ENTRY_ID, entryId)468            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0469            return PendingIntent.getActivity(470                context,471                PasskeyPendingIntentIds.next(),472                intent,473                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,474            )475        }476477        fun createPendingIntent(context: Context, requestJson: String): PendingIntent {478            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)479                .putExtra(EXTRA_MODE, MODE_CREATE)480                .putExtra(EXTRA_REQUEST_JSON, requestJson)481            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0482            return PendingIntent.getActivity(483                context,484                PasskeyPendingIntentIds.next(),485                intent,486                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,487            )488        }489490        fun choosePendingIntent(491            context: Context,492            request: BeginGetCredentialRequest,493        ): PendingIntent {494            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)495                .putExtra(EXTRA_MODE, MODE_CHOOSE)496                .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))497            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0498            return PendingIntent.getActivity(499                context,500                PasskeyPendingIntentIds.next(),501                intent,502                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,503            )504        }505    }506507    private data class ManualChoice(508        val entryId: String,509        val displayName: String,510        val username: String,511        val requestJson: String,512        val clientDataHash: ByteArray?,513    )514}