/** * Shamir Secret Sharing over GF(2^8). * * Splits a secret byte-array into `n` shares such that any `k` (the * threshold) reconstruct the original, but any combination of `k-1` or * fewer reveals nothing about the secret beyond its length. Each byte of * the secret is shared independently using a random polynomial of degree * `k-1` whose constant term is the secret byte; shares are points on that * polynomial evaluated at `x = 1..n`. * * Field: GF(2^8) with the irreducible polynomial 0x11b (AES'). Tables for * exp and log are precomputed at module load time and reused for both * multiplication and inversion. All field operations are constant-time * (table lookups + xor / mod), so split + combine leak nothing about the * secret bytes through timing. * * The wire format for a share is: * * 1 byte x-coordinate (1..255) * N bytes y-coordinates, one per secret byte * * `splitSecret` returns an array of length `n`. `combineShares` accepts * any subset of shares; the threshold is implicit in the polynomial * degree the sender chose. Combining `k-1` shares yields a different * (random-looking) result — there's no way to detect under-threshold * combination from the shares alone, so callers must enforce the * threshold separately (e.g. by waiting for `k` arrivals before combining) * AND verify the reconstructed key against the AEAD tag of the * ciphertext it was meant to decrypt. */ const FIELD_SIZE = 256; const EXP = new Uint8Array(FIELD_SIZE * 2); const LOG = new Uint8Array(FIELD_SIZE); (function buildTables(): void { // Generator: 0x03. Standard AES-style table buildup. let x = 1; for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; // Multiply x by the generator (0x03) in GF(2^8) with the AES // reduction polynomial 0x1b on overflow. let next = x ^ ((x << 1) & 0xff); if (x & 0x80) next ^= 0x1b; x = next & 0xff; } // Mirror the first half so `EXP[i + j]` works for any 0..510 without // an extra modulus. for (let i = 255; i < FIELD_SIZE * 2; i++) EXP[i] = EXP[i - 255]!; })(); function gfMul(a: number, b: number): number { if (a === 0 || b === 0) return 0; return EXP[LOG[a]! + LOG[b]!]!; } function gfDiv(a: number, b: number): number { if (b === 0) throw new Error('Shamir: division by zero'); if (a === 0) return 0; // a/b = exp(log(a) - log(b)) — keep the index non-negative by adding 255. return EXP[LOG[a]! - LOG[b]! + 255]!; } /** * Evaluate a polynomial `coeffs[0] + coeffs[1]*x + … + coeffs[d]*x^d` * at point `x` using Horner's method. Constant-time in the coefficients * (no early-out on zero terms). */ function evalPoly(coeffs: Uint8Array, x: number): number { let acc = coeffs[coeffs.length - 1]!; for (let i = coeffs.length - 2; i >= 0; i--) { acc = gfMul(acc, x) ^ coeffs[i]!; } return acc; } /** * One Shamir share. * * - `x`: the x-coordinate, 1..255 (unique per share within a split). * - `y`: a y-coordinate for each byte of the original secret. */ export interface ShamirShare { x: number; y: Uint8Array; } /** * Split `secret` into `n` shares; any `k` reconstructs. * * @param secret the bytes to split (any length ≥ 1) * @param k threshold (1 ≤ k ≤ n ≤ 255) * @param n total number of shares * @param random RNG callback (length → bytes). Must be cryptographically * secure. Inject `crypto.randomBytes.bind(crypto)` from a * `CryptoProvider` to reuse the SDK's RNG. * @returns array of `n` shares with x-coordinates 1..n (skipping 0 * because P(0) IS the secret). */ export function splitSecret( secret: Uint8Array, k: number, n: number, random: (length: number) => Uint8Array, ): ShamirShare[] { if (!Number.isInteger(k) || !Number.isInteger(n)) { throw new Error('Shamir: k and n must be integers'); } if (k < 1) throw new Error('Shamir: threshold must be ≥ 1'); if (n < k) throw new Error('Shamir: n must be ≥ k'); if (n > 255) throw new Error('Shamir: n must be ≤ 255 (GF(2^8) limit)'); if (secret.length === 0) throw new Error('Shamir: secret must be non-empty'); // Pre-allocate per-share y-buffers. We fill column-wise: for each byte // of the secret we draw a fresh random polynomial of degree k-1 with // constant term equal to the secret byte, then emit y[byteIndex] for // each share at its x-coordinate. const shares: ShamirShare[] = []; for (let i = 0; i < n; i++) { shares.push({ x: i + 1, y: new Uint8Array(secret.length) }); } // For each column (byte of the secret), build a fresh polynomial. const coeffs = new Uint8Array(k); for (let byteIdx = 0; byteIdx < secret.length; byteIdx++) { coeffs[0] = secret[byteIdx]!; if (k > 1) { const rnd = random(k - 1); for (let j = 0; j < k - 1; j++) coeffs[j + 1] = rnd[j]!; } for (let i = 0; i < n; i++) { const share = shares[i]!; share.y[byteIdx] = evalPoly(coeffs, share.x); } // Zero the polynomial buffer between columns. Doesn't help against a // V8 GC adversary but is the right discipline. coeffs.fill(0); } return shares; } /** * Reconstruct the original secret from a subset of shares using * Lagrange interpolation evaluated at x = 0. * * `shares.length` MUST equal the threshold the secret was split with * (the algorithm doesn't know `k`; supplying fewer or more shares either * yields garbage or wastes work). The returned secret has the same * length as each share's y-buffer. * * The secret cannot be authenticated from the shares alone — the caller * is expected to verify the reconstructed key against an AEAD tag (e.g. * the AES-GCM ciphertext it was used to encrypt). Without that * authentication, an attacker who supplied a forged share at any of the * sample points can flip the reconstructed key to anything they want. */ export function combineShares(shares: ShamirShare[]): Uint8Array { if (shares.length === 0) throw new Error('Shamir: need at least one share'); const len = shares[0]!.y.length; for (const s of shares) { if (s.y.length !== len) throw new Error('Shamir: share length mismatch'); if (s.x === 0) throw new Error('Shamir: x-coordinate 0 is reserved for the secret'); } // Reject duplicate x-coordinates: two shares with the same x but // different y collide as polynomial evaluations and produce nonsense. const xs = new Set(); for (const s of shares) { if (xs.has(s.x)) throw new Error('Shamir: duplicate x-coordinate in share set'); xs.add(s.x); } const out = new Uint8Array(len); // Precompute Lagrange basis weights at x=0: // L_i(0) = ∏_{j ≠ i} -x_j / (x_i - x_j) // In GF(2^8) negation is identity, so this simplifies to ∏ x_j / (x_i + x_j). const weights = new Uint8Array(shares.length); for (let i = 0; i < shares.length; i++) { let num = 1; let den = 1; const xi = shares[i]!.x; for (let j = 0; j < shares.length; j++) { if (i === j) continue; const xj = shares[j]!.x; num = gfMul(num, xj); den = gfMul(den, xi ^ xj); } weights[i] = gfDiv(num, den); } for (let byteIdx = 0; byteIdx < len; byteIdx++) { let acc = 0; for (let i = 0; i < shares.length; i++) { acc ^= gfMul(shares[i]!.y[byteIdx]!, weights[i]!); } out[byteIdx] = acc; } return out; } /** * Encode a `ShamirShare` to a single Uint8Array on the wire: * * 1 byte x-coordinate * N bytes y-coordinates * * `decodeShare` is the inverse. Callers ship this byte sequence as * opaque payload — the encoding is stable across platforms and is what * `@shade/recovery` puts on the wire. */ export function encodeShare(share: ShamirShare): Uint8Array { if (share.x < 1 || share.x > 255) throw new Error('Shamir: x out of range [1..255]'); const out = new Uint8Array(1 + share.y.length); out[0] = share.x; out.set(share.y, 1); return out; } export function decodeShare(bytes: Uint8Array): ShamirShare { if (bytes.length < 2) throw new Error('Shamir: encoded share must be ≥ 2 bytes'); const x = bytes[0]!; if (x === 0) throw new Error('Shamir: x-coordinate 0 in encoded share'); return { x, y: bytes.slice(1) }; }