2250ea07907a

fix: Repair passkey confirmation and add manual chooser (v2.16.0)

- Preserve Android's authenticated client-data hash when Samsung omits the final provider request - Add a searchable chooser for GitHub-eligible Peach passkeys - Keep manual selection constrained by RP ID, caller origin, and allowCredentials - Verify 1,094 unit tests, lint, and signed release assembly

time
2026-09-06 15:03 UTC · 1 month ago
machine
koh2
parent
c9bac667b84d
5 files VERSION+1 −1app/src/main/java/com/peachpasswords/credentials/CredentialProviderUnlockActivity.kt+14 −1app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+164 −3app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+17 −2app/src/test/java/com/peachpasswords/passkeys/CredentialProviderContractTest.kt+30 −0
VERSION+1 −1
@@ -1 +1 @@1-2.15.101+2.16.0

view file

app/src/main/java/com/peachpasswords/credentials/CredentialProviderUnlockActivity.kt+14 −1
@@ -11,6 +11,7 @@1111 import androidx.credentials.provider.BeginGetPasswordOption1212 import androidx.credentials.provider.BeginGetPublicKeyCredentialOption1313 import androidx.credentials.provider.BeginGetCredentialResponse14+import androidx.credentials.provider.Action1415 import androidx.credentials.provider.CredentialEntry1516 import androidx.credentials.provider.PasswordCredentialEntry1617 import androidx.credentials.provider.PendingIntentHandler@@ -114,6 +115,7 @@114115                             candidate.entryId,115116                             option.requestJson,116117                             callerOrigin,118+                            option.clientDataHash,117119                         ),118120                         beginGetPublicKeyCredentialOption = option,119121                         displayName = candidate.displayName,@@ -127,9 +129,20 @@127129         }128130 129131         val result = Intent()132+        val hasPasskeyEntries = entries.any { it is PublicKeyCredentialEntry }133+        val builder = BeginGetCredentialResponse.Builder().setCredentialEntries(entries)134+        if (hasPasskeyEntries) {135+            builder.addAction(136+                Action(137+                    "Choose another Peach passkey",138+                    PasskeyCredentialSelectionActivity.choosePendingIntent(this, request, callerOrigin),139+                    "Search eligible passkeys",140+                ),141+            )142+        }130143         PendingIntentHandler.setBeginGetCredentialResponse(131144             result,132-            BeginGetCredentialResponse.Builder().setCredentialEntries(entries).build(),145+            builder.build(),133146         )134147         setResult(RESULT_OK, result)135148         finish()

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+164 −3
@@ -5,6 +5,25 @@55 import android.content.Intent66 import android.os.Build77 import android.os.Bundle8+import androidx.activity.compose.setContent9+import androidx.compose.foundation.background10+import androidx.compose.foundation.clickable11+import androidx.compose.foundation.layout.Column12+import androidx.compose.foundation.layout.fillMaxSize13+import androidx.compose.foundation.layout.fillMaxWidth14+import androidx.compose.foundation.layout.padding15+import androidx.compose.foundation.lazy.LazyColumn16+import androidx.compose.foundation.lazy.items17+import androidx.compose.foundation.shape.RoundedCornerShape18+import androidx.compose.material3.Text19+import androidx.compose.runtime.getValue20+import androidx.compose.runtime.mutableStateOf21+import androidx.compose.runtime.remember22+import androidx.compose.runtime.setValue23+import androidx.compose.ui.Modifier24+import androidx.compose.ui.text.font.FontWeight25+import androidx.compose.ui.unit.dp26+import androidx.compose.ui.unit.sp827 import androidx.credentials.GetCredentialResponse928 import androidx.credentials.GetPublicKeyCredentialOption1029 import androidx.credentials.CreatePublicKeyCredentialRequest@@ -13,6 +32,8 @@1332 import androidx.credentials.exceptions.CreateCredentialUnsupportedException1433 import androidx.credentials.exceptions.GetCredentialUnsupportedException1534 import androidx.credentials.provider.PendingIntentHandler35+import androidx.credentials.provider.BeginGetCredentialRequest36+import androidx.credentials.provider.BeginGetPublicKeyCredentialOption1637 import androidx.fragment.app.FragmentActivity1738 import androidx.lifecycle.lifecycleScope1839 import com.peachpasswords.BuildConfig@@ -20,6 +41,11 @@2041 import com.peachpasswords.ui.lock.Biometrics2142 import com.peachpasswords.ui.components.QaFlags2243 import com.peachpasswords.ui.components.SecureWindowPolicy44+import com.peachpasswords.ui.theme.ACCENT_COLORS45+import com.peachpasswords.ui.theme.DEFAULT_ACCENT46+import com.peachpasswords.ui.theme.PeachTheme47+import com.peachpasswords.ui.theme.PeachType48+import com.peachpasswords.ui.theme.peachColors2349 import com.peachpasswords.vault.VaultRepository2450 import com.peachpasswords.vault.isLogin2551 import dagger.hilt.android.AndroidEntryPoint@@ -48,7 +74,8 @@4874             SecureWindowPolicy.acquire(window)4975             debugSecureLease = true5076         }51-        lifecycleScope.launch { completeSelection() }77+        if (intent.getStringExtra(EXTRA_MODE) == MODE_CHOOSE) showManualChooser()78+        else lifecycleScope.launch { completeSelection() }5279     }5380 5481     override fun onDestroy() {@@ -79,7 +106,10 @@79106             ?.filterIsInstance<GetPublicKeyCredentialOption>()80107             ?.firstOrNull()81108         val requestJson = option?.requestJson ?: intent.getStringExtra(EXTRA_REQUEST_JSON)82-        val entry = entryId?.let(vault::getEntry)109+        val clientDataHash = selectionClientDataHash(110+            frameworkHash = option?.clientDataHash,111+            authenticatedHash = intent.getByteArrayExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH),112+        )83113         // F1/F2: the origin must be framework-verified (privileged browser84114         // allowlist, else the caller's apk-key-hash). Unresolvable callers85115         // fail closed — PasskeyAssertion.build refuses a null origin.@@ -90,6 +120,96 @@90120             frameworkOrigin = frameworkOrigin,91121             authenticatedOrigin = intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN),92122         )123+        completeAssertion(entryId, requestJson, origin, clientDataHash)124+    }125+126+    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 choices = request?.beginGetCredentialOptions135+            ?.filterIsInstance<BeginGetPublicKeyCredentialOption>()136+            ?.flatMap { option ->137+                PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, origin)138+                    .map { ManualChoice(it.entryId, it.displayName, it.username, option) }139+            }140+            .orEmpty()141+        if (choices.isEmpty()) {142+            finish()143+            return144+        }145+        val settings = vault.settings.value146+        val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT147+        setContent {148+            PeachTheme(accent = accent, textSize = settings.textSize) {149+                var query by remember { mutableStateOf("") }150+                val visible = choices.filter {151+                    query.isBlank() || it.displayName.contains(query, ignoreCase = true) ||152+                        it.username.contains(query, ignoreCase = true)153+                }154+                Column(155+                    Modifier.fillMaxSize().background(peachColors.Bg).padding(20.dp),156+                ) {157+                    Text("Choose a passkey", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp)158+                    androidx.compose.foundation.text.BasicTextField(159+                        value = query,160+                        onValueChange = { query = it },161+                        singleLine = true,162+                        textStyle = androidx.compose.ui.text.TextStyle(163+                            color = peachColors.T0,164+                            fontFamily = PeachType.Mono,165+                            fontSize = 14.sp,166+                        ),167+                        modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)168+                            .background(peachColors.S2, RoundedCornerShape(8.dp)).padding(12.dp),169+                        decorationBox = { inner ->170+                            if (query.isEmpty()) {171+                                Text(172+                                    "Search eligible Peach passkeys…",173+                                    color = peachColors.T2,174+                                    fontFamily = PeachType.Mono,175+                                    fontSize = 12.sp,176+                                )177+                            }178+                            inner()179+                        },180+                    )181+                    LazyColumn {182+                        items(visible, key = { "${it.entryId}:${it.option.requestJson.hashCode()}" }) { choice ->183+                            Column(184+                                Modifier.fillMaxWidth().clickable {185+                                    lifecycleScope.launch {186+                                        completeAssertion(187+                                            choice.entryId,188+                                            choice.option.requestJson,189+                                            origin,190+                                            choice.option.clientDataHash,191+                                        )192+                                    }193+                                }.padding(vertical = 12.dp),194+                            ) {195+                                Text(choice.displayName, color = peachColors.T0, fontFamily = PeachType.Body, fontWeight = FontWeight.Medium)196+                                Text(choice.username, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 12.sp)197+                            }198+                        }199+                    }200+                }201+            }202+        }203+    }204+205+    private suspend fun completeAssertion(206+        entryId: String?,207+        requestJson: String?,208+        origin: String?,209+        clientDataHash: ByteArray?,210+    ) {211+        val result = Intent()212+        val entry = entryId?.let(vault::getEntry)93213         // H-07: WebAuthn's UV flag must reflect actual user verification, not a94214         // hard-coded bit. If the relying party demands UV, run the platform95215         // biometric / device-credential prompt and only sign with UV=1 on@@ -118,7 +238,7 @@118238                 entry = entry,119239                 requestJson = requestJson,120240                 origin = origin,121-                clientDataHashOverride = option?.clientDataHash,241+                clientDataHashOverride = clientDataHash,122242                 userVerified = userVerified,123243             )124244         } else {@@ -277,8 +397,12 @@277397         private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"278398         private const val EXTRA_AUTHENTICATED_ORIGIN =279399             "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"400+        private const val EXTRA_AUTHENTICATED_CLIENT_DATA_HASH =401+            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_CLIENT_DATA_HASH"280402         private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"403+        private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"281404         private const val MODE_CREATE = "create"405+        private const val MODE_CHOOSE = "choose"282406         // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.283407         // Only "required" forces a biometric / device-credential prompt here;284408         // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.@@ -289,11 +413,13 @@289413             entryId: String,290414             requestJson: String,291415             authenticatedOrigin: String?,416+            authenticatedClientDataHash: ByteArray?,292417         ): PendingIntent {293418             val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)294419                 .putExtra(EXTRA_ENTRY_ID, entryId)295420                 .putExtra(EXTRA_REQUEST_JSON, requestJson)296421                 .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)422+                .putExtra(EXTRA_AUTHENTICATED_CLIENT_DATA_HASH, authenticatedClientDataHash)297423             val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0298424             return PendingIntent.getActivity(299425                 context,@@ -315,7 +441,32 @@315441                 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,316442             )317443         }444+445+        fun choosePendingIntent(446+            context: Context,447+            request: BeginGetCredentialRequest,448+            authenticatedOrigin: String?,449+        ): PendingIntent {450+            val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)451+                .putExtra(EXTRA_MODE, MODE_CHOOSE)452+                .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))453+                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)454+            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0455+            return PendingIntent.getActivity(456+                context,457+                "choose:${request.hashCode()}".hashCode(),458+                intent,459+                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,460+            )461+        }318462     }463+464+    private data class ManualChoice(465+        val entryId: String,466+        val displayName: String,467+        val username: String,468+        val option: BeginGetPublicKeyCredentialOption,469+    )319470 }320471 321472 /**@@ -328,3 +479,13 @@328479 internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =329480     frameworkOrigin?.takeIf { it.isNotBlank() }330481         ?: authenticatedOrigin?.takeIf { it.isNotBlank() }482+483+/**484+ * Prefer request data attached by Credential Manager to the selected row. Some485+ * OEM confirmation-button paths omit that final request, so retain the same486+ * framework-authenticated hash that arrived in the BeginGet request.487+ */488+internal fun selectionClientDataHash(489+    frameworkHash: ByteArray?,490+    authenticatedHash: ByteArray?,491+): ByteArray? = frameworkHash ?: authenticatedHash

