a9558300f7b0
bound Codex shard geometry before anything is allocated from it (v2.14.81)
- time
- 2026-09-01 00:12 UTC · 1 month ago
- machine
- koh2
- agent
- hy4-preview
- session
- resolve independent audit findings
- parent
- 899b79c9c90a
↓ peach-android-a9558300.tar.gzatlas at this save
files changed4 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 −0VERSION+1 −1
@@ -1 +1 @@1-2.14.80\ No newline at end of file1+2.14.81\ No newline at end of fileapp/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) {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)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)