package com.peachpasswords.vault import com.github.luben.zstd.Zstd import org.bouncycastle.crypto.digests.Blake3Digest import org.bouncycastle.crypto.generators.Argon2BytesGenerator import org.bouncycastle.crypto.params.Argon2Parameters import java.io.ByteArrayOutputStream import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.add import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull /** * PeachScript binary payload (`PCHS`) — a byte-for-byte port of the browser's * `peachPayload.ts` (MF-2). Produces and consumes the encrypted envelope * `[ salt(16) | nonce(12) | ciphertext | tag(16) ]` whose plaintext is * `[ FORMAT_V3 | inner_len(LE) | inner | zero-pad-to-bucket ]`, * `inner = [ body | blake3(body)[0..4) ]`, * `body = [ MAGIC "PCHS"(BE) | VERSION | sectionCount | reserved(2) | section* ]`, * each section `[ type | rawLen(LE) | compLen(LE) | zstd(level 19) ]`. * * The output of [buildPayload] is importable by the browser, and [parsePayload] * reads browser-produced payloads. Verified by a real browser-fixture KAT * (PeachScriptTest). Library notes are in app/drafts/MF1MF2/BROWSER-SPEC.md. */ object PeachPayloadCodec { // ---- header / section constants (peachPayload.ts:48-124) -------------- private const val MAGIC = 0x50434853 // "PCHS" big-endian private const val VERSION = 0x01 private const val HEADER_LEN = 8 private const val CHECKSUM_LEN = 4 private const val SALT_LEN = 16 private const val NONCE_LEN = 12 private const val TAG_LEN = 16 private val SIZE_BUCKETS = intArrayOf(1024, 4096, 16384, 65536, 262144) private const val FORMAT_V2 = 0x02 private const val FORMAT_V3 = 0x03 private const val ARGON2_MEMORY_KIB = 65536 private const val ARGON2_ITERATIONS = 3 private const val ARGON2_PARALLELISM = 4 private const val SECTION_ENTRIES = 0x01 private const val SECTION_SETTINGS = 0x02 private const val SECTION_LICENSE = 0x03 private const val SECTION_RECALL = 0x04 private const val SECTION_DECLINED_CARDS = 0x05 private const val ENTRY_TYPE_LOGIN = 0x01 private const val ENTRY_TYPE_NOTE = 0x02 private const val ENTRY_TYPE_CARD = 0x03 private const val ENTRY_TYPE_PASSKEY = 0x04 private const val F_HAS_TOTP = 1 shl 0 private const val F_HAS_TAGS = 1 shl 1 private const val F_HAS_NOTES = 1 shl 2 private const val F_HAS_CANARY = 1 shl 3 private const val F_HAS_PWCHANGED = 1 shl 4 private const val F_HAS_BREACH = 1 shl 5 private const val F_HAS_ADDITIONAL_URLS = 1 shl 6 private const val F_HAS_DELETED = 1 shl 7 private const val F2_HAS_EMAIL = 1 shl 0 private val NETWORK_VALUES = listOf("visa", "mastercard", "amex", "discover", "unknown") private const val ZSTD_LEVEL = 19 private val secureRandom = SecureRandom() private val json = Json { ignoreUnknownKeys = true; encodeDefaults = false } // M-02: hard caps on attacker-controlled lengths/counts. A crafted backup // could otherwise trigger OOM via huge declared uncompressed sizes or // decompression bombs. Bounds follow M-01's MAX_IMPORT_BYTES (50 MiB). private const val MAX_ENVELOPE_BYTES = 50L * 1024 * 1024 private const val MAX_INNER_BYTES = MAX_ENVELOPE_BYTES private const val MAX_SECTION_RAW_BYTES = 16L * 1024 * 1024 private const val MAX_SECTION_COMP_BYTES = 32L * 1024 * 1024 private const val MAX_ENTRY_COUNT = 100_000L private const val MAX_PASSKEY_BLOB_BYTES = 1L * 1024 * 1024 class PeachScriptException(message: String) : Exception(message) data class ParsedPayload( val entries: List, val settings: VaultSettings = VaultSettings(), val licenseKey: String? = null, val licenseActivatedAt: Long? = null, ) // ---- public API ------------------------------------------------------- /** Build the encrypted envelope `salt||nonce||ct||tag` from [entries]. */ fun buildPayload( entries: List, passphrase: String, settings: VaultSettings = VaultSettings(), licenseKey: String? = null, licenseActivatedAt: Long? = null, ): ByteArray { val entriesRaw = encodeEntriesSection(entries) val sections = buildList { add(buildSection(SECTION_ENTRIES, entriesRaw)) add(buildSection(SECTION_SETTINGS, encodeSettingsSection(settings))) if (!licenseKey.isNullOrBlank()) { add(buildSection(SECTION_LICENSE, encodeLicenseSection(licenseKey, licenseActivatedAt))) } } val header = ByteArray(HEADER_LEN) writeU32BE(header, 0, MAGIC.toLong()) header[4] = VERSION.toByte() header[5] = sections.size.toByte() // bytes 6,7 reserved (zero) val body = concat(listOf(header) + sections) val checksum = blake3Truncated(body) val inner = concat(listOf(body, checksum)) val header5 = ByteArray(5) header5[0] = FORMAT_V3.toByte() writeU32LE(header5, 1, inner.size.toLong()) val wrapped = padToNearestBucket(concat(listOf(header5, inner))) val salt = ByteArray(SALT_LEN).also(secureRandom::nextBytes) val nonce = ByteArray(NONCE_LEN).also(secureRandom::nextBytes) val key = deriveKey(passphrase, salt) return try { val ctTag = aesGcmEncrypt(key, nonce, wrapped) concat(listOf(salt, nonce, ctTag)) } finally { key.fill(0) } } /** Parse the encrypted envelope back to entries. Throws [PeachScriptException]. */ fun parsePayload(envelope: ByteArray, passphrase: String): List = parsePayloadData(envelope, passphrase).entries /** Parse the encrypted envelope back to all supported payload metadata. */ fun parsePayloadData(envelope: ByteArray, passphrase: String): ParsedPayload { // M-02: cap before any allocation so a crafted 4 GB envelope can't OOM us. if (envelope.size > MAX_ENVELOPE_BYTES) throw PeachScriptException("PAYLOAD_TOO_LARGE") if (envelope.size < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) { throw PeachScriptException("PAYLOAD_TOO_SHORT") } val salt = envelope.copyOfRange(0, SALT_LEN) val nonce = envelope.copyOfRange(SALT_LEN, SALT_LEN + NONCE_LEN) val ctTag = envelope.copyOfRange(SALT_LEN + NONCE_LEN, envelope.size) val key = deriveKey(passphrase, salt) val decrypted = try { aesGcmDecrypt(key, nonce, ctTag) } catch (_: Exception) { throw PeachScriptException("INVALID_PASSPHRASE") } finally { key.fill(0) } // Detect padded v2/v3 format: leading FORMAT byte + 4-byte LE inner length. // M-02: keep innerLen as Long; the prior `5 + innerLen` could overflow Int // when the wire value set the high bit, bypassing the size check. val inner: ByteArray if (decrypted.size >= 5 && (decrypted[0].toInt() == FORMAT_V2 || decrypted[0].toInt() == FORMAT_V3)) { val innerLen = readU32LE(decrypted, 1) if (innerLen > MAX_INNER_BYTES) throw PeachScriptException("INNER_TOO_LARGE") if (5L + innerLen > decrypted.size) throw PeachScriptException("PAYLOAD_TOO_SHORT") inner = decrypted.copyOfRange(5, Math.toIntExact(5L + innerLen)) } else { inner = decrypted // v1 — no length prefix / no padding } if (inner.size < HEADER_LEN + CHECKSUM_LEN) throw PeachScriptException("PAYLOAD_TOO_SHORT") val body = inner.copyOfRange(0, inner.size - CHECKSUM_LEN) val stored = inner.copyOfRange(inner.size - CHECKSUM_LEN, inner.size) if (!constantTimeEqual(stored, blake3Truncated(body))) throw PeachScriptException("CHECKSUM_FAILED") if (readU32BE(body, 0) != MAGIC.toLong()) throw PeachScriptException("BAD_MAGIC") val version = body[4].toInt() and 0xff if (version != VERSION) throw PeachScriptException("UNSUPPORTED_VERSION:$version") val sectionCount = body[5].toInt() and 0xff var entries = emptyList() var settings = VaultSettings() var licenseKey: String? = null var licenseActivatedAt: Long? = null var cursor = HEADER_LEN for (i in 0 until sectionCount) { if (cursor + 9 > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT") val type = body[cursor].toInt() and 0xff // M-02: keep raw/compressed sizes as Long and cap before Zstd allocates // the declared uncompressed length (decompression-bomb guard). val length = readU32LE(body, cursor + 1) val clength = readU32LE(body, cursor + 5) if (length > MAX_SECTION_RAW_BYTES || clength > MAX_SECTION_COMP_BYTES) { throw PeachScriptException("SECTION_TOO_LARGE") } val dataStart = cursor + 9 val dataEndLong = dataStart.toLong() + clength if (dataEndLong > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT") val compressed = body.copyOfRange(dataStart, Math.toIntExact(dataEndLong)) val data = Zstd.decompress(compressed, Math.toIntExact(length)) if (data.size.toLong() != length) throw PeachScriptException("SECTION_LENGTH_MISMATCH") when (type) { SECTION_ENTRIES -> entries = decodeEntriesSection(data) SECTION_SETTINGS -> settings = decodeSettingsSection(data) ?: VaultSettings() SECTION_LICENSE -> { val license = decodeLicenseSection(data) licenseKey = license?.first licenseActivatedAt = license?.second } // RECALL/DECLINED_CARDS and unknown types are skipped. Android // does not expose browser manual-summon or declined-card stores. else -> {} } cursor = Math.toIntExact(dataEndLong) } return ParsedPayload( entries = entries, settings = settings, licenseKey = licenseKey, licenseActivatedAt = licenseActivatedAt, ) } // ---- section assembly (peachPayload.ts:316-333) ----------------------- private fun buildSection(type: Int, raw: ByteArray): ByteArray { val compressed = Zstd.compress(raw, ZSTD_LEVEL) val out = ByteArray(9 + compressed.size) out[0] = type.toByte() writeU32LE(out, 1, raw.size.toLong()) writeU32LE(out, 5, compressed.size.toLong()) System.arraycopy(compressed, 0, out, 9, compressed.size) return out } private fun encodeSettingsSection(settings: VaultSettings): ByteArray = VaultCodec.encodeSettings(settings).toByteArray(Charsets.UTF_8) private fun decodeSettingsSection(data: ByteArray): VaultSettings? = VaultCodec.decodeSettings(String(data, Charsets.UTF_8)) private fun encodeLicenseSection(key: String, activatedAt: Long?): ByteArray { val obj = buildJsonObject { put("key", JsonPrimitive(key)) if (activatedAt != null) put("activatedAt", JsonPrimitive(activatedAt)) } return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8) } private fun decodeLicenseSection(data: ByteArray): Pair? { val obj = runCatching { json.parseToJsonElement(String(data, Charsets.UTF_8)).jsonObject }.getOrNull() ?: return null val key = obj["key"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null val activatedAt = obj["activatedAt"]?.jsonPrimitive?.longOrNull return key to activatedAt } // ---- entries section: string table + packed bodies -------------------- private class StringTable { private val map = LinkedHashMap() /** u16 index; 0 reserved for absent. */ fun intern(value: String?): Int { if (value.isNullOrEmpty()) return 0 map[value]?.let { return it } val idx = map.size + 1 if (idx > 0xffff) throw PeachScriptException("STRING_TABLE_OVERFLOW") map[value] = idx return idx } fun serialize(): ByteArray { val out = BinaryWriter() out.u16(map.size) for (s in map.keys) { val e = s.toByteArray(Charsets.UTF_8) if (e.size > 0xffff) throw PeachScriptException("STRING_TOO_LONG") out.u16(e.size) out.bytes(e) } return out.toBytes() } } private fun encodeEntriesSection(entries: List): ByteArray { val table = StringTable() // Pre-intern every title first (peachPayload.ts:400-401) so indices match. for (e in entries) table.intern(e.title) val bodies = entries.map { encodeEntry(it, table) } val tableBytes = table.serialize() val out = BinaryWriter() out.u32(entries.size.toLong()) out.bytes(tableBytes) for (b in bodies) out.bytes(b) return out.toBytes() } private fun decodeEntriesSection(data: ByteArray): List { val r = BinaryReader(data, 0) // M-02: cap entry count before pre-allocating the list (was unbounded). val count = r.u32() if (count > MAX_ENTRY_COUNT) throw PeachScriptException("ENTRY_COUNT_OVERFLOW") val strings = readStringTable(r) val out = ArrayList(Math.toIntExact(count)) for (i in 0 until count) out.add(decodeEntry(r, strings)) return out } private fun readStringTable(r: BinaryReader): List { val count = r.u16() val strings = ArrayList(count + 1) strings.add(null) // index 0 = absent for (i in 0 until count) { val len = r.u16() strings.add(String(r.read(len), Charsets.UTF_8)) } return strings } private fun lookup(strings: List, idx: Int): String? = if (idx == 0 || idx >= strings.size) null else strings[idx] // ---- per-entry packing (peachPayload.ts:443-601) ---------------------- private fun encodeEntry(entry: VaultEntry, table: StringTable): ByteArray { val type = entryTypeByte(entry) val w = BinaryWriter() w.u8(type) w.bytes(uuidToBytes(entry.id)) val createdSec = entry.createdAt / 1000L val updatedSec = entry.modifiedAt / 1000L val updatedDelta = maxOf(0L, updatedSec - createdSec) val deletedSec = entry.deletedAt?.let { it / 1000L } ?: 0L // Compute flags up front (the browser back-patches; same bytes result). var flags = 0 var flags2 = 0 if (entry.deletedAt != null) flags = flags or F_HAS_DELETED when (type) { ENTRY_TYPE_LOGIN -> { if (!entry.email.isNullOrEmpty()) flags2 = flags2 or F2_HAS_EMAIL if (!entry.totp.isNullOrEmpty()) flags = flags or F_HAS_TOTP if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES if (entry.canary != null) flags = flags or F_HAS_CANARY if (entry.passwordChangedAt != null) flags = flags or F_HAS_PWCHANGED if ((entry.breach ?: 0) > 0) flags = flags or F_HAS_BREACH if (!entry.additionalUrls.isNullOrEmpty()) flags = flags or F_HAS_ADDITIONAL_URLS } ENTRY_TYPE_NOTE -> { if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS } ENTRY_TYPE_CARD -> { if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES } } w.u8(flags) w.u8(flags2) // FORMAT_V3 always writes flags2 w.u32(createdSec) w.u32(updatedDelta) w.u32(deletedSec) w.u16(table.intern(entry.title)) when (type) { ENTRY_TYPE_LOGIN -> { w.u16(table.intern(entry.url)) w.u16(table.intern(entry.username)) w.lpString(entry.password ?: "") if (flags2 and F2_HAS_EMAIL != 0) w.lpString(entry.email!!) if (flags and F_HAS_TOTP != 0) w.lpString(entry.totp!!) if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!) if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes)) if (flags and F_HAS_CANARY != 0) { w.u16(table.intern(entry.canary!!.webhookUrl)) w.u16(table.intern(entry.canary.label)) } if (flags and F_HAS_PWCHANGED != 0) { val pwSec = entry.passwordChangedAt!! / 1000L w.u32(maxOf(0L, pwSec - createdSec)) } if (flags and F_HAS_BREACH != 0) w.u32((entry.breach ?: 0).toLong()) if (flags and F_HAS_ADDITIONAL_URLS != 0) writeStrings(w, table, entry.additionalUrls!!) } ENTRY_TYPE_NOTE -> { if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes)) if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!) } ENTRY_TYPE_CARD -> { w.u16(table.intern(entry.cardholderName)) w.lpString(entry.cardNumber ?: "") w.lpString(entry.expMonth ?: "") w.lpString(entry.expYear ?: "") w.lpString(entry.cvc ?: "") w.u16(table.intern(entry.cardNickname)) w.u8(networkToByte(entry.cardNetwork)) // M-03: prefer the stored field. Deriving it lost the last four // for cards held without a full PAN (the browser stores last4 // independently, so those cards exported as blank). w.u16(table.intern(entry.last4?.takeIf { it.isNotEmpty() } ?: deriveLast4(entry))) if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!) if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes)) } ENTRY_TYPE_PASSKEY -> { val blob = encodePasskeyBlob(entry) w.u32(blob.size.toLong()) w.bytes(blob) } } return w.toBytes() } private fun decodeEntry(r: BinaryReader, strings: List): VaultEntry { val type = r.u8() val id = bytesToUuid(r.read(16)) val flags = r.u8() val flags2 = r.u8() // FORMAT_V3 val createdSec = r.u32() val updatedDelta = r.u32() val deletedSec = r.u32() val titleIdx = r.u16() val createdAt = createdSec * 1000L val modifiedAt = (createdSec + updatedDelta) * 1000L val deletedAt = if (deletedSec > 0) deletedSec * 1000L else null val title = lookup(strings, titleIdx) ?: "" return when (type) { ENTRY_TYPE_LOGIN -> { val urlIdx = r.u16() val usernameIdx = r.u16() val password = r.lpString() val email = if (flags2 and F2_HAS_EMAIL != 0) r.lpString() else null val totp = if (flags and F_HAS_TOTP != 0) r.lpString() else null val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null var canary: Canary? = null if (flags and F_HAS_CANARY != 0) { val webhookUrl = lookup(strings, r.u16()) ?: "" val label = lookup(strings, r.u16()) canary = Canary(webhookUrl = webhookUrl, label = label) } val pwChanged = if (flags and F_HAS_PWCHANGED != 0) (createdSec + r.u32()) * 1000L else null val breach = if (flags and F_HAS_BREACH != 0) r.u32().toInt() else null val additionalUrls = if (flags and F_HAS_ADDITIONAL_URLS != 0) readStrings(r, strings) else null VaultEntry( id = id, type = "login", title = title, url = lookup(strings, urlIdx), username = lookup(strings, usernameIdx), password = password, email = email, totp = totp, tags = tags, notes = notes, canary = canary, passwordChangedAt = pwChanged, breach = breach, additionalUrls = additionalUrls, createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt, ) } ENTRY_TYPE_NOTE -> { val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null VaultEntry( id = id, type = "note", title = title, notes = notes, tags = tags, createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt, ) } ENTRY_TYPE_CARD -> { val cardholderIdx = r.u16() val cardNumber = r.lpString() val expMonth = r.lpString() val expYear = r.lpString() val cvc = r.lpString() val nicknameIdx = r.u16() val network = networkFromByte(r.u8()) val last4 = lookup(strings, r.u16()) // M-03: was read and dropped val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null VaultEntry( id = id, type = "card", title = title, cardholderName = lookup(strings, cardholderIdx), cardNumber = cardNumber.ifEmpty { null }, expMonth = expMonth.ifEmpty { null }, expYear = expYear.ifEmpty { null }, cvc = cvc.ifEmpty { null }, cardNickname = lookup(strings, nicknameIdx), cardNetwork = network, last4 = last4, tags = tags, notes = notes, createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt, ) } ENTRY_TYPE_PASSKEY -> { // M-02: cap passkey blob before allocation (was unbounded). val blobLen = r.u32() if (blobLen > MAX_PASSKEY_BLOB_BYTES) throw PeachScriptException("PASSKEY_BLOB_TOO_LARGE") val blob = r.read(Math.toIntExact(blobLen)) decodePasskeyBlob(id, title, createdAt, modifiedAt, deletedAt, blob) } else -> throw PeachScriptException("UNKNOWN_ENTRY_TYPE:$type") } } // M-03: the count is a u8, so >255 items cannot be represented. Truncating // silently dropped user data into an export that still looked successful — // fail loudly instead and let the caller decide (matches lpString's // STRING_TOO_LONG_U8 behaviour for the same reason). private fun writeTags(w: BinaryWriter, table: StringTable, tags: List) { if (tags.size > 0xff) throw PeachScriptException("TAG_LIST_TOO_LONG") w.u8(tags.size) for (t in tags) w.u16(table.intern(t)) } private fun writeStrings(w: BinaryWriter, table: StringTable, items: List) { if (items.size > 0xff) throw PeachScriptException("URL_LIST_TOO_LONG") w.u8(items.size) for (s in items) w.u16(table.intern(s)) } private fun readTags(r: BinaryReader, strings: List): List { val n = r.u8() val out = ArrayList(n) for (i in 0 until n) lookup(strings, r.u16())?.let { out.add(it) } return out } private fun readStrings(r: BinaryReader, strings: List): List = readTags(r, strings) private fun entryTypeByte(entry: VaultEntry): Int { if (entry.passkey != null) return ENTRY_TYPE_PASSKEY return when (entry.type) { "note" -> ENTRY_TYPE_NOTE "card" -> ENTRY_TYPE_CARD "passkey" -> ENTRY_TYPE_PASSKEY else -> ENTRY_TYPE_LOGIN } } private fun networkToByte(network: String?): Int { if (network.isNullOrEmpty()) return 0xff val idx = NETWORK_VALUES.indexOf(network) return if (idx >= 0) idx else NETWORK_VALUES.indexOf("unknown") } private fun networkFromByte(b: Int): String? { if (b == 0xff) return null return NETWORK_VALUES.getOrNull(b) ?: "unknown" } private fun deriveLast4(entry: VaultEntry): String? { val digits = entry.cardNumber?.filter { it.isDigit() } ?: return null return if (digits.length >= 4) digits.takeLast(4) else digits.ifEmpty { null } } // ---- passkey JSON blob (peachPayload.ts:552-596) ---------------------- // Browser passkeys carry privateKeyJwk as a JSON object. Android stores that // object as compact JSON text so Parcelable vault entries can preserve it // across import/export; Credential Provider signing is implemented separately. private fun encodePasskeyBlob(entry: VaultEntry): ByteArray { val pk = entry.passkey val obj = buildJsonObject { putIfPresent("rpId", pk?.rpId ?: entry.rpId) putIfPresent("rpName", pk?.rpName ?: entry.rpName) putIfPresent("credentialId", pk?.credentialId ?: entry.credentialId) putIfPresent("publicKeyCose", pk?.publicKeyCose ?: entry.publicKeyCose) putJsonObjectStringIfPresent("privateKeyJwk", pk?.privateKeyJwk ?: entry.privateKeyJwk) putIfPresent("userHandle", pk?.userHandle ?: entry.userHandle) putIfPresent("userName", pk?.userName ?: entry.userName) putIfPresent("userDisplayName", pk?.userDisplayName ?: entry.userDisplayName) putIfPresent("algorithm", (pk?.algorithm ?: entry.algorithm)) putIfPresent("signCount", (pk?.signCount ?: entry.signCount)) putIfPresent("aaguid", pk?.aaguid ?: entry.aaguid) putIfPresent("backupEligible", pk?.backupEligible ?: entry.backupEligible) putIfPresent("backupState", pk?.backupState ?: entry.backupState) putIfPresent("lastUsedAt", entry.lastUsedAt) // Login fields carried alongside a passkey-bearing login entry. putIfPresent("password", entry.password) putIfPresent("url", entry.url) putIfPresent("username", entry.username) putIfPresent("email", entry.email) putIfPresent("totp", entry.totp) putIfPresent("notes", entry.notes) if (!entry.tags.isNullOrEmpty()) { put("tags", buildJsonArray { entry.tags.forEach { add(JsonPrimitive(it)) } }) } } return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8) } private fun decodePasskeyBlob( id: String, title: String, createdAt: Long, modifiedAt: Long, deletedAt: Long?, blob: ByteArray, ): VaultEntry { val obj = json.parseToJsonElement(String(blob, Charsets.UTF_8)).jsonObject fun str(k: String) = obj[k]?.jsonPrimitive?.contentOrNull fun intf(k: String) = obj[k]?.jsonPrimitive?.intOrNull fun longf(k: String) = obj[k]?.jsonPrimitive?.longOrNull fun boolf(k: String) = obj[k]?.jsonPrimitive?.booleanOrNull fun objectJson(k: String): String? { val value = obj[k] ?: return null val nested = runCatching { value.jsonObject }.getOrNull() ?: return null return json.encodeToString(JsonObject.serializer(), nested) } val tags = obj["tags"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } val credentialId = str("credentialId") val publicKeyCose = str("publicKeyCose") val privateKeyJwk = objectJson("privateKeyJwk") val rpId = str("rpId") val userHandle = str("userHandle") val userName = str("userName") val passkey = if (credentialId != null && publicKeyCose != null && rpId != null && userHandle != null && userName != null) { PasskeyData( credentialId = credentialId, publicKeyCose = publicKeyCose, rpId = rpId, privateKeyJwk = privateKeyJwk, rpName = str("rpName"), userHandle = userHandle, userName = userName, userDisplayName = str("userDisplayName"), algorithm = intf("algorithm") ?: -7, signCount = intf("signCount") ?: 0, aaguid = str("aaguid"), backupEligible = boolf("backupEligible") ?: false, backupState = boolf("backupState") ?: false, ) } else null return VaultEntry( id = id, type = "passkey", title = title, url = str("url"), username = str("username"), email = str("email"), password = str("password"), totp = str("totp"), notes = str("notes"), tags = tags, createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt, passkey = passkey, lastUsedAt = longf("lastUsedAt"), rpId = rpId, rpName = str("rpName"), credentialId = credentialId, publicKeyCose = publicKeyCose, privateKeyJwk = privateKeyJwk, userHandle = userHandle, userName = userName, userDisplayName = str("userDisplayName"), algorithm = intf("algorithm"), aaguid = str("aaguid"), backupEligible = boolf("backupEligible"), backupState = boolf("backupState"), signCount = intf("signCount"), ) } // ---- uuid (peachPayload.ts:792-806) ----------------------------------- fun uuidToBytes(uuid: String): ByteArray { val hex = uuid.replace("-", "") if (hex.length != 32) throw PeachScriptException("UUID_FORMAT") return ByteArray(16) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() } } fun bytesToUuid(bytes: ByteArray): String { if (bytes.size != 16) throw PeachScriptException("UUID_LENGTH") val hex = bytes.joinToString("") { "%02x".format(it) } return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-" + "${hex.substring(16, 20)}-${hex.substring(20)}" } // ---- crypto / hashing ------------------------------------------------- private fun deriveKey(passphrase: String, salt: ByteArray): ByteArray { // Argon2id over the UTF-8 BYTES of the passphrase, matching the browser's // hash-wasm KDF input (avoids the MF-4 UTF-16 divergence in this path). val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) .withSalt(salt) .withParallelism(ARGON2_PARALLELISM) .withMemoryAsKB(ARGON2_MEMORY_KIB) .withIterations(ARGON2_ITERATIONS) .build() val gen = Argon2BytesGenerator().apply { init(params) } val out = ByteArray(32) gen.generateBytes(passphrase.toByteArray(Charsets.UTF_8), out) return out } private fun aesGcmEncrypt(key: ByteArray, nonce: ByteArray, plaintext: ByteArray): ByteArray { val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce)) return cipher.doFinal(plaintext) // ciphertext || tag; no AAD (browser uses none) } private fun aesGcmDecrypt(key: ByteArray, nonce: ByteArray, ctTag: ByteArray): ByteArray { val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce)) return cipher.doFinal(ctTag) } private fun blake3Truncated(data: ByteArray): ByteArray { val digest = Blake3Digest(256) digest.update(data, 0, data.size) val full = ByteArray(32) digest.doFinal(full, 0) return full.copyOfRange(0, CHECKSUM_LEN) } private fun padToNearestBucket(data: ByteArray): ByteArray { val bucket = SIZE_BUCKETS.firstOrNull { it >= data.size } var target = bucket ?: run { var t = 262144 while (t < data.size) t *= 2 t } if (target == data.size) return data return data.copyOf(target) } private fun constantTimeEqual(a: ByteArray, b: ByteArray): Boolean { if (a.size != b.size) return false var diff = 0 for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt()) return diff == 0 } private fun concat(parts: List): ByteArray { val total = parts.sumOf { it.size } val out = ByteArray(total) var c = 0 for (p in parts) { System.arraycopy(p, 0, out, c, p.size); c += p.size } return out } // ---- little/big-endian helpers ---------------------------------------- private fun writeU32LE(buf: ByteArray, off: Int, v: Long) { buf[off] = (v and 0xff).toByte() buf[off + 1] = ((v ushr 8) and 0xff).toByte() buf[off + 2] = ((v ushr 16) and 0xff).toByte() buf[off + 3] = ((v ushr 24) and 0xff).toByte() } private fun writeU32BE(buf: ByteArray, off: Int, v: Long) { buf[off] = ((v ushr 24) and 0xff).toByte() buf[off + 1] = ((v ushr 16) and 0xff).toByte() buf[off + 2] = ((v ushr 8) and 0xff).toByte() buf[off + 3] = (v and 0xff).toByte() } private fun readU32LE(buf: ByteArray, off: Int): Long { return (buf[off].toLong() and 0xff) or ((buf[off + 1].toLong() and 0xff) shl 8) or ((buf[off + 2].toLong() and 0xff) shl 16) or ((buf[off + 3].toLong() and 0xff) shl 24) } private fun readU32BE(buf: ByteArray, off: Int): Long { return ((buf[off].toLong() and 0xff) shl 24) or ((buf[off + 1].toLong() and 0xff) shl 16) or ((buf[off + 2].toLong() and 0xff) shl 8) or (buf[off + 3].toLong() and 0xff) } private class BinaryWriter { private val out = ByteArrayOutputStream() fun u8(v: Int) { out.write(v and 0xff) } fun u16(v: Int) { out.write(v and 0xff); out.write((v ushr 8) and 0xff) } fun u32(v: Long) { out.write((v and 0xff).toInt()); out.write(((v ushr 8) and 0xff).toInt()) out.write(((v ushr 16) and 0xff).toInt()); out.write(((v ushr 24) and 0xff).toInt()) } fun bytes(b: ByteArray) { out.write(b) } fun lpString(value: String) { val enc = value.toByteArray(Charsets.UTF_8) if (enc.size > 0xff) throw PeachScriptException("STRING_TOO_LONG_U8") u8(enc.size); bytes(enc) } fun toBytes(): ByteArray = out.toByteArray() } private class BinaryReader(private val data: ByteArray, start: Int) { private var cursor = start fun u8(): Int = data[cursor++].toInt() and 0xff fun u16(): Int { val v = (data[cursor].toInt() and 0xff) or ((data[cursor + 1].toInt() and 0xff) shl 8); cursor += 2; return v } fun u32(): Long { val v = (data[cursor].toLong() and 0xff) or ((data[cursor + 1].toLong() and 0xff) shl 8) or ((data[cursor + 2].toLong() and 0xff) shl 16) or ((data[cursor + 3].toLong() and 0xff) shl 24) cursor += 4; return v } fun read(n: Int): ByteArray { val out = data.copyOfRange(cursor, cursor + n); cursor += n; return out } fun lpString(): String = String(read(u8()), Charsets.UTF_8) } private fun JsonObjectBuilder.putIfPresent(key: String, value: String?) { if (!value.isNullOrEmpty()) put(key, JsonPrimitive(value)) } private fun JsonObjectBuilder.putJsonObjectStringIfPresent(key: String, value: String?) { if (value.isNullOrBlank()) return val obj = runCatching { json.parseToJsonElement(value).jsonObject }.getOrNull() ?: return put(key, obj) } private fun JsonObjectBuilder.putIfPresent(key: String, value: Int?) { if (value != null) put(key, JsonPrimitive(value)) } private fun JsonObjectBuilder.putIfPresent(key: String, value: Long?) { if (value != null) put(key, JsonPrimitive(value)) } private fun JsonObjectBuilder.putIfPresent(key: String, value: Boolean?) { if (value != null) put(key, JsonPrimitive(value)) } }