2250ea07907a
fix: Repair passkey confirmation and add manual chooser (v2.16.0)
- time
- 2026-09-06 15:03 UTC · 1 month ago
- machine
- koh2
- parent
- c9bac667b84d
↓ peach-android-2250ea07.tar.gzatlas at this save
files changed5 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 −0app/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()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 ?: authenticatedHashapp/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,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 }