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}