PasskeyRegistration.kt

10.1 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 org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters12import kotlinx.serialization.json.Json13import 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        val algorithm = pickAlgorithm(request["pubKeyCredParams"]) ?: return null4546        val keyMaterial = generateKeyMaterial(algorithm)47        val credentialId = ByteArray(32).also(random::nextBytes)48        val publicKeyCose = keyMaterial.publicKeyCose49        val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose)50        val attestationObject = Cbor.map(51            "fmt" to Cbor.text("none"),52            "attStmt" to Cbor.map(),53            "authData" to Cbor.bytes(authData),54        )55        val clientDataJson = clientDataJson("webauthn.create", challenge, origin ?: originFromRpId(rpId))56        val passkey = PasskeyData(57            credentialId = b64(credentialId),58            publicKeyCose = b64(publicKeyCose),59            privateKeyJwk = keyMaterial.privateKeyJwk,60            rpId = rpId,61            rpName = rpName,62            userHandle = userHandle,63            userName = userName,64            userDisplayName = userDisplayName,65            algorithm = algorithm,66            signCount = 0,67            aaguid = b64(ByteArray(16)),68            backupEligible = false,69            backupState = false,70            transports = listOf("internal"),71            createdAt = now,72        )73        val entry = VaultEntry(74            id = "",75            type = "login",76            title = rpName,77            username = userName,78            url = "https://$rpId",79            passkey = passkey,80            createdAt = 0,81            modifiedAt = 0,82        )83        val responseJson = json.encodeToString(84            JsonObject.serializer(),85            buildJsonObject {86                put("id", passkey.credentialId)87                put("rawId", passkey.credentialId)88                put("type", "public-key")89                put("response", buildJsonObject {90                    put("clientDataJSON", b64(clientDataJson))91                    put("authenticatorData", b64(authData))92                    put("attestationObject", b64(attestationObject))93                    put("transports", buildJsonArray { add(JsonPrimitive("internal")) })94                    put("publicKey", b64(keyMaterial.publicKeySpki))95                    put("publicKeyAlgorithm", algorithm)96                })97                put("authenticatorAttachment", "platform")98                put("clientExtensionResults", buildJsonObject {})99            },100        )101        return PasskeyRegistrationResult(entryDraft = entry, responseJson = responseJson)102    }103104    private data class KeyMaterial(105        val publicKeyCose: ByteArray,106        val publicKeySpki: ByteArray,107        val privateKeyJwk: String,108    )109110    private fun pickAlgorithm(value: kotlinx.serialization.json.JsonElement?): Int? {111        val params = runCatching { value?.jsonArray }.getOrNull() ?: return -7112        val offered = params.mapNotNull { item ->113            runCatching { item.jsonObject["alg"]?.jsonPrimitive?.intOrNull }.getOrNull()114        }.toSet()115        return when {116            -8 in offered -> -8117            -7 in offered -> -7118            else -> null119        }120    }121122    private fun generateKeyMaterial(algorithm: Int): KeyMaterial =123        if (algorithm == -8) generateEd25519KeyMaterial() else generateEs256KeyMaterial()124125    private fun generateEs256KeyMaterial(): KeyMaterial {126        val keyPair = KeyPairGenerator.getInstance("EC").apply {127            initialize(ECGenParameterSpec("secp256r1"))128        }.generateKeyPair()129        val privateKey = keyPair.private as ECPrivateKey130        val publicKey = keyPair.public as ECPublicKey131        val publicKeyCose = Cbor.map(132            1 to Cbor.uint(2),133            3 to Cbor.nint(-7),134            -1 to Cbor.uint(1),135            -2 to Cbor.bytes(publicKey.w.affineX.toFixed32()),136            -3 to Cbor.bytes(publicKey.w.affineY.toFixed32()),137        )138        return KeyMaterial(139            publicKeyCose = publicKeyCose,140            publicKeySpki = publicKey.encoded,141            privateKeyJwk = privateEs256Jwk(privateKey, publicKey),142        )143    }144145    private fun generateEd25519KeyMaterial(): KeyMaterial {146        val privateKey = Ed25519PrivateKeyParameters(random)147        val publicKey = privateKey.generatePublicKey().encoded148        val seed = privateKey.encoded149        val publicKeyCose = Cbor.map(150            1 to Cbor.uint(1),151            3 to Cbor.nint(-8),152            -1 to Cbor.uint(6),153            -2 to Cbor.bytes(publicKey),154        )155        val privateKeyJwk = json.encodeToString(156            JsonObject.serializer(),157            buildJsonObject {158                put("kty", "OKP")159                put("crv", "Ed25519")160                put("d", b64(seed))161                put("x", b64(publicKey))162            },163        )164        return KeyMaterial(165            publicKeyCose = publicKeyCose,166            publicKeySpki = ed25519Spki(publicKey),167            privateKeyJwk = privateKeyJwk,168        )169    }170171    private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray): ByteArray {172        val base = ByteArray(37)173        java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)).copyInto(base, 0)174        base[32] = 0x4d175        val length = byteArrayOf(((credentialId.size ushr 8) and 0xff).toByte(), (credentialId.size and 0xff).toByte())176        return base + ByteArray(16) + length + credentialId + publicKeyCose177    }178179    private fun clientDataJson(type: String, challenge: String, origin: String): ByteArray =180        json.encodeToString(181            JsonObject.serializer(),182            buildJsonObject {183                put("type", type)184                put("challenge", challenge)185                put("origin", origin)186                put("crossOrigin", JsonPrimitive(false))187            },188        ).toByteArray(Charsets.UTF_8)189190    private fun privateEs256Jwk(privateKey: ECPrivateKey, publicKey: ECPublicKey): String =191        json.encodeToString(192            JsonObject.serializer(),193            buildJsonObject {194                put("kty", "EC")195                put("crv", "P-256")196                put("d", b64(privateKey.s.toFixed32()))197                put("x", b64(publicKey.w.affineX.toFixed32()))198                put("y", b64(publicKey.w.affineY.toFixed32()))199            },200        )201202    private fun originFromRpId(rpId: String): String =203        if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") "http://$rpId" else "https://$rpId"204205    private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)206207    private fun ed25519Spki(publicKey: ByteArray): ByteArray =208        byteArrayOf(209            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,210        ) + publicKey211}212213private object Cbor {214    fun uint(value: Int) = integer(value)215    fun nint(value: Int) = integer(value)216    fun bytes(value: ByteArray) = major(2, value.size) + value217    fun text(value: String): ByteArray {218        val bytes = value.toByteArray(Charsets.UTF_8)219        return major(3, bytes.size) + bytes220    }221    fun map(vararg entries: Pair<Any, ByteArray>): ByteArray {222        val body = entries.flatMap { (key, value) ->223            val encodedKey = when (key) {224                is Int -> integer(key)225                is String -> text(key)226                else -> error("unsupported cbor key")227            }228            listOf(encodedKey, value)229        }.fold(ByteArray(0)) { acc, bytes -> acc + bytes }230        return major(5, entries.size) + body231    }232    private fun integer(value: Int): ByteArray =233        if (value >= 0) major(0, value) else major(1, -1 - value)234    private fun major(type: Int, value: Int): ByteArray = when {235        value < 24 -> byteArrayOf(((type shl 5) or value).toByte())236        value < 0x100 -> byteArrayOf(((type shl 5) or 24).toByte(), value.toByte())237        value < 0x10000 -> byteArrayOf(((type shl 5) or 25).toByte(), (value ushr 8).toByte(), value.toByte())238        else -> error("cbor length too large")239    }240}241242private fun java.math.BigInteger.toFixed32(): ByteArray {243    val raw = toByteArray()244    val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw245    return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }246}