PasskeyAssertion.kt

5.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 kotlinx.serialization.json.Json15import kotlinx.serialization.json.JsonObject16import kotlinx.serialization.json.JsonPrimitive17import kotlinx.serialization.json.buildJsonObject18import kotlinx.serialization.json.contentOrNull19import kotlinx.serialization.json.intOrNull20import kotlinx.serialization.json.jsonObject21import kotlinx.serialization.json.jsonPrimitive22import kotlinx.serialization.json.put2324data class PasskeyAssertionResult(25    val responseJson: String,26    val nextSignCount: Int,27)2829object PasskeyAssertion {30    private val json = Json { ignoreUnknownKeys = true }31    private val urlEncoder = Base64.getUrlEncoder().withoutPadding()32    private val urlDecoder = Base64.getUrlDecoder()3334    fun build(35        entry: VaultEntry,36        requestJson: String,37        origin: String?,38        clientDataHashOverride: ByteArray? = null,39    ): PasskeyAssertionResult? {40        val passkey = entry.passkey ?: return null41        if (passkey.algorithm != -7) return null42        val privateKeyJwk = passkey.privateKeyJwk ?: entry.privateKeyJwk ?: return null43        val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null44        val rpId = request["rpId"]?.jsonPrimitive?.contentOrNull ?: passkey.rpId45        if (rpId != passkey.rpId) return null46        val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null4748        val nextSignCount = passkey.signCount + 149        val flags = 0x05 or (if (passkey.backupEligible) 0x08 else 0) or (if (passkey.backupState) 0x10 else 0)50        val authData = buildAuthenticatorData(passkey.rpId, flags, nextSignCount)51        val clientDataJson = buildClientDataJson(52            challenge = challenge,53            origin = origin?.takeIf { it.isNotBlank() } ?: originFromRpId(passkey.rpId),54        )55        val clientDataHash = clientDataHashOverride ?: sha256(clientDataJson)56        val signature = signEs256(privateKeyJwk, authData + clientDataHash) ?: return null5758        val responseJson = json.encodeToString(59            JsonObject.serializer(),60            buildJsonObject {61                put("id", passkey.credentialId)62                put("rawId", passkey.credentialId)63                put("type", "public-key")64                put("response", buildJsonObject {65                    put("clientDataJSON", b64(clientDataJson))66                    put("authenticatorData", b64(authData))67                    put("signature", b64(signature))68                    put("userHandle", passkey.userHandle)69                })70                put("authenticatorAttachment", "platform")71                put("clientExtensionResults", buildJsonObject {})72            },73        )74        return PasskeyAssertionResult(responseJson = responseJson, nextSignCount = nextSignCount)75    }7677    private fun buildClientDataJson(challenge: String, origin: String): ByteArray =78        json.encodeToString(79            JsonObject.serializer(),80            buildJsonObject {81                put("type", "webauthn.get")82                put("challenge", challenge)83                put("origin", origin)84                put("crossOrigin", JsonPrimitive(false))85            },86        ).toByteArray(Charsets.UTF_8)8788    private fun buildAuthenticatorData(rpId: String, flags: Int, signCount: Int): ByteArray {89        val out = ByteArray(37)90        sha256(rpId.toByteArray(Charsets.UTF_8)).copyInto(out, 0)91        out[32] = flags.toByte()92        out[33] = ((signCount ushr 24) and 0xff).toByte()93        out[34] = ((signCount ushr 16) and 0xff).toByte()94        out[35] = ((signCount ushr 8) and 0xff).toByte()95        out[36] = (signCount and 0xff).toByte()96        return out97    }9899    private fun signEs256(privateKeyJwk: String, message: ByteArray): ByteArray? {100        val jwk = runCatching { json.parseToJsonElement(privateKeyJwk).jsonObject }.getOrNull() ?: return null101        if (jwk["kty"]?.jsonPrimitive?.contentOrNull != "EC") return null102        if (jwk["crv"]?.jsonPrimitive?.contentOrNull != "P-256") return null103        val d = jwk["d"]?.jsonPrimitive?.contentOrNull?.let(::b64Decode) ?: return null104        val privateKey = ecPrivateKey(d)105        val signer = Signature.getInstance("SHA256withECDSA")106        signer.initSign(privateKey)107        signer.update(message)108        return signer.sign()109    }110111    private fun ecPrivateKey(d: ByteArray): ECPrivateKey {112        val keyFactory = KeyFactory.getInstance("EC")113        val spec = ECPrivateKeySpec(BigInteger(1, d), ecP256Spec())114        return keyFactory.generatePrivate(spec) as ECPrivateKey115    }116117    private fun ecP256Spec(): ECParameterSpec {118        val params = AlgorithmParameters.getInstance("EC")119        params.init(ECGenParameterSpec("secp256r1"))120        return params.getParameterSpec(ECParameterSpec::class.java)121    }122123    private fun originFromRpId(rpId: String): String =124        if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") {125            "http://$rpId"126        } else {127            "https://$rpId"128        }129130    private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data)131    private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)132    private fun b64Decode(value: String): ByteArray {133        val padded = value.padEnd(((value.length + 3) / 4) * 4, '=')134        return urlDecoder.decode(padded)135    }136}