# 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.
