21276fade773

keep Codex chunk size a ceiling so pre-350 paper still restores, and loosen the sync id cap (v2.14.84)

The v2.14.81 geometry bounds pinned the chunk data length to the current generator's exact CHUNK_SIZE. That was stricter than the browser and broke a real case: the browser derives the shard size from the first scanned chunk specifically so Codexes printed before the chunk size changed (the previous value was 800 bytes) still restore, and it enforces no chunk length of its own (src/popup/codex/restore.ts). Android's Reed-Solomon decoder derives shard size the same way, so those Codexes restored here too. A printed Codex is often the only copy of a vault, so rejecting it is worse than the hostile-envelope case the check was added for. Chunk size is now an upper bound (4096 bytes — above a full-range Aztec code's alphanumeric capacity and above the historical 800), and the whole reconstruction is budgeted against the inflate cap instead, which stops the many-valid-stripes variant of the same attack. The data-shard ceiling also moves into deriveShardCounts, mirroring the browser's PCH-01-010, so the bound holds wherever it is called rather than only at the one call site. MAX_ID_LENGTH rises from 128 to 512. Every import path on both platforms preserves the id a restored backup already carries (the browser's sanitizeRestoredEntry; Android's draft.id.ifBlank { UUID }) and neither bounds it, so a cap set tight to a UUID would turn one such restored entry into a permanent, hard-to-diagnose sync failure. Count and frame byte caps already bound total bytes however long a single key is. Verification: a legacy 800-byte-chunk Codex now restores as a regression guard, plus an over-ceiling chunk still rejected and a multi-stripe reconstruction past the inflate cap refused; full JVM suite 1040 tests / 0 failures / 0 errors / 2 skipped, up from 1038; lintDebug 0 errors and 90 warnings, unchanged; assembleDebug, debug androidTest sources, and signed assembleRelease all green.

time
2026-09-01 01:11 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
resolve independent audit findings
parent
8d576bb50c88
5 files VERSION+1 −1app/src/main/java/com/peachpasswords/codex/CodexCodec.kt+33 −13app/src/main/java/com/peachpasswords/codex/CodexTypes.kt+6 −1app/src/main/java/com/peachpasswords/sync/SyncLimits.kt+8 −1app/src/test/java/com/peachpasswords/codex/CodexCodecTest.kt+60 −5
VERSION+1 −1
@@ -1 +1 @@1-2.14.83\ No newline at end of file1+2.14.84\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/codex/CodexCodec.kt+33 −13
@@ -117,9 +117,24 @@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)).length120+    /**121+     * Upper bound on one chunk's decoded bytes, not the generator's exact122+     * [CHUNK_SIZE]: the browser's decoder derives the shard size from the123+     * first scanned chunk precisely so Codexes printed before the current124+     * chunk size (800 bytes) still restore, and it enforces no length of its125+     * own. The bound sits above a full-range Aztec code's alphanumeric126+     * capacity, so it rejects only what no barcode could have carried.127+     */128+    private const val MAX_CHUNK_BYTES = 4096129+    private val MAX_ENCODED_CHUNK_CHARS = base64Url(ByteArray(MAX_CHUNK_BYTES)).length122130 131+    /**132+     * Budget for the whole reconstructed payload. [inflate] refuses anything133+     * over [MAX_INFLATED_BYTES], so a payload larger than this can never134+     * succeed; the headroom covers deflate framing on a payload at the cap.135+     */136+    private val MAX_RECONSTRUCTED_BYTES = MAX_INFLATED_BYTES + 1024 * 1024137+123138     private val VERIFY_CODE = Regex("PX-[0-9A-Z]{4}-[0-9A-Z]{4}")124139 125140     /**@@ -140,9 +155,11 @@140155      * integers, and reconstruction sized `Array(total)` straight from `t`. A141156      * barcode with a valid hash over a tiny payload and t = Int.MAX_VALUE142157      * 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.158+     * Every bound here is the encoder's own, except the chunk size, which is a159+     * ceiling rather than an exact match so older paper still restores (see160+     * [MAX_CHUNK_BYTES]). [buildEnvelope] emits at most161+     * [MAX_TOTAL_SHARDS_PER_STRIPE] shards per stripe and at most162+     * [MAX_STRIPES] stripes.146163      */147164     internal fun isValidEnvelope(env: ChunkEnvelope): Boolean =148165         env.v == CODEX_VERSION &&@@ -151,7 +168,7 @@151168             env.s in 0 until env.capS &&152169             env.t in 1..MAX_TOTAL_SHARDS_PER_STRIPE &&153170             env.i in 0 until env.t &&154-            env.d.length == ENCODED_CHUNK_CHARS &&171+            env.d.length in 1..MAX_ENCODED_CHUNK_CHARS &&155172             (env.h.length == 16 || env.h.length == 32)156173 157174     fun verifyChunkIntegrity(env: ChunkEnvelope): Boolean {@@ -173,6 +190,7 @@173190             throw CodexException(CodexErrorCode.INVALID_ENVELOPE, "mixed verify codes or stripe counts")174191         }175192         val byStripe = good.groupBy { it.s }193+        var reconstructedBytes = 0L176194         val out = java.io.ByteArrayOutputStream()177195         for (sIdx in 0 until stripeCount) {178196             val chunks = byStripe[sIdx]@@ -182,17 +200,19 @@182200                 throw CodexException(CodexErrorCode.INVALID_ENVELOPE, "mixed shard totals in stripe $sIdx")183201             }184202             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-            }191203             val shards = Array(total) { ByteArray(0) }192204             val present = BooleanArray(total)193205             for (env in chunks) {194206                 if (env.i in 0 until total) {195-                    shards[env.i] = fromBase64Url(env.d)207+                    val decoded = fromBase64Url(env.d)208+                    reconstructedBytes += decoded.size209+                    if (reconstructedBytes > MAX_RECONSTRUCTED_BYTES) {210+                        throw CodexException(211+                            CodexErrorCode.PAYLOAD_TOO_LARGE,212+                            "Reconstructed payload exceeds the ${MAX_INFLATED_BYTES / (1024 * 1024)} MiB cap",213+                        )214+                    }215+                    shards[env.i] = decoded196216                     present[env.i] = true197217                 }198218             }

