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>
5.4 KiB
Trust UX — Fingerprint Gates (V3.3)
Status: shipped in 0.4.0, GA-frozen in 4.0 — see V3.3 plan.
Shade ships with a small number of blocking verification gates that
fire automatically before the operations where MITM risk is highest.
Each gate calls a handler you register on the SDK; until the user (or
your handler) approves, the operation aborts with
FingerprintNotVerifiedError.
The point of the gate model is to be alert-fatigue-free: you don't see a prompt before every chat message, just before the handful of moments that genuinely matter.
What the gates protect
| Gate | Fires when | Default policy |
|---|---|---|
first-large-file |
Shade.upload(...) for an unverified peer with a known size at or above the configured threshold. |
Threshold 10 MiB. Below = no gate. |
backup-import |
Shade.importBackup(...) before any state is written. Handler receives the fingerprint of the identity embedded in the backup. |
Always fires. |
new-device-trust |
Shade.acceptIdentityChange(...) after a peer rotates identity. The peer's identity_version is bumped first so any prior verification is automatically stale. |
Always fires. |
inbox-fanout |
Reserved for V3.6 (@shade/inbox). Per-recipient hook is wired today so apps can register it now. |
Always fires. |
Registering handlers
const shade = await createShade({
prekeyServer: 'https://prekeys.example.com',
storage: 'sqlite:/data/shade.db',
});
shade.beforeFirstLargeFile(10 * 1024 * 1024, async (ctx) => {
// ctx.peerAddress, ctx.fingerprint, ctx.fileSize
return await ui.confirmFingerprintModal(ctx);
});
shade.beforeBackupImport(async (ctx) => {
// ctx.fingerprint = fingerprint of the identity in the backup blob
return await ui.confirmBackupOwner(ctx);
});
shade.beforeNewDeviceTrust(async (ctx) => {
// ctx.fingerprint = fingerprint of the rotated identity
return await ui.confirmDeviceRotation(ctx);
});
Return true to allow the operation and persist a 'user' verification.
Return false (or throw) to abort with FingerprintNotVerifiedError.
If you don't register a handler, the gate logs a one-time warning per
peer and proceeds on TOFU, persisting a 'tofu-after-warning'
verification. This satisfies the V3.3 acceptance criterion that apps
without registered gates get sane defaults instead of hard-failing — but
it does mean the gate is informational, not a hard wall, in that
configuration. Always register handlers in production.
Manual verification
The handler model assumes your app drives the OOB compare/confirm flow. If the user verifies through some other path (QR code scan, audio read-aloud, transitive trust from V3.10), call:
await shade.markPeerVerified('bob'); // pin current fingerprint
await shade.unmarkPeerVerified('bob'); // revoke
const ok = await shade.isPeerVerified('bob'); // check status
markPeerVerified reads the peer's current fingerprint and pins it
together with the per-peer identity_version. When the peer rotates
(acceptIdentityChange), the version bumps and the saved verification
goes stale automatically — isPeerVerified will return false until
the user re-verifies.
Tuning thresholds
The first-large-file threshold is the only knob that's customer-tunable
without code changes. The defaults are conservative:
- Default:
10 MiB. Big enough that ordinary chat attachments don't trigger; small enough that obvious "exfil candidates" do. - Lower (e.g.
1 MiB) for high-sensitivity deployments — every document goes through the gate. - Raise (e.g.
100 MiB) only for use cases where small uploads are routine and large transfers are deliberate / pre-arranged.
backup-import and new-device-trust have no threshold by design — the
spec mandates an irremovable minimum gate for both, since each one
either trusts a fresh identity or overwrites pinned trust wholesale.
React widget
Use <FingerprintGate /> from @shade/widgets to block UI on
verification status:
import { FingerprintGate } from '@shade/widgets';
<FingerprintGate peerAddress="bob">
<ChatThread peer="bob" />
</FingerprintGate>
The default fallback shows the safety number, a "Copy OOB text" button,
and an "I have verified" button that calls Shade.markPeerVerified.
Pass a fallback render prop to use your own UI, or onVerified to
react to the unverified → verified transition.
<FingerprintCompare /> is the existing observer-dashboard widget; it
now exposes the same Copy-OOB / verify actions when an onVerified
prop is wired.
Errors
FingerprintNotVerifiedError carries:
peerAddress— the address the gate was protecting.gate—'first-large-file' | 'backup-import' | 'new-device-trust' | 'inbox-fanout'.code = 'SHADE_FINGERPRINT_NOT_VERIFIED'— maps to HTTP 403.
Catch it explicitly when wrapping upload, importBackup, and
acceptIdentityChange:
try {
await shade.upload({ to: 'bob', input: bytes });
} catch (err) {
if (err instanceof FingerprintNotVerifiedError) {
showVerifyFirst(err.peerAddress);
return;
}
throw err;
}
Migration from 0.3.x
No breaking changes: existing apps gain warning-mode gates automatically
(see the no-handler note above). To upgrade to hard gates, register
handlers for the operations you use. Your existing FingerprintCompare
calls keep working; pass onVerified to enable the new actions.