PasskeyAssertion.kt

10.3 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)3031internal 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}4142internal sealed interface PasskeyAssertionBuild {43    data class Success(val result: PasskeyAssertionResult) : PasskeyAssertionBuild44    data class Failure(val reason: PasskeyAssertionFailure) : PasskeyAssertionBuild45}4647object PasskeyAssertion {48    private val json = Json { ignoreUnknownKeys = true }49    private val urlEncoder = Base64.getUrlEncoder().withoutPadding()50    private val urlDecoder = Base64.getUrlDecoder()5152    fun build(53        entry: VaultEntry,54        requestJson: String,55        origin: String?,56        clientDataHashOverride: ByteArray? = null,57        // Set to true only when a biometric/device-credential prompt has actually58        // succeeded for this assertion. Defaults to false so existing callers and59        // tests that bypass the prompt still produce a valid flag byte (UV=0).60        userVerified: Boolean = false,61    ): PasskeyAssertionResult? = (buildDetailed(62        entry = entry,63        requestJson = requestJson,64        origin = origin,65        clientDataHashOverride = clientDataHashOverride,66        userVerified = userVerified,67    ) as? PasskeyAssertionBuild.Success)?.result6869    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)80        // F2 fail-closed: never synthesize an origin. F1: the credential is81        // only released to the caller origin it was bound to (legacy82        // credentials keep the rpId-domain gate for verified web callers).83        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)90        val rpId = request["rpId"]?.jsonPrimitive?.contentOrNull ?: passkey.rpId91        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)9697        val nextSignCount = passkey.signCount + 198        // UP is implicit (user tapped the credential), BE/BS reflect the99        // persisted record (immutable once written), and UV only when the caller100        // actually performed user verification. AT must NOT be set on assertions.101        val flags = assertionFlags(passkey.backupEligible, passkey.backupState, userVerified)102        val authData = buildAuthenticatorData(passkey.rpId, flags, nextSignCount)103        val clientDataJson = buildClientDataJson(104            challenge = challenge,105            origin = callerOrigin,106        )107        val clientDataHash = clientDataHashOverride ?: sha256(clientDataJson)108        val signature = signPasskey(privateKeyJwk, passkey.algorithm, authData + clientDataHash)109            ?: return PasskeyAssertionBuild.Failure(PasskeyAssertionFailure.SIGNATURE_FAILED)110111        val responseJson = json.encodeToString(112            JsonObject.serializer(),113            buildJsonObject {114                put("id", passkey.credentialId)115                put("rawId", passkey.credentialId)116                put("type", "public-key")117                put("response", buildJsonObject {118                    if (clientDataHashOverride == null) {119                        put("clientDataJSON", b64(clientDataJson))120                    }121                    put("authenticatorData", b64(authData))122                    put("signature", b64(signature))123                    put("userHandle", passkey.userHandle)124                })125                put("authenticatorAttachment", "platform")126                put("clientExtensionResults", buildJsonObject {})127            },128        )129        return PasskeyAssertionBuild.Success(130            PasskeyAssertionResult(responseJson = responseJson, nextSignCount = nextSignCount),131        )132    }133134    private fun buildClientDataJson(challenge: String, origin: String): ByteArray =135        json.encodeToString(136            JsonObject.serializer(),137            buildJsonObject {138                put("type", "webauthn.get")139                put("challenge", challenge)140                put("origin", origin)141                put("crossOrigin", JsonPrimitive(false))142            },143        ).toByteArray(Charsets.UTF_8)144145    private fun buildAuthenticatorData(rpId: String, flags: Int, signCount: Int): ByteArray {146        val out = ByteArray(37)147        sha256(rpId.toByteArray(Charsets.UTF_8)).copyInto(out, 0)148        out[32] = flags.toByte()149        out[33] = ((signCount ushr 24) and 0xff).toByte()150        out[34] = ((signCount ushr 16) and 0xff).toByte()151        out[35] = ((signCount ushr 8) and 0xff).toByte()152        out[36] = (signCount and 0xff).toByte()153        return out154    }155156    // AT (0x40) is intentionally NOT included — assertions never carry157    // attested credential data; that's a registration-only field.158    private fun assertionFlags(backupEligible: Boolean, backupState: Boolean, userVerified: Boolean): Int {159        var f = FLAG_UP160        if (userVerified) f = f or FLAG_UV161        if (backupEligible) f = f or FLAG_BE162        if (backupState) f = f or FLAG_BS163        return f164    }165166    private const val FLAG_UP: Int = 0x01167    private const val FLAG_UV: Int = 0x04168    private const val FLAG_BE: Int = 0x08169    private const val FLAG_BS: Int = 0x10170171    private fun signPasskey(privateKeyJwk: String, algorithm: Int, message: ByteArray): ByteArray? =172        when (algorithm) {173            -7 -> signEs256(privateKeyJwk, message)174            -8 -> signEd25519(privateKeyJwk, message)175            else -> null176        }177178    private fun signEs256(privateKeyJwk: String, message: ByteArray): ByteArray? {179        val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null180        if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "EC") return null181        if (jwk["crv"]?.jsonPrimitive?.contentOrNull != "P-256") return null182        val d = jwk["d"]?.jsonPrimitive?.contentOrNull?.let(::b64Decode) ?: return null183        val privateKey = ecPrivateKey(d)184        val signer = Signature.getInstance("SHA256withECDSA")185        signer.initSign(privateKey)186        signer.update(message)187        return signer.sign()188    }189190    private fun signEd25519(privateKeyJwk: String, message: ByteArray): ByteArray? {191        val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null192        if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "OKP") return null193        if (jwk["crv"]?.jsonPrimitive?.contentOrNull != "Ed25519") return null194        val d = jwk["d"]?.jsonPrimitive?.contentOrNull?.let(::b64Decode) ?: return null195        if (d.size != 32) return null196        val signer = Ed25519Signer()197        signer.init(true, Ed25519PrivateKeyParameters(d, 0))198        signer.update(message, 0, message.size)199        return signer.generateSignature()200    }201202    private fun ecPrivateKey(d: ByteArray): ECPrivateKey {203        val keyFactory = KeyFactory.getInstance("EC")204        val spec = ECPrivateKeySpec(BigInteger(1, d), ecP256Spec())205        return keyFactory.generatePrivate(spec) as ECPrivateKey206    }207208    private fun ecP256Spec(): ECParameterSpec {209        val params = AlgorithmParameters.getInstance("EC")210        params.init(ECGenParameterSpec("secp256r1"))211        return params.getParameterSpec(ECParameterSpec::class.java)212    }213214    private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data)215    private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)216    private fun b64Decode(value: String): ByteArray {217        val padded = value.padEnd(((value.length + 3) / 4) * 4, '=')218        return urlDecoder.decode(padded)219    }220}221222/**223 * H-07: Decide whether to sign a WebAuthn assertion given the relying party's224 * user-verification requirement and the actual ceremony outcome.225 *226 * If the RP requires UV and the authenticator could not verify the user, the227 * assertion must NOT be signed — WebAuthn requires UV=1 only when verification228 * actually happened, and an RP that requires UV will reject UV=0. Fail closed229 * rather than produce a misleading assertion.230 *231 * For "preferred", "discouraged", or absent UV requirements, the assertion is232 * always signed with the real UV bit reflecting what actually happened.233 */234internal fun shouldSignAssertion(uvRequired: Boolean, userVerified: Boolean): Boolean =235    !uvRequired || userVerified