PasskeyCredentialSelectionActivity.kt

14.5 KB · raw

1package com.peachpasswords.passkeys23import android.app.PendingIntent4import android.content.Context5import android.content.Intent6import android.os.Build7import android.os.Bundle8import androidx.credentials.GetCredentialResponse9import androidx.credentials.GetPublicKeyCredentialOption10import androidx.credentials.CreatePublicKeyCredentialRequest11import androidx.credentials.CreatePublicKeyCredentialResponse12import androidx.credentials.PublicKeyCredential13import androidx.credentials.exceptions.CreateCredentialUnsupportedException14import androidx.credentials.exceptions.GetCredentialUnsupportedException15import androidx.credentials.provider.PendingIntentHandler16import androidx.fragment.app.FragmentActivity17import androidx.lifecycle.lifecycleScope18import com.peachpasswords.BuildConfig19import com.peachpasswords.shared.extractDomain20import com.peachpasswords.ui.lock.Biometrics21import com.peachpasswords.ui.components.QaFlags22import com.peachpasswords.ui.components.SecureWindowPolicy23import com.peachpasswords.vault.VaultRepository24import com.peachpasswords.vault.isLogin25import dagger.hilt.android.AndroidEntryPoint26import javax.inject.Inject27import kotlinx.coroutines.launch28import kotlinx.coroutines.suspendCancellableCoroutine29import kotlinx.serialization.json.Json30import kotlinx.serialization.json.contentOrNull31import kotlinx.serialization.json.jsonObject32import kotlinx.serialization.json.jsonPrimitive33import kotlin.coroutines.resume3435@AndroidEntryPoint36class PasskeyCredentialSelectionActivity : FragmentActivity() {37    @Inject lateinit var vault: VaultRepository38    private var debugSecureLease = false3940    override fun onCreate(savedInstanceState: Bundle?) {41        super.onCreate(savedInstanceState)42        // H-05: separate window from MainActivity — sign assertion and registration43        // operate on passkey material. Block screenshots / recents in every build44        // unless an explicit debug QA bypass was placed on this activity's intent.45        if (!BuildConfig.DEBUG) {46            SecureWindowPolicy.setUserSecure(window, true)47        } else if (!qaSecureCaptureBypass()) {48            SecureWindowPolicy.acquire(window)49            debugSecureLease = true50        }51        lifecycleScope.launch { completeSelection() }52    }5354    override fun onDestroy() {55        if (debugSecureLease) {56            SecureWindowPolicy.release(window)57            debugSecureLease = false58        }59        super.onDestroy()60    }6162    private fun qaSecureCaptureBypass(): Boolean =63        BuildConfig.DEBUG && (64            intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||65                QaFlags.disableSecureCaptureForQa66            )6768    private suspend fun completeSelection() {69        if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {70            completeCreate()71            return72        }73        val result = Intent()74        val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)75        val providerRequest = runCatching {76            PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)77        }.getOrNull()78        val option = providerRequest?.credentialOptions79            ?.filterIsInstance<GetPublicKeyCredentialOption>()80            ?.firstOrNull()81        val requestJson = option?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)82        val entry = entryId?.let(vault::getEntry)83        // F1/F2: the origin must be framework-verified (privileged browser84        // allowlist, else the caller's apk-key-hash). Unresolvable callers85        // fail closed — PasskeyAssertion.build refuses a null origin.86        val frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->87            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))88        }89        val origin = selectionOrigin(90            frameworkOrigin = frameworkOrigin,91            authenticatedOrigin = intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN),92        )93        // H-07: WebAuthn's UV flag must reflect actual user verification, not a94        // hard-coded bit. If the relying party demands UV, run the platform95        // biometric / device-credential prompt and only sign with UV=1 on96        // success. If UV is required but verification fails, do NOT sign —97        // fail closed rather than produce a misleading UV=0 assertion.98        val uvRequired = entry != null && requestJson != null &&99            userVerificationRequirement(requestJson) == UV_REQUIRED100        val userVerified = if (uvRequired) {101            runUserVerificationPrompt()102        } else {103            false104        }105        if (uvRequired && !shouldSignAssertion(uvRequired, userVerified)) {106            PendingIntentHandler.setGetCredentialException(107                result,108                GetCredentialUnsupportedException(109                    "User verification is required but could not be completed.",110                ),111            )112            setResult(RESULT_OK, result)113            finish()114            return115        }116        val assertion = if (entry != null && requestJson != null) {117            PasskeyAssertion.build(118                entry = entry,119                requestJson = requestJson,120                origin = origin,121                clientDataHashOverride = option?.clientDataHash,122                userVerified = userVerified,123            )124        } else {125            null126        }127128        if (entry != null && assertion != null) {129            val passkey = entry.passkey130            val persisted = if (passkey != null) {131                val now = System.currentTimeMillis()132                runCatching {133                    vault.updateEntry(134                        entry.copy(135                            passkey = passkey.copy(signCount = assertion.nextSignCount),136                            signCount = assertion.nextSignCount,137                            lastUsedAt = now,138                        ),139                    )140                }.isSuccess141            } else {142                false143            }144            if (persisted) {145                PendingIntentHandler.setGetCredentialResponse(146                    result,147                    GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),148                )149            } else {150                PendingIntentHandler.setGetCredentialException(151                    result,152                    GetCredentialUnsupportedException("Peach could not update this passkey."),153                )154            }155        } else {156            PendingIntentHandler.setGetCredentialException(157                result,158                GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),159            )160        }161        setResult(RESULT_OK, result)162        finish()163    }164165    private suspend fun completeCreate() {166        val result = Intent()167        val providerRequest = runCatching {168            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)169        }.getOrNull()170        val request = providerRequest?.callingRequest as? CreatePublicKeyCredentialRequest171        val requestJson = request?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)172        // F1/F2: request.origin is caller-supplied and therefore untrusted;173        // the only acceptable origins are the framework-verified ones. A null174        // origin makes PasskeyRegistration.build fail closed.175        val origin = providerRequest?.callingAppInfo?.let { info ->176            PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))177        }178        // H-07: WebAuthn's UV flag on registration must reflect an actual179        // user-verification ceremony, not a hard-coded bit. Read the RP's180        // requirement from the creation options, run the same platform181        // biometric / device-credential prompt the assertion path uses, and182        // fail closed if UV is required but verification did not succeed.183        val uvRequired = requestJson != null &&184            registrationUserVerificationRequirement(requestJson) == UV_REQUIRED185        // Opportunistic in all cases: mirror assertion semantics for the186        // non-required branch (never hard-code UV=1). Biometrics.isAvailable187        // inside runUserVerificationPrompt already returns false when the188        // device cannot verify, so a missing authenticator yields UV=0.189        val userVerified = runUserVerificationPrompt()190        if (requestJson != null && !shouldSignAssertion(uvRequired, userVerified)) {191            PendingIntentHandler.setCreateCredentialException(192                result,193                CreateCredentialUnsupportedException(194                    "User verification is required but could not be completed.",195                ),196            )197            setResult(RESULT_OK, result)198            finish()199            return200        }201        val registration = requestJson?.let {202            PasskeyRegistration.build(it, origin, userVerified = userVerified)203        }204        val stored = registration?.let { storeRegistration(it.entryDraft) } ?: false205        if (registration != null && stored) {206            PendingIntentHandler.setCreateCredentialResponse(207                result,208                CreatePublicKeyCredentialResponse(registration.responseJson),209            )210        } else {211            PendingIntentHandler.setCreateCredentialException(212                result,213                CreateCredentialUnsupportedException("Peach cannot create this passkey yet."),214            )215        }216        setResult(RESULT_OK, result)217        finish()218    }219220    // Reads the RP's userVerification requirement from a PublicKeyCredential221    // CreationOptions payload. Per WebAuthn, the canonical location is222    // `authenticatorSelection.userVerification`; we fall back to a top-level223    // `userVerification` field for clients that flatten it. Returns null when224    // the request does not specify a value (treated as not-required).225    private fun registrationUserVerificationRequirement(requestJson: String): String? =226        runCatching {227            val root = Json.parseToJsonElement(requestJson).jsonObject228            val fromSelection = root["authenticatorSelection"]?.jsonObject229                ?.get("userVerification")?.jsonPrimitive?.contentOrNull230            fromSelection ?: root["userVerification"]?.jsonPrimitive?.contentOrNull231        }.getOrNull()232233    private fun userVerificationRequirement(requestJson: String): String? =234        runCatching {235            Json.parseToJsonElement(requestJson).jsonObject["userVerification"]236                ?.jsonPrimitive?.contentOrNull237        }.getOrNull()238239    private suspend fun runUserVerificationPrompt(): Boolean {240        if (!Biometrics.isAvailable(this)) return false241        return suspendCancellableCoroutine { cont ->242            Biometrics.authenticate(243                activity = this,244                title = "Verify it's you",245                subtitle = "Confirm to use this passkey",246                onSuccess = { if (cont.isActive) cont.resume(true) },247                onFail = { if (cont.isActive) cont.resume(false) },248            )249        }250    }251252    private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {253        val rpId = draft.passkey?.rpId ?: return false254        val target = vault.getEntries().firstOrNull { entry ->255                entry.deletedAt == null &&256                entry.passkey == null &&257                entry.isLogin() &&258                listOfNotNull(entry.url, *entry.additionalUrls.orEmpty().toTypedArray())259                    .any { extractDomain(it) == rpId }260        }261        return runCatching {262            if (target != null) {263                vault.updateEntry(264                    target.copy(265                        passkey = draft.passkey,266                        username = target.username ?: draft.username,267                    ),268                )269            } else {270                vault.addEntry(draft)271            }272        }.isSuccess273    }274275    companion object {276        private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"277        private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"278        private const val EXTRA_AUTHENTICATED_ORIGIN =279            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"280        private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"281        private const val MODE_CREATE = "create"282        // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.283        // Only "required" forces a biometric / device-credential prompt here;284        // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.285        private const val UV_REQUIRED = "required"286287        fun pendingIntent(288            context: Context,289            entryId: String,290            requestJson: String,291            authenticatedOrigin: String?,292        ): PendingIntent {293            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)294                .putExtra(EXTRA_ENTRY_ID, entryId)295                .putExtra(EXTRA_REQUEST_JSON, requestJson)296                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)297            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0298            return PendingIntent.getActivity(299                context,300                "$entryId:$requestJson".hashCode(),301                intent,302                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,303            )304        }305306        fun createPendingIntent(context: Context, requestJson: String): PendingIntent {307            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)308                .putExtra(EXTRA_MODE, MODE_CREATE)309                .putExtra(EXTRA_REQUEST_JSON, requestJson)310            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0311            return PendingIntent.getActivity(312                context,313                requestJson.hashCode(),314                intent,315                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,316            )317        }318    }319}320321/**322 * The authentication-action response is built only after Peach has verified323 * the caller from the framework's BeginGetCredentialRequest. Some OEM324 * Credential Manager implementations omit the final provider request when a325 * credential row is returned from that nested response, so retain that same326 * verified origin as a fallback instead of turning a valid row into a no-op.327 */328internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =329    frameworkOrigin?.takeIf { it.isNotBlank() }330        ?: authenticatedOrigin?.takeIf { it.isNotBlank() }