a9558300f7b0

bound Codex shard geometry before anything is allocated from it (v2.14.81)

PAI-2026-002: a v3 envelope's per-chunk hash covers only the decoded data bytes, so t/S/s/i were unauthenticated integers that reconstructCompressed fed straight into Array(total) and BooleanArray(total). A barcode with a correct hash over a tiny payload and t = Int.MAX_VALUE therefore cost a multi-gigabyte allocation and killed the process before the payload was ever authenticated. CodexCodec.isValidEnvelope now gates every scanned code on the encoder's own bounds: v, verify-code shape, stripe count <= the ceiling implied by the 16 MiB inflate cap, stripe and shard indices inside their declared ranges, the GF(2^8) 255-shard ceiling, 200 data shards, and exactly CHUNK_SIZE decoded bytes. Invalid geometry is skipped like any unreadable code, so a damaged code still cannot abort an otherwise good restore. Reconstruction additionally rejects mixed verify codes, mixed stripe counts, and mixed shard totals within one stripe instead of silently interleaving two Codexes. Verification: six new hostile-envelope cases, including one that proves real codes still restore alongside rejected ones; full JVM suite 1028 tests / 0 failures / 0 errors / 2 skipped, up from 1022; lintDebug 0 errors and 90 warnings, unchanged; assembleDebug and debug androidTest sources compile.

