PeachPayloadCodec.kt

31.5 KB · raw

1package com.peachpasswords.vault23import com.github.luben.zstd.Zstd4import org.bouncycastle.crypto.digests.Blake3Digest5import org.bouncycastle.crypto.generators.Argon2BytesGenerator6import org.bouncycastle.crypto.params.Argon2Parameters7import java.io.ByteArrayOutputStream8import java.security.SecureRandom9import javax.crypto.Cipher10import javax.crypto.spec.GCMParameterSpec11import javax.crypto.spec.SecretKeySpec12import kotlinx.serialization.json.Json13import kotlinx.serialization.json.JsonObject14import kotlinx.serialization.json.JsonObjectBuilder15import kotlinx.serialization.json.JsonPrimitive16import kotlinx.serialization.json.add17import kotlinx.serialization.json.booleanOrNull18import kotlinx.serialization.json.buildJsonArray19import kotlinx.serialization.json.buildJsonObject20import kotlinx.serialization.json.contentOrNull21import kotlinx.serialization.json.intOrNull22import kotlinx.serialization.json.jsonArray23import kotlinx.serialization.json.jsonObject24import kotlinx.serialization.json.jsonPrimitive25import kotlinx.serialization.json.longOrNull2627/**28 * PeachScript binary payload (`PCHS`) — a byte-for-byte port of the browser's29 * `peachPayload.ts` (MF-2). Produces and consumes the encrypted envelope30 * `[ salt(16) | nonce(12) | ciphertext | tag(16) ]` whose plaintext is31 * `[ FORMAT_V3 | inner_len(LE) | inner | zero-pad-to-bucket ]`,32 * `inner = [ body | blake3(body)[0..4) ]`,33 * `body = [ MAGIC "PCHS"(BE) | VERSION | sectionCount | reserved(2) | section* ]`,34 * each section `[ type | rawLen(LE) | compLen(LE) | zstd(level 19) ]`.35 *36 * The output of [buildPayload] is importable by the browser, and [parsePayload]37 * reads browser-produced payloads. Verified by a real browser-fixture KAT38 * (PeachScriptTest). Library notes are in app/drafts/MF1MF2/BROWSER-SPEC.md.39 */40object PeachPayloadCodec {4142    // ---- header / section constants (peachPayload.ts:48-124) --------------43    private const val MAGIC = 0x50434853          // "PCHS" big-endian44    private const val VERSION = 0x0145    private const val HEADER_LEN = 846    private const val CHECKSUM_LEN = 447    private const val SALT_LEN = 1648    private const val NONCE_LEN = 1249    private const val TAG_LEN = 165051    private val SIZE_BUCKETS = intArrayOf(1024, 4096, 16384, 65536, 262144)5253    private const val FORMAT_V2 = 0x0254    private const val FORMAT_V3 = 0x035556    private const val ARGON2_MEMORY_KIB = 6553657    private const val ARGON2_ITERATIONS = 358    private const val ARGON2_PARALLELISM = 45960    private const val SECTION_ENTRIES = 0x0161    private const val SECTION_SETTINGS = 0x0262    private const val SECTION_LICENSE = 0x0363    private const val SECTION_RECALL = 0x0464    private const val SECTION_DECLINED_CARDS = 0x056566    private const val ENTRY_TYPE_LOGIN = 0x0167    private const val ENTRY_TYPE_NOTE = 0x0268    private const val ENTRY_TYPE_CARD = 0x0369    private const val ENTRY_TYPE_PASSKEY = 0x047071    private const val F_HAS_TOTP = 1 shl 072    private const val F_HAS_TAGS = 1 shl 173    private const val F_HAS_NOTES = 1 shl 274    private const val F_HAS_CANARY = 1 shl 375    private const val F_HAS_PWCHANGED = 1 shl 476    private const val F_HAS_BREACH = 1 shl 577    private const val F_HAS_ADDITIONAL_URLS = 1 shl 678    private const val F_HAS_DELETED = 1 shl 77980    private const val F2_HAS_EMAIL = 1 shl 08182    private val NETWORK_VALUES = listOf("visa", "mastercard", "amex", "discover", "unknown")8384    private const val ZSTD_LEVEL = 1985    private val secureRandom = SecureRandom()86    private val json = Json { ignoreUnknownKeys = true; encodeDefaults = false }8788    class PeachScriptException(message: String) : Exception(message)8990    // ---- public API -------------------------------------------------------9192    /** Build the encrypted envelope `salt||nonce||ct||tag` from [entries]. */93    fun buildPayload(entries: List<VaultEntry>, passphrase: String): ByteArray {94        // sectionCount = 1 (ENTRIES). The browser also emits SETTINGS etc.; Android95        // export carries entries only — a valid payload the browser reads (it skips96        // absent sections). See BROWSER-SPEC §B2.11.97        val entriesRaw = encodeEntriesSection(entries)98        val sections = listOf(buildSection(SECTION_ENTRIES, entriesRaw))99100        val header = ByteArray(HEADER_LEN)101        writeU32BE(header, 0, MAGIC.toLong())102        header[4] = VERSION.toByte()103        header[5] = sections.size.toByte()104        // bytes 6,7 reserved (zero)105106        val body = concat(listOf(header) + sections)107        val checksum = blake3Truncated(body)108        val inner = concat(listOf(body, checksum))109110        val header5 = ByteArray(5)111        header5[0] = FORMAT_V3.toByte()112        writeU32LE(header5, 1, inner.size.toLong())113        val wrapped = padToNearestBucket(concat(listOf(header5, inner)))114115        val salt = ByteArray(SALT_LEN).also(secureRandom::nextBytes)116        val nonce = ByteArray(NONCE_LEN).also(secureRandom::nextBytes)117        val key = deriveKey(passphrase, salt)118        return try {119            val ctTag = aesGcmEncrypt(key, nonce, wrapped)120            concat(listOf(salt, nonce, ctTag))121        } finally {122            key.fill(0)123        }124    }125126    /** Parse the encrypted envelope back to entries. Throws [PeachScriptException]. */127    fun parsePayload(envelope: ByteArray, passphrase: String): List<VaultEntry> {128        if (envelope.size < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {129            throw PeachScriptException("PAYLOAD_TOO_SHORT")130        }131        val salt = envelope.copyOfRange(0, SALT_LEN)132        val nonce = envelope.copyOfRange(SALT_LEN, SALT_LEN + NONCE_LEN)133        val ctTag = envelope.copyOfRange(SALT_LEN + NONCE_LEN, envelope.size)134135        val key = deriveKey(passphrase, salt)136        val decrypted = try {137            aesGcmDecrypt(key, nonce, ctTag)138        } catch (_: Exception) {139            throw PeachScriptException("INVALID_PASSPHRASE")140        } finally {141            key.fill(0)142        }143144        // Detect padded v2/v3 format: leading FORMAT byte + 4-byte LE inner length.145        val inner: ByteArray146        if (decrypted.size >= 5 && (decrypted[0].toInt() == FORMAT_V2 || decrypted[0].toInt() == FORMAT_V3)) {147            val innerLen = readU32LE(decrypted, 1).toInt()148            if (5 + innerLen > decrypted.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")149            inner = decrypted.copyOfRange(5, 5 + innerLen)150        } else {151            inner = decrypted // v1 — no length prefix / no padding152        }153154        if (inner.size < HEADER_LEN + CHECKSUM_LEN) throw PeachScriptException("PAYLOAD_TOO_SHORT")155        val body = inner.copyOfRange(0, inner.size - CHECKSUM_LEN)156        val stored = inner.copyOfRange(inner.size - CHECKSUM_LEN, inner.size)157        if (!constantTimeEqual(stored, blake3Truncated(body))) throw PeachScriptException("CHECKSUM_FAILED")158159        if (readU32BE(body, 0) != MAGIC.toLong()) throw PeachScriptException("BAD_MAGIC")160        val version = body[4].toInt() and 0xff161        if (version != VERSION) throw PeachScriptException("UNSUPPORTED_VERSION:$version")162        val sectionCount = body[5].toInt() and 0xff163164        var entries = emptyList<VaultEntry>()165        var cursor = HEADER_LEN166        for (i in 0 until sectionCount) {167            if (cursor + 9 > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")168            val type = body[cursor].toInt() and 0xff169            val length = readU32LE(body, cursor + 1).toInt()170            val clength = readU32LE(body, cursor + 5).toInt()171            val dataStart = cursor + 9172            val dataEnd = dataStart + clength173            if (dataEnd > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")174            val compressed = body.copyOfRange(dataStart, dataEnd)175            val data = Zstd.decompress(compressed, length)176            if (data.size != length) throw PeachScriptException("SECTION_LENGTH_MISMATCH")177            when (type) {178                SECTION_ENTRIES -> entries = decodeEntriesSection(data)179                // SETTINGS/LICENSE/RECALL/DECLINED_CARDS and unknown types are180                // skipped — Android needs only the entries (BROWSER-SPEC §B2.12).181                else -> {}182            }183            cursor = dataEnd184        }185        return entries186    }187188    // ---- section assembly (peachPayload.ts:316-333) -----------------------189190    private fun buildSection(type: Int, raw: ByteArray): ByteArray {191        val compressed = Zstd.compress(raw, ZSTD_LEVEL)192        val out = ByteArray(9 + compressed.size)193        out[0] = type.toByte()194        writeU32LE(out, 1, raw.size.toLong())195        writeU32LE(out, 5, compressed.size.toLong())196        System.arraycopy(compressed, 0, out, 9, compressed.size)197        return out198    }199200    // ---- entries section: string table + packed bodies --------------------201202    private class StringTable {203        private val map = LinkedHashMap<String, Int>()204205        /** u16 index; 0 reserved for absent. */206        fun intern(value: String?): Int {207            if (value.isNullOrEmpty()) return 0208            map[value]?.let { return it }209            val idx = map.size + 1210            if (idx > 0xffff) throw PeachScriptException("STRING_TABLE_OVERFLOW")211            map[value] = idx212            return idx213        }214215        fun serialize(): ByteArray {216            val out = BinaryWriter()217            out.u16(map.size)218            for (s in map.keys) {219                val e = s.toByteArray(Charsets.UTF_8)220                if (e.size > 0xffff) throw PeachScriptException("STRING_TOO_LONG")221                out.u16(e.size)222                out.bytes(e)223            }224            return out.toBytes()225        }226    }227228    private fun encodeEntriesSection(entries: List<VaultEntry>): ByteArray {229        val table = StringTable()230        // Pre-intern every title first (peachPayload.ts:400-401) so indices match.231        for (e in entries) table.intern(e.title)232        val bodies = entries.map { encodeEntry(it, table) }233        val tableBytes = table.serialize()234235        val out = BinaryWriter()236        out.u32(entries.size.toLong())237        out.bytes(tableBytes)238        for (b in bodies) out.bytes(b)239        return out.toBytes()240    }241242    private fun decodeEntriesSection(data: ByteArray): List<VaultEntry> {243        val r = BinaryReader(data, 0)244        val count = r.u32().toInt()245        val strings = readStringTable(r)246        val out = ArrayList<VaultEntry>(count)247        for (i in 0 until count) out.add(decodeEntry(r, strings))248        return out249    }250251    private fun readStringTable(r: BinaryReader): List<String?> {252        val count = r.u16()253        val strings = ArrayList<String?>(count + 1)254        strings.add(null) // index 0 = absent255        for (i in 0 until count) {256            val len = r.u16()257            strings.add(String(r.read(len), Charsets.UTF_8))258        }259        return strings260    }261262    private fun lookup(strings: List<String?>, idx: Int): String? =263        if (idx == 0 || idx >= strings.size) null else strings[idx]264265    // ---- per-entry packing (peachPayload.ts:443-601) ----------------------266267    private fun encodeEntry(entry: VaultEntry, table: StringTable): ByteArray {268        val type = entryTypeByte(entry)269        val w = BinaryWriter()270        w.u8(type)271        w.bytes(uuidToBytes(entry.id))272273        val createdSec = entry.createdAt / 1000L274        val updatedSec = entry.modifiedAt / 1000L275        val updatedDelta = maxOf(0L, updatedSec - createdSec)276        val deletedSec = entry.deletedAt?.let { it / 1000L } ?: 0L277278        // Compute flags up front (the browser back-patches; same bytes result).279        var flags = 0280        var flags2 = 0281        if (entry.deletedAt != null) flags = flags or F_HAS_DELETED282283        when (type) {284            ENTRY_TYPE_LOGIN -> {285                if (!entry.email.isNullOrEmpty()) flags2 = flags2 or F2_HAS_EMAIL286                if (!entry.totp.isNullOrEmpty()) flags = flags or F_HAS_TOTP287                if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS288                if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES289                if (entry.canary != null) flags = flags or F_HAS_CANARY290                if (entry.passwordChangedAt != null) flags = flags or F_HAS_PWCHANGED291                if ((entry.breach ?: 0) > 0) flags = flags or F_HAS_BREACH292                if (!entry.additionalUrls.isNullOrEmpty()) flags = flags or F_HAS_ADDITIONAL_URLS293            }294            ENTRY_TYPE_NOTE -> {295                if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES296                if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS297            }298            ENTRY_TYPE_CARD -> {299                if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS300                if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES301            }302        }303304        w.u8(flags)305        w.u8(flags2) // FORMAT_V3 always writes flags2306        w.u32(createdSec)307        w.u32(updatedDelta)308        w.u32(deletedSec)309        w.u16(table.intern(entry.title))310311        when (type) {312            ENTRY_TYPE_LOGIN -> {313                w.u16(table.intern(entry.url))314                w.u16(table.intern(entry.username))315                w.lpString(entry.password ?: "")316                if (flags2 and F2_HAS_EMAIL != 0) w.lpString(entry.email!!)317                if (flags and F_HAS_TOTP != 0) w.lpString(entry.totp!!)318                if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)319                if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))320                if (flags and F_HAS_CANARY != 0) {321                    w.u16(table.intern(entry.canary!!.webhookUrl))322                    w.u16(table.intern(entry.canary.label))323                }324                if (flags and F_HAS_PWCHANGED != 0) {325                    val pwSec = entry.passwordChangedAt!! / 1000L326                    w.u32(maxOf(0L, pwSec - createdSec))327                }328                if (flags and F_HAS_BREACH != 0) w.u32((entry.breach ?: 0).toLong())329                if (flags and F_HAS_ADDITIONAL_URLS != 0) writeStrings(w, table, entry.additionalUrls!!)330            }331            ENTRY_TYPE_NOTE -> {332                if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))333                if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)334            }335            ENTRY_TYPE_CARD -> {336                w.u16(table.intern(entry.cardholderName))337                w.lpString(entry.cardNumber ?: "")338                w.lpString(entry.expMonth ?: "")339                w.lpString(entry.expYear ?: "")340                w.lpString(entry.cvc ?: "")341                w.u16(table.intern(entry.cardNickname))342                w.u8(networkToByte(entry.cardNetwork))343                // Android has no last4 field — derive it from the card number so the344                // wire format matches the browser (BROWSER-SPEC: card last4 note).345                w.u16(table.intern(deriveLast4(entry)))346                if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)347                if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))348            }349            ENTRY_TYPE_PASSKEY -> {350                val blob = encodePasskeyBlob(entry)351                w.u32(blob.size.toLong())352                w.bytes(blob)353            }354        }355        return w.toBytes()356    }357358    private fun decodeEntry(r: BinaryReader, strings: List<String?>): VaultEntry {359        val type = r.u8()360        val id = bytesToUuid(r.read(16))361        val flags = r.u8()362        val flags2 = r.u8() // FORMAT_V3363        val createdSec = r.u32()364        val updatedDelta = r.u32()365        val deletedSec = r.u32()366        val titleIdx = r.u16()367368        val createdAt = createdSec * 1000L369        val modifiedAt = (createdSec + updatedDelta) * 1000L370        val deletedAt = if (deletedSec > 0) deletedSec * 1000L else null371        val title = lookup(strings, titleIdx) ?: ""372373        return when (type) {374            ENTRY_TYPE_LOGIN -> {375                val urlIdx = r.u16()376                val usernameIdx = r.u16()377                val password = r.lpString()378                val email = if (flags2 and F2_HAS_EMAIL != 0) r.lpString() else null379                val totp = if (flags and F_HAS_TOTP != 0) r.lpString() else null380                val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null381                val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null382                var canary: Canary? = null383                if (flags and F_HAS_CANARY != 0) {384                    val webhookUrl = lookup(strings, r.u16()) ?: ""385                    val label = lookup(strings, r.u16())386                    canary = Canary(webhookUrl = webhookUrl, label = label)387                }388                val pwChanged = if (flags and F_HAS_PWCHANGED != 0) (createdSec + r.u32()) * 1000L else null389                val breach = if (flags and F_HAS_BREACH != 0) r.u32().toInt() else null390                val additionalUrls = if (flags and F_HAS_ADDITIONAL_URLS != 0) readStrings(r, strings) else null391                VaultEntry(392                    id = id, type = "login", title = title,393                    url = lookup(strings, urlIdx), username = lookup(strings, usernameIdx),394                    password = password, email = email, totp = totp, tags = tags, notes = notes,395                    canary = canary, passwordChangedAt = pwChanged, breach = breach,396                    additionalUrls = additionalUrls, createdAt = createdAt, modifiedAt = modifiedAt,397                    deletedAt = deletedAt,398                )399            }400            ENTRY_TYPE_NOTE -> {401                val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null402                val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null403                VaultEntry(404                    id = id, type = "note", title = title, notes = notes, tags = tags,405                    createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,406                )407            }408            ENTRY_TYPE_CARD -> {409                val cardholderIdx = r.u16()410                val cardNumber = r.lpString()411                val expMonth = r.lpString()412                val expYear = r.lpString()413                val cvc = r.lpString()414                val nicknameIdx = r.u16()415                val network = networkFromByte(r.u8())416                r.u16() // last4 index — Android has no field; dropped (number carries it)417                val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null418                val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null419                VaultEntry(420                    id = id, type = "card", title = title,421                    cardholderName = lookup(strings, cardholderIdx),422                    cardNumber = cardNumber.ifEmpty { null }, expMonth = expMonth.ifEmpty { null },423                    expYear = expYear.ifEmpty { null }, cvc = cvc.ifEmpty { null },424                    cardNickname = lookup(strings, nicknameIdx), cardNetwork = network,425                    tags = tags, notes = notes,426                    createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,427                )428            }429            ENTRY_TYPE_PASSKEY -> {430                val blobLen = r.u32().toInt()431                val blob = r.read(blobLen)432                decodePasskeyBlob(id, title, createdAt, modifiedAt, deletedAt, blob)433            }434            else -> throw PeachScriptException("UNKNOWN_ENTRY_TYPE:$type")435        }436    }437438    private fun writeTags(w: BinaryWriter, table: StringTable, tags: List<String>) {439        val n = minOf(tags.size, 0xff)440        w.u8(n)441        for (i in 0 until n) w.u16(table.intern(tags[i]))442    }443444    private fun writeStrings(w: BinaryWriter, table: StringTable, items: List<String>) {445        val n = minOf(items.size, 0xff)446        w.u8(n)447        for (i in 0 until n) w.u16(table.intern(items[i]))448    }449450    private fun readTags(r: BinaryReader, strings: List<String?>): List<String> {451        val n = r.u8()452        val out = ArrayList<String>(n)453        for (i in 0 until n) lookup(strings, r.u16())?.let { out.add(it) }454        return out455    }456457    private fun readStrings(r: BinaryReader, strings: List<String?>): List<String> = readTags(r, strings)458459    private fun entryTypeByte(entry: VaultEntry): Int {460        if (entry.passkey != null) return ENTRY_TYPE_PASSKEY461        return when (entry.type) {462            "note" -> ENTRY_TYPE_NOTE463            "card" -> ENTRY_TYPE_CARD464            "passkey" -> ENTRY_TYPE_PASSKEY465            else -> ENTRY_TYPE_LOGIN466        }467    }468469    private fun networkToByte(network: String?): Int {470        if (network.isNullOrEmpty()) return 0xff471        val idx = NETWORK_VALUES.indexOf(network)472        return if (idx >= 0) idx else NETWORK_VALUES.indexOf("unknown")473    }474475    private fun networkFromByte(b: Int): String? {476        if (b == 0xff) return null477        return NETWORK_VALUES.getOrNull(b) ?: "unknown"478    }479480    private fun deriveLast4(entry: VaultEntry): String? {481        val digits = entry.cardNumber?.filter { it.isDigit() } ?: return null482        return if (digits.length >= 4) digits.takeLast(4) else digits.ifEmpty { null }483    }484485    // ---- passkey JSON blob (peachPayload.ts:552-596) ----------------------486    // [PLATFORM GAP] Android's PasskeyData has no privateKeyJwk field, so a487    // browser passkey's private key is dropped on import. All other passkey +488    // login fields round-trip. See BROWSER-SPEC Implementation Notes.489490    private fun encodePasskeyBlob(entry: VaultEntry): ByteArray {491        val pk = entry.passkey492        val obj = buildJsonObject {493            putIfPresent("rpId", pk?.rpId ?: entry.rpId)494            putIfPresent("rpName", pk?.rpName ?: entry.rpName)495            putIfPresent("credentialId", pk?.credentialId ?: entry.credentialId)496            putIfPresent("publicKeyCose", pk?.publicKeyCose ?: entry.publicKeyCose)497            putIfPresent("userHandle", pk?.userHandle ?: entry.userHandle)498            putIfPresent("userName", pk?.userName ?: entry.userName)499            putIfPresent("userDisplayName", pk?.userDisplayName ?: entry.userDisplayName)500            putIfPresent("algorithm", (pk?.algorithm ?: entry.algorithm))501            putIfPresent("signCount", (pk?.signCount ?: entry.signCount))502            putIfPresent("aaguid", pk?.aaguid ?: entry.aaguid)503            putIfPresent("backupEligible", pk?.backupEligible ?: entry.backupEligible)504            putIfPresent("backupState", pk?.backupState ?: entry.backupState)505            putIfPresent("lastUsedAt", entry.lastUsedAt)506            // Login fields carried alongside a passkey-bearing login entry.507            putIfPresent("password", entry.password)508            putIfPresent("url", entry.url)509            putIfPresent("username", entry.username)510            putIfPresent("email", entry.email)511            putIfPresent("totp", entry.totp)512            putIfPresent("notes", entry.notes)513            if (!entry.tags.isNullOrEmpty()) {514                put("tags", buildJsonArray { entry.tags.forEach { add(JsonPrimitive(it)) } })515            }516        }517        return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8)518    }519520    private fun decodePasskeyBlob(521        id: String, title: String, createdAt: Long, modifiedAt: Long, deletedAt: Long?, blob: ByteArray,522    ): VaultEntry {523        val obj = json.parseToJsonElement(String(blob, Charsets.UTF_8)).jsonObject524        fun str(k: String) = obj[k]?.jsonPrimitive?.contentOrNull525        fun intf(k: String) = obj[k]?.jsonPrimitive?.intOrNull526        fun longf(k: String) = obj[k]?.jsonPrimitive?.longOrNull527        fun boolf(k: String) = obj[k]?.jsonPrimitive?.booleanOrNull528        val tags = obj["tags"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull }529        val credentialId = str("credentialId")530        val publicKeyCose = str("publicKeyCose")531        val rpId = str("rpId")532        val userHandle = str("userHandle")533        val userName = str("userName")534        val passkey = if (credentialId != null && publicKeyCose != null && rpId != null &&535            userHandle != null && userName != null) {536            PasskeyData(537                credentialId = credentialId, publicKeyCose = publicKeyCose, rpId = rpId,538                rpName = str("rpName"), userHandle = userHandle, userName = userName,539                userDisplayName = str("userDisplayName"), algorithm = intf("algorithm") ?: -7,540                signCount = intf("signCount") ?: 0, aaguid = str("aaguid"),541                backupEligible = boolf("backupEligible") ?: false, backupState = boolf("backupState") ?: false,542            )543        } else null544        return VaultEntry(545            id = id, type = "passkey", title = title,546            url = str("url"), username = str("username"), email = str("email"),547            password = str("password"), totp = str("totp"), notes = str("notes"), tags = tags,548            createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,549            passkey = passkey, lastUsedAt = longf("lastUsedAt"),550            rpId = rpId, rpName = str("rpName"), credentialId = credentialId,551            publicKeyCose = publicKeyCose, userHandle = userHandle, userName = userName,552            userDisplayName = str("userDisplayName"), algorithm = intf("algorithm"),553            aaguid = str("aaguid"), backupEligible = boolf("backupEligible"),554            backupState = boolf("backupState"), signCount = intf("signCount"),555        )556    }557558    // ---- uuid (peachPayload.ts:792-806) -----------------------------------559560    fun uuidToBytes(uuid: String): ByteArray {561        val hex = uuid.replace("-", "")562        if (hex.length != 32) throw PeachScriptException("UUID_FORMAT")563        return ByteArray(16) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }564    }565566    fun bytesToUuid(bytes: ByteArray): String {567        if (bytes.size != 16) throw PeachScriptException("UUID_LENGTH")568        val hex = bytes.joinToString("") { "%02x".format(it) }569        return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-" +570            "${hex.substring(16, 20)}-${hex.substring(20)}"571    }572573    // ---- crypto / hashing -------------------------------------------------574575    private fun deriveKey(passphrase: String, salt: ByteArray): ByteArray {576        // Argon2id over the UTF-8 BYTES of the passphrase, matching the browser's577        // hash-wasm KDF input (avoids the MF-4 UTF-16 divergence in this path).578        val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)579            .withSalt(salt)580            .withParallelism(ARGON2_PARALLELISM)581            .withMemoryAsKB(ARGON2_MEMORY_KIB)582            .withIterations(ARGON2_ITERATIONS)583            .build()584        val gen = Argon2BytesGenerator().apply { init(params) }585        val out = ByteArray(32)586        gen.generateBytes(passphrase.toByteArray(Charsets.UTF_8), out)587        return out588    }589590    private fun aesGcmEncrypt(key: ByteArray, nonce: ByteArray, plaintext: ByteArray): ByteArray {591        val cipher = Cipher.getInstance("AES/GCM/NoPadding")592        cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))593        return cipher.doFinal(plaintext) // ciphertext || tag; no AAD (browser uses none)594    }595596    private fun aesGcmDecrypt(key: ByteArray, nonce: ByteArray, ctTag: ByteArray): ByteArray {597        val cipher = Cipher.getInstance("AES/GCM/NoPadding")598        cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))599        return cipher.doFinal(ctTag)600    }601602    private fun blake3Truncated(data: ByteArray): ByteArray {603        val digest = Blake3Digest(256)604        digest.update(data, 0, data.size)605        val full = ByteArray(32)606        digest.doFinal(full, 0)607        return full.copyOfRange(0, CHECKSUM_LEN)608    }609610    private fun padToNearestBucket(data: ByteArray): ByteArray {611        val bucket = SIZE_BUCKETS.firstOrNull { it >= data.size }612        var target = bucket ?: run {613            var t = 262144614            while (t < data.size) t *= 2615            t616        }617        if (target == data.size) return data618        return data.copyOf(target)619    }620621    private fun constantTimeEqual(a: ByteArray, b: ByteArray): Boolean {622        if (a.size != b.size) return false623        var diff = 0624        for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt())625        return diff == 0626    }627628    private fun concat(parts: List<ByteArray>): ByteArray {629        val total = parts.sumOf { it.size }630        val out = ByteArray(total)631        var c = 0632        for (p in parts) { System.arraycopy(p, 0, out, c, p.size); c += p.size }633        return out634    }635636    // ---- little/big-endian helpers ----------------------------------------637638    private fun writeU32LE(buf: ByteArray, off: Int, v: Long) {639        buf[off] = (v and 0xff).toByte()640        buf[off + 1] = ((v ushr 8) and 0xff).toByte()641        buf[off + 2] = ((v ushr 16) and 0xff).toByte()642        buf[off + 3] = ((v ushr 24) and 0xff).toByte()643    }644645    private fun writeU32BE(buf: ByteArray, off: Int, v: Long) {646        buf[off] = ((v ushr 24) and 0xff).toByte()647        buf[off + 1] = ((v ushr 16) and 0xff).toByte()648        buf[off + 2] = ((v ushr 8) and 0xff).toByte()649        buf[off + 3] = (v and 0xff).toByte()650    }651652    private fun readU32LE(buf: ByteArray, off: Int): Long {653        return (buf[off].toLong() and 0xff) or654            ((buf[off + 1].toLong() and 0xff) shl 8) or655            ((buf[off + 2].toLong() and 0xff) shl 16) or656            ((buf[off + 3].toLong() and 0xff) shl 24)657    }658659    private fun readU32BE(buf: ByteArray, off: Int): Long {660        return ((buf[off].toLong() and 0xff) shl 24) or661            ((buf[off + 1].toLong() and 0xff) shl 16) or662            ((buf[off + 2].toLong() and 0xff) shl 8) or663            (buf[off + 3].toLong() and 0xff)664    }665666    private class BinaryWriter {667        private val out = ByteArrayOutputStream()668        fun u8(v: Int) { out.write(v and 0xff) }669        fun u16(v: Int) { out.write(v and 0xff); out.write((v ushr 8) and 0xff) }670        fun u32(v: Long) {671            out.write((v and 0xff).toInt()); out.write(((v ushr 8) and 0xff).toInt())672            out.write(((v ushr 16) and 0xff).toInt()); out.write(((v ushr 24) and 0xff).toInt())673        }674        fun bytes(b: ByteArray) { out.write(b) }675        fun lpString(value: String) {676            val enc = value.toByteArray(Charsets.UTF_8)677            if (enc.size > 0xff) throw PeachScriptException("STRING_TOO_LONG_U8")678            u8(enc.size); bytes(enc)679        }680        fun toBytes(): ByteArray = out.toByteArray()681    }682683    private class BinaryReader(private val data: ByteArray, start: Int) {684        private var cursor = start685        fun u8(): Int = data[cursor++].toInt() and 0xff686        fun u16(): Int { val v = (data[cursor].toInt() and 0xff) or ((data[cursor + 1].toInt() and 0xff) shl 8); cursor += 2; return v }687        fun u32(): Long {688            val v = (data[cursor].toLong() and 0xff) or ((data[cursor + 1].toLong() and 0xff) shl 8) or689                ((data[cursor + 2].toLong() and 0xff) shl 16) or ((data[cursor + 3].toLong() and 0xff) shl 24)690            cursor += 4; return v691        }692        fun read(n: Int): ByteArray { val out = data.copyOfRange(cursor, cursor + n); cursor += n; return out }693        fun lpString(): String = String(read(u8()), Charsets.UTF_8)694    }695696    private fun JsonObjectBuilder.putIfPresent(key: String, value: String?) {697        if (!value.isNullOrEmpty()) put(key, JsonPrimitive(value))698    }699    private fun JsonObjectBuilder.putIfPresent(key: String, value: Int?) {700        if (value != null) put(key, JsonPrimitive(value))701    }702    private fun JsonObjectBuilder.putIfPresent(key: String, value: Long?) {703        if (value != null) put(key, JsonPrimitive(value))704    }705    private fun JsonObjectBuilder.putIfPresent(key: String, value: Boolean?) {706        if (value != null) put(key, JsonPrimitive(value))707    }708}