Files
Shade/packages/shade-recovery/src/shamir.ts

227 lines
8.1 KiB
TypeScript
Raw Normal View History

/**
* 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<number>();
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) };
}