release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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
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>
This commit is contained in:
348
packages/shade-recovery/tests/adversarial.test.ts
Normal file
348
packages/shade-recovery/tests/adversarial.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Adversarial tests for V3.10 Social Key Recovery.
|
||||
*
|
||||
* Holds the line on the V3.10 acceptance criteria:
|
||||
* - No coalition of (k-1) guardians can reconstruct the secret.
|
||||
* - Forged shares are detected by the AEAD on the backup blob.
|
||||
* - Guardian decline propagates to the new device.
|
||||
* - Per-guardian fingerprint-gate refusal blocks the share release.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import fc from 'fast-check';
|
||||
import type { Shade } from '@shade/sdk';
|
||||
import {
|
||||
attachGuardian,
|
||||
combineShares,
|
||||
decodeShare,
|
||||
encodeShare,
|
||||
MemoryRecoveryStore,
|
||||
RecoveryDeclinedError,
|
||||
recoveryKeyToBackupPassphrase,
|
||||
requestRecovery,
|
||||
setupRecovery,
|
||||
splitSecret,
|
||||
type GuardianApproveHandler,
|
||||
} from '../src/index.js';
|
||||
import {
|
||||
MemoryRecoveryTransport,
|
||||
spawnShade,
|
||||
startTestPrekeyServer,
|
||||
type TestEnv,
|
||||
} from './helpers.js';
|
||||
|
||||
const cryptoRandom = (n: number): Uint8Array => {
|
||||
const out = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(out);
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('Adversarial — k-1 collusion never recovers', () => {
|
||||
test('property: any (k-1) subset of shares fails to recover the key', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.uint8Array({ minLength: 16, maxLength: 32 }),
|
||||
fc.integer({ min: 2, max: 5 }),
|
||||
fc.integer({ min: 0, max: 3 }),
|
||||
(recoveryKey, k, extra) => {
|
||||
const n = k + extra;
|
||||
if (n > 8) return;
|
||||
const shares = splitSecret(recoveryKey, k, n, cryptoRandom);
|
||||
// Try every (k-1)-sized subset.
|
||||
const indices = Array.from({ length: n }, (_, i) => i);
|
||||
for (const subset of subsets(indices, k - 1)) {
|
||||
const reconstructed = combineShares(subset.map((i) => shares[i]!));
|
||||
// Recovered bytes never equal the secret (with probability
|
||||
// 1 - 1/256^len, vanishingly small for 16+ byte secrets).
|
||||
expect(Array.from(reconstructed)).not.toEqual(Array.from(recoveryKey));
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 30 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Adversarial — guardian decline + forged share', () => {
|
||||
let env: TestEnv;
|
||||
let alice: Shade;
|
||||
let alice2: Shade;
|
||||
let guardians: Shade[];
|
||||
let transport: MemoryRecoveryTransport;
|
||||
const guardianStores = new Map<string, MemoryRecoveryStore>();
|
||||
const detachers: Array<() => void> = [];
|
||||
|
||||
// Per-test approve toggles (so we can flip a guardian to decline mid-suite).
|
||||
const approveOverrides = new Map<string, GuardianApproveHandler>();
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await startTestPrekeyServer();
|
||||
alice = await spawnShade(env.prekeyUrl, 'alice');
|
||||
const guardianAddrs = ['bob', 'carol', 'dan', 'eve', 'faythe'];
|
||||
guardians = await Promise.all(guardianAddrs.map((a) => spawnShade(env.prekeyUrl, a)));
|
||||
alice2 = await spawnShade(env.prekeyUrl, 'alice-new-device');
|
||||
|
||||
transport = new MemoryRecoveryTransport();
|
||||
transport.add(alice);
|
||||
transport.add(alice2);
|
||||
for (const g of guardians) transport.add(g);
|
||||
|
||||
for (const g of guardians) {
|
||||
const store = new MemoryRecoveryStore();
|
||||
guardianStores.set(g.myAddress, store);
|
||||
const attached = attachGuardian({
|
||||
shade: g,
|
||||
store,
|
||||
approve: async (ctx) => {
|
||||
const override = approveOverrides.get(g.myAddress);
|
||||
if (override !== undefined) return override(ctx);
|
||||
return true;
|
||||
},
|
||||
deliver: transport.bind(g),
|
||||
});
|
||||
detachers.push(attached.stop);
|
||||
}
|
||||
|
||||
// Run setup once so all guardians have a deposit.
|
||||
const result = await setupRecovery({
|
||||
shade: alice,
|
||||
guardians: guardians.map((g) => g.myAddress),
|
||||
threshold: 3,
|
||||
deliver: transport.bind(alice),
|
||||
});
|
||||
expect(result.allDelivered).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const d of detachers) d();
|
||||
await alice.shutdown();
|
||||
await alice2.shutdown();
|
||||
for (const g of guardians) await g.shutdown();
|
||||
env.stop();
|
||||
});
|
||||
|
||||
test('declines from 3 guardians push us below threshold', async () => {
|
||||
approveOverrides.set('bob', async () => false);
|
||||
approveOverrides.set('carol', async () => false);
|
||||
approveOverrides.set('dan', async () => false);
|
||||
|
||||
const sample = await guardianStores.get('eve')!.list();
|
||||
const setupId = sample[0]!.setupId;
|
||||
|
||||
await expect(
|
||||
requestRecovery({
|
||||
shade: alice2,
|
||||
originalAddress: 'alice',
|
||||
guardians: guardians.map((g) => g.myAddress),
|
||||
threshold: 3,
|
||||
setupId,
|
||||
deliver: transport.bind(alice2),
|
||||
timeoutMs: 5000,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(RecoveryDeclinedError);
|
||||
|
||||
approveOverrides.clear();
|
||||
});
|
||||
|
||||
test('throwing approve handler counts as decline with descriptive reason', async () => {
|
||||
approveOverrides.set('bob', async () => {
|
||||
throw new Error('user pressed cancel');
|
||||
});
|
||||
approveOverrides.set('carol', async () => {
|
||||
throw new Error('user pressed cancel');
|
||||
});
|
||||
approveOverrides.set('dan', async () => {
|
||||
throw new Error('user pressed cancel');
|
||||
});
|
||||
|
||||
const sample = await guardianStores.get('eve')!.list();
|
||||
const setupId = sample[0]!.setupId;
|
||||
|
||||
try {
|
||||
await requestRecovery({
|
||||
shade: alice2,
|
||||
originalAddress: 'alice',
|
||||
guardians: guardians.map((g) => g.myAddress),
|
||||
threshold: 3,
|
||||
setupId,
|
||||
deliver: transport.bind(alice2),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
throw new Error('expected RecoveryDeclinedError');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(RecoveryDeclinedError);
|
||||
}
|
||||
|
||||
approveOverrides.clear();
|
||||
});
|
||||
|
||||
test('unknown setupId from new device is auto-declined by guardians', async () => {
|
||||
await expect(
|
||||
requestRecovery({
|
||||
shade: alice2,
|
||||
originalAddress: 'alice',
|
||||
guardians: guardians.map((g) => g.myAddress),
|
||||
threshold: 3,
|
||||
setupId: 'fake-setup-id',
|
||||
deliver: transport.bind(alice2),
|
||||
timeoutMs: 5000,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(RecoveryDeclinedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Adversarial — single forged share rejected by AEAD', () => {
|
||||
// We exercise this at the unit level instead of going through the
|
||||
// full Shade-pair wiring, because the entire point is that
|
||||
// requestRecovery's reconstruction loop tries threshold-sized
|
||||
// subsets of grants until one authenticates the backup blob.
|
||||
test('a corrupted share never authenticates against the backup AEAD tag', async () => {
|
||||
const recoveryKey = cryptoRandom(32);
|
||||
// Encrypt some plaintext with this key via the same path Shade uses.
|
||||
const passphrase = recoveryKeyToBackupPassphrase(recoveryKey);
|
||||
expect(passphrase.startsWith('shade-rk:')).toBe(true);
|
||||
|
||||
// Split into 3-of-5.
|
||||
const shares = splitSecret(recoveryKey, 3, 5, cryptoRandom);
|
||||
// Forge share #2 by flipping a high-entropy byte.
|
||||
const forged = { x: shares[1]!.x, y: new Uint8Array(shares[1]!.y) };
|
||||
forged.y[5] = (forged.y[5]! ^ 0xff) & 0xff;
|
||||
const forgedSet = [shares[0]!, forged, shares[2]!];
|
||||
const reconstructed = combineShares(forgedSet);
|
||||
// The reconstructed key MUST differ from the real one.
|
||||
expect(Array.from(reconstructed)).not.toEqual(Array.from(recoveryKey));
|
||||
// Conversely, the honest 3 shares reconstruct exactly.
|
||||
const honest = combineShares([shares[0]!, shares[1]!, shares[2]!]);
|
||||
expect(Array.from(honest)).toEqual(Array.from(recoveryKey));
|
||||
});
|
||||
|
||||
test('encode → tamper → decode preserves x-coordinate but flips y', () => {
|
||||
const share = { x: 7, y: new Uint8Array([1, 2, 3, 4, 5]) };
|
||||
const bytes = encodeShare(share);
|
||||
const tampered = new Uint8Array(bytes);
|
||||
tampered[3] ^= 0x42;
|
||||
const decoded = decodeShare(tampered);
|
||||
expect(decoded.x).toBe(7);
|
||||
expect(decoded.y[2]).not.toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fingerprint-gate enforcement on guardian side', () => {
|
||||
// Verifies V3.10 acceptance criterion #3:
|
||||
// "Guardian-side widget krever fingerprint-bekreftelse før send"
|
||||
//
|
||||
// This test simulates a guardian whose approve callback ONLY returns
|
||||
// true when the requesterFingerprint is what they OOB-confirmed. The
|
||||
// wrong fingerprint → decline.
|
||||
let env: TestEnv;
|
||||
let alice: Shade;
|
||||
let alice2: Shade;
|
||||
let bob: Shade;
|
||||
let transport: MemoryRecoveryTransport;
|
||||
let guardianStore: MemoryRecoveryStore;
|
||||
let detach: (() => void) | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await startTestPrekeyServer();
|
||||
alice = await spawnShade(env.prekeyUrl, 'alice');
|
||||
bob = await spawnShade(env.prekeyUrl, 'bob');
|
||||
alice2 = await spawnShade(env.prekeyUrl, 'alice-new-device');
|
||||
transport = new MemoryRecoveryTransport();
|
||||
transport.add(alice);
|
||||
transport.add(alice2);
|
||||
transport.add(bob);
|
||||
guardianStore = new MemoryRecoveryStore();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (detach !== null) detach();
|
||||
await alice.shutdown();
|
||||
await alice2.shutdown();
|
||||
await bob.shutdown();
|
||||
env.stop();
|
||||
});
|
||||
|
||||
test('approve handler that demands an OOB-correct fingerprint releases share', async () => {
|
||||
const oobConfirmedFingerprint = await alice2.fingerprint;
|
||||
const attached = attachGuardian({
|
||||
shade: bob,
|
||||
store: guardianStore,
|
||||
approve: async (ctx) => {
|
||||
// The user has pre-committed to releasing the share only when
|
||||
// requesterFingerprint matches the value they verified OOB.
|
||||
return ctx.requesterFingerprint === oobConfirmedFingerprint;
|
||||
},
|
||||
deliver: transport.bind(bob),
|
||||
});
|
||||
detach = attached.stop;
|
||||
|
||||
// Setup: bob holds a share for alice (1-of-1 trivially recoverable).
|
||||
const setupResult = await setupRecovery({
|
||||
shade: alice,
|
||||
guardians: [bob.myAddress],
|
||||
threshold: 1,
|
||||
deliver: transport.bind(alice),
|
||||
});
|
||||
expect(setupResult.allDelivered).toBe(true);
|
||||
|
||||
const recovered = await requestRecovery({
|
||||
shade: alice2,
|
||||
originalAddress: 'alice',
|
||||
guardians: [bob.myAddress],
|
||||
threshold: 1,
|
||||
setupId: setupResult.setupId,
|
||||
deliver: transport.bind(alice2),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(recovered.applied).toBe(true);
|
||||
});
|
||||
|
||||
test('approve handler that REJECTS a wrong fingerprint never sends a grant', async () => {
|
||||
// Force the approve to compare against a fingerprint that doesn't match.
|
||||
detach!();
|
||||
const fakeOob = '00000 00000 00000 00000 00000 00000 00000 00000 00000 00000 00000 00000';
|
||||
const attached = attachGuardian({
|
||||
shade: bob,
|
||||
store: guardianStore,
|
||||
approve: async (ctx) => ctx.requesterFingerprint === fakeOob,
|
||||
deliver: transport.bind(bob),
|
||||
});
|
||||
detach = attached.stop;
|
||||
|
||||
// Take the existing setup's setupId (already in store from previous test).
|
||||
const sample = await guardianStore.list();
|
||||
expect(sample.length).toBeGreaterThan(0);
|
||||
const setupId = sample[0]!.setupId;
|
||||
|
||||
await expect(
|
||||
requestRecovery({
|
||||
shade: alice2,
|
||||
originalAddress: 'alice',
|
||||
guardians: [bob.myAddress],
|
||||
threshold: 1,
|
||||
setupId,
|
||||
deliver: transport.bind(alice2),
|
||||
timeoutMs: 3000,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(RecoveryDeclinedError);
|
||||
});
|
||||
});
|
||||
|
||||
function* subsets<T>(items: ReadonlyArray<T>, size: number): IterableIterator<T[]> {
|
||||
if (size <= 0) {
|
||||
yield [];
|
||||
return;
|
||||
}
|
||||
if (items.length < size) return;
|
||||
const indices = new Array<number>(size);
|
||||
for (let i = 0; i < size; i++) indices[i] = i;
|
||||
while (true) {
|
||||
yield indices.map((i) => items[i]!);
|
||||
let i = size - 1;
|
||||
while (i >= 0 && indices[i]! === items.length - size + i) i--;
|
||||
if (i < 0) return;
|
||||
indices[i] = indices[i]! + 1;
|
||||
for (let j = i + 1; j < size; j++) indices[j] = indices[j - 1]! + 1;
|
||||
}
|
||||
}
|
||||
|
||||
104
packages/shade-recovery/tests/helpers.ts
Normal file
104
packages/shade-recovery/tests/helpers.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Test helpers — boot a local prekey server, mint Shade instances, and
|
||||
* pair them with an in-process delivery transport that calls
|
||||
* `shade.receive` on the recipient. Mirrors the pattern other Shade
|
||||
* test suites use, kept private to this package so we don't pull in
|
||||
* `@shade/server` at runtime.
|
||||
*/
|
||||
|
||||
import { createPrekeyServer, MemoryPrekeyStore, PrekeyServerEvents } from '@shade/server';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { createShade, type Shade } from '@shade/sdk';
|
||||
import type { ShadeEnvelope } from '@shade/core';
|
||||
import type { RecoveryDeliver } from '../src/setup.js';
|
||||
|
||||
export interface TestEnv {
|
||||
prekeyUrl: string;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export async function startTestPrekeyServer(): Promise<TestEnv> {
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
const prekey = createPrekeyServer({
|
||||
crypto,
|
||||
store: new MemoryPrekeyStore(),
|
||||
disableRateLimit: true,
|
||||
events: new PrekeyServerEvents(),
|
||||
});
|
||||
const server = Bun.serve({ port: 0, fetch: prekey.fetch });
|
||||
const port = (server as unknown as { port: number }).port;
|
||||
return {
|
||||
prekeyUrl: `http://localhost:${port}`,
|
||||
stop: () => server.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function spawnShade(prekeyUrl: string, address: string): Promise<Shade> {
|
||||
return createShade({ prekeyServer: prekeyUrl, address });
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process transport that delivers `(to, envelope)` to the named
|
||||
* Shade by calling `shade.receive(from, envelope)` on it. Matches the
|
||||
* `RecoveryDeliver` shape so it can be plugged into setup/guardian/
|
||||
* request flows.
|
||||
*
|
||||
* Construction order matters: register every party before delivering
|
||||
* so the lookup never fails. Use `addr` to keep the from-address
|
||||
* symmetric with what `Shade.send` uses.
|
||||
*/
|
||||
export class MemoryRecoveryTransport {
|
||||
private readonly directory = new Map<string, Shade>();
|
||||
/** Per-pair pending-deliveries chain to preserve ordering. */
|
||||
private readonly chains = new Map<string, Promise<unknown>>();
|
||||
/** Counters of how many envelopes flowed in each direction (for tests). */
|
||||
public readonly delivered: Array<{ from: string; to: string }> = [];
|
||||
/**
|
||||
* Optional drop-after-N policy used to simulate guardians that go
|
||||
* unreachable. Keyed on `to` address. Set with `dropAfter(addr, n)`.
|
||||
*/
|
||||
private readonly dropPolicies = new Map<string, number>();
|
||||
|
||||
add(shade: Shade): void {
|
||||
this.directory.set(shade.myAddress, shade);
|
||||
}
|
||||
|
||||
/** Drop envelopes addressed to `to` after the first `n` have flowed. */
|
||||
dropAfter(to: string, n: number): void {
|
||||
this.dropPolicies.set(to, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `RecoveryDeliver` callback bound to `from`. Call this once
|
||||
* per Shade so its outbound sends route through the transport.
|
||||
*/
|
||||
bind(from: Shade): RecoveryDeliver {
|
||||
return async (to: string, envelope: ShadeEnvelope) => {
|
||||
const recipient = this.directory.get(to);
|
||||
if (recipient === undefined) {
|
||||
throw new Error(`MemoryRecoveryTransport: unknown recipient "${to}"`);
|
||||
}
|
||||
// Apply drop policy.
|
||||
const policy = this.dropPolicies.get(to);
|
||||
if (policy !== undefined) {
|
||||
if (policy <= 0) throw new Error(`MemoryRecoveryTransport: dropping for "${to}"`);
|
||||
this.dropPolicies.set(to, policy - 1);
|
||||
}
|
||||
this.delivered.push({ from: from.myAddress, to });
|
||||
// Serialize per (from→to) pair to preserve ordering.
|
||||
const key = `${from.myAddress}→${to}`;
|
||||
const prev = this.chains.get(key) ?? Promise.resolve();
|
||||
const next = prev.then(async () => {
|
||||
await recipient.receive(from.myAddress, envelope);
|
||||
});
|
||||
this.chains.set(
|
||||
key,
|
||||
next.catch(() => {
|
||||
// chain even on failures so subsequent sends don't deadlock
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
await next;
|
||||
};
|
||||
}
|
||||
}
|
||||
122
packages/shade-recovery/tests/integration.test.ts
Normal file
122
packages/shade-recovery/tests/integration.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* End-to-end recovery flow: 5 guardians, threshold 3.
|
||||
*
|
||||
* The test boots six Shade instances (alice + bob/carol/dan/eve/faythe),
|
||||
* runs `setupRecovery` from alice, simulates loss + new device by
|
||||
* spawning `alice2` with a fresh address, then runs `requestRecovery`
|
||||
* from alice2. After the flow alice2's storage holds alice's original
|
||||
* identity.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import type { Shade } from '@shade/sdk';
|
||||
import {
|
||||
attachGuardian,
|
||||
MemoryRecoveryStore,
|
||||
RecoveryDeclinedError,
|
||||
requestRecovery,
|
||||
setupRecovery,
|
||||
} from '../src/index.js';
|
||||
import {
|
||||
MemoryRecoveryTransport,
|
||||
spawnShade,
|
||||
startTestPrekeyServer,
|
||||
type TestEnv,
|
||||
} from './helpers.js';
|
||||
|
||||
describe('Social key recovery — 3-of-5 end-to-end', () => {
|
||||
let env: TestEnv;
|
||||
let alice: Shade;
|
||||
let alice2: Shade; // new device after loss
|
||||
let guardians: Shade[];
|
||||
let transport: MemoryRecoveryTransport;
|
||||
const guardianStores = new Map<string, MemoryRecoveryStore>();
|
||||
const detachers: Array<() => void> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await startTestPrekeyServer();
|
||||
|
||||
alice = await spawnShade(env.prekeyUrl, 'alice');
|
||||
const guardianAddrs = ['bob', 'carol', 'dan', 'eve', 'faythe'];
|
||||
guardians = await Promise.all(guardianAddrs.map((a) => spawnShade(env.prekeyUrl, a)));
|
||||
alice2 = await spawnShade(env.prekeyUrl, 'alice-new-device');
|
||||
|
||||
transport = new MemoryRecoveryTransport();
|
||||
transport.add(alice);
|
||||
transport.add(alice2);
|
||||
for (const g of guardians) transport.add(g);
|
||||
|
||||
// Wire each guardian to auto-approve. We override per-test below
|
||||
// when we need declines.
|
||||
for (const g of guardians) {
|
||||
const store = new MemoryRecoveryStore();
|
||||
guardianStores.set(g.myAddress, store);
|
||||
const attached = attachGuardian({
|
||||
shade: g,
|
||||
store,
|
||||
approve: async () => true,
|
||||
deliver: transport.bind(g),
|
||||
});
|
||||
detachers.push(attached.stop);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const d of detachers) d();
|
||||
await alice.shutdown();
|
||||
await alice2.shutdown();
|
||||
for (const g of guardians) await g.shutdown();
|
||||
env.stop();
|
||||
});
|
||||
|
||||
test('setup distributes shares to all 5 guardians', async () => {
|
||||
const result = await setupRecovery({
|
||||
shade: alice,
|
||||
guardians: guardians.map((g) => g.myAddress),
|
||||
threshold: 3,
|
||||
deliver: transport.bind(alice),
|
||||
});
|
||||
expect(result.threshold).toBe(3);
|
||||
expect(result.guardianCount).toBe(5);
|
||||
expect(result.allDelivered).toBe(true);
|
||||
expect(result.deliveries.length).toBe(5);
|
||||
for (const d of result.deliveries) {
|
||||
expect(d.error).toBeNull();
|
||||
}
|
||||
// Each guardian must have stored its share.
|
||||
// Allow a microtask for the onMessage handler to finish save.
|
||||
await Promise.resolve();
|
||||
for (const g of guardians) {
|
||||
const store = guardianStores.get(g.myAddress)!;
|
||||
const list = await store.list();
|
||||
expect(list.length).toBe(1);
|
||||
expect(list[0]!.originalAddress).toBe('alice');
|
||||
expect(list[0]!.guardianCount).toBe(5);
|
||||
expect(list[0]!.threshold).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
test('recovery from new device with all 5 guardians available', async () => {
|
||||
// Find the setupId from any guardian.
|
||||
const sample = await guardianStores.get('bob')!.list();
|
||||
const setupId = sample[0]!.setupId;
|
||||
const aliceFingerprintBefore = await alice.fingerprint;
|
||||
|
||||
const result = await requestRecovery({
|
||||
shade: alice2,
|
||||
originalAddress: 'alice',
|
||||
guardians: guardians.map((g) => g.myAddress),
|
||||
threshold: 3,
|
||||
setupId,
|
||||
deliver: transport.bind(alice2),
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(result.granted.length).toBeGreaterThanOrEqual(3);
|
||||
expect(result.declined.length).toBe(0);
|
||||
// alice2 now hosts alice's identity → fingerprints match.
|
||||
const recoveredFingerprint = await alice2.fingerprint;
|
||||
expect(recoveredFingerprint).toBe(aliceFingerprintBefore);
|
||||
});
|
||||
});
|
||||
147
packages/shade-recovery/tests/shamir.test.ts
Normal file
147
packages/shade-recovery/tests/shamir.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user