9cebcd3b8029

fix: Rebuild Credential Manager passkey flow (v2.17.0)

- Keep BeginGet discovery separate from final credential completion - Delegate Android 15 verification to Credential Manager and consume its result - Preserve one Peach verification fallback for older or incomplete framework paths - Make manual choice explicit and fail framework boundary errors closed - Use unique PendingIntent identities and non-secret stage diagnostics - Verify 1,095 JVM tests, lint with zero errors, and signed release assembly

time
2026-09-07 23:05 UTC · 1 month ago
machine
koh2
parent
8c0dd8e3209b
9 files VERSION+1 −1app/src/main/java/com/peachpasswords/credentials/CredentialProviderUnlockActivity.kt+18 −8app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialEntryFactory.kt+39 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+61 −101app/src/main/java/com/peachpasswords/passkeys/PasskeyPendingIntentIds.kt+9 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyUnlockIntent.kt+1 −2app/src/main/java/com/peachpasswords/passkeys/PasskeyVerificationRoute.kt+12 −0app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+3 −10app/src/test/java/com/peachpasswords/passkeys/CredentialProviderContractTest.kt+41 −59
VERSION+1 −1
@@ -1 +1 @@1-2.16.51+2.17.0

view file

app/src/main/java/com/peachpasswords/credentials/CredentialProviderUnlockActivity.kt+18 −8
@@ -3,6 +3,7 @@33 import android.content.Intent44 import android.graphics.drawable.Icon55 import android.os.Bundle6+import android.util.Log67 import com.peachpasswords.BuildConfig78 import androidx.activity.compose.setContent89 import com.peachpasswords.ui.components.QaFlags@@ -20,6 +21,7 @@2021 import com.peachpasswords.R2122 import com.peachpasswords.passkeys.PasskeyCallerOrigin2223 import com.peachpasswords.passkeys.PasskeyCredentialPlanner24+import com.peachpasswords.passkeys.PasskeyCredentialEntryFactory2325 import com.peachpasswords.passkeys.PasskeyCredentialSelectionActivity2426 import com.peachpasswords.passkeys.PasskeyUnlockIntent2527 import com.peachpasswords.autofill.appLinkDomains@@ -45,9 +47,20 @@4547             SecureWindowPolicy.acquire(window)4648             debugSecureLease = true4749         }48-        val request = intent.getBundleExtra(PasskeyUnlockIntent.EXTRA_REQUEST)50+        val request = runCatching {51+            PendingIntentHandler.retrieveBeginGetCredentialRequest(intent)52+        }.getOrNull() ?: intent.getBundleExtra(PasskeyUnlockIntent.EXTRA_REQUEST)4953             ?.let(BeginGetCredentialRequest::fromBundle)5054         if (request == null) {55+            val result = Intent()56+            PendingIntentHandler.setGetCredentialException(57+                result,58+                androidx.credentials.exceptions.GetCredentialUnsupportedException(59+                    "Peach did not receive the unlock request from Android.",60+                ),61+            )62+            Log.w("PeachPasskey", "flow=unlock stage=begin_request_invalid")63+            setResult(RESULT_OK, result)5164             finish()5265             return5366         }@@ -69,6 +82,7 @@6982     }7083 7184     private fun complete(request: BeginGetCredentialRequest) {85+        Log.i("PeachPasskey", "flow=unlock stage=response_build_started")7286         val info = request.callingAppInfo7387         val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)7488         val webOrigin = info?.let { runCatching { it.getOrigin(allowlist) }.getOrNull() }@@ -107,21 +121,17 @@107121                     option.requestJson,108122                     callerOrigin,109123                 ).map { candidate ->110-                    PublicKeyCredentialEntry(124+                    PasskeyCredentialEntryFactory.create(111125                         context = this,112126                         username = candidate.username,113127                         pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(114128                             this,115129                             candidate.entryId,116-                            option.requestJson,117-                            callerOrigin,118-                            option.clientDataHash,119130                         ),120-                        beginGetPublicKeyCredentialOption = option,131+                        option = option,121132                         displayName = candidate.displayName,122133                         lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),123134                         icon = icon,124-                        isAutoSelectAllowed = false,125135                     )126136                 }127137                 else -> emptyList<CredentialEntry>()@@ -135,7 +145,7 @@135145             builder.addAction(136146                 Action(137147                     "Choose another Peach passkey",138-                    PasskeyCredentialSelectionActivity.choosePendingIntent(this, request, callerOrigin),148+                    PasskeyCredentialSelectionActivity.choosePendingIntent(this, request),139149                     "Search eligible passkeys",140150                 ),141151             )

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialEntryFactory.kt+39 −0
@@ -0,0 +1,39 @@1+package com.peachpasswords.passkeys2+3+import android.app.PendingIntent4+import android.content.Context5+import android.graphics.drawable.Icon6+import android.os.Build7+import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG8+import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL9+import androidx.credentials.provider.BeginGetPublicKeyCredentialOption10+import androidx.credentials.provider.BiometricPromptData11+import androidx.credentials.provider.PublicKeyCredentialEntry12+import java.time.Instant13+14+internal object PasskeyCredentialEntryFactory {15+    fun create(16+        context: Context,17+        username: String,18+        displayName: String,19+        lastUsedTime: Instant,20+        pendingIntent: PendingIntent,21+        option: BeginGetPublicKeyCredentialOption,22+        icon: Icon,23+    ): PublicKeyCredentialEntry {24+        val builder = PublicKeyCredentialEntry.Builder(context, username, pendingIntent, option)25+            .setDisplayName(displayName)26+            .setLastUsedTime(lastUsedTime)27+            .setIcon(icon)28+            .setAutoSelectAllowed(false)29+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {30+            builder.setBiometricPromptData(31+                BiometricPromptData(32+                    cryptoObject = null,33+                    allowedAuthenticators = BIOMETRIC_STRONG or DEVICE_CREDENTIAL,34+                ),35+            )36+        }37+        return builder.build()38+    }39+}

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+61 −101
@@ -5,6 +5,7 @@55 import android.content.Intent66 import android.os.Build77 import android.os.Bundle8+import android.util.Log89 import androidx.activity.compose.setContent910 import androidx.compose.foundation.background1011 import androidx.compose.foundation.clickable@@ -103,36 +104,42 @@103104             PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)104105         }.getOrNull()105106         val option = providerRequest?.credentialOptions106-            ?.filterIsInstance<GetPublicKeyCredentialOption>()107-            ?.firstOrNull()108-        val authenticatedRequestJson = intent.getStringExtra(EXTRA_REQUEST_JSON)109-        val requestJson = selectionRequestJson(110-            frameworkJson = option?.requestJson,111-            authenticatedJson = authenticatedRequestJson,112-        )113-        val clientDataHash = selectionClientDataHash(114-            frameworkHash = option?.clientDataHash,115-            authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH),116-        )117-        // F1/F2: the origin must be framework-verified (privileged browser118-        // allowlist, else the caller's apk-key-hash). Unresolvable callers119-        // fail closed — PasskeyAssertion.build refuses a null origin.107+            ?.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+        }120113         val frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->121114             PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))122115         }123-        val origin = selectionOrigin(124-            frameworkOrigin = frameworkOrigin,125-            authenticatedOrigin = intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN),116+        val biometricResult = providerRequest.biometricPromptResult117+        val route = passkeyVerificationRoute(118+            hasEmbeddedResult = biometricResult != null,119+            embeddedSucceeded = biometricResult?.isSuccessful == true,126120         )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()}")127137         completeAssertion(128138             entryId,129-            requestJson,130-            origin,131-            clientDataHash,132-            uvRequiredOverride = selectionRequiresUserVerification(133-                frameworkJson = option?.requestJson,134-                authenticatedJson = authenticatedRequestJson,135-            ),139+            option.requestJson,140+            frameworkOrigin,141+            option.clientDataHash,142+            verifiedUser = true,136143         )137144     }138145 @@ -143,7 +150,6 @@143150             ?.let(BeginGetCredentialRequest::fromBundle)144151         val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)145152         val origin = request?.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, allowlist) }146-            ?: intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN)147153         val requestChoices = request?.beginGetCredentialOptions148154             ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()149155             ?.flatMap { option ->@@ -159,23 +165,10 @@159165                     }160166             }161167             .orEmpty()162-        val choices = requestChoices.ifEmpty {163-            val requestJson = intent.getStringExtra(EXTRA_REQUEST_JSON)164-            val authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH)165-            if (requestJson == null) emptyList()166-            else PasskeyCredentialPlanner.candidates(vault.getEntries(), requestJson, origin)167-                .map {168-                    ManualChoice(169-                        entryId = it.entryId,170-                        displayName = it.displayName,171-                        username = it.username,172-                        requestJson = requestJson,173-                        clientDataHash = authenticatedHash,174-                    )175-                }176-        }168+        val choices = requestChoices177169         if (choices.isEmpty()) {178-            finish()170+            Log.w(TAG, "flow=manual stage=begin_request_invalid")171+            failGet("Peach did not receive an eligible passkey request from Android.")179172             return180173         }181174         val settings = vault.settings.value@@ -219,11 +212,19 @@219212                             Column(220213                                 Modifier.fillMaxWidth().clickable {221214                                     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")222222                                         completeAssertion(223223                                             choice.entryId,224224                                             choice.requestJson,225225                                             origin,226226                                             choice.clientDataHash,227+                                            verifiedUser = true,227228                                         )228229                                     }229230                                 }.padding(vertical = 12.dp),@@ -244,6 +245,7 @@244245         origin: String?,245246         clientDataHash: ByteArray?,246247         uvRequiredOverride: Boolean? = null,248+        verifiedUser: Boolean? = null,247249     ) {248250         val result = Intent()249251         val entry = entryId?.let(vault::getEntry)@@ -254,7 +256,7 @@254256         // fail closed rather than produce a misleading UV=0 assertion.255257         val uvRequired = entry != null && requestJson != null &&256258             (uvRequiredOverride ?: (userVerificationRequirement(requestJson) == UV_REQUIRED))257-        val userVerified = if (uvRequired) {259+        val userVerified = verifiedUser ?: if (uvRequired) {258260             runUserVerificationPrompt()259261         } else {260262             false@@ -299,17 +301,20 @@299301                 false300302             }301303             if (persisted) {304+                Log.i(TAG, "flow=assertion stage=response_ready")302305                 PendingIntentHandler.setGetCredentialResponse(303306                     result,304307                     GetCredentialResponse(PublicKeyCredential(assertion.responseJson)),305308                 )306309             } else {310+                Log.w(TAG, "flow=assertion stage=persist_failed")307311                 PendingIntentHandler.setGetCredentialException(308312                     result,309313                     GetCredentialUnsupportedException("Peach could not update this passkey."),310314                 )311315             }312316         } else {317+            Log.w(TAG, "flow=assertion stage=build_failed")313318             PendingIntentHandler.setGetCredentialException(314319                 result,315320                 GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),@@ -319,6 +324,16 @@319324         finish()320325     }321326 327+    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+    }336+322337     private suspend fun completeCreate() {323338         val result = Intent()324339         val providerRequest = runCatching {@@ -432,10 +447,6 @@432447     companion object {433448         private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"434449         private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"435-        private const val EXTRA_AUTHENTICATED_ORIGIN =436-            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"437-        private const val EXTRA_AUTHENTICATED_CLIENT_DATA_HASH =438-            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_CLIENT_DATA_HASH"439450         private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"440451         private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"441452         private const val MODE_CREATE = "create"@@ -444,23 +455,18 @@444455         // Only "required" forces a biometric / device-credential prompt here;445456         // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.446457         private const val UV_REQUIRED = "required"458+        private const val TAG = "PeachPasskey"447459 448460         fun pendingIntent(449461             context: Context,450462             entryId: String,451-            requestJson: String,452-            authenticatedOrigin: String?,453-            authenticatedClientDataHash: ByteArray?,454463         ): PendingIntent {455464             val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)456465                 .putExtra(EXTRA_ENTRY_ID, entryId)457-                .putExtra(EXTRA_REQUEST_JSON, requestJson)458-                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)459-                .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, authenticatedClientDataHash)460466             val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0461467             return PendingIntent.getActivity(462468                 context,463-                "$entryId:$requestJson".hashCode(),469+                PasskeyPendingIntentIds.next(),464470                 intent,465471                 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,466472             )@@ -473,7 +479,7 @@473479             val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0474480             return PendingIntent.getActivity(475481                 context,476-                requestJson.hashCode(),482+                PasskeyPendingIntentIds.next(),477483                 intent,478484                 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,479485             )@@ -482,21 +488,14 @@482488         fun choosePendingIntent(483489             context: Context,484490             request: BeginGetCredentialRequest,485-            authenticatedOrigin: String?,486491         ): PendingIntent {487-            val option = request.beginGetCredentialOptions488-                .filterIsInstance<BeginGetPublicKeyCredentialOption>()489-                .firstOrNull()490492             val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)491493                 .putExtra(EXTRA_MODE, MODE_CHOOSE)492494                 .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))493-                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)494-                .putExtra(EXTRA_REQUEST_JSON, option?.requestJson)495-                .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, option?.clientDataHash)496495             val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0497496             return PendingIntent.getActivity(498497                 context,499-                "choose:${request.hashCode()}".hashCode(),498+                PasskeyPendingIntentIds.next(),500499                 intent,501500                 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,502501             )@@ -510,43 +509,4 @@510509         val requestJson: String,511510         val clientDataHash: ByteArray?,512511     )513-}514-515-/**516- * The authentication-action response is built only after Peach has verified517- * the caller from the framework's BeginGetCredentialRequest. Some OEM518- * Credential Manager implementations omit the final provider request when a519- * credential row is returned from that nested response, so retain that same520- * verified origin as a fallback instead of turning a valid row into a no-op.521- */522-internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =523-    frameworkOrigin?.takeIf { it.isNotBlank() }524-        ?: authenticatedOrigin?.takeIf { it.isNotBlank() }525-526-/**527- * Keep the framework-authenticated BeginGet hash whenever it is available.528- * Samsung can omit or replace the final row request depending on whether the529- * row or confirmation button was tapped; both UI paths must sign identical530- * client data.531- */532-internal fun selectionClientDataHash(533-    frameworkHash: ByteArray?,534-    authenticatedHash: ByteArray?,535-): ByteArray? = authenticatedHash ?: frameworkHash536-537-/** Use the final assertion JSON when present; its challenge pairs with the final request. */538-internal fun selectionRequestJson(539-    frameworkJson: String?,540-    authenticatedJson: String?,541-): String? = frameworkJson ?: authenticatedJson542-543-/** Never let an OEM's final row payload weaken BeginGet's UV requirement. */544-internal fun selectionRequiresUserVerification(545-    frameworkJson: String?,546-    authenticatedJson: String?,547-): Boolean = listOfNotNull(frameworkJson, authenticatedJson).any { requestJson ->548-    runCatching {549-        Json.parseToJsonElement(requestJson).jsonObject["userVerification"]550-            ?.jsonPrimitive?.contentOrNull == "required"551-    }.getOrDefault(false)552512 }

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyPendingIntentIds.kt+9 −0
@@ -0,0 +1,9 @@1+package com.peachpasswords.passkeys2+3+import java.util.concurrent.atomic.AtomicInteger4+5+internal object PasskeyPendingIntentIds {6+    private val nextId = AtomicInteger(0x504b0000)7+8+    fun next(): Int = nextId.getAndIncrement()9+}

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyUnlockIntent.kt+1 −2
@@ -13,7 +13,7 @@1313             .putExtra(EXTRA_REQUEST, BeginGetCredentialRequest.asBundle(request))1414         return PendingIntent.getActivity(1515             context,16-            REQUEST_CODE,16+            PasskeyPendingIntentIds.next(),1717             intent,1818             PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),1919         )@@ -23,5 +23,4 @@2323         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 02424 2525     const val EXTRA_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_GET_REQUEST"26-    private const val REQUEST_CODE = 0x504b2726 }

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyVerificationRoute.kt+12 −0
@@ -0,0 +1,12 @@1+package com.peachpasswords.passkeys2+3+internal enum class PasskeyVerificationRoute { ACCEPT, REJECT, PROMPT_IN_PEACH }4+5+internal fun passkeyVerificationRoute(6+    hasEmbeddedResult: Boolean,7+    embeddedSucceeded: Boolean,8+): PasskeyVerificationRoute = when {9+    !hasEmbeddedResult -> PasskeyVerificationRoute.PROMPT_IN_PEACH10+    embeddedSucceeded -> PasskeyVerificationRoute.ACCEPT11+    else -> PasskeyVerificationRoute.REJECT12+}

