PasskeyCredentialSelectionActivity.kt

23.4 KB · raw

1package com.peachpasswords.passkeys23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.os.Build7import android.os.Bundle8import androidx.activity.compose.setContent9import androidx.compose.foundation.background10import androidx.compose.foundation.clickable11import androidx.compose.foundation.layout.Column12import androidx.compose.foundation.layout.fillMaxSize13import androidx.compose.foundation.layout.fillMaxWidth14import androidx.compose.foundation.layout.padding15import androidx.compose.foundation.lazy.LazyColumn16import androidx.compose.foundation.lazy.items17import androidx.compose.foundation.shape.RoundedCornerShape18import androidx.compose.material3.Text19import androidx.compose.runtime.getValue20import androidx.compose.runtime.mutableStateOf21import androidx.compose.runtime.remember22import androidx.compose.runtime.setValue23import androidx.compose.ui.Modifier24import androidx.compose.ui.text.font.FontWeight25import androidx.compose.ui.unit.dp26import androidx.compose.ui.unit.sp27import androidx.credentials.GetCredentialResponse28import androidx.credentials.GetPublicKeyCredentialOption29import androidx.credentials.CreatePublicKeyCredentialRequest30import androidx.credentials.CreatePublicKeyCredentialResponse31import androidx.credentials.PublicKeyCredential32import androidx.credentials.exceptions.CreateCredentialUnsupportedException33import androidx.credentials.exceptions.GetCredentialUnsupportedException34import androidx.credentials.provider.PendingIntentHandler35import androidx.credentials.provider.BeginGetCredentialRequest36import androidx.credentials.provider.BeginGetPublicKeyCredentialOption37import androidx.fragment.app.FragmentActivity38import androidx.lifecycle.lifecycleScope39import com.peachpasswords.BuildConfig40import com.peachpasswords.shared.extractDomain41import com.peachpasswords.ui.lock.Biometrics42import com.peachpasswords.ui.components.QaFlags43import com.peachpasswords.ui.components.SecureWindowPolicy44import com.peachpasswords.ui.theme.ACCENT_COLORS45import com.peachpasswords.ui.theme.DEFAULT_ACCENT46import com.peachpasswords.ui.theme.PeachTheme47import com.peachpasswords.ui.theme.PeachType48import com.peachpasswords.ui.theme.peachColors49import com.peachpasswords.vault.VaultRepository50import com.peachpasswords.vault.isLogin51import dagger.hilt.android.AndroidEntryPoint52import javax.inject.Inject53import kotlinx.coroutines.launch54import kotlinx.coroutines.suspendCancellableCoroutine55import kotlinx.serialization.json.Json56import kotlinx.serialization.json.contentOrNull57import kotlinx.serialization.json.jsonObject58import kotlinx.serialization.json.jsonPrimitive59import kotlin.coroutines.resume6061@AndroidEntryPoint62class PasskeyCredentialSelectionActivity : FragmentActivity() {63    @Inject lateinit var vault: VaultRepository64    private var debugSecureLease = false6566    override fun onCreate(savedInstanceState: Bundle?) {67        super.onCreate(savedInstanceState)68        // H-05: separate window from MainActivity — sign assertion and registration69        // operate on passkey material. Block screenshots / recents in every build70        // unless an explicit debug QA bypass was placed on this activity's intent.71        if (!BuildConfig.DEBUG) {72            SecureWindowPolicy.setUserSecure(window, true)73        } else if (!qaSecureCaptureBypass()) {74            SecureWindowPolicy.acquire(window)75            debugSecureLease = true76        }77        if (intent.getStringExtra(EXTRA_MODE) == MODE_CHOOSE) showManualChooser()78        else lifecycleScope.launch { completeSelection() }79    }8081    override fun onDestroy() {82        if (debugSecureLease) {83            SecureWindowPolicy.release(window)84            debugSecureLease = false85        }86        super.onDestroy()87    }8889    private fun qaSecureCaptureBypass(): Boolean =90        BuildConfig.DEBUG && (91            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||92                QaFlags.disableSecureCaptureForQa93            )9495    private suspend fun completeSelection() {96        if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {97            completeCreate()98            return99        }100        val result = Intent()101        val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)102        val providerRequest = runCatching {103            PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)104        }.getOrNull()105        val option = providerRequest?.credentialOptions106            ?.filterIsInstance<GetPublicKeyCredentialOption>()107            ?.firstOrNull()108        val requestJson = option?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)109        val clientDataHash = selectionClientDataHash(110            frameworkHash = option?.clientDataHash,111            authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH),112        )113        // F1/F2: the origin must be framework-verified (privileged browser114        // allowlist, else the caller's apk-key-hash). Unresolvable callers115        // fail closed — PasskeyAssertion.build refuses a null origin.116        val frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->117            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))118        }119        val origin = selectionOrigin(120            frameworkOrigin = frameworkOrigin,121            authenticatedOrigin = intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN),122        )123        completeAssertion(entryId, requestJson, origin, clientDataHash)124    }125126    private fun showManualChooser() {127        val request = runCatching {128            PendingIntentHandler.retrieveBeginGetCredentialRequest(intent)129        }.getOrNull() ?: intent.getBundleExtra(EXTRA_BEGIN_REQUEST)130            ?.let(BeginGetCredentialRequest::fromBundle)131        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)132        val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) }133            ?: intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN)134        val requestChoices = request?.beginGetCredentialOptions135            ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()136            ?.flatMap { option ->137                PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)138                    .map {139                        ManualChoice(140                            it.entryId,141                            it.displayName,142                            it.username,143                            option.requestJson,144                            option.clientDataHash,145                        )146                    }147            }148            .orEmpty()149        val choices = requestChoices.ifEmpty {150            val requestJson = intent.getStringExtra(EXTRA_REQUEST_JSON)151            val authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH)152            if (requestJson == null) emptyList()153            else PasskeyCredentialPlanner.candidates(vault.getEntries(), requestJson, origin)154                .map {155                    ManualChoice(156                        entryId = it.entryId,157                        displayName = it.displayName,158                        username = it.username,159                        requestJson = requestJson,160                        clientDataHash = authenticatedHash,161                    )162                }163        }164        if (choices.isEmpty()) {165            finish()166            return167        }168        val settings = vault.settings.value169        val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT170        setContent {171            PeachTheme(accent = accent, textSize = settings.textSize) {172                var query by remember { mutableStateOf("") }173                val visible = choices.filter {174                    query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||175                        it.username.contains(query, ignoreCase = true)176                }177                Column(178                    Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),179                ) {180                    Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)181                    androidx.compose.foundation.text.BasicTextField(182                        value = query,183                        onValueChange = { query = it },184                        singleLine = true,185                        textStyle = androidx.compose.ui.text.TextStyle(186                            color = peachColors.T0,187                            fontFamily = PeachType.Mono,188                            fontSize = 14.sp,189                        ),190                        modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)191                            .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),192                        decorationBox = { inner ->193                            if (query.isEmpty()) {194                                Text(195                                    "Search eligible Peach passkeys…",196                                    color = peachColors.T2,197                                    fontFamily = PeachType.Mono,198                                    fontSize = 12.sp,199                                )200                            }201                            inner()202                        },203                    )204                    LazyColumn {205                        items(visible, key = { "${it.entryId}:${it.requestJson.hashCode()}" }) { choice ->206                            Column(207                                Modifier.fillMaxWidth().clickable {208                                    lifecycleScope.launch {209                                        completeAssertion(210                                            choice.entryId,211                                            choice.requestJson,212                                            origin,213                                            choice.clientDataHash,214                                        )215                                    }216                                }.padding(vertical = 12.dp),217                            ) {218                                Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)219                                Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)220                            }221                        }222                    }223                }224            }225        }226    }227228    private suspend fun completeAssertion(229        entryId: String?,230        requestJson: String?,231        origin: String?,232        clientDataHash: ByteArray?,233    ) {234        val result = Intent()235        val entry = entryId?.let(vault::getEntry)236        // H-07: WebAuthn's UV flag must reflect actual user verification, not a237        // hard-coded bit. If the relying party demands UV, run the platform238        // biometric / device-credential prompt and only sign with UV=1 on239        // success. If UV is required but verification fails, do NOT sign —240        // fail closed rather than produce a misleading UV=0 assertion.241        val uvRequired = entry != null && requestJson != null &&242            userVerificationRequirement(requestJson) == UV_REQUIRED243        val userVerified = if (uvRequired) {244            runUserVerificationPrompt()245        } else {246            false247        }248        if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {249            PendingIntentHandler.setGetCredentialException(250                result,251                GetCredentialUnsupportedException(252                    "User verification is required but could not be completed.",253                ),254            )255            setResult(RESULT_OK, result)256            finish()257            return258        }259        val assertion = if (entry != null && requestJson != null) {260            PasskeyAssertion.build(261                entry = entry,262                requestJson = requestJson,263                origin = origin,264                clientDataHashOverride = clientDataHash,265                userVerified = userVerified,266            )267        } else {268            null269        }270271        if (entry != null && assertion != null) {272            val passkey = entry.passkey273            val persisted = if (passkey != null) {274                val now = System.currentTimeMillis()275                runCatching {276                    vault.updateEntry(277                        entry.copy(278                            passkey = passkey.copy(signCount = assertion.nextSignCount),279                            signCount = assertion.nextSignCount,280                            lastUsedAt = now,281                        ),282                    )283                }.isSuccess284            } else {285                false286            }287            if (persisted) {288                PendingIntentHandler.setGetCredentialResponse(289                    result,290                    GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),291                )292            } else {293                PendingIntentHandler.setGetCredentialException(294                    result,295                    GetCredentialUnsupportedException("Peach could not update this passkey."),296                )297            }298        } else {299            PendingIntentHandler.setGetCredentialException(300                result,301                GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),302            )303        }304        setResult(RESULT_OK, result)305        finish()306    }307308    private suspend fun completeCreate() {309        val result = Intent()310        val providerRequest = runCatching {311            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)312        }.getOrNull()313        val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest314        val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)315        // F1/F2: request.origin is caller-supplied and therefore untrusted;316        // the only acceptable origins are the framework-verified ones. A null317        // origin makes PasskeyRegistration.build fail closed.318        val origin = providerRequest?.callingAppInfo?.let { info ->319            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))320        }321        // H-07: WebAuthn's UV flag on registration must reflect an actual322        // user-verification ceremony, not a hard-coded bit. Read the RP's323        // requirement from the creation options, run the same platform324        // biometric / device-credential prompt the assertion path uses, and325        // fail closed if UV is required but verification did not succeed.326        val uvRequired = requestJson != null &&327            registrationUserVerificationRequirement(requestJson) == UV_REQUIRED328        // Opportunistic in all cases: mirror assertion semantics for the329        // non-required branch (never hard-code UV=1). Biometrics.isAvailable330        // inside runUserVerificationPrompt already returns false when the331        // device cannot verify, so a missing authenticator yields UV=0.332        val userVerified = runUserVerificationPrompt()333        if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {334            PendingIntentHandler.setCreateCredentialException(335                result,336                CreateCredentialUnsupportedException(337                    "User verification is required but could not be completed.",338                ),339            )340            setResult(RESULT_OK, result)341            finish()342            return343        }344        val registration = requestJson?.let {345            PasskeyRegistration.build(it, origin, userVerified = userVerified)346        }347        val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false348        if (registration != null && stored) {349            PendingIntentHandler.setCreateCredentialResponse(350                result,351                CreatePublicKeyCredentialResponse(registration.responseJson),352            )353        } else {354            PendingIntentHandler.setCreateCredentialException(355                result,356                CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),357            )358        }359        setResult(RESULT_OK, result)360        finish()361    }362363    // Reads the RP's userVerification requirement from a PublicKeyCredential364    // CreationOptions payload. Per WebAuthn, the canonical location is365    // `authenticatorSelection.userVerification`; we fall back to a top-level366    // `userVerification` field for clients that flatten it. Returns null when367    // the request does not specify a value (treated as not-required).368    private fun registrationUserVerificationRequirement(requestJson: String): String? =369        runCatching {370            val root = Json.parseToJsonElement(requestJson).jsonObject371            val fromSelection = root["authenticatorSelection"]?.jsonObject372                ?.get("userVerification")?.jsonPrimitive?.contentOrNull373            fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull374        }.getOrNull()375376    private fun userVerificationRequirement(requestJson: String): String? =377        runCatching {378            Json.parseToJsonElement(requestJson).jsonObject["userVerification"]379                ?.jsonPrimitive?.contentOrNull380        }.getOrNull()381382    private suspend fun runUserVerificationPrompt(): Boolean {383        if (!Biometrics.isAvailable(this)) return false384        return suspendCancellableCoroutine { cont ->385            Biometrics.authenticate(386                activity = this,387                title = "Verify it's you",388                subtitle = "Confirm to use this passkey",389                onSuccess = { if (cont.isActive) cont.resume(true) },390                onFail = { if (cont.isActive) cont.resume(false) },391            )392        }393    }394395    private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {396        val rpId = draft.passkey?.rpId ?: return false397        val target = vault.getEntries().firstOrNull { entry ->398                entry.deletedAt == null &&399                entry.passkey == null &&400                entry.isLogin() &&401                listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())402                    .any { extractDomain(it) == rpId }403        }404        return runCatching {405            if (target != null) {406                vault.updateEntry(407                    target.copy(408                        passkey = draft.passkey,409                        username = target.username ?: draft.username,410                    ),411                )412            } else {413                vault.addEntry(draft)414            }415        }.isSuccess416    }417418    companion object {419        private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"420        private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"421        private const val EXTRA_AUTHENTICATED_ORIGIN =422            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"423        private const val EXTRA_AUTHENTICATED_CLIENT_DATA_HASH =424            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_CLIENT_DATA_HASH"425        private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"426        private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"427        private const val MODE_CREATE = "create"428        private const val MODE_CHOOSE = "choose"429        // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.430        // Only "required" forces a biometric / device-credential prompt here;431        // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.432        private const val UV_REQUIRED = "required"433434        fun pendingIntent(435            context: Context,436            entryId: String,437            requestJson: String,438            authenticatedOrigin: String?,439            authenticatedClientDataHash: ByteArray?,440        ): PendingIntent {441            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)442                .putExtra(EXTRA_ENTRY_ID, entryId)443                .putExtra(EXTRA_REQUEST_JSON, requestJson)444                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)445                .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, authenticatedClientDataHash)446            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0447            return PendingIntent.getActivity(448                context,449                "$entryId:$requestJson".hashCode(),450                intent,451                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,452            )453        }454455        fun createPendingIntent(context: Context, requestJson: String): PendingIntent {456            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)457                .putExtra(EXTRA_MODE, MODE_CREATE)458                .putExtra(EXTRA_REQUEST_JSON, requestJson)459            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0460            return PendingIntent.getActivity(461                context,462                requestJson.hashCode(),463                intent,464                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,465            )466        }467468        fun choosePendingIntent(469            context: Context,470            request: BeginGetCredentialRequest,471            authenticatedOrigin: String?,472        ): PendingIntent {473            val option = request.beginGetCredentialOptions474                .filterIsInstance<BeginGetPublicKeyCredentialOption>()475                .firstOrNull()476            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)477                .putExtra(EXTRA_MODE, MODE_CHOOSE)478                .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))479                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)480                .putExtra(EXTRA_REQUEST_JSON, option?.requestJson)481                .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, option?.clientDataHash)482            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0483            return PendingIntent.getActivity(484                context,485                "choose:${request.hashCode()}".hashCode(),486                intent,487                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,488            )489        }490    }491492    private data class ManualChoice(493        val entryId: String,494        val displayName: String,495        val username: String,496        val requestJson: String,497        val clientDataHash: ByteArray?,498    )499}500501/**502 * The authentication-action response is built only after Peach has verified503 * the caller from the framework's BeginGetCredentialRequest. Some OEM504 * Credential Manager implementations omit the final provider request when a505 * credential row is returned from that nested response, so retain that same506 * verified origin as a fallback instead of turning a valid row into a no-op.507 */508internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =509    frameworkOrigin?.takeIf { it.isNotBlank() }510        ?: authenticatedOrigin?.takeIf { it.isNotBlank() }511512/**513 * Keep the framework-authenticated BeginGet hash whenever it is available.514 * Samsung can omit or replace the final row request depending on whether the515 * row or confirmation button was tapped; both UI paths must sign identical516 * client data.517 */518internal fun selectionClientDataHash(519    frameworkHash: ByteArray?,520    authenticatedHash: ByteArray?,521): ByteArray? = authenticatedHash ?: frameworkHash