Skip to main content

nono_proxy/tls_intercept/
bundle.rs

1//! Trust bundle composition for the sandboxed child.
2//!
3//! When TLS interception is active the proxy writes a PEM file containing:
4//!
5//! 1. The contents of the parent process's `SSL_CERT_FILE` (if set), so that
6//!    a corporate / private CA configured on the host continues to be
7//!    trusted by the agent.
8//! 2. The host's system trust store (via `rustls-native-certs`), so that
9//!    the agent retains trust for normal public CAs after we override
10//!    `SSL_CERT_FILE` to point at this bundle.
11//! 3. The proxy's ephemeral session CA, so the minted leaf certificates
12//!    served by the intercept acceptor are accepted by the agent.
13//!
14//! ## Why all three layers
15//!
16//! `SSL_CERT_FILE` (and friends) **replaces** the default trust store for
17//! most runtimes (Python `requests`, OpenSSL, curl). If we wrote only the
18//! ephemeral CA, the agent would lose trust for every other host. If we
19//! wrote only system roots + ephemeral CA, we would silently strip any
20//! corporate CA the host had configured. Layering all three preserves the
21//! host's existing trust posture and additively adds nono's intercept CA.
22//!
23//! ## File permissions
24//!
25//! The bundle is written with mode `0o400` (owner read-only). The CA private
26//! key is **never** written to disk — it lives only in memory inside
27//! [`super::ca::EphemeralCa`].
28
29use crate::error::{ProxyError, Result};
30use std::path::{Path, PathBuf};
31use tracing::{debug, warn};
32
33/// Inputs to [`write_bundle`].
34pub struct BundleInputs<'a> {
35    /// Directory the bundle will be written into. Caller is responsible for
36    /// ensuring the directory exists with appropriate permissions
37    /// (e.g. `~/.nono/sessions/<session_id>/` at `0o700`).
38    pub dir: &'a Path,
39    /// Filename inside `dir`. Conventionally `intercept-ca.pem`.
40    pub filename: &'a str,
41    /// Optional contents of the parent process's `SSL_CERT_FILE`. When
42    /// `Some`, prepended verbatim to the bundle so that any corporate CA
43    /// trust the host had configured is preserved.
44    pub parent_ssl_cert_file: Option<&'a [u8]>,
45    /// PEM-encoded ephemeral session CA cert (from
46    /// [`super::ca::EphemeralCa::cert_pem`]).
47    pub ephemeral_ca_pem: &'a str,
48}
49
50/// Compose the trust bundle and write it to disk.
51///
52/// Returns the absolute path to the written file. Errors include:
53/// * unable to read the system trust store
54/// * unable to create the file with restrictive permissions
55/// * unable to write
56pub fn write_bundle(inputs: BundleInputs<'_>) -> Result<PathBuf> {
57    let mut pem = String::new();
58
59    if let Some(parent_pem) = inputs.parent_ssl_cert_file {
60        // Pass through verbatim; we don't try to parse and re-emit because
61        // any non-cert lines (comments, etc.) would be lost on re-emission.
62        match std::str::from_utf8(parent_pem) {
63            Ok(s) => {
64                debug!(
65                    "tls_intercept: merging parent SSL_CERT_FILE contents \
66                     ({} bytes) into trust bundle",
67                    s.len()
68                );
69                pem.push_str(s);
70                if !pem.ends_with('\n') {
71                    pem.push('\n');
72                }
73            }
74            Err(_) => {
75                warn!(
76                    "tls_intercept: parent SSL_CERT_FILE contents are not valid UTF-8; \
77                     skipping merge — corporate CAs configured on the host may not be \
78                     trusted by the sandboxed child"
79                );
80            }
81        }
82    }
83
84    // System trust store. We read full DER certs (rustls-native-certs returns
85    // CertificateDer<'static>) and PEM-encode each one ourselves rather than
86    // depending on a base64 helper crate.
87    let system_certs = rustls_native_certs::load_native_certs();
88    if !system_certs.errors.is_empty() {
89        // Non-fatal: some certs may have failed to parse, but we keep the
90        // ones that did. Log so an operator can see the count if it ever
91        // matters.
92        debug!(
93            "tls_intercept: rustls-native-certs reported {} non-fatal errors while \
94             loading system trust store",
95            system_certs.errors.len()
96        );
97    }
98    if system_certs.certs.is_empty() && inputs.parent_ssl_cert_file.is_none() {
99        // No system roots and no parent file — the agent would lose trust
100        // for every public CA. Refuse rather than ship a broken bundle.
101        return Err(ProxyError::Config(
102            "tls_intercept: failed to load any system trust roots; \
103             refusing to write a bundle that would strip the agent's TLS trust"
104                .to_string(),
105        ));
106    }
107    debug!(
108        "tls_intercept: appending {} certs from the system trust store to bundle",
109        system_certs.certs.len()
110    );
111    for cert in system_certs.certs {
112        pem.push_str("-----BEGIN CERTIFICATE-----\n");
113        pem.push_str(&base64_chunked(cert.as_ref()));
114        pem.push_str("-----END CERTIFICATE-----\n");
115    }
116
117    // Ephemeral CA cert.
118    if !inputs.ephemeral_ca_pem.contains("BEGIN CERTIFICATE") {
119        return Err(ProxyError::Config(
120            "tls_intercept: ephemeral CA PEM is not in the expected format".to_string(),
121        ));
122    }
123    pem.push_str(inputs.ephemeral_ca_pem);
124    if !pem.ends_with('\n') {
125        pem.push('\n');
126    }
127
128    // Write atomically with restrictive permissions.
129    let path = inputs.dir.join(inputs.filename);
130    write_with_restrictive_perms(&path, pem.as_bytes())?;
131    debug!(
132        "tls_intercept: wrote trust bundle ({} bytes) to {}",
133        pem.len(),
134        path.display()
135    );
136    Ok(path)
137}
138
139/// Write bytes to `path` with mode `0o400` on Unix. On Windows this falls
140/// back to a plain write; nono is currently Unix-only but the FFI bindings
141/// don't enforce that at the proxy crate boundary, so we keep the cfg
142/// fence narrow.
143fn write_with_restrictive_perms(path: &Path, contents: &[u8]) -> Result<()> {
144    use std::io::Write;
145
146    // Remove any pre-existing file so we don't inherit permissions from a
147    // previous session (defence in depth — the parent dir should be 0o700
148    // anyway).
149    if path.exists() {
150        std::fs::remove_file(path).map_err(|e| {
151            ProxyError::Config(format!(
152                "tls_intercept: cannot remove stale bundle '{}': {}",
153                path.display(),
154                e
155            ))
156        })?;
157    }
158
159    #[cfg(unix)]
160    {
161        use std::os::unix::fs::OpenOptionsExt;
162        let mut file = std::fs::OpenOptions::new()
163            .write(true)
164            .create_new(true)
165            .mode(0o400)
166            .open(path)
167            .map_err(|e| {
168                ProxyError::Config(format!(
169                    "tls_intercept: cannot create bundle '{}': {}",
170                    path.display(),
171                    e
172                ))
173            })?;
174        file.write_all(contents).map_err(|e| {
175            ProxyError::Config(format!(
176                "tls_intercept: cannot write bundle '{}': {}",
177                path.display(),
178                e
179            ))
180        })?;
181        file.flush().ok();
182    }
183    #[cfg(not(unix))]
184    {
185        std::fs::write(path, contents).map_err(|e| {
186            ProxyError::Config(format!(
187                "tls_intercept: cannot write bundle '{}': {}",
188                path.display(),
189                e
190            ))
191        })?;
192    }
193    Ok(())
194}
195
196/// Encode `bytes` as base64 with 64-character lines, matching the convention
197/// used by OpenSSL and most CA bundles. Implemented inline to avoid pulling
198/// in a base64 helper just for cert emission (the `base64` crate is already
199/// a dep but its config API has churned across versions; doing it ourselves
200/// keeps this stable).
201fn base64_chunked(bytes: &[u8]) -> String {
202    use base64::engine::{Engine, general_purpose::STANDARD};
203    let encoded = STANDARD.encode(bytes);
204    let mut out = String::with_capacity(encoded.len() + encoded.len() / 64 + 1);
205    for chunk in encoded.as_bytes().chunks(64) {
206        out.push_str(std::str::from_utf8(chunk).unwrap_or(""));
207        out.push('\n');
208    }
209    out
210}
211
212#[cfg(test)]
213#[allow(clippy::unwrap_used)]
214mod tests {
215    use super::*;
216    use crate::tls_intercept::ca::EphemeralCa;
217
218    #[test]
219    fn bundle_contains_ephemeral_and_system_roots() {
220        let dir = tempfile::tempdir().unwrap();
221        let ca = EphemeralCa::generate().unwrap();
222        let path = write_bundle(BundleInputs {
223            dir: dir.path(),
224            filename: "intercept-ca.pem",
225            parent_ssl_cert_file: None,
226            ephemeral_ca_pem: ca.cert_pem(),
227        })
228        .unwrap();
229
230        let contents = std::fs::read_to_string(&path).unwrap();
231        let cert_count = contents.matches("BEGIN CERTIFICATE").count();
232        assert!(
233            cert_count >= 2,
234            "bundle should contain at least one system root + the ephemeral CA, got {}",
235            cert_count
236        );
237        assert!(
238            contents.contains(ca.cert_pem().trim()),
239            "ephemeral CA PEM must appear verbatim in bundle"
240        );
241    }
242
243    #[test]
244    fn bundle_merges_parent_file() {
245        let dir = tempfile::tempdir().unwrap();
246        let ca = EphemeralCa::generate().unwrap();
247        let parent = b"# corporate roots\n-----BEGIN CERTIFICATE-----\nMIIBcorpfake\n-----END CERTIFICATE-----\n";
248        let path = write_bundle(BundleInputs {
249            dir: dir.path(),
250            filename: "intercept-ca.pem",
251            parent_ssl_cert_file: Some(parent),
252            ephemeral_ca_pem: ca.cert_pem(),
253        })
254        .unwrap();
255
256        let contents = std::fs::read_to_string(&path).unwrap();
257        assert!(contents.contains("MIIBcorpfake"));
258    }
259
260    #[test]
261    fn bundle_rejects_malformed_ephemeral_pem() {
262        let dir = tempfile::tempdir().unwrap();
263        let result = write_bundle(BundleInputs {
264            dir: dir.path(),
265            filename: "intercept-ca.pem",
266            parent_ssl_cert_file: None,
267            ephemeral_ca_pem: "not a certificate",
268        });
269        assert!(result.is_err());
270    }
271
272    #[test]
273    #[cfg(unix)]
274    fn bundle_file_has_restrictive_permissions() {
275        use std::os::unix::fs::PermissionsExt;
276        let dir = tempfile::tempdir().unwrap();
277        let ca = EphemeralCa::generate().unwrap();
278        let path = write_bundle(BundleInputs {
279            dir: dir.path(),
280            filename: "intercept-ca.pem",
281            parent_ssl_cert_file: None,
282            ephemeral_ca_pem: ca.cert_pem(),
283        })
284        .unwrap();
285
286        let metadata = std::fs::metadata(&path).unwrap();
287        let mode = metadata.permissions().mode() & 0o777;
288        assert_eq!(mode, 0o400, "bundle must be owner-read-only");
289    }
290}