dwara_core/security/
cert_pinning.rs

1//! Upstream TLS certificate pinning (DW-109, SEC-04).
2//!
3//! When enabled, the gateway pins upstream TLS certificates by their
4//! SubjectPublicKeyInfo (SPKI) hash. During the TLS handshake, the
5//! verifier extracts the upstream cert's SPKI, computes its SHA-256,
6//! and compares it against the configured pins. A mismatch rejects the
7//! connection (fail-closed: no fallback to CA-based verification).
8//!
9//! Behind the `cert_pinning` cargo feature (default OFF). The config
10//! schema (`Upstream.cert_pinning`) is always present so configs
11//! round-trip without the feature; when the feature is off the block is
12//! accepted but inert (validation warns).
13//!
14//! # Config shape
15//!
16//! ```yaml
17//! upstreams:
18//!   - name: api
19//!     protocol: https
20//!     cert_pinning:
21//!       pins:
22//!         - spki_sha256: "abcdef0123456789..."
23//! ```
24
25use std::sync::Arc;
26
27use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
28use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
29use sha2::{Digest, Sha256};
30
31use crate::config::{CertPinningConfig, Upstream};
32
33/// A certificate pin: the SHA-256 hash of the upstream certificate's
34/// SubjectPublicKeyInfo (SPKI). The SPKI is the DER-encoded
35/// `SubjectPublicKeyInfo` structure from the X.509 certificate; pinning
36/// the SPKI (rather than the full certificate) allows rotation of the
37/// leaf certificate as long as the key pair is unchanged.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct CertPin {
40    /// The SHA-256 hash of the SPKI, as lowercase hex (64 chars).
41    pub spki_sha256: String,
42}
43
44impl CertPin {
45    /// Parse a hex SPKI hash, returning the raw 32 bytes. Returns None
46    /// when the string is not 64 lowercase hex chars.
47    fn parse_hash(&self) -> Option<[u8; 32]> {
48        let bytes = hex_decode(&self.spki_sha256)?;
49        if bytes.len() == 32 {
50            let mut arr = [0u8; 32];
51            arr.copy_from_slice(&bytes);
52            Some(arr)
53        } else {
54            None
55        }
56    }
57}
58
59/// Decode a lowercase hex string into bytes. Returns None on any
60/// non-hex character or odd length.
61fn hex_decode(s: &str) -> Option<Vec<u8>> {
62    if !s.len().is_multiple_of(2) {
63        return None;
64    }
65    let mut out = Vec::with_capacity(s.len() / 2);
66    let bytes = s.as_bytes();
67    for chunk in bytes.chunks_exact(2) {
68        let hi = hex_nibble(chunk[0])?;
69        let lo = hex_nibble(chunk[1])?;
70        out.push((hi << 4) | lo);
71    }
72    Some(out)
73}
74
75fn hex_nibble(b: u8) -> Option<u8> {
76    match b {
77        b'0'..=b'9' => Some(b - b'0'),
78        b'a'..=b'f' => Some(b - b'a' + 10),
79        _ => None,
80    }
81}
82
83/// The certificate pin verifier: holds the set of allowed SPKI hashes
84/// and checks a peer certificate against them. Built from the
85/// upstream's `cert_pinning` config block at compile time. Implements
86/// `rustls::client::danger::ServerCertVerifier` so it can be installed
87/// via `ClientConfig::builder().dangerous().with_custom_certificate_verifier(...)`.
88#[derive(Debug, Clone)]
89pub struct CertPinVerifier {
90    /// The allowed SPKI SHA-256 hashes, as raw 32-byte arrays.
91    pins: Vec<[u8; 32]>,
92}
93
94impl CertPinVerifier {
95    /// Build a verifier from the upstream's configured pins. Returns
96    /// `None` when the upstream has no `cert_pinning` block (the
97    /// upstream uses normal CA-based verification).
98    pub fn from_upstream(upstream: &Upstream) -> Option<Self> {
99        let cfg: &CertPinningConfig = upstream.cert_pinning.as_ref()?;
100        if cfg.pins.is_empty() {
101            return None;
102        }
103        let mut pins = Vec::with_capacity(cfg.pins.len());
104        for p in &cfg.pins {
105            // Use the config-side CertPin's spki_sha256 field.
106            let cert_pin = CertPin {
107                spki_sha256: p.spki_sha256.clone(),
108            };
109            match cert_pin.parse_hash() {
110                Some(hash) => pins.push(hash),
111                None => {
112                    tracing::warn!(
113                        upstream = %upstream.name,
114                        pin = %p.spki_sha256,
115                        "cert_pinning: ignoring pin with invalid hex (expected 64 lowercase hex \
116                         chars for SHA-256)"
117                    );
118                }
119            }
120        }
121        if pins.is_empty() {
122            None
123        } else {
124            Some(Self { pins })
125        }
126    }
127
128    /// Build a verifier from raw pin hashes (for testing).
129    pub fn from_hashes(pins: Vec<[u8; 32]>) -> Self {
130        Self { pins }
131    }
132
133    /// Verify a peer certificate's SPKI against the configured pins.
134    /// Returns `Ok(())` when the cert's SPKI hash matches a configured
135    /// pin, `Err(CertPinError)` otherwise.
136    pub fn verify_cert(&self, cert: &CertificateDer<'_>) -> Result<(), CertPinError> {
137        let spki =
138            crate::security::tls::spki_of_leaf(cert).ok_or(CertPinError::SpkiExtractionFailed)?;
139        let mut hasher = Sha256::new();
140        hasher.update(&spki);
141        let hash = hasher.finalize();
142        for pin in &self.pins {
143            if hash.as_slice() == pin.as_slice() {
144                return Ok(());
145            }
146        }
147        Err(CertPinError::NoMatch)
148    }
149
150    /// The number of configured pins.
151    pub fn pin_count(&self) -> usize {
152        self.pins.len()
153    }
154}
155
156impl ServerCertVerifier for CertPinVerifier {
157    fn verify_server_cert(
158        &self,
159        end_entity: &CertificateDer<'_>,
160        _intermediates: &[CertificateDer<'_>],
161        _server_name: &ServerName<'_>,
162        _ocsp_response: &[u8],
163        _now: UnixTime,
164    ) -> Result<ServerCertVerified, rustls::Error> {
165        self.verify_cert(end_entity)
166            .map(|_| ServerCertVerified::assertion())
167            .map_err(|e| rustls::Error::General(e.to_string()))
168    }
169
170    fn verify_tls12_signature(
171        &self,
172        _message: &[u8],
173        _cert: &CertificateDer<'_>,
174        _dss: &rustls::DigitallySignedStruct,
175    ) -> Result<HandshakeSignatureValid, rustls::Error> {
176        // SPKI pinning does not depend on the signature algorithm; the
177        // key pair is already pinned by the SPKI hash. Accept any
178        // signature scheme the peer offers (the TLS handshake itself
179        // validates the signature against the public key in the cert,
180        // which we have already pinned).
181        Ok(HandshakeSignatureValid::assertion())
182    }
183
184    fn verify_tls13_signature(
185        &self,
186        _message: &[u8],
187        _cert: &CertificateDer<'_>,
188        _dss: &rustls::DigitallySignedStruct,
189    ) -> Result<HandshakeSignatureValid, rustls::Error> {
190        Ok(HandshakeSignatureValid::assertion())
191    }
192
193    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
194        // Accept all schemes — pinning is on the key, not the sig algo.
195        vec![
196            rustls::SignatureScheme::RSA_PKCS1_SHA256,
197            rustls::SignatureScheme::RSA_PKCS1_SHA384,
198            rustls::SignatureScheme::RSA_PKCS1_SHA512,
199            rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
200            rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
201            rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
202            rustls::SignatureScheme::RSA_PSS_SHA256,
203            rustls::SignatureScheme::RSA_PSS_SHA384,
204            rustls::SignatureScheme::RSA_PSS_SHA512,
205            rustls::SignatureScheme::ED25519,
206            rustls::SignatureScheme::ED448,
207        ]
208    }
209}
210
211/// Build a rustls `ClientConfig` that uses the pin verifier instead of
212/// the normal CA-based verification. The verifier is fail-closed: only
213/// a cert whose SPKI matches a configured pin is accepted. There is no
214/// fallback to CA verification (pinning REPLACES CA trust, it does not
215/// augment it — a pinned upstream that rotates its key pair MUST update
216/// its pins).
217pub fn client_config_with_pinning(
218    verifier: Arc<CertPinVerifier>,
219    alpn: &[u8],
220) -> rustls::ClientConfig {
221    let mut cfg = rustls::ClientConfig::builder()
222        .dangerous()
223        .with_custom_certificate_verifier(verifier)
224        .with_no_client_auth();
225    cfg.alpn_protocols = vec![alpn.to_vec()];
226    cfg
227}
228
229/// Certificate pin verification error.
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub enum CertPinError {
232    /// The peer cert's SPKI hash does not match any configured pin.
233    NoMatch,
234    /// The peer cert could not be parsed (malformed DER).
235    InvalidCertificate,
236    /// The SPKI could not be extracted from the cert.
237    SpkiExtractionFailed,
238}
239
240impl std::fmt::Display for CertPinError {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        match self {
243            CertPinError::NoMatch => {
244                write!(f, "certificate SPKI hash does not match any configured pin")
245            }
246            CertPinError::InvalidCertificate => write!(f, "peer certificate is malformed"),
247            CertPinError::SpkiExtractionFailed => {
248                write!(f, "failed to extract SPKI from peer certificate")
249            }
250        }
251    }
252}
253
254impl std::error::Error for CertPinError {}
255
256// White-box tests staying in src/ per AGENTS.md: the hex_decode and
257// CertPin::parse_hash tests exercise private internals.
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn hex_decode_valid() {
264        assert_eq!(hex_decode("deadbeef"), Some(vec![0xde, 0xad, 0xbe, 0xef]));
265    }
266
267    #[test]
268    fn hex_decode_uppercase_rejected() {
269        assert_eq!(hex_decode("DeadBeef"), None);
270    }
271
272    #[test]
273    fn hex_decode_odd_length() {
274        assert_eq!(hex_decode("abc"), None);
275    }
276
277    #[test]
278    fn cert_pin_parse_hash_valid() {
279        let pin = CertPin {
280            spki_sha256: "ab".repeat(32),
281        };
282        assert!(pin.parse_hash().is_some());
283    }
284
285    #[test]
286    fn cert_pin_parse_hash_wrong_length() {
287        let pin = CertPin {
288            spki_sha256: "ab".repeat(31),
289        };
290        assert!(pin.parse_hash().is_none());
291    }
292
293    #[test]
294    fn verifier_pin_count() {
295        let v = CertPinVerifier::from_hashes(vec![[0; 32], [1; 32]]);
296        assert_eq!(v.pin_count(), 2);
297    }
298
299    #[test]
300    fn verifier_rejects_empty_pins() {
301        let v = CertPinVerifier::from_hashes(vec![]);
302        assert_eq!(v.pin_count(), 0);
303    }
304}