view file

app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+17 −2
@@ -14,6 +14,7 @@1414 import androidx.credentials.provider.CredentialEntry1515 import androidx.credentials.provider.CreateEntry1616 import androidx.credentials.provider.AuthenticationAction17+import androidx.credentials.provider.Action1718 import androidx.credentials.provider.CredentialProviderService1819 import androidx.credentials.provider.ProviderClearCredentialStateRequest1920 import androidx.credentials.provider.PublicKeyCredentialEntry@@ -79,8 +80,21 @@7980             )8081             return8182         }82-        val entries = passkeyEntries(request) + passwordEntries(request)83-        callback.onResult(BeginGetCredentialResponse(credentialEntries = entries))83+        val passkeys = passkeyEntries(request)84+        val entries = passkeys + passwordEntries(request)85+        val actions = if (passkeys.isNotEmpty()) {86+            val origin = request.callingAppInfo?.let {87+                PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))88+            }89+            listOf(90+                Action(91+                    "Choose another Peach passkey",92+                    PasskeyCredentialSelectionActivity.choosePendingIntent(this, request, origin),93+                    "Search eligible passkeys",94+                ),95+            )96+        } else emptyList()97+        callback.onResult(BeginGetCredentialResponse(credentialEntries = entries, actions = actions))8498     }8599 86100     private fun passkeyEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {@@ -109,6 +123,7 @@109123                         candidate.entryId,110124                         option.requestJson,111125                         callerOrigin,126+                        option.clientDataHash,112127                     ),113128                     beginGetPublicKeyCredentialOption = option,114129                     displayName = candidate.displayName,

