ff53d8a9b4be

fix: Preserve authenticated credential handoff (v2.17.2)

- Keep the vault available across the one trusted Credential Manager round trip - Treat Peach unlock as verification for that pending passkey ceremony - Avoid a second Credential Manager biometric after unlocking Peach - Retain Credential Manager verification when the vault was already unlocked - Make authenticated PendingIntents one-shot to prevent replay - Verify 1,097 JVM tests, lint with zero errors, and signed release assembly

time
2026-09-08 00:33 UTC · 1 month ago
machine
koh2
parent
7f47a411cc3a
8 files VERSION+1 −1app/src/main/java/com/peachpasswords/credentials/CredentialProviderUnlockActivity.kt+8 −1app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialEntryFactory.kt+4 −1app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+15 −4app/src/main/java/com/peachpasswords/passkeys/PasskeyUnlockIntent.kt+1 −1app/src/main/java/com/peachpasswords/passkeys/PasskeyVerificationRoute.kt+2 −0app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+1 −0app/src/test/java/com/peachpasswords/passkeys/CredentialProviderContractTest.kt+32 −3
VERSION+1 −1
@@ -1 +1 @@1-2.17.11+2.17.2

view file

app/src/main/java/com/peachpasswords/credentials/CredentialProviderUnlockActivity.kt+8 −1
@@ -127,11 +127,13 @@127127                         pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(128128                             this,129129                             candidate.entryId,130+                            verifiedByUnlock = true,130131                         ),131132                         option = option,132133                         displayName = candidate.displayName,133134                         lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),134135                         icon = icon,136+                        requireCredentialManagerVerification = false,135137                     )136138                 }137139                 else -> emptyList<CredentialEntry>()@@ -145,7 +147,11 @@145147             builder.addAction(146148                 Action(147149                     "Choose another Peach passkey",148-                    PasskeyCredentialSelectionActivity.choosePendingIntent(this, request),150+                    PasskeyCredentialSelectionActivity.choosePendingIntent(151+                        this,152+                        request,153+                        verifiedByUnlock = true,154+                    ),149155                     "Search eligible passkeys",150156                 ),151157             )@@ -154,6 +160,7 @@154160             result,155161             builder.build(),156162         )163+        vault.expectTransientBackground()157164         setResult(RESULT_OK, result)158165         finish()159166     }

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialEntryFactory.kt+4 −1
@@ -20,13 +20,16 @@2020         pendingIntent: PendingIntent,2121         option: BeginGetPublicKeyCredentialOption,2222         icon: Icon,23+        requireCredentialManagerVerification: Boolean,2324     ): PublicKeyCredentialEntry {2425         val builder = PublicKeyCredentialEntry.Builder(context, username, pendingIntent, option)2526             .setDisplayName(displayName)2627             .setLastUsedTime(lastUsedTime)2728             .setIcon(icon)2829             .setAutoSelectAllowed(false)29-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {30+        if (requireCredentialManagerVerification &&31+            Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM32+        ) {3033             builder.setBiometricPromptData(3134                 BiometricPromptData(3235                     cryptoObject = null,

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+15 −4
@@ -115,6 +115,7 @@115115         }116116         val biometricResult = providerRequest.biometricPromptResult117117         val route = passkeyVerificationRoute(118+            verifiedByUnlock = intent.getBooleanExtra(EXTRA_VERIFIED_BY_UNLOCK, false),118119             hasEmbeddedResult = biometricResult != null,119120             embeddedSucceeded = biometricResult?.isSuccessful == true,120121         )@@ -212,7 +213,11 @@212213                             Column(213214                                 Modifier.fillMaxWidth().clickable {214215                                     lifecycleScope.launch {215-                                        val userVerified = runUserVerificationPrompt()216+                                        val verifiedByUnlock = intent.getBooleanExtra(217+                                            EXTRA_VERIFIED_BY_UNLOCK,218+                                            false,219+                                        )220+                                        val userVerified = verifiedByUnlock || runUserVerificationPrompt()216221                                         if (!userVerified) {217222                                             Log.w(TAG, "flow=manual stage=verification_incomplete")218223                                             failGet("User verification is required but could not be completed.")@@ -448,6 +453,8 @@448453 449454     companion object {450455         private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"456+        private const val EXTRA_VERIFIED_BY_UNLOCK =457+            "com.peachpasswords.passkeys.EXTRA_VERIFIED_BY_UNLOCK"451458         private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"452459         private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"453460         private const val EXTRA_BEGIN_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_REQUEST"@@ -462,15 +469,17 @@462469         fun pendingIntent(463470             context: Context,464471             entryId: String,472+            verifiedByUnlock: Boolean = false,465473         ): PendingIntent {466474             val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)467475                 .putExtra(EXTRA_ENTRY_ID, entryId)476+                .putExtra(EXTRA_VERIFIED_BY_UNLOCK, verifiedByUnlock)468477             val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0469478             return PendingIntent.getActivity(470479                 context,471480                 PasskeyPendingIntentIds.next(),472481                 intent,473-                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,482+                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT or mutableFlag,474483             )475484         }476485 @@ -483,23 +492,25 @@483492                 context,484493                 PasskeyPendingIntentIds.next(),485494                 intent,486-                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,495+                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT or mutableFlag,487496             )488497         }489498 490499         fun choosePendingIntent(491500             context: Context,492501             request: BeginGetCredentialRequest,502+            verifiedByUnlock: Boolean = false,493503         ): PendingIntent {494504             val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)495505                 .putExtra(EXTRA_MODE, MODE_CHOOSE)496506                 .putExtra(EXTRA_BEGIN_REQUEST, BeginGetCredentialRequest.asBundle(request))507+                .putExtra(EXTRA_VERIFIED_BY_UNLOCK, verifiedByUnlock)497508             val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0498509             return PendingIntent.getActivity(499510                 context,500511                 PasskeyPendingIntentIds.next(),501512                 intent,502-                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,513+                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT or mutableFlag,503514             )504515         }505516     }

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyUnlockIntent.kt+1 −1
@@ -15,7 +15,7 @@1515             context,1616             PasskeyPendingIntentIds.next(),1717             intent,18-            PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),18+            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT or mutableFlag(),1919         )2020     }2121 

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyVerificationRoute.kt+2 −0
@@ -3,9 +3,11 @@33 internal enum class PasskeyVerificationRoute { ACCEPT, REJECT, PROMPT_IN_PEACH }44 55 internal fun passkeyVerificationRoute(6+    verifiedByUnlock: Boolean,67     hasEmbeddedResult: Boolean,78     embeddedSucceeded: Boolean,89 ): PasskeyVerificationRoute = when {10+    verifiedByUnlock -> PasskeyVerificationRoute.ACCEPT911     !hasEmbeddedResult -> PasskeyVerificationRoute.PROMPT_IN_PEACH1012     embeddedSucceeded -> PasskeyVerificationRoute.ACCEPT1113     else -> PasskeyVerificationRoute.REJECT

view file

app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+1 −0
@@ -123,6 +123,7 @@123123                     displayName = candidate.displayName,124124                     lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),125125                     icon = icon,126+                    requireCredentialManagerVerification = true,126127                 )127128             }128129         }

