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 46 47 48 49 val callerOrigin = origin?.takeIf { it.isNotBlank() } ?: return null50 if (PasskeyCallerOrigin.isPlaintextWebOrigin(callerOrigin)) return null51 val rp = request["rp"]?.jsonObject ?: return null52 val user = request["user"]?.jsonObject ?: return null53 val rpId = rp["id"]?.jsonPrimitive?.contentOrNull ?: return null54 if (PasskeyCallerOrigin.isWebOrigin(callerOrigin) &&55 !PasskeyCallerOrigin.rpIdMatchesOrigin(rpId, callerOrigin)56 ) {57 return null58 }59 val rpName = rp["name"]?.jsonPrimitive?.contentOrNull ?: rpId60 val userHandle = user["id"]?.jsonPrimitive?.contentOrNull ?: return null61 val userName = user["name"]?.jsonPrimitive?.contentOrNull ?: return null62 val userDisplayName = user["displayName"]?.jsonPrimitive?.contentOrNull ?: userName63 val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null64 val algorithm = pickAlgorithm(request["pubKeyCredParams"]) ?: return null6566 val keyMaterial = generateKeyMaterial(algorithm)67 val credentialId = ByteArray(32).also(random::nextBytes)68 val publicKeyCose = keyMaterial.publicKeyCose69 70 71 72 73 74 75 val backupEligible = true76 val backupState = false77 val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose, registrationFlags(userVerified))78 val attestationObject = Cbor.map(79 "fmt" to Cbor.text("none"),80 "attStmt" to Cbor.map(),81 "authData" to Cbor.bytes(authData),82 )83 val clientDataJson = clientDataJson("webauthn.create", challenge, callerOrigin)84 val passkey = PasskeyData(85 credentialId = b64(credentialId),86 publicKeyCose = b64(publicKeyCose),87 privateKeyJwk = keyMaterial.privateKeyJwk,88 rpId = rpId,89 rpName = rpName,90 userHandle = userHandle,91 userName = userName,92 userDisplayName = userDisplayName,93 algorithm = algorithm,94 signCount = 0,95 aaguid = b64(ByteArray(16)),96 backupEligible = backupEligible,97 backupState = backupState,98 transports = listOf("internal"),99 createdAt = now,100 callerOrigin = callerOrigin,101 )102 val entry = VaultEntry(103 id = "",104 type = "login",105 title = rpName,106 username = userName,107 url = "https://$rpId",108 passkey = passkey,109 createdAt = 0,110 modifiedAt = 0,111 )112 val responseJson = json.encodeToString(113 JsonObject.serializer(),114 buildJsonObject {115 put("id", passkey.credentialId)116 put("rawId", passkey.credentialId)117 put("type", "public-key")118 put("response", buildJsonObject {119 put("clientDataJSON", b64(clientDataJson))120 put("authenticatorData", b64(authData))121 put("attestationObject", b64(attestationObject))122 put("transports", buildJsonArray { add(JsonPrimitive("internal")) })123 put("publicKey", b64(keyMaterial.publicKeySpki))124 put("publicKeyAlgorithm", algorithm)125 })126 put("authenticatorAttachment", "platform")127 put("clientExtensionResults", buildJsonObject {})128 },129 )130 return PasskeyRegistrationResult(entryDraft = entry, responseJson = responseJson)131 }132133 private data class KeyMaterial(134 val publicKeyCose: ByteArray,135 val publicKeySpki: ByteArray,136 val privateKeyJwk: String,137 )138139 private fun pickAlgorithm(value: kotlinx.serialization.json.JsonElement?): Int? {140 val params = runCatching { value?.jsonArray }.getOrNull() ?: return -7141 val offered = params.mapNotNull { item ->142 runCatching { item.jsonObject["alg"]?.jsonPrimitive?.intOrNull }.getOrNull()143 }.toSet()144 return when {145 -8 in offered -> -8146 -7 in offered -> -7147 else -> null148 }149 }150151 private fun generateKeyMaterial(algorithm: Int): KeyMaterial =152 if (algorithm == -8) generateEd25519KeyMaterial() else generateEs256KeyMaterial()153154 private fun generateEs256KeyMaterial(): KeyMaterial {155 val keyPair = KeyPairGenerator.getInstance("EC").apply {156 initialize(ECGenParameterSpec("secp256r1"))157 }.generateKeyPair()158 val privateKey = keyPair.private as ECPrivateKey159 val publicKey = keyPair.public as ECPublicKey160 val publicKeyCose = Cbor.map(161 1 to Cbor.uint(2),162 3 to Cbor.nint(-7),163 -1 to Cbor.uint(1),164 -2 to Cbor.bytes(publicKey.w.affineX.toFixed32()),165 -3 to Cbor.bytes(publicKey.w.affineY.toFixed32()),166 )167 return KeyMaterial(168 publicKeyCose = publicKeyCose,169 publicKeySpki = publicKey.encoded,170 privateKeyJwk = privateEs256Jwk(privateKey, publicKey),171 )172 }173174 private fun generateEd25519KeyMaterial(): KeyMaterial {175 val privateKey = Ed25519PrivateKeyParameters(random)176 val publicKey = privateKey.generatePublicKey().encoded177 val seed = privateKey.encoded178 val publicKeyCose = Cbor.map(179 1 to Cbor.uint(1),180 3 to Cbor.nint(-8),181 -1 to Cbor.uint(6),182 -2 to Cbor.bytes(publicKey),183 )184 val privateKeyJwk = json.encodeToString(185 JsonObject.serializer(),186 buildJsonObject {187 put("kty", "OKP")188 put("crv", "Ed25519")189 put("d", b64(seed))190 put("x", b64(publicKey))191 },192 )193 return KeyMaterial(194 publicKeyCose = publicKeyCose,195 publicKeySpki = ed25519Spki(publicKey),196 privateKeyJwk = privateKeyJwk,197 )198 }199200 private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray, flags: Int): ByteArray {201 val base = ByteArray(37)202 java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)).copyInto(base, 0)203 204 205 206 207 208 209 base[32] = flags.toByte()210 val length = byteArrayOf(((credentialId.size ushr 8) and 0xff).toByte(), (credentialId.size and 0xff).toByte())211 return base + ByteArray(16) + length + credentialId + publicKeyCose212 }213214 private const val FLAG_UP: Int = 0x01215 private const val FLAG_UV: Int = 0x04216 private const val FLAG_BE: Int = 0x08217 private const val FLAG_AT: Int = 0x40218219 220 221 222 223 224 private fun registrationFlags(userVerified: Boolean): Int =225 FLAG_UP or FLAG_BE or FLAG_AT or (if (userVerified) FLAG_UV else 0)226227 private fun clientDataJson(type: String, challenge: String, origin: String): ByteArray =228 json.encodeToString(229 JsonObject.serializer(),230 buildJsonObject {231 put("type", type)232 put("challenge", challenge)233 put("origin", origin)234 put("crossOrigin", JsonPrimitive(false))235 },236 ).toByteArray(Charsets.UTF_8)237238 private fun privateEs256Jwk(privateKey: ECPrivateKey, publicKey: ECPublicKey): String =239 json.encodeToString(240 JsonObject.serializer(),241 buildJsonObject {242 put("kty", "EC")243 put("crv", "P-256")244 put("d", b64(privateKey.s.toFixed32()))245 put("x", b64(publicKey.w.affineX.toFixed32()))246 put("y", b64(publicKey.w.affineY.toFixed32()))247 },248 )249250 private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data)251252 private fun ed25519Spki(publicKey: ByteArray): ByteArray =253 byteArrayOf(254 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,255 ) + publicKey256}257258private object Cbor {259 fun uint(value: Int) = integer(value)260 fun nint(value: Int) = integer(value)261 fun bytes(value: ByteArray) = major(2, value.size) + value262 fun text(value: String): ByteArray {263 val bytes = value.toByteArray(Charsets.UTF_8)264 return major(3, bytes.size) + bytes265 }266 fun map(vararg entries: Pair<Any, ByteArray>): ByteArray {267 val body = entries.flatMap { (key, value) ->268 val encodedKey = when (key) {269 is Int -> integer(key)270 is String -> text(key)271 else -> error("unsupported cbor key")272 }273 listOf(encodedKey, value)274 }.fold(ByteArray(0)) { acc, bytes -> acc + bytes }275 return major(5, entries.size) + body276 }277 private fun integer(value: Int): ByteArray =278 if (value >= 0) major(0, value) else major(1, -1 - value)279 private fun major(type: Int, value: Int): ByteArray = when {280 value < 24 -> byteArrayOf(((type shl 5) or value).toByte())281 value < 0x100 -> byteArrayOf(((type shl 5) or 24).toByte(), value.toByte())282 value < 0x10000 -> byteArrayOf(((type shl 5) or 25).toByte(), (value ushr 8).toByte(), value.toByte())283 else -> error("cbor length too large")284 }285}286287private fun java.math.BigInteger.toFixed32(): ByteArray {288 val raw = toByteArray()289 val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw290 return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) }291}