24fe1013d9b3

M-02: PeachScript parsing — caps on envelope, sections, entries, passkey blobs; Long arithmetic with checked bounds

time
2026-07-29 14:23 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
6a51c07ba744
2 files app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+39 −15app/src/test/java/com/peachpasswords/vault/PeachScriptHardeningTest.kt+182 −0
app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+39 −15
@@ -85,6 +85,16 @@8585     private val secureRandom = SecureRandom()8686     private val json = Json { ignoreUnknownKeys = true; encodeDefaults = false }8787 88+    // M-02: hard caps on attacker-controlled lengths/counts. A crafted backup89+    // could otherwise trigger OOM via huge declared uncompressed sizes or90+    // decompression bombs. Bounds follow M-01's MAX_IMPORT_BYTES (50 MiB).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 * 102497+8898     class PeachScriptException(message: String) : Exception(message)8999 90100     data class ParsedPayload(@@ -145,6 +155,8 @@145155 146156     /** Parse the encrypted envelope back to all supported payload metadata. */147157     fun parsePayloadData(envelope: ByteArray, passphrase: String): ParsedPayload {158+        // M-02: cap before any allocation so a crafted 4 GB envelope can't OOM us.159+        if (envelope.size > MAX_ENVELOPE_BYTES) throw PeachScriptException("PAYLOAD_TOO_LARGE")148160         if (envelope.size < SALT_LEN + NONCE_LEN + TAG_LEN + HEADER_LEN + CHECKSUM_LEN) {149161             throw PeachScriptException("PAYLOAD_TOO_SHORT")150162         }@@ -162,11 +174,14 @@162174         }163175 164176         // Detect padded v2/v3 format: leading FORMAT byte + 4-byte LE inner length.177+        // M-02: keep innerLen as Long; the prior `5 + innerLen` could overflow Int178+        // when the wire value set the high bit, bypassing the size check.165179         val inner: ByteArray166180         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)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))170185         } else {171186             inner = decrypted // v1 — no length prefix / no padding172187         }@@ -189,14 +204,19 @@189204         for (i in 0 until sectionCount) {190205             if (cursor + 9 > body.size) throw PeachScriptException("PAYLOAD_TOO_SHORT")191206             val type = body[cursor].toInt() and 0xff192-            val length = readU32LE(body, cursor + 1).toInt()193-            val clength = readU32LE(body, cursor + 5).toInt()207+            // M-02: keep raw/compressed sizes as Long and cap before Zstd allocates208+            // the declared uncompressed length (decompression-bomb guard).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+            }194214             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")215+            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")200220             when (type) {201221                 SECTION_ENTRIES -> entries = decodeEntriesSection(data)202222                 SECTION_SETTINGS -> settings = decodeSettingsSection(data) ?: VaultSettings()@@ -209,7 +229,7 @@209229                 // does not expose browser manual-summon or declined-card stores.210230                 else -> {}211231             }212-            cursor = dataEnd232+            cursor = Math.toIntExact(dataEndLong)213233         }214234         return ParsedPayload(215235             entries = entries,@@ -297,9 +317,11 @@297317 298318     private fun decodeEntriesSection(data: ByteArray): List<VaultEntry> {299319         val r = BinaryReader(data, 0)300-        val count = r.u32().toInt()320+        // M-02: cap entry count before pre-allocating the list (was unbounded).321+        val count = r.u32()322+        if (count > MAX_ENTRY_COUNT) throw PeachScriptException("ENTRY_COUNT_OVERFLOW")301323         val strings = readStringTable(r)302-        val out = ArrayList<VaultEntry>(count)324+        val out = ArrayList<VaultEntry>(Math.toIntExact(count))303325         for (i in 0 until count) out.add(decodeEntry(r, strings))304326         return out305327     }@@ -483,8 +505,10 @@483505                 )484506             }485507             ENTRY_TYPE_PASSKEY -> {486-                val blobLen = r.u32().toInt()487-                val blob = r.read(blobLen)508+                // M-02: cap passkey blob before allocation (was unbounded).509+                val blobLen = r.u32()510+                if (blobLen > MAX_PASSKEY_BLOB_BYTES) throw PeachScriptException("PASSKEY_BLOB_TOO_LARGE")511+                val blob = r.read(Math.toIntExact(blobLen))488512                 decodePasskeyBlob(id, title, createdAt, modifiedAt, deletedAt, blob)489513             }490514             else -> throw PeachScriptException("UNKNOWN_ENTRY_TYPE:$type")

view file

