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>
157 lines
5.2 KiB
TypeScript
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();
|