7f47a411cc3a

fix: Classify passkey assertion failures safely (v2.17.1)

- Replace opaque assertion nulls with typed internal failure reasons - Log only the rejected invariant, never credential or request material - Preserve the existing nullable assertion API for callers and tests - Verify 1,097 JVM tests, lint with zero errors, and signed release assembly

time
2026-09-08 00:16 UTC · 1 month ago
machine
koh2
parent
9cebcd3b8029
4 files VERSION+1 −1app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+52 −10app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+7 −5app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+26 −0
VERSION+1 −1
@@ -1 +1 @@1-2.17.01+2.17.1

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+52 −10
@@ -28,6 +28,22 @@2828     val nextSignCount: Int,2929 )3030 31+internal enum class PasskeyAssertionFailure {32+    MISSING_PASSKEY,33+    MISSING_PRIVATE_KEY,34+    MISSING_ORIGIN,35+    CALLER_NOT_ALLOWED,36+    INVALID_REQUEST,37+    RP_MISMATCH,38+    MISSING_CHALLENGE,39+    SIGNATURE_FAILED,40+}41+42+internal sealed interface PasskeyAssertionBuild {43+    data class Success(val result: PasskeyAssertionResult) : PasskeyAssertionBuild44+    data class Failure(val reason: PasskeyAssertionFailure) : PasskeyAssertionBuild45+}46+3147 object PasskeyAssertion {3248     private val json = Json { ignoreUnknownKeys = true }3349     private val urlEncoder = Base64.getUrlEncoder().withoutPadding()@@ -42,18 +58,41 @@4258         // succeeded for this assertion. Defaults to false so existing callers and4359         // tests that bypass the prompt still produce a valid flag byte (UV=0).4460         userVerified: Boolean = false,45-    ): PasskeyAssertionResult? {46-        val passkey = entry.passkey ?: return null47-        val privateKeyJwk = passkey.privateKeyJwk ?: entry.privateKeyJwk ?: return null61+    ): PasskeyAssertionResult? = (buildDetailed(62+        entry = entry,63+        requestJson = requestJson,64+        origin = origin,65+        clientDataHashOverride = clientDataHashOverride,66+        userVerified = userVerified,67+    ) as? PasskeyAssertionBuild.Success)?.result68+69+    internal fun buildDetailed(70+        entry: VaultEntry,71+        requestJson: String,72+        origin: String?,73+        clientDataHashOverride: ByteArray? = null,74+        userVerified: Boolean = false,75+    ): PasskeyAssertionBuild {76+        val passkey = entry.passkey77+            ?: return PasskeyAssertionBuild.Failure(PasskeyAssertionFailure.MISSING_PASSKEY)78+        val privateKeyJwk = passkey.privateKeyJwk ?: entry.privateKeyJwk79+            ?: return PasskeyAssertionBuild.Failure(PasskeyAssertionFailure.MISSING_PRIVATE_KEY)4880         // F2 fail-closed: never synthesize an origin. F1: the credential is4981         // only released to the caller origin it was bound to (legacy5082         // credentials keep the rpId-domain gate for verified web callers).51-        val callerOrigin = origin?.takeIf { it.isNotBlank() } ?: return null52-        if (!PasskeyCallerOrigin.canServeCredential(passkey.callerOrigin, passkey.rpId, callerOrigin)) return null53-        val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null83+        val callerOrigin = origin?.takeIf { it.isNotBlank() }84+            ?: return PasskeyAssertionBuild.Failure(PasskeyAssertionFailure.MISSING_ORIGIN)85+        if (!PasskeyCallerOrigin.canServeCredential(passkey.callerOrigin, passkey.rpId, callerOrigin)) {86+            return PasskeyAssertionBuild.Failure(PasskeyAssertionFailure.CALLER_NOT_ALLOWED)87+        }88+        val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull()89+            ?: return PasskeyAssertionBuild.Failure(PasskeyAssertionFailure.INVALID_REQUEST)5490         val rpId = request["rpId"]?.jsonPrimitive?.contentOrNull ?: passkey.rpId55-        if (rpId != passkey.rpId) return null56-        val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null91+        if (rpId != passkey.rpId) {92+            return PasskeyAssertionBuild.Failure(PasskeyAssertionFailure.RP_MISMATCH)93+        }94+        val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull95+            ?: return PasskeyAssertionBuild.Failure(PasskeyAssertionFailure.MISSING_CHALLENGE)5796 5897         val nextSignCount = passkey.signCount + 15998         // UP is implicit (user tapped the credential), BE/BS reflect the@@ -66,7 +105,8 @@66105             origin = callerOrigin,67106         )68107         val clientDataHash = clientDataHashOverride ?: sha256(clientDataJson)69-        val signature = signPasskey(privateKeyJwk, passkey.algorithm, authData + clientDataHash) ?: return null108+        val signature = signPasskey(privateKeyJwk, passkey.algorithm, authData + clientDataHash)109+            ?: return PasskeyAssertionBuild.Failure(PasskeyAssertionFailure.SIGNATURE_FAILED)70110 71111         val responseJson = json.encodeToString(72112             JsonObject.serializer(),@@ -86,7 +126,9 @@86126                 put("clientExtensionResults", buildJsonObject {})87127             },88128         )89-        return PasskeyAssertionResult(responseJson = responseJson, nextSignCount = nextSignCount)129+        return PasskeyAssertionBuild.Success(130+            PasskeyAssertionResult(responseJson = responseJson, nextSignCount = nextSignCount),131+        )90132     }91133 92134     private fun buildClientDataJson(challenge: String, origin: String): ByteArray =

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+7 −5
@@ -272,16 +272,18 @@272272             finish()273273             return274274         }275-        val assertion = if (entry != null && requestJson != null) {276-            PasskeyAssertion.build(275+        val assertionBuild = if (entry != null && requestJson != null) {276+            PasskeyAssertion.buildDetailed(277277                 entry = entry,278278                 requestJson = requestJson,279279                 origin = origin,280280                 clientDataHashOverride = clientDataHash,281281                 userVerified = userVerified,282282             )283-        } else {284-            null283+        } else null284+        val assertion = (assertionBuild as? PasskeyAssertionBuild.Success)?.result285+        if (assertionBuild is PasskeyAssertionBuild.Failure) {286+            Log.w(TAG, "flow=assertion stage=build_failed reason=${assertionBuild.reason.name.lowercase()}")285287         }286288 287289         if (entry != null && assertion != null) {@@ -314,7 +316,7 @@314316                 )315317             }316318         } else {317-            Log.w(TAG, "flow=assertion stage=build_failed")319+            if (assertionBuild == null) Log.w(TAG, "flow=assertion stage=build_failed reason=missing_input")318320             PendingIntentHandler.setGetCredentialException(319321                 result,320322                 GetCredentialUnsupportedException("Peach cannot sign this passkey yet."),

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+26 −0
@@ -124,6 +124,32 @@124124         val entry = passkeyEntry(keyPair.private as ECPrivateKey)125125         assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", null))126126         assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", " "))127+        assertEquals(128+            PasskeyAssertionFailure.MISSING_ORIGIN,129+            (PasskeyAssertion.buildDetailed(130+                entry,131+                """{"challenge":"c","rpId":"example.com"}""",132+                null,133+            ) as PasskeyAssertionBuild.Failure).reason,134+        )135+    }136+137+    @Test fun reports_caller_binding_mismatch_without_exposing_request_data() {138+        val keyPair = KeyPairGenerator.getInstance("EC").apply {139+            initialize(ECGenParameterSpec("secp256r1"))140+        }.generateKeyPair()141+        val entry = passkeyEntry(keyPair.private as ECPrivateKey)142+143+        val outcome = PasskeyAssertion.buildDetailed(144+            entry,145+            """{"challenge":"c","rpId":"example.com"}""",146+            "https://unrelated.test",147+        )148+149+        assertEquals(150+            PasskeyAssertionFailure.CALLER_NOT_ALLOWED,151+            (outcome as PasskeyAssertionBuild.Failure).reason,152+        )127153     }128154 129155     @Test fun bound_credential_refuses_every_other_caller() {

view file