view file

app/src/main/java/com/peachpasswords/codex/CodexTypes.kt+6 −1
@@ -38,11 +38,16 @@3838  * only the envelope's `t` field — no manifest — so we try each candidate3939  * parity count and pick the one that round-trips. Monotonicity guarantees a4040  * unique solution.41+ *42+ * The data-shard bound is what makes that uniqueness claim true, and it43+ * mirrors the browser's `deriveShardCounts` (PCH-01-010): [computeParityShards]44+ * saturates at 35, so without the bound a saturated parity count round-trips45+ * for any total at all and the inversion accepts absurd layouts.4146  */4247 fun deriveShardCounts(totalChunks: Int): Pair<Int, Int> {4348     for (parity in CODEX_MIN_PARITY_SHARDS..CODEX_MAX_PARITY_SHARDS) {4449         val data = totalChunks - parity45-        if (data < 1) continue50+        if (data < 1 || data > MAX_DATA_SHARDS_PER_STRIPE) continue4651         if (computeParityShards(data) == parity) return data to parity4752     }4853     throw CodexException(CodexErrorCode.ERASURE_VERIFICATION_FAILED, "Cannot derive shard layout for totalChunks=$totalChunks")

view file

app/src/main/java/com/peachpasswords/sync/SyncLimits.kt+8 −1
@@ -35,8 +35,15 @@3535      * Entry and device identifiers are UUID strings (36 chars). The cap exists3636      * so a peer cannot pad a map with multi-kilobyte keys — the count limits3737      * alone leave the cost of any single key unbounded.38+     *39+     * Deliberately loose: every import path on both platforms preserves the id40+     * a restored backup already carries (the browser's `sanitizeRestoredEntry`,41+     * Android's `draft.id.ifBlank { UUID }`), and neither bounds it, so a cap42+     * set tight to a UUID would turn one such restored entry into a permanent43+     * sync failure. The real protection is the count cap plus the frame byte44+     * cap, which together bound total bytes however long a single key is.3845      */39-    const val MAX_ID_LENGTH = 12846+    const val MAX_ID_LENGTH = 5124047     const val MAX_ENTRY_PLAINTEXT_BYTES = 512 * 10244148     const val MAX_ENTRY_ENVELOPE_BYTES = (MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 644249     /**

view file

app/src/test/java/com/peachpasswords/codex/CodexCodecTest.kt+60 −5
@@ -106,11 +106,66 @@106106         assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(total = 256)))107107     }108108 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))109+    @Test fun oversized_chunk_data_is_rejected() {110+        // One byte past the ceiling, and far past a full-range Aztec code's capacity.111+        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(data = ByteArray(4097))))112+        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(data = ByteArray(64 * 1024))))113+        assertTrue(CodexCodec.isValidEnvelope(hostileChunk()))114+    }115+116+    /**117+     * Cross-platform: the browser's decoder derives the shard size from the118+     * first scanned chunk so that Codexes printed before the current119+     * [CHUNK_SIZE] (the previous value was 800 bytes) still restore, and it120+     * enforces no chunk length of its own. Android's Reed-Solomon decoder121+     * derives shard size the same way. Pinning the chunk length to the current122+     * generator's would turn every such printed Codex into an unreadable one.123+     */124+    @Test fun a_codex_printed_with_an_older_chunk_size_still_restores() {125+        val legacyChunkSize = 800126+        val payload = ByteArray(legacyChunkSize * 2) { (it % 251).toByte() }127+        // t = 4 inverts to 2 data + 2 parity shards; every shard is present, so128+        // reconstruction is just the concatenation of the two data shards.129+        val shards = listOf(130+            payload.copyOfRange(0, legacyChunkSize),131+            payload.copyOfRange(legacyChunkSize, legacyChunkSize * 2),132+            ByteArray(legacyChunkSize),133+            ByteArray(legacyChunkSize),134+        )135+        val envelopes = shards.mapIndexed { i, bytes ->136+            ChunkEnvelope(137+                v = 3, c = "PX-AB12-CD34", s = 0, capS = 1, i = i, t = 4,138+                p = i >= 2, d = CodexCodec.base64Url(bytes),139+                h = CodexCodec.sha256Hex(bytes).take(32),140+            )141+        }142+        assertTrue("legacy envelopes are accepted", envelopes.all { CodexCodec.isValidEnvelope(it) })143+        assertArrayEquals(payload, CodexCodec.reconstructCompressed(envelopes))144+    }145+146+    /**147+     * Bounding each envelope is not enough on its own: a hostile Codex can148+     * simply carry many valid stripes. The whole reconstruction is budgeted149+     * against the inflate cap, which it can never legitimately exceed.150+     */151+    @Test fun a_reconstruction_past_the_inflate_cap_is_refused() {152+        val chunk = ByteArray(4096) // the largest chunk a single envelope may carry153+        val stripeCount = 20 // 20 × 230 × 4096 is past the 16 MiB inflate cap154+        val envelopes = (0 until stripeCount).flatMap { s ->155+            (0 until 230).map { i ->156+                ChunkEnvelope(157+                    v = 3, c = "PX-AB12-CD34", s = s, capS = stripeCount,158+                    i = i, t = 230, p = false,159+                    d = CodexCodec.base64Url(chunk), h = CodexCodec.sha256Hex(chunk).take(32),160+                )161+            }162+        }163+        val error = try {164+            CodexCodec.reconstructCompressed(envelopes); null165+        } catch (e: CodexException) {166+            e167+        }168+        assertEquals(CodexErrorCode.PAYLOAD_TOO_LARGE, error?.code)114169     }115170 116171     @Test fun malformed_verify_code_is_rejected() {

view file