PasskeyAssertion.kt

8.5 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        val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null49        val rpId = request["rpId"]?.jsonPrimitive?.contentOrNull ?: passkey.rpId50        if (rpId != passkey.rpId) return null51        val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null5253        val nextSignCount = passkey.signCount + 154        // UP is implicit (user tapped the credential), BE/BS reflect the55        // persisted record (immutable once written), and UV only when the caller56        // actually performed user verification. AT must NOT be set on assertions.57        val flags = assertionFlags(passkey.backupEligible, passkey.backupState, userVerified)58        val authData = buildAuthenticatorData(passkey.rpId, flags, nextSignCount)59        val clientDataJson = buildClientDataJson(60            challenge = challenge,61            origin = origin?.takeIf { it.isNotBlank() } ?: originFromRpId(passkey.rpId),62        )63        val clientDataHash = clientDataHashOverride ?: sha256(clientDataJson)64        val signature = signPasskey(privateKeyJwk, passkey.algorithm, authData + clientDataHash) ?: return null6566        val responseJson = json.encodeToString(67            JsonObject.serializer(),68            buildJsonObject {69                put("id", passkey.credentialId)70                put("rawId", passkey.credentialId)71                put("type", "public-key")72                put("response", buildJsonObject {73                    put("clientDataJSON", b64(clientDataJson))74                    put("authenticatorData", b64(authData))75                    put("signature", b64(signature))76                    put("userHandle", passkey.userHandle)77                })78                put("authenticatorAttachment", "platform")79                put("clientExtensionResults", buildJsonObject {})80            },81        )82        return PasskeyAssertionResult(responseJson = responseJson, nextSignCount = nextSignCount)83    }8485    private fun buildClientDataJson(challenge: String, origin: String): ByteArray =86        json.encodeToString(87            JsonObject.serializer(),88            buildJsonObject {89                put("type", "webauthn.get")90                put("challenge", challenge)91                put("origin", origin)92                put("crossOrigin", JsonPrimitive(false))93            },94        ).toByteArray(Charsets.UTF_8)9596    private fun buildAuthenticatorData(rpId: String, flags: Int, signCount: Int): ByteArray {97        val out = ByteArray(37)98        sha256(rpId.toByteArray(Charsets.UTF_8)).copyInto(out, 0)99        out[32] = flags.toByte()100        out[33] = ((signCount ushr 24) and 0xff).toByte()101        out[34] = ((signCount ushr 16) and 0xff).toByte()102        out[35] = ((signCount ushr 8) and 0xff).toByte()103        out[36] = (signCount and 0xff).toByte()104        return out105    }106107    // AT (0x40) is intentionally NOT included — assertions never carry108    // attested credential data; that's a registration-only field.109    private fun assertionFlags(backupEligible: Boolean, backupState: Boolean, userVerified: Boolean): Int {110        var f = FLAG_UP111        if (userVerified) f = f or FLAG_UV112        if (backupEligible) f = f or FLAG_BE113        if (backupState) f = f or FLAG_BS114        return f115    }116117    private const val FLAG_UP: Int = 0x01118    private const val FLAG_UV: Int = 0x04119    private const val FLAG_BE: Int = 0x08120    private const val FLAG_BS: Int = 0x10121122    private fun signPasskey(privateKeyJwk: String, algorithm: Int, message: ByteArray): ByteArray? =123        when (algorithm) {124            -7 -> signEs256(privateKeyJwk, message)125            -8 -> signEd25519(privateKeyJwk, message)126            else -> null127        }128129    private fun signEs256(privateKeyJwk: String, message: ByteArray): ByteArray? {130        val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null131        if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "EC") return null132        if (jwk["crv"]?.jsonPrimitive?.contentOrNull != "P-256") return null133        val d = jwk["d"]?.jsonPrimitive?.contentOrNull?.let(::b64Decode) ?: return null134        val privateKey = ecPrivateKey(d)135        val signer = Signature.getInstance("SHA256withECDSA")136        signer.initSign(privateKey)137        signer.update(message)138        return signer.sign()139    }140141    private fun signEd25519(privateKeyJwk: String, message: ByteArray): ByteArray? {142        val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null143        if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "OKP") return null144        if (jwk["crv"]?.jsonPrimitive?.contentOrNull != "Ed25519") return null145        val d = jwk["d"]?.jsonPrimitive?.contentOrNull?.let(::b64Decode) ?: return null146        if (d.size != 32) return null147        val signer = Ed25519Signer()148        signer.init(true, Ed25519PrivateKeyParameters(d, 0))149        signer.update(message, 0, message.size)150        return signer.generateSignature()151    }152153    private fun ecPrivateKey(d: ByteArray): ECPrivateKey {154        val keyFactory = KeyFactory.getInstance("EC")155        val spec = ECPrivateKeySpec(BigInteger(1, d), ecP256Spec())156        return keyFactory.generatePrivate(spec) as ECPrivateKey157    }158159    private fun ecP256Spec(): ECParameterSpec {160        val params = AlgorithmParameters.getInstance("EC")161        params.init(ECGenParameterSpec("secp256r1"))162        return params.getParameterSpec(ECParameterSpec::class.java)163    }164165    private fun originFromRpId(rpId: String): String =166        if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") {167            "http://$rpId"168        } else {169            "https://$rpId"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