hyper_custom_cert/lib.rs
1//! hyper-custom-cert
2//!
3//! A reusable HTTP client library that provides:
4//! - A small, ergonomic wrapper surface for building HTTP clients
5//! - A dev-only option to accept self-signed/invalid certificates (feature-gated)
6//! - A production-grade path to trust a custom Root CA by providing PEM bytes
7//! - Clear security boundaries and feature flags
8//!
9//! This crate is derived from a reference implementation located under
10//! `reference-implementation/hyper-custom-cert` in this repository. The reference
11//! implementation remains unchanged and serves as inspiration and verification.
12//!
13//! Note: Networking internals are intentionally abstracted for now; this crate
14//! focuses on a robust and secure configuration API surfaced via a builder.
15//!
16//! WebAssembly support and limitations
17//! -----------------------------------
18//! For wasm32 targets, this crate currently exposes API stubs that return
19//! `ClientError::WasmNotImplemented` when attempting to perform operations that
20//! would require configuring a TLS client with a custom Root CA. This is by design:
21//!
22//! Browsers do not allow web applications to programmatically install or trust
23//! custom Certificate Authorities. Trust decisions are enforced by the browser and
24//! the underlying OS. As a result, while native builds can securely add a custom
25//! Root CA (e.g., via `with_root_ca_pem` behind the `rustls` feature), the same is
26//! not possible in the browser environment. Any runtime method that would require
27//! such behavior will return `WasmNotImplemented` on wasm targets.
28//!
29//! If you need to target WebAssembly, build with `--no-default-features` to avoid
30//! pulling in native TLS dependencies, and expect stubbed behavior until a future
31//! browser capability or design change enables safe support.
32
33use std::collections::HashMap;
34use std::error::Error as StdError;
35use std::fmt;
36#[cfg(feature = "rustls")]
37use std::fs;
38#[cfg(feature = "rustls")]
39use std::path::Path;
40use std::time::Duration;
41
42/// Error type for this crate's runtime operations.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum ClientError {
45 /// Returned on wasm32 targets where runtime operations requiring custom CA
46 /// trust are not available due to browser security constraints.
47 WasmNotImplemented,
48}
49
50impl fmt::Display for ClientError {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 match self {
53 ClientError::WasmNotImplemented => write!(
54 f,
55 "Not implemented on WebAssembly (browser restricts programmatic CA trust)"
56 ),
57 }
58 }
59}
60
61impl StdError for ClientError {}
62
63/// Reusable HTTP client configured via [`HttpClientBuilder`].
64///
65/// # Examples
66///
67/// Build a client with a custom timeout and default headers:
68///
69/// ```
70/// use hyper_custom_cert::HttpClient;
71/// use std::time::Duration;
72/// use std::collections::HashMap;
73///
74/// let mut headers = HashMap::new();
75/// headers.insert("x-app".into(), "demo".into());
76///
77/// let client = HttpClient::builder()
78/// .with_timeout(Duration::from_secs(10))
79/// .with_default_headers(headers)
80/// .build();
81///
82/// // Placeholder call; does not perform I/O in this crate.
83/// let _ = client.request("https://example.com");
84/// ```
85pub struct HttpClient {
86 timeout: Duration,
87 default_headers: HashMap<String, String>,
88 /// When enabled (dev-only feature), allows accepting invalid/self-signed certs.
89 #[cfg(feature = "insecure-dangerous")]
90 accept_invalid_certs: bool,
91 /// Optional PEM-encoded custom Root CA to trust in addition to system roots.
92 root_ca_pem: Option<Vec<u8>>,
93 /// Optional certificate pins for additional security beyond CA validation.
94 #[cfg(feature = "rustls")]
95 pinned_cert_sha256: Option<Vec<[u8; 32]>>,
96}
97
98impl HttpClient {
99 /// Construct a new client using secure defaults by delegating to the builder.
100 pub fn new() -> Self {
101 HttpClientBuilder::new().build()
102 }
103
104 /// Start building a client with explicit configuration.
105 pub fn builder() -> HttpClientBuilder {
106 HttpClientBuilder::new()
107 }
108
109 /// Convenience constructor that enables acceptance of self-signed/invalid
110 /// certificates. This is gated behind the `insecure-dangerous` feature and intended
111 /// strictly for development and testing. NEVER enable in production.
112 #[cfg(feature = "insecure-dangerous")]
113 pub fn with_self_signed_certs() -> Self {
114 HttpClient::builder()
115 .insecure_accept_invalid_certs(true)
116 .build()
117 }
118}
119
120// Native (non-wasm) runtime placeholder implementation
121#[cfg(not(target_arch = "wasm32"))]
122impl HttpClient {
123 /// Minimal runtime method to demonstrate how requests would be issued.
124 /// On native targets, this currently returns Ok(()) as a placeholder
125 /// without performing network I/O.
126 pub fn request(&self, _url: &str) -> Result<(), ClientError> {
127 // Touch configuration fields to avoid dead_code warnings until
128 // network I/O is implemented.
129 let _ = (&self.timeout, &self.default_headers, &self.root_ca_pem);
130 #[cfg(feature = "insecure-dangerous")]
131 let _ = &self.accept_invalid_certs;
132 #[cfg(feature = "rustls")]
133 let _ = &self.pinned_cert_sha256;
134 Ok(())
135 }
136}
137
138// WebAssembly stubbed runtime implementation
139#[cfg(target_arch = "wasm32")]
140impl HttpClient {
141 /// On wasm32 targets, runtime methods are stubbed and return
142 /// `ClientError::WasmNotImplemented` because browsers do not allow
143 /// programmatic installation/trust of custom CAs.
144 pub fn request(&self, _url: &str) -> Result<(), ClientError> {
145 Err(ClientError::WasmNotImplemented)
146 }
147}
148
149/// Builder for configuring and creating an [`HttpClient`].
150pub struct HttpClientBuilder {
151 timeout: Duration,
152 default_headers: HashMap<String, String>,
153 #[cfg(feature = "insecure-dangerous")]
154 accept_invalid_certs: bool,
155 root_ca_pem: Option<Vec<u8>>,
156 #[cfg(feature = "rustls")]
157 pinned_cert_sha256: Option<Vec<[u8; 32]>>,
158}
159
160impl HttpClientBuilder {
161 /// Start a new builder with default settings.
162 pub fn new() -> Self {
163 Self {
164 timeout: Duration::from_secs(30),
165 default_headers: HashMap::new(),
166 #[cfg(feature = "insecure-dangerous")]
167 accept_invalid_certs: false,
168 root_ca_pem: None,
169 #[cfg(feature = "rustls")]
170 pinned_cert_sha256: None,
171 }
172 }
173
174 /// Set a request timeout to apply to client operations.
175 pub fn with_timeout(mut self, timeout: Duration) -> Self {
176 self.timeout = timeout;
177 self
178 }
179
180 /// Set default headers that will be added to every request initiated by this client.
181 pub fn with_default_headers(mut self, headers: HashMap<String, String>) -> Self {
182 self.default_headers = headers;
183 self
184 }
185
186 /// Dev-only: accept self-signed/invalid TLS certificates. Requires the
187 /// `insecure-dangerous` feature to be enabled. NEVER enable this in production.
188 ///
189 /// # Examples
190 ///
191 /// Enable insecure mode during local development (dangerous):
192 ///
193 /// ```ignore
194 /// use hyper_custom_cert::HttpClient;
195 ///
196 /// // Requires: --features insecure-dangerous
197 /// let client = HttpClient::builder()
198 /// .insecure_accept_invalid_certs(true)
199 /// .build();
200 /// ```
201 #[cfg(feature = "insecure-dangerous")]
202 pub fn insecure_accept_invalid_certs(mut self, accept: bool) -> Self {
203 self.accept_invalid_certs = accept;
204 self
205 }
206
207 /// Provide a PEM-encoded Root CA certificate to be trusted by the client.
208 /// This is the production-ready way to trust a custom CA.
209 ///
210 /// # Examples
211 ///
212 /// ```ignore
213 /// use hyper_custom_cert::HttpClient;
214 ///
215 /// // Requires: --no-default-features --features rustls
216 /// let client = HttpClient::builder()
217 /// .with_root_ca_pem(include_bytes!("../examples-data/root-ca.pem"))
218 /// .build();
219 /// ```
220 #[cfg(feature = "rustls")]
221 pub fn with_root_ca_pem(mut self, pem_bytes: &[u8]) -> Self {
222 self.root_ca_pem = Some(pem_bytes.to_vec());
223 self
224 }
225
226 /// Provide a PEM-encoded Root CA certificate file to be trusted by the client.
227 /// This is the production-ready way to trust a custom CA from a file path.
228 ///
229 /// The file will be read during builder configuration and its contents stored
230 /// in the client. This method will panic if the file cannot be read, similar
231 /// to how `include_bytes!` macro behaves.
232 ///
233 /// # Security Considerations
234 ///
235 /// Only use certificate files from trusted sources. Ensure proper file permissions
236 /// are set to prevent unauthorized modification of the certificate file.
237 ///
238 /// # Panics
239 ///
240 /// This method will panic if:
241 /// - The file does not exist
242 /// - The file cannot be read due to permissions or I/O errors
243 /// - The path is invalid
244 ///
245 /// # Examples
246 ///
247 /// ```ignore
248 /// use hyper_custom_cert::HttpClient;
249 ///
250 /// // Requires: --no-default-features --features rustls
251 /// let client = HttpClient::builder()
252 /// .with_root_ca_file("path/to/root-ca.pem")
253 /// .build();
254 /// ```
255 ///
256 /// Using a `std::path::Path`:
257 ///
258 /// ```ignore
259 /// use hyper_custom_cert::HttpClient;
260 /// use std::path::Path;
261 ///
262 /// // Requires: --no-default-features --features rustls
263 /// let ca_path = Path::new("certs/custom-ca.pem");
264 /// let client = HttpClient::builder()
265 /// .with_root_ca_file(ca_path)
266 /// .build();
267 /// ```
268 #[cfg(feature = "rustls")]
269 pub fn with_root_ca_file<P: AsRef<Path>>(mut self, path: P) -> Self {
270 let pem_bytes = fs::read(path.as_ref()).unwrap_or_else(|e| {
271 panic!(
272 "Failed to read CA certificate file '{}': {}",
273 path.as_ref().display(),
274 e
275 )
276 });
277 self.root_ca_pem = Some(pem_bytes);
278 self
279 }
280
281 /// Configure certificate pinning using SHA256 fingerprints for additional security.
282 ///
283 /// Certificate pinning provides an additional layer of security beyond CA validation
284 /// by verifying that the server's certificate matches one of the provided fingerprints.
285 /// This helps protect against compromised CAs and man-in-the-middle attacks.
286 ///
287 /// # Security Considerations
288 ///
289 /// - Certificate pinning should be used in conjunction with, not as a replacement for,
290 /// proper CA validation.
291 /// - Pinned certificates must be updated when the server's certificate changes.
292 /// - Consider having backup pins for certificate rotation scenarios.
293 /// - This method provides additional security but requires careful maintenance.
294 ///
295 /// # Parameters
296 ///
297 /// * `pins` - A vector of 32-byte SHA256 fingerprints of certificates to pin.
298 /// Each fingerprint should be the SHA256 hash of the certificate's DER encoding.
299 ///
300 /// # Examples
301 ///
302 /// ```ignore
303 /// use hyper_custom_cert::HttpClient;
304 ///
305 /// // Example SHA256 fingerprints (these are just examples)
306 /// let pin1: [u8; 32] = [
307 /// 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0,
308 /// 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
309 /// 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00,
310 /// 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0x07, 0x18
311 /// ];
312 ///
313 /// let pin2: [u8; 32] = [
314 /// 0xf0, 0xe1, 0xd2, 0xc3, 0xb4, 0xa5, 0x96, 0x87,
315 /// 0x78, 0x69, 0x5a, 0x4b, 0x3c, 0x2d, 0x1e, 0x0f,
316 /// 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
317 /// 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff
318 /// ];
319 ///
320 /// // Requires: --no-default-features --features rustls
321 /// let client = HttpClient::builder()
322 /// .with_pinned_cert_sha256(vec![pin1, pin2])
323 /// .build();
324 /// ```
325 #[cfg(feature = "rustls")]
326 pub fn with_pinned_cert_sha256(mut self, pins: Vec<[u8; 32]>) -> Self {
327 self.pinned_cert_sha256 = Some(pins);
328 self
329 }
330
331 /// Finalize the configuration and build an [`HttpClient`].
332 pub fn build(self) -> HttpClient {
333 HttpClient {
334 timeout: self.timeout,
335 default_headers: self.default_headers,
336 #[cfg(feature = "insecure-dangerous")]
337 accept_invalid_certs: self.accept_invalid_certs,
338 root_ca_pem: self.root_ca_pem,
339 #[cfg(feature = "rustls")]
340 pinned_cert_sha256: self.pinned_cert_sha256,
341 }
342 }
343}
344
345/// Default construction uses builder defaults.
346impl Default for HttpClient {
347 fn default() -> Self {
348 Self::new()
349 }
350}
351
352/// Default builder state is secure and ergonomic.
353impl Default for HttpClientBuilder {
354 fn default() -> Self {
355 Self::new()
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn builder_default_builds() {
365 let _client = HttpClient::builder().build();
366 }
367
368 #[test]
369 fn builder_allows_timeout_and_headers() {
370 let mut headers = HashMap::new();
371 headers.insert("x-test".into(), "1".into());
372 let builder = HttpClient::builder()
373 .with_timeout(Duration::from_secs(5))
374 .with_default_headers(headers);
375 #[cfg(feature = "rustls")]
376 let builder = builder.with_root_ca_pem(b"-----BEGIN CERTIFICATE-----\n...");
377 let _client = builder.build();
378 }
379
380 #[cfg(feature = "insecure-dangerous")]
381 #[test]
382 fn builder_allows_insecure_when_feature_enabled() {
383 let _client = HttpClient::builder()
384 .insecure_accept_invalid_certs(true)
385 .build();
386 let _client2 = HttpClient::with_self_signed_certs();
387 }
388
389 #[cfg(not(target_arch = "wasm32"))]
390 #[test]
391 fn request_returns_ok_on_native() {
392 let client = HttpClient::builder().build();
393 let res = client.request("https://example.com");
394 assert!(res.is_ok());
395 }
396
397 #[cfg(all(feature = "rustls", not(target_arch = "wasm32")))]
398 #[test]
399 fn builder_allows_root_ca_file() {
400 use std::fs;
401 use std::io::Write;
402
403 // Create a temporary file with test certificate content
404 let temp_dir = std::env::temp_dir();
405 let cert_file = temp_dir.join("test-ca.pem");
406
407 let test_cert = b"-----BEGIN CERTIFICATE-----
408MIICxjCCAa4CAQAwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAe
409Fw0yNTA4MTQwMDAwMDBaFw0yNjA4MTQwMDAwMDBaMBIxEDAOBgNVBAMMB1Rlc3Qg
410Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDTest...
411-----END CERTIFICATE-----";
412
413 // Write test certificate to temporary file
414 {
415 let mut file = fs::File::create(&cert_file).expect("Failed to create temp cert file");
416 file.write_all(test_cert)
417 .expect("Failed to write cert to temp file");
418 }
419
420 // Test that the builder can read the certificate file
421 let client = HttpClient::builder().with_root_ca_file(&cert_file).build();
422
423 // Verify the certificate was loaded
424 assert!(client.root_ca_pem.is_some());
425 assert_eq!(client.root_ca_pem.as_ref().unwrap(), test_cert);
426
427 // Clean up
428 let _ = fs::remove_file(cert_file);
429 }
430}