docs: add design note for the client HTTP TOFU proxy (D5)

Pins the implementation approach for closing audit finding A-2026-07-02:
a loopback TCP-to-TLS tunnel reusing the livekit_proxy pattern and the
shared per-host fingerprint store, with ws_proxy-equivalent TOFU
first-trust/mismatch flows (required because the first TLS contact with
a server is the login HTTP request), per-host tunnel lifecycle for
multi-profile health polling, and removal of the acceptInvalidCerts
path plus the dangerous-settings feature flag as the ratchet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
This commit is contained in:
Claude
2026-07-19 13:58:58 +00:00
parent bf508c9e6b
commit 8c768bf4c6
2 changed files with 92 additions and 1 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ here (and the audit's closure table) as items land.
| D2 | Data-layer direction (raw SQL vs dead sqlc `db/dbgen` vs `store.Store`) | A-2026-07-05 / A-2026-07-06 | **Adopt sqlc for real**: wire `db.DB` method bodies to the generated `dbgen` queries so sqlc becomes the actual, type-checked query layer. The `sqlc-verify` CI job stays and starts earning its keep. | Planned |
| D3 | Fate of `Server/store/` (untested abstraction seam) | prior audit #6 | **Remove `store/`**: execute the prior audit's P4 "single data layer" direction. Services call the (sqlc-backed) `db` package directly; tests use in-memory SQLite instead of `MemStore`. | Planned (sequence with/after D2) |
| D4 | Protocol constants sync (`message_types.go` / `protocolTypes.ts` claim a nonexistent `docs/protocol-schema.json`) | A-2026-07-08 | **Create real codegen**: commit an actual `protocol-schema.json` plus a generator that emits the Go and TS constant files (and, ideally, protocol.md's message table), making the "single source of truth" comment true. | **Implemented 2026-07-19**: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-generate`/`protocol-verify` + CI gate. protocol.md table generation deferred to D7. |
| D5 | Client HTTP TLS gap (`allowSelfSigned: true`, no TOFU pinning on the REST path) | A-2026-07-02 | **Next security work**: build the TOFU HTTP proxy in Rust (mirroring `ws_proxy.rs`) as the next security task — highest-priority security item. | Planned |
| D5 | Client HTTP TLS gap (`allowSelfSigned: true`, no TOFU pinning on the REST path) | A-2026-07-02 | **Next security work**: build the TOFU HTTP proxy in Rust (mirroring `ws_proxy.rs`) as the next security task — highest-priority security item. | Design ready 2026-07-19 — see [http-tofu-proxy.md](http-tofu-proxy.md); implementation is the next work unit |
| D6 | Abandoned SolidJS beachhead + stale `docs/client-architecture.md` | A-2026-07-12 | **Delete it all**: remove `src/components/solid/`, `solidMount`/`solidAdapter`, `vite-plugin-solid`, and Solid test deps; retire `client-architecture.md` in favor of [docs/architecture/client.md](../architecture/client.md). | **Implemented 2026-07-19** — solid/ dir, solidMount/solidAdapter, setup-solid tests, vite-plugin-solid, jsx tsconfig settings, and solid-js/@solidjs deps all removed; client-architecture.md is now a pointer. |
| D7 | Spec refresh strategy for api.md / protocol.md / schema.md | A-2026-07-03 | **One refresh PR first**, using the audit's §2 conformance matrix as the checklist; afterwards specs are kept current per-PR (see the maintenance rule in [docs/architecture/README.md](../architecture/README.md)). Announcement channels (D1) later update the *fresh* specs. | **Implemented 2026-07-19** — all three specs refreshed against the code (incl. E2EE protocol section, migrations 001015, profile/blocks/plugin-admin endpoints); reference tables now point at `protocol-schema.json`. |
| D8 | What to implement first | backlog §6 | **Greenlit now: Protocol codegen (D4) + the quick-wins batch**`LogAudit` error handling (`admin/handlers_backup.go`), contradictory upload `Cache-Control` (`upload_handler.go`), hub inline settings SQL through the data layer (`ws/hub.go`), Hub constructor cleanup (required collaborators into `NewHub`). | **Implemented 2026-07-19** (all four quick wins + D4). Hub cleanup shipped as: race fix — `eventPersister`/`eventStore`/`pluginSink` are now atomic (they were plain fields written by `main.go` after `NewRouter` had already started `Run`); remaining pre-Run setters now reject late calls with an error log instead of racing silently. Note discovered during the work: the discarded-`LogAudit` pattern is repo-wide (23 call sites) — the two tracker-flagged backup handlers are fixed; whether best-effort audit writes stay the convention elsewhere needs a policy decision. |
+91
View File
@@ -0,0 +1,91 @@
# Client HTTP TOFU Proxy (D5) — Design
**Status:** design only, not implemented
**Decision:** D5 in [audit-2026-07-19-decisions.md](audit-2026-07-19-decisions.md) — "next security work"
**Closes:** audit finding A-2026-07-02 (client HTTP path accepts any TLS certificate)
## Problem
Every REST call from the client uses `tauri-plugin-http` with
`danger: { acceptInvalidCerts: true }` (`allowSelfSigned` hardcoded in
`src/main.ts`), and the bearer token rides on every request. The WS path
(`ws_proxy.rs`) and the LiveKit path (`livekit_proxy.rs`) pin a
trust-on-first-use SHA-256 certificate fingerprint per host; the HTTP path is
the only unpinned transport. An active MITM can capture session tokens without
ever triggering the cert-mismatch UI.
## Approach — loopback TCP→TLS tunnel (reuse the LiveKit proxy pattern)
Add `src-tauri/src/http_proxy.rs`, structurally a sibling of
`livekit_proxy.rs`: a plain TCP listener on `127.0.0.1:{ephemeral}` that
byte-shovels to `https://{host}:{port}` over rustls with a pinned-fingerprint
verifier. The webview then talks **plain HTTP to loopback**, and all TLS trust
decisions live in Rust:
- `livekit_proxy.rs` already contains the two building blocks to extract into
a shared module (`tls_tunnel.rs`): the loopback `TcpListener` accept loop
and the `PinnedCertVerifier` (SHA-256 colon-hex fingerprint check,
`livekit_proxy.rs` ~line 79).
- HTTP/1.1 keep-alive works transparently over a byte tunnel. The `Host`
header sent by the webview must be rewritten? **No** — configure the API
client to send the real host in `Host` (tauri-plugin-http keeps the URL's
host; since the URL is `http://127.0.0.1:{port}`, inject a `Host: {real}`
header explicitly, or terminate HTTP in the proxy — see "Two variants").
TLS SNI is handled by the tunnel (it dials by hostname).
### Two variants, pick at implementation time
1. **Pure byte tunnel** (smallest): identical to livekit_proxy. Requires the
TS client to set `Host` explicitly per request (tauri-plugin-http allows
custom headers; verify it doesn't override `Host` — if it does, fall back
to variant 2).
2. **Minimal HTTP-aware proxy**: parse only the request line + headers,
rewrite `Host`, then stream bodies both ways. More code, but removes the
header caveat and allows per-request logging. Still no TLS termination in
the webview.
## TOFU semantics (must match ws_proxy)
- **Pin store:** the same per-host fingerprint store used by `ws_proxy.rs`
(`certs.json` via `commands.rs`); one fingerprint per host covers all three
transports.
- **First contact:** unlike today, the *first* TLS contact with a server is
the login HTTP request, not the WS connect. The HTTP proxy must therefore
implement the same first-trust flow as `ws_proxy.rs`: unknown host →
accept, store fingerprint, emit `cert-tofu` event (banner); known host +
mismatch → refuse the connection and emit the mismatch event
(`CertMismatchModal` flow, reusing `accept_cert_fingerprint`,
`ws_proxy.rs` ~line 419).
- **Rotation:** accepting a new fingerprint in the modal must apply to all
three transports at once (single store already guarantees this).
## Lifecycle & wiring
- Commands: `http_proxy_start(host, port) -> u16` (idempotent per host,
returns loopback port), `http_proxy_stop(host)`. One tunnel per host —
the Connect page's multi-profile health polling (15s) starts tunnels on
demand for each profile it polls; quick-switch stops the old host's tunnel.
- TS changes: `createApiClient` gains a `baseUrl` of
`http://127.0.0.1:{port}` resolved via the proxy; delete the
`allowSelfSigned` flag and the `danger:` fetch options entirely. The
`dangerous-settings` feature flag on tauri-plugin-http can then be dropped
from `src-tauri/Cargo.toml` — build fails if any dangling
`acceptInvalidCerts` remains, which is the desired ratchet.
- The self-hosted updater (`update_commands.rs`) already pins TLS itself —
unchanged.
- CSP already allows localhost connections (`tauri.conf.json`).
## Testing
- Rust: unit tests for the verifier (match/mismatch/unknown-host TOFU), and
an integration test dialing a local TLS listener with a self-signed cert
(mirror `ws_proxy.rs`'s existing test style).
- TS: api tests swap to the loopback base URL; add a regression test that no
code path passes `acceptInvalidCerts`.
- Manual: first connect (banner), cert rotation (modal), multi-profile health
polling, large upload/download streaming through the tunnel.
## Non-goals
- No system-proxy support changes, no HTTP/2 (server is HTTP/1.1 via chi),
no change to the WS or LiveKit proxies.