PasskeyRegistration.kt

11.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 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        // Peach's vault always permits the credential's private key to leave the50        // device via PeachScript export or paired-device sync, so every freshly51        // registered passkey is by definition backup-eligible. backupState stays52        // false: at the moment of registration the credential has not yet been53        // mirrored anywhere — that fact is written here so the BE/BS bits in the54        // authenticator data never disagree with the persisted record.55        val backupEligible = true56        val backupState = false57        val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose)58        val attestationObject = Cbor.map(59            "fmt" to Cbor.text("none"),60            "attStmt" to Cbor.map(),61            "authData" to Cbor.bytes(authData),62        )63        val clientDataJson = clientDataJson("webauthn.create", challenge, origin ?: originFromRpId(rpId))64        val passkey = PasskeyData(65            credentialId = b64(credentialId),66            publicKeyCose = b64(publicKeyCose),67            privateKeyJwk = keyMaterial.privateKeyJwk,68            rpId = rpId,69            rpName = rpName,70            userHandle = userHandle,71            userName = userName,72            userDisplayName = userDisplayName,73            algorithm = algorithm,74            signCount = 0,75            aaguid = b64(ByteArray(16)),76            backupEligible = backupEligible,77            backupState = backupState,78            transports = listOf("internal"),79            createdAt = now,80        )81        val entry = VaultEntry(82            id = "",83            type = "login",84            title = rpName,85            username = userName,86            url = "https://$rpId",87            passkey = passkey,88            createdAt = 0,89            modifiedAt = 0,90        )91        val responseJson = json.encodeToString(92            JsonObject.serializer(),93            buildJsonObject {94                put("id", passkey.credentialId)95                put("rawId", passkey.credentialId)96                put("type", "public-key")97                put("response", buildJsonObject {98                    put("clientDataJSON", b64(clientDataJson))99                    put("authenticatorData", b64(authData))100                    put("attestationObject", b64(attestationObject))101                    put("transports", buildJsonArray { add(JsonPrimitive("internal")) })102                    put("publicKey", b64(keyMaterial.publicKeySpki))103                    put("publicKeyAlgorithm", algorithm)104                })105                put("authenticatorAttachment", "platform")106                put("clientExtensionResults", buildJsonObject {})107            },108        )109        return PasskeyRegistrationResult(entryDraft = entry, responseJson = responseJson)110    }111112    private data class KeyMaterial(113        val publicKeyCose: ByteArray,114        val publicKeySpki: ByteArray,115        val privateKeyJwk: String,116    )117118    private fun pickAlgorithm(value: kotlinx.serialization.json.JsonElement?): Int? {119        val params = runCatching { value?.jsonArray }.getOrNull() ?: return -7120        val offered = params.mapNotNull { item ->121            runCatching { item.jsonObject["alg"]?.jsonPrimitive?.intOrNull }.getOrNull()122        }.toSet()123        return when {124            -8 in offered -> -8125            -7 in offered -> -7126            else -> null127        }128    }129130    private fun generateKeyMaterial(algorithm: Int): KeyMaterial =131        if (algorithm == -8) generateEd25519KeyMaterial() else generateEs256KeyMaterial()132133    private fun generateEs256KeyMaterial(): KeyMaterial {134        val keyPair = KeyPairGenerator.getInstance("EC").apply {135            initialize(ECGenParameterSpec("secp256r1"))136        }.generateKeyPair()137        val privateKey = keyPair.private as ECPrivateKey138        val publicKey = keyPair.public as ECPublicKey139        val publicKeyCose = Cbor.map(140            1 to Cbor.uint(2),141            3 to Cbor.nint(-7),142            -1 to Cbor.uint(1),143            -2 to Cbor.bytes(publicKey.w.affineX.toFixed32()),144            -3 to Cbor.bytes(publicKey.w.affineY.toFixed32()),145        )146        return KeyMaterial(147            publicKeyCose = publicKeyCose,148            publicKeySpki = publicKey.encoded,149            privateKeyJwk = privateEs256Jwk(privateKey, publicKey),150        )151    }152153    private fun generateEd25519KeyMaterial(): KeyMaterial {154        val privateKey = Ed25519PrivateKeyParameters(random)155        val publicKey = privateKey.generatePublicKey().encoded156        val seed = privateKey.encoded157        val publicKeyCose = Cbor.map(158            1 to Cbor.uint(1),159            3 to Cbor.nint(-8),160            -1 to Cbor.uint(6),161            -2 to Cbor.bytes(publicKey),162        )163        val privateKeyJwk = json.encodeToString(164            JsonObject.serializer(),165            buildJsonObject {166                put("kty", "OKP")167                put("crv", "Ed25519")168                put("d", b64(seed))169                put("x", b64(publicKey))170            },171        )172        return KeyMaterial(173            publicKeyCose = publicKeyCose,174            publicKeySpki = ed25519Spki(publicKey),175            privateKeyJwk = privateKeyJwk,176        )177    }178179    private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray): ByteArray {180        val base = ByteArray(37)181        java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)).copyInto(base, 0)182        // Registration always implies the user just confirmed (UV=1), the183        // credential is backup-eligible by construction (BE), and attested184        // credential data is being included (AT). UP=1 is implicit in the tap.185        base[32] = REGISTRATION_FLAGS.toByte()186        val length = byteArrayOf(((credentialId.size ushr 8) and 0xff).toByte(), (credentialId.size and 0xff).toByte())187        return base + ByteArray(16) + length + credentialId + publicKeyCose188    }189190    private const val FLAG_UP: Int = 0x01191    private const val FLAG_UV: Int = 0x04192    private const val FLAG_BE: Int = 0x08193    private const val FLAG_AT: Int = 0x40194    private const val REGISTRATION_FLAGS: Int = FLAG_UP or FLAG_UV or FLAG_BE or FLAG_AT195196    private fun clientDataJson(type: String, challenge: String, origin: String): ByteArray =197        json.encodeToString(198            JsonObject.serializer(),199            buildJsonObject {200                put("type", type)201                put("challenge", challenge)202                put("origin", origin)203                put("crossOrigin", JsonPrimitive(false))204            },205        ).toByteArray(Charsets.UTF_8)206207    private fun privateEs256Jwk(privateKey: ECPrivateKey, publicKey: ECPublicKey): String =208        json.encodeToString(209            JsonObject.serializer(),210            buildJsonObject {211                put("kty", "EC")212                put("crv", "P-256")213                put("d", b64(privateKey.s.toFixed32()))214                put("x", b64(publicKey.w.affineX.toFixed32()))215                put("y", b64(publicKey.w.affineY.toFixed32()))216            },217        )218219    private fun originFromRpId(rpId: String): String =220        if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") "http://$rpId" else "https://$rpId"221222    private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)223224    private fun ed25519Spki(publicKey: ByteArray): ByteArray =225        byteArrayOf(226            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,227        ) + publicKey228}229230private object Cbor {231    fun uint(value: Int) = integer(value)232    fun nint(value: Int) = integer(value)233    fun bytes(value: ByteArray) = major(2, value.size) + value234    fun text(value: String): ByteArray {235        val bytes = value.toByteArray(Charsets.UTF_8)236        return major(3, bytes.size) + bytes237    }238    fun map(vararg entries: Pair<Any, ByteArray>): ByteArray {239        val body = entries.flatMap { (key, value) ->240            val encodedKey = when (key) {241                is Int -> integer(key)242                is String -> text(key)243                else -> error("unsupported cbor key")244            }245            listOf(encodedKey, value)246        }.fold(ByteArray(0)) { acc, bytes -> acc + bytes }247        return major(5, entries.size) + body248    }249    private fun integer(value: Int): ByteArray =250        if (value >= 0) major(0, value) else major(1, -1 - value)251    private fun major(type: Int, value: Int): ByteArray = when {252        value < 24 -> byteArrayOf(((type shl 5) or value).toByte())253        value < 0x100 -> byteArrayOf(((type shl 5) or 24).toByte(), value.toByte())254        value < 0x10000 -> byteArrayOf(((type shl 5) or 25).toByte(), (value ushr 8).toByte(), value.toByte())255        else -> error("cbor length too large")256    }257}258259private fun java.math.BigInteger.toFixed32(): ByteArray {260    val raw = toByteArray()261    val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw262    return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }263}