CodexCodec.kt

6.9 KB · raw

1package com.peachpasswords.codex23import kotlinx.serialization.json.Json4import java.security.MessageDigest5import java.util.Base646import java.util.zip.Deflater7import java.util.zip.Inflater89/**10 * Pure (Android-free) Codex byte pipeline: compress → stripe → Reed-Solomon →11 * envelopes, and the inverse for restore. Kept independent of Aztec/PDF so the12 * full round-trip is unit-testable on the JVM. Port of the browser13 * `generate.ts` / `restore.ts` / `erasure.ts` byte handling.14 */15object CodexCodec {16    val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }1718    private val urlEncoder = Base64.getUrlEncoder().withoutPadding()19    private val urlDecoder = Base64.getUrlDecoder()2021    /**22     * F4: decompression bomb guard — mirrors PeachPayloadCodec's23     * MAX_SECTION_RAW_BYTES (16 MiB). A codex payload is the same vault JSON24     * the PeachScript sections carry, so the cap is identical by definition.25     */26    const val MAX_INFLATED_BYTES = 16L * 1024 * 10242728    fun base64Url(bytes: ByteArray): String = urlEncoder.encodeToString(bytes)29    fun fromBase64Url(s: String): ByteArray = urlDecoder.decode(s)3031    /** Uppercase hex of SHA-256(bytes) — per-chunk hash + vault fingerprint source. */32    fun sha256Hex(bytes: ByteArray): String {33        val digest = MessageDigest.getInstance("SHA-256").digest(bytes)34        val sb = StringBuilder(digest.size * 2)35        for (b in digest) sb.append("%02X".format(b.toInt() and 0xff))36        return sb.toString()37    }3839    fun deflate(data: ByteArray): ByteArray {40        val deflater = Deflater(Deflater.BEST_COMPRESSION).apply { setInput(data); finish() }41        val out = java.io.ByteArrayOutputStream(data.size / 2 + 32)42        val buf = ByteArray(8192)43        while (!deflater.finished()) out.write(buf, 0, deflater.deflate(buf))44        deflater.end()45        return out.toByteArray()46    }4748    fun inflate(data: ByteArray): ByteArray {49        val inflater = Inflater().apply { setInput(data) }50        val out = java.io.ByteArrayOutputStream(data.size * 3 + 32)51        val buf = ByteArray(8192)52        while (!inflater.finished()) {53            val n = inflater.inflate(buf)54            if (n == 0 && inflater.needsInput()) break // trailing zero-pad reached55            out.write(buf, 0, n)56            if (out.size() > MAX_INFLATED_BYTES) {57                inflater.end()58                throw CodexException(59                    CodexErrorCode.PAYLOAD_TOO_LARGE,60                    "Inflated payload exceeds the ${MAX_INFLATED_BYTES / (1024 * 1024)} MiB cap",61                )62            }63        }64        inflater.end()65        return out.toByteArray()66    }6768    // ── Stripe layout ─────────────────────────────────────────────────────6970    fun planStripeShardCounts(totalDataShards: Int): IntArray {71        if (totalDataShards <= 0) return IntArray(0)72        val numStripes = maxOf(1, Math.ceil(totalDataShards.toDouble() / MAX_DATA_SHARDS_PER_STRIPE).toInt())73        val base = totalDataShards / numStripes74        val remainder = totalDataShards % numStripes75        return IntArray(numStripes) { base + if (it < remainder) 1 else 0 }76    }7778    data class Stripe(val dataShards: Int, val parityShards: Int, val shards: Array<ByteArray>) {79        val totalChunks: Int get() = dataShards + parityShards80    }8182    /** Compress + stripe + RS-encode into envelopes for [verifyCode]. */83    fun encodeToEnvelopes(compressed: ByteArray, verifyCode: String): Pair<List<ChunkEnvelope>, CodexLayout> {84        val totalDataShards = maxOf(1, Math.ceil(compressed.size.toDouble() / CHUNK_SIZE).toInt())85        val counts = planStripeShardCounts(totalDataShards)86        val envelopes = ArrayList<ChunkEnvelope>()87        var offset = 088        var totalData = 089        var totalParity = 090        for ((sIdx, dataShards) in counts.withIndex()) {91            val region = dataShards * CHUNK_SIZE92            val parityShards = computeParityShards(dataShards)93            val total = dataShards + parityShards94            val shards = Array(total) { ByteArray(CHUNK_SIZE) }95            for (d in 0 until dataShards) {96                val start = offset + d * CHUNK_SIZE97                val len = (compressed.size - start).coerceIn(0, CHUNK_SIZE)98                if (len > 0) System.arraycopy(compressed, start, shards[d], 0, len)99            }100            ReedSolomon.encode(shards, dataShards, parityShards)101            for (i in 0 until total) {102                envelopes.add(buildEnvelope(shards[i], i, total, i >= dataShards, verifyCode, sIdx, counts.size))103            }104            offset += region105            totalData += dataShards106            totalParity += parityShards107        }108        return envelopes to CodexLayout(counts.size, totalData, totalParity, totalData + totalParity)109    }110111    private fun buildEnvelope(112        chunk: ByteArray, index: Int, total: Int, isParity: Boolean,113        verifyCode: String, stripe: Int, stripeCount: Int,114    ): ChunkEnvelope = ChunkEnvelope(115        v = 3, c = verifyCode, s = stripe, capS = stripeCount,116        i = index, t = total, p = isParity,117        d = base64Url(chunk), h = sha256Hex(chunk).take(32),118    )119120    fun verifyChunkIntegrity(env: ChunkEnvelope): Boolean {121        if (env.h.length != 16 && env.h.length != 32) return false122        return sha256Hex(fromBase64Url(env.d)).take(env.h.length).equals(env.h, ignoreCase = true)123    }124125    /**126     * Reconstruct the compressed payload from scanned envelopes, tolerating127     * lost codes via Reed-Solomon. Groups by stripe, reconstructs each, then128     * concatenates the data regions in stripe order.129     */130    fun reconstructCompressed(envelopes: List<ChunkEnvelope>): ByteArray {131        val good = envelopes.filter { verifyChunkIntegrity(it) }132        if (good.isEmpty()) throw CodexException(CodexErrorCode.INSUFFICIENT_CHUNKS, "no valid chunks")133        val byStripe = good.groupBy { it.s }134        val stripeCount = good.maxOf { it.capS }135        val out = java.io.ByteArrayOutputStream()136        for (sIdx in 0 until stripeCount) {137            val chunks = byStripe[sIdx]138                ?: throw CodexException(CodexErrorCode.INSUFFICIENT_CHUNKS, "stripe $sIdx missing entirely")139            val total = chunks.first().t140            val (dataShards, parityShards) = deriveShardCounts(total)141            val shards = Array(total) { ByteArray(0) }142            val present = BooleanArray(total)143            for (env in chunks) {144                if (env.i in 0 until total) {145                    shards[env.i] = fromBase64Url(env.d)146                    present[env.i] = true147                }148            }149            ReedSolomon.reconstruct(shards, dataShards, parityShards, present)150            for (d in 0 until dataShards) out.write(shards[d])151        }152        return out.toByteArray()153    }154}155156data class CodexLayout(val stripeCount: Int, val dataChunks: Int, val parityChunks: Int, val totalChunks: Int)