PasskeyAssertion.kt

8.7 KB · raw

1package com.peachpasswords.passkeys23import com.peachpasswords.vault.VaultEntry4import java.math.BigInteger5import java.security.AlgorithmParameters6import java.security.KeyFactory7import java.security.MessageDigest8import java.security.Signature9import java.security.interfaces.ECPrivateKey10import java.security.spec.ECGenParameterSpec11import java.security.spec.ECParameterSpec12import java.security.spec.ECPrivateKeySpec13import java.util.Base6414import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters15import org.bouncycastle.crypto.signers.Ed25519Signer16import kotlinx.serialization.json.Json17import kotlinx.serialization.json.JsonObject18import kotlinx.serialization.json.JsonPrimitive19import kotlinx.serialization.json.buildJsonObject20import kotlinx.serialization.json.contentOrNull21import kotlinx.serialization.json.intOrNull22import kotlinx.serialization.json.jsonObject23import kotlinx.serialization.json.jsonPrimitive24import kotlinx.serialization.json.put2526data class PasskeyAssertionResult(27    val responseJson: String,28    val nextSignCount: Int,29)3031object PasskeyAssertion {32    private val json = Json { ignoreUnknownKeys = true }33    private val urlEncoder = Base64.getUrlEncoder().withoutPadding()34    private val urlDecoder = Base64.getUrlDecoder()3536    fun build(37        entry: VaultEntry,38        requestJson: String,39        origin: String?,40        clientDataHashOverride: ByteArray? = null,41        // Set to true only when a biometric/device-credential prompt has actually42        // succeeded for this assertion. Defaults to false so existing callers and43        // tests that bypass the prompt still produce a valid flag byte (UV=0).44        userVerified: Boolean = false,45    ): PasskeyAssertionResult? {46        val passkey = entry.passkey ?: return null47        val privateKeyJwk = passkey.privateKeyJwk ?: entry.privateKeyJwk ?: return null48        // F2 fail-closed: never synthesize an origin. F1: the credential is49        // only released to the caller origin it was bound to (legacy50        // 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 null54        val rpId = request["rpId"]?.jsonPrimitive?.contentOrNull ?: passkey.rpId55        if (rpId != passkey.rpId) return null56        val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null5758        val nextSignCount = passkey.signCount + 159        // UP is implicit (user tapped the credential), BE/BS reflect the60        // persisted record (immutable once written), and UV only when the caller61        // actually performed user verification. AT must NOT be set on assertions.62        val flags = assertionFlags(passkey.backupEligible, passkey.backupState, userVerified)63        val authData = buildAuthenticatorData(passkey.rpId, flags, nextSignCount)64        val clientDataJson = buildClientDataJson(65            challenge = challenge,66            origin = callerOrigin,67        )68        val clientDataHash = clientDataHashOverride ?: sha256(clientDataJson)69        val signature = signPasskey(privateKeyJwk, passkey.algorithm, authData + clientDataHash) ?: return null7071        val responseJson = json.encodeToString(72            JsonObject.serializer(),73            buildJsonObject {74                put("id", passkey.credentialId)75                put("rawId", passkey.credentialId)76                put("type", "public-key")77                put("response", buildJsonObject {78                    if (clientDataHashOverride == null) {79                        put("clientDataJSON", b64(clientDataJson))80                    }81                    put("authenticatorData", b64(authData))82                    put("signature", b64(signature))83                    put("userHandle", passkey.userHandle)84                })85                put("authenticatorAttachment", "platform")86                put("clientExtensionResults", buildJsonObject {})87            },88        )89        return PasskeyAssertionResult(responseJson = responseJson, nextSignCount = nextSignCount)90    }9192    private fun buildClientDataJson(challenge: String, origin: String): ByteArray =93        json.encodeToString(94            JsonObject.serializer(),95            buildJsonObject {96                put("type", "webauthn.get")97                put("challenge", challenge)98                put("origin", origin)99                put("crossOrigin", JsonPrimitive(false))100            },101        ).toByteArray(Charsets.UTF_8)102103    private fun buildAuthenticatorData(rpId: String, flags: Int, signCount: Int): ByteArray {104        val out = ByteArray(37)105        sha256(rpId.toByteArray(Charsets.UTF_8)).copyInto(out, 0)106        out[32] = flags.toByte()107        out[33] = ((signCount ushr 24) and 0xff).toByte()108        out[34] = ((signCount ushr 16) and 0xff).toByte()109        out[35] = ((signCount ushr 8) and 0xff).toByte()110        out[36] = (signCount and 0xff).toByte()111        return out112    }113114    // AT (0x40) is intentionally NOT included — assertions never carry115    // attested credential data; that's a registration-only field.116    private fun assertionFlags(backupEligible: Boolean, backupState: Boolean, userVerified: Boolean): Int {117        var f = FLAG_UP118        if (userVerified) f = f or FLAG_UV119        if (backupEligible) f = f or FLAG_BE120        if (backupState) f = f or FLAG_BS121        return f122    }123124    private const val FLAG_UP: Int = 0x01125    private const val FLAG_UV: Int = 0x04126    private const val FLAG_BE: Int = 0x08127    private const val FLAG_BS: Int = 0x10128129    private fun signPasskey(privateKeyJwk: String, algorithm: Int, message: ByteArray): ByteArray? =130        when (algorithm) {131            -7 -> signEs256(privateKeyJwk, message)132            -8 -> signEd25519(privateKeyJwk, message)133            else -> null134        }135136    private fun signEs256(privateKeyJwk: String, message: ByteArray): ByteArray? {137        val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null138        if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "EC") return null139        if (jwk["crv"]?.jsonPrimitive?.contentOrNull != "P-256") return null140        val d = jwk["d"]?.jsonPrimitive?.contentOrNull?.let(::b64Decode) ?: return null141        val privateKey = ecPrivateKey(d)142        val signer = Signature.getInstance("SHA256withECDSA")143        signer.initSign(privateKey)144        signer.update(message)145        return signer.sign()146    }147148    private fun signEd25519(privateKeyJwk: String, message: ByteArray): ByteArray? {149        val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null150        if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "OKP") return null151        if (jwk["crv"]?.jsonPrimitive?.contentOrNull != "Ed25519") return null152        val d = jwk["d"]?.jsonPrimitive?.contentOrNull?.let(::b64Decode) ?: return null153        if (d.size != 32) return null154        val signer = Ed25519Signer()155        signer.init(true, Ed25519PrivateKeyParameters(d, 0))156        signer.update(message, 0, message.size)157        return signer.generateSignature()158    }159160    private fun ecPrivateKey(d: ByteArray): ECPrivateKey {161        val keyFactory = KeyFactory.getInstance("EC")162        val spec = ECPrivateKeySpec(BigInteger(1, d), ecP256Spec())163        return keyFactory.generatePrivate(spec) as ECPrivateKey164    }165166    private fun ecP256Spec(): ECParameterSpec {167        val params = AlgorithmParameters.getInstance("EC")168        params.init(ECGenParameterSpec("secp256r1"))169        return params.getParameterSpec(ECParameterSpec::class.java)170    }171172    private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data)173    private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)174    private fun b64Decode(value: String): ByteArray {175        val padded = value.padEnd(((value.length + 3) / 4) * 4, '=')176        return urlDecoder.decode(padded)177    }178}179180/**181 * H-07: Decide whether to sign a WebAuthn assertion given the relying party's182 * user-verification requirement and the actual ceremony outcome.183 *184 * If the RP requires UV and the authenticator could not verify the user, the185 * assertion must NOT be signed — WebAuthn requires UV=1 only when verification186 * actually happened, and an RP that requires UV will reject UV=0. Fail closed187 * rather than produce a misleading assertion.188 *189 * For "preferred", "discouraged", or absent UV requirements, the assertion is190 * always signed with the real UV bit reflecting what actually happened.191 */192internal fun shouldSignAssertion(uvRequired: Boolean, userVerified: Boolean): Boolean =193    !uvRequired || userVerified