Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
227 lines
8.1 KiB
TypeScript
227 lines
8.1 KiB
TypeScript
/**
|
|
* 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) };
|
|
}
|