nono_proxy/tls_intercept/
bundle.rs1use crate::error::{ProxyError, Result};
30use std::path::{Path, PathBuf};
31use tracing::{debug, warn};
32
33pub struct BundleInputs<'a> {
35 pub dir: &'a Path,
39 pub filename: &'a str,
41 pub parent_ssl_cert_file: Option<&'a [u8]>,
45 pub ephemeral_ca_pem: &'a str,
48}
49
50pub 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 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 let system_certs = rustls_native_certs::load_native_certs();
88 if !system_certs.errors.is_empty() {
89 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 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 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 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
139fn write_with_restrictive_perms(path: &Path, contents: &[u8]) -> Result<()> {
144 use std::io::Write;
145
146 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
196fn base64_chunked(bytes: &[u8]) -> String {
202 use base64::engine::{general_purpose::STANDARD, Engine};
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}