PeachPayloadCodec.kt

36.3 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    // M-02: hard caps on attacker-controlled lengths/counts. A crafted backup89    // could otherwise trigger OOM via huge declared uncompressed sizes or90    // decompression bombs. Bounds follow M-01's MAX_IMPORT_BYTES (50 MiB).91    private const val MAX_ENVELOPE_BYTES = 50L * 1024 * 102492    private const val MAX_INNER_BYTES = MAX_ENVELOPE_BYTES93    private const val MAX_SECTION_RAW_BYTES = 16L * 1024 * 102494    private const val MAX_SECTION_COMP_BYTES = 32L * 1024 * 102495    private const val MAX_ENTRY_COUNT = 100_000L96    private const val MAX_PASSKEY_BLOB_BYTES = 1L * 1024 * 10249798    class PeachScriptException(message: String) : Exception(message)99100    data class ParsedPayload(101        val entries: List<VaultEntry>,102        val settings: VaultSettings = VaultSettings(),103        val licenseKey: String? = null,104        val licenseActivatedAt: Long? = null,105    )106107    // ---- public API -------------------------------------------------------108109    /** Build the encrypted envelope `salt||nonce||ct||tag` from [entries]. */110    fun buildPayload(111        entries: List<VaultEntry>,112        passphrase: String,113        settings: VaultSettings = VaultSettings(),114        licenseKey: String? = null,115        licenseActivatedAt: Long? = null,116    ): ByteArray {117        val entriesRaw = encodeEntriesSection(entries)118        val sections = buildList {119            add(buildSection(SECTION_ENTRIES, entriesRaw))120            add(buildSection(SECTION_SETTINGS, encodeSettingsSection(settings)))121            if (!licenseKey.isNullOrBlank()) {122                add(buildSection(SECTION_LICENSE, encodeLicenseSection(licenseKey, licenseActivatedAt)))123            }124        }125126        val header = ByteArray(HEADER_LEN)127        writeU32BE(header, 0, MAGIC.toLong())128        header[4] = VERSION.toByte()129        header[5] = sections.size.toByte()130        // bytes 6,7 reserved (zero)131132        val body = concat(listOf(header) + sections)133        val checksum = blake3Truncated(body)134        val inner = concat(listOf(body, checksum))135136        val header5 = ByteArray(5)137        header5[0] = FORMAT_V3.toByte()138        writeU32LE(header5, 1, inner.size.toLong())139        val wrapped = padToNearestBucket(concat(listOf(header5, inner)))140141        val salt = ByteArray(SALT_LEN).also(secureRandom::nextBytes)142        val nonce = ByteArray(NONCE_LEN).also(secureRandom::nextBytes)143        val key = deriveKey(passphrase, salt)144        return try {145            val ctTag = aesGcmEncrypt(key, nonce, wrapped)146            concat(listOf(salt, nonce, ctTag))147        } finally {148            key.fill(0)149        }150    }151152    /** Parse the encrypted envelope back to entries. Throws [PeachScriptException]. */153    fun parsePayload(envelope: ByteArray, passphrase: String): List<VaultEntry> =154        parsePayloadData(envelope, passphrase).entries155156    /** Parse the encrypted envelope back to all supported payload metadata. */157    fun parsePayloadData(envelope: ByteArray, passphrase: String): ParsedPayload {158        // M-02: cap before any allocation so a crafted 4 GB envelope can't OOM us.159        if (envelope.size > MAX_ENVELOPE_BYTES) throw PeachScriptException("PAYLOAD_TOO_LARGE")160        if (envelope.size < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {161            throw PeachScriptException("PAYLOAD_TOO_SHORT")162        }163        val salt = envelope.copyOfRange(0, SALT_LEN)164        val nonce = envelope.copyOfRange(SALT_LEN, SALT_LEN + NONCE_LEN)165        val ctTag = envelope.copyOfRange(SALT_LEN + NONCE_LEN, envelope.size)166167        val key = deriveKey(passphrase, salt)168        val decrypted = try {169            aesGcmDecrypt(key, nonce, ctTag)170        } catch (_: Exception) {171            throw PeachScriptException("INVALID_PASSPHRASE")172        } finally {173            key.fill(0)174        }175176        // Detect padded v2/v3 format: leading FORMAT byte + 4-byte LE inner length.177        // M-02: keep innerLen as Long; the prior `5 + innerLen` could overflow Int178        // when the wire value set the high bit, bypassing the size check.179        val inner: ByteArray180        if (decrypted.size >= 5 && (decrypted[0].toInt() == FORMAT_V2 || decrypted[0].toInt() == FORMAT_V3)) {181            val innerLen = readU32LE(decrypted, 1)182            if (innerLen > MAX_INNER_BYTES) throw PeachScriptException("INNER_TOO_LARGE")183            if (5L + innerLen > decrypted.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")184            inner = decrypted.copyOfRange(5, Math.toIntExact(5L + innerLen))185        } else {186            inner = decrypted // v1 — no length prefix / no padding187        }188189        if (inner.size < HEADER_LEN + CHECKSUM_LEN) throw PeachScriptException("PAYLOAD_TOO_SHORT")190        val body = inner.copyOfRange(0, inner.size - CHECKSUM_LEN)191        val stored = inner.copyOfRange(inner.size - CHECKSUM_LEN, inner.size)192        if (!constantTimeEqual(stored, blake3Truncated(body))) throw PeachScriptException("CHECKSUM_FAILED")193194        if (readU32BE(body, 0) != MAGIC.toLong()) throw PeachScriptException("BAD_MAGIC")195        val version = body[4].toInt() and 0xff196        if (version != VERSION) throw PeachScriptException("UNSUPPORTED_VERSION:$version")197        val sectionCount = body[5].toInt() and 0xff198199        var entries = emptyList<VaultEntry>()200        var settings = VaultSettings()201        var licenseKey: String? = null202        var licenseActivatedAt: Long? = null203        var cursor = HEADER_LEN204        for (i in 0 until sectionCount) {205            if (cursor + 9 > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")206            val type = body[cursor].toInt() and 0xff207            // M-02: keep raw/compressed sizes as Long and cap before Zstd allocates208            // the declared uncompressed length (decompression-bomb guard).209            val length = readU32LE(body, cursor + 1)210            val clength = readU32LE(body, cursor + 5)211            if (length > MAX_SECTION_RAW_BYTES || clength > MAX_SECTION_COMP_BYTES) {212                throw PeachScriptException("SECTION_TOO_LARGE")213            }214            val dataStart = cursor + 9215            val dataEndLong = dataStart.toLong() + clength216            if (dataEndLong > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")217            val compressed = body.copyOfRange(dataStart, Math.toIntExact(dataEndLong))218            val data = Zstd.decompress(compressed, Math.toIntExact(length))219            if (data.size.toLong() != length) throw PeachScriptException("SECTION_LENGTH_MISMATCH")220            when (type) {221                SECTION_ENTRIES -> entries = decodeEntriesSection(data)222                SECTION_SETTINGS -> settings = decodeSettingsSection(data) ?: VaultSettings()223                SECTION_LICENSE -> {224                    val license = decodeLicenseSection(data)225                    licenseKey = license?.first226                    licenseActivatedAt = license?.second227                }228                // RECALL/DECLINED_CARDS and unknown types are skipped. Android229                // does not expose browser manual-summon or declined-card stores.230                else -> {}231            }232            cursor = Math.toIntExact(dataEndLong)233        }234        return ParsedPayload(235            entries = entries,236            settings = settings,237            licenseKey = licenseKey,238            licenseActivatedAt = licenseActivatedAt,239        )240    }241242    // ---- section assembly (peachPayload.ts:316-333) -----------------------243244    private fun buildSection(type: Int, raw: ByteArray): ByteArray {245        val compressed = Zstd.compress(raw, ZSTD_LEVEL)246        val out = ByteArray(9 + compressed.size)247        out[0] = type.toByte()248        writeU32LE(out, 1, raw.size.toLong())249        writeU32LE(out, 5, compressed.size.toLong())250        System.arraycopy(compressed, 0, out, 9, compressed.size)251        return out252    }253254    private fun encodeSettingsSection(settings: VaultSettings): ByteArray =255        VaultCodec.encodeSettings(settings).toByteArray(Charsets.UTF_8)256257    private fun decodeSettingsSection(data: ByteArray): VaultSettings? =258        VaultCodec.decodeSettings(String(data, Charsets.UTF_8))259260    private fun encodeLicenseSection(key: String, activatedAt: Long?): ByteArray {261        val obj = buildJsonObject {262            put("key", JsonPrimitive(key))263            if (activatedAt != null) put("activatedAt", JsonPrimitive(activatedAt))264        }265        return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8)266    }267268    private fun decodeLicenseSection(data: ByteArray): Pair<String, Long?>? {269        val obj = runCatching { json.parseToJsonElement(String(data, Charsets.UTF_8)).jsonObject }.getOrNull()270            ?: return null271        val key = obj["key"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null272        val activatedAt = obj["activatedAt"]?.jsonPrimitive?.longOrNull273        return key to activatedAt274    }275276    // ---- entries section: string table + packed bodies --------------------277278    private class StringTable {279        private val map = LinkedHashMap<String, Int>()280281        /** u16 index; 0 reserved for absent. */282        fun intern(value: String?): Int {283            if (value.isNullOrEmpty()) return 0284            map[value]?.let { return it }285            val idx = map.size + 1286            if (idx > 0xffff) throw PeachScriptException("STRING_TABLE_OVERFLOW")287            map[value] = idx288            return idx289        }290291        fun serialize(): ByteArray {292            val out = BinaryWriter()293            out.u16(map.size)294            for (s in map.keys) {295                val e = s.toByteArray(Charsets.UTF_8)296                if (e.size > 0xffff) throw PeachScriptException("STRING_TOO_LONG")297                out.u16(e.size)298                out.bytes(e)299            }300            return out.toBytes()301        }302    }303304    private fun encodeEntriesSection(entries: List<VaultEntry>): ByteArray {305        val table = StringTable()306        // Pre-intern every title first (peachPayload.ts:400-401) so indices match.307        for (e in entries) table.intern(e.title)308        val bodies = entries.map { encodeEntry(it, table) }309        val tableBytes = table.serialize()310311        val out = BinaryWriter()312        out.u32(entries.size.toLong())313        out.bytes(tableBytes)314        for (b in bodies) out.bytes(b)315        return out.toBytes()316    }317318    private fun decodeEntriesSection(data: ByteArray): List<VaultEntry> {319        val r = BinaryReader(data, 0)320        // M-02: cap entry count before pre-allocating the list (was unbounded).321        val count = r.u32()322        if (count > MAX_ENTRY_COUNT) throw PeachScriptException("ENTRY_COUNT_OVERFLOW")323        val strings = readStringTable(r)324        val out = ArrayList<VaultEntry>(Math.toIntExact(count))325        for (i in 0 until count) out.add(decodeEntry(r, strings))326        return out327    }328329    private fun readStringTable(r: BinaryReader): List<String?> {330        val count = r.u16()331        val strings = ArrayList<String?>(count + 1)332        strings.add(null) // index 0 = absent333        for (i in 0 until count) {334            val len = r.u16()335            strings.add(String(r.read(len), Charsets.UTF_8))336        }337        return strings338    }339340    private fun lookup(strings: List<String?>, idx: Int): String? =341        if (idx == 0 || idx >= strings.size) null else strings[idx]342343    // ---- per-entry packing (peachPayload.ts:443-601) ----------------------344345    private fun encodeEntry(entry: VaultEntry, table: StringTable): ByteArray {346        val type = entryTypeByte(entry)347        val w = BinaryWriter()348        w.u8(type)349        w.bytes(uuidToBytes(entry.id))350351        val createdSec = entry.createdAt / 1000L352        val updatedSec = entry.modifiedAt / 1000L353        val updatedDelta = maxOf(0L, updatedSec - createdSec)354        val deletedSec = entry.deletedAt?.let { it / 1000L } ?: 0L355356        // Compute flags up front (the browser back-patches; same bytes result).357        var flags = 0358        var flags2 = 0359        if (entry.deletedAt != null) flags = flags or F_HAS_DELETED360361        when (type) {362            ENTRY_TYPE_LOGIN -> {363                if (!entry.email.isNullOrEmpty()) flags2 = flags2 or F2_HAS_EMAIL364                if (!entry.totp.isNullOrEmpty()) flags = flags or F_HAS_TOTP365                if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS366                if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES367                if (entry.canary != null) flags = flags or F_HAS_CANARY368                if (entry.passwordChangedAt != null) flags = flags or F_HAS_PWCHANGED369                if ((entry.breach ?: 0) > 0) flags = flags or F_HAS_BREACH370                if (!entry.additionalUrls.isNullOrEmpty()) flags = flags or F_HAS_ADDITIONAL_URLS371            }372            ENTRY_TYPE_NOTE -> {373                if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES374                if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS375            }376            ENTRY_TYPE_CARD -> {377                if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS378                if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES379            }380        }381382        w.u8(flags)383        w.u8(flags2) // FORMAT_V3 always writes flags2384        w.u32(createdSec)385        w.u32(updatedDelta)386        w.u32(deletedSec)387        w.u16(table.intern(entry.title))388389        when (type) {390            ENTRY_TYPE_LOGIN -> {391                w.u16(table.intern(entry.url))392                w.u16(table.intern(entry.username))393                w.lpString(entry.password ?: "")394                if (flags2 and F2_HAS_EMAIL != 0) w.lpString(entry.email!!)395                if (flags and F_HAS_TOTP != 0) w.lpString(entry.totp!!)396                if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)397                if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))398                if (flags and F_HAS_CANARY != 0) {399                    w.u16(table.intern(entry.canary!!.webhookUrl))400                    w.u16(table.intern(entry.canary.label))401                }402                if (flags and F_HAS_PWCHANGED != 0) {403                    val pwSec = entry.passwordChangedAt!! / 1000L404                    w.u32(maxOf(0L, pwSec - createdSec))405                }406                if (flags and F_HAS_BREACH != 0) w.u32((entry.breach ?: 0).toLong())407                if (flags and F_HAS_ADDITIONAL_URLS != 0) writeStrings(w, table, entry.additionalUrls!!)408            }409            ENTRY_TYPE_NOTE -> {410                if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))411                if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)412            }413            ENTRY_TYPE_CARD -> {414                w.u16(table.intern(entry.cardholderName))415                w.lpString(entry.cardNumber ?: "")416                w.lpString(entry.expMonth ?: "")417                w.lpString(entry.expYear ?: "")418                w.lpString(entry.cvc ?: "")419                w.u16(table.intern(entry.cardNickname))420                w.u8(networkToByte(entry.cardNetwork))421                // Android has no last4 field — derive it from the card number so the422                // wire format matches the browser (BROWSER-SPEC: card last4 note).423                w.u16(table.intern(deriveLast4(entry)))424                if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)425                if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))426            }427            ENTRY_TYPE_PASSKEY -> {428                val blob = encodePasskeyBlob(entry)429                w.u32(blob.size.toLong())430                w.bytes(blob)431            }432        }433        return w.toBytes()434    }435436    private fun decodeEntry(r: BinaryReader, strings: List<String?>): VaultEntry {437        val type = r.u8()438        val id = bytesToUuid(r.read(16))439        val flags = r.u8()440        val flags2 = r.u8() // FORMAT_V3441        val createdSec = r.u32()442        val updatedDelta = r.u32()443        val deletedSec = r.u32()444        val titleIdx = r.u16()445446        val createdAt = createdSec * 1000L447        val modifiedAt = (createdSec + updatedDelta) * 1000L448        val deletedAt = if (deletedSec > 0) deletedSec * 1000L else null449        val title = lookup(strings, titleIdx) ?: ""450451        return when (type) {452            ENTRY_TYPE_LOGIN -> {453                val urlIdx = r.u16()454                val usernameIdx = r.u16()455                val password = r.lpString()456                val email = if (flags2 and F2_HAS_EMAIL != 0) r.lpString() else null457                val totp = if (flags and F_HAS_TOTP != 0) r.lpString() else null458                val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null459                val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null460                var canary: Canary? = null461                if (flags and F_HAS_CANARY != 0) {462                    val webhookUrl = lookup(strings, r.u16()) ?: ""463                    val label = lookup(strings, r.u16())464                    canary = Canary(webhookUrl = webhookUrl, label = label)465                }466                val pwChanged = if (flags and F_HAS_PWCHANGED != 0) (createdSec + r.u32()) * 1000L else null467                val breach = if (flags and F_HAS_BREACH != 0) r.u32().toInt() else null468                val additionalUrls = if (flags and F_HAS_ADDITIONAL_URLS != 0) readStrings(r, strings) else null469                VaultEntry(470                    id = id, type = "login", title = title,471                    url = lookup(strings, urlIdx), username = lookup(strings, usernameIdx),472                    password = password, email = email, totp = totp, tags = tags, notes = notes,473                    canary = canary, passwordChangedAt = pwChanged, breach = breach,474                    additionalUrls = additionalUrls, createdAt = createdAt, modifiedAt = modifiedAt,475                    deletedAt = deletedAt,476                )477            }478            ENTRY_TYPE_NOTE -> {479                val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null480                val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null481                VaultEntry(482                    id = id, type = "note", title = title, notes = notes, tags = tags,483                    createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,484                )485            }486            ENTRY_TYPE_CARD -> {487                val cardholderIdx = r.u16()488                val cardNumber = r.lpString()489                val expMonth = r.lpString()490                val expYear = r.lpString()491                val cvc = r.lpString()492                val nicknameIdx = r.u16()493                val network = networkFromByte(r.u8())494                r.u16() // last4 index — Android has no field; dropped (number carries it)495                val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null496                val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null497                VaultEntry(498                    id = id, type = "card", title = title,499                    cardholderName = lookup(strings, cardholderIdx),500                    cardNumber = cardNumber.ifEmpty { null }, expMonth = expMonth.ifEmpty { null },501                    expYear = expYear.ifEmpty { null }, cvc = cvc.ifEmpty { null },502                    cardNickname = lookup(strings, nicknameIdx), cardNetwork = network,503                    tags = tags, notes = notes,504                    createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,505                )506            }507            ENTRY_TYPE_PASSKEY -> {508                // M-02: cap passkey blob before allocation (was unbounded).509                val blobLen = r.u32()510                if (blobLen > MAX_PASSKEY_BLOB_BYTES) throw PeachScriptException("PASSKEY_BLOB_TOO_LARGE")511                val blob = r.read(Math.toIntExact(blobLen))512                decodePasskeyBlob(id, title, createdAt, modifiedAt, deletedAt, blob)513            }514            else -> throw PeachScriptException("UNKNOWN_ENTRY_TYPE:$type")515        }516    }517518    private fun writeTags(w: BinaryWriter, table: StringTable, tags: List<String>) {519        val n = minOf(tags.size, 0xff)520        w.u8(n)521        for (i in 0 until n) w.u16(table.intern(tags[i]))522    }523524    private fun writeStrings(w: BinaryWriter, table: StringTable, items: List<String>) {525        val n = minOf(items.size, 0xff)526        w.u8(n)527        for (i in 0 until n) w.u16(table.intern(items[i]))528    }529530    private fun readTags(r: BinaryReader, strings: List<String?>): List<String> {531        val n = r.u8()532        val out = ArrayList<String>(n)533        for (i in 0 until n) lookup(strings, r.u16())?.let { out.add(it) }534        return out535    }536537    private fun readStrings(r: BinaryReader, strings: List<String?>): List<String> = readTags(r, strings)538539    private fun entryTypeByte(entry: VaultEntry): Int {540        if (entry.passkey != null) return ENTRY_TYPE_PASSKEY541        return when (entry.type) {542            "note" -> ENTRY_TYPE_NOTE543            "card" -> ENTRY_TYPE_CARD544            "passkey" -> ENTRY_TYPE_PASSKEY545            else -> ENTRY_TYPE_LOGIN546        }547    }548549    private fun networkToByte(network: String?): Int {550        if (network.isNullOrEmpty()) return 0xff551        val idx = NETWORK_VALUES.indexOf(network)552        return if (idx >= 0) idx else NETWORK_VALUES.indexOf("unknown")553    }554555    private fun networkFromByte(b: Int): String? {556        if (b == 0xff) return null557        return NETWORK_VALUES.getOrNull(b) ?: "unknown"558    }559560    private fun deriveLast4(entry: VaultEntry): String? {561        val digits = entry.cardNumber?.filter { it.isDigit() } ?: return null562        return if (digits.length >= 4) digits.takeLast(4) else digits.ifEmpty { null }563    }564565    // ---- passkey JSON blob (peachPayload.ts:552-596) ----------------------566    // Browser passkeys carry privateKeyJwk as a JSON object. Android stores that567    // object as compact JSON text so Parcelable vault entries can preserve it568    // across import/export; Credential Provider signing is implemented separately.569570    private fun encodePasskeyBlob(entry: VaultEntry): ByteArray {571        val pk = entry.passkey572        val obj = buildJsonObject {573            putIfPresent("rpId", pk?.rpId ?: entry.rpId)574            putIfPresent("rpName", pk?.rpName ?: entry.rpName)575            putIfPresent("credentialId", pk?.credentialId ?: entry.credentialId)576            putIfPresent("publicKeyCose", pk?.publicKeyCose ?: entry.publicKeyCose)577            putJsonObjectStringIfPresent("privateKeyJwk", pk?.privateKeyJwk ?: entry.privateKeyJwk)578            putIfPresent("userHandle", pk?.userHandle ?: entry.userHandle)579            putIfPresent("userName", pk?.userName ?: entry.userName)580            putIfPresent("userDisplayName", pk?.userDisplayName ?: entry.userDisplayName)581            putIfPresent("algorithm", (pk?.algorithm ?: entry.algorithm))582            putIfPresent("signCount", (pk?.signCount ?: entry.signCount))583            putIfPresent("aaguid", pk?.aaguid ?: entry.aaguid)584            putIfPresent("backupEligible", pk?.backupEligible ?: entry.backupEligible)585            putIfPresent("backupState", pk?.backupState ?: entry.backupState)586            putIfPresent("lastUsedAt", entry.lastUsedAt)587            // Login fields carried alongside a passkey-bearing login entry.588            putIfPresent("password", entry.password)589            putIfPresent("url", entry.url)590            putIfPresent("username", entry.username)591            putIfPresent("email", entry.email)592            putIfPresent("totp", entry.totp)593            putIfPresent("notes", entry.notes)594            if (!entry.tags.isNullOrEmpty()) {595                put("tags", buildJsonArray { entry.tags.forEach { add(JsonPrimitive(it)) } })596            }597        }598        return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8)599    }600601    private fun decodePasskeyBlob(602        id: String, title: String, createdAt: Long, modifiedAt: Long, deletedAt: Long?, blob: ByteArray,603    ): VaultEntry {604        val obj = json.parseToJsonElement(String(blob, Charsets.UTF_8)).jsonObject605        fun str(k: String) = obj[k]?.jsonPrimitive?.contentOrNull606        fun intf(k: String) = obj[k]?.jsonPrimitive?.intOrNull607        fun longf(k: String) = obj[k]?.jsonPrimitive?.longOrNull608        fun boolf(k: String) = obj[k]?.jsonPrimitive?.booleanOrNull609        fun objectJson(k: String): String? {610            val value = obj[k] ?: return null611            val nested = runCatching { value.jsonObject }.getOrNull() ?: return null612            return json.encodeToString(JsonObject.serializer(), nested)613        }614        val tags = obj["tags"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull }615        val credentialId = str("credentialId")616        val publicKeyCose = str("publicKeyCose")617        val privateKeyJwk = objectJson("privateKeyJwk")618        val rpId = str("rpId")619        val userHandle = str("userHandle")620        val userName = str("userName")621        val passkey = if (credentialId != null && publicKeyCose != null && rpId != null &&622            userHandle != null && userName != null) {623            PasskeyData(624                credentialId = credentialId, publicKeyCose = publicKeyCose, rpId = rpId,625                privateKeyJwk = privateKeyJwk,626                rpName = str("rpName"), userHandle = userHandle, userName = userName,627                userDisplayName = str("userDisplayName"), algorithm = intf("algorithm") ?: -7,628                signCount = intf("signCount") ?: 0, aaguid = str("aaguid"),629                backupEligible = boolf("backupEligible") ?: false, backupState = boolf("backupState") ?: false,630            )631        } else null632        return VaultEntry(633            id = id, type = "passkey", title = title,634            url = str("url"), username = str("username"), email = str("email"),635            password = str("password"), totp = str("totp"), notes = str("notes"), tags = tags,636            createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,637            passkey = passkey, lastUsedAt = longf("lastUsedAt"),638            rpId = rpId, rpName = str("rpName"), credentialId = credentialId,639            publicKeyCose = publicKeyCose, privateKeyJwk = privateKeyJwk,640            userHandle = userHandle, userName = userName,641            userDisplayName = str("userDisplayName"), algorithm = intf("algorithm"),642            aaguid = str("aaguid"), backupEligible = boolf("backupEligible"),643            backupState = boolf("backupState"), signCount = intf("signCount"),644        )645    }646647    // ---- uuid (peachPayload.ts:792-806) -----------------------------------648649    fun uuidToBytes(uuid: String): ByteArray {650        val hex = uuid.replace("-", "")651        if (hex.length != 32) throw PeachScriptException("UUID_FORMAT")652        return ByteArray(16) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }653    }654655    fun bytesToUuid(bytes: ByteArray): String {656        if (bytes.size != 16) throw PeachScriptException("UUID_LENGTH")657        val hex = bytes.joinToString("") { "%02x".format(it) }658        return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-" +659            "${hex.substring(16, 20)}-${hex.substring(20)}"660    }661662    // ---- crypto / hashing -------------------------------------------------663664    private fun deriveKey(passphrase: String, salt: ByteArray): ByteArray {665        // Argon2id over the UTF-8 BYTES of the passphrase, matching the browser's666        // hash-wasm KDF input (avoids the MF-4 UTF-16 divergence in this path).667        val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)668            .withSalt(salt)669            .withParallelism(ARGON2_PARALLELISM)670            .withMemoryAsKB(ARGON2_MEMORY_KIB)671            .withIterations(ARGON2_ITERATIONS)672            .build()673        val gen = Argon2BytesGenerator().apply { init(params) }674        val out = ByteArray(32)675        gen.generateBytes(passphrase.toByteArray(Charsets.UTF_8), out)676        return out677    }678679    private fun aesGcmEncrypt(key: ByteArray, nonce: ByteArray, plaintext: ByteArray): ByteArray {680        val cipher = Cipher.getInstance("AES/GCM/NoPadding")681        cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))682        return cipher.doFinal(plaintext) // ciphertext || tag; no AAD (browser uses none)683    }684685    private fun aesGcmDecrypt(key: ByteArray, nonce: ByteArray, ctTag: ByteArray): ByteArray {686        val cipher = Cipher.getInstance("AES/GCM/NoPadding")687        cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))688        return cipher.doFinal(ctTag)689    }690691    private fun blake3Truncated(data: ByteArray): ByteArray {692        val digest = Blake3Digest(256)693        digest.update(data, 0, data.size)694        val full = ByteArray(32)695        digest.doFinal(full, 0)696        return full.copyOfRange(0, CHECKSUM_LEN)697    }698699    private fun padToNearestBucket(data: ByteArray): ByteArray {700        val bucket = SIZE_BUCKETS.firstOrNull { it >= data.size }701        var target = bucket ?: run {702            var t = 262144703            while (t < data.size) t *= 2704            t705        }706        if (target == data.size) return data707        return data.copyOf(target)708    }709710    private fun constantTimeEqual(a: ByteArray, b: ByteArray): Boolean {711        if (a.size != b.size) return false712        var diff = 0713        for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt())714        return diff == 0715    }716717    private fun concat(parts: List<ByteArray>): ByteArray {718        val total = parts.sumOf { it.size }719        val out = ByteArray(total)720        var c = 0721        for (p in parts) { System.arraycopy(p, 0, out, c, p.size); c += p.size }722        return out723    }724725    // ---- little/big-endian helpers ----------------------------------------726727    private fun writeU32LE(buf: ByteArray, off: Int, v: Long) {728        buf[off] = (v and 0xff).toByte()729        buf[off + 1] = ((v ushr 8) and 0xff).toByte()730        buf[off + 2] = ((v ushr 16) and 0xff).toByte()731        buf[off + 3] = ((v ushr 24) and 0xff).toByte()732    }733734    private fun writeU32BE(buf: ByteArray, off: Int, v: Long) {735        buf[off] = ((v ushr 24) and 0xff).toByte()736        buf[off + 1] = ((v ushr 16) and 0xff).toByte()737        buf[off + 2] = ((v ushr 8) and 0xff).toByte()738        buf[off + 3] = (v and 0xff).toByte()739    }740741    private fun readU32LE(buf: ByteArray, off: Int): Long {742        return (buf[off].toLong() and 0xff) or743            ((buf[off + 1].toLong() and 0xff) shl 8) or744            ((buf[off + 2].toLong() and 0xff) shl 16) or745            ((buf[off + 3].toLong() and 0xff) shl 24)746    }747748    private fun readU32BE(buf: ByteArray, off: Int): Long {749        return ((buf[off].toLong() and 0xff) shl 24) or750            ((buf[off + 1].toLong() and 0xff) shl 16) or751            ((buf[off + 2].toLong() and 0xff) shl 8) or752            (buf[off + 3].toLong() and 0xff)753    }754755    private class BinaryWriter {756        private val out = ByteArrayOutputStream()757        fun u8(v: Int) { out.write(v and 0xff) }758        fun u16(v: Int) { out.write(v and 0xff); out.write((v ushr 8) and 0xff) }759        fun u32(v: Long) {760            out.write((v and 0xff).toInt()); out.write(((v ushr 8) and 0xff).toInt())761            out.write(((v ushr 16) and 0xff).toInt()); out.write(((v ushr 24) and 0xff).toInt())762        }763        fun bytes(b: ByteArray) { out.write(b) }764        fun lpString(value: String) {765            val enc = value.toByteArray(Charsets.UTF_8)766            if (enc.size > 0xff) throw PeachScriptException("STRING_TOO_LONG_U8")767            u8(enc.size); bytes(enc)768        }769        fun toBytes(): ByteArray = out.toByteArray()770    }771772    private class BinaryReader(private val data: ByteArray, start: Int) {773        private var cursor = start774        fun u8(): Int = data[cursor++].toInt() and 0xff775        fun u16(): Int { val v = (data[cursor].toInt() and 0xff) or ((data[cursor + 1].toInt() and 0xff) shl 8); cursor += 2; return v }776        fun u32(): Long {777            val v = (data[cursor].toLong() and 0xff) or ((data[cursor + 1].toLong() and 0xff) shl 8) or778                ((data[cursor + 2].toLong() and 0xff) shl 16) or ((data[cursor + 3].toLong() and 0xff) shl 24)779            cursor += 4; return v780        }781        fun read(n: Int): ByteArray { val out = data.copyOfRange(cursor, cursor + n); cursor += n; return out }782        fun lpString(): String = String(read(u8()), Charsets.UTF_8)783    }784785    private fun JsonObjectBuilder.putIfPresent(key: String, value: String?) {786        if (!value.isNullOrEmpty()) put(key, JsonPrimitive(value))787    }788    private fun JsonObjectBuilder.putJsonObjectStringIfPresent(key: String, value: String?) {789        if (value.isNullOrBlank()) return790        val obj = runCatching { json.parseToJsonElement(value).jsonObject }.getOrNull() ?: return791        put(key, obj)792    }793    private fun JsonObjectBuilder.putIfPresent(key: String, value: Int?) {794        if (value != null) put(key, JsonPrimitive(value))795    }796    private fun JsonObjectBuilder.putIfPresent(key: String, value: Long?) {797        if (value != null) put(key, JsonPrimitive(value))798    }799    private fun JsonObjectBuilder.putIfPresent(key: String, value: Boolean?) {800        if (value != null) put(key, JsonPrimitive(value))801    }802}