157 lines
5.4 KiB
Markdown
157 lines
5.4 KiB
Markdown
|
|
# 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.
|