90a8a195ca0e
feat: emit Vault V4 while retaining legacy reads (v2.15.0)
- time
- 2026-09-05 12:38 UTC · 1 month ago
- machine
- koh2
- agent
- gpt-6
- session
- Vault V4 password and passphrase support
- parent
- 6695a8409f18
↓ peach-android-90a8a195.tar.gzatlas at this save
files changed3 files
VERSION+1 −1app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+24 −15app/src/test/java/com/peachpasswords/vault/PeachScriptTest.kt+9 −0app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+24 −15
@@ -52,6 +52,7 @@5252 5353 private const val FORMAT_V2 = 0x025454 private const val FORMAT_V3 = 0x0355+ private const val FORMAT_V4 = 0x045556 5657 private const val ARGON2_MEMORY_KIB = 655365758 private const val ARGON2_ITERATIONS = 3@@ -134,7 +135,7 @@134135 val inner = concat(listOf(body, checksum))135136 136137 val header5 = ByteArray(5)137- header5[0] = FORMAT_V3.toByte()138+ header5[0] = FORMAT_V4.toByte()138139 writeU32LE(header5, 1, inner.size.toLong())139140 val wrapped = padToNearestBucket(concat(listOf(header5, inner)))140141 @@ -173,11 +174,12 @@173174 key.fill(0)174175 }175176 176- // Detect padded v2/v3 format: leading FORMAT byte + 4-byte LE inner length.177+ // Detect padded v2-v4 format: leading FORMAT byte + 4-byte LE inner length.177178 // M-02: keep innerLen as Long; the prior `5 + innerLen` could overflow Int178179 // when the wire value set the high bit, bypassing the size check.179180 val inner: ByteArray180- if (decrypted.size >= 5 && (decrypted[0].toInt() == FORMAT_V2 || decrypted[0].toInt() == FORMAT_V3)) {181+ val format = decrypted.firstOrNull()?.toInt()?.and(0xff) ?: 1182+ if (decrypted.size >= 5 && format in FORMAT_V2..FORMAT_V4) {181183 val innerLen = readU32LE(decrypted, 1)182184 if (innerLen > MAX_INNER_BYTES) throw PeachScriptException("INNER_TOO_LARGE")183185 if (5L + innerLen > decrypted.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")@@ -218,7 +220,7 @@218220 val data = Zstd.decompress(compressed, Math.toIntExact(length))219221 if (data.size.toLong() != length) throw PeachScriptException("SECTION_LENGTH_MISMATCH")220222 when (type) {221- SECTION_ENTRIES -> entries = decodeEntriesSection(data)223+ SECTION_ENTRIES -> entries = decodeEntriesSection(data, format)222224 SECTION_SETTINGS -> settings = decodeSettingsSection(data) ?: VaultSettings()223225 SECTION_LICENSE -> {224226 val license = decodeLicenseSection(data)@@ -315,14 +317,14 @@315317 return out.toBytes()316318 }317319 318- private fun decodeEntriesSection(data: ByteArray): List<VaultEntry> {320+ private fun decodeEntriesSection(data: ByteArray, format: Int): List<VaultEntry> {319321 val r = BinaryReader(data, 0)320322 // M-02: cap entry count before pre-allocating the list (was unbounded).321323 val count = r.u32()322324 if (count > MAX_ENTRY_COUNT) throw PeachScriptException("ENTRY_COUNT_OVERFLOW")323325 val strings = readStringTable(r)324326 val out = ArrayList<VaultEntry>(Math.toIntExact(count))325- for (i in 0 until count) out.add(decodeEntry(r, strings))327+ for (i in 0 until count) out.add(decodeEntry(r, strings, format))326328 return out327329 }328330 @@ -380,7 +382,7 @@380382 }381383 382384 w.u8(flags)383- w.u8(flags2) // FORMAT_V3 always writes flags2385+ w.u8(flags2) // V3+ always writes flags2384386 w.u32(createdSec)385387 w.u32(updatedDelta)386388 w.u32(deletedSec)@@ -390,7 +392,7 @@390392 ENTRY_TYPE_LOGIN -> {391393 w.u16(table.intern(entry.url))392394 w.u16(table.intern(entry.username))393- w.lpString(entry.password ?: "")395+ w.lpString(entry.password ?: "", 2)394396 if (flags2 and F2_HAS_EMAIL != 0) w.lpString(entry.email!!)395397 if (flags and F_HAS_TOTP != 0) w.lpString(entry.totp!!)396398 if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)@@ -434,11 +436,11 @@434436 return w.toBytes()435437 }436438 437- private fun decodeEntry(r: BinaryReader, strings: List<String?>): VaultEntry {439+ private fun decodeEntry(r: BinaryReader, strings: List<String?>, format: Int): VaultEntry {438440 val type = r.u8()439441 val id = bytesToUuid(r.read(16))440442 val flags = r.u8()441- val flags2 = r.u8() // FORMAT_V3443+ val flags2 = if (format >= FORMAT_V3) r.u8() else 0442444 val createdSec = r.u32()443445 val updatedDelta = r.u32()444446 val deletedSec = r.u32()@@ -453,7 +455,7 @@453455 ENTRY_TYPE_LOGIN -> {454456 val urlIdx = r.u16()455457 val usernameIdx = r.u16()456- val password = r.lpString()458+ val password = r.lpString(if (format >= FORMAT_V4) 2 else 1)457459 val email = if (flags2 and F2_HAS_EMAIL != 0) r.lpString() else null458460 val totp = if (flags and F_HAS_TOTP != 0) r.lpString() else null459461 val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null@@ -767,10 +769,16 @@767769 out.write(((v ushr 16) and 0xff).toInt()); out.write(((v ushr 24) and 0xff).toInt())768770 }769771 fun bytes(b: ByteArray) { out.write(b) }770- fun lpString(value: String) {772+ fun lpString(value: String, lengthBytes: Int = 1) {771773 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+ if (lengthBytes == 1) {775+ if (enc.size > 0xff) throw PeachScriptException("STRING_TOO_LONG_U8")776+ u8(enc.size)777+ } else {778+ if (enc.size > 0xffff) throw PeachScriptException("STRING_TOO_LONG_U16")779+ u16(enc.size)780+ }781+ bytes(enc)774782 }775783 fun toBytes(): ByteArray = out.toByteArray()776784 }@@ -785,7 +793,8 @@785793 cursor += 4; return v786794 }787795 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)796+ fun lpString(lengthBytes: Int = 1): String =797+ String(read(if (lengthBytes == 1) u8() else u16()), Charsets.UTF_8)789798 }790799 791800 private fun JsonObjectBuilder.putIfPresent(key: String, value: String?) {app/src/test/java/com/peachpasswords/vault/PeachScriptTest.kt+9 −0
@@ -109,6 +109,15 @@109109 assertEquals("123", c.cvc)110110 }111111 112+ @Test fun v4_round_trips_256_character_ascii_and_multibyte_passwords() {113+ for (password in listOf("a".repeat(256), "界".repeat(256))) {114+ val encoded = PeachPayloadCodec.buildPayload(listOf(login().copy(password = password)), "pw-123")115+ val decoded = PeachPayloadCodec.parsePayload(encoded, "pw-123").single()116+117+ assertEquals(password, decoded.password)118+ }119+ }120+112121 // ── Cross-direction: Android export parsed by the browser's algorithm ───113122 // (Verified manually via /tmp; here we at least prove a browser-shaped114123 // envelope layout: salt(16)+nonce(12)+ct+tag, padded to a size bucket.)