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>
148 lines
5.4 KiB
TypeScript
148 lines
5.4 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import fc from 'fast-check';
|
|
import {
|
|
combineShares,
|
|
decodeShare,
|
|
encodeShare,
|
|
splitSecret,
|
|
type ShamirShare,
|
|
} from '../src/shamir.js';
|
|
|
|
const cryptoRandom = (n: number): Uint8Array => {
|
|
const out = new Uint8Array(n);
|
|
globalThis.crypto.getRandomValues(out);
|
|
return out;
|
|
};
|
|
|
|
describe('Shamir Secret Sharing', () => {
|
|
test('split + combine roundtrip restores the secret (k=3, n=5)', () => {
|
|
const secret = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
|
const shares = splitSecret(secret, 3, 5, cryptoRandom);
|
|
expect(shares.length).toBe(5);
|
|
// Pick first 3 shares — any 3 should work.
|
|
const subset = shares.slice(0, 3);
|
|
const combined = combineShares(subset);
|
|
expect(Array.from(combined)).toEqual(Array.from(secret));
|
|
});
|
|
|
|
test('any threshold-sized subset reconstructs', () => {
|
|
const secret = cryptoRandom(32);
|
|
const shares = splitSecret(secret, 3, 5, cryptoRandom);
|
|
// All 10 possible 3-subsets must reconstruct.
|
|
for (let i = 0; i < 5; i++) {
|
|
for (let j = i + 1; j < 5; j++) {
|
|
for (let k = j + 1; k < 5; k++) {
|
|
const combined = combineShares([shares[i]!, shares[j]!, shares[k]!]);
|
|
expect(Array.from(combined)).toEqual(Array.from(secret));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('exactly threshold shares reconstruct (k=2, n=2)', () => {
|
|
const secret = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
|
|
const shares = splitSecret(secret, 2, 2, cryptoRandom);
|
|
const combined = combineShares(shares);
|
|
expect(Array.from(combined)).toEqual(Array.from(secret));
|
|
});
|
|
|
|
test('k-1 shares yield a wrong (random-looking) result', () => {
|
|
const secret = cryptoRandom(32);
|
|
const shares = splitSecret(secret, 3, 5, cryptoRandom);
|
|
const truncated = shares.slice(0, 2); // 2 < k=3
|
|
const combined = combineShares(truncated);
|
|
// We can't reliably assert "looks random", but we can assert
|
|
// it's not equal to the secret (passing 2 shares to a polynomial
|
|
// of degree 2 yields a different polynomial with prob ≈ 1).
|
|
expect(Array.from(combined)).not.toEqual(Array.from(secret));
|
|
});
|
|
|
|
test('split rejects k < 1, n < k, n > 255', () => {
|
|
expect(() => splitSecret(new Uint8Array([1]), 0, 5, cryptoRandom)).toThrow();
|
|
expect(() => splitSecret(new Uint8Array([1]), 6, 5, cryptoRandom)).toThrow();
|
|
expect(() => splitSecret(new Uint8Array([1]), 1, 256, cryptoRandom)).toThrow();
|
|
});
|
|
|
|
test('split rejects empty secret', () => {
|
|
expect(() => splitSecret(new Uint8Array(0), 1, 1, cryptoRandom)).toThrow();
|
|
});
|
|
|
|
test('combine rejects empty share set', () => {
|
|
expect(() => combineShares([])).toThrow();
|
|
});
|
|
|
|
test('combine rejects duplicate x-coordinates', () => {
|
|
const secret = new Uint8Array([1, 2, 3]);
|
|
const shares = splitSecret(secret, 2, 3, cryptoRandom);
|
|
const dup: ShamirShare[] = [shares[0]!, { x: shares[0]!.x, y: shares[1]!.y }];
|
|
expect(() => combineShares(dup)).toThrow(/duplicate x-coordinate/);
|
|
});
|
|
|
|
test('combine rejects mismatched share lengths', () => {
|
|
const a: ShamirShare = { x: 1, y: new Uint8Array([1, 2, 3]) };
|
|
const b: ShamirShare = { x: 2, y: new Uint8Array([1, 2]) };
|
|
expect(() => combineShares([a, b])).toThrow(/length mismatch/);
|
|
});
|
|
|
|
test('encode + decode share roundtrip', () => {
|
|
const share: ShamirShare = { x: 7, y: new Uint8Array([1, 2, 3, 4, 5]) };
|
|
const bytes = encodeShare(share);
|
|
expect(bytes.length).toBe(6);
|
|
expect(bytes[0]).toBe(7);
|
|
const decoded = decodeShare(bytes);
|
|
expect(decoded.x).toBe(7);
|
|
expect(Array.from(decoded.y)).toEqual([1, 2, 3, 4, 5]);
|
|
});
|
|
|
|
test('encodeShare rejects x out of range', () => {
|
|
expect(() => encodeShare({ x: 0, y: new Uint8Array([1]) })).toThrow();
|
|
expect(() => encodeShare({ x: 256, y: new Uint8Array([1]) })).toThrow();
|
|
});
|
|
|
|
test('decodeShare rejects x=0', () => {
|
|
const bad = new Uint8Array([0, 1, 2, 3]);
|
|
expect(() => decodeShare(bad)).toThrow();
|
|
});
|
|
|
|
test('property: random-secret roundtrip preserves bytes for arbitrary k/n', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.uint8Array({ minLength: 1, maxLength: 64 }),
|
|
fc.integer({ min: 1, max: 8 }),
|
|
fc.integer({ min: 0, max: 8 }),
|
|
(secret, k, extra) => {
|
|
const n = k + extra;
|
|
if (n > 16) return;
|
|
const shares = splitSecret(secret, k, n, cryptoRandom);
|
|
// Pick the first k shares — any k will do.
|
|
const reconstructed = combineShares(shares.slice(0, k));
|
|
expect(Array.from(reconstructed)).toEqual(Array.from(secret));
|
|
},
|
|
),
|
|
{ numRuns: 50 },
|
|
);
|
|
});
|
|
|
|
test('property: any k-1 share subset yields a different output than the secret', () => {
|
|
// This is a probabilistic statement: with random secrets and
|
|
// random polynomials, P(reconstruction collides with the secret
|
|
// by accident) is ≈ 1/256^len, vanishingly small for 32-byte
|
|
// secrets.
|
|
fc.assert(
|
|
fc.property(
|
|
fc.uint8Array({ minLength: 16, maxLength: 32 }),
|
|
fc.integer({ min: 2, max: 6 }),
|
|
(secret, k) => {
|
|
const n = k + 2;
|
|
if (n > 16) return;
|
|
const shares = splitSecret(secret, k, n, cryptoRandom);
|
|
const subset = shares.slice(0, k - 1); // k-1 < threshold
|
|
const combined = combineShares(subset);
|
|
expect(Array.from(combined)).not.toEqual(Array.from(secret));
|
|
},
|
|
),
|
|
{ numRuns: 30 },
|
|
);
|
|
});
|
|
});
|