PasskeyAssertion.kt

8.6 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                    put("clientDataJSON", b64(clientDataJson))79                    put("authenticatorData", b64(authData))80                    put("signature", b64(signature))81                    put("userHandle", passkey.userHandle)82                })83                put("authenticatorAttachment", "platform")84                put("clientExtensionResults", buildJsonObject {})85            },86        )87        return PasskeyAssertionResult(responseJson = responseJson, nextSignCount = nextSignCount)88    }8990    private fun buildClientDataJson(challenge: String, origin: String): ByteArray =91        json.encodeToString(92            JsonObject.serializer(),93            buildJsonObject {94                put("type", "webauthn.get")95                put("challenge", challenge)96                put("origin", origin)97                put("crossOrigin", JsonPrimitive(false))98            },99        ).toByteArray(Charsets.UTF_8)100101    private fun buildAuthenticatorData(rpId: String, flags: Int, signCount: Int): ByteArray {102        val out = ByteArray(37)103        sha256(rpId.toByteArray(Charsets.UTF_8)).copyInto(out, 0)104        out[32] = flags.toByte()105        out[33] = ((signCount ushr 24) and 0xff).toByte()106        out[34] = ((signCount ushr 16) and 0xff).toByte()107        out[35] = ((signCount ushr 8) and 0xff).toByte()108        out[36] = (signCount and 0xff).toByte()109        return out110    }111112    // AT (0x40) is intentionally NOT included — assertions never carry113    // attested credential data; that's a registration-only field.114    private fun assertionFlags(backupEligible: Boolean, backupState: Boolean, userVerified: Boolean): Int {115        var f = FLAG_UP116        if (userVerified) f = f or FLAG_UV117        if (backupEligible) f = f or FLAG_BE118        if (backupState) f = f or FLAG_BS119        return f120    }121122    private const val FLAG_UP: Int = 0x01123    private const val FLAG_UV: Int = 0x04124    private const val FLAG_BE: Int = 0x08125    private const val FLAG_BS: Int = 0x10126127    private fun signPasskey(privateKeyJwk: String, algorithm: Int, message: ByteArray): ByteArray? =128        when (algorithm) {129            -7 -> signEs256(privateKeyJwk, message)130            -8 -> signEd25519(privateKeyJwk, message)131            else -> null132        }133134    private fun signEs256(privateKeyJwk: String, message: ByteArray): ByteArray? {135        val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null136        if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "EC") return null137        if (jwk["crv"]?.jsonPrimitive?.contentOrNull != "P-256") return null138        val d = jwk["d"]?.jsonPrimitive?.contentOrNull?.let(::b64Decode) ?: return null139        val privateKey = ecPrivateKey(d)140        val signer = Signature.getInstance("SHA256withECDSA")141        signer.initSign(privateKey)142        signer.update(message)143        return signer.sign()144    }145146    private fun signEd25519(privateKeyJwk: String, message: ByteArray): ByteArray? {147        val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null148        if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "OKP") return null149        if (jwk["crv"]?.jsonPrimitive?.contentOrNull != "Ed25519") return null150        val d = jwk["d"]?.jsonPrimitive?.contentOrNull?.let(::b64Decode) ?: return null151        if (d.size != 32) return null152        val signer = Ed25519Signer()153        signer.init(true, Ed25519PrivateKeyParameters(d, 0))154        signer.update(message, 0, message.size)155        return signer.generateSignature()156    }157158    private fun ecPrivateKey(d: ByteArray): ECPrivateKey {159        val keyFactory = KeyFactory.getInstance("EC")160        val spec = ECPrivateKeySpec(BigInteger(1, d), ecP256Spec())161        return keyFactory.generatePrivate(spec) as ECPrivateKey162    }163164    private fun ecP256Spec(): ECParameterSpec {165        val params = AlgorithmParameters.getInstance("EC")166        params.init(ECGenParameterSpec("secp256r1"))167        return params.getParameterSpec(ECParameterSpec::class.java)168    }169170    private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data)171    private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)172    private fun b64Decode(value: String): ByteArray {173        val padded = value.padEnd(((value.length + 3) / 4) * 4, '=')174        return urlDecoder.decode(padded)175    }176}177178/**179 * H-07: Decide whether to sign a WebAuthn assertion given the relying party's180 * user-verification requirement and the actual ceremony outcome.181 *182 * If the RP requires UV and the authenticator could not verify the user, the183 * assertion must NOT be signed — WebAuthn requires UV=1 only when verification184 * actually happened, and an RP that requires UV will reject UV=0. Fail closed185 * rather than produce a misleading assertion.186 *187 * For "preferred", "discouraged", or absent UV requirements, the assertion is188 * always signed with the real UV bit reflecting what actually happened.189 */190internal fun shouldSignAssertion(uvRequired: Boolean, userVerified: Boolean): Boolean =191    !uvRequired || userVerified