PeachPayloadCodec.kt

34.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    data class ParsedPayload(91        val entries: List<VaultEntry>,92        val settings: VaultSettings = VaultSettings(),93        val licenseKey: String? = null,94        val licenseActivatedAt: Long? = null,95    )9697    // ---- public API -------------------------------------------------------9899    /** Build the encrypted envelope `salt||nonce||ct||tag` from [entries]. */100    fun buildPayload(101        entries: List<VaultEntry>,102        passphrase: String,103        settings: VaultSettings = VaultSettings(),104        licenseKey: String? = null,105        licenseActivatedAt: Long? = null,106    ): ByteArray {107        val entriesRaw = encodeEntriesSection(entries)108        val sections = buildList {109            add(buildSection(SECTION_ENTRIES, entriesRaw))110            add(buildSection(SECTION_SETTINGS, encodeSettingsSection(settings)))111            if (!licenseKey.isNullOrBlank()) {112                add(buildSection(SECTION_LICENSE, encodeLicenseSection(licenseKey, licenseActivatedAt)))113            }114        }115116        val header = ByteArray(HEADER_LEN)117        writeU32BE(header, 0, MAGIC.toLong())118        header[4] = VERSION.toByte()119        header[5] = sections.size.toByte()120        // bytes 6,7 reserved (zero)121122        val body = concat(listOf(header) + sections)123        val checksum = blake3Truncated(body)124        val inner = concat(listOf(body, checksum))125126        val header5 = ByteArray(5)127        header5[0] = FORMAT_V3.toByte()128        writeU32LE(header5, 1, inner.size.toLong())129        val wrapped = padToNearestBucket(concat(listOf(header5, inner)))130131        val salt = ByteArray(SALT_LEN).also(secureRandom::nextBytes)132        val nonce = ByteArray(NONCE_LEN).also(secureRandom::nextBytes)133        val key = deriveKey(passphrase, salt)134        return try {135            val ctTag = aesGcmEncrypt(key, nonce, wrapped)136            concat(listOf(salt, nonce, ctTag))137        } finally {138            key.fill(0)139        }140    }141142    /** Parse the encrypted envelope back to entries. Throws [PeachScriptException]. */143    fun parsePayload(envelope: ByteArray, passphrase: String): List<VaultEntry> =144        parsePayloadData(envelope, passphrase).entries145146    /** Parse the encrypted envelope back to all supported payload metadata. */147    fun parsePayloadData(envelope: ByteArray, passphrase: String): ParsedPayload {148        if (envelope.size < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {149            throw PeachScriptException("PAYLOAD_TOO_SHORT")150        }151        val salt = envelope.copyOfRange(0, SALT_LEN)152        val nonce = envelope.copyOfRange(SALT_LEN, SALT_LEN + NONCE_LEN)153        val ctTag = envelope.copyOfRange(SALT_LEN + NONCE_LEN, envelope.size)154155        val key = deriveKey(passphrase, salt)156        val decrypted = try {157            aesGcmDecrypt(key, nonce, ctTag)158        } catch (_: Exception) {159            throw PeachScriptException("INVALID_PASSPHRASE")160        } finally {161            key.fill(0)162        }163164        // Detect padded v2/v3 format: leading FORMAT byte + 4-byte LE inner length.165        val inner: ByteArray166        if (decrypted.size >= 5 && (decrypted[0].toInt() == FORMAT_V2 || decrypted[0].toInt() == FORMAT_V3)) {167            val innerLen = readU32LE(decrypted, 1).toInt()168            if (5 + innerLen > decrypted.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")169            inner = decrypted.copyOfRange(5, 5 + innerLen)170        } else {171            inner = decrypted // v1 — no length prefix / no padding172        }173174        if (inner.size < HEADER_LEN + CHECKSUM_LEN) throw PeachScriptException("PAYLOAD_TOO_SHORT")175        val body = inner.copyOfRange(0, inner.size - CHECKSUM_LEN)176        val stored = inner.copyOfRange(inner.size - CHECKSUM_LEN, inner.size)177        if (!constantTimeEqual(stored, blake3Truncated(body))) throw PeachScriptException("CHECKSUM_FAILED")178179        if (readU32BE(body, 0) != MAGIC.toLong()) throw PeachScriptException("BAD_MAGIC")180        val version = body[4].toInt() and 0xff181        if (version != VERSION) throw PeachScriptException("UNSUPPORTED_VERSION:$version")182        val sectionCount = body[5].toInt() and 0xff183184        var entries = emptyList<VaultEntry>()185        var settings = VaultSettings()186        var licenseKey: String? = null187        var licenseActivatedAt: Long? = null188        var cursor = HEADER_LEN189        for (i in 0 until sectionCount) {190            if (cursor + 9 > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")191            val type = body[cursor].toInt() and 0xff192            val length = readU32LE(body, cursor + 1).toInt()193            val clength = readU32LE(body, cursor + 5).toInt()194            val dataStart = cursor + 9195            val dataEnd = dataStart + clength196            if (dataEnd > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")197            val compressed = body.copyOfRange(dataStart, dataEnd)198            val data = Zstd.decompress(compressed, length)199            if (data.size != length) throw PeachScriptException("SECTION_LENGTH_MISMATCH")200            when (type) {201                SECTION_ENTRIES -> entries = decodeEntriesSection(data)202                SECTION_SETTINGS -> settings = decodeSettingsSection(data) ?: VaultSettings()203                SECTION_LICENSE -> {204                    val license = decodeLicenseSection(data)205                    licenseKey = license?.first206                    licenseActivatedAt = license?.second207                }208                // RECALL/DECLINED_CARDS and unknown types are skipped. Android209                // does not expose browser manual-summon or declined-card stores.210                else -> {}211            }212            cursor = dataEnd213        }214        return ParsedPayload(215            entries = entries,216            settings = settings,217            licenseKey = licenseKey,218            licenseActivatedAt = licenseActivatedAt,219        )220    }221222    // ---- section assembly (peachPayload.ts:316-333) -----------------------223224    private fun buildSection(type: Int, raw: ByteArray): ByteArray {225        val compressed = Zstd.compress(raw, ZSTD_LEVEL)226        val out = ByteArray(9 + compressed.size)227        out[0] = type.toByte()228        writeU32LE(out, 1, raw.size.toLong())229        writeU32LE(out, 5, compressed.size.toLong())230        System.arraycopy(compressed, 0, out, 9, compressed.size)231        return out232    }233234    private fun encodeSettingsSection(settings: VaultSettings): ByteArray =235        VaultCodec.encodeSettings(settings).toByteArray(Charsets.UTF_8)236237    private fun decodeSettingsSection(data: ByteArray): VaultSettings? =238        VaultCodec.decodeSettings(String(data, Charsets.UTF_8))239240    private fun encodeLicenseSection(key: String, activatedAt: Long?): ByteArray {241        val obj = buildJsonObject {242            put("key", JsonPrimitive(key))243            if (activatedAt != null) put("activatedAt", JsonPrimitive(activatedAt))244        }245        return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8)246    }247248    private fun decodeLicenseSection(data: ByteArray): Pair<String, Long?>? {249        val obj = runCatching { json.parseToJsonElement(String(data, Charsets.UTF_8)).jsonObject }.getOrNull()250            ?: return null251        val key = obj["key"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null252        val activatedAt = obj["activatedAt"]?.jsonPrimitive?.longOrNull253        return key to activatedAt254    }255256    // ---- entries section: string table + packed bodies --------------------257258    private class StringTable {259        private val map = LinkedHashMap<String, Int>()260261        /** u16 index; 0 reserved for absent. */262        fun intern(value: String?): Int {263            if (value.isNullOrEmpty()) return 0264            map[value]?.let { return it }265            val idx = map.size + 1266            if (idx > 0xffff) throw PeachScriptException("STRING_TABLE_OVERFLOW")267            map[value] = idx268            return idx269        }270271        fun serialize(): ByteArray {272            val out = BinaryWriter()273            out.u16(map.size)274            for (s in map.keys) {275                val e = s.toByteArray(Charsets.UTF_8)276                if (e.size > 0xffff) throw PeachScriptException("STRING_TOO_LONG")277                out.u16(e.size)278                out.bytes(e)279            }280            return out.toBytes()281        }282    }283284    private fun encodeEntriesSection(entries: List<VaultEntry>): ByteArray {285        val table = StringTable()286        // Pre-intern every title first (peachPayload.ts:400-401) so indices match.287        for (e in entries) table.intern(e.title)288        val bodies = entries.map { encodeEntry(it, table) }289        val tableBytes = table.serialize()290291        val out = BinaryWriter()292        out.u32(entries.size.toLong())293        out.bytes(tableBytes)294        for (b in bodies) out.bytes(b)295        return out.toBytes()296    }297298    private fun decodeEntriesSection(data: ByteArray): List<VaultEntry> {299        val r = BinaryReader(data, 0)300        val count = r.u32().toInt()301        val strings = readStringTable(r)302        val out = ArrayList<VaultEntry>(count)303        for (i in 0 until count) out.add(decodeEntry(r, strings))304        return out305    }306307    private fun readStringTable(r: BinaryReader): List<String?> {308        val count = r.u16()309        val strings = ArrayList<String?>(count + 1)310        strings.add(null) // index 0 = absent311        for (i in 0 until count) {312            val len = r.u16()313            strings.add(String(r.read(len), Charsets.UTF_8))314        }315        return strings316    }317318    private fun lookup(strings: List<String?>, idx: Int): String? =319        if (idx == 0 || idx >= strings.size) null else strings[idx]320321    // ---- per-entry packing (peachPayload.ts:443-601) ----------------------322323    private fun encodeEntry(entry: VaultEntry, table: StringTable): ByteArray {324        val type = entryTypeByte(entry)325        val w = BinaryWriter()326        w.u8(type)327        w.bytes(uuidToBytes(entry.id))328329        val createdSec = entry.createdAt / 1000L330        val updatedSec = entry.modifiedAt / 1000L331        val updatedDelta = maxOf(0L, updatedSec - createdSec)332        val deletedSec = entry.deletedAt?.let { it / 1000L } ?: 0L333334        // Compute flags up front (the browser back-patches; same bytes result).335        var flags = 0336        var flags2 = 0337        if (entry.deletedAt != null) flags = flags or F_HAS_DELETED338339        when (type) {340            ENTRY_TYPE_LOGIN -> {341                if (!entry.email.isNullOrEmpty()) flags2 = flags2 or F2_HAS_EMAIL342                if (!entry.totp.isNullOrEmpty()) flags = flags or F_HAS_TOTP343                if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS344                if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES345                if (entry.canary != null) flags = flags or F_HAS_CANARY346                if (entry.passwordChangedAt != null) flags = flags or F_HAS_PWCHANGED347                if ((entry.breach ?: 0) > 0) flags = flags or F_HAS_BREACH348                if (!entry.additionalUrls.isNullOrEmpty()) flags = flags or F_HAS_ADDITIONAL_URLS349            }350            ENTRY_TYPE_NOTE -> {351                if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES352                if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS353            }354            ENTRY_TYPE_CARD -> {355                if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS356                if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES357            }358        }359360        w.u8(flags)361        w.u8(flags2) // FORMAT_V3 always writes flags2362        w.u32(createdSec)363        w.u32(updatedDelta)364        w.u32(deletedSec)365        w.u16(table.intern(entry.title))366367        when (type) {368            ENTRY_TYPE_LOGIN -> {369                w.u16(table.intern(entry.url))370                w.u16(table.intern(entry.username))371                w.lpString(entry.password ?: "")372                if (flags2 and F2_HAS_EMAIL != 0) w.lpString(entry.email!!)373                if (flags and F_HAS_TOTP != 0) w.lpString(entry.totp!!)374                if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)375                if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))376                if (flags and F_HAS_CANARY != 0) {377                    w.u16(table.intern(entry.canary!!.webhookUrl))378                    w.u16(table.intern(entry.canary.label))379                }380                if (flags and F_HAS_PWCHANGED != 0) {381                    val pwSec = entry.passwordChangedAt!! / 1000L382                    w.u32(maxOf(0L, pwSec - createdSec))383                }384                if (flags and F_HAS_BREACH != 0) w.u32((entry.breach ?: 0).toLong())385                if (flags and F_HAS_ADDITIONAL_URLS != 0) writeStrings(w, table, entry.additionalUrls!!)386            }387            ENTRY_TYPE_NOTE -> {388                if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))389                if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)390            }391            ENTRY_TYPE_CARD -> {392                w.u16(table.intern(entry.cardholderName))393                w.lpString(entry.cardNumber ?: "")394                w.lpString(entry.expMonth ?: "")395                w.lpString(entry.expYear ?: "")396                w.lpString(entry.cvc ?: "")397                w.u16(table.intern(entry.cardNickname))398                w.u8(networkToByte(entry.cardNetwork))399                // Android has no last4 field — derive it from the card number so the400                // wire format matches the browser (BROWSER-SPEC: card last4 note).401                w.u16(table.intern(deriveLast4(entry)))402                if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)403                if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))404            }405            ENTRY_TYPE_PASSKEY -> {406                val blob = encodePasskeyBlob(entry)407                w.u32(blob.size.toLong())408                w.bytes(blob)409            }410        }411        return w.toBytes()412    }413414    private fun decodeEntry(r: BinaryReader, strings: List<String?>): VaultEntry {415        val type = r.u8()416        val id = bytesToUuid(r.read(16))417        val flags = r.u8()418        val flags2 = r.u8() // FORMAT_V3419        val createdSec = r.u32()420        val updatedDelta = r.u32()421        val deletedSec = r.u32()422        val titleIdx = r.u16()423424        val createdAt = createdSec * 1000L425        val modifiedAt = (createdSec + updatedDelta) * 1000L426        val deletedAt = if (deletedSec > 0) deletedSec * 1000L else null427        val title = lookup(strings, titleIdx) ?: ""428429        return when (type) {430            ENTRY_TYPE_LOGIN -> {431                val urlIdx = r.u16()432                val usernameIdx = r.u16()433                val password = r.lpString()434                val email = if (flags2 and F2_HAS_EMAIL != 0) r.lpString() else null435                val totp = if (flags and F_HAS_TOTP != 0) r.lpString() else null436                val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null437                val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null438                var canary: Canary? = null439                if (flags and F_HAS_CANARY != 0) {440                    val webhookUrl = lookup(strings, r.u16()) ?: ""441                    val label = lookup(strings, r.u16())442                    canary = Canary(webhookUrl = webhookUrl, label = label)443                }444                val pwChanged = if (flags and F_HAS_PWCHANGED != 0) (createdSec + r.u32()) * 1000L else null445                val breach = if (flags and F_HAS_BREACH != 0) r.u32().toInt() else null446                val additionalUrls = if (flags and F_HAS_ADDITIONAL_URLS != 0) readStrings(r, strings) else null447                VaultEntry(448                    id = id, type = "login", title = title,449                    url = lookup(strings, urlIdx), username = lookup(strings, usernameIdx),450                    password = password, email = email, totp = totp, tags = tags, notes = notes,451                    canary = canary, passwordChangedAt = pwChanged, breach = breach,452                    additionalUrls = additionalUrls, createdAt = createdAt, modifiedAt = modifiedAt,453                    deletedAt = deletedAt,454                )455            }456            ENTRY_TYPE_NOTE -> {457                val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null458                val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null459                VaultEntry(460                    id = id, type = "note", title = title, notes = notes, tags = tags,461                    createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,462                )463            }464            ENTRY_TYPE_CARD -> {465                val cardholderIdx = r.u16()466                val cardNumber = r.lpString()467                val expMonth = r.lpString()468                val expYear = r.lpString()469                val cvc = r.lpString()470                val nicknameIdx = r.u16()471                val network = networkFromByte(r.u8())472                r.u16() // last4 index — Android has no field; dropped (number carries it)473                val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null474                val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null475                VaultEntry(476                    id = id, type = "card", title = title,477                    cardholderName = lookup(strings, cardholderIdx),478                    cardNumber = cardNumber.ifEmpty { null }, expMonth = expMonth.ifEmpty { null },479                    expYear = expYear.ifEmpty { null }, cvc = cvc.ifEmpty { null },480                    cardNickname = lookup(strings, nicknameIdx), cardNetwork = network,481                    tags = tags, notes = notes,482                    createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,483                )484            }485            ENTRY_TYPE_PASSKEY -> {486                val blobLen = r.u32().toInt()487                val blob = r.read(blobLen)488                decodePasskeyBlob(id, title, createdAt, modifiedAt, deletedAt, blob)489            }490            else -> throw PeachScriptException("UNKNOWN_ENTRY_TYPE:$type")491        }492    }493494    private fun writeTags(w: BinaryWriter, table: StringTable, tags: List<String>) {495        val n = minOf(tags.size, 0xff)496        w.u8(n)497        for (i in 0 until n) w.u16(table.intern(tags[i]))498    }499500    private fun writeStrings(w: BinaryWriter, table: StringTable, items: List<String>) {501        val n = minOf(items.size, 0xff)502        w.u8(n)503        for (i in 0 until n) w.u16(table.intern(items[i]))504    }505506    private fun readTags(r: BinaryReader, strings: List<String?>): List<String> {507        val n = r.u8()508        val out = ArrayList<String>(n)509        for (i in 0 until n) lookup(strings, r.u16())?.let { out.add(it) }510        return out511    }512513    private fun readStrings(r: BinaryReader, strings: List<String?>): List<String> = readTags(r, strings)514515    private fun entryTypeByte(entry: VaultEntry): Int {516        if (entry.passkey != null) return ENTRY_TYPE_PASSKEY517        return when (entry.type) {518            "note" -> ENTRY_TYPE_NOTE519            "card" -> ENTRY_TYPE_CARD520            "passkey" -> ENTRY_TYPE_PASSKEY521            else -> ENTRY_TYPE_LOGIN522        }523    }524525    private fun networkToByte(network: String?): Int {526        if (network.isNullOrEmpty()) return 0xff527        val idx = NETWORK_VALUES.indexOf(network)528        return if (idx >= 0) idx else NETWORK_VALUES.indexOf("unknown")529    }530531    private fun networkFromByte(b: Int): String? {532        if (b == 0xff) return null533        return NETWORK_VALUES.getOrNull(b) ?: "unknown"534    }535536    private fun deriveLast4(entry: VaultEntry): String? {537        val digits = entry.cardNumber?.filter { it.isDigit() } ?: return null538        return if (digits.length >= 4) digits.takeLast(4) else digits.ifEmpty { null }539    }540541    // ---- passkey JSON blob (peachPayload.ts:552-596) ----------------------542    // Browser passkeys carry privateKeyJwk as a JSON object. Android stores that543    // object as compact JSON text so Parcelable vault entries can preserve it544    // across import/export; Credential Provider signing is implemented separately.545546    private fun encodePasskeyBlob(entry: VaultEntry): ByteArray {547        val pk = entry.passkey548        val obj = buildJsonObject {549            putIfPresent("rpId", pk?.rpId ?: entry.rpId)550            putIfPresent("rpName", pk?.rpName ?: entry.rpName)551            putIfPresent("credentialId", pk?.credentialId ?: entry.credentialId)552            putIfPresent("publicKeyCose", pk?.publicKeyCose ?: entry.publicKeyCose)553            putJsonObjectStringIfPresent("privateKeyJwk", pk?.privateKeyJwk ?: entry.privateKeyJwk)554            putIfPresent("userHandle", pk?.userHandle ?: entry.userHandle)555            putIfPresent("userName", pk?.userName ?: entry.userName)556            putIfPresent("userDisplayName", pk?.userDisplayName ?: entry.userDisplayName)557            putIfPresent("algorithm", (pk?.algorithm ?: entry.algorithm))558            putIfPresent("signCount", (pk?.signCount ?: entry.signCount))559            putIfPresent("aaguid", pk?.aaguid ?: entry.aaguid)560            putIfPresent("backupEligible", pk?.backupEligible ?: entry.backupEligible)561            putIfPresent("backupState", pk?.backupState ?: entry.backupState)562            putIfPresent("lastUsedAt", entry.lastUsedAt)563            // Login fields carried alongside a passkey-bearing login entry.564            putIfPresent("password", entry.password)565            putIfPresent("url", entry.url)566            putIfPresent("username", entry.username)567            putIfPresent("email", entry.email)568            putIfPresent("totp", entry.totp)569            putIfPresent("notes", entry.notes)570            if (!entry.tags.isNullOrEmpty()) {571                put("tags", buildJsonArray { entry.tags.forEach { add(JsonPrimitive(it)) } })572            }573        }574        return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8)575    }576577    private fun decodePasskeyBlob(578        id: String, title: String, createdAt: Long, modifiedAt: Long, deletedAt: Long?, blob: ByteArray,579    ): VaultEntry {580        val obj = json.parseToJsonElement(String(blob, Charsets.UTF_8)).jsonObject581        fun str(k: String) = obj[k]?.jsonPrimitive?.contentOrNull582        fun intf(k: String) = obj[k]?.jsonPrimitive?.intOrNull583        fun longf(k: String) = obj[k]?.jsonPrimitive?.longOrNull584        fun boolf(k: String) = obj[k]?.jsonPrimitive?.booleanOrNull585        fun objectJson(k: String): String? {586            val value = obj[k] ?: return null587            val nested = runCatching { value.jsonObject }.getOrNull() ?: return null588            return json.encodeToString(JsonObject.serializer(), nested)589        }590        val tags = obj["tags"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull }591        val credentialId = str("credentialId")592        val publicKeyCose = str("publicKeyCose")593        val privateKeyJwk = objectJson("privateKeyJwk")594        val rpId = str("rpId")595        val userHandle = str("userHandle")596        val userName = str("userName")597        val passkey = if (credentialId != null && publicKeyCose != null && rpId != null &&598            userHandle != null && userName != null) {599            PasskeyData(600                credentialId = credentialId, publicKeyCose = publicKeyCose, rpId = rpId,601                privateKeyJwk = privateKeyJwk,602                rpName = str("rpName"), userHandle = userHandle, userName = userName,603                userDisplayName = str("userDisplayName"), algorithm = intf("algorithm") ?: -7,604                signCount = intf("signCount") ?: 0, aaguid = str("aaguid"),605                backupEligible = boolf("backupEligible") ?: false, backupState = boolf("backupState") ?: false,606            )607        } else null608        return VaultEntry(609            id = id, type = "passkey", title = title,610            url = str("url"), username = str("username"), email = str("email"),611            password = str("password"), totp = str("totp"), notes = str("notes"), tags = tags,612            createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,613            passkey = passkey, lastUsedAt = longf("lastUsedAt"),614            rpId = rpId, rpName = str("rpName"), credentialId = credentialId,615            publicKeyCose = publicKeyCose, privateKeyJwk = privateKeyJwk,616            userHandle = userHandle, userName = userName,617            userDisplayName = str("userDisplayName"), algorithm = intf("algorithm"),618            aaguid = str("aaguid"), backupEligible = boolf("backupEligible"),619            backupState = boolf("backupState"), signCount = intf("signCount"),620        )621    }622623    // ---- uuid (peachPayload.ts:792-806) -----------------------------------624625    fun uuidToBytes(uuid: String): ByteArray {626        val hex = uuid.replace("-", "")627        if (hex.length != 32) throw PeachScriptException("UUID_FORMAT")628        return ByteArray(16) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }629    }630631    fun bytesToUuid(bytes: ByteArray): String {632        if (bytes.size != 16) throw PeachScriptException("UUID_LENGTH")633        val hex = bytes.joinToString("") { "%02x".format(it) }634        return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-" +635            "${hex.substring(16, 20)}-${hex.substring(20)}"636    }637638    // ---- crypto / hashing -------------------------------------------------639640    private fun deriveKey(passphrase: String, salt: ByteArray): ByteArray {641        // Argon2id over the UTF-8 BYTES of the passphrase, matching the browser's642        // hash-wasm KDF input (avoids the MF-4 UTF-16 divergence in this path).643        val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)644            .withSalt(salt)645            .withParallelism(ARGON2_PARALLELISM)646            .withMemoryAsKB(ARGON2_MEMORY_KIB)647            .withIterations(ARGON2_ITERATIONS)648            .build()649        val gen = Argon2BytesGenerator().apply { init(params) }650        val out = ByteArray(32)651        gen.generateBytes(passphrase.toByteArray(Charsets.UTF_8), out)652        return out653    }654655    private fun aesGcmEncrypt(key: ByteArray, nonce: ByteArray, plaintext: ByteArray): ByteArray {656        val cipher = Cipher.getInstance("AES/GCM/NoPadding")657        cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))658        return cipher.doFinal(plaintext) // ciphertext || tag; no AAD (browser uses none)659    }660661    private fun aesGcmDecrypt(key: ByteArray, nonce: ByteArray, ctTag: ByteArray): ByteArray {662        val cipher = Cipher.getInstance("AES/GCM/NoPadding")663        cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))664        return cipher.doFinal(ctTag)665    }666667    private fun blake3Truncated(data: ByteArray): ByteArray {668        val digest = Blake3Digest(256)669        digest.update(data, 0, data.size)670        val full = ByteArray(32)671        digest.doFinal(full, 0)672        return full.copyOfRange(0, CHECKSUM_LEN)673    }674675    private fun padToNearestBucket(data: ByteArray): ByteArray {676        val bucket = SIZE_BUCKETS.firstOrNull { it >= data.size }677        var target = bucket ?: run {678            var t = 262144679            while (t < data.size) t *= 2680            t681        }682        if (target == data.size) return data683        return data.copyOf(target)684    }685686    private fun constantTimeEqual(a: ByteArray, b: ByteArray): Boolean {687        if (a.size != b.size) return false688        var diff = 0689        for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt())690        return diff == 0691    }692693    private fun concat(parts: List<ByteArray>): ByteArray {694        val total = parts.sumOf { it.size }695        val out = ByteArray(total)696        var c = 0697        for (p in parts) { System.arraycopy(p, 0, out, c, p.size); c += p.size }698        return out699    }700701    // ---- little/big-endian helpers ----------------------------------------702703    private fun writeU32LE(buf: ByteArray, off: Int, v: Long) {704        buf[off] = (v and 0xff).toByte()705        buf[off + 1] = ((v ushr 8) and 0xff).toByte()706        buf[off + 2] = ((v ushr 16) and 0xff).toByte()707        buf[off + 3] = ((v ushr 24) and 0xff).toByte()708    }709710    private fun writeU32BE(buf: ByteArray, off: Int, v: Long) {711        buf[off] = ((v ushr 24) and 0xff).toByte()712        buf[off + 1] = ((v ushr 16) and 0xff).toByte()713        buf[off + 2] = ((v ushr 8) and 0xff).toByte()714        buf[off + 3] = (v and 0xff).toByte()715    }716717    private fun readU32LE(buf: ByteArray, off: Int): Long {718        return (buf[off].toLong() and 0xff) or719            ((buf[off + 1].toLong() and 0xff) shl 8) or720            ((buf[off + 2].toLong() and 0xff) shl 16) or721            ((buf[off + 3].toLong() and 0xff) shl 24)722    }723724    private fun readU32BE(buf: ByteArray, off: Int): Long {725        return ((buf[off].toLong() and 0xff) shl 24) or726            ((buf[off + 1].toLong() and 0xff) shl 16) or727            ((buf[off + 2].toLong() and 0xff) shl 8) or728            (buf[off + 3].toLong() and 0xff)729    }730731    private class BinaryWriter {732        private val out = ByteArrayOutputStream()733        fun u8(v: Int) { out.write(v and 0xff) }734        fun u16(v: Int) { out.write(v and 0xff); out.write((v ushr 8) and 0xff) }735        fun u32(v: Long) {736            out.write((v and 0xff).toInt()); out.write(((v ushr 8) and 0xff).toInt())737            out.write(((v ushr 16) and 0xff).toInt()); out.write(((v ushr 24) and 0xff).toInt())738        }739        fun bytes(b: ByteArray) { out.write(b) }740        fun lpString(value: String) {741            val enc = value.toByteArray(Charsets.UTF_8)742            if (enc.size > 0xff) throw PeachScriptException("STRING_TOO_LONG_U8")743            u8(enc.size); bytes(enc)744        }745        fun toBytes(): ByteArray = out.toByteArray()746    }747748    private class BinaryReader(private val data: ByteArray, start: Int) {749        private var cursor = start750        fun u8(): Int = data[cursor++].toInt() and 0xff751        fun u16(): Int { val v = (data[cursor].toInt() and 0xff) or ((data[cursor + 1].toInt() and 0xff) shl 8); cursor += 2; return v }752        fun u32(): Long {753            val v = (data[cursor].toLong() and 0xff) or ((data[cursor + 1].toLong() and 0xff) shl 8) or754                ((data[cursor + 2].toLong() and 0xff) shl 16) or ((data[cursor + 3].toLong() and 0xff) shl 24)755            cursor += 4; return v756        }757        fun read(n: Int): ByteArray { val out = data.copyOfRange(cursor, cursor + n); cursor += n; return out }758        fun lpString(): String = String(read(u8()), Charsets.UTF_8)759    }760761    private fun JsonObjectBuilder.putIfPresent(key: String, value: String?) {762        if (!value.isNullOrEmpty()) put(key, JsonPrimitive(value))763    }764    private fun JsonObjectBuilder.putJsonObjectStringIfPresent(key: String, value: String?) {765        if (value.isNullOrBlank()) return766        val obj = runCatching { json.parseToJsonElement(value).jsonObject }.getOrNull() ?: return767        put(key, obj)768    }769    private fun JsonObjectBuilder.putIfPresent(key: String, value: Int?) {770        if (value != null) put(key, JsonPrimitive(value))771    }772    private fun JsonObjectBuilder.putIfPresent(key: String, value: Long?) {773        if (value != null) put(key, JsonPrimitive(value))774    }775    private fun JsonObjectBuilder.putIfPresent(key: String, value: Boolean?) {776        if (value != null) put(key, JsonPrimitive(value))777    }778}