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.longOrNull262728293031323334353637383940object PeachPayloadCodec {4142 43 private const val MAGIC = 0x50434853 44 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 90 91 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 109110 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 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 154 fun parsePayload(envelope: ByteArray, passphrase: String): List<VaultEntry> =155 parsePayloadData(envelope, passphrase).entries156157 158 fun parsePayloadData(envelope: ByteArray, passphrase: String): ParsedPayload {159 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 178 179 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 189 }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 210 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 231 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 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 279280 private class StringTable {281 private val map = LinkedHashMap<String, Int>()282283 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 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 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) 335 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 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 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) 386 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 424 425 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()) 498 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 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 523 524 525 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 574 575 576 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 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 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 671672 private fun deriveKey(passphrase: String, salt: ByteArray): ByteArray {673 674 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) 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 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}