view file

app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+3 −10
@@ -83,13 +83,10 @@8383         val passkeys = passkeyEntries(request)8484         val entries = passkeys + passwordEntries(request)8585         val actions = if (passkeys.isNotEmpty()) {86-            val origin = request.callingAppInfo?.let {87-                PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))88-            }8986             listOf(9087                 Action(9188                     "Choose another Peach passkey",92-                    PasskeyCredentialSelectionActivity.choosePendingIntent(this, request, origin),89+                    PasskeyCredentialSelectionActivity.choosePendingIntent(this, request),9390                     "Search eligible passkeys",9491                 ),9592             )@@ -115,21 +112,17 @@115112         }116113         return passkeyOptions.flatMap { option ->117114             PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate ->118-                PublicKeyCredentialEntry(115+                PasskeyCredentialEntryFactory.create(119116                     context = this,120117                     username = candidate.username,121118                     pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(122119                         this,123120                         candidate.entryId,124-                        option.requestJson,125-                        callerOrigin,126-                        option.clientDataHash,127121                     ),128-                    beginGetPublicKeyCredentialOption = option,122+                    option = option,129123                     displayName = candidate.displayName,130124                     lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),131125                     icon = icon,132-                    isAutoSelectAllowed = false,133126                 )134127             }135128         }

