CodexCodec.kt

6.3 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    fun base64Url(bytes: ByteArray): String = urlEncoder.encodeToString(bytes)22    fun fromBase64Url(s: String): ByteArray = urlDecoder.decode(s)2324    /** Uppercase hex of SHA-256(bytes) — per-chunk hash + vault fingerprint source. */25    fun sha256Hex(bytes: ByteArray): String {26        val digest = MessageDigest.getInstance("SHA-256").digest(bytes)27        val sb = StringBuilder(digest.size * 2)28        for (b in digest) sb.append("%02X".format(b.toInt() and 0xff))29        return sb.toString()30    }3132    fun deflate(data: ByteArray): ByteArray {33        val deflater = Deflater(Deflater.BEST_COMPRESSION).apply { setInput(data); finish() }34        val out = java.io.ByteArrayOutputStream(data.size / 2 + 32)35        val buf = ByteArray(8192)36        while (!deflater.finished()) out.write(buf, 0, deflater.deflate(buf))37        deflater.end()38        return out.toByteArray()39    }4041    fun inflate(data: ByteArray): ByteArray {42        val inflater = Inflater().apply { setInput(data) }43        val out = java.io.ByteArrayOutputStream(data.size * 3 + 32)44        val buf = ByteArray(8192)45        while (!inflater.finished()) {46            val n = inflater.inflate(buf)47            if (n == 0 && inflater.needsInput()) break // trailing zero-pad reached48            out.write(buf, 0, n)49        }50        inflater.end()51        return out.toByteArray()52    }5354    // ── Stripe layout ─────────────────────────────────────────────────────5556    fun planStripeShardCounts(totalDataShards: Int): IntArray {57        if (totalDataShards <= 0) return IntArray(0)58        val numStripes = maxOf(1, Math.ceil(totalDataShards.toDouble() / MAX_DATA_SHARDS_PER_STRIPE).toInt())59        val base = totalDataShards / numStripes60        val remainder = totalDataShards % numStripes61        return IntArray(numStripes) { base + if (it < remainder) 1 else 0 }62    }6364    data class Stripe(val dataShards: Int, val parityShards: Int, val shards: Array<ByteArray>) {65        val totalChunks: Int get() = dataShards + parityShards66    }6768    /** Compress + stripe + RS-encode into envelopes for [verifyCode]. */69    fun encodeToEnvelopes(compressed: ByteArray, verifyCode: String): Pair<List<ChunkEnvelope>, CodexLayout> {70        val totalDataShards = maxOf(1, Math.ceil(compressed.size.toDouble() / CHUNK_SIZE).toInt())71        val counts = planStripeShardCounts(totalDataShards)72        val envelopes = ArrayList<ChunkEnvelope>()73        var offset = 074        var totalData = 075        var totalParity = 076        for ((sIdx, dataShards) in counts.withIndex()) {77            val region = dataShards * CHUNK_SIZE78            val parityShards = computeParityShards(dataShards)79            val total = dataShards + parityShards80            val shards = Array(total) { ByteArray(CHUNK_SIZE) }81            for (d in 0 until dataShards) {82                val start = offset + d * CHUNK_SIZE83                val len = (compressed.size - start).coerceIn(0, CHUNK_SIZE)84                if (len > 0) System.arraycopy(compressed, start, shards[d], 0, len)85            }86            ReedSolomon.encode(shards, dataShards, parityShards)87            for (i in 0 until total) {88                envelopes.add(buildEnvelope(shards[i], i, total, i >= dataShards, verifyCode, sIdx, counts.size))89            }90            offset += region91            totalData += dataShards92            totalParity += parityShards93        }94        return envelopes to CodexLayout(counts.size, totalData, totalParity, totalData + totalParity)95    }9697    private fun buildEnvelope(98        chunk: ByteArray, index: Int, total: Int, isParity: Boolean,99        verifyCode: String, stripe: Int, stripeCount: Int,100    ): ChunkEnvelope = ChunkEnvelope(101        v = 3, c = verifyCode, s = stripe, capS = stripeCount,102        i = index, t = total, p = isParity,103        d = base64Url(chunk), h = sha256Hex(chunk).take(32),104    )105106    fun verifyChunkIntegrity(env: ChunkEnvelope): Boolean {107        if (env.h.length != 16 && env.h.length != 32) return false108        return sha256Hex(fromBase64Url(env.d)).take(env.h.length).equals(env.h, ignoreCase = true)109    }110111    /**112     * Reconstruct the compressed payload from scanned envelopes, tolerating113     * lost codes via Reed-Solomon. Groups by stripe, reconstructs each, then114     * concatenates the data regions in stripe order.115     */116    fun reconstructCompressed(envelopes: List<ChunkEnvelope>): ByteArray {117        val good = envelopes.filter { verifyChunkIntegrity(it) }118        if (good.isEmpty()) throw CodexException(CodexErrorCode.INSUFFICIENT_CHUNKS, "no valid chunks")119        val byStripe = good.groupBy { it.s }120        val stripeCount = good.maxOf { it.capS }121        val out = java.io.ByteArrayOutputStream()122        for (sIdx in 0 until stripeCount) {123            val chunks = byStripe[sIdx]124                ?: throw CodexException(CodexErrorCode.INSUFFICIENT_CHUNKS, "stripe $sIdx missing entirely")125            val total = chunks.first().t126            val (dataShards, parityShards) = deriveShardCounts(total)127            val shards = Array(total) { ByteArray(0) }128            val present = BooleanArray(total)129            for (env in chunks) {130                if (env.i in 0 until total) {131                    shards[env.i] = fromBase64Url(env.d)132                    present[env.i] = true133                }134            }135            ReedSolomon.reconstruct(shards, dataShards, parityShards, present)136            for (d in 0 until dataShards) out.write(shards[d])137        }138        return out.toByteArray()139    }140}141142data class CodexLayout(val stripeCount: Int, val dataChunks: Int, val parityChunks: Int, val totalChunks: Int)