7f47a411cc3a
fix: Classify passkey assertion failures safely (v2.17.1)
- time
- 2026-09-08 00:16 UTC · 1 month ago
- machine
- koh2
- parent
- 9cebcd3b8029
↓ peach-android-7f47a411.tar.gzatlas at this save
files changed4 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 −0app/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 =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."),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() {