PasskeyRegistration.kt

12.6 KB · raw

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        // Set to true only when a biometric / device-credential prompt has39        // actually succeeded for this registration. Defaults to false so40        // callers that bypass the prompt still produce a valid flag byte41        // (UV=0) rather than the old hard-coded UV=1 claim.42        userVerified: Boolean = false,43    ): PasskeyRegistrationResult? {44        val request = runCatching { json.parseToJsonElement(requestJson).jsonObject }.getOrNull() ?: return null45        // F2 fail-closed: clientDataJSON carries ONLY the framework-verified46        // caller origin — never a synthesized https://<rpId>. F1: web origins47        // must sit inside the rpId's registrable domain; native-app origins48        // (android:apk-key-hash:*) bind the credential to the calling app.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        // Peach's vault always permits the credential's private key to leave the70        // device via PeachScript export or paired-device sync, so every freshly71        // registered passkey is by definition backup-eligible. backupState stays72        // false: at the moment of registration the credential has not yet been73        // mirrored anywhere — that fact is written here so the BE/BS bits in the74        // authenticator data never disagree with the persisted record.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        // UP=1 (implicit in the credential pick); BE set because Peach vaults204        // are exportable/syncable by construction; AT set because attested205        // credential data is always included at registration; UV reflects an206        // actual user-verification ceremony performed by the caller — never207        // assume it. H-07 fix: the flag byte used to hard-code UV=1 without208        // running any ceremony, which is precisely the misleading-claim bug.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    // AT (0x40) is always included for registration because the response220    // carries attested credential data; BE (0x08) is set because Peach221    // vaults are exportable/syncable by construction; UP (0x01) is implicit222    // in the user picking a credential. UV (0x04) is set only when the223    // caller actually performed user verification.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}