/** * 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(); const detachers: Array<() => void> = []; // Per-test approve toggles (so we can flip a guardian to decline mid-suite). const approveOverrides = new Map(); 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(items: ReadonlyArray, size: number): IterableIterator { if (size <= 0) { yield []; return; } if (items.length < size) return; const indices = new Array(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; } }