package com.peachpasswords.codex import kotlinx.serialization.json.Json import java.security.MessageDigest import java.util.Base64 import java.util.zip.Deflater import java.util.zip.Inflater /** * Pure (Android-free) Codex byte pipeline: compress → stripe → Reed-Solomon → * envelopes, and the inverse for restore. Kept independent of Aztec/PDF so the * full round-trip is unit-testable on the JVM. Port of the browser * `generate.ts` / `restore.ts` / `erasure.ts` byte handling. */ object CodexCodec { val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } private val urlEncoder = Base64.getUrlEncoder().withoutPadding() private val urlDecoder = Base64.getUrlDecoder() /** * F4: decompression bomb guard — mirrors PeachPayloadCodec's * MAX_SECTION_RAW_BYTES (16 MiB). A codex payload is the same vault JSON * the PeachScript sections carry, so the cap is identical by definition. */ const val MAX_INFLATED_BYTES = 16L * 1024 * 1024 fun base64Url(bytes: ByteArray): String = urlEncoder.encodeToString(bytes) fun fromBase64Url(s: String): ByteArray = urlDecoder.decode(s) /** Uppercase hex of SHA-256(bytes) — per-chunk hash + vault fingerprint source. */ fun sha256Hex(bytes: ByteArray): String { val digest = MessageDigest.getInstance("SHA-256").digest(bytes) val sb = StringBuilder(digest.size * 2) for (b in digest) sb.append("%02X".format(b.toInt() and 0xff)) return sb.toString() } fun deflate(data: ByteArray): ByteArray { val deflater = Deflater(Deflater.BEST_COMPRESSION).apply { setInput(data); finish() } val out = java.io.ByteArrayOutputStream(data.size / 2 + 32) val buf = ByteArray(8192) while (!deflater.finished()) out.write(buf, 0, deflater.deflate(buf)) deflater.end() return out.toByteArray() } fun inflate(data: ByteArray): ByteArray { val inflater = Inflater().apply { setInput(data) } val out = java.io.ByteArrayOutputStream(data.size * 3 + 32) val buf = ByteArray(8192) // PB-26-004: a malformed stream can return 0 bytes without finishing // or needing input, spinning the loop indefinitely. Treat every // zero-progress state as an explicit failure: needsInput means trailing // zero-pad; needsDictionary means a preset dictionary stream Codex // never produces; any other 0-return is malformed. while (!inflater.finished()) { val n = inflater.inflate(buf) if (n == 0) { if (inflater.finished()) break inflater.end() if (inflater.needsDictionary()) { throw CodexException( CodexErrorCode.CORRUPTED, "Inflate requires a preset dictionary — Codex never produces one", ) } if (inflater.needsInput()) { throw CodexException( CodexErrorCode.CORRUPTED, "Inflate input ended before the stream finished", ) } throw CodexException( CodexErrorCode.CORRUPTED, "Inflate made no progress without needing input", ) } out.write(buf, 0, n) if (out.size() > MAX_INFLATED_BYTES) { inflater.end() throw CodexException( CodexErrorCode.PAYLOAD_TOO_LARGE, "Inflated payload exceeds the ${MAX_INFLATED_BYTES / (1024 * 1024)} MiB cap", ) } } inflater.end() return out.toByteArray() } // ── Stripe layout ───────────────────────────────────────────────────── fun planStripeShardCounts(totalDataShards: Int): IntArray { if (totalDataShards <= 0) return IntArray(0) val numStripes = maxOf(1, Math.ceil(totalDataShards.toDouble() / MAX_DATA_SHARDS_PER_STRIPE).toInt()) val base = totalDataShards / numStripes val remainder = totalDataShards % numStripes return IntArray(numStripes) { base + if (it < remainder) 1 else 0 } } data class Stripe(val dataShards: Int, val parityShards: Int, val shards: Array) { val totalChunks: Int get() = dataShards + parityShards } /** Compress + stripe + RS-encode into envelopes for [verifyCode]. */ fun encodeToEnvelopes(compressed: ByteArray, verifyCode: String): Pair, CodexLayout> { val totalDataShards = maxOf(1, Math.ceil(compressed.size.toDouble() / CHUNK_SIZE).toInt()) val counts = planStripeShardCounts(totalDataShards) val envelopes = ArrayList() var offset = 0 var totalData = 0 var totalParity = 0 for ((sIdx, dataShards) in counts.withIndex()) { val region = dataShards * CHUNK_SIZE val parityShards = computeParityShards(dataShards) val total = dataShards + parityShards val shards = Array(total) { ByteArray(CHUNK_SIZE) } for (d in 0 until dataShards) { val start = offset + d * CHUNK_SIZE val len = (compressed.size - start).coerceIn(0, CHUNK_SIZE) if (len > 0) System.arraycopy(compressed, start, shards[d], 0, len) } ReedSolomon.encode(shards, dataShards, parityShards) for (i in 0 until total) { envelopes.add(buildEnvelope(shards[i], i, total, i >= dataShards, verifyCode, sIdx, counts.size)) } offset += region totalData += dataShards totalParity += parityShards } return envelopes to CodexLayout(counts.size, totalData, totalParity, totalData + totalParity) } private fun buildEnvelope( chunk: ByteArray, index: Int, total: Int, isParity: Boolean, verifyCode: String, stripe: Int, stripeCount: Int, ): ChunkEnvelope = ChunkEnvelope( v = 3, c = verifyCode, s = stripe, capS = stripeCount, i = index, t = total, p = isParity, d = base64Url(chunk), h = sha256Hex(chunk).take(32), ) /** * Upper bound on one chunk's decoded bytes, not the generator's exact * [CHUNK_SIZE]: the browser's decoder derives the shard size from the * first scanned chunk precisely so Codexes printed before the current * chunk size (800 bytes) still restore, and it enforces no length of its * own. The bound sits above a full-range Aztec code's alphanumeric * capacity, so it rejects only what no barcode could have carried. */ private const val MAX_CHUNK_BYTES = 4096 private val MAX_ENCODED_CHUNK_CHARS = base64Url(ByteArray(MAX_CHUNK_BYTES)).length /** * Budget for the whole reconstructed payload. [inflate] refuses anything * over [MAX_INFLATED_BYTES], so a payload larger than this can never * succeed; the headroom covers deflate framing on a payload at the cap. */ private val MAX_RECONSTRUCTED_BYTES = MAX_INFLATED_BYTES + 1024 * 1024 private val VERIFY_CODE = Regex("PX-[0-9A-Z]{4}-[0-9A-Z]{4}") /** * Stripe ceiling implied by the inflate cap: the largest legal payload * divided by the largest legal stripe, rounded up. The extra stripe covers * deflate's own framing overhead, which can push the compressed payload a * fraction above the uncompressed cap. */ private val MAX_STRIPES: Int = ((MAX_INFLATED_BYTES + MAX_DATA_SHARDS_PER_STRIPE * CHUNK_SIZE - 1) / (MAX_DATA_SHARDS_PER_STRIPE * CHUNK_SIZE)).toInt() + 1 /** * Reject a scanned envelope whose declared geometry no real Codex can * produce, before anything is allocated from it (PAI-2026-002). * * The per-chunk hash covers `d` only — `t`, `S`, `s` and `i` are unauthenticated * integers, and reconstruction sized `Array(total)` straight from `t`. A * barcode with a valid hash over a tiny payload and t = Int.MAX_VALUE * therefore cost a multi-gigabyte allocation before it could be rejected. * Every bound here is the encoder's own, except the chunk size, which is a * ceiling rather than an exact match so older paper still restores (see * [MAX_CHUNK_BYTES]). [buildEnvelope] emits at most * [MAX_TOTAL_SHARDS_PER_STRIPE] shards per stripe and at most * [MAX_STRIPES] stripes. */ internal fun isValidEnvelope(env: ChunkEnvelope): Boolean = env.v == CODEX_VERSION && VERIFY_CODE.matches(env.c) && env.capS in 1..MAX_STRIPES && env.s in 0 until env.capS && env.t in 1..MAX_TOTAL_SHARDS_PER_STRIPE && env.i in 0 until env.t && env.d.length in 1..MAX_ENCODED_CHUNK_CHARS && (env.h.length == 16 || env.h.length == 32) fun verifyChunkIntegrity(env: ChunkEnvelope): Boolean { if (env.h.length != 16 && env.h.length != 32) return false return sha256Hex(fromBase64Url(env.d)).take(env.h.length).equals(env.h, ignoreCase = true) } /** * Reconstruct the compressed payload from scanned envelopes, tolerating * lost codes via Reed-Solomon. Groups by stripe, reconstructs each, then * concatenates the data regions in stripe order. */ fun reconstructCompressed(envelopes: List): ByteArray { val good = envelopes.filter { isValidEnvelope(it) && verifyChunkIntegrity(it) } if (good.isEmpty()) throw CodexException(CodexErrorCode.INSUFFICIENT_CHUNKS, "no valid chunks") val verifyCode = good.first().c val stripeCount = good.first().capS if (good.any { it.c != verifyCode || it.capS != stripeCount }) { throw CodexException(CodexErrorCode.INVALID_ENVELOPE, "mixed verify codes or stripe counts") } val byStripe = good.groupBy { it.s } var reconstructedBytes = 0L val out = java.io.ByteArrayOutputStream() for (sIdx in 0 until stripeCount) { val chunks = byStripe[sIdx] ?: throw CodexException(CodexErrorCode.INSUFFICIENT_CHUNKS, "stripe $sIdx missing entirely") val total = chunks.first().t if (chunks.any { it.t != total }) { throw CodexException(CodexErrorCode.INVALID_ENVELOPE, "mixed shard totals in stripe $sIdx") } val (dataShards, parityShards) = deriveShardCounts(total) val shards = Array(total) { ByteArray(0) } val present = BooleanArray(total) for (env in chunks) { if (env.i in 0 until total) { val decoded = fromBase64Url(env.d) reconstructedBytes += decoded.size if (reconstructedBytes > MAX_RECONSTRUCTED_BYTES) { throw CodexException( CodexErrorCode.PAYLOAD_TOO_LARGE, "Reconstructed payload exceeds the ${MAX_INFLATED_BYTES / (1024 * 1024)} MiB cap", ) } shards[env.i] = decoded present[env.i] = true } } ReedSolomon.reconstruct(shards, dataShards, parityShards, present) for (d in 0 until dataShards) out.write(shards[d]) } return out.toByteArray() } } data class CodexLayout(val stripeCount: Int, val dataChunks: Int, val parityChunks: Int, val totalChunks: Int)