PeachPayloadCodec.kt

37.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).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 = 0x0355    private const val FORMAT_V4 = 0x045657    private const val ARGON2_MEMORY_KIB = 6553658    private const val ARGON2_ITERATIONS = 359    private const val ARGON2_PARALLELISM = 46061    private const val SECTION_ENTRIES = 0x0162    private const val SECTION_SETTINGS = 0x0263    private const val SECTION_LICENSE = 0x0364    private const val SECTION_RECALL = 0x0465    private const val SECTION_DECLINED_CARDS = 0x056667    private const val ENTRY_TYPE_LOGIN = 0x0168    private const val ENTRY_TYPE_NOTE = 0x0269    private const val ENTRY_TYPE_CARD = 0x0370    private const val ENTRY_TYPE_PASSKEY = 0x047172    private const val F_HAS_TOTP = 1 shl 073    private const val F_HAS_TAGS = 1 shl 174    private const val F_HAS_NOTES = 1 shl 275    private const val F_HAS_CANARY = 1 shl 376    private const val F_HAS_PWCHANGED = 1 shl 477    private const val F_HAS_BREACH = 1 shl 578    private const val F_HAS_ADDITIONAL_URLS = 1 shl 679    private const val F_HAS_DELETED = 1 shl 78081    private const val F2_HAS_EMAIL = 1 shl 08283    private val NETWORK_VALUES = listOf("visa", "mastercard", "amex", "discover", "unknown")8485    private const val ZSTD_LEVEL = 1986    private val secureRandom = SecureRandom()87    private val json = Json { ignoreUnknownKeys = true; encodeDefaults = false }8889    // M-02: hard caps on attacker-controlled lengths/counts. A crafted backup90    // could otherwise trigger OOM via huge declared uncompressed sizes or91    // decompression bombs. Bounds follow M-01's MAX_IMPORT_BYTES (50 MiB).92    private const val MAX_ENVELOPE_BYTES = 50L * 1024 * 102493    private const val MAX_INNER_BYTES = MAX_ENVELOPE_BYTES94    private const val MAX_SECTION_RAW_BYTES = 16L * 1024 * 102495    private const val MAX_SECTION_COMP_BYTES = 32L * 1024 * 102496    private const val MAX_ENTRY_COUNT = 100_000L97    private const val MAX_PASSKEY_BLOB_BYTES = 1L * 1024 * 10249899    class PeachScriptException(message: String) : Exception(message)100101    data class ParsedPayload(102        val entries: List<VaultEntry>,103        val settings: VaultSettings = VaultSettings(),104        val licenseKey: String? = null,105        val licenseActivatedAt: Long? = null,106    )107108    // ---- public API -------------------------------------------------------109110    /** Build the encrypted envelope `salt||nonce||ct||tag` from [entries]. */111    fun buildPayload(112        entries: List<VaultEntry>,113        passphrase: String,114        settings: VaultSettings = VaultSettings(),115        licenseKey: String? = null,116        licenseActivatedAt: Long? = null,117    ): ByteArray {118        val entriesRaw = encodeEntriesSection(entries)119        val sections = buildList {120            add(buildSection(SECTION_ENTRIES, entriesRaw))121            add(buildSection(SECTION_SETTINGS, encodeSettingsSection(settings)))122            if (!licenseKey.isNullOrBlank()) {123                add(buildSection(SECTION_LICENSE, encodeLicenseSection(licenseKey, licenseActivatedAt)))124            }125        }126127        val header = ByteArray(HEADER_LEN)128        writeU32BE(header, 0, MAGIC.toLong())129        header[4] = VERSION.toByte()130        header[5] = sections.size.toByte()131        // bytes 6,7 reserved (zero)132133        val body = concat(listOf(header) + sections)134        val checksum = blake3Truncated(body)135        val inner = concat(listOf(body, checksum))136137        val header5 = ByteArray(5)138        header5[0] = FORMAT_V4.toByte()139        writeU32LE(header5, 1, inner.size.toLong())140        val wrapped = padToNearestBucket(concat(listOf(header5, inner)))141142        val salt = ByteArray(SALT_LEN).also(secureRandom::nextBytes)143        val nonce = ByteArray(NONCE_LEN).also(secureRandom::nextBytes)144        val key = deriveKey(passphrase, salt)145        return try {146            val ctTag = aesGcmEncrypt(key, nonce, wrapped)147            concat(listOf(salt, nonce, ctTag))148        } finally {149            key.fill(0)150        }151    }152153    /** Parse the encrypted envelope back to entries. Throws [PeachScriptException]. */154    fun parsePayload(envelope: ByteArray, passphrase: String): List<VaultEntry> =155        parsePayloadData(envelope, passphrase).entries156157    /** Parse the encrypted envelope back to all supported payload metadata. */158    fun parsePayloadData(envelope: ByteArray, passphrase: String): ParsedPayload {159        // M-02: cap before any allocation so a crafted 4 GB envelope can't OOM us.160        if (envelope.size > MAX_ENVELOPE_BYTES) throw PeachScriptException("PAYLOAD_TOO_LARGE")161        if (envelope.size < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {162            throw PeachScriptException("PAYLOAD_TOO_SHORT")163        }164        val salt = envelope.copyOfRange(0, SALT_LEN)165        val nonce = envelope.copyOfRange(SALT_LEN, SALT_LEN + NONCE_LEN)166        val ctTag = envelope.copyOfRange(SALT_LEN + NONCE_LEN, envelope.size)167168        val key = deriveKey(passphrase, salt)169        val decrypted = try {170            aesGcmDecrypt(key, nonce, ctTag)171        } catch (_: Exception) {172            throw PeachScriptException("INVALID_PASSPHRASE")173        } finally {174            key.fill(0)175        }176177        // Detect padded v2-v4 format: leading FORMAT byte + 4-byte LE inner length.178        // M-02: keep innerLen as Long; the prior `5 + innerLen` could overflow Int179        // when the wire value set the high bit, bypassing the size check.180        val inner: ByteArray181        val format = decrypted.firstOrNull()?.toInt()?.and(0xff) ?: 1182        if (decrypted.size >= 5 && format in FORMAT_V2..FORMAT_V4) {183            val innerLen = readU32LE(decrypted, 1)184            if (innerLen > MAX_INNER_BYTES) throw PeachScriptException("INNER_TOO_LARGE")185            if (5L + innerLen > decrypted.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")186            inner = decrypted.copyOfRange(5, Math.toIntExact(5L + innerLen))187        } else {188            inner = decrypted // v1 — no length prefix / no padding189        }190191        if (inner.size < HEADER_LEN + CHECKSUM_LEN) throw PeachScriptException("PAYLOAD_TOO_SHORT")192        val body = inner.copyOfRange(0, inner.size - CHECKSUM_LEN)193        val stored = inner.copyOfRange(inner.size - CHECKSUM_LEN, inner.size)194        if (!constantTimeEqual(stored, blake3Truncated(body))) throw PeachScriptException("CHECKSUM_FAILED")195196        if (readU32BE(body, 0) != MAGIC.toLong()) throw PeachScriptException("BAD_MAGIC")197        val version = body[4].toInt() and 0xff198        if (version != VERSION) throw PeachScriptException("UNSUPPORTED_VERSION:$version")199        val sectionCount = body[5].toInt() and 0xff200201        var entries = emptyList<VaultEntry>()202        var settings = VaultSettings()203        var licenseKey: String? = null204        var licenseActivatedAt: Long? = null205        var cursor = HEADER_LEN206        for (i in 0 until sectionCount) {207            if (cursor + 9 > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")208            val type = body[cursor].toInt() and 0xff209            // M-02: keep raw/compressed sizes as Long and cap before Zstd allocates210            // the declared uncompressed length (decompression-bomb guard).211            val length = readU32LE(body, cursor + 1)212            val clength = readU32LE(body, cursor + 5)213            if (length > MAX_SECTION_RAW_BYTES || clength > MAX_SECTION_COMP_BYTES) {214                throw PeachScriptException("SECTION_TOO_LARGE")215            }216            val dataStart = cursor + 9217            val dataEndLong = dataStart.toLong() + clength218            if (dataEndLong > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")219            val compressed = body.copyOfRange(dataStart, Math.toIntExact(dataEndLong))220            val data = Zstd.decompress(compressed, Math.toIntExact(length))221            if (data.size.toLong() != length) throw PeachScriptException("SECTION_LENGTH_MISMATCH")222            when (type) {223                SECTION_ENTRIES -> entries = decodeEntriesSection(data, format)224                SECTION_SETTINGS -> settings = decodeSettingsSection(data) ?: VaultSettings()225                SECTION_LICENSE -> {226                    val license = decodeLicenseSection(data)227                    licenseKey = license?.first228                    licenseActivatedAt = license?.second229                }230                // RECALL/DECLINED_CARDS and unknown types are skipped. Android231                // does not expose browser manual-summon or declined-card stores.232                else -> {}233            }234            cursor = Math.toIntExact(dataEndLong)235        }236        return ParsedPayload(237            entries = entries,238            settings = settings,239            licenseKey = licenseKey,240            licenseActivatedAt = licenseActivatedAt,241        )242    }243244    // ---- section assembly (peachPayload.ts:316-333) -----------------------245246    private fun buildSection(type: Int, raw: ByteArray): ByteArray {247        val compressed = Zstd.compress(raw, ZSTD_LEVEL)248        val out = ByteArray(9 + compressed.size)249        out[0] = type.toByte()250        writeU32LE(out, 1, raw.size.toLong())251        writeU32LE(out, 5, compressed.size.toLong())252        System.arraycopy(compressed, 0, out, 9, compressed.size)253        return out254    }255256    private fun encodeSettingsSection(settings: VaultSettings): ByteArray =257        VaultCodec.encodeSettings(settings).toByteArray(Charsets.UTF_8)258259    private fun decodeSettingsSection(data: ByteArray): VaultSettings? =260        VaultCodec.decodeSettings(String(data, Charsets.UTF_8))261262    private fun encodeLicenseSection(key: String, activatedAt: Long?): ByteArray {263        val obj = buildJsonObject {264            put("key", JsonPrimitive(key))265            if (activatedAt != null) put("activatedAt", JsonPrimitive(activatedAt))266        }267        return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8)268    }269270    private fun decodeLicenseSection(data: ByteArray): Pair<String, Long?>? {271        val obj = runCatching { json.parseToJsonElement(String(data, Charsets.UTF_8)).jsonObject }.getOrNull()272            ?: return null273        val key = obj["key"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null274        val activatedAt = obj["activatedAt"]?.jsonPrimitive?.longOrNull275        return key to activatedAt276    }277278    // ---- entries section: string table + packed bodies --------------------279280    private class StringTable {281        private val map = LinkedHashMap<String, Int>()282283        /** u16 index; 0 reserved for absent. */284        fun intern(value: String?): Int {285            if (value.isNullOrEmpty()) return 0286            map[value]?.let { return it }287            val idx = map.size + 1288            if (idx > 0xffff) throw PeachScriptException("STRING_TABLE_OVERFLOW")289            map[value] = idx290            return idx291        }292293        fun serialize(): ByteArray {294            val out = BinaryWriter()295            out.u16(map.size)296            for (s in map.keys) {297                val e = s.toByteArray(Charsets.UTF_8)298                if (e.size > 0xffff) throw PeachScriptException("STRING_TOO_LONG")299                out.u16(e.size)300                out.bytes(e)301            }302            return out.toBytes()303        }304    }305306    private fun encodeEntriesSection(entries: List<VaultEntry>): ByteArray {307        val table = StringTable()308        // Pre-intern every title first (peachPayload.ts:400-401) so indices match.309        for (e in entries) table.intern(e.title)310        val bodies = entries.map { encodeEntry(it, table) }311        val tableBytes = table.serialize()312313        val out = BinaryWriter()314        out.u32(entries.size.toLong())315        out.bytes(tableBytes)316        for (b in bodies) out.bytes(b)317        return out.toBytes()318    }319320    private fun decodeEntriesSection(data: ByteArray, format: Int): List<VaultEntry> {321        val r = BinaryReader(data, 0)322        // M-02: cap entry count before pre-allocating the list (was unbounded).323        val count = r.u32()324        if (count > MAX_ENTRY_COUNT) throw PeachScriptException("ENTRY_COUNT_OVERFLOW")325        val strings = readStringTable(r)326        val out = ArrayList<VaultEntry>(Math.toIntExact(count))327        for (i in 0 until count) out.add(decodeEntry(r, strings, format))328        return out329    }330331    private fun readStringTable(r: BinaryReader): List<String?> {332        val count = r.u16()333        val strings = ArrayList<String?>(count + 1)334        strings.add(null) // index 0 = absent335        for (i in 0 until count) {336            val len = r.u16()337            strings.add(String(r.read(len), Charsets.UTF_8))338        }339        return strings340    }341342    private fun lookup(strings: List<String?>, idx: Int): String? =343        if (idx == 0 || idx >= strings.size) null else strings[idx]344345    // ---- per-entry packing (peachPayload.ts:443-601) ----------------------346347    private fun encodeEntry(entry: VaultEntry, table: StringTable): ByteArray {348        val type = entryTypeByte(entry)349        val w = BinaryWriter()350        w.u8(type)351        w.bytes(uuidToBytes(entry.id))352353        val createdSec = entry.createdAt / 1000L354        val updatedSec = entry.modifiedAt / 1000L355        val updatedDelta = maxOf(0L, updatedSec - createdSec)356        val deletedSec = entry.deletedAt?.let { it / 1000L } ?: 0L357358        // Compute flags up front (the browser back-patches; same bytes result).359        var flags = 0360        var flags2 = 0361        if (entry.deletedAt != null) flags = flags or F_HAS_DELETED362363        when (type) {364            ENTRY_TYPE_LOGIN -> {365                if (!entry.email.isNullOrEmpty()) flags2 = flags2 or F2_HAS_EMAIL366                if (!entry.totp.isNullOrEmpty()) flags = flags or F_HAS_TOTP367                if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS368                if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES369                if (entry.canary != null) flags = flags or F_HAS_CANARY370                if (entry.passwordChangedAt != null) flags = flags or F_HAS_PWCHANGED371                if ((entry.breach ?: 0) > 0) flags = flags or F_HAS_BREACH372                if (!entry.additionalUrls.isNullOrEmpty()) flags = flags or F_HAS_ADDITIONAL_URLS373            }374            ENTRY_TYPE_NOTE -> {375                if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES376                if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS377            }378            ENTRY_TYPE_CARD -> {379                if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS380                if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES381            }382        }383384        w.u8(flags)385        w.u8(flags2) // V3+ always writes flags2386        w.u32(createdSec)387        w.u32(updatedDelta)388        w.u32(deletedSec)389        w.u16(table.intern(entry.title))390391        when (type) {392            ENTRY_TYPE_LOGIN -> {393                w.u16(table.intern(entry.url))394                w.u16(table.intern(entry.username))395                w.lpString(entry.password ?: "", 2)396                if (flags2 and F2_HAS_EMAIL != 0) w.lpString(entry.email!!)397                if (flags and F_HAS_TOTP != 0) w.lpString(entry.totp!!)398                if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)399                if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))400                if (flags and F_HAS_CANARY != 0) {401                    w.u16(table.intern(entry.canary!!.webhookUrl))402                    w.u16(table.intern(entry.canary.label))403                }404                if (flags and F_HAS_PWCHANGED != 0) {405                    val pwSec = entry.passwordChangedAt!! / 1000L406                    w.u32(maxOf(0L, pwSec - createdSec))407                }408                if (flags and F_HAS_BREACH != 0) w.u32((entry.breach ?: 0).toLong())409                if (flags and F_HAS_ADDITIONAL_URLS != 0) writeStrings(w, table, entry.additionalUrls!!)410            }411            ENTRY_TYPE_NOTE -> {412                if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))413                if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)414            }415            ENTRY_TYPE_CARD -> {416                w.u16(table.intern(entry.cardholderName))417                w.lpString(entry.cardNumber ?: "")418                w.lpString(entry.expMonth ?: "")419                w.lpString(entry.expYear ?: "")420                w.lpString(entry.cvc ?: "")421                w.u16(table.intern(entry.cardNickname))422                w.u8(networkToByte(entry.cardNetwork))423                // M-03: prefer the stored field. Deriving it lost the last four424                // for cards held without a full PAN (the browser stores last4425                // independently, so those cards exported as blank).426                w.u16(table.intern(entry.last4?.takeIf { it.isNotEmpty() } ?: deriveLast4(entry)))427                if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)428                if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))429            }430            ENTRY_TYPE_PASSKEY -> {431                val blob = encodePasskeyBlob(entry)432                w.u32(blob.size.toLong())433                w.bytes(blob)434            }435        }436        return w.toBytes()437    }438439    private fun decodeEntry(r: BinaryReader, strings: List<String?>, format: Int): VaultEntry {440        val type = r.u8()441        val id = bytesToUuid(r.read(16))442        val flags = r.u8()443        val flags2 = if (format >= FORMAT_V3) r.u8() else 0444        val createdSec = r.u32()445        val updatedDelta = r.u32()446        val deletedSec = r.u32()447        val titleIdx = r.u16()448449        val createdAt = createdSec * 1000L450        val modifiedAt = (createdSec + updatedDelta) * 1000L451        val deletedAt = if (deletedSec > 0) deletedSec * 1000L else null452        val title = lookup(strings, titleIdx) ?: ""453454        return when (type) {455            ENTRY_TYPE_LOGIN -> {456                val urlIdx = r.u16()457                val usernameIdx = r.u16()458                val password = r.lpString(if (format >= FORMAT_V4) 2 else 1)459                val email = if (flags2 and F2_HAS_EMAIL != 0) r.lpString() else null460                val totp = if (flags and F_HAS_TOTP != 0) r.lpString() else null461                val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null462                val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null463                var canary: Canary? = null464                if (flags and F_HAS_CANARY != 0) {465                    val webhookUrl = lookup(strings, r.u16()) ?: ""466                    val label = lookup(strings, r.u16())467                    canary = Canary(webhookUrl = webhookUrl, label = label)468                }469                val pwChanged = if (flags and F_HAS_PWCHANGED != 0) (createdSec + r.u32()) * 1000L else null470                val breach = if (flags and F_HAS_BREACH != 0) r.u32().toInt() else null471                val additionalUrls = if (flags and F_HAS_ADDITIONAL_URLS != 0) readStrings(r, strings) else null472                VaultEntry(473                    id = id, type = "login", title = title,474                    url = lookup(strings, urlIdx), username = lookup(strings, usernameIdx),475                    password = password, email = email, totp = totp, tags = tags, notes = notes,476                    canary = canary, passwordChangedAt = pwChanged, breach = breach,477                    additionalUrls = additionalUrls, createdAt = createdAt, modifiedAt = modifiedAt,478                    deletedAt = deletedAt,479                )480            }481            ENTRY_TYPE_NOTE -> {482                val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null483                val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null484                VaultEntry(485                    id = id, type = "note", title = title, notes = notes, tags = tags,486                    createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,487                )488            }489            ENTRY_TYPE_CARD -> {490                val cardholderIdx = r.u16()491                val cardNumber = r.lpString()492                val expMonth = r.lpString()493                val expYear = r.lpString()494                val cvc = r.lpString()495                val nicknameIdx = r.u16()496                val network = networkFromByte(r.u8())497                val last4 = lookup(strings, r.u16()) // M-03: was read and dropped498                val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null499                val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null500                VaultEntry(501                    id = id, type = "card", title = title,502                    cardholderName = lookup(strings, cardholderIdx),503                    cardNumber = cardNumber.ifEmpty { null }, expMonth = expMonth.ifEmpty { null },504                    expYear = expYear.ifEmpty { null }, cvc = cvc.ifEmpty { null },505                    cardNickname = lookup(strings, nicknameIdx), cardNetwork = network,506                    last4 = last4,507                    tags = tags, notes = notes,508                    createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,509                )510            }511            ENTRY_TYPE_PASSKEY -> {512                // M-02: cap passkey blob before allocation (was unbounded).513                val blobLen = r.u32()514                if (blobLen > MAX_PASSKEY_BLOB_BYTES) throw PeachScriptException("PASSKEY_BLOB_TOO_LARGE")515                val blob = r.read(Math.toIntExact(blobLen))516                decodePasskeyBlob(id, title, createdAt, modifiedAt, deletedAt, blob)517            }518            else -> throw PeachScriptException("UNKNOWN_ENTRY_TYPE:$type")519        }520    }521522    // M-03: the count is a u8, so >255 items cannot be represented. Truncating523    // silently dropped user data into an export that still looked successful —524    // fail loudly instead and let the caller decide (matches lpString's525    // STRING_TOO_LONG_U8 behaviour for the same reason).526    private fun writeTags(w: BinaryWriter, table: StringTable, tags: List<String>) {527        if (tags.size > 0xff) throw PeachScriptException("TAG_LIST_TOO_LONG")528        w.u8(tags.size)529        for (t in tags) w.u16(table.intern(t))530    }531532    private fun writeStrings(w: BinaryWriter, table: StringTable, items: List<String>) {533        if (items.size > 0xff) throw PeachScriptException("URL_LIST_TOO_LONG")534        w.u8(items.size)535        for (s in items) w.u16(table.intern(s))536    }537538    private fun readTags(r: BinaryReader, strings: List<String?>): List<String> {539        val n = r.u8()540        val out = ArrayList<String>(n)541        for (i in 0 until n) lookup(strings, r.u16())?.let { out.add(it) }542        return out543    }544545    private fun readStrings(r: BinaryReader, strings: List<String?>): List<String> = readTags(r, strings)546547    private fun entryTypeByte(entry: VaultEntry): Int {548        if (entry.passkey != null) return ENTRY_TYPE_PASSKEY549        return when (entry.type) {550            "note" -> ENTRY_TYPE_NOTE551            "card" -> ENTRY_TYPE_CARD552            "passkey" -> ENTRY_TYPE_PASSKEY553            else -> ENTRY_TYPE_LOGIN554        }555    }556557    private fun networkToByte(network: String?): Int {558        if (network.isNullOrEmpty()) return 0xff559        val idx = NETWORK_VALUES.indexOf(network)560        return if (idx >= 0) idx else NETWORK_VALUES.indexOf("unknown")561    }562563    private fun networkFromByte(b: Int): String? {564        if (b == 0xff) return null565        return NETWORK_VALUES.getOrNull(b) ?: "unknown"566    }567568    private fun deriveLast4(entry: VaultEntry): String? {569        val digits = entry.cardNumber?.filter { it.isDigit() } ?: return null570        return if (digits.length >= 4) digits.takeLast(4) else digits.ifEmpty { null }571    }572573    // ---- passkey JSON blob (peachPayload.ts:552-596) ----------------------574    // Browser passkeys carry privateKeyJwk as a JSON object. Android stores that575    // object as compact JSON text so Parcelable vault entries can preserve it576    // across import/export; Credential Provider signing is implemented separately.577578    private fun encodePasskeyBlob(entry: VaultEntry): ByteArray {579        val pk = entry.passkey580        val obj = buildJsonObject {581            putIfPresent("rpId", pk?.rpId ?: entry.rpId)582            putIfPresent("rpName", pk?.rpName ?: entry.rpName)583            putIfPresent("credentialId", pk?.credentialId ?: entry.credentialId)584            putIfPresent("publicKeyCose", pk?.publicKeyCose ?: entry.publicKeyCose)585            putJsonObjectStringIfPresent("privateKeyJwk", pk?.privateKeyJwk ?: entry.privateKeyJwk)586            putIfPresent("userHandle", pk?.userHandle ?: entry.userHandle)587            putIfPresent("userName", pk?.userName ?: entry.userName)588            putIfPresent("userDisplayName", pk?.userDisplayName ?: entry.userDisplayName)589            putIfPresent("algorithm", (pk?.algorithm ?: entry.algorithm))590            putIfPresent("signCount", (pk?.signCount ?: entry.signCount))591            putIfPresent("aaguid", pk?.aaguid ?: entry.aaguid)592            putIfPresent("backupEligible", pk?.backupEligible ?: entry.backupEligible)593            putIfPresent("backupState", pk?.backupState ?: entry.backupState)594            putIfPresent("lastUsedAt", entry.lastUsedAt)595            // Login fields carried alongside a passkey-bearing login entry.596            putIfPresent("password", entry.password)597            putIfPresent("url", entry.url)598            putIfPresent("username", entry.username)599            putIfPresent("email", entry.email)600            putIfPresent("totp", entry.totp)601            putIfPresent("notes", entry.notes)602            if (!entry.tags.isNullOrEmpty()) {603                put("tags", buildJsonArray { entry.tags.forEach { add(JsonPrimitive(it)) } })604            }605        }606        return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8)607    }608609    private fun decodePasskeyBlob(610        id: String, title: String, createdAt: Long, modifiedAt: Long, deletedAt: Long?, blob: ByteArray,611    ): VaultEntry {612        val obj = json.parseToJsonElement(String(blob, Charsets.UTF_8)).jsonObject613        fun str(k: String) = obj[k]?.jsonPrimitive?.contentOrNull614        fun intf(k: String) = obj[k]?.jsonPrimitive?.intOrNull615        fun longf(k: String) = obj[k]?.jsonPrimitive?.longOrNull616        fun boolf(k: String) = obj[k]?.jsonPrimitive?.booleanOrNull617        fun objectJson(k: String): String? {618            val value = obj[k] ?: return null619            val nested = runCatching { value.jsonObject }.getOrNull() ?: return null620            return json.encodeToString(JsonObject.serializer(), nested)621        }622        val tags = obj["tags"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull }623        val credentialId = str("credentialId")624        val publicKeyCose = str("publicKeyCose")625        val privateKeyJwk = objectJson("privateKeyJwk")626        val rpId = str("rpId")627        val userHandle = str("userHandle")628        val userName = str("userName")629        val passkey = if (credentialId != null && publicKeyCose != null && rpId != null &&630            userHandle != null && userName != null) {631            PasskeyData(632                credentialId = credentialId, publicKeyCose = publicKeyCose, rpId = rpId,633                privateKeyJwk = privateKeyJwk,634                rpName = str("rpName"), userHandle = userHandle, userName = userName,635                userDisplayName = str("userDisplayName"), algorithm = intf("algorithm") ?: -7,636                signCount = intf("signCount") ?: 0, aaguid = str("aaguid"),637                backupEligible = boolf("backupEligible") ?: false, backupState = boolf("backupState") ?: false,638            )639        } else null640        return VaultEntry(641            id = id, type = "passkey", title = title,642            url = str("url"), username = str("username"), email = str("email"),643            password = str("password"), totp = str("totp"), notes = str("notes"), tags = tags,644            createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,645            passkey = passkey, lastUsedAt = longf("lastUsedAt"),646            rpId = rpId, rpName = str("rpName"), credentialId = credentialId,647            publicKeyCose = publicKeyCose, privateKeyJwk = privateKeyJwk,648            userHandle = userHandle, userName = userName,649            userDisplayName = str("userDisplayName"), algorithm = intf("algorithm"),650            aaguid = str("aaguid"), backupEligible = boolf("backupEligible"),651            backupState = boolf("backupState"), signCount = intf("signCount"),652        )653    }654655    // ---- uuid (peachPayload.ts:792-806) -----------------------------------656657    fun uuidToBytes(uuid: String): ByteArray {658        val hex = uuid.replace("-", "")659        if (hex.length != 32) throw PeachScriptException("UUID_FORMAT")660        return ByteArray(16) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }661    }662663    fun bytesToUuid(bytes: ByteArray): String {664        if (bytes.size != 16) throw PeachScriptException("UUID_LENGTH")665        val hex = bytes.joinToString("") { "%02x".format(it) }666        return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-" +667            "${hex.substring(16, 20)}-${hex.substring(20)}"668    }669670    // ---- crypto / hashing -------------------------------------------------671672    private fun deriveKey(passphrase: String, salt: ByteArray): ByteArray {673        // Argon2id over the UTF-8 BYTES of the passphrase, matching the browser's674        // hash-wasm KDF input (avoids the MF-4 UTF-16 divergence in this path).675        val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)676            .withSalt(salt)677            .withParallelism(ARGON2_PARALLELISM)678            .withMemoryAsKB(ARGON2_MEMORY_KIB)679            .withIterations(ARGON2_ITERATIONS)680            .build()681        val gen = Argon2BytesGenerator().apply { init(params) }682        val out = ByteArray(32)683        gen.generateBytes(passphrase.toByteArray(Charsets.UTF_8), out)684        return out685    }686687    private fun aesGcmEncrypt(key: ByteArray, nonce: ByteArray, plaintext: ByteArray): ByteArray {688        val cipher = Cipher.getInstance("AES/GCM/NoPadding")689        cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))690        return cipher.doFinal(plaintext) // ciphertext || tag; no AAD (browser uses none)691    }692693    private fun aesGcmDecrypt(key: ByteArray, nonce: ByteArray, ctTag: ByteArray): ByteArray {694        val cipher = Cipher.getInstance("AES/GCM/NoPadding")695        cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))696        return cipher.doFinal(ctTag)697    }698699    private fun blake3Truncated(data: ByteArray): ByteArray {700        val digest = Blake3Digest(256)701        digest.update(data, 0, data.size)702        val full = ByteArray(32)703        digest.doFinal(full, 0)704        return full.copyOfRange(0, CHECKSUM_LEN)705    }706707    private fun padToNearestBucket(data: ByteArray): ByteArray {708        val target = paddedBucketSize(data.size)709        if (target == data.size) return data710        return data.copyOf(target)711    }712713    internal fun paddedBucketSize(size: Int): Int {714        val bucket = SIZE_BUCKETS.firstOrNull { it >= size }715        return bucket ?: run {716            var target = SIZE_BUCKETS.last()717            while (target < size) target *= 2718            target719        }720    }721722    private fun constantTimeEqual(a: ByteArray, b: ByteArray): Boolean {723        if (a.size != b.size) return false724        var diff = 0725        for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt())726        return diff == 0727    }728729    private fun concat(parts: List<ByteArray>): ByteArray {730        val total = parts.sumOf { it.size }731        val out = ByteArray(total)732        var c = 0733        for (p in parts) { System.arraycopy(p, 0, out, c, p.size); c += p.size }734        return out735    }736737    // ---- little/big-endian helpers ----------------------------------------738739    private fun writeU32LE(buf: ByteArray, off: Int, v: Long) {740        buf[off] = (v and 0xff).toByte()741        buf[off + 1] = ((v ushr 8) and 0xff).toByte()742        buf[off + 2] = ((v ushr 16) and 0xff).toByte()743        buf[off + 3] = ((v ushr 24) and 0xff).toByte()744    }745746    private fun writeU32BE(buf: ByteArray, off: Int, v: Long) {747        buf[off] = ((v ushr 24) and 0xff).toByte()748        buf[off + 1] = ((v ushr 16) and 0xff).toByte()749        buf[off + 2] = ((v ushr 8) and 0xff).toByte()750        buf[off + 3] = (v and 0xff).toByte()751    }752753    private fun readU32LE(buf: ByteArray, off: Int): Long {754        return (buf[off].toLong() and 0xff) or755            ((buf[off + 1].toLong() and 0xff) shl 8) or756            ((buf[off + 2].toLong() and 0xff) shl 16) or757            ((buf[off + 3].toLong() and 0xff) shl 24)758    }759760    private fun readU32BE(buf: ByteArray, off: Int): Long {761        return ((buf[off].toLong() and 0xff) shl 24) or762            ((buf[off + 1].toLong() and 0xff) shl 16) or763            ((buf[off + 2].toLong() and 0xff) shl 8) or764            (buf[off + 3].toLong() and 0xff)765    }766767    private class BinaryWriter {768        private val out = ByteArrayOutputStream()769        fun u8(v: Int) { out.write(v and 0xff) }770        fun u16(v: Int) { out.write(v and 0xff); out.write((v ushr 8) and 0xff) }771        fun u32(v: Long) {772            out.write((v and 0xff).toInt()); out.write(((v ushr 8) and 0xff).toInt())773            out.write(((v ushr 16) and 0xff).toInt()); out.write(((v ushr 24) and 0xff).toInt())774        }775        fun bytes(b: ByteArray) { out.write(b) }776        fun lpString(value: String, lengthBytes: Int = 1) {777            val enc = value.toByteArray(Charsets.UTF_8)778            if (lengthBytes == 1) {779                if (enc.size > 0xff) throw PeachScriptException("STRING_TOO_LONG_U8")780                u8(enc.size)781            } else {782                if (enc.size > 0xffff) throw PeachScriptException("STRING_TOO_LONG_U16")783                u16(enc.size)784            }785            bytes(enc)786        }787        fun toBytes(): ByteArray = out.toByteArray()788    }789790    private class BinaryReader(private val data: ByteArray, start: Int) {791        private var cursor = start792        fun u8(): Int = data[cursor++].toInt() and 0xff793        fun u16(): Int { val v = (data[cursor].toInt() and 0xff) or ((data[cursor + 1].toInt() and 0xff) shl 8); cursor += 2; return v }794        fun u32(): Long {795            val v = (data[cursor].toLong() and 0xff) or ((data[cursor + 1].toLong() and 0xff) shl 8) or796                ((data[cursor + 2].toLong() and 0xff) shl 16) or ((data[cursor + 3].toLong() and 0xff) shl 24)797            cursor += 4; return v798        }799        fun read(n: Int): ByteArray { val out = data.copyOfRange(cursor, cursor + n); cursor += n; return out }800        fun lpString(lengthBytes: Int = 1): String =801            String(read(if (lengthBytes == 1) u8() else u16()), Charsets.UTF_8)802    }803804    private fun JsonObjectBuilder.putIfPresent(key: String, value: String?) {805        if (!value.isNullOrEmpty()) put(key, JsonPrimitive(value))806    }807    private fun JsonObjectBuilder.putJsonObjectStringIfPresent(key: String, value: String?) {808        if (value.isNullOrBlank()) return809        val obj = runCatching { json.parseToJsonElement(value).jsonObject }.getOrNull() ?: return810        put(key, obj)811    }812    private fun JsonObjectBuilder.putIfPresent(key: String, value: Int?) {813        if (value != null) put(key, JsonPrimitive(value))814    }815    private fun JsonObjectBuilder.putIfPresent(key: String, value: Long?) {816        if (value != null) put(key, JsonPrimitive(value))817    }818    private fun JsonObjectBuilder.putIfPresent(key: String, value: Boolean?) {819        if (value != null) put(key, JsonPrimitive(value))820    }821}