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 class PeachScriptException(message: String) : Exception(message)8990 data class ParsedPayload(91 val entries: List<VaultEntry>,92 val settings: VaultSettings = VaultSettings(),93 val licenseKey: String? = null,94 val licenseActivatedAt: Long? = null,95 )9697 9899 100 fun buildPayload(101 entries: List<VaultEntry>,102 passphrase: String,103 settings: VaultSettings = VaultSettings(),104 licenseKey: String? = null,105 licenseActivatedAt: Long? = null,106 ): ByteArray {107 val entriesRaw = encodeEntriesSection(entries)108 val sections = buildList {109 add(buildSection(SECTION_ENTRIES, entriesRaw))110 add(buildSection(SECTION_SETTINGS, encodeSettingsSection(settings)))111 if (!licenseKey.isNullOrBlank()) {112 add(buildSection(SECTION_LICENSE, encodeLicenseSection(licenseKey, licenseActivatedAt)))113 }114 }115116 val header = ByteArray(HEADER_LEN)117 writeU32BE(header, 0, MAGIC.toLong())118 header[4] = VERSION.toByte()119 header[5] = sections.size.toByte()120 121122 val body = concat(listOf(header) + sections)123 val checksum = blake3Truncated(body)124 val inner = concat(listOf(body, checksum))125126 val header5 = ByteArray(5)127 header5[0] = FORMAT_V3.toByte()128 writeU32LE(header5, 1, inner.size.toLong())129 val wrapped = padToNearestBucket(concat(listOf(header5, inner)))130131 val salt = ByteArray(SALT_LEN).also(secureRandom::nextBytes)132 val nonce = ByteArray(NONCE_LEN).also(secureRandom::nextBytes)133 val key = deriveKey(passphrase, salt)134 return try {135 val ctTag = aesGcmEncrypt(key, nonce, wrapped)136 concat(listOf(salt, nonce, ctTag))137 } finally {138 key.fill(0)139 }140 }141142 143 fun parsePayload(envelope: ByteArray, passphrase: String): List<VaultEntry> =144 parsePayloadData(envelope, passphrase).entries145146 147 fun parsePayloadData(envelope: ByteArray, passphrase: String): ParsedPayload {148 if (envelope.size < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {149 throw PeachScriptException("PAYLOAD_TOO_SHORT")150 }151 val salt = envelope.copyOfRange(0, SALT_LEN)152 val nonce = envelope.copyOfRange(SALT_LEN, SALT_LEN + NONCE_LEN)153 val ctTag = envelope.copyOfRange(SALT_LEN + NONCE_LEN, envelope.size)154155 val key = deriveKey(passphrase, salt)156 val decrypted = try {157 aesGcmDecrypt(key, nonce, ctTag)158 } catch (_: Exception) {159 throw PeachScriptException("INVALID_PASSPHRASE")160 } finally {161 key.fill(0)162 }163164 165 val inner: ByteArray166 if (decrypted.size >= 5 && (decrypted[0].toInt() == FORMAT_V2 || decrypted[0].toInt() == FORMAT_V3)) {167 val innerLen = readU32LE(decrypted, 1).toInt()168 if (5 + innerLen > decrypted.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")169 inner = decrypted.copyOfRange(5, 5 + innerLen)170 } else {171 inner = decrypted 172 }173174 if (inner.size < HEADER_LEN + CHECKSUM_LEN) throw PeachScriptException("PAYLOAD_TOO_SHORT")175 val body = inner.copyOfRange(0, inner.size - CHECKSUM_LEN)176 val stored = inner.copyOfRange(inner.size - CHECKSUM_LEN, inner.size)177 if (!constantTimeEqual(stored, blake3Truncated(body))) throw PeachScriptException("CHECKSUM_FAILED")178179 if (readU32BE(body, 0) != MAGIC.toLong()) throw PeachScriptException("BAD_MAGIC")180 val version = body[4].toInt() and 0xff181 if (version != VERSION) throw PeachScriptException("UNSUPPORTED_VERSION:$version")182 val sectionCount = body[5].toInt() and 0xff183184 var entries = emptyList<VaultEntry>()185 var settings = VaultSettings()186 var licenseKey: String? = null187 var licenseActivatedAt: Long? = null188 var cursor = HEADER_LEN189 for (i in 0 until sectionCount) {190 if (cursor + 9 > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")191 val type = body[cursor].toInt() and 0xff192 val length = readU32LE(body, cursor + 1).toInt()193 val clength = readU32LE(body, cursor + 5).toInt()194 val dataStart = cursor + 9195 val dataEnd = dataStart + clength196 if (dataEnd > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")197 val compressed = body.copyOfRange(dataStart, dataEnd)198 val data = Zstd.decompress(compressed, length)199 if (data.size != length) throw PeachScriptException("SECTION_LENGTH_MISMATCH")200 when (type) {201 SECTION_ENTRIES -> entries = decodeEntriesSection(data)202 SECTION_SETTINGS -> settings = decodeSettingsSection(data) ?: VaultSettings()203 SECTION_LICENSE -> {204 val license = decodeLicenseSection(data)205 licenseKey = license?.first206 licenseActivatedAt = license?.second207 }208 209 210 else -> {}211 }212 cursor = dataEnd213 }214 return ParsedPayload(215 entries = entries,216 settings = settings,217 licenseKey = licenseKey,218 licenseActivatedAt = licenseActivatedAt,219 )220 }221222 223224 private fun buildSection(type: Int, raw: ByteArray): ByteArray {225 val compressed = Zstd.compress(raw, ZSTD_LEVEL)226 val out = ByteArray(9 + compressed.size)227 out[0] = type.toByte()228 writeU32LE(out, 1, raw.size.toLong())229 writeU32LE(out, 5, compressed.size.toLong())230 System.arraycopy(compressed, 0, out, 9, compressed.size)231 return out232 }233234 private fun encodeSettingsSection(settings: VaultSettings): ByteArray =235 VaultCodec.encodeSettings(settings).toByteArray(Charsets.UTF_8)236237 private fun decodeSettingsSection(data: ByteArray): VaultSettings? =238 VaultCodec.decodeSettings(String(data, Charsets.UTF_8))239240 private fun encodeLicenseSection(key: String, activatedAt: Long?): ByteArray {241 val obj = buildJsonObject {242 put("key", JsonPrimitive(key))243 if (activatedAt != null) put("activatedAt", JsonPrimitive(activatedAt))244 }245 return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8)246 }247248 private fun decodeLicenseSection(data: ByteArray): Pair<String, Long?>? {249 val obj = runCatching { json.parseToJsonElement(String(data, Charsets.UTF_8)).jsonObject }.getOrNull()250 ?: return null251 val key = obj["key"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null252 val activatedAt = obj["activatedAt"]?.jsonPrimitive?.longOrNull253 return key to activatedAt254 }255256 257258 private class StringTable {259 private val map = LinkedHashMap<String, Int>()260261 262 fun intern(value: String?): Int {263 if (value.isNullOrEmpty()) return 0264 map[value]?.let { return it }265 val idx = map.size + 1266 if (idx > 0xffff) throw PeachScriptException("STRING_TABLE_OVERFLOW")267 map[value] = idx268 return idx269 }270271 fun serialize(): ByteArray {272 val out = BinaryWriter()273 out.u16(map.size)274 for (s in map.keys) {275 val e = s.toByteArray(Charsets.UTF_8)276 if (e.size > 0xffff) throw PeachScriptException("STRING_TOO_LONG")277 out.u16(e.size)278 out.bytes(e)279 }280 return out.toBytes()281 }282 }283284 private fun encodeEntriesSection(entries: List<VaultEntry>): ByteArray {285 val table = StringTable()286 287 for (e in entries) table.intern(e.title)288 val bodies = entries.map { encodeEntry(it, table) }289 val tableBytes = table.serialize()290291 val out = BinaryWriter()292 out.u32(entries.size.toLong())293 out.bytes(tableBytes)294 for (b in bodies) out.bytes(b)295 return out.toBytes()296 }297298 private fun decodeEntriesSection(data: ByteArray): List<VaultEntry> {299 val r = BinaryReader(data, 0)300 val count = r.u32().toInt()301 val strings = readStringTable(r)302 val out = ArrayList<VaultEntry>(count)303 for (i in 0 until count) out.add(decodeEntry(r, strings))304 return out305 }306307 private fun readStringTable(r: BinaryReader): List<String?> {308 val count = r.u16()309 val strings = ArrayList<String?>(count + 1)310 strings.add(null) 311 for (i in 0 until count) {312 val len = r.u16()313 strings.add(String(r.read(len), Charsets.UTF_8))314 }315 return strings316 }317318 private fun lookup(strings: List<String?>, idx: Int): String? =319 if (idx == 0 || idx >= strings.size) null else strings[idx]320321 322323 private fun encodeEntry(entry: VaultEntry, table: StringTable): ByteArray {324 val type = entryTypeByte(entry)325 val w = BinaryWriter()326 w.u8(type)327 w.bytes(uuidToBytes(entry.id))328329 val createdSec = entry.createdAt / 1000L330 val updatedSec = entry.modifiedAt / 1000L331 val updatedDelta = maxOf(0L, updatedSec - createdSec)332 val deletedSec = entry.deletedAt?.let { it / 1000L } ?: 0L333334 335 var flags = 0336 var flags2 = 0337 if (entry.deletedAt != null) flags = flags or F_HAS_DELETED338339 when (type) {340 ENTRY_TYPE_LOGIN -> {341 if (!entry.email.isNullOrEmpty()) flags2 = flags2 or F2_HAS_EMAIL342 if (!entry.totp.isNullOrEmpty()) flags = flags or F_HAS_TOTP343 if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS344 if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES345 if (entry.canary != null) flags = flags or F_HAS_CANARY346 if (entry.passwordChangedAt != null) flags = flags or F_HAS_PWCHANGED347 if ((entry.breach ?: 0) > 0) flags = flags or F_HAS_BREACH348 if (!entry.additionalUrls.isNullOrEmpty()) flags = flags or F_HAS_ADDITIONAL_URLS349 }350 ENTRY_TYPE_NOTE -> {351 if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES352 if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS353 }354 ENTRY_TYPE_CARD -> {355 if (!entry.tags.isNullOrEmpty()) flags = flags or F_HAS_TAGS356 if (!entry.notes.isNullOrEmpty()) flags = flags or F_HAS_NOTES357 }358 }359360 w.u8(flags)361 w.u8(flags2) 362 w.u32(createdSec)363 w.u32(updatedDelta)364 w.u32(deletedSec)365 w.u16(table.intern(entry.title))366367 when (type) {368 ENTRY_TYPE_LOGIN -> {369 w.u16(table.intern(entry.url))370 w.u16(table.intern(entry.username))371 w.lpString(entry.password ?: "")372 if (flags2 and F2_HAS_EMAIL != 0) w.lpString(entry.email!!)373 if (flags and F_HAS_TOTP != 0) w.lpString(entry.totp!!)374 if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)375 if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))376 if (flags and F_HAS_CANARY != 0) {377 w.u16(table.intern(entry.canary!!.webhookUrl))378 w.u16(table.intern(entry.canary.label))379 }380 if (flags and F_HAS_PWCHANGED != 0) {381 val pwSec = entry.passwordChangedAt!! / 1000L382 w.u32(maxOf(0L, pwSec - createdSec))383 }384 if (flags and F_HAS_BREACH != 0) w.u32((entry.breach ?: 0).toLong())385 if (flags and F_HAS_ADDITIONAL_URLS != 0) writeStrings(w, table, entry.additionalUrls!!)386 }387 ENTRY_TYPE_NOTE -> {388 if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))389 if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)390 }391 ENTRY_TYPE_CARD -> {392 w.u16(table.intern(entry.cardholderName))393 w.lpString(entry.cardNumber ?: "")394 w.lpString(entry.expMonth ?: "")395 w.lpString(entry.expYear ?: "")396 w.lpString(entry.cvc ?: "")397 w.u16(table.intern(entry.cardNickname))398 w.u8(networkToByte(entry.cardNetwork))399 400 401 w.u16(table.intern(deriveLast4(entry)))402 if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)403 if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))404 }405 ENTRY_TYPE_PASSKEY -> {406 val blob = encodePasskeyBlob(entry)407 w.u32(blob.size.toLong())408 w.bytes(blob)409 }410 }411 return w.toBytes()412 }413414 private fun decodeEntry(r: BinaryReader, strings: List<String?>): VaultEntry {415 val type = r.u8()416 val id = bytesToUuid(r.read(16))417 val flags = r.u8()418 val flags2 = r.u8() 419 val createdSec = r.u32()420 val updatedDelta = r.u32()421 val deletedSec = r.u32()422 val titleIdx = r.u16()423424 val createdAt = createdSec * 1000L425 val modifiedAt = (createdSec + updatedDelta) * 1000L426 val deletedAt = if (deletedSec > 0) deletedSec * 1000L else null427 val title = lookup(strings, titleIdx) ?: ""428429 return when (type) {430 ENTRY_TYPE_LOGIN -> {431 val urlIdx = r.u16()432 val usernameIdx = r.u16()433 val password = r.lpString()434 val email = if (flags2 and F2_HAS_EMAIL != 0) r.lpString() else null435 val totp = if (flags and F_HAS_TOTP != 0) r.lpString() else null436 val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null437 val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null438 var canary: Canary? = null439 if (flags and F_HAS_CANARY != 0) {440 val webhookUrl = lookup(strings, r.u16()) ?: ""441 val label = lookup(strings, r.u16())442 canary = Canary(webhookUrl = webhookUrl, label = label)443 }444 val pwChanged = if (flags and F_HAS_PWCHANGED != 0) (createdSec + r.u32()) * 1000L else null445 val breach = if (flags and F_HAS_BREACH != 0) r.u32().toInt() else null446 val additionalUrls = if (flags and F_HAS_ADDITIONAL_URLS != 0) readStrings(r, strings) else null447 VaultEntry(448 id = id, type = "login", title = title,449 url = lookup(strings, urlIdx), username = lookup(strings, usernameIdx),450 password = password, email = email, totp = totp, tags = tags, notes = notes,451 canary = canary, passwordChangedAt = pwChanged, breach = breach,452 additionalUrls = additionalUrls, createdAt = createdAt, modifiedAt = modifiedAt,453 deletedAt = deletedAt,454 )455 }456 ENTRY_TYPE_NOTE -> {457 val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null458 val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null459 VaultEntry(460 id = id, type = "note", title = title, notes = notes, tags = tags,461 createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,462 )463 }464 ENTRY_TYPE_CARD -> {465 val cardholderIdx = r.u16()466 val cardNumber = r.lpString()467 val expMonth = r.lpString()468 val expYear = r.lpString()469 val cvc = r.lpString()470 val nicknameIdx = r.u16()471 val network = networkFromByte(r.u8())472 r.u16() 473 val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null474 val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null475 VaultEntry(476 id = id, type = "card", title = title,477 cardholderName = lookup(strings, cardholderIdx),478 cardNumber = cardNumber.ifEmpty { null }, expMonth = expMonth.ifEmpty { null },479 expYear = expYear.ifEmpty { null }, cvc = cvc.ifEmpty { null },480 cardNickname = lookup(strings, nicknameIdx), cardNetwork = network,481 tags = tags, notes = notes,482 createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,483 )484 }485 ENTRY_TYPE_PASSKEY -> {486 val blobLen = r.u32().toInt()487 val blob = r.read(blobLen)488 decodePasskeyBlob(id, title, createdAt, modifiedAt, deletedAt, blob)489 }490 else -> throw PeachScriptException("UNKNOWN_ENTRY_TYPE:$type")491 }492 }493494 private fun writeTags(w: BinaryWriter, table: StringTable, tags: List<String>) {495 val n = minOf(tags.size, 0xff)496 w.u8(n)497 for (i in 0 until n) w.u16(table.intern(tags[i]))498 }499500 private fun writeStrings(w: BinaryWriter, table: StringTable, items: List<String>) {501 val n = minOf(items.size, 0xff)502 w.u8(n)503 for (i in 0 until n) w.u16(table.intern(items[i]))504 }505506 private fun readTags(r: BinaryReader, strings: List<String?>): List<String> {507 val n = r.u8()508 val out = ArrayList<String>(n)509 for (i in 0 until n) lookup(strings, r.u16())?.let { out.add(it) }510 return out511 }512513 private fun readStrings(r: BinaryReader, strings: List<String?>): List<String> = readTags(r, strings)514515 private fun entryTypeByte(entry: VaultEntry): Int {516 if (entry.passkey != null) return ENTRY_TYPE_PASSKEY517 return when (entry.type) {518 "note" -> ENTRY_TYPE_NOTE519 "card" -> ENTRY_TYPE_CARD520 "passkey" -> ENTRY_TYPE_PASSKEY521 else -> ENTRY_TYPE_LOGIN522 }523 }524525 private fun networkToByte(network: String?): Int {526 if (network.isNullOrEmpty()) return 0xff527 val idx = NETWORK_VALUES.indexOf(network)528 return if (idx >= 0) idx else NETWORK_VALUES.indexOf("unknown")529 }530531 private fun networkFromByte(b: Int): String? {532 if (b == 0xff) return null533 return NETWORK_VALUES.getOrNull(b) ?: "unknown"534 }535536 private fun deriveLast4(entry: VaultEntry): String? {537 val digits = entry.cardNumber?.filter { it.isDigit() } ?: return null538 return if (digits.length >= 4) digits.takeLast(4) else digits.ifEmpty { null }539 }540541 542 543 544 545546 private fun encodePasskeyBlob(entry: VaultEntry): ByteArray {547 val pk = entry.passkey548 val obj = buildJsonObject {549 putIfPresent("rpId", pk?.rpId ?: entry.rpId)550 putIfPresent("rpName", pk?.rpName ?: entry.rpName)551 putIfPresent("credentialId", pk?.credentialId ?: entry.credentialId)552 putIfPresent("publicKeyCose", pk?.publicKeyCose ?: entry.publicKeyCose)553 putJsonObjectStringIfPresent("privateKeyJwk", pk?.privateKeyJwk ?: entry.privateKeyJwk)554 putIfPresent("userHandle", pk?.userHandle ?: entry.userHandle)555 putIfPresent("userName", pk?.userName ?: entry.userName)556 putIfPresent("userDisplayName", pk?.userDisplayName ?: entry.userDisplayName)557 putIfPresent("algorithm", (pk?.algorithm ?: entry.algorithm))558 putIfPresent("signCount", (pk?.signCount ?: entry.signCount))559 putIfPresent("aaguid", pk?.aaguid ?: entry.aaguid)560 putIfPresent("backupEligible", pk?.backupEligible ?: entry.backupEligible)561 putIfPresent("backupState", pk?.backupState ?: entry.backupState)562 putIfPresent("lastUsedAt", entry.lastUsedAt)563 564 putIfPresent("password", entry.password)565 putIfPresent("url", entry.url)566 putIfPresent("username", entry.username)567 putIfPresent("email", entry.email)568 putIfPresent("totp", entry.totp)569 putIfPresent("notes", entry.notes)570 if (!entry.tags.isNullOrEmpty()) {571 put("tags", buildJsonArray { entry.tags.forEach { add(JsonPrimitive(it)) } })572 }573 }574 return json.encodeToString(JsonObject.serializer(), obj).toByteArray(Charsets.UTF_8)575 }576577 private fun decodePasskeyBlob(578 id: String, title: String, createdAt: Long, modifiedAt: Long, deletedAt: Long?, blob: ByteArray,579 ): VaultEntry {580 val obj = json.parseToJsonElement(String(blob, Charsets.UTF_8)).jsonObject581 fun str(k: String) = obj[k]?.jsonPrimitive?.contentOrNull582 fun intf(k: String) = obj[k]?.jsonPrimitive?.intOrNull583 fun longf(k: String) = obj[k]?.jsonPrimitive?.longOrNull584 fun boolf(k: String) = obj[k]?.jsonPrimitive?.booleanOrNull585 fun objectJson(k: String): String? {586 val value = obj[k] ?: return null587 val nested = runCatching { value.jsonObject }.getOrNull() ?: return null588 return json.encodeToString(JsonObject.serializer(), nested)589 }590 val tags = obj["tags"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull }591 val credentialId = str("credentialId")592 val publicKeyCose = str("publicKeyCose")593 val privateKeyJwk = objectJson("privateKeyJwk")594 val rpId = str("rpId")595 val userHandle = str("userHandle")596 val userName = str("userName")597 val passkey = if (credentialId != null && publicKeyCose != null && rpId != null &&598 userHandle != null && userName != null) {599 PasskeyData(600 credentialId = credentialId, publicKeyCose = publicKeyCose, rpId = rpId,601 privateKeyJwk = privateKeyJwk,602 rpName = str("rpName"), userHandle = userHandle, userName = userName,603 userDisplayName = str("userDisplayName"), algorithm = intf("algorithm") ?: -7,604 signCount = intf("signCount") ?: 0, aaguid = str("aaguid"),605 backupEligible = boolf("backupEligible") ?: false, backupState = boolf("backupState") ?: false,606 )607 } else null608 return VaultEntry(609 id = id, type = "passkey", title = title,610 url = str("url"), username = str("username"), email = str("email"),611 password = str("password"), totp = str("totp"), notes = str("notes"), tags = tags,612 createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,613 passkey = passkey, lastUsedAt = longf("lastUsedAt"),614 rpId = rpId, rpName = str("rpName"), credentialId = credentialId,615 publicKeyCose = publicKeyCose, privateKeyJwk = privateKeyJwk,616 userHandle = userHandle, userName = userName,617 userDisplayName = str("userDisplayName"), algorithm = intf("algorithm"),618 aaguid = str("aaguid"), backupEligible = boolf("backupEligible"),619 backupState = boolf("backupState"), signCount = intf("signCount"),620 )621 }622623 624625 fun uuidToBytes(uuid: String): ByteArray {626 val hex = uuid.replace("-", "")627 if (hex.length != 32) throw PeachScriptException("UUID_FORMAT")628 return ByteArray(16) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }629 }630631 fun bytesToUuid(bytes: ByteArray): String {632 if (bytes.size != 16) throw PeachScriptException("UUID_LENGTH")633 val hex = bytes.joinToString("") { "%02x".format(it) }634 return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-" +635 "${hex.substring(16, 20)}-${hex.substring(20)}"636 }637638 639640 private fun deriveKey(passphrase: String, salt: ByteArray): ByteArray {641 642 643 val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)644 .withSalt(salt)645 .withParallelism(ARGON2_PARALLELISM)646 .withMemoryAsKB(ARGON2_MEMORY_KIB)647 .withIterations(ARGON2_ITERATIONS)648 .build()649 val gen = Argon2BytesGenerator().apply { init(params) }650 val out = ByteArray(32)651 gen.generateBytes(passphrase.toByteArray(Charsets.UTF_8), out)652 return out653 }654655 private fun aesGcmEncrypt(key: ByteArray, nonce: ByteArray, plaintext: ByteArray): ByteArray {656 val cipher = Cipher.getInstance("AES/GCM/NoPadding")657 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))658 return cipher.doFinal(plaintext) 659 }660661 private fun aesGcmDecrypt(key: ByteArray, nonce: ByteArray, ctTag: ByteArray): ByteArray {662 val cipher = Cipher.getInstance("AES/GCM/NoPadding")663 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))664 return cipher.doFinal(ctTag)665 }666667 private fun blake3Truncated(data: ByteArray): ByteArray {668 val digest = Blake3Digest(256)669 digest.update(data, 0, data.size)670 val full = ByteArray(32)671 digest.doFinal(full, 0)672 return full.copyOfRange(0, CHECKSUM_LEN)673 }674675 private fun padToNearestBucket(data: ByteArray): ByteArray {676 val bucket = SIZE_BUCKETS.firstOrNull { it >= data.size }677 var target = bucket ?: run {678 var t = 262144679 while (t < data.size) t *= 2680 t681 }682 if (target == data.size) return data683 return data.copyOf(target)684 }685686 private fun constantTimeEqual(a: ByteArray, b: ByteArray): Boolean {687 if (a.size != b.size) return false688 var diff = 0689 for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt())690 return diff == 0691 }692693 private fun concat(parts: List<ByteArray>): ByteArray {694 val total = parts.sumOf { it.size }695 val out = ByteArray(total)696 var c = 0697 for (p in parts) { System.arraycopy(p, 0, out, c, p.size); c += p.size }698 return out699 }700701 702703 private fun writeU32LE(buf: ByteArray, off: Int, v: Long) {704 buf[off] = (v and 0xff).toByte()705 buf[off + 1] = ((v ushr 8) and 0xff).toByte()706 buf[off + 2] = ((v ushr 16) and 0xff).toByte()707 buf[off + 3] = ((v ushr 24) and 0xff).toByte()708 }709710 private fun writeU32BE(buf: ByteArray, off: Int, v: Long) {711 buf[off] = ((v ushr 24) and 0xff).toByte()712 buf[off + 1] = ((v ushr 16) and 0xff).toByte()713 buf[off + 2] = ((v ushr 8) and 0xff).toByte()714 buf[off + 3] = (v and 0xff).toByte()715 }716717 private fun readU32LE(buf: ByteArray, off: Int): Long {718 return (buf[off].toLong() and 0xff) or719 ((buf[off + 1].toLong() and 0xff) shl 8) or720 ((buf[off + 2].toLong() and 0xff) shl 16) or721 ((buf[off + 3].toLong() and 0xff) shl 24)722 }723724 private fun readU32BE(buf: ByteArray, off: Int): Long {725 return ((buf[off].toLong() and 0xff) shl 24) or726 ((buf[off + 1].toLong() and 0xff) shl 16) or727 ((buf[off + 2].toLong() and 0xff) shl 8) or728 (buf[off + 3].toLong() and 0xff)729 }730731 private class BinaryWriter {732 private val out = ByteArrayOutputStream()733 fun u8(v: Int) { out.write(v and 0xff) }734 fun u16(v: Int) { out.write(v and 0xff); out.write((v ushr 8) and 0xff) }735 fun u32(v: Long) {736 out.write((v and 0xff).toInt()); out.write(((v ushr 8) and 0xff).toInt())737 out.write(((v ushr 16) and 0xff).toInt()); out.write(((v ushr 24) and 0xff).toInt())738 }739 fun bytes(b: ByteArray) { out.write(b) }740 fun lpString(value: String) {741 val enc = value.toByteArray(Charsets.UTF_8)742 if (enc.size > 0xff) throw PeachScriptException("STRING_TOO_LONG_U8")743 u8(enc.size); bytes(enc)744 }745 fun toBytes(): ByteArray = out.toByteArray()746 }747748 private class BinaryReader(private val data: ByteArray, start: Int) {749 private var cursor = start750 fun u8(): Int = data[cursor++].toInt() and 0xff751 fun u16(): Int { val v = (data[cursor].toInt() and 0xff) or ((data[cursor + 1].toInt() and 0xff) shl 8); cursor += 2; return v }752 fun u32(): Long {753 val v = (data[cursor].toLong() and 0xff) or ((data[cursor + 1].toLong() and 0xff) shl 8) or754 ((data[cursor + 2].toLong() and 0xff) shl 16) or ((data[cursor + 3].toLong() and 0xff) shl 24)755 cursor += 4; return v756 }757 fun read(n: Int): ByteArray { val out = data.copyOfRange(cursor, cursor + n); cursor += n; return out }758 fun lpString(): String = String(read(u8()), Charsets.UTF_8)759 }760761 private fun JsonObjectBuilder.putIfPresent(key: String, value: String?) {762 if (!value.isNullOrEmpty()) put(key, JsonPrimitive(value))763 }764 private fun JsonObjectBuilder.putJsonObjectStringIfPresent(key: String, value: String?) {765 if (value.isNullOrBlank()) return766 val obj = runCatching { json.parseToJsonElement(value).jsonObject }.getOrNull() ?: return767 put(key, obj)768 }769 private fun JsonObjectBuilder.putIfPresent(key: String, value: Int?) {770 if (value != null) put(key, JsonPrimitive(value))771 }772 private fun JsonObjectBuilder.putIfPresent(key: String, value: Long?) {773 if (value != null) put(key, JsonPrimitive(value))774 }775 private fun JsonObjectBuilder.putIfPresent(key: String, value: Boolean?) {776 if (value != null) put(key, JsonPrimitive(value))777 }778}