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 50 51 52 53 54 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 183 184 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}