PasskeyRegistration.kt

8.2 KB · raw

1package com.peachpasswords.passkeys23import com.peachpasswords.vault.PasskeyData4import com.peachpasswords.vault.VaultEntry5import java.security.KeyPairGenerator6import java.security.SecureRandom7import java.security.interfaces.ECPrivateKey8import java.security.interfaces.ECPublicKey9import java.security.spec.ECGenParameterSpec10import java.util.Base6411import kotlinx.serialization.json.Json12import kotlinx.serialization.json.JsonArray13import kotlinx.serialization.json.JsonObject14import kotlinx.serialization.json.JsonPrimitive15import kotlinx.serialization.json.buildJsonArray16import kotlinx.serialization.json.buildJsonObject17import kotlinx.serialization.json.contentOrNull18import kotlinx.serialization.json.intOrNull19import kotlinx.serialization.json.jsonArray20import kotlinx.serialization.json.jsonObject21import kotlinx.serialization.json.jsonPrimitive22import kotlinx.serialization.json.put2324data class PasskeyRegistrationResult(25    val entryDraft: VaultEntry,26    val responseJson: String,27)2829object PasskeyRegistration {30    private val json = Json { ignoreUnknownKeys = true }31    private val random = SecureRandom()32    private val urlEncoder = Base64.getUrlEncoder().withoutPadding()3334    fun build(requestJson: String, origin: String?, now: Long = System.currentTimeMillis()): PasskeyRegistrationResult? {35        val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null36        val rp = request["rp"]?.jsonObject ?: return null37        val user = request["user"]?.jsonObject ?: return null38        val rpId = rp["id"]?.jsonPrimitive?.contentOrNull ?: return null39        val rpName = rp["name"]?.jsonPrimitive?.contentOrNull ?: rpId40        val userHandle = user["id"]?.jsonPrimitive?.contentOrNull ?: return null41        val userName = user["name"]?.jsonPrimitive?.contentOrNull ?: return null42        val userDisplayName = user["displayName"]?.jsonPrimitive?.contentOrNull ?: userName43        val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null44        if (!offersEs256(request["pubKeyCredParams"])) return null4546        val keyPair = KeyPairGenerator.getInstance("EC").apply {47            initialize(ECGenParameterSpec("secp256r1"))48        }.generateKeyPair()49        val privateKey = keyPair.private as ECPrivateKey50        val publicKey = keyPair.public as ECPublicKey51        val credentialId = ByteArray(32).also(random::nextBytes)52        val publicKeyCose = Cbor.map(53            1 to Cbor.uint(2),54            3 to Cbor.nint(-7),55            -1 to Cbor.uint(1),56            -2 to Cbor.bytes(publicKey.w.affineX.toFixed32()),57            -3 to Cbor.bytes(publicKey.w.affineY.toFixed32()),58        )59        val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose)60        val attestationObject = Cbor.map(61            "fmt" to Cbor.text("none"),62            "attStmt" to Cbor.map(),63            "authData" to Cbor.bytes(authData),64        )65        val clientDataJson = clientDataJson("webauthn.create", challenge, origin ?: originFromRpId(rpId))66        val passkey = PasskeyData(67            credentialId = b64(credentialId),68            publicKeyCose = b64(publicKeyCose),69            privateKeyJwk = privateJwk(privateKey, publicKey),70            rpId = rpId,71            rpName = rpName,72            userHandle = userHandle,73            userName = userName,74            userDisplayName = userDisplayName,75            algorithm = -7,76            signCount = 0,77            aaguid = b64(ByteArray(16)),78            backupEligible = false,79            backupState = false,80            transports = listOf("internal"),81            createdAt = now,82        )83        val entry = VaultEntry(84            id = "",85            type = "login",86            title = rpName,87            username = userName,88            url = "https://$rpId",89            passkey = passkey,90            createdAt = 0,91            modifiedAt = 0,92        )93        val responseJson = json.encodeToString(94            JsonObject.serializer(),95            buildJsonObject {96                put("id", passkey.credentialId)97                put("rawId", passkey.credentialId)98                put("type", "public-key")99                put("response", buildJsonObject {100                    put("clientDataJSON", b64(clientDataJson))101                    put("authenticatorData", b64(authData))102                    put("attestationObject", b64(attestationObject))103                    put("transports", buildJsonArray { add(JsonPrimitive("internal")) })104                    put("publicKey", b64(publicKey.encoded))105                    put("publicKeyAlgorithm", -7)106                })107                put("authenticatorAttachment", "platform")108                put("clientExtensionResults", buildJsonObject {})109            },110        )111        return PasskeyRegistrationResult(entryDraft = entry, responseJson = responseJson)112    }113114    private fun offersEs256(value: kotlinx.serialization.json.JsonElement?): Boolean {115        val params = runCatching { value?.jsonArray }.getOrNull() ?: return true116        return params.any { item ->117            runCatching { item.jsonObject["alg"]?.jsonPrimitive?.intOrNull == -7 }.getOrDefault(false)118        }119    }120121    private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray): ByteArray {122        val base = ByteArray(37)123        java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)).copyInto(base, 0)124        base[32] = 0x4d125        val length = byteArrayOf(((credentialId.size ushr 8) and 0xff).toByte(), (credentialId.size and 0xff).toByte())126        return base + ByteArray(16) + length + credentialId + publicKeyCose127    }128129    private fun clientDataJson(type: String, challenge: String, origin: String): ByteArray =130        json.encodeToString(131            JsonObject.serializer(),132            buildJsonObject {133                put("type", type)134                put("challenge", challenge)135                put("origin", origin)136                put("crossOrigin", JsonPrimitive(false))137            },138        ).toByteArray(Charsets.UTF_8)139140    private fun privateJwk(privateKey: ECPrivateKey, publicKey: ECPublicKey): String =141        json.encodeToString(142            JsonObject.serializer(),143            buildJsonObject {144                put("kty", "EC")145                put("crv", "P-256")146                put("d", b64(privateKey.s.toFixed32()))147                put("x", b64(publicKey.w.affineX.toFixed32()))148                put("y", b64(publicKey.w.affineY.toFixed32()))149            },150        )151152    private fun originFromRpId(rpId: String): String =153        if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") "http://$rpId" else "https://$rpId"154155    private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)156}157158private object Cbor {159    fun uint(value: Int) = integer(value)160    fun nint(value: Int) = integer(value)161    fun bytes(value: ByteArray) = major(2, value.size) + value162    fun text(value: String): ByteArray {163        val bytes = value.toByteArray(Charsets.UTF_8)164        return major(3, bytes.size) + bytes165    }166    fun map(vararg entries: Pair<Any, ByteArray>): ByteArray {167        val body = entries.flatMap { (key, value) ->168            val encodedKey = when (key) {169                is Int -> integer(key)170                is String -> text(key)171                else -> error("unsupported cbor key")172            }173            listOf(encodedKey, value)174        }.fold(ByteArray(0)) { acc, bytes -> acc + bytes }175        return major(5, entries.size) + body176    }177    private fun integer(value: Int): ByteArray =178        if (value >= 0) major(0, value) else major(1, -1 - value)179    private fun major(type: Int, value: Int): ByteArray = when {180        value < 24 -> byteArrayOf(((type shl 5) or value).toByte())181        value < 0x100 -> byteArrayOf(((type shl 5) or 24).toByte(), value.toByte())182        value < 0x10000 -> byteArrayOf(((type shl 5) or 25).toByte(), (value ushr 8).toByte(), value.toByte())183        else -> error("cbor length too large")184    }185}186187private fun java.math.BigInteger.toFixed32(): ByteArray {188    val raw = toByteArray()189    val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw190    return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }191}