view file

app/src/test/java/com/peachpasswords/passkeys/CredentialProviderContractTest.kt+41 −59
@@ -1,6 +1,7 @@11 package com.peachpasswords.passkeys22 33 import java.io.File4+import org.junit.Assert.assertEquals45 import org.junit.Assert.assertFalse56 import org.junit.Assert.assertTrue67 import org.junit.Test@@ -15,82 +16,63 @@1516     }1617 1718     @Test18-    fun selectionPrefersTheFinalFrameworkOrigin() {19-        assertTrue(20-            selectionOrigin("https://github.com", "https://example.com") ==21-                "https://github.com",22-        )23-    }19+    fun selectedRowUsesOnlyTheFinalProviderRequest() {20+        val selection = source("passkeys/PasskeyCredentialSelectionActivity.kt")2421 25-    @Test26-    fun selectionRetainsAuthenticatedBeginRequestOriginWhenOemOmitsFinalRequest() {27-        assertTrue(28-            selectionOrigin(null, "https://github.com") == "https://github.com",29-        )22+        assertTrue(selection.contains("retrieveProviderGetCredentialRequest(intent)"))23+        assertFalse(selection.contains("selectionOrigin("))24+        assertFalse(selection.contains("selectionClientDataHash("))25+        assertFalse(selection.contains("selectionRequestJson("))26+        assertFalse(selection.contains("selectionRequiresUserVerification("))27+        assertFalse(selection.contains("EXTRA_AUTHENTICATED_CLIENT_DATA_HASH"))3028     }3129 3230     @Test33-    fun selectionStillFailsClosedWithoutAnyVerifiedOrigin() {34-        assertTrue(selectionOrigin(null, null) == null)35-        assertTrue(selectionOrigin("  ", "") == null)36-    }31+    fun providerOffersAnExplicitManualPasskeyChooser() {32+        val service = source("passkeys/PeachCredentialProviderService.kt")33+        val unlock = source("credentials/CredentialProviderUnlockActivity.kt")34+        val selection = source("passkeys/PasskeyCredentialSelectionActivity.kt")3735 38-    @Test39-    fun selectionRetainsAuthenticatedBeginRequestHashWhenOemOmitsFinalRequest() {40-        val authenticatedHash = byteArrayOf(1, 2, 3)41-42-        assertTrue(selectionClientDataHash(null, authenticatedHash)!!.contentEquals(authenticatedHash))36+        assertTrue(service.contains("Choose another Peach passkey"))37+        assertTrue(unlock.contains("Choose another Peach passkey"))38+        assertTrue(selection.contains("retrieveBeginGetCredentialRequest(intent)"))39+        assertTrue(selection.contains("PasskeyCredentialPlanner.candidates"))4340     }4441 4542     @Test46-    fun selectionKeepsTheAuthenticatedBeginHashWhenSamsungReplacesFinalHash() {47-        val frameworkHash = byteArrayOf(4, 5, 6)48-        val authenticatedHash = byteArrayOf(1, 2, 3)43+    fun api35PasskeyRowsDelegateVerificationToCredentialManager() {44+        val service = source("passkeys/PeachCredentialProviderService.kt")45+        val unlock = source("credentials/CredentialProviderUnlockActivity.kt")46+        val factory = source("passkeys/PasskeyCredentialEntryFactory.kt")4947 50-        assertTrue(51-            selectionClientDataHash(frameworkHash, authenticatedHash)!!52-                .contentEquals(authenticatedHash),53-        )48+        assertTrue(service.contains("PasskeyCredentialEntryFactory.create"))49+        assertTrue(unlock.contains("PasskeyCredentialEntryFactory.create"))50+        assertTrue(factory.contains("Build.VERSION_CODES.VANILLA_ICE_CREAM"))51+        assertTrue(factory.contains("setBiometricPromptData"))52+        assertTrue(factory.contains("BIOMETRIC_STRONG or DEVICE_CREDENTIAL"))5453     }5554 5655     @Test57-    fun selectionKeepsFinalJsonForTheSignedAssertion() {58-        val frameworkJson = """{"challenge":"final"}"""59-        val authenticatedJson =60-            """{"challenge":"begin","userVerification":"required"}"""61-62-        assertTrue(63-            selectionRequestJson(frameworkJson, authenticatedJson) == frameworkJson,56+    fun credentialManagerVerificationResultHasOneOwner() {57+        assertEquals(58+            PasskeyVerificationRoute.ACCEPT,59+            passkeyVerificationRoute(hasEmbeddedResult = true, embeddedSucceeded = true),6460         )61+        assertEquals(62+            PasskeyVerificationRoute.REJECT,63+            passkeyVerificationRoute(hasEmbeddedResult = true, embeddedSucceeded = false),64+        )65+        assertEquals(66+            PasskeyVerificationRoute.PROMPT_IN_PEACH,67+            passkeyVerificationRoute(hasEmbeddedResult = false, embeddedSucceeded = false),68+        )6569     }6670 6771     @Test68-    fun selectionUsesFinalJsonOnlyWithoutAuthenticatedBeginJson() {69-        val frameworkJson = """{"challenge":"final"}"""72+    fun pendingIntentRequestCodesAreNeverReusedWithinAProcess() {73+        val codes = (1..1_000).map { PasskeyPendingIntentIds.next() }7074 71-        assertTrue(selectionRequestJson(frameworkJson, null) == frameworkJson)72-    }73-74-    @Test75-    fun authenticatedBeginJsonCanRequireUvWithoutReplacingFinalAssertionJson() {76-        val frameworkJson = """{"challenge":"final"}"""77-        val authenticatedJson =78-            """{"challenge":"begin","userVerification":"required"}"""79-80-        assertTrue(selectionRequiresUserVerification(frameworkJson, authenticatedJson))81-        assertTrue(selectionRequestJson(frameworkJson, authenticatedJson) == frameworkJson)82-    }83-84-    @Test85-    fun providerOffersAnExplicitManualPasskeyChooser() {86-        val service = source("passkeys/PeachCredentialProviderService.kt")87-        val unlock = source("credentials/CredentialProviderUnlockActivity.kt")88-        val selection = source("passkeys/PasskeyCredentialSelectionActivity.kt")89-90-        assertTrue(service.contains("Choose another Peach passkey"))91-        assertTrue(unlock.contains("Choose another Peach passkey"))92-        assertTrue(selection.contains("retrieveBeginGetCredentialRequest(intent)"))93-        assertTrue(selection.contains("PasskeyCredentialPlanner.candidates"))75+        assertEquals(codes.size, codes.toSet().size)9476     }9577 9678     private fun source(relativePath: String): String =

view file