Files
Shade/packages/shade-sdk/src/gates.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

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