view file

app/src/test/java/com/peachpasswords/passkeys/CredentialProviderContractTest.kt+32 −3
@@ -56,16 +56,45 @@5656     fun credentialManagerVerificationResultHasOneOwner() {5757         assertEquals(5858             PasskeyVerificationRoute.ACCEPT,59-            passkeyVerificationRoute(hasEmbeddedResult = true, embeddedSucceeded = true),59+            passkeyVerificationRoute(60+                verifiedByUnlock = false,61+                hasEmbeddedResult = true,62+                embeddedSucceeded = true,63+            ),6064         )6165         assertEquals(6266             PasskeyVerificationRoute.REJECT,63-            passkeyVerificationRoute(hasEmbeddedResult = true, embeddedSucceeded = false),67+            passkeyVerificationRoute(68+                verifiedByUnlock = false,69+                hasEmbeddedResult = true,70+                embeddedSucceeded = false,71+            ),6472         )6573         assertEquals(6674             PasskeyVerificationRoute.PROMPT_IN_PEACH,67-            passkeyVerificationRoute(hasEmbeddedResult = false, embeddedSucceeded = false),75+            passkeyVerificationRoute(76+                verifiedByUnlock = false,77+                hasEmbeddedResult = false,78+                embeddedSucceeded = false,79+            ),6880         )81+        assertEquals(82+            PasskeyVerificationRoute.ACCEPT,83+            passkeyVerificationRoute(84+                verifiedByUnlock = true,85+                hasEmbeddedResult = false,86+                embeddedSucceeded = false,87+            ),88+        )89+    }90+91+    @Test92+    fun authenticatedUnlockPreservesVaultForExactlyTheCredentialManagerHandoff() {93+        val unlock = source("credentials/CredentialProviderUnlockActivity.kt")94+95+        assertTrue(unlock.contains("vault.expectTransientBackground()"))96+        assertTrue(unlock.contains("verifiedByUnlock = true"))97+        assertTrue(source("passkeys/PasskeyCredentialSelectionActivity.kt").contains("PendingIntent.FLAG_ONE_SHOT"))6998     }7099 71100     @Test

view file