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