d79cd9e7a5f0

fix: Complete locked passkey selection (v2.15.8)

- Make the authentication-action pending intent mutable so Android can attach its final request. - Carry the framework-verified caller origin through the authenticated response for OEMs that omit it on the nested credential selection. - Prefer the final framework origin and remain fail-closed when neither verified origin exists. Verified with the full JDK 17 unit-test suite, lintDebug, signed assembleRelease, and in-place arm64 installation.

time
2026-09-06 14:33 UTC · 1 month ago
machine
koh2
parent
647ed7040b49
6 files VERSION+1 −1app/src/main/java/com/peachpasswords/credentials/CredentialProviderUnlockActivity.kt+1 −0app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+25 −2app/src/main/java/com/peachpasswords/passkeys/PasskeyUnlockIntent.kt+5 −1app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+1 −0app/src/test/java/com/peachpasswords/passkeys/CredentialProviderContractTest.kt+40 −0
VERSION+1 −1
@@ -1 +1 @@1-2.15.71+2.15.8

view file

app/src/main/java/com/peachpasswords/credentials/CredentialProviderUnlockActivity.kt+1 −0
@@ -113,6 +113,7 @@113113                             this,114114                             candidate.entryId,115115                             option.requestJson,116+                            callerOrigin,116117                         ),117118                         beginGetPublicKeyCredentialOption = option,118119                         displayName = candidate.displayName,

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+25 −2
@@ -83,9 +83,13 @@8383         // F1/F2: the origin must be framework-verified (privileged browser8484         // allowlist, else the caller's apk-key-hash). Unresolvable callers8585         // fail closed — PasskeyAssertion.build refuses a null origin.86-        val origin = providerRequest?.callingAppInfo?.let { info ->86+        val frameworkOrigin = providerRequest?.callingAppInfo?.let { info ->8787             PasskeyCallerOrigin.resolve(info, PasskeyCallerOrigin.privilegedAllowlist(this))8888         }89+        val origin = selectionOrigin(90+            frameworkOrigin = frameworkOrigin,91+            authenticatedOrigin = intent.getStringExtra(EXTRA_AUTHENTICATED_ORIGIN),92+        )8993         // H-07: WebAuthn's UV flag must reflect actual user verification, not a9094         // hard-coded bit. If the relying party demands UV, run the platform9195         // biometric / device-credential prompt and only sign with UV=1 on@@ -271,6 +275,8 @@271275     companion object {272276         private const val EXTRA_ENTRY_ID = "com.peachpasswords.passkeys.EXTRA_ENTRY_ID"273277         private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"278+        private const val EXTRA_AUTHENTICATED_ORIGIN =279+            "com.peachpasswords.passkeys.EXTRA_AUTHENTICATED_ORIGIN"274280         private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"275281         private const val MODE_CREATE = "create"276282         // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.@@ -278,10 +284,16 @@278284         // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.279285         private const val UV_REQUIRED = "required"280286 281-        fun pendingIntent(context: Context, entryId: String, requestJson: String): PendingIntent {287+        fun pendingIntent(288+            context: Context,289+            entryId: String,290+            requestJson: String,291+            authenticatedOrigin: String?,292+        ): PendingIntent {282293             val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)283294                 .putExtra(EXTRA_ENTRY_ID, entryId)284295                 .putExtra(EXTRA_REQUEST_JSON, requestJson)296+                .putExtra(EXTRA_AUTHENTICATED_ORIGIN, authenticatedOrigin)285297             val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0286298             return PendingIntent.getActivity(287299                 context,@@ -305,3 +317,14 @@305317         }306318     }307319 }320+321+/**322+ * The authentication-action response is built only after Peach has verified323+ * the caller from the framework's BeginGetCredentialRequest. Some OEM324+ * Credential Manager implementations omit the final provider request when a325+ * credential row is returned from that nested response, so retain that same326+ * verified origin as a fallback instead of turning a valid row into a no-op.327+ */328+internal fun selectionOrigin(frameworkOrigin: String?, authenticatedOrigin: String?): String? =329+    frameworkOrigin?.takeIf { it.isNotBlank() }330+        ?: authenticatedOrigin?.takeIf { it.isNotBlank() }

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyUnlockIntent.kt+5 −1
@@ -3,6 +3,7 @@33 import android.app.PendingIntent44 import android.content.Context55 import android.content.Intent6+import android.os.Build67 import androidx.credentials.provider.BeginGetCredentialRequest78 import com.peachpasswords.credentials.CredentialProviderUnlockActivity89 @@ -14,9 +15,12 @@1415             context,1516             REQUEST_CODE,1617             intent,17-            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,18+            PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),1819         )1920     }21+22+    private fun mutableFlag(): Int =23+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 02024 2125     const val EXTRA_REQUEST = "com.peachpasswords.passkeys.EXTRA_BEGIN_GET_REQUEST"2226     private const val REQUEST_CODE = 0x504b

view file

app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+1 −0
@@ -108,6 +108,7 @@108108                         this,109109                         candidate.entryId,110110                         option.requestJson,111+                        callerOrigin,111112                     ),112113                     beginGetPublicKeyCredentialOption = option,113114                     displayName = candidate.displayName,

view file

app/src/test/java/com/peachpasswords/passkeys/CredentialProviderContractTest.kt+40 −0
@@ -0,0 +1,40 @@1+package com.peachpasswords.passkeys2+3+import java.io.File4+import org.junit.Assert.assertFalse5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+class CredentialProviderContractTest {9+    @Test10+    fun authenticationActionPendingIntentIsMutableForFrameworkRequestAttachment() {11+        val source = source("passkeys/PasskeyUnlockIntent.kt")12+13+        assertTrue(source.contains("PendingIntent.FLAG_MUTABLE"))14+        assertFalse(source.contains("PendingIntent.FLAG_IMMUTABLE"))15+    }16+17+    @Test18+    fun selectionPrefersTheFinalFrameworkOrigin() {19+        assertTrue(20+            selectionOrigin("https://github.com", "https://example.com") ==21+                "https://github.com",22+        )23+    }24+25+    @Test26+    fun selectionRetainsAuthenticatedBeginRequestOriginWhenOemOmitsFinalRequest() {27+        assertTrue(28+            selectionOrigin(null, "https://github.com") == "https://github.com",29+        )30+    }31+32+    @Test33+    fun selectionStillFailsClosedWithoutAnyVerifiedOrigin() {34+        assertTrue(selectionOrigin(null, null) == null)35+        assertTrue(selectionOrigin("  ", "") == null)36+    }37+38+    private fun source(relativePath: String): String =39+        File("src/main/java/com/peachpasswords/$relativePath").readText()40+}

view file