Skip to content

Shamir's Secret Sharing — Kotlin source

Split a secret into N shares where any K shares can reconstruct it — but fewer than K reveal nothing. Based on Shamir's threshold scheme over GF(256).

This is the Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Shamir's Secret Sharing over GF(256) - the Galois field of 256 elements.
//
// Language: Kotlin 1.9+ (JVM), standard library only.
// Ported from src/lib/secret-sharing.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference: same
// inputs -> same share strings / reconstructed secrets.
//
// Pure math, zero dependencies. Addition in GF(256) is XOR; multiplication
// uses discrete log/exp tables built from the generator 3 (0x03) under the
// same reduction polynomial as AES (x^8 + x^4 + x^3 + x + 1 = 0x11B).
//
// Split: for each byte of the secret, build a random polynomial of degree
// K-1 whose constant term is the secret byte, then evaluate it at x = 1..N.
// Reconstruct: with K or more shares, Lagrange interpolation at x = 0
// recovers each constant term. Fewer than K shares reveal nothing
// (information-theoretic security).

import java.security.SecureRandom

/** Exponent table: GF256_EXP[i] = 3^i in GF(256). Index 255 mirrors index 0. */
val GF256_EXP = IntArray(256)

/** Discrete log table: GF256_LOG[3^i] = i (GF256_LOG[0] is unused). */
val GF256_LOG = IntArray(256)

/** Multiply by 2 (x) in GF(256), reducing by 0x11B - the AES "xtime". */
fun xtime(a: Int): Int =
    ((a shl 1) xor (if (a and 0x80 != 0) 0x11b else 0)) and 0xff

// Top-level property initializer: fill both tables from the generator 3.
private val tablesBuilt = run {
    var x = 1
    for (i in 0 until 255) {
        GF256_EXP[i] = x
        GF256_LOG[x] = i
        // step to the next power of the generator 3: x *= 3 (i.e. x ^ xtime(x))
        x = x xor xtime(x)
    }
    // 3 has order 255, so EXP wraps: EXP[255] === EXP[0].
    GF256_EXP[255] = 1
}

/** Addition in GF(256) is bitwise XOR (also serves as subtraction). */
fun gfAdd(a: Int, b: Int): Int = (a xor b) and 0xff

/** Multiply two field elements via log/exp tables. */
fun gfMul(a: Int, b: Int): Int {
    if (a == 0 || b == 0) return 0
    return GF256_EXP[(GF256_LOG[a] + GF256_LOG[b]) % 255]
}

/** Multiplicative inverse of a non-zero element. */
fun gfInv(a: Int): Int {
    require(a != 0) { "0 has no multiplicative inverse in GF(256)" }
    return GF256_EXP[(255 - GF256_LOG[a]) % 255]
}

/** Divide a by b in GF(256). */
fun gfDiv(a: Int, b: Int): Int {
    require(b != 0) { "Division by zero in GF(256)" }
    if (a == 0) return 0
    return GF256_EXP[(GF256_LOG[a] + 255 - GF256_LOG[b]) % 255]
}

/** Evaluate a polynomial (coeffs[0] = constant term) at x, Horner style. */
fun evalPoly(coeffs: IntArray, x: Int): Int {
    var y = 0
    for (i in coeffs.indices.reversed()) {
        y = gfAdd(gfMul(y, x), coeffs[i])
    }
    return y
}

/**
 * Lagrange interpolation at x = 0 over distinct-x points - recovers the
 * polynomial's constant term. Subtraction is XOR, so (0 - xm) = xm and
 * (xj - xm) = xj ^ xm.
 */
fun interpolateAtZero(points: List<Pair<Int, Int>>): Int {
    var result = 0
    for (j in points.indices) {
        val (xj, yj) = points[j]
        var weight = 1
        for (m in points.indices) {
            if (m == j) continue
            val xm = points[m].first
            weight = gfMul(weight, gfDiv(xm, xj xor xm))
        }
        result = gfAdd(result, gfMul(yj, weight))
    }
    return result
}

private fun toHex(bytes: ByteArray): String =
    bytes.joinToString("") { "%02x".format(it) }

private fun fromHex(hex: String): ByteArray =
    ByteArray(hex.length / 2) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }

private val CSPRNG = SecureRandom()

private fun defaultRandomBytes(n: Int): ByteArray =
    ByteArray(n).also(CSPRNG::nextBytes)

