PasskeyCredentialSelectionActivity.kt

23.7 KB · raw

1package com.peachpasswords.passkeys23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.os.Build7import android.os.Bundle8import 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 = selectionRequestJson(109            frameworkJson = option?.requestJson,110            authenticatedJson = intent.getStringExtra(EXTRA_REQUEST_JSON),111        )112        val clientDataHash = selectionClientDataHash(113            frameworkHash = option?.clientDataHash,114            authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH),115        )116        // F1/F2: the origin must be framework-verified (privileged browser117        // allowlist, else the caller's apk-key-hash). Unresolvable callers118        // fail closed — PasskeyAssertion.build refuses a null origin.119        val frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->120            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))121        }122        val origin = selectionOrigin(123            frameworkOrigin = frameworkOrigin,124            authenticatedOrigin = intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN),125        )126        completeAssertion(entryId, requestJson, origin, clientDataHash)127    }128129    private fun showManualChooser() {130        val request = runCatching {131            PendingIntentHandler.retrieveBeginGetCredentialRequest(intent)132        }.getOrNull() ?: intent.getBundleExtra(EXTRA_BEGIN_REQUEST)133            ?.let(BeginGetCredentialRequest::fromBundle)134        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)135        val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) }136            ?: intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN)137        val requestChoices = request?.beginGetCredentialOptions138            ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()139            ?.flatMap { option ->140                PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)141                    .map {142                        ManualChoice(143                            it.entryId,144                            it.displayName,145                            it.username,146                            option.requestJson,147                            option.clientDataHash,148                        )149                    }150            }151            .orEmpty()152        val choices = requestChoices.ifEmpty {153            val requestJson = intent.getStringExtra(EXTRA_REQUEST_JSON)154            val authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH)155            if (requestJson == null) emptyList()156            else PasskeyCredentialPlanner.candidates(vault.getEntries(), requestJson, origin)157                .map {158                    ManualChoice(159                        entryId = it.entryId,160                        displayName = it.displayName,161                        username = it.username,162                        requestJson = requestJson,163                        clientDataHash = authenticatedHash,164                    )165                }166        }167        if (choices.isEmpty()) {168            finish()169            return170        }171        val settings = vault.settings.value172        val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT173        setContent {174            PeachTheme(accent = accent, textSize = settings.textSize) {175                var query by remember { mutableStateOf("") }176                val visible = choices.filter {177                    query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||178                        it.username.contains(query, ignoreCase = true)179                }180                Column(181                    Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),182                ) {183                    Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)184                    androidx.compose.foundation.text.BasicTextField(185                        value = query,186                        onValueChange = { query = it },187                        singleLine = true,188                        textStyle = androidx.compose.ui.text.TextStyle(189                            color = peachColors.T0,190                            fontFamily = PeachType.Mono,191                            fontSize = 14.sp,192                        ),193                        modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)194                            .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),195                        decorationBox = { inner ->196                            if (query.isEmpty()) {197                                Text(198                                    "Search eligible Peach passkeys…",199                                    color = peachColors.T2,200                                    fontFamily = PeachType.Mono,201                                    fontSize = 12.sp,202                                )203                            }204                            inner()205                        },206                    )207                    LazyColumn {208                        items(visible, key = { "${it.entryId}:${it.requestJson.hashCode()}" }) { choice ->209                            Column(210                                Modifier.fillMaxWidth().clickable {211                                    lifecycleScope.launch {212                                        completeAssertion(213                                            choice.entryId,214                                            choice.requestJson,215                                            origin,216                                            choice.clientDataHash,217                                        )218                                    }219                                }.padding(vertical = 12.dp),220                            ) {221                                Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)222                                Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)223                            }224                        }225                    }226                }227            }228        }229    }230231    private suspend fun completeAssertion(232        entryId: String?,233        requestJson: String?,234        origin: String?,235        clientDataHash: ByteArray?,236    ) {237        val result = Intent()238        val entry = entryId?.let(vault::getEntry)239        // H-07: WebAuthn's UV flag must reflect actual user verification, not a240        // hard-coded bit. If the relying party demands UV, run the platform241        // biometric / device-credential prompt and only sign with UV=1 on242        // success. If UV is required but verification fails, do NOT sign —243        // fail closed rather than produce a misleading UV=0 assertion.244        val uvRequired = entry != null && requestJson != null &&245            userVerificationRequirement(requestJson) == UV_REQUIRED246        val userVerified = if (uvRequired) {247            runUserVerificationPrompt()248        } else {249            false250        }251        if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {252            PendingIntentHandler.setGetCredentialException(253                result,254                GetCredentialUnsupportedException(255                    "User verification is required but could not be completed.",256                ),257            )258            setResult(RESULT_OK, result)259            finish()260            return261        }262        val assertion = if (entry != null && requestJson != null) {263            PasskeyAssertion.build(264                entry = entry,265                requestJson = requestJson,266                origin = origin,267                clientDataHashOverride = clientDataHash,268                userVerified = userVerified,269            )270        } else {271            null272        }273274        if (entry != null && assertion != null) {275            val passkey = entry.passkey276            val persisted = if (passkey != null) {277                val now = System.currentTimeMillis()278                runCatching {279                    vault.updateEntry(280                        entry.copy(281                            passkey = passkey.copy(signCount = assertion.nextSignCount),282                            signCount = assertion.nextSignCount,283                            lastUsedAt = now,284                        ),285                    )286                }.isSuccess287            } else {288                false289            }290            if (persisted) {291                PendingIntentHandler.setGetCredentialResponse(292                    result,293                    GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),294                )295            } else {296                PendingIntentHandler.setGetCredentialException(297                    result,298                    GetCredentialUnsupportedException("Peach could not update this passkey."),299                )300            }301        } else {302            PendingIntentHandler.setGetCredentialException(303                result,304                GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),305            )306        }307        setResult(RESULT_OK, result)308        finish()309    }310311    private suspend fun completeCreate() {312        val result = Intent()313        val providerRequest = runCatching {314            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)315        }.getOrNull()316        val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest317        val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)318        // F1/F2: request.origin is caller-supplied and therefore untrusted;319        // the only acceptable origins are the framework-verified ones. A null320        // origin makes PasskeyRegistration.build fail closed.321        val origin = providerRequest?.callingAppInfo?.let { info ->322            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))323        }324        // H-07: WebAuthn's UV flag on registration must reflect an actual325        // user-verification ceremony, not a hard-coded bit. Read the RP's326        // requirement from the creation options, run the same platform327        // biometric / device-credential prompt the assertion path uses, and328        // fail closed if UV is required but verification did not succeed.329        val uvRequired = requestJson != null &&330            registrationUserVerificationRequirement(requestJson) == UV_REQUIRED331        // Opportunistic in all cases: mirror assertion semantics for the332        // non-required branch (never hard-code UV=1). Biometrics.isAvailable333        // inside runUserVerificationPrompt already returns false when the334        // device cannot verify, so a missing authenticator yields UV=0.335        val userVerified = runUserVerificationPrompt()336        if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {337            PendingIntentHandler.setCreateCredentialException(338                result,339                CreateCredentialUnsupportedException(340                    "User verification is required but could not be completed.",341                ),342            )343            setResult(RESULT_OK, result)344            finish()345            return346        }347        val registration = requestJson?.let {348            PasskeyRegistration.build(it, origin, userVerified = userVerified)349        }350        val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false351        if (registration != null && stored) {352            PendingIntentHandler.setCreateCredentialResponse(353                result,354                CreatePublicKeyCredentialResponse(registration.responseJson),355            )356        } else {357            PendingIntentHandler.setCreateCredentialException(358                result,359                CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),360            )361        }362        setResult(RESULT_OK, result)363        finish()364    }365366    // Reads the RP's userVerification requirement from a PublicKeyCredential367    // CreationOptions payload. Per WebAuthn, the canonical location is368    // `authenticatorSelection.userVerification`; we fall back to a top-level369    // `userVerification` field for clients that flatten it. Returns null when370    // the request does not specify a value (treated as not-required).371    private fun registrationUserVerificationRequirement(requestJson: String): String? =372        runCatching {373            val root = Json.parseToJsonElement(requestJson).jsonObject374            val fromSelection = root["authenticatorSelection"]?.jsonObject375                ?.get("userVerification")?.jsonPrimitive?.contentOrNull376            fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull377        }.getOrNull()378379    private fun userVerificationRequirement(requestJson: String): String? =380        runCatching {381            Json.parseToJsonElement(requestJson).jsonObject["userVerification"]382                ?.jsonPrimitive?.contentOrNull383        }.getOrNull()384385    private suspend fun runUserVerificationPrompt(): Boolean {386        if (!Biometrics.isAvailable(this)) return false387        return suspendCancellableCoroutine { cont ->388            Biometrics.authenticate(389                activity = this,390                title = "Verify it's you",391                subtitle = "Confirm to use this passkey",392                onSuccess = { if (cont.isActive) cont.resume(true) },393                onFail = { if (cont.isActive) cont.resume(false) },394            )395        }396    }397398    private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {399        val rpId = draft.passkey?.rpId ?: return false400        val target = vault.getEntries().firstOrNull { entry ->401                entry.deletedAt == null &&402                entry.passkey == null &&403                entry.isLogin() &&404                listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())405                    .any { extractDomain(it) == rpId }406        }407        return runCatching {408            if (target != null) {409                vault.updateEntry(410                    target.copy(411                        passkey = draft.passkey,412                        username = target.username ?: draft.username,413                    ),414                )415            } else {416                vault.addEntry(draft)417            }418        }.isSuccess419    }420421    companion object {422        private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"423        private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"424        private const val EXTRA_AUTHENTICATED_ORIGIN =425            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"426        private const val EXTRA_AUTHENTICATED_CLIENT_DATA_HASH =427            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_CLIENT_DATA_HASH"428        private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"429        private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"430        private const val MODE_CREATE = "create"431        private const val MODE_CHOOSE = "choose"432        // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.433        // Only "required" forces a biometric / device-credential prompt here;434        // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.435        private const val UV_REQUIRED = "required"436437        fun pendingIntent(438            context: Context,439            entryId: String,440            requestJson: String,441            authenticatedOrigin: String?,442            authenticatedClientDataHash: ByteArray?,443        ): PendingIntent {444            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)445                .putExtra(EXTRA_ENTRY_ID, entryId)446                .putExtra(EXTRA_REQUEST_JSON, requestJson)447                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)448                .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, authenticatedClientDataHash)449            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0450            return PendingIntent.getActivity(451                context,452                "$entryId:$requestJson".hashCode(),453                intent,454                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,455            )456        }457458        fun createPendingIntent(context: Context, requestJson: String): PendingIntent {459            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)460                .putExtra(EXTRA_MODE, MODE_CREATE)461                .putExtra(EXTRA_REQUEST_JSON, requestJson)462            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0463            return PendingIntent.getActivity(464                context,465                requestJson.hashCode(),466                intent,467                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,468            )469        }470471        fun choosePendingIntent(472            context: Context,473            request: BeginGetCredentialRequest,474            authenticatedOrigin: String?,475        ): PendingIntent {476            val option = request.beginGetCredentialOptions477                .filterIsInstance<BeginGetPublicKeyCredentialOption>()478                .firstOrNull()479            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)480                .putExtra(EXTRA_MODE, MODE_CHOOSE)481                .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))482                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)483                .putExtra(EXTRA_REQUEST_JSON, option?.requestJson)484                .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, option?.clientDataHash)485            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0486            return PendingIntent.getActivity(487                context,488                "choose:${request.hashCode()}".hashCode(),489                intent,490                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,491            )492        }493    }494495    private data class ManualChoice(496        val entryId: String,497        val displayName: String,498        val username: String,499        val requestJson: String,500        val clientDataHash: ByteArray?,501    )502}503504/**505 * The authentication-action response is built only after Peach has verified506 * the caller from the framework's BeginGetCredentialRequest. Some OEM507 * Credential Manager implementations omit the final provider request when a508 * credential row is returned from that nested response, so retain that same509 * verified origin as a fallback instead of turning a valid row into a no-op.510 */511internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =512    frameworkOrigin?.takeIf { it.isNotBlank() }513        ?: authenticatedOrigin?.takeIf { it.isNotBlank() }514515/**516 * Keep the framework-authenticated BeginGet hash whenever it is available.517 * Samsung can omit or replace the final row request depending on whether the518 * row or confirmation button was tapped; both UI paths must sign identical519 * client data.520 */521internal fun selectionClientDataHash(522    frameworkHash: ByteArray?,523    authenticatedHash: ByteArray?,524): ByteArray? = authenticatedHash ?: frameworkHash525526/** Keep UV/challenge semantics from the framework-authenticated BeginGet request. */527internal fun selectionRequestJson(528    frameworkJson: String?,529    authenticatedJson: String?,530): String? = authenticatedJson ?: frameworkJson