Files
Shade/docs/trust-ux.md
Sterister e6fdf31b49
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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>
2026-05-03 18:35:35 +02:00

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.