app/src/test/java/com/peachpasswords/vault/PeachScriptHardeningTest.kt+182 −0
@@ -0,0 +1,182 @@1+package com.peachpasswords.vault2+3+import com.github.luben.zstd.Zstd4+import org.bouncycastle.crypto.digests.Blake3Digest5+import org.bouncycastle.crypto.generators.Argon2BytesGenerator6+import org.bouncycastle.crypto.params.Argon2Parameters7+import org.junit.Assert.assertEquals8+import org.junit.Assert.assertThrows9+import org.junit.Assert.assertTrue10+import org.junit.Test11+import javax.crypto.Cipher12+import javax.crypto.spec.GCMParameterSpec13+import javax.crypto.spec.SecretKeySpec14+15+/**16+ * M-02 regression tests: PeachScript parser must reject attacker-controlled17+ * lengths/counts before any unbounded allocation. Each test crafts a payload18+ * with a malicious header field and asserts that [PeachPayloadCodec.parsePayload]19+ * refuses it with the expected error code.20+ */21+class PeachScriptHardeningTest {22+23+    private val passphrase = "test-passphrase"24+25+    @Test fun oversized_envelope_rejected() {26+        // First guard fires before any allocation, so this just needs to be27+        // above MAX_ENVELOPE_BYTES.28+        val env = ByteArray(50 * 1024 * 1024 + 1)29+        val ex = assertThrows(PeachPayloadCodec.PeachScriptException::class.java) {30+            PeachPayloadCodec.parsePayload(env, passphrase)31+        }32+        assertEquals("PAYLOAD_TOO_LARGE", ex.message)33+    }34+35+    @Test fun declared_length_exceeds_actual_remaining_rejected() {36+        // v3 plaintext whose declared innerLen (1 MiB) is well under the cap37+        // but vastly larger than the actual plaintext — must trip the38+        // remaining-bytes check without overflowing Int arithmetic.39+        val plaintext = ByteArray(64).apply {40+            this[0] = 0x03 // FORMAT_V341+            writeU32LE(this, 1, 1024L * 1024) // declared innerLen42+        }43+        val env = encryptPlaintext(plaintext, passphrase)44+        val ex = assertThrows(PeachPayloadCodec.PeachScriptException::class.java) {45+            PeachPayloadCodec.parsePayload(env, passphrase)46+        }47+        assertTrue(48+            "expected PAYLOAD_TOO_SHORT or INNER_TOO_LARGE, got ${ex.message}",49+            ex.message == "PAYLOAD_TOO_SHORT" || ex.message == "INNER_TOO_LARGE",50+        )51+    }52+53+    @Test fun decompression_bomb_rejected() {54+        // Section header declares a 4 GiB uncompressed length but the compressed55+        // payload is tiny — must be rejected before Zstd allocates the bomb size.56+        val body = ByteArray(8 + 9 + 16).apply {57+            writeU32BE(this, 0, 0x50434853L) // MAGIC58+            this[4] = 0x01 // VERSION59+            this[5] = 0x01 // sectionCount60+            this[8] = 0x01 // SECTION_ENTRIES61+            writeU32LE(this, 9, 0xFFFFFFFFL) // rawLen = 4 GiB (bomb)62+            writeU32LE(this, 13, 16L) // compLen = 1663+        }64+        val env = encryptPlaintext(v3Plaintext(body), passphrase)65+        val ex = assertThrows(PeachPayloadCodec.PeachScriptException::class.java) {66+            PeachPayloadCodec.parsePayload(env, passphrase)67+        }68+        assertEquals("SECTION_TOO_LARGE", ex.message)69+    }70+71+    @Test fun entry_count_overflow_rejected() {72+        // Entries section declares a 4 GiB count — must be rejected before the73+        // ArrayList pre-allocation runs.74+        val entriesRaw = ByteArray(4).apply { writeU32LE(this, 0, 0xFFFFFFFFL) }75+        val compressed = Zstd.compress(entriesRaw, 19)76+        val body = ByteArray(8 + 9 + compressed.size).apply {77+            writeU32BE(this, 0, 0x50434853L)78+            this[4] = 0x0179+            this[5] = 0x0180+            this[8] = 0x01 // SECTION_ENTRIES81+            writeU32LE(this, 9, entriesRaw.size.toLong())82+            writeU32LE(this, 13, compressed.size.toLong())83+            System.arraycopy(compressed, 0, this, 17, compressed.size)84+        }85+        val env = encryptPlaintext(v3Plaintext(body), passphrase)86+        val ex = assertThrows(PeachPayloadCodec.PeachScriptException::class.java) {87+            PeachPayloadCodec.parsePayload(env, passphrase)88+        }89+        assertEquals("ENTRY_COUNT_OVERFLOW", ex.message)90+    }91+92+    @Test fun oversize_passkey_blob_rejected() {93+        // One passkey entry whose blobLen is 4 GiB — must be rejected before94+        // r.read() allocates the declared size.95+        // Entry header layout: type(1) | id(16) | flags(1) | flags2(1) |96+        // createdSec(4) | updatedDelta(4) | deletedSec(4) | titleIdx(2) | blobLen(4)97+        val entry = ByteArray(33 + 4).apply {98+            this[0] = 0x04 // ENTRY_TYPE_PASSKEY99+            writeU32LE(this, 33, 0xFFFFFFFFL) // blobLen = 4 GiB100+        }101+        val entriesRaw = ByteArray(6 + entry.size).apply {102+            writeU32LE(this, 0, 1L) // count = 1103+            writeU16LE(this, 4, 0) // empty string table104+            System.arraycopy(entry, 0, this, 6, entry.size)105+        }106+        val compressed = Zstd.compress(entriesRaw, 19)107+        val body = ByteArray(8 + 9 + compressed.size).apply {108+            writeU32BE(this, 0, 0x50434853L)109+            this[4] = 0x01110+            this[5] = 0x01111+            this[8] = 0x01112+            writeU32LE(this, 9, entriesRaw.size.toLong())113+            writeU32LE(this, 13, compressed.size.toLong())114+            System.arraycopy(compressed, 0, this, 17, compressed.size)115+        }116+        val env = encryptPlaintext(v3Plaintext(body), passphrase)117+        val ex = assertThrows(PeachPayloadCodec.PeachScriptException::class.java) {118+            PeachPayloadCodec.parsePayload(env, passphrase)119+        }120+        assertEquals("PASSKEY_BLOB_TOO_LARGE", ex.message)121+    }122+123+    // ---- crypto / framing helpers (mirror production wire format) ---------124+125+    private fun v3Plaintext(body: ByteArray): ByteArray {126+        val checksum = blake3Truncated(body)127+        val inner = body + checksum128+        val out = ByteArray(5 + inner.size)129+        out[0] = 0x03 // FORMAT_V3130+        writeU32LE(out, 1, inner.size.toLong())131+        System.arraycopy(inner, 0, out, 5, inner.size)132+        return out133+    }134+135+    private fun encryptPlaintext(plaintext: ByteArray, passphrase: String): ByteArray {136+        val salt = ByteArray(16).also { java.security.SecureRandom().nextBytes(it) }137+        val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }138+        val key = deriveKey(passphrase, salt)139+        val ctTag = try {140+            val cipher = Cipher.getInstance("AES/GCM/NoPadding")141+            cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))142+            cipher.doFinal(plaintext)143+        } finally { key.fill(0) }144+        return salt + nonce + ctTag145+    }146+147+    private fun deriveKey(passphrase: String, salt: ByteArray): ByteArray {148+        val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)149+            .withSalt(salt).withParallelism(4).withMemoryAsKB(65536).withIterations(3).build()150+        val gen = Argon2BytesGenerator().apply { init(params) }151+        val out = ByteArray(32)152+        gen.generateBytes(passphrase.toByteArray(Charsets.UTF_8), out)153+        return out154+    }155+156+    private fun blake3Truncated(data: ByteArray): ByteArray {157+        val digest = Blake3Digest(256)158+        digest.update(data, 0, data.size)159+        val full = ByteArray(32)160+        digest.doFinal(full, 0)161+        return full.copyOfRange(0, 4)162+    }163+164+    private fun writeU32LE(buf: ByteArray, off: Int, v: Long) {165+        buf[off] = (v and 0xff).toByte()166+        buf[off + 1] = ((v ushr 8) and 0xff).toByte()167+        buf[off + 2] = ((v ushr 16) and 0xff).toByte()168+        buf[off + 3] = ((v ushr 24) and 0xff).toByte()169+    }170+171+    private fun writeU32BE(buf: ByteArray, off: Int, v: Long) {172+        buf[off] = ((v ushr 24) and 0xff).toByte()173+        buf[off + 1] = ((v ushr 16) and 0xff).toByte()174+        buf[off + 2] = ((v ushr 8) and 0xff).toByte()175+        buf[off + 3] = (v and 0xff).toByte()176+    }177+178+    private fun writeU16LE(buf: ByteArray, off: Int, v: Int) {179+        buf[off] = (v and 0xff).toByte()180+        buf[off + 1] = ((v ushr 8) and 0xff).toByte()181+    }182+}\ No newline at end of file

view file