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
3 files VERSION+1 −1app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+24 −15app/src/test/java/com/peachpasswords/vault/PeachScriptTest.kt+9 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.991+2.15.0

view file

app/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?) {

view file

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.)

view file