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 = 0x035556 private const val ARGON2_MEMORY_KIB = 6553657 private const val ARGON2_ITERATIONS = 358 private const val ARGON2_PARALLELISM = 45960 private const val SECTION_ENTRIES = 0x0161 private const val SECTION_SETTINGS = 0x0262 private const val SECTION_LICENSE = 0x0363 private const val SECTION_RECALL = 0x0464 private const val SECTION_DECLINED_CARDS = 0x056566 private const val ENTRY_TYPE_LOGIN = 0x0167 private const val ENTRY_TYPE_NOTE = 0x0268 private const val ENTRY_TYPE_CARD = 0x0369 private const val ENTRY_TYPE_PASSKEY = 0x047071 private const val F_HAS_TOTP = 1 shl 072 private const val F_HAS_TAGS = 1 shl 173 private const val F_HAS_NOTES = 1 shl 274 private const val F_HAS_CANARY = 1 shl 375 private const val F_HAS_PWCHANGED = 1 shl 476 private const val F_HAS_BREACH = 1 shl 577 private const val F_HAS_ADDITIONAL_URLS = 1 shl 678 private const val F_HAS_DELETED = 1 shl 77980 private const val F2_HAS_EMAIL = 1 shl 08182 private val NETWORK_VALUES = listOf("visa", "mastercard", "amex", "discover", "unknown")8384 private const val ZSTD_LEVEL = 1985 private val secureRandom = SecureRandom()86 private val json = Json { ignoreUnknownKeys = true; encodeDefaults = false }8788 89 90 91 private const val MAX_ENVELOPE_BYTES = 50L * 1024 * 102492 private const val MAX_INNER_BYTES = MAX_ENVELOPE_BYTES93 private const val MAX_SECTION_RAW_BYTES = 16L * 1024 * 102494 private const val MAX_SECTION_COMP_BYTES = 32L * 1024 * 102495 private const val MAX_ENTRY_COUNT = 100_000L96 private const val MAX_PASSKEY_BLOB_BYTES = 1L * 1024 * 10249798 class PeachScriptException(message: String) : Exception(message)99100 data class ParsedPayload(101 val entries: List<VaultEntry>,102 val settings: VaultSettings = VaultSettings(),103 val licenseKey: String? = null,104 val licenseActivatedAt: Long? = null,105 )106107 108109 110 fun buildPayload(111 entries: List<VaultEntry>,112 passphrase: String,113 settings: VaultSettings = VaultSettings(),114 licenseKey: String? = null,115 licenseActivatedAt: Long? = null,116 ): ByteArray {117 val entriesRaw = encodeEntriesSection(entries)118 val sections = buildList {119 add(buildSection(SECTION_ENTRIES, entriesRaw))120 add(buildSection(SECTION_SETTINGS, encodeSettingsSection(settings)))121 if (!licenseKey.isNullOrBlank()) {122 add(buildSection(SECTION_LICENSE, encodeLicenseSection(licenseKey, licenseActivatedAt)))123 }124 }125126 val header = ByteArray(HEADER_LEN)127 writeU32BE(header, 0, MAGIC.toLong())128 header[4] = VERSION.toByte()129 header[5] = sections.size.toByte()130 131132 val body = concat(listOf(header) + sections)133 val checksum = blake3Truncated(body)134 val inner = concat(listOf(body, checksum))135136 val header5 = ByteArray(5)137 header5[0] = FORMAT_V3.toByte()138 writeU32LE(header5, 1, inner.size.toLong())139 val wrapped = padToNearestBucket(concat(listOf(header5, inner)))140141 val salt = ByteArray(SALT_LEN).also(secureRandom::nextBytes)142 val nonce = ByteArray(NONCE_LEN).also(secureRandom::nextBytes)143 val key = deriveKey(passphrase, salt)144 return try {145 val ctTag = aesGcmEncrypt(key, nonce, wrapped)146 concat(listOf(salt, nonce, ctTag))147 } finally {148 key.fill(0)149 }150 }151152 153 fun parsePayload(envelope: ByteArray, passphrase: String): List<VaultEntry> =154 parsePayloadData(envelope, passphrase).entries155156 157 fun parsePayloadData(envelope: ByteArray, passphrase: String): ParsedPayload {158 159 if (envelope.size > MAX_ENVELOPE_BYTES) throw PeachScriptException("PAYLOAD_TOO_LARGE")160 if (envelope.size < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {161 throw PeachScriptException("PAYLOAD_TOO_SHORT")162 }163 val salt = envelope.copyOfRange(0, SALT_LEN)164 val nonce = envelope.copyOfRange(SALT_LEN, SALT_LEN + NONCE_LEN)165 val ctTag = envelope.copyOfRange(SALT_LEN + NONCE_LEN, envelope.size)166167 val key = deriveKey(passphrase, salt)168 val decrypted = try {169 aesGcmDecrypt(key, nonce, ctTag)170 } catch (_: Exception) {171 throw PeachScriptException("INVALID_PASSPHRASE")172 } finally {173 key.fill(0)174 }175176 177 178 179 val inner: ByteArray180 if (decrypted.size >= 5 && (decrypted[0].toInt() == FORMAT_V2 || decrypted[0].toInt() == FORMAT_V3)) {181 val innerLen = readU32LE(decrypted, 1)182 if (innerLen > MAX_INNER_BYTES) throw PeachScriptException("INNER_TOO_LARGE")183 if (5L + innerLen > decrypted.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")184 inner = decrypted.copyOfRange(5, Math.toIntExact(5L + innerLen))185 } else {186 inner = decrypted 187 }188189 if (inner.size < HEADER_LEN + CHECKSUM_LEN) throw PeachScriptException("PAYLOAD_TOO_SHORT")190 val body = inner.copyOfRange(0, inner.size - CHECKSUM_LEN)191 val stored = inner.copyOfRange(inner.size - CHECKSUM_LEN, inner.size)192 if (!constantTimeEqual(stored, blake3Truncated(body))) throw PeachScriptException("CHECKSUM_FAILED")193194 if (readU32BE(body, 0) != MAGIC.toLong()) throw PeachScriptException("BAD_MAGIC")195 val version = body[4].toInt() and 0xff196 if (version != VERSION) throw PeachScriptException("UNSUPPORTED_VERSION:$version")197 val sectionCount = body[5].toInt() and 0xff198199 var entries = emptyList<VaultEntry>()200 var settings = VaultSettings()201 var licenseKey: String? = null202 var licenseActivatedAt: Long? = null203 var cursor = HEADER_LEN204 for (i in 0 until sectionCount) {205 if (cursor + 9 > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")206 val type = body[cursor].toInt() and 0xff207 208 209 val length = readU32LE(body, cursor + 1)210 val clength = readU32LE(body, cursor + 5)211 if (length > MAX_SECTION_RAW_BYTES || clength > MAX_SECTION_COMP_BYTES) {212 throw PeachScriptException("SECTION_TOO_LARGE")213 }214 val dataStart = cursor + 9215 val dataEndLong = dataStart.toLong() + clength216 if (dataEndLong > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")217 val compressed = body.copyOfRange(dataStart, Math.toIntExact(dataEndLong))218 val data = Zstd.decompress(compressed, Math.toIntExact(length))219 if (data.size.toLong() != length) throw PeachScriptException("SECTION_LENGTH_MISMATCH")220 when (type) {221 SECTION_ENTRIES -> entries = decodeEntriesSection(data)222 SECTION_SETTINGS -> settings = decodeSettingsSection(data) ?: VaultSettings()223 SECTION_LICENSE -> {224 val license = decodeLicenseSection(data)225 licenseKey = license?.first226 licenseActivatedAt = license?.second227 }228 229 230 else -> {}231 }232 cursor = Math.toIntExact(dataEndLong)233 }234 return ParsedPayload(235 entries = entries,236 settings = settings,237 licenseKey = licenseKey,238 licenseActivatedAt = licenseActivatedAt,239 )240 }241242 243244 private fun buildSection(type: Int, raw: ByteArray): ByteArray {245 val compressed = Zstd.compress(raw, ZSTD_LEVEL)246 val out = ByteArray(9 + compressed.size)247 out[0] = type.toByte()248 writeU32LE(out, 1, raw.size.toLong())249 writeU32LE(out, 5, compressed.size.toLong())250 System.arraycopy(compressed, 0, out, 9, compressed.size)251 return out252 }253254 private fun encodeSettingsSection(settings: VaultSettings): ByteArray =255 VaultCodec.encodeSettings(settings).toByteArray(Charsets.UTF_8)256257 private fun decodeSettingsSection(data: ByteArray): VaultSettings? =258 VaultCodec.decodeSettings(String(data, Charsets.UTF_8))259260 private fun encodeLicenseSection(key: String, activatedAt: Long?): ByteArray {261 val obj = buildJsonObject {262 put("key", JsonPrimitive(key))263 if (activatedAt != null) put("activatedAt", JsonPrimitive(activatedAt))264 }265 return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8)266 }267268 private fun decodeLicenseSection(data: ByteArray): Pair<String, Long?>? {269 val obj = runCatching { json.parseToJsonElement(String(data, Charsets.UTF_8)).jsonObject }.getOrNull()270 ?: return null271 val key = obj["key"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null272 val activatedAt = obj["activatedAt"]?.jsonPrimitive?.longOrNull273 return key to activatedAt274 }275276 277278 private class StringTable {279 private val map = LinkedHashMap<String, Int>()280281 282 fun intern(value: String?): Int {283 if (value.isNullOrEmpty()) return 0284 map[value]?.let { return it }285 val idx = map.size + 1286 if (idx > 0xffff) throw PeachScriptException("STRING_TABLE_OVERFLOW")287 map[value] = idx288 return idx289 }290291 fun serialize(): ByteArray {292 val out = BinaryWriter()293 out.u16(map.size)294 for (s in map.keys) {295 val e = s.toByteArray(Charsets.UTF_8)296 if (e.size > 0xffff) throw PeachScriptException("STRING_TOO_LONG")297 out.u16(e.size)298 out.bytes(e)299 }300 return out.toBytes()301 }302 }303304 private fun encodeEntriesSection(entries: List<VaultEntry>): ByteArray {305 val table = StringTable()306 307 for (e in entries) table.intern(e.title)308 val bodies = entries.map { encodeEntry(it, table) }309 val tableBytes = table.serialize()310311 val out = BinaryWriter()312 out.u32(entries.size.toLong())313 out.bytes(tableBytes)314 for (b in bodies) out.bytes(b)315 return out.toBytes()316 }317318 private fun decodeEntriesSection(data: ByteArray): List<VaultEntry> {319 val r = BinaryReader(data, 0)320 321 val count = r.u32()322 if (count > MAX_ENTRY_COUNT) throw PeachScriptException("ENTRY_COUNT_OVERFLOW")323 val strings = readStringTable(r)324 val out = ArrayList<VaultEntry>(Math.toIntExact(count))325 for (i in 0 until count) out.add(decodeEntry(r, strings))326 return out327 }328329 private fun readStringTable(r: BinaryReader): List<String?> {330 val count = r.u16()331 val strings = ArrayList<String?>(count + 1)332 strings.add(null) 333 for (i in 0 until count) {334 val len = r.u16()335 strings.add(String(r.read(len), Charsets.UTF_8))336 }337 return strings338 }339340 private fun lookup(strings: List<String?>, idx: Int): String? =341 if (idx == 0 || idx >= strings.size) null else strings[idx]342343 344345 private fun encodeEntry(entry: VaultEntry, table: StringTable): ByteArray {346 val type = entryTypeByte(entry)347 val w = BinaryWriter()348 w.u8(type)349 w.bytes(uuidToBytes(entry.id))350351 val createdSec = entry.createdAt / 1000L352 val updatedSec = entry.modifiedAt / 1000L353 val updatedDelta = maxOf(0L, updatedSec - createdSec)354 val deletedSec = entry.deletedAt?.let { it / 1000L } ?: 0L355356 357 var flags = 0358 var flags2 = 0359 if (entry.deletedAt != null) flags = flags or F_HAS_DELETED360361 when (type) {362 ENTRY_TYPE_LOGIN -> {363 if (!entry.email.isNullOrEmpty()) flags2 = flags2 or F2_HAS_EMAIL364 if (!entry.totp.isNullOrEmpty()) flags = flags or F_HAS_TOTP365 if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS366 if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES367 if (entry.canary != null) flags = flags or F_HAS_CANARY368 if (entry.passwordChangedAt != null) flags = flags or F_HAS_PWCHANGED369 if ((entry.breach ?: 0) > 0) flags = flags or F_HAS_BREACH370 if (!entry.additionalUrls.isNullOrEmpty()) flags = flags or F_HAS_ADDITIONAL_URLS371 }372 ENTRY_TYPE_NOTE -> {373 if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES374 if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS375 }376 ENTRY_TYPE_CARD -> {377 if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS378 if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES379 }380 }381382 w.u8(flags)383 w.u8(flags2) 384 w.u32(createdSec)385 w.u32(updatedDelta)386 w.u32(deletedSec)387 w.u16(table.intern(entry.title))388389 when (type) {390 ENTRY_TYPE_LOGIN -> {391 w.u16(table.intern(entry.url))392 w.u16(table.intern(entry.username))393 w.lpString(entry.password ?: "")394 if (flags2 and F2_HAS_EMAIL != 0) w.lpString(entry.email!!)395 if (flags and F_HAS_TOTP != 0) w.lpString(entry.totp!!)396 if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)397 if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))398 if (flags and F_HAS_CANARY != 0) {399 w.u16(table.intern(entry.canary!!.webhookUrl))400 w.u16(table.intern(entry.canary.label))401 }402 if (flags and F_HAS_PWCHANGED != 0) {403 val pwSec = entry.passwordChangedAt!! / 1000L404 w.u32(maxOf(0L, pwSec - createdSec))405 }406 if (flags and F_HAS_BREACH != 0) w.u32((entry.breach ?: 0).toLong())407 if (flags and F_HAS_ADDITIONAL_URLS != 0) writeStrings(w, table, entry.additionalUrls!!)408 }409 ENTRY_TYPE_NOTE -> {410 if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))411 if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)412 }413 ENTRY_TYPE_CARD -> {414 w.u16(table.intern(entry.cardholderName))415 w.lpString(entry.cardNumber ?: "")416 w.lpString(entry.expMonth ?: "")417 w.lpString(entry.expYear ?: "")418 w.lpString(entry.cvc ?: "")419 w.u16(table.intern(entry.cardNickname))420 w.u8(networkToByte(entry.cardNetwork))421 422 423 424 w.u16(table.intern(entry.last4?.takeIf { it.isNotEmpty() } ?: deriveLast4(entry)))425 if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)426 if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))427 }428 ENTRY_TYPE_PASSKEY -> {429 val blob = encodePasskeyBlob(entry)430 w.u32(blob.size.toLong())431 w.bytes(blob)432 }433 }434 return w.toBytes()435 }436437 private fun decodeEntry(r: BinaryReader, strings: List<String?>): VaultEntry {438 val type = r.u8()439 val id = bytesToUuid(r.read(16))440 val flags = r.u8()441 val flags2 = r.u8() 442 val createdSec = r.u32()443 val updatedDelta = r.u32()444 val deletedSec = r.u32()445 val titleIdx = r.u16()446447 val createdAt = createdSec * 1000L448 val modifiedAt = (createdSec + updatedDelta) * 1000L449 val deletedAt = if (deletedSec > 0) deletedSec * 1000L else null450 val title = lookup(strings, titleIdx) ?: ""451452 return when (type) {453 ENTRY_TYPE_LOGIN -> {454 val urlIdx = r.u16()455 val usernameIdx = r.u16()456 val password = r.lpString()457 val email = if (flags2 and F2_HAS_EMAIL != 0) r.lpString() else null458 val totp = if (flags and F_HAS_TOTP != 0) r.lpString() else null459 val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null460 val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null461 var canary: Canary? = null462 if (flags and F_HAS_CANARY != 0) {463 val webhookUrl = lookup(strings, r.u16()) ?: ""464 val label = lookup(strings, r.u16())465 canary = Canary(webhookUrl = webhookUrl, label = label)466 }467 val pwChanged = if (flags and F_HAS_PWCHANGED != 0) (createdSec + r.u32()) * 1000L else null468 val breach = if (flags and F_HAS_BREACH != 0) r.u32().toInt() else null469 val additionalUrls = if (flags and F_HAS_ADDITIONAL_URLS != 0) readStrings(r, strings) else null470 VaultEntry(471 id = id, type = "login", title = title,472 url = lookup(strings, urlIdx), username = lookup(strings, usernameIdx),473 password = password, email = email, totp = totp, tags = tags, notes = notes,474 canary = canary, passwordChangedAt = pwChanged, breach = breach,475 additionalUrls = additionalUrls, createdAt = createdAt, modifiedAt = modifiedAt,476 deletedAt = deletedAt,477 )478 }479 ENTRY_TYPE_NOTE -> {480 val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null481 val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null482 VaultEntry(483 id = id, type = "note", title = title, notes = notes, tags = tags,484 createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,485 )486 }487 ENTRY_TYPE_CARD -> {488 val cardholderIdx = r.u16()489 val cardNumber = r.lpString()490 val expMonth = r.lpString()491 val expYear = r.lpString()492 val cvc = r.lpString()493 val nicknameIdx = r.u16()494 val network = networkFromByte(r.u8())495 val last4 = lookup(strings, r.u16()) 496 val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null497 val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null498 VaultEntry(499 id = id, type = "card", title = title,500 cardholderName = lookup(strings, cardholderIdx),501 cardNumber = cardNumber.ifEmpty { null }, expMonth = expMonth.ifEmpty { null },502 expYear = expYear.ifEmpty { null }, cvc = cvc.ifEmpty { null },503 cardNickname = lookup(strings, nicknameIdx), cardNetwork = network,504 last4 = last4,505 tags = tags, notes = notes,506 createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,507 )508 }509 ENTRY_TYPE_PASSKEY -> {510 511 val blobLen = r.u32()512 if (blobLen > MAX_PASSKEY_BLOB_BYTES) throw PeachScriptException("PASSKEY_BLOB_TOO_LARGE")513 val blob = r.read(Math.toIntExact(blobLen))514 decodePasskeyBlob(id, title, createdAt, modifiedAt, deletedAt, blob)515 }516 else -> throw PeachScriptException("UNKNOWN_ENTRY_TYPE:$type")517 }518 }519520 521 522 523 524 private fun writeTags(w: BinaryWriter, table: StringTable, tags: List<String>) {525 if (tags.size > 0xff) throw PeachScriptException("TAG_LIST_TOO_LONG")526 w.u8(tags.size)527 for (t in tags) w.u16(table.intern(t))528 }529530 private fun writeStrings(w: BinaryWriter, table: StringTable, items: List<String>) {531 if (items.size > 0xff) throw PeachScriptException("URL_LIST_TOO_LONG")532 w.u8(items.size)533 for (s in items) w.u16(table.intern(s))534 }535536 private fun readTags(r: BinaryReader, strings: List<String?>): List<String> {537 val n = r.u8()538 val out = ArrayList<String>(n)539 for (i in 0 until n) lookup(strings, r.u16())?.let { out.add(it) }540 return out541 }542543 private fun readStrings(r: BinaryReader, strings: List<String?>): List<String> = readTags(r, strings)544545 private fun entryTypeByte(entry: VaultEntry): Int {546 if (entry.passkey != null) return ENTRY_TYPE_PASSKEY547 return when (entry.type) {548 "note" -> ENTRY_TYPE_NOTE549 "card" -> ENTRY_TYPE_CARD550 "passkey" -> ENTRY_TYPE_PASSKEY551 else -> ENTRY_TYPE_LOGIN552 }553 }554555 private fun networkToByte(network: String?): Int {556 if (network.isNullOrEmpty()) return 0xff557 val idx = NETWORK_VALUES.indexOf(network)558 return if (idx >= 0) idx else NETWORK_VALUES.indexOf("unknown")559 }560561 private fun networkFromByte(b: Int): String? {562 if (b == 0xff) return null563 return NETWORK_VALUES.getOrNull(b) ?: "unknown"564 }565566 private fun deriveLast4(entry: VaultEntry): String? {567 val digits = entry.cardNumber?.filter { it.isDigit() } ?: return null568 return if (digits.length >= 4) digits.takeLast(4) else digits.ifEmpty { null }569 }570571 572 573 574 575576 private fun encodePasskeyBlob(entry: VaultEntry): ByteArray {577 val pk = entry.passkey578 val obj = buildJsonObject {579 putIfPresent("rpId", pk?.rpId ?: entry.rpId)580 putIfPresent("rpName", pk?.rpName ?: entry.rpName)581 putIfPresent("credentialId", pk?.credentialId ?: entry.credentialId)582 putIfPresent("publicKeyCose", pk?.publicKeyCose ?: entry.publicKeyCose)583 putJsonObjectStringIfPresent("privateKeyJwk", pk?.privateKeyJwk ?: entry.privateKeyJwk)584 putIfPresent("userHandle", pk?.userHandle ?: entry.userHandle)585 putIfPresent("userName", pk?.userName ?: entry.userName)586 putIfPresent("userDisplayName", pk?.userDisplayName ?: entry.userDisplayName)587 putIfPresent("algorithm", (pk?.algorithm ?: entry.algorithm))588 putIfPresent("signCount", (pk?.signCount ?: entry.signCount))589 putIfPresent("aaguid", pk?.aaguid ?: entry.aaguid)590 putIfPresent("backupEligible", pk?.backupEligible ?: entry.backupEligible)591 putIfPresent("backupState", pk?.backupState ?: entry.backupState)592 putIfPresent("lastUsedAt", entry.lastUsedAt)593 594 putIfPresent("password", entry.password)595 putIfPresent("url", entry.url)596 putIfPresent("username", entry.username)597 putIfPresent("email", entry.email)598 putIfPresent("totp", entry.totp)599 putIfPresent("notes", entry.notes)600 if (!entry.tags.isNullOrEmpty()) {601 put("tags", buildJsonArray { entry.tags.forEach { add(JsonPrimitive(it)) } })602 }603 }604 return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8)605 }606607 private fun decodePasskeyBlob(608 id: String, title: String, createdAt: Long, modifiedAt: Long, deletedAt: Long?, blob: ByteArray,609 ): VaultEntry {610 val obj = json.parseToJsonElement(String(blob, Charsets.UTF_8)).jsonObject611 fun str(k: String) = obj[k]?.jsonPrimitive?.contentOrNull612 fun intf(k: String) = obj[k]?.jsonPrimitive?.intOrNull613 fun longf(k: String) = obj[k]?.jsonPrimitive?.longOrNull614 fun boolf(k: String) = obj[k]?.jsonPrimitive?.booleanOrNull615 fun objectJson(k: String): String? {616 val value = obj[k] ?: return null617 val nested = runCatching { value.jsonObject }.getOrNull() ?: return null618 return json.encodeToString(JsonObject.serializer(), nested)619 }620 val tags = obj["tags"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull }621 val credentialId = str("credentialId")622 val publicKeyCose = str("publicKeyCose")623 val privateKeyJwk = objectJson("privateKeyJwk")624 val rpId = str("rpId")625 val userHandle = str("userHandle")626 val userName = str("userName")627 val passkey = if (credentialId != null && publicKeyCose != null && rpId != null &&628 userHandle != null && userName != null) {629 PasskeyData(630 credentialId = credentialId, publicKeyCose = publicKeyCose, rpId = rpId,631 privateKeyJwk = privateKeyJwk,632 rpName = str("rpName"), userHandle = userHandle, userName = userName,633 userDisplayName = str("userDisplayName"), algorithm = intf("algorithm") ?: -7,634 signCount = intf("signCount") ?: 0, aaguid = str("aaguid"),635 backupEligible = boolf("backupEligible") ?: false, backupState = boolf("backupState") ?: false,636 )637 } else null638 return VaultEntry(639 id = id, type = "passkey", title = title,640 url = str("url"), username = str("username"), email = str("email"),641 password = str("password"), totp = str("totp"), notes = str("notes"), tags = tags,642 createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,643 passkey = passkey, lastUsedAt = longf("lastUsedAt"),644 rpId = rpId, rpName = str("rpName"), credentialId = credentialId,645 publicKeyCose = publicKeyCose, privateKeyJwk = privateKeyJwk,646 userHandle = userHandle, userName = userName,647 userDisplayName = str("userDisplayName"), algorithm = intf("algorithm"),648 aaguid = str("aaguid"), backupEligible = boolf("backupEligible"),649 backupState = boolf("backupState"), signCount = intf("signCount"),650 )651 }652653 654655 fun uuidToBytes(uuid: String): ByteArray {656 val hex = uuid.replace("-", "")657 if (hex.length != 32) throw PeachScriptException("UUID_FORMAT")658 return ByteArray(16) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }659 }660661 fun bytesToUuid(bytes: ByteArray): String {662 if (bytes.size != 16) throw PeachScriptException("UUID_LENGTH")663 val hex = bytes.joinToString("") { "%02x".format(it) }664 return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-" +665 "${hex.substring(16, 20)}-${hex.substring(20)}"666 }667668 669670 private fun deriveKey(passphrase: String, salt: ByteArray): ByteArray {671 672 673 val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)674 .withSalt(salt)675 .withParallelism(ARGON2_PARALLELISM)676 .withMemoryAsKB(ARGON2_MEMORY_KIB)677 .withIterations(ARGON2_ITERATIONS)678 .build()679 val gen = Argon2BytesGenerator().apply { init(params) }680 val out = ByteArray(32)681 gen.generateBytes(passphrase.toByteArray(Charsets.UTF_8), out)682 return out683 }684685 private fun aesGcmEncrypt(key: ByteArray, nonce: ByteArray, plaintext: ByteArray): ByteArray {686 val cipher = Cipher.getInstance("AES/GCM/NoPadding")687 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))688 return cipher.doFinal(plaintext) 689 }690691 private fun aesGcmDecrypt(key: ByteArray, nonce: ByteArray, ctTag: ByteArray): ByteArray {692 val cipher = Cipher.getInstance("AES/GCM/NoPadding")693 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))694 return cipher.doFinal(ctTag)695 }696697 private fun blake3Truncated(data: ByteArray): ByteArray {698 val digest = Blake3Digest(256)699 digest.update(data, 0, data.size)700 val full = ByteArray(32)701 digest.doFinal(full, 0)702 return full.copyOfRange(0, CHECKSUM_LEN)703 }704705 private fun padToNearestBucket(data: ByteArray): ByteArray {706 val bucket = SIZE_BUCKETS.firstOrNull { it >= data.size }707 var target = bucket ?: run {708 var t = 262144709 while (t < data.size) t *= 2710 t711 }712 if (target == data.size) return data713 return data.copyOf(target)714 }715716 private fun constantTimeEqual(a: ByteArray, b: ByteArray): Boolean {717 if (a.size != b.size) return false718 var diff = 0719 for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt())720 return diff == 0721 }722723 private fun concat(parts: List<ByteArray>): ByteArray {724 val total = parts.sumOf { it.size }725 val out = ByteArray(total)726 var c = 0727 for (p in parts) { System.arraycopy(p, 0, out, c, p.size); c += p.size }728 return out729 }730731 732733 private fun writeU32LE(buf: ByteArray, off: Int, v: Long) {734 buf[off] = (v and 0xff).toByte()735 buf[off + 1] = ((v ushr 8) and 0xff).toByte()736 buf[off + 2] = ((v ushr 16) and 0xff).toByte()737 buf[off + 3] = ((v ushr 24) and 0xff).toByte()738 }739740 private fun writeU32BE(buf: ByteArray, off: Int, v: Long) {741 buf[off] = ((v ushr 24) and 0xff).toByte()742 buf[off + 1] = ((v ushr 16) and 0xff).toByte()743 buf[off + 2] = ((v ushr 8) and 0xff).toByte()744 buf[off + 3] = (v and 0xff).toByte()745 }746747 private fun readU32LE(buf: ByteArray, off: Int): Long {748 return (buf[off].toLong() and 0xff) or749 ((buf[off + 1].toLong() and 0xff) shl 8) or750 ((buf[off + 2].toLong() and 0xff) shl 16) or751 ((buf[off + 3].toLong() and 0xff) shl 24)752 }753754 private fun readU32BE(buf: ByteArray, off: Int): Long {755 return ((buf[off].toLong() and 0xff) shl 24) or756 ((buf[off + 1].toLong() and 0xff) shl 16) or757 ((buf[off + 2].toLong() and 0xff) shl 8) or758 (buf[off + 3].toLong() and 0xff)759 }760761 private class BinaryWriter {762 private val out = ByteArrayOutputStream()763 fun u8(v: Int) { out.write(v and 0xff) }764 fun u16(v: Int) { out.write(v and 0xff); out.write((v ushr 8) and 0xff) }765 fun u32(v: Long) {766 out.write((v and 0xff).toInt()); out.write(((v ushr 8) and 0xff).toInt())767 out.write(((v ushr 16) and 0xff).toInt()); out.write(((v ushr 24) and 0xff).toInt())768 }769 fun bytes(b: ByteArray) { out.write(b) }770 fun lpString(value: String) {771 val enc = value.toByteArray(Charsets.UTF_8)772 if (enc.size > 0xff) throw PeachScriptException("STRING_TOO_LONG_U8")773 u8(enc.size); bytes(enc)774 }775 fun toBytes(): ByteArray = out.toByteArray()776 }777778 private class BinaryReader(private val data: ByteArray, start: Int) {779 private var cursor = start780 fun u8(): Int = data[cursor++].toInt() and 0xff781 fun u16(): Int { val v = (data[cursor].toInt() and 0xff) or ((data[cursor + 1].toInt() and 0xff) shl 8); cursor += 2; return v }782 fun u32(): Long {783 val v = (data[cursor].toLong() and 0xff) or ((data[cursor + 1].toLong() and 0xff) shl 8) or784 ((data[cursor + 2].toLong() and 0xff) shl 16) or ((data[cursor + 3].toLong() and 0xff) shl 24)785 cursor += 4; return v786 }787 fun read(n: Int): ByteArray { val out = data.copyOfRange(cursor, cursor + n); cursor += n; return out }788 fun lpString(): String = String(read(u8()), Charsets.UTF_8)789 }790791 private fun JsonObjectBuilder.putIfPresent(key: String, value: String?) {792 if (!value.isNullOrEmpty()) put(key, JsonPrimitive(value))793 }794 private fun JsonObjectBuilder.putJsonObjectStringIfPresent(key: String, value: String?) {795 if (value.isNullOrBlank()) return796 val obj = runCatching { json.parseToJsonElement(value).jsonObject }.getOrNull() ?: return797 put(key, obj)798 }799 private fun JsonObjectBuilder.putIfPresent(key: String, value: Int?) {800 if (value != null) put(key, JsonPrimitive(value))801 }802 private fun JsonObjectBuilder.putIfPresent(key: String, value: Long?) {803 if (value != null) put(key, JsonPrimitive(value))804 }805 private fun JsonObjectBuilder.putIfPresent(key: String, value: Boolean?) {806 if (value != null) put(key, JsonPrimitive(value))807 }808}