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

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:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View 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;
}
}