package com.peachpasswords.passkeys import com.peachpasswords.vault.PasskeyData import com.peachpasswords.vault.VaultEntry import java.security.KeyPairGenerator import java.security.SecureRandom import java.security.interfaces.ECPrivateKey import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec import java.util.Base64 import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put data class PasskeyRegistrationResult( val entryDraft: VaultEntry, val responseJson: String, ) object PasskeyRegistration { private val json = Json { ignoreUnknownKeys = true } private val random = SecureRandom() private val urlEncoder = Base64.getUrlEncoder().withoutPadding() fun build(requestJson: String, origin: String?, now: Long = System.currentTimeMillis()): PasskeyRegistrationResult? { val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null val rp = request["rp"]?.jsonObject ?: return null val user = request["user"]?.jsonObject ?: return null val rpId = rp["id"]?.jsonPrimitive?.contentOrNull ?: return null val rpName = rp["name"]?.jsonPrimitive?.contentOrNull ?: rpId val userHandle = user["id"]?.jsonPrimitive?.contentOrNull ?: return null val userName = user["name"]?.jsonPrimitive?.contentOrNull ?: return null val userDisplayName = user["displayName"]?.jsonPrimitive?.contentOrNull ?: userName val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null if (!offersEs256(request["pubKeyCredParams"])) return null val keyPair = KeyPairGenerator.getInstance("EC").apply { initialize(ECGenParameterSpec("secp256r1")) }.generateKeyPair() val privateKey = keyPair.private as ECPrivateKey val publicKey = keyPair.public as ECPublicKey val credentialId = ByteArray(32).also(random::nextBytes) val publicKeyCose = Cbor.map( 1 to Cbor.uint(2), 3 to Cbor.nint(-7), -1 to Cbor.uint(1), -2 to Cbor.bytes(publicKey.w.affineX.toFixed32()), -3 to Cbor.bytes(publicKey.w.affineY.toFixed32()), ) val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose) val attestationObject = Cbor.map( "fmt" to Cbor.text("none"), "attStmt" to Cbor.map(), "authData" to Cbor.bytes(authData), ) val clientDataJson = clientDataJson("webauthn.create", challenge, origin ?: originFromRpId(rpId)) val passkey = PasskeyData( credentialId = b64(credentialId), publicKeyCose = b64(publicKeyCose), privateKeyJwk = privateJwk(privateKey, publicKey), rpId = rpId, rpName = rpName, userHandle = userHandle, userName = userName, userDisplayName = userDisplayName, algorithm = -7, signCount = 0, aaguid = b64(ByteArray(16)), backupEligible = false, backupState = false, transports = listOf("internal"), createdAt = now, ) val entry = VaultEntry( id = "", type = "login", title = rpName, username = userName, url = "https://$rpId", passkey = passkey, createdAt = 0, modifiedAt = 0, ) val responseJson = json.encodeToString( JsonObject.serializer(), buildJsonObject { put("id", passkey.credentialId) put("rawId", passkey.credentialId) put("type", "public-key") put("response", buildJsonObject { put("clientDataJSON", b64(clientDataJson)) put("authenticatorData", b64(authData)) put("attestationObject", b64(attestationObject)) put("transports", buildJsonArray { add(JsonPrimitive("internal")) }) put("publicKey", b64(publicKey.encoded)) put("publicKeyAlgorithm", -7) }) put("authenticatorAttachment", "platform") put("clientExtensionResults", buildJsonObject {}) }, ) return PasskeyRegistrationResult(entryDraft = entry, responseJson = responseJson) } private fun offersEs256(value: kotlinx.serialization.json.JsonElement?): Boolean { val params = runCatching { value?.jsonArray }.getOrNull() ?: return true return params.any { item -> runCatching { item.jsonObject["alg"]?.jsonPrimitive?.intOrNull == -7 }.getOrDefault(false) } } private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray): ByteArray { val base = ByteArray(37) java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)).copyInto(base, 0) base[32] = 0x4d val length = byteArrayOf(((credentialId.size ushr 8) and 0xff).toByte(), (credentialId.size and 0xff).toByte()) return base + ByteArray(16) + length + credentialId + publicKeyCose } private fun clientDataJson(type: String, challenge: String, origin: String): ByteArray = json.encodeToString( JsonObject.serializer(), buildJsonObject { put("type", type) put("challenge", challenge) put("origin", origin) put("crossOrigin", JsonPrimitive(false)) }, ).toByteArray(Charsets.UTF_8) private fun privateJwk(privateKey: ECPrivateKey, publicKey: ECPublicKey): String = json.encodeToString( JsonObject.serializer(), buildJsonObject { put("kty", "EC") put("crv", "P-256") put("d", b64(privateKey.s.toFixed32())) put("x", b64(publicKey.w.affineX.toFixed32())) put("y", b64(publicKey.w.affineY.toFixed32())) }, ) private fun originFromRpId(rpId: String): String = if (rpId.equals("localhost", ignoreCase = true) || rpId == "127.0.0.1" || rpId == "[::1]") "http://$rpId" else "https://$rpId" private fun b64(data: ByteArray): String = urlEncoder.encodeToString(data) } private object Cbor { fun uint(value: Int) = integer(value) fun nint(value: Int) = integer(value) fun bytes(value: ByteArray) = major(2, value.size) + value fun text(value: String): ByteArray { val bytes = value.toByteArray(Charsets.UTF_8) return major(3, bytes.size) + bytes } fun map(vararg entries: Pair): ByteArray { val body = entries.flatMap { (key, value) -> val encodedKey = when (key) { is Int -> integer(key) is String -> text(key) else -> error("unsupported cbor key") } listOf(encodedKey, value) }.fold(ByteArray(0)) { acc, bytes -> acc + bytes } return major(5, entries.size) + body } private fun integer(value: Int): ByteArray = if (value >= 0) major(0, value) else major(1, -1 - value) private fun major(type: Int, value: Int): ByteArray = when { value < 24 -> byteArrayOf(((type shl 5) or value).toByte()) value < 0x100 -> byteArrayOf(((type shl 5) or 24).toByte(), value.toByte()) value < 0x10000 -> byteArrayOf(((type shl 5) or 25).toByte(), (value ushr 8).toByte(), value.toByte()) else -> error("cbor length too large") } } private fun java.math.BigInteger.toFixed32(): ByteArray { val raw = toByteArray() val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw return ByteArray(32).also { unsigned.copyInto(it, 32 - unsigned.size) } }