From 8c768bf4c60517c34698e4fa94ccaddce5d0b849 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 13:58:58 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA --- docs/plans/audit-2026-07-19-decisions.md | 2 +- docs/plans/http-tofu-proxy.md | 91 ++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 docs/plans/http-tofu-proxy.md diff --git a/docs/plans/audit-2026-07-19-decisions.md b/docs/plans/audit-2026-07-19-decisions.md index 1c4e3dbc..24a6aa90 100644 --- a/docs/plans/audit-2026-07-19-decisions.md +++ b/docs/plans/audit-2026-07-19-decisions.md @@ -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 001–015, 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. | diff --git a/docs/plans/http-tofu-proxy.md b/docs/plans/http-tofu-proxy.md new file mode 100644 index 00000000..88790107 --- /dev/null +++ b/docs/plans/http-tofu-proxy.md @@ -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.