Files
Shade/docs/trust-ux.md

157 lines
5.4 KiB
Markdown
Raw Permalink Normal View History

# Trust UX — Fingerprint Gates (V3.3)
> Status: shipped in 0.4.0, GA-frozen in 4.0 — see [V3.3 plan](./archive/V3.3.md).
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
```ts
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:
```ts
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:
```tsx
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`:
```ts
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.