Files
Shade/scripts/soak.ts
Sterister e6fdf31b49
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
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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>
2026-05-03 18:35:35 +02:00

157 lines
5.2 KiB
TypeScript

#!/usr/bin/env bun
/**
* Shade 4.0 GA — combined soak harness.
*
* Runs the ratchet stack under sustained ping-pong load for a configurable
* duration. Designed to be wrapped in `systemd-run` / `nohup` / a Gitea
* scheduled job and left running for ≥ 2 weeks before tagging GA-stable.
*
* Usage:
* bun run scripts/soak.ts # default 1h
* bun run scripts/soak.ts --hours 168 # 1 week
* bun run scripts/soak.ts --hours 336 # 2 weeks (V4.0 §Soak)
* bun run scripts/soak.ts --hours 0.05 # 3 minute smoke (CI gate)
*/
import { ShadeSessionManager } from '../packages/shade-core/src/index.js';
import { SubtleCryptoProvider, MemoryStorage } from '../packages/shade-crypto-web/src/index.js';
interface Args {
hours: number;
pairs: number;
reportSeconds: number;
}
function parseArgs(argv: string[]): Args {
const out: Args = { hours: 1, pairs: 4, reportSeconds: 60 };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--hours') out.hours = parseFloat(argv[++i]!);
else if (a === '--pairs') out.pairs = parseInt(argv[++i]!, 10);
else if (a === '--report-seconds') out.reportSeconds = parseInt(argv[++i]!, 10);
else if (a === '--help') {
console.log('Usage: bun run scripts/soak.ts [--hours N] [--pairs N] [--report-seconds N]');
process.exit(0);
}
}
return out;
}
interface Counters {
ratchetSent: number;
ratchetRecv: number;
ratchetErrs: number;
errors: { workload: string; message: string; ts: number }[];
}
function newCounters(): Counters {
return {
ratchetSent: 0,
ratchetRecv: 0,
ratchetErrs: 0,
errors: [],
};
}
interface Pair {
id: number;
aliceMgr: ShadeSessionManager;
bobMgr: ShadeSessionManager;
aliceAddr: string;
bobAddr: string;
}
async function setupPair(id: number): Promise<Pair> {
const crypto = new SubtleCryptoProvider();
const aliceMgr = new ShadeSessionManager(crypto, new MemoryStorage());
const bobMgr = new ShadeSessionManager(crypto, new MemoryStorage());
await aliceMgr.initialize();
await bobMgr.initialize();
const otpks = await bobMgr.generateOneTimePreKeys(5);
const bundle = await bobMgr.createPreKeyBundle();
bundle.oneTimePreKey = { keyId: otpks[0]!.keyId, publicKey: otpks[0]!.keyPair.publicKey };
const aliceAddr = `alice-${id}`;
const bobAddr = `bob-${id}`;
await aliceMgr.initSessionFromBundle(bobAddr, bundle);
// Establish the receiver session by sending a first message and decrypting.
const init = await aliceMgr.encrypt(bobAddr, 'init');
await bobMgr.decrypt(aliceAddr, init);
return { id, aliceMgr, bobMgr, aliceAddr, bobAddr };
}
async function ratchetPingPong(pair: Pair, counters: Counters, abort: AbortSignal) {
const { aliceMgr, bobMgr, aliceAddr, bobAddr } = pair;
let i = 0;
while (!abort.aborted) {
try {
const env = await aliceMgr.encrypt(bobAddr, `msg-${i++}`);
counters.ratchetSent++;
await bobMgr.decrypt(aliceAddr, env);
counters.ratchetRecv++;
const reply = await bobMgr.encrypt(aliceAddr, `re-${i}`);
counters.ratchetSent++;
await aliceMgr.decrypt(bobAddr, reply);
counters.ratchetRecv++;
if ((i & 0xff) === 0) await new Promise((r) => setTimeout(r, 0));
} catch (err) {
counters.ratchetErrs++;
counters.errors.push({
workload: `pair-${pair.id}`,
message: (err as Error).message,
ts: Date.now(),
});
if (counters.ratchetErrs > 8) break;
}
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const totalMs = Math.max(60_000, Math.round(args.hours * 3_600_000));
const ac = new AbortController();
const counters = newCounters();
const start = Date.now();
console.log(`[soak] start — ${args.pairs} pairs, ${args.hours}h, report every ${args.reportSeconds}s`);
const pairs = await Promise.all(Array.from({ length: args.pairs }, (_, i) => setupPair(i)));
setTimeout(() => ac.abort(), totalMs).unref();
process.on('SIGINT', () => ac.abort());
process.on('SIGTERM', () => ac.abort());
const reporter = setInterval(() => {
const elapsedS = ((Date.now() - start) / 1000).toFixed(0);
const rps = (counters.ratchetSent / Math.max(1, (Date.now() - start) / 1000)).toFixed(1);
console.log(
`[soak] t=${elapsedS}s sent=${counters.ratchetSent} recv=${counters.ratchetRecv} errs=${counters.ratchetErrs} (${rps} sent/s)`,
);
if (counters.errors.length > 32) counters.errors.splice(0, counters.errors.length - 32);
}, args.reportSeconds * 1000);
reporter.unref();
await Promise.all(pairs.map((p) => ratchetPingPong(p, counters, ac.signal)));
clearInterval(reporter);
const elapsedS = ((Date.now() - start) / 1000).toFixed(0);
console.log(`\n[soak] done in ${elapsedS}s`);
console.log(` ratchet sent ${counters.ratchetSent}`);
console.log(` ratchet recv ${counters.ratchetRecv}`);
console.log(` ratchet errs ${counters.ratchetErrs}`);
if (counters.ratchetErrs > 0) {
console.error('\nFAIL: ratchet errors recorded:');
for (const e of counters.errors.slice(-10)) {
console.error(` [${new Date(e.ts).toISOString()}] ${e.workload}: ${e.message}`);
}
process.exit(1);
}
process.exit(0);
}
void main();