b22874a1827a

fix: Sign browser passkey assertions consistently (v2.15.10)

When Android supplies the browser-owned client-data hash, omit Peach's independently assembled clientDataJSON so the relying party verifies the same bytes Peach signed. Add a cryptographic regression test covering the framework-hash response shape. Verified with the focused passkey assertion suite and the full JDK 17 unit-test, lintDebug, and signed assembleRelease gate.

time
2026-09-06 14:51 UTC · 1 month ago
machine
koh2
parent
2d43b6458f09
3 files VERSION+1 −1app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+3 −1app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+25 −0
VERSION+1 −1
@@ -1 +1 @@1-2.15.91+2.15.10

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+3 −1
@@ -75,7 +75,9 @@7575                 put("rawId", passkey.credentialId)7676                 put("type", "public-key")7777                 put("response", buildJsonObject {78-                    put("clientDataJSON", b64(clientDataJson))78+                    if (clientDataHashOverride == null) {79+                        put("clientDataJSON", b64(clientDataJson))80+                    }7981                     put("authenticatorData", b64(authData))8082                     put("signature", b64(signature))8183                     put("userHandle", passkey.userHandle)

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+25 −0
@@ -15,6 +15,7 @@1515 import kotlinx.serialization.json.jsonObject1616 import kotlinx.serialization.json.jsonPrimitive1717 import org.junit.Assert.assertEquals18+import org.junit.Assert.assertFalse1819 import org.junit.Assert.assertNotNull1920 import org.junit.Assert.assertTrue2021 import org.junit.Test@@ -85,6 +86,30 @@8586         verifier.init(false, Ed25519PublicKeyParameters(publicKey, 0))8687         verifier.update(message, 0, message.size)8788         assertTrue(verifier.verifySignature(signature))89+    }90+91+    @Test fun browser_client_data_hash_does_not_emit_conflicting_client_data_json() {92+        val keyPair = KeyPairGenerator.getInstance("EC").apply {93+            initialize(ECGenParameterSpec("secp256r1"))94+        }.generateKeyPair()95+        val clientDataHash = MessageDigest.getInstance("SHA-256")96+            .digest("framework-owned-client-data".toByteArray())97+98+        val result = PasskeyAssertion.build(99+            entry = passkeyEntry(keyPair.private as ECPrivateKey),100+            requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",101+            origin = "https://example.com",102+            clientDataHashOverride = clientDataHash,103+        )104+105+        assertNotNull(result)106+        val payload = Json.parseToJsonElement(result!!.responseJson).jsonObject["response"]!!.jsonObject107+        assertFalse(payload.containsKey("clientDataJSON"))108+        val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)109+        val verifier = Signature.getInstance("SHA256withECDSA")110+        verifier.initVerify(keyPair.public as ECPublicKey)111+        verifier.update(authData + clientDataHash)112+        assertTrue(verifier.verify(b64Decode(payload["signature"]!!.jsonPrimitive.content)))88113     }89114 90115     @Test fun returns_null_for_missing_private_key() {

view file