/** Random-bytes source for splitSecret - injectable for deterministic tests. */
fun interface RandomBytes {
    operator fun invoke(n: Int): ByteArray
}

/**
 * Split a secret into totalShares shares (x = 1..N) where any threshold of
 * them reconstruct it. Returns hex share strings like "01-a3b2c1..." - the
 * two-digit hex x-coordinate, a dash, then one hex byte per secret byte.
 */
fun splitSecret(
    secret: String,
    totalShares: Int,
    threshold: Int,
    random: RandomBytes = RandomBytes(::defaultRandomBytes),
): List<String> {
    require(threshold >= 2) {
        "Threshold must be at least 2 (a 1-of-N split is just the secret itself)"
    }
    require(totalShares <= 255) {
        "Total shares must be at most 255 (share x-coordinates live in 1..255)"
    }
    require(threshold <= totalShares) {
        "Threshold ($threshold) cannot exceed total shares ($totalShares)"
    }

    val bytes = secret.toByteArray(Charsets.UTF_8)
    val yParts = Array(totalShares) { ByteArray(bytes.size) }
    val coeffs = IntArray(threshold)

    for (b in bytes.indices) {
        coeffs[0] = bytes[b].toInt() and 0xff
        if (threshold > 1) {
            val rand = random(threshold - 1)
            for (j in 1 until threshold) coeffs[j] = rand[j - 1].toInt() and 0xff
        }
        for (i in 1..totalShares) {
            yParts[i - 1][b] = evalPoly(coeffs, i).toByte()
        }
    }

    return yParts.mapIndexed { idx, ys -> "%02x-".format(idx + 1) + toHex(ys) }
}

/** A parsed share: its x-coordinate and its per-byte polynomial evaluations. */
data class ParsedShare(val x: Int, val y: ByteArray)

/** Parse one "xx-hex" share string; throws on any malformed input. */
fun parseShare(share: String): ParsedShare {
    val s = share.trim()
    if (s.indexOf('-') != 2 || !Regex("^[0-9a-fA-F]{2}$").matches(s.take(2))) {
        throw IllegalArgumentException(
            "Malformed share \"$s\" - expected the format \"xx-hex…\" (e.g. \"01-a3b2c1\")"
        )
    }
    val yHex = s.substring(3)
    if (!Regex("^[0-9a-fA-F]*$").matches(yHex) || yHex.length % 2 != 0) {
        throw IllegalArgumentException(
            "Malformed share \"$s\" - the payload must be an even-length hex string"
        )
    }
    val x = s.take(2).toInt(16)
    if (x == 0) {
        throw IllegalArgumentException("Share x-coordinate 00 is invalid - shares are numbered from 01")
    }
    return ParsedShare(x, fromHex(yHex))
}

/**
 * Reconstruct the secret from an arbitrary collection of share strings.
 * Needs at least 2 distinct shares (the threshold of the original split);
 * anything less than the true threshold K yields garbage without warning -
 * that is the security property of the scheme, not a bug.
 */
fun reconstructSecret(shares: List<String>): String {
    if (shares.size < 2) {
        throw IllegalArgumentException("Need at least 2 shares to reconstruct")
    }
    val byX = LinkedHashMap<Int, ByteArray>()
    for (share in shares) {
        val (x, y) = parseShare(share)
        // The same share pasted twice is harmless (dedupe); a colliding
        // x-coordinate with a different payload cannot belong to one split.
        val existing = byX[x]
        if (existing == null) {
            byX[x] = y
        } else if (!existing.contentEquals(y)) {
            throw IllegalArgumentException(
                "Two different shares both claim x=%02x - they cannot come from the same split".format(x)
            )
        }
    }
    val points = byX.entries.sortedBy { it.key }
    if (points.size < 2) {
        throw IllegalArgumentException("Need at least 2 distinct shares to reconstruct")
    }
    val len = points[0].value.size
    if (points.any { it.value.size != len }) {
        throw IllegalArgumentException("All shares must be the same length - they do not come from the same split")
    }

    val out = ByteArray(len)
    for (b in 0 until len) {
        val pair = points.map { (x, ys) -> x to (ys[b].toInt() and 0xff) }
        out[b] = interpolateAtZero(pair).toByte()
    }
    return String(out, Charsets.UTF_8)
}

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →