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(35 requestJson: String,36 origin: String?,37 now: Long = System.currentTimeMillis(),38 39 40 41 42 userVerified: Boolean = false,43 ): PasskeyRegistrationResult? {44 val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null45 val rp = request["rp"]?.jsonObject ?: return null46 val user = request["user"]?.jsonObject ?: return null47 val rpId = rp["id"]?.jsonPrimitive?.contentOrNull ?: return null48 val rpName = rp["name"]?.jsonPrimitive?.contentOrNull ?: rpId49 val userHandle = user["id"]?.jsonPrimitive?.contentOrNull ?: return null50 val userName = user["name"]?.jsonPrimitive?.contentOrNull ?: return null51 val userDisplayName = user["displayName"]?.jsonPrimitive?.contentOrNull ?: userName52 val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null53 val algorithm = pickAlgorithm(request["pubKeyCredParams"]) ?: return null5455 val keyMaterial = generateKeyMaterial(algorithm)56 val credentialId = ByteArray(32).also(random::nextBytes)57 val publicKeyCose = keyMaterial.publicKeyCose58 59 60 61 62 63 64 val backupEligible = true65 val backupState = false66 val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose, registrationFlags(userVerified))67 val attestationObject = Cbor.map(68 "fmt" to Cbor.text("none"),69 "attStmt" to Cbor.map(),70 "authData" to Cbor.bytes(authData),71 )72 val clientDataJson = clientDataJson("webauthn.create", challenge, origin ?: originFromRpId(rpId))73 val passkey = PasskeyData(74 credentialId = b64(credentialId),75 publicKeyCose = b64(publicKeyCose),76 privateKeyJwk = keyMaterial.privateKeyJwk,77 rpId = rpId,78 rpName = rpName,79 userHandle = userHandle,80 userName = userName,81 userDisplayName = userDisplayName,82 algorithm = algorithm,83 signCount = 0,84 aaguid = b64(ByteArray(16)),85 backupEligible = backupEligible,86 backupState = backupState,87 transports = listOf("internal"),88 createdAt = now,89 )90 val entry = VaultEntry(91 id = "",92 type = "login",93 title = rpName,94 username = userName,95 url = "https://$rpId",96 passkey = passkey,97 createdAt = 0,98 modifiedAt = 0,99 )100 val responseJson = json.encodeToString(101 JsonObject.serializer(),102 buildJsonObject {103 put("id", passkey.credentialId)104 put("rawId", passkey.credentialId)105 put("type", "public-key")106 put("response", buildJsonObject {107 put("clientDataJSON", b64(clientDataJson))108 put("authenticatorData", b64(authData))109 put("attestationObject", b64(attestationObject))110 put("transports", buildJsonArray { add(JsonPrimitive("internal")) })111 put("publicKey", b64(keyMaterial.publicKeySpki))112 put("publicKeyAlgorithm", algorithm)113 })114 put("authenticatorAttachment", "platform")115 put("clientExtensionResults", buildJsonObject {})116 },117 )118 return PasskeyRegistrationResult(entryDraft = entry, responseJson = responseJson)119 }120121 private data class KeyMaterial(122 val publicKeyCose: ByteArray,123 val publicKeySpki: ByteArray,124 val privateKeyJwk: String,125 )126127 private fun pickAlgorithm(value: kotlinx.serialization.json.JsonElement?): Int? {128 val params = runCatching { value?.jsonArray }.getOrNull() ?: return -7129 val offered = params.mapNotNull { item ->130 runCatching { item.jsonObject["alg"]?.jsonPrimitive?.intOrNull }.getOrNull()131 }.toSet()132 return when {133 -8 in offered -> -8134 -7 in offered -> -7135 else -> null136 }137 }138139 private fun generateKeyMaterial(algorithm: Int): KeyMaterial =140 if (algorithm == -8) generateEd25519KeyMaterial() else generateEs256KeyMaterial()141142 private fun generateEs256KeyMaterial(): KeyMaterial {143 val keyPair = KeyPairGenerator.getInstance("EC").apply {144 initialize(ECGenParameterSpec("secp256r1"))145 }.generateKeyPair()146 val privateKey = keyPair.private as ECPrivateKey147 val publicKey = keyPair.public as ECPublicKey148 val publicKeyCose = Cbor.map(149 1 to Cbor.uint(2),150 3 to Cbor.nint(-7),151 -1 to Cbor.uint(1),152 -2 to Cbor.bytes(publicKey.w.affineX.toFixed32()),153 -3 to Cbor.bytes(publicKey.w.affineY.toFixed32()),154 )155 return KeyMaterial(156 publicKeyCose = publicKeyCose,157 publicKeySpki = publicKey.encoded,158 privateKeyJwk = privateEs256Jwk(privateKey, publicKey),159 )160 }161162 private fun generateEd25519KeyMaterial(): KeyMaterial {163 val privateKey = Ed25519PrivateKeyParameters(random)164 val publicKey = privateKey.generatePublicKey().encoded165 val seed = privateKey.encoded166 val publicKeyCose = Cbor.map(167 1 to Cbor.uint(1),168 3 to Cbor.nint(-8),169 -1 to Cbor.uint(6),170 -2 to Cbor.bytes(publicKey),171 )172 val privateKeyJwk = json.encodeToString(173 JsonObject.serializer(),174 buildJsonObject {175 put("kty", "OKP")176 put("crv", "Ed25519")177 put("d", b64(seed))178 put("x", b64(publicKey))179 },180 )181 return KeyMaterial(182 publicKeyCose = publicKeyCose,183 publicKeySpki = ed25519Spki(publicKey),184 privateKeyJwk = privateKeyJwk,185 )186 }187188 private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray, flags: Int): ByteArray {189 val base = ByteArray(37)190 java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)).copyInto(base, 0)191 192 193 194 195 196 197 base[32] = flags.toByte()198 val length = byteArrayOf(((credentialId.size ushr 8) and 0xff).toByte(), (credentialId.size and 0xff).toByte())199 return base + ByteArray(16) + length + credentialId + publicKeyCose200 }201202 private const val FLAG_UP: Int = 0x01203 private const val FLAG_UV: Int = 0x04204 private const val FLAG_BE: Int = 0x08205 private const val FLAG_AT: Int = 0x40206207 208 209 210 211 212 private fun registrationFlags(userVerified: Boolean): Int =213 FLAG_UP or FLAG_BE or FLAG_AT or (if (userVerified) FLAG_UV else 0)214215 private fun clientDataJson(type: String, challenge: String, origin: String): ByteArray =216 json.encodeToString(217 JsonObject.serializer(),218 buildJsonObject {219 put("type", type)220 put("challenge", challenge)221 put("origin", origin)222 put("crossOrigin", JsonPrimitive(false))223 },224 ).toByteArray(Charsets.UTF_8)225226 private fun privateEs256Jwk(privateKey: ECPrivateKey, publicKey: ECPublicKey): String =227 json.encodeToString(228 JsonObject.serializer(),229 buildJsonObject {230 put("kty", "EC")231 put("crv", "P-256")232 put("d", b64(privateKey.s.toFixed32()))233 put("x", b64(publicKey.w.affineX.toFixed32()))234 put("y", b64(publicKey.w.affineY.toFixed32()))235 },236 )237238 private fun originFromRpId(rpId: String): String =239 if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") "http://$rpId" else "https://$rpId"240241 private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)242243 private fun ed25519Spki(publicKey: ByteArray): ByteArray =244 byteArrayOf(245 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,246 ) + publicKey247}248249private object Cbor {250 fun uint(value: Int) = integer(value)251 fun nint(value: Int) = integer(value)252 fun bytes(value: ByteArray) = major(2, value.size) + value253 fun text(value: String): ByteArray {254 val bytes = value.toByteArray(Charsets.UTF_8)255 return major(3, bytes.size) + bytes256 }257 fun map(vararg entries: Pair<Any, ByteArray>): ByteArray {258 val body = entries.flatMap { (key, value) ->259 val encodedKey = when (key) {260 is Int -> integer(key)261 is String -> text(key)262 else -> error("unsupported cbor key")263 }264 listOf(encodedKey, value)265 }.fold(ByteArray(0)) { acc, bytes -> acc + bytes }266 return major(5, entries.size) + body267 }268 private fun integer(value: Int): ByteArray =269 if (value >= 0) major(0, value) else major(1, -1 - value)270 private fun major(type: Int, value: Int): ByteArray = when {271 value < 24 -> byteArrayOf(((type shl 5) or value).toByte())272 value < 0x100 -> byteArrayOf(((type shl 5) or 24).toByte(), value.toByte())273 value < 0x10000 -> byteArrayOf(((type shl 5) or 25).toByte(), (value ushr 8).toByte(), value.toByte())274 else -> error("cbor length too large")275 }276}277278private fun java.math.BigInteger.toFixed32(): ByteArray {279 val raw = toByteArray()280 val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw281 return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }282}