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
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:
216
packages/shade-sdk/src/gates.ts
Normal file
216
packages/shade-sdk/src/gates.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user