view file

app/src/test/java/com/peachpasswords/passkeys/CredentialProviderContractTest.kt+30 −0
@@ -35,6 +35,36 @@3535         assertTrue(selectionOrigin("  ", "") == null)3636     }3737 38+    @Test39+    fun selectionRetainsAuthenticatedBeginRequestHashWhenOemOmitsFinalRequest() {40+        val authenticatedHash = byteArrayOf(1, 2, 3)41+42+        assertTrue(selectionClientDataHash(null, authenticatedHash)!!.contentEquals(authenticatedHash))43+    }44+45+    @Test46+    fun selectionPrefersTheFinalFrameworkHash() {47+        val frameworkHash = byteArrayOf(4, 5, 6)48+        val authenticatedHash = byteArrayOf(1, 2, 3)49+50+        assertTrue(51+            selectionClientDataHash(frameworkHash, authenticatedHash)!!52+                .contentEquals(frameworkHash),53+        )54+    }55+56+    @Test57+    fun providerOffersAnExplicitManualPasskeyChooser() {58+        val service = source("passkeys/PeachCredentialProviderService.kt")59+        val unlock = source("credentials/CredentialProviderUnlockActivity.kt")60+        val selection = source("passkeys/PasskeyCredentialSelectionActivity.kt")61+62+        assertTrue(service.contains("Choose another Peach passkey"))63+        assertTrue(unlock.contains("Choose another Peach passkey"))64+        assertTrue(selection.contains("retrieveBeginGetCredentialRequest(intent)"))65+        assertTrue(selection.contains("PasskeyCredentialPlanner.candidates"))66+    }67+3868     private fun source(relativePath: String): String =3969         File("src/main/java/com/peachpasswords/$relativePath").readText()4070 }

view file