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; /** * 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(); 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 { 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 { 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 { 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 { 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 { 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 { 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 { await this.runGate( { peerAddress: address, fingerprint, gate: 'inbox-fanout' }, this.inboxFanout, ); } private async runGate( ctx: FingerprintGateContext, handler: FingerprintGateHandler | null, ): Promise { 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'; } }