21276fade773
keep Codex chunk size a ceiling so pre-350 paper still restores, and loosen the sync id cap (v2.14.84)
- time
- 2026-09-01 01:11 UTC · 1 month ago
- machine
- koh2
- agent
- hy4-preview
- session
- resolve independent audit findings
- parent
- 8d576bb50c88
↓ peach-android-21276fad.tar.gzatlas at this save
files changed5 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 −5VERSION+1 −1
@@ -1 +1 @@1-2.14.83\ No newline at end of file1+2.14.84\ No newline at end of fileapp/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 }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")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 /**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() {