time
2026-09-01 00:12 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
resolve independent audit findings
parent
899b79c9c90a
4 files VERSION+1 −1app/src/main/java/com/peachpasswords/codex/CodexCodec.kt+52 −2app/src/main/java/com/peachpasswords/codex/CodexTypes.kt+5 −1app/src/test/java/com/peachpasswords/codex/CodexCodecTest.kt+89 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.80\ No newline at end of file1+2.14.81\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/codex/CodexCodec.kt+52 −2
@@ -117,6 +117,43 @@117117         d = base64Url(chunk), h = sha256Hex(chunk).take(32),118118     )119119 120+    /** Every chunk carries exactly CHUNK_SIZE bytes, so its encoding has one length. */121+    private val ENCODED_CHUNK_CHARS = base64Url(ByteArray(CHUNK_SIZE)).length122+123+    private val VERIFY_CODE = Regex("PX-[0-9A-Z]{4}-[0-9A-Z]{4}")124+125+    /**126+     * Stripe ceiling implied by the inflate cap: the largest legal payload127+     * divided by the largest legal stripe, rounded up. The extra stripe covers128+     * deflate's own framing overhead, which can push the compressed payload a129+     * fraction above the uncompressed cap.130+     */131+    private val MAX_STRIPES: Int =132+        ((MAX_INFLATED_BYTES + MAX_DATA_SHARDS_PER_STRIPE * CHUNK_SIZE - 1) /133+            (MAX_DATA_SHARDS_PER_STRIPE * CHUNK_SIZE)).toInt() + 1134+135+    /**136+     * Reject a scanned envelope whose declared geometry no real Codex can137+     * produce, before anything is allocated from it (PAI-2026-002).138+     *139+     * The per-chunk hash covers `d` only — `t`, `S`, `s` and `i` are unauthenticated140+     * integers, and reconstruction sized `Array(total)` straight from `t`. A141+     * barcode with a valid hash over a tiny payload and t = Int.MAX_VALUE142+     * therefore cost a multi-gigabyte allocation before it could be rejected.143+     * Every bound here is the encoder's own: [buildEnvelope] emits exactly144+     * [CHUNK_SIZE] data bytes, at most [MAX_TOTAL_SHARDS_PER_STRIPE] shards per145+     * stripe, and at most [MAX_STRIPES] stripes.146+     */147+    internal fun isValidEnvelope(env: ChunkEnvelope): Boolean =148+        env.v == CODEX_VERSION &&149+            VERIFY_CODE.matches(env.c) &&150+            env.capS in 1..MAX_STRIPES &&151+            env.s in 0 until env.capS &&152+            env.t in 1..MAX_TOTAL_SHARDS_PER_STRIPE &&153+            env.i in 0 until env.t &&154+            env.d.length == ENCODED_CHUNK_CHARS &&155+            (env.h.length == 16 || env.h.length == 32)156+120157     fun verifyChunkIntegrity(env: ChunkEnvelope): Boolean {121158         if (env.h.length != 16 && env.h.length != 32) return false122159         return sha256Hex(fromBase64Url(env.d)).take(env.h.length).equals(env.h, ignoreCase = true)@@ -128,16 +165,29 @@128165      * concatenates the data regions in stripe order.129166      */130167     fun reconstructCompressed(envelopes: List<ChunkEnvelope>): ByteArray {131-        val good = envelopes.filter { verifyChunkIntegrity(it) }168+        val good = envelopes.filter { isValidEnvelope(it) && verifyChunkIntegrity(it) }132169         if (good.isEmpty()) throw CodexException(CodexErrorCode.INSUFFICIENT_CHUNKS, "no valid chunks")170+        val verifyCode = good.first().c171+        val stripeCount = good.first().capS172+        if (good.any { it.c != verifyCode || it.capS != stripeCount }) {173+            throw CodexException(CodexErrorCode.INVALID_ENVELOPE, "mixed verify codes or stripe counts")174+        }133175         val byStripe = good.groupBy { it.s }134-        val stripeCount = good.maxOf { it.capS }135176         val out = java.io.ByteArrayOutputStream()136177         for (sIdx in 0 until stripeCount) {137178             val chunks = byStripe[sIdx]138179                 ?: throw CodexException(CodexErrorCode.INSUFFICIENT_CHUNKS, "stripe $sIdx missing entirely")139180             val total = chunks.first().t181+            if (chunks.any { it.t != total }) {182+                throw CodexException(CodexErrorCode.INVALID_ENVELOPE, "mixed shard totals in stripe $sIdx")183+            }140184             val (dataShards, parityShards) = deriveShardCounts(total)185+            if (dataShards > MAX_DATA_SHARDS_PER_STRIPE) {186+                throw CodexException(187+                    CodexErrorCode.INVALID_ENVELOPE,188+                    "stripe $sIdx claims $dataShards data shards",189+                )190+            }141191             val shards = Array(total) { ByteArray(0) }142192             val present = BooleanArray(total)143193             for (env in chunks) {

view file

app/src/main/java/com/peachpasswords/codex/CodexTypes.kt+5 −1
@@ -20,6 +20,10 @@2020 const val CODEX_MAX_PARITY_SHARDS = 352121 const val CODEX_PARITY_RATIO = 0.152222 23+const val CODEX_VERSION = 324+/** GF(2⁸) addresses one stripe with a single byte, so data + parity ≤ 255. */25+const val MAX_TOTAL_SHARDS_PER_STRIPE = 25526+2327 const val AZTEC_ECC_PERCENT = 252428 const val CODES_PER_ROW = 62529 const val CODES_PER_COL = 6@@ -86,7 +90,7 @@8690     VAULT_LOCKED, VAULT_EMPTY, COMPRESSION_ROUNDTRIP_FAILED, ERASURE_VERIFICATION_FAILED,8791     AZTEC_GENERATION_FAILED, INSUFFICIENT_CHUNKS, RECONSTRUCTION_VERIFY_CODE_MISMATCH,8892     RECONSTRUCTION_FINGERPRINT_MISMATCH, PDF_GENERATION_FAILED, INVALID_VAULT_BLOB, RESTORE_FAILED,89-    PAYLOAD_TOO_LARGE,93+    PAYLOAD_TOO_LARGE, INVALID_ENVELOPE,9094 }9195 9296 class CodexException(val code: CodexErrorCode, message: String) : Exception(message)

view file

app/src/test/java/com/peachpasswords/codex/CodexCodecTest.kt+89 −0
@@ -66,6 +66,95 @@6666         assertArrayEquals(data, CodexCodec.inflate(CodexCodec.deflate(data)))6767     }6868 69+    /**70+     * PAI-2026-002: the per-chunk hash covers `d` only, so a hostile code71+     * passes verifyChunkIntegrity with a tiny payload and any `t`/`S`/`i` it72+     * likes. Every case below used to reach Array(t) before being rejected.73+     */74+    private fun hostileChunk(75+        total: Int = 4,76+        index: Int = 0,77+        stripe: Int = 0,78+        stripeCount: Int = 1,79+        data: ByteArray = ByteArray(CHUNK_SIZE),80+        verifyCode: String = "PX-AB12-CD34",81+    ): ChunkEnvelope = ChunkEnvelope(82+        v = 3, c = verifyCode, s = stripe, capS = stripeCount, i = index, t = total,83+        p = index >= total - 2,84+        d = CodexCodec.base64Url(data),85+        h = CodexCodec.sha256Hex(data).take(32),86+    )87+88+    @Test fun hostile_shard_total_is_rejected_before_allocation() {89+        val hostile = hostileChunk(total = Int.MAX_VALUE)90+        assertTrue("geometry is rejected", !CodexCodec.isValidEnvelope(hostile))91+        val error = try {92+            CodexCodec.reconstructCompressed(listOf(hostile))93+            null94+        } catch (e: CodexException) {95+            e96+        }97+        assertEquals(CodexErrorCode.INSUFFICIENT_CHUNKS, error?.code)98+    }99+100+    @Test fun out_of_range_shard_and_stripe_indices_are_rejected() {101+        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(total = 4, index = 4)))102+        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(total = 4, index = -1)))103+        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(stripe = 2, stripeCount = 2)))104+        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(stripeCount = 0)))105+        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(stripeCount = 100_000)))106+        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(total = 256)))107+    }108+109+    @Test fun short_and_oversized_chunk_data_are_rejected() {110+        val short = hostileChunk(data = ByteArray(CHUNK_SIZE - 1))111+        val oversized = hostileChunk(data = ByteArray(CHUNK_SIZE + 1))112+        assertTrue(!CodexCodec.isValidEnvelope(short))113+        assertTrue(!CodexCodec.isValidEnvelope(oversized))114+    }115+116+    @Test fun malformed_verify_code_is_rejected() {117+        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(verifyCode = "PX-AB12-CD3")))118+        assertTrue(CodexCodec.isValidEnvelope(hostileChunk()))119+    }120+121+    @Test fun mixed_verify_codes_and_shard_totals_are_rejected() {122+        val (envelopes, _) = CodexCodec.encodeToEnvelopes(123+            CodexCodec.deflate("hello codex".toByteArray()),124+            "PX-AB12-CD34",125+        )126+        val other = CodexCodec.encodeToEnvelopes(127+            CodexCodec.deflate("hello codex".toByteArray()),128+            "PX-ZZ99-YY88",129+        ).first.first()130+        var error = try {131+            CodexCodec.reconstructCompressed(envelopes + other); null132+        } catch (e: CodexException) {133+            e134+        }135+        assertEquals(CodexErrorCode.INVALID_ENVELOPE, error?.code)136+137+        val mixedTotal = envelopes.first().copy(t = envelopes.first().t + 1, i = 0)138+        error = try {139+            CodexCodec.reconstructCompressed(listOf(envelopes.first(), mixedTotal)); null140+        } catch (e: CodexException) {141+            e142+        }143+        assertEquals(CodexErrorCode.INVALID_ENVELOPE, error?.code)144+    }145+146+    @Test fun hostile_envelopes_are_skipped_and_real_codes_still_restore() {147+        val (envelopes, _) = CodexCodec.encodeToEnvelopes(148+            CodexCodec.deflate("survives hostile neighbours".toByteArray()),149+            "PX-AB12-CD34",150+        )151+        val recovered = CodexCodec.reconstructCompressed(152+            listOf(hostileChunk(total = Int.MAX_VALUE)) + envelopes +153+                listOf(hostileChunk(verifyCode = "nope")),154+        )155+        assertEquals("survives hostile neighbours", String(CodexCodec.inflate(recovered)))156+    }157+69158     @Test fun inflate_refuses_decompression_bombs() {70159         val bomb = CodexCodec.deflate(ByteArray(CodexCodec.MAX_INFLATED_BYTES.toInt() + 1))71160         assertTrue("bomb compresses tiny", bomb.size < 64 * 1024)

view file