9cebcd3b8029
fix: Rebuild Credential Manager passkey flow (v2.17.0)
- time
- 2026-09-07 23:05 UTC · 1 month ago
- machine
- koh2
- parent
- 8c0dd8e3209b
↓ peach-android-9cebcd3b.tar.gzatlas at this save
files changed9 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 −59app/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 )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+}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 }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+}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 }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+}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 }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 =