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 org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters import kotlinx.serialization.json.Json 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(), // Set to true only when a biometric / device-credential prompt has // actually succeeded for this registration. Defaults to false so // callers that bypass the prompt still produce a valid flag byte // (UV=0) rather than the old hard-coded UV=1 claim. userVerified: Boolean = false, ): 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 val algorithm = pickAlgorithm(request["pubKeyCredParams"]) ?: return null val keyMaterial = generateKeyMaterial(algorithm) val credentialId = ByteArray(32).also(random::nextBytes) val publicKeyCose = keyMaterial.publicKeyCose // Peach's vault always permits the credential's private key to leave the // device via PeachScript export or paired-device sync, so every freshly // registered passkey is by definition backup-eligible. backupState stays // false: at the moment of registration the credential has not yet been // mirrored anywhere — that fact is written here so the BE/BS bits in the // authenticator data never disagree with the persisted record. val backupEligible = true val backupState = false val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose, registrationFlags(userVerified)) 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 = keyMaterial.privateKeyJwk, rpId = rpId, rpName = rpName, userHandle = userHandle, userName = userName, userDisplayName = userDisplayName, algorithm = algorithm, signCount = 0, aaguid = b64(ByteArray(16)), backupEligible = backupEligible, backupState = backupState, 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(keyMaterial.publicKeySpki)) put("publicKeyAlgorithm", algorithm) }) put("authenticatorAttachment", "platform") put("clientExtensionResults", buildJsonObject {}) }, ) return PasskeyRegistrationResult(entryDraft = entry, responseJson = responseJson) } private data class KeyMaterial( val publicKeyCose: ByteArray, val publicKeySpki: ByteArray, val privateKeyJwk: String, ) private fun pickAlgorithm(value: kotlinx.serialization.json.JsonElement?): Int? { val params = runCatching { value?.jsonArray }.getOrNull() ?: return -7 val offered = params.mapNotNull { item -> runCatching { item.jsonObject["alg"]?.jsonPrimitive?.intOrNull }.getOrNull() }.toSet() return when { -8 in offered -> -8 -7 in offered -> -7 else -> null } } private fun generateKeyMaterial(algorithm: Int): KeyMaterial = if (algorithm == -8) generateEd25519KeyMaterial() else generateEs256KeyMaterial() private fun generateEs256KeyMaterial(): KeyMaterial { val keyPair = KeyPairGenerator.getInstance("EC").apply { initialize(ECGenParameterSpec("secp256r1")) }.generateKeyPair() val privateKey = keyPair.private as ECPrivateKey val publicKey = keyPair.public as ECPublicKey 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()), ) return KeyMaterial( publicKeyCose = publicKeyCose, publicKeySpki = publicKey.encoded, privateKeyJwk = privateEs256Jwk(privateKey, publicKey), ) } private fun generateEd25519KeyMaterial(): KeyMaterial { val privateKey = Ed25519PrivateKeyParameters(random) val publicKey = privateKey.generatePublicKey().encoded val seed = privateKey.encoded val publicKeyCose = Cbor.map( 1 to Cbor.uint(1), 3 to Cbor.nint(-8), -1 to Cbor.uint(6), -2 to Cbor.bytes(publicKey), ) val privateKeyJwk = json.encodeToString( JsonObject.serializer(), buildJsonObject { put("kty", "OKP") put("crv", "Ed25519") put("d", b64(seed)) put("x", b64(publicKey)) }, ) return KeyMaterial( publicKeyCose = publicKeyCose, publicKeySpki = ed25519Spki(publicKey), privateKeyJwk = privateKeyJwk, ) } private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray, flags: Int): ByteArray { val base = ByteArray(37) java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)).copyInto(base, 0) // UP=1 (implicit in the credential pick); BE set because Peach vaults // are exportable/syncable by construction; AT set because attested // credential data is always included at registration; UV reflects an // actual user-verification ceremony performed by the caller — never // assume it. H-07 fix: the flag byte used to hard-code UV=1 without // running any ceremony, which is precisely the misleading-claim bug. base[32] = flags.toByte() val length = byteArrayOf(((credentialId.size ushr 8) and 0xff).toByte(), (credentialId.size and 0xff).toByte()) return base + ByteArray(16) + length + credentialId + publicKeyCose } private const val FLAG_UP: Int = 0x01 private const val FLAG_UV: Int = 0x04 private const val FLAG_BE: Int = 0x08 private const val FLAG_AT: Int = 0x40 // AT (0x40) is always included for registration because the response // carries attested credential data; BE (0x08) is set because Peach // vaults are exportable/syncable by construction; UP (0x01) is implicit // in the user picking a credential. UV (0x04) is set only when the // caller actually performed user verification. private fun registrationFlags(userVerified: Boolean): Int = FLAG_UP or FLAG_BE or FLAG_AT or (if (userVerified) FLAG_UV else 0) 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 privateEs256Jwk(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 fun ed25519Spki(publicKey: ByteArray): ByteArray = byteArrayOf( 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, ) + publicKey } 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) } }