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
↓ peach-android-24fe1013.tar.gzatlas at this save
files changed2 files
app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+39 −15app/src/test/java/com/peachpasswords/vault/PeachScriptHardeningTest.kt+182 −0app/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")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