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) while (!inflater.finished()) { val n = inflater.inflate(buf) if (n == 0 && inflater.needsInput()) break // trailing zero-pad reached 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), ) 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 { verifyChunkIntegrity(it) } if (good.isEmpty()) throw CodexException(CodexErrorCode.INSUFFICIENT_CHUNKS, "no valid chunks") val byStripe = good.groupBy { it.s } val stripeCount = good.maxOf { it.capS } 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 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) { shards[env.i] = fromBase64Url(env.d) 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)