Skip to content

feat(net): support a custom corporate root CA for all outbound connections - #134

Merged
pmaxhogan merged 4 commits into
mainfrom
feat/custom-root-ca
Jul 20, 2026
Merged

pmaxhogan merged 4 commits into
mainfrom
feat/custom-root-ca

Conversation

@pmaxhogan

Copy link
Copy Markdown
Owner

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

Comment thread crates/driven-tls/src/lib.rs Dismissed
Comment thread src-tauri/src/commands/settings.rs Dismissed
Comment thread src-tauri/src/commands/settings.rs Dismissed
Comment thread src-tauri/src/commands/settings.rs Dismissed
Comment thread src-tauri/src/commands/settings.rs Dismissed
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Coverage

Area main this PR delta
Rust (lib crates) 78.74% 78.85% +0.11 (OK)
UI (vue/ts) 89.36% 89.46% +0.10 (OK)

Gate: passed - no coverage regression (epsilon 0.1 pp).

@pmaxhogan
pmaxhogan force-pushed the feat/custom-root-ca branch from af10e25 to a49dfa1 Compare July 20, 2026 17:59
Comment thread src-tauri/src/updater.rs Dismissed
Comment thread src-tauri/src/updater.rs Dismissed
Comment thread src-tauri/src/updater.rs Dismissed
Comment thread src-tauri/src/updater.rs Dismissed
@pmaxhogan
pmaxhogan enabled auto-merge (squash) July 20, 2026 18:24
pmaxhogan and others added 3 commits July 20, 2026 14:18
…tions

Add an optional GlobalSettings.custom_root_ca_path (a PEM bundle) that is
added to the system trust store for every outbound HTTP client in the
workspace. A new leaf crate driven-tls hosts the single shared helper
apply_custom_ca (reqwest 0.12) plus load/validate primitives; it sits below
driven-net, driven-drive and src-tauri with no dependency cycle.

Trust semantics: additive (add_root_certificate on top of the native roots -
source-verified in reqwest 0.12 and 0.13), no verification bypass, and a
missing/unreadable/unparseable/empty PEM fails the client build. Threaded into
all 9 reqwest build sites plus the tauri-plugin-updater download client
(reqwest 0.13) via configure_client.

Settings UI: a CA path input with validate-on-save (cert count / parse error)
via a new validate_custom_ca IPC command.

Part of #34 (item 14, Corporate CA pinning) - references the V2 backlog epic
without closing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZQVP2tUuTLh8oL31D8heC
- normalize_ca_path: use Option::filter (clippy 1.97 manual_filter is deny).
- Add fail-closed / parse coverage in the counted crates (driven-net,
  driven-drive, driven-cli) plus updater + telemetry sink tests so the
  regression-vs-main coverage gate holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZQVP2tUuTLh8oL31D8heC
Bring the regression-vs-main coverage delta to ~neutral.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZQVP2tUuTLh8oL31D8heC
@pmaxhogan
pmaxhogan force-pushed the feat/custom-root-ca branch from a49dfa1 to ce6bf3c Compare July 20, 2026 19:24
@pmaxhogan
pmaxhogan merged commit 929e93d into main Jul 20, 2026
18 checks passed
@pmaxhogan
pmaxhogan deleted the feat/custom-root-ca branch July 20, 2026 20:26
@github-project-automation github-project-automation Bot moved this from Todo to Done in Driven Jul 20, 2026
pmaxhogan added a commit that referenced this pull request Jul 20, 2026
…145)

Adds proxy support to every outbound HTTP client, completing the
corporate-network story started by the custom-CA work (#134) through the
same `driven-tls` seam.

## Modes (KV-stored `global` settings, serde-default - NO migration)
- **system** (default): unchanged - reqwest env-proxy pickup (DESIGN
5.8.7).
- **none**: explicit `.no_proxy()` everywhere - bypasses env proxies
too.
- **manual**: one proxy URL (`http://`, `https://`, `socks5://`,
`socks5h://` - socks5h resolves DNS proxy-side); reqwest `socks` feature
enabled on both workspace reqwest 0.12 and the updater's reqwest 0.13.
- **pac**: PAC file URL or local path, compiled with an embedded
pure-Rust JS engine (boa 0.20), evaluated per-URL via `Proxy::custom`
with a 256-entry per-HOST LRU. Standard helpers implemented
(isPlainHostName, dnsDomainIs, localHostOrDomainIs, dnsDomainLevels,
shExpMatch, dnsResolve, isResolvable, myIpAddress, isInNet); date/time
predicates (weekdayRange/dateRange/timeRange) are defined-but-false
stubs.

## Semantics + documented caveats
- CONFIG failures fail closed at settings-save AND client build (bad
URL, unfetchable/uncompilable PAC): clients are never built silently
unproxied. A corrupt stored mode string degrades to `system` (the
historical default).
- RUNTIME PAC eval errors (e.g. a DNS failure mid-eval) log a warning
and go direct for that request - browser-standard PAC failure behavior.
- The PAC realm is pure ECMAScript (no fetch/fs/process); DNS via the
helpers only. There is deliberately NO JS execution watchdog yet
(admin-configured input; LRU bounds eval count) - future hardening if
untrusted PAC ever becomes possible. DNS helpers use OS-resolver
timeouts.
- Cache keys on host only: a PAC branching on scheme/path gets the
per-host decision (endorsed pragmatic subset).
- Updater: PAC evaluates per-URL there too via a version-neutral engine
handle; CA certs + proxy fold into ONE configure_client closure (the
plugin keeps only the last).
- CLI: env proxy + `DRIVEN_PROXY_URL` (manual) only; PAC unsupported
there.
- deny.toml: `paste` (RUSTSEC-2024-0436, unmaintained build-time
proc-macro via boa) ignored with justification; `lru` at 0.16.3+ (clears
RUSTSEC-2026-0002).
- New IPC `validate_proxy` + Settings UI proxy section (mode select,
conditional inputs, inline validation), localized, vitest mount
coverage.

Refs #34

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01QZQVP2tUuTLh8oL31D8heC
pmaxhogan added a commit that referenced this pull request Jul 24, 2026
🤖 I have created a release *beep* *boop*
---


## [2.1.0](v2.0.1...v2.1.0)
(2026-07-24)


### Features

* **core:** adaptive upload parallelism with throughput probe and
disk-saturation gate
([#143](#143))
([8ecced6](8ecced6))
* **core:** filesystem timestamp-granularity probe with ctime fallback
and per-directory gitignore cascade
([#141](#141))
([344262c](344262c))
* **drive:** support Google Shared Drive destinations end-to-end
([#142](#142))
([d9c3161](d9c3161))
* **net:** native OS reachability backends with automatic fallback
([#138](#138))
([319e85f](319e85f))
* **net:** SOCKS5 and PAC proxy support for all outbound connections
([#145](#145))
([2f0b7d1](2f0b7d1))
* **net:** support a custom corporate root CA for all outbound
connections ([#134](#134))
([929e93d](929e93d))
* per-source toggle to back up OneDrive cloud-only placeholder files
([#133](#133))
([6863ea3](6863ea3))
* **telemetry:** capture latency percentiles and add rollup query
endpoint ([#132](#132))
([4e9fde6](4e9fde6))
* **telemetry:** preview exactly what a telemetry ping sends
([#139](#139))
([95fbd9a](95fbd9a))


### Bug Fixes

* **core:** commit file_state for a create that skipped post-upload so
the next scan updates instead of re-creating
([#146](#146))
([f5230d1](f5230d1))
* **deps:** bump tauri-winrt-notification to drop vulnerable quick-xml
(closes [#89](#89))
([#129](#129))
([232fd8f](232fd8f))
* **telemetry:** exclude pre-schema rows from latency rollup
([#137](#137))
([1ae6220](1ae6220))
* **ui:** add cursor pointer to buttons and link-buttons
([#136](#136))
([dbd4809](dbd4809))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants