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>
217 lines
7.2 KiB
TypeScript
217 lines
7.2 KiB
TypeScript
import type { StorageProvider, PeerVerificationSource } from '@shade/core';
|
|
import { FingerprintNotVerifiedError } from '@shade/core';
|
|
|
|
/**
|
|
* Reason a gate was triggered. Surfaced to the registered handler so apps
|
|
* can render different UI per gate.
|
|
*/
|
|
export type FingerprintGate =
|
|
| 'first-large-file'
|
|
| 'backup-import'
|
|
| 'new-device-trust'
|
|
| 'inbox-fanout';
|
|
|
|
/**
|
|
* Context handed to a fingerprint-gate handler. The handler decides
|
|
* whether the operation may proceed (return `true`) or must be aborted
|
|
* (return `false` or throw).
|
|
*/
|
|
export interface FingerprintGateContext {
|
|
/** Peer address being acted on (own address for `backup-import`). */
|
|
peerAddress: string;
|
|
/** Safety number to display. 60 digits, 12 groups of 5. */
|
|
fingerprint: string;
|
|
/** Which gate fired. */
|
|
gate: FingerprintGate;
|
|
/** For `first-large-file`, the file size in bytes. */
|
|
fileSize?: number;
|
|
}
|
|
|
|
/** Sync or async predicate. `true` allows; `false` (or throw) rejects. */
|
|
export type FingerprintGateHandler = (
|
|
ctx: FingerprintGateContext,
|
|
) => boolean | Promise<boolean>;
|
|
|
|
/**
|
|
* Registry that tracks gate handlers and the persisted verification state
|
|
* for peers. Used internally by `Shade` to wrap critical operations.
|
|
*
|
|
* Decision tree for every gate check:
|
|
* 1. Peer already verified (fingerprint + identityVersion match) → allow.
|
|
* 2. Handler registered → invoke; on `true` mark verified, on `false`
|
|
* throw `FingerprintNotVerifiedError`.
|
|
* 3. No handler registered → log a one-time warning, mark
|
|
* `tofu-after-warning`, and allow.
|
|
*
|
|
* Step 3 satisfies the V3.3 acceptance criterion that apps without
|
|
* registered gates get sane defaults instead of hard-failing.
|
|
*/
|
|
export class FingerprintGateRegistry {
|
|
private firstLargeFile: { threshold: number; handler: FingerprintGateHandler } | null = null;
|
|
private backupImport: FingerprintGateHandler | null = null;
|
|
private newDeviceTrust: FingerprintGateHandler | null = null;
|
|
private inboxFanout: FingerprintGateHandler | null = null;
|
|
private warnedPeers = new Set<string>();
|
|
|
|
constructor(private readonly storage: StorageProvider) {}
|
|
|
|
registerFirstLargeFile(threshold: number, handler: FingerprintGateHandler): void {
|
|
if (!Number.isFinite(threshold) || threshold < 0) {
|
|
throw new Error('beforeFirstLargeFile: threshold must be a non-negative number');
|
|
}
|
|
this.firstLargeFile = { threshold, handler };
|
|
}
|
|
|
|
registerBackupImport(handler: FingerprintGateHandler): void {
|
|
this.backupImport = handler;
|
|
}
|
|
|
|
registerNewDeviceTrust(handler: FingerprintGateHandler): void {
|
|
this.newDeviceTrust = handler;
|
|
}
|
|
|
|
registerInboxFanout(handler: FingerprintGateHandler): void {
|
|
this.inboxFanout = handler;
|
|
}
|
|
|
|
/** Default first-large-file threshold: 10 MiB. */
|
|
static readonly DEFAULT_LARGE_FILE_THRESHOLD = 10 * 1024 * 1024;
|
|
|
|
/** Returns the configured threshold (default 10 MiB if not configured). */
|
|
getFirstLargeFileThreshold(): number {
|
|
return this.firstLargeFile?.threshold ?? FingerprintGateRegistry.DEFAULT_LARGE_FILE_THRESHOLD;
|
|
}
|
|
|
|
/**
|
|
* Check whether the recorded verification for `address` is still valid
|
|
* against `currentFingerprint` and the peer's current identity-version.
|
|
*/
|
|
async isVerified(address: string, currentFingerprint: string): Promise<boolean> {
|
|
const v = await this.storage.getPeerVerification(address);
|
|
if (v === null) return false;
|
|
if (v.fingerprint !== currentFingerprint) return false;
|
|
const currentVersion = await this.storage.getPeerIdentityVersion(address);
|
|
return v.identityVersion === currentVersion;
|
|
}
|
|
|
|
/**
|
|
* Persist that `address` has been verified at `fingerprint`. Called
|
|
* by the SDK after a handler returns `true`, or directly by the app
|
|
* via `Shade.markPeerVerified`.
|
|
*/
|
|
async markVerified(
|
|
address: string,
|
|
fingerprint: string,
|
|
source: PeerVerificationSource = 'user',
|
|
): Promise<void> {
|
|
const identityVersion = await this.storage.getPeerIdentityVersion(address);
|
|
await this.storage.savePeerVerification({
|
|
peerAddress: address,
|
|
fingerprint,
|
|
verifiedAt: Date.now(),
|
|
verifiedBy: source,
|
|
identityVersion,
|
|
});
|
|
}
|
|
|
|
/** Removes any persisted verification for `address`. */
|
|
async revoke(address: string): Promise<void> {
|
|
await this.storage.removePeerVerification(address);
|
|
}
|
|
|
|
/**
|
|
* Run the first-large-file gate. Returns silently when the file is
|
|
* under threshold, when the peer is already verified, or when the
|
|
* handler approves. Throws `FingerprintNotVerifiedError` on rejection.
|
|
*/
|
|
async checkFirstLargeFile(
|
|
address: string,
|
|
fingerprint: string,
|
|
fileSize: number,
|
|
): Promise<void> {
|
|
const threshold = this.getFirstLargeFileThreshold();
|
|
if (fileSize < threshold) return;
|
|
await this.runGate({
|
|
peerAddress: address,
|
|
fingerprint,
|
|
gate: 'first-large-file',
|
|
fileSize,
|
|
}, this.firstLargeFile?.handler ?? null);
|
|
}
|
|
|
|
/**
|
|
* Run the backup-import gate. Always invoked regardless of any
|
|
* configurable threshold — the spec mandates an irremovable minimum
|
|
* gate for backup-import.
|
|
*/
|
|
async checkBackupImport(address: string, fingerprint: string): Promise<void> {
|
|
await this.runGate(
|
|
{ peerAddress: address, fingerprint, gate: 'backup-import' },
|
|
this.backupImport,
|
|
);
|
|
}
|
|
|
|
/** Run the new-device (post-rotation) gate. Always invoked. */
|
|
async checkNewDeviceTrust(address: string, fingerprint: string): Promise<void> {
|
|
await this.runGate(
|
|
{ peerAddress: address, fingerprint, gate: 'new-device-trust' },
|
|
this.newDeviceTrust,
|
|
);
|
|
}
|
|
|
|
/** Run the inbox-fanout gate (V3.6). Always invoked per recipient. */
|
|
async checkInboxFanout(address: string, fingerprint: string): Promise<void> {
|
|
await this.runGate(
|
|
{ peerAddress: address, fingerprint, gate: 'inbox-fanout' },
|
|
this.inboxFanout,
|
|
);
|
|
}
|
|
|
|
private async runGate(
|
|
ctx: FingerprintGateContext,
|
|
handler: FingerprintGateHandler | null,
|
|
): Promise<void> {
|
|
if (await this.isVerified(ctx.peerAddress, ctx.fingerprint)) return;
|
|
|
|
if (handler !== null) {
|
|
let approved: boolean;
|
|
try {
|
|
approved = await handler(ctx);
|
|
} catch (err) {
|
|
throw new FingerprintNotVerifiedError(
|
|
ctx.peerAddress,
|
|
ctx.gate,
|
|
`gate handler threw: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
if (!approved) {
|
|
throw new FingerprintNotVerifiedError(ctx.peerAddress, ctx.gate);
|
|
}
|
|
await this.markVerified(ctx.peerAddress, ctx.fingerprint, 'user');
|
|
return;
|
|
}
|
|
|
|
if (!this.warnedPeers.has(ctx.peerAddress)) {
|
|
this.warnedPeers.add(ctx.peerAddress);
|
|
console.warn(
|
|
`[Shade] gate=${ctx.gate} fired for ${ctx.peerAddress} but no handler is registered — ` +
|
|
`allowing on TOFU. Register Shade.before${gateMethodSuffix(ctx.gate)}() to require explicit verification.`,
|
|
);
|
|
}
|
|
await this.markVerified(ctx.peerAddress, ctx.fingerprint, 'tofu-after-warning');
|
|
}
|
|
}
|
|
|
|
function gateMethodSuffix(gate: FingerprintGate): string {
|
|
switch (gate) {
|
|
case 'first-large-file':
|
|
return 'FirstLargeFile';
|
|
case 'backup-import':
|
|
return 'BackupImport';
|
|
case 'new-device-trust':
|
|
return 'NewDeviceTrust';
|
|
case 'inbox-fanout':
|
|
return 'InboxFanout';
|
|
}
|
|
}
|