You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(net): support a custom corporate root CA for all outbound connections (#134)
## What / why
Corporate and TLS-inspection networks terminate TLS with a private root
CA that
is not in the public trust store, so every outbound HTTPS connection
Driven
makes fails behind them. This adds an optional **`custom_root_ca_path`**
global
setting (a PEM file that may hold multiple certs) whose certificates are
added
to the system trust store for **all** outbound HTTP clients. Empty/unset
= system
trust only (unchanged behaviour). Implements item 14 ("Corporate CA
pinning") of
the V2 backlog, #34 (which DESIGN s5.8.7 had deferred).
## Trust semantics (the security boundary)
- **Additive, and that is the whole security boundary.** The CA is
*added* on top
of the OS/enterprise native roots via `add_root_certificate`; it never
replaces
them. Source-verified in both reqwest versions in the tree: 0.12
populates one
`RootCertStore` with native + custom roots
(`async_impl/client.rs:687-714`);
0.13 (the updater plugin) uses
`rustls_platform_verifier::Verifier::new_with_extra_roots(extra,
platform)`
(`async_impl/client.rs:756-769`). Because it is purely additive,
*failing to
apply the CA can only make a connection stricter, never weaker* - so the
security boundary is entirely "additive + no verification bypass",
nothing else.
- **No verification bypass anywhere.** No `danger_accept_invalid_certs`,
no
`tls_built_in_root_certs(false)` / `tls_certs_only`, no
hostname-verification
disable. Grep-verified; the one helper crate (`driven-tls`) documents
this as a
locked invariant at the call site.
- **A configured-but-bad PEM fails the client build** (missing /
unreadable /
unparseable / zero certs). This is a UX/correctness choice, not the
security
boundary (additive trust cannot fail *open*): we would rather surface a
broken
corporate-CA config than silently make requests the proxy will reject
anyway.
Save-time validation (below) stops a broken path being persisted in the
first
place. Note the best-effort paths (telemetry ping, Google userinfo, and
the
updater when AppState is unavailable) deliberately *proceed without* on
a load
error / missing state rather than hard-fail - they cannot weaken trust,
so this
is a functionality edge case, not a fail-open.
## The one shared helper + the 10 outbound sites
New leaf crate **`driven-tls`** (depends only on `reqwest` +
`thiserror`) hosts
`apply_custom_ca(builder, &CustomCaConfig)` plus `load_certificates` /
`validate_ca_file`. It is a leaf because `driven-drive` is itself a leaf
that
`driven-core` depends on, so no pre-existing crate could host a helper
reachable
by all HTTP-using crates without a cycle.
The 9 reqwest build sites from the seams audit, each threaded via
constructor/param
following that crate's existing config style:
| # | Site | How it gets the CA |
|---|------|--------------------|
| 1 | captive-portal probe (`driven-net`) | `ReqwestBackend::new(ca)`
stores it; rebuilt on pool teardown |
| 2 | per-service probe (`driven-net`) | same |
| 3 | OAuth refresh client (`driven-drive` token_store) |
`from_stored_refresh_token(.., &ca)` |
| 4 | OAuth PKCE consent exchange (`driven-drive` oauth) |
`run_pkce_loopback_flow(.., &ca)` |
| 5 | Drive metadata client (`driven-drive`) |
`with_default_clients(tokens, &ca)` / `new(.., &ca)` |
| 6 | Drive stream client (`driven-drive`) | same |
| 7 | telemetry sink (`src-tauri`) | `HttpTelemetrySink::new(ca)`,
resolved once at boot |
| 8 | Google userinfo fetch (`src-tauri` accounts) | loaded per
add-account command |
| 9 | GitHub-releases update check (`src-tauri` settings) | loaded per
check/list command |
Plus a **10th** path the seams list did not cover and the advisor
flagged as the
one that matters most in a TLS-inspecting environment:
**tauri-plugin-updater's
own download client** (the signed manifest + binary fetch). The plugin
is on
reqwest **0.13** (workspace + `driven-tls` are 0.12), so it is wired
through the
plugin's `configure_client` hook using an aliased 0.13 `reqwest_updater`
dep
(already in the tree via the plugin) - the fallible PEM parse happens
first in
`run_check` (fail-closed), then the pre-parsed certs are added inside
the
infallible closure.
A hidden-constructor grep (`Client::new()` / `reqwest::get` /
`Client::default()`)
found no other outbound clients.
## Restart vs hot-reload
Applied **when each client is built**. Long-lived clients (Drive
metadata/stream,
OAuth refresh, network probes, telemetry sink) are built once at account
assembly
/ boot, so a CA change takes effect when the account is next assembled -
in
practice an **app restart** (the live `reconfigure_all` path only
re-applies
pacer/gates, it does not rebuild these clients). The per-operation
clients (OAuth
consent, userinfo, releases check, updater) rebuild each call and pick
up the
current setting on next use. The UI caption states "applies to new
connections
after an app restart".
## Settings UI + validation
- `GlobalSettings.custom_root_ca_path: Option<PathBuf>` (storage
`#[serde(default)]`
for back-compat), DTO + patch (double-option) + TS mirrors.
- New `validate_custom_ca(path)` IPC command parses the PEM and returns
the cert
count or a parse error, for inline save-time feedback. `update_settings`
also
validates on save, so a broken path cannot be persisted; a blank path
clears
the setting.
- Rules-tab input with live cert-count / error feedback.
Reviewer note: `validate_custom_ca` (and the build-time re-read) read a
plain,
user-typed **persisted** path rather than going through the app's
dialog-token
confinement model. This is intentional and low-risk: a CA path is an
inherently
user-supplied setting, and the command returns only a cert count / parse
status,
never file contents.
## Tests
- `driven-tls`: unit tests for `apply_custom_ca` / `validate_ca_file` -
valid
single cert, multi-cert bundle, garbage (-> `NoCertificates`), missing
(->
`Read`), corrupt-body PEM, and the `None` no-op. (Additivity itself
rests on the
reqwest source read above - it needs a live handshake to test, so it is
asserted by source + the locked invariant comment, not a unit test.)
- `driven-drive`: fail-closed wiring test (bad CA -> refresh-client
build fails).
- `src-tauri`: `normalize_ca_path`, `storage::Global` serde back-compat
(pre-field
blob -> `None`), and the `validate_custom_ca` command (cert count /
blank /
garbage).
- UI: three Settings.vue vitest cases (valid -> validate+save+count,
invalid ->
no save + error, clear -> null patch).
- Gates: `cargo fmt --check`, `clippy --workspace --all-targets -D
warnings`,
per-crate `cargo test` (driven-tls/net/drive/app), `cargo check
--workspace`;
`pnpm lint` (0 errors), `vue-tsc`, `prettier --check`, `vitest` (265
pass).
Part of #34 (does not close the tracking epic).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
0 commit comments