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,149 @@
import { describe, test, expect, beforeEach, mock } from 'bun:test';
import { MemoryStorage } from '@shade/crypto-web';
import { FingerprintNotVerifiedError } from '@shade/core';
import { FingerprintGateRegistry } from '../src/gates.js';
describe('FingerprintGateRegistry — unit', () => {
let storage: MemoryStorage;
let gates: FingerprintGateRegistry;
beforeEach(() => {
storage = new MemoryStorage();
gates = new FingerprintGateRegistry(storage);
});
test('default first-large-file threshold is 10 MiB', () => {
expect(gates.getFirstLargeFileThreshold()).toBe(10 * 1024 * 1024);
});
test('threshold becomes the registered value', () => {
gates.registerFirstLargeFile(2048, () => true);
expect(gates.getFirstLargeFileThreshold()).toBe(2048);
});
test('rejects negative thresholds', () => {
expect(() => gates.registerFirstLargeFile(-1, () => true)).toThrow();
});
test('checkFirstLargeFile is a no-op when size < threshold', async () => {
const handler = mock(() => true);
gates.registerFirstLargeFile(10_000, handler);
await gates.checkFirstLargeFile('bob', 'fp', 1_000);
expect(handler).not.toHaveBeenCalled();
});
test('checkFirstLargeFile invokes handler and approves on true', async () => {
let called = false;
gates.registerFirstLargeFile(10, (ctx) => {
called = true;
expect(ctx.gate).toBe('first-large-file');
expect(ctx.fileSize).toBe(100);
expect(ctx.peerAddress).toBe('bob');
expect(ctx.fingerprint).toBe('FP');
return true;
});
await gates.checkFirstLargeFile('bob', 'FP', 100);
expect(called).toBe(true);
// Subsequent calls: peer is verified, handler not consulted.
let secondCalled = false;
gates.registerFirstLargeFile(10, () => {
secondCalled = true;
return false;
});
await gates.checkFirstLargeFile('bob', 'FP', 100);
expect(secondCalled).toBe(false);
});
test('handler false → throws FingerprintNotVerifiedError', async () => {
gates.registerFirstLargeFile(10, () => false);
await expect(gates.checkFirstLargeFile('bob', 'FP', 100)).rejects.toBeInstanceOf(
FingerprintNotVerifiedError,
);
});
test('handler throw → throws FingerprintNotVerifiedError', async () => {
gates.registerFirstLargeFile(10, () => {
throw new Error('user closed modal');
});
await expect(gates.checkFirstLargeFile('bob', 'FP', 100)).rejects.toBeInstanceOf(
FingerprintNotVerifiedError,
);
});
test('no handler registered → TOFU + warn + persists verification', async () => {
const originalWarn = console.warn;
let warnings = 0;
console.warn = () => {
warnings += 1;
};
try {
// backup-import always fires (no threshold)
await gates.checkBackupImport('bob', 'FP');
} finally {
console.warn = originalWarn;
}
expect(warnings).toBe(1);
// The peer is now considered verified at FP under the
// tofu-after-warning source.
expect(await gates.isVerified('bob', 'FP')).toBe(true);
const v = await storage.getPeerVerification('bob');
expect(v?.verifiedBy).toBe('tofu-after-warning');
});
test('warn fires only once per peer', async () => {
const originalWarn = console.warn;
let warnings = 0;
console.warn = () => {
warnings += 1;
};
try {
await gates.checkBackupImport('bob', 'FP');
await gates.checkBackupImport('bob', 'FP');
await gates.checkBackupImport('bob', 'FP');
} finally {
console.warn = originalWarn;
}
expect(warnings).toBe(1);
});
test('isVerified is fingerprint-sensitive', async () => {
await gates.markVerified('bob', 'FP_OLD');
expect(await gates.isVerified('bob', 'FP_OLD')).toBe(true);
expect(await gates.isVerified('bob', 'FP_NEW')).toBe(false);
});
test('identity-version bump invalidates verification', async () => {
await gates.markVerified('bob', 'FP');
expect(await gates.isVerified('bob', 'FP')).toBe(true);
await storage.bumpPeerIdentityVersion('bob');
expect(await gates.isVerified('bob', 'FP')).toBe(false);
});
test('revoke removes saved verification', async () => {
await gates.markVerified('bob', 'FP');
expect(await gates.isVerified('bob', 'FP')).toBe(true);
await gates.revoke('bob');
expect(await gates.isVerified('bob', 'FP')).toBe(false);
});
test('backup-import and new-device are minimum-gates (no threshold bypass)', async () => {
let backupCalled = false;
let newDeviceCalled = false;
gates.registerBackupImport(() => {
backupCalled = true;
return true;
});
gates.registerNewDeviceTrust(() => {
newDeviceCalled = true;
return true;
});
await gates.checkBackupImport('me', 'FP1');
await gates.checkNewDeviceTrust('bob', 'FP2');
expect(backupCalled).toBe(true);
expect(newDeviceCalled).toBe(true);
});
});