Skip to content

Commit af10e25

Browse files
pmaxhoganclaude
andcommitted
feat(net): support a custom corporate root CA for all outbound connections
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
1 parent de87a34 commit af10e25

29 files changed

Lines changed: 1092 additions & 105 deletions

Cargo.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ members = [
88
"crates/driven-vss",
99
"crates/driven-vss-helper",
1010
"crates/driven-net",
11+
"crates/driven-tls",
1112
"crates/driven-cli",
1213
"crates/driven-test-fixtures",
1314
"crates/driven-chaos",

crates/driven-cli/src/main.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ use driven_drive::google::oauth::{run_pkce_loopback_flow, OAuthProgress};
3333
use driven_drive::google::token_store::{KeyringTokenStore, RefreshingTokenSource};
3434
use driven_drive::google::{md5_hex, parse_installed_client_config, GoogleDriveStore, UploadBytes};
3535
use driven_drive::remote_store::{RemoteStore, UploadBody};
36+
use driven_drive::CustomCaConfig;
3637

3738
/// The public installed-app client id (SPEC s4; M4 brief). Used when neither
3839
/// `--client-id`, the env var, nor `client_secret.json` supplies one.
@@ -265,6 +266,7 @@ async fn run_auth(args: AuthArgs) -> anyhow::Result<()> {
265266
&creds.client_secret,
266267
open_system_browser,
267268
tx,
269+
&cli_custom_ca(),
268270
)
269271
.await?;
270272

@@ -399,13 +401,26 @@ fn build_store(account: &str, creds: &ClientCreds) -> anyhow::Result<GoogleDrive
399401
"no refresh token stored for account '{account}'; run `driven-cli auth --account {account}` first"
400402
)
401403
})?;
404+
let ca = cli_custom_ca();
402405
let token_source = RefreshingTokenSource::from_stored_refresh_token(
403406
refresh_token,
404407
creds.client_id.clone(),
405408
creds.client_secret.clone(),
409+
&ca,
406410
)?
407411
.with_store(store);
408-
GoogleDriveStore::with_default_clients(token_source)
412+
GoogleDriveStore::with_default_clients(token_source, &ca)
413+
}
414+
415+
/// Issue #34: the dev/e2e CLI reads its custom root CA (if any) from the
416+
/// `DRIVEN_CUSTOM_CA_PATH` env var so it can run behind the same corporate
417+
/// TLS-inspecting proxy the desktop app supports via its settings. Unset =
418+
/// system trust only (unchanged behaviour).
419+
fn cli_custom_ca() -> CustomCaConfig {
420+
match std::env::var_os("DRIVEN_CUSTOM_CA_PATH") {
421+
Some(v) if !v.is_empty() => CustomCaConfig::from_path(Some(PathBuf::from(v))),
422+
_ => CustomCaConfig::none(),
423+
}
409424
}
410425

411426
#[cfg(test)]

crates/driven-drive/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ uuid.workspace = true
2929
oauth2.workspace = true
3030
keyring.workspace = true
3131
reqwest.workspace = true
32+
# Issue #34 corporate CA pinning: threads a user-configured custom root CA into
33+
# the Drive metadata/stream clients, the OAuth refresh client, and the PKCE
34+
# consent-exchange client (additive; see driven-tls).
35+
driven-tls = { path = "../driven-tls" }
3236
url.workspace = true
3337
hex.workspace = true
3438
# Direct deps (not yet in workspace.dependencies). parking_lot for an

crates/driven-drive/src/google/mod.rs

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ use std::time::Duration;
4949

5050
use async_trait::async_trait;
5151
use bytes::Bytes;
52+
use driven_tls::CustomCaConfig;
5253
use futures::Stream;
5354
use serde::Deserialize;
5455
use tokio::io::{AsyncRead, ReadBuf};
@@ -532,12 +533,14 @@ impl GoogleDriveStore {
532533
/// flows over (the caller builds it with the DESIGN s5.8.4 metadata
533534
/// timeouts). A second, no-overall-timeout streaming client is derived
534535
/// for the resumable chunk PUT + download paths.
535-
pub fn new(http: reqwest::Client, tokens: RefreshingTokenSource) -> Self {
536+
pub fn new(http: reqwest::Client, tokens: RefreshingTokenSource, ca: &CustomCaConfig) -> Self {
536537
let _ = TARGET;
537-
// The streaming client mirrors `http`'s TLS/proxy config but drops
538-
// the overall request cap; if the dedicated build fails we fall back
539-
// to the provided client (correctness over a missing idle timeout).
540-
let http_stream = build_stream_client().unwrap_or_else(|e| {
538+
// The streaming client mirrors `http`'s TLS/proxy config (including the
539+
// issue #34 custom root CA via `ca`) but drops the overall request cap;
540+
// if the dedicated build fails we fall back to the provided client
541+
// (correctness over a missing idle timeout - `http` already carries the
542+
// same additive CA trust).
543+
let http_stream = build_stream_client(ca).unwrap_or_else(|e| {
541544
warn!(
542545
target: TARGET,
543546
error = %e,
@@ -557,9 +560,12 @@ impl GoogleDriveStore {
557560
/// clients (DESIGN s5.8.4 timeouts) from a [`RefreshingTokenSource`].
558561
/// Convenience for the CLI / e2e paths that do not already hold a tuned
559562
/// client.
560-
pub fn with_default_clients(tokens: RefreshingTokenSource) -> anyhow::Result<Self> {
561-
let http = build_meta_client()?;
562-
let http_stream = build_stream_client()?;
563+
pub fn with_default_clients(
564+
tokens: RefreshingTokenSource,
565+
ca: &CustomCaConfig,
566+
) -> anyhow::Result<Self> {
567+
let http = build_meta_client(ca)?;
568+
let http_stream = build_stream_client(ca)?;
563569
Ok(Self {
564570
http,
565571
http_stream,
@@ -1621,26 +1627,31 @@ impl GoogleDriveStore {
16211627
// Free helpers.
16221628
// ---------------------------------------------------------------------------
16231629

1624-
/// Builds the metadata Drive client with the DESIGN s5.8.4 timeouts.
1625-
pub(crate) fn build_meta_client() -> anyhow::Result<reqwest::Client> {
1626-
reqwest::Client::builder()
1630+
/// Builds the metadata Drive client with the DESIGN s5.8.4 timeouts. `ca` adds
1631+
/// the user's custom root CA (issue #34) additively; fail-closed if it cannot be
1632+
/// loaded.
1633+
pub(crate) fn build_meta_client(ca: &CustomCaConfig) -> anyhow::Result<reqwest::Client> {
1634+
let builder = reqwest::Client::builder()
16271635
.connect_timeout(CONNECT_TIMEOUT)
16281636
.timeout(META_TOTAL_TIMEOUT)
16291637
.read_timeout(STREAM_IDLE_TIMEOUT)
16301638
.pool_max_idle_per_host(4)
1631-
.pool_idle_timeout(Duration::from_secs(90))
1639+
.pool_idle_timeout(Duration::from_secs(90));
1640+
driven_tls::apply_custom_ca(builder, ca)?
16321641
.build()
16331642
.map_err(|e| anyhow::anyhow!("drive: failed to build metadata client: {e}"))
16341643
}
16351644

16361645
/// Builds the streaming Drive client (resumable chunk PUT + download): no
16371646
/// overall request cap, only the per-byte idle timeout (DESIGN s5.8.4 `*`).
1638-
pub(crate) fn build_stream_client() -> anyhow::Result<reqwest::Client> {
1639-
reqwest::Client::builder()
1647+
/// `ca` adds the user's custom root CA (issue #34) additively; fail-closed.
1648+
pub(crate) fn build_stream_client(ca: &CustomCaConfig) -> anyhow::Result<reqwest::Client> {
1649+
let builder = reqwest::Client::builder()
16401650
.connect_timeout(CONNECT_TIMEOUT)
16411651
.read_timeout(STREAM_IDLE_TIMEOUT)
16421652
.pool_max_idle_per_host(4)
1643-
.pool_idle_timeout(Duration::from_secs(90))
1653+
.pool_idle_timeout(Duration::from_secs(90));
1654+
driven_tls::apply_custom_ca(builder, ca)?
16441655
.build()
16451656
.map_err(|e| anyhow::anyhow!("drive: failed to build streaming client: {e}"))
16461657
}

crates/driven-drive/src/google/oauth.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
2525
use std::time::Duration;
2626

27+
use driven_tls::CustomCaConfig;
2728
use oauth2::basic::BasicClient;
2829
use oauth2::{
2930
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl,
@@ -116,6 +117,7 @@ pub async fn run_pkce_loopback_flow(
116117
client_secret: &str,
117118
open_browser: impl FnOnce(&str) -> anyhow::Result<()>,
118119
progress_tx: Sender<OAuthProgress>,
120+
ca: &CustomCaConfig,
119121
) -> anyhow::Result<Tokens> {
120122
let (listener_v4, listener_v6, port) = bind_dual_loopback().await?;
121123
// The redirect URI we register with Google MUST be one literal string and
@@ -130,11 +132,13 @@ pub async fn run_pkce_loopback_flow(
130132
// SPEC s4 / oauth2 v5 upgrade notes) with bounded connect/total timeouts
131133
// (DESIGN s5.8.4; codex V-A1) so a hung token endpoint cannot stall the
132134
// code exchange indefinitely.
133-
let http = reqwest::Client::builder()
135+
let http_builder = reqwest::Client::builder()
134136
.redirect(reqwest::redirect::Policy::none())
135137
.connect_timeout(EXCHANGE_CONNECT_TIMEOUT)
136-
.timeout(EXCHANGE_TOTAL_TIMEOUT)
137-
.build()?;
138+
.timeout(EXCHANGE_TOTAL_TIMEOUT);
139+
// Issue #34: add the user's custom root CA additively (fail-closed) so the
140+
// token exchange works behind a corporate TLS-inspecting proxy.
141+
let http = driven_tls::apply_custom_ca(http_builder, ca)?.build()?;
138142

139143
let client = BasicClient::new(ClientId::new(client_id.to_string()))
140144
.set_client_secret(ClientSecret::new(client_secret.to_string()))

crates/driven-drive/src/google/token_store.rs

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
use std::sync::Arc;
3535
use std::time::Duration;
3636

37+
use driven_tls::CustomCaConfig;
3738
use keyring::Entry;
3839
use serde::Deserialize;
3940
use tokio::sync::Mutex;
@@ -324,8 +325,9 @@ impl RefreshingTokenSource {
324325
refresh_token: impl Into<String>,
325326
client_id: impl Into<String>,
326327
client_secret: impl Into<String>,
328+
ca: &CustomCaConfig,
327329
) -> anyhow::Result<Self> {
328-
let http = build_refresh_client()?;
330+
let http = build_refresh_client(ca)?;
329331
let tokens = Tokens {
330332
access_token: String::new(),
331333
refresh_token: refresh_token.into(),
@@ -473,11 +475,15 @@ fn now_unix() -> i64 {
473475
/// time keeps a hung token endpoint from wedging every Drive request (the
474476
/// refresh holds the token mutex across the await); disabling redirects keeps
475477
/// the credential-bearing client from being steered to an attacker endpoint.
476-
fn build_refresh_client() -> anyhow::Result<reqwest::Client> {
477-
reqwest::Client::builder()
478+
fn build_refresh_client(ca: &CustomCaConfig) -> anyhow::Result<reqwest::Client> {
479+
let builder = reqwest::Client::builder()
478480
.connect_timeout(REFRESH_CONNECT_TIMEOUT)
479481
.timeout(REFRESH_TOTAL_TIMEOUT)
480-
.redirect(reqwest::redirect::Policy::none())
482+
.redirect(reqwest::redirect::Policy::none());
483+
// Issue #34: add the user's custom root CA additively; fail-closed if a
484+
// configured CA cannot be loaded (a corporate proxy would break the refresh
485+
// otherwise, and silently ignoring the CA is never correct).
486+
driven_tls::apply_custom_ca(builder, ca)?
481487
.build()
482488
.map_err(|e| anyhow::anyhow!("drive: failed to build OAuth refresh client: {e}"))
483489
}
@@ -591,18 +597,31 @@ mod tests {
591597
// V-A1: the refresh client must build with timeouts + redirect::none.
592598
// Building it is offline (no network); a failure would be a TLS-init
593599
// bug, so this is a real assertion, not a skip.
594-
let client = build_refresh_client();
600+
let client = build_refresh_client(&CustomCaConfig::none());
595601
assert!(
596602
client.is_ok(),
597603
"refresh client must build offline: {:?}",
598604
client.err()
599605
);
600606
}
601607

608+
#[test]
609+
fn refresh_client_fails_closed_with_a_bad_ca() {
610+
// Issue #34: a configured-but-unloadable custom CA must FAIL the client
611+
// build (fail-closed), not silently fall back to system-trust-only. This
612+
// is the representative wiring assertion for the driven-tls threading.
613+
let missing = std::path::PathBuf::from("/driven/no/such/ca-bundle.pem");
614+
let ca = CustomCaConfig::from_path(Some(missing));
615+
assert!(
616+
build_refresh_client(&ca).is_err(),
617+
"a missing custom CA file must fail the refresh-client build"
618+
);
619+
}
620+
602621
#[test]
603622
fn with_store_wires_the_keychain_store() {
604623
// C-P2-4 / V-A3: with_store attaches a store; without it, none.
605-
let http = build_refresh_client().unwrap();
624+
let http = build_refresh_client(&CustomCaConfig::none()).unwrap();
606625
let tokens = Tokens {
607626
access_token: String::new(),
608627
refresh_token: "rt".to_string(),

crates/driven-drive/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,9 @@
1414
pub mod fake;
1515
pub mod google;
1616
pub mod remote_store;
17+
18+
// Issue #34: re-export the custom-root-CA config type so callers that already
19+
// depend on `driven-drive` (the CLI, the google_e2e integration test) can name
20+
// it without a separate `driven-tls` dependency. `apply_custom_ca` /
21+
// `validate_ca_file` live in `driven_tls` for the crates that build clients.
22+
pub use driven_tls::CustomCaConfig;

crates/driven-drive/tests/google_e2e.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,16 @@ fn e2e_creds(test_name: &str) -> Option<E2eCreds> {
8585
/// child folder id the scenario should operate under (ROADMAP M4: "each test
8686
/// uses a UUID-named child folder under the dest folder and cleans up").
8787
async fn setup_store(creds: &E2eCreds) -> (GoogleDriveStore, String) {
88+
let ca = driven_drive::CustomCaConfig::none();
8889
let token_source = RefreshingTokenSource::from_stored_refresh_token(
8990
creds.refresh_token.clone(),
9091
creds.client_id.clone(),
9192
creds.client_secret.clone(),
93+
&ca,
9294
)
9395
.expect("build refreshing token source");
9496
let store =
95-
GoogleDriveStore::with_default_clients(token_source).expect("build GoogleDriveStore");
97+
GoogleDriveStore::with_default_clients(token_source, &ca).expect("build GoogleDriveStore");
9698

9799
// Each test operates inside a fresh UUID-named child folder so concurrent
98100
// runs never collide and cleanup is a single trash of that subtree

crates/driven-net/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ publish = false
1313
# implements lives in driven-core::network (CODEX_NOTES P2-9: this is the
1414
# production "implementer crate" behind that seam).
1515
driven-core = { path = "../driven-core" }
16+
# Issue #34 corporate CA pinning: threads a user-configured custom root CA into
17+
# the captive-portal + per-service probe clients (additive; see driven-tls).
18+
driven-tls = { path = "../driven-tls" }
1619
anyhow.workspace = true
1720
thiserror.workspace = true
1821
tracing.workspace = true

0 commit comments

Comments
 (0)