Skip to main content

noxu_rep/
auth.rs

1//! Peer authentication and authorisation for replication.
2//!
3//! Replication in v1.4.x and earlier had no authentication on
4//! the wire (see `docs/src/internal/security-review-2026-05.md`,
5//! finding NA-1). The mTLS-by-default plan
6//! (`docs/src/internal/auth-mtls-design-2026-05.md`) closes
7//! NA-1 / NA-2 / NA-3 / NA-4 / NA-8 / TLS-1 by:
8//!
9//!   1. Requiring TLS on the dispatcher's transport.
10//!   2. Using rustls's standard chain verification (CA-rooted).
11//!   3. **Plus this module's `PeerAllowlistVerifier`** (Phase 2),
12//!      which runs after chain validation succeeds and confirms
13//!      that the peer's leaf-certificate subject names are in a
14//!      configured allowlist.
15//!
16//! **Phase 1** added the allowlist matching logic in isolation.
17//! **Phase 2** (v3.1.0) wires the verifier through the rustls
18//! `ServerConfig` via `PeerAllowlistVerifier` and updates the
19//! client config to present a client certificate.  Both changes
20//! are guarded by the `tls-rustls` feature flag.
21//!
22//! ## What goes in the allowlist
23//!
24//! Each allowlist entry is matched against the peer cert's
25//! subject Common Name (CN) AND each Subject Alternative Name
26//! (SAN) DNS entry. Matching is case-insensitive. Wildcards
27//! (`*.cluster.example`) are NOT supported in v1.5.0 — the
28//! allowlist is a literal set of expected names; a wildcard
29//! would weaken the security boundary by accepting any cert
30//! the operator's CA happens to sign with a name in that
31//! domain.
32//!
33//! ## Why subject-based and not pinning the cert hash
34//!
35//! Cert pinning (storing a SHA-256 of the peer's leaf cert) is
36//! more restrictive but breaks rotation: rotating any peer's
37//! cert requires updating every other peer's pinned-hash list.
38//! Subject-based authorisation lets the operator rotate certs
39//! freely under the same CA without touching the allowlist.
40
41use std::collections::BTreeSet;
42
43/// Membership policy: which peer subject names are allowed to
44/// participate in the replication group.
45///
46/// Construct via [`PeerAllowlist::new`] from a list of
47/// expected subject names. Names are normalised to lowercase
48/// at construction time so [`PeerAllowlist::contains`] is
49/// case-insensitive.
50#[derive(Clone, Debug, Default)]
51pub struct PeerAllowlist {
52    /// Lowercased, deduplicated subject names.
53    allowed: BTreeSet<String>,
54}
55
56impl PeerAllowlist {
57    /// Build an allowlist from any iterable of subject-name
58    /// strings. Names are stored lowercased; duplicates and
59    /// empty strings are filtered out.
60    ///
61    /// An allowlist with zero entries means "no peer is
62    /// authorised", which is a valid (if useless) state — the
63    /// caller should treat zero-entry allowlists as a
64    /// configuration error before constructing the verifier.
65    pub fn new<I, S>(names: I) -> Self
66    where
67        I: IntoIterator<Item = S>,
68        S: AsRef<str>,
69    {
70        let allowed = names
71            .into_iter()
72            .filter_map(|s| {
73                let s = s.as_ref().trim();
74                if s.is_empty() { None } else { Some(s.to_ascii_lowercase()) }
75            })
76            .collect();
77        Self { allowed }
78    }
79
80    /// Number of unique entries in the allowlist.
81    pub fn len(&self) -> usize {
82        self.allowed.len()
83    }
84
85    /// `true` iff the allowlist is empty (no peers
86    /// authorised).
87    pub fn is_empty(&self) -> bool {
88        self.allowed.is_empty()
89    }
90
91    /// `true` iff `name` is exactly equal to some entry,
92    /// case-insensitive. Wildcards are NOT supported.
93    pub fn contains(&self, name: &str) -> bool {
94        self.allowed.contains(&name.trim().to_ascii_lowercase())
95    }
96
97    /// `true` iff ANY of `names` is in the allowlist. The
98    /// caller passes every name extracted from the peer cert
99    /// (subject CN + each SAN DNS entry); membership is
100    /// granted if at least one matches.
101    pub fn contains_any<I, S>(&self, names: I) -> bool
102    where
103        I: IntoIterator<Item = S>,
104        S: AsRef<str>,
105    {
106        names.into_iter().any(|n| self.contains(n.as_ref()))
107    }
108
109    /// Read-only iterator over the lowercased entries. Order
110    /// is `BTreeSet` order (lexicographic).
111    pub fn iter(&self) -> impl Iterator<Item = &str> {
112        self.allowed.iter().map(String::as_str)
113    }
114}
115
116// ─── Cert name extraction (tls-rustls only) ─────────────────────────────────
117
118/// Minimal X.509 DER parser: decode a tag-length prefix.
119///
120/// Returns `(length, bytes_consumed_for_length_encoding)` or `None` if
121/// the slice is too short or uses an unsupported form.
122#[cfg(feature = "tls-rustls")]
123fn der_decode_len(data: &[u8]) -> Option<(usize, usize)> {
124    let first = *data.first()? as usize;
125    if first < 0x80 {
126        Some((first, 1))
127    } else {
128        let n = first & 0x7F;
129        // Reject indefinite-length (n==0) and lengths > 4 bytes (> 4 GiB).
130        if n == 0 || n > 4 || data.len() < 1 + n {
131            return None;
132        }
133        let mut len = 0usize;
134        for &b in &data[1..1 + n] {
135            len = (len << 8) | (b as usize);
136        }
137        Some((len, 1 + n))
138    }
139}
140
141/// Parse one DER TLV: returns `(tag, value_slice, remaining_after_TLV)`.
142#[cfg(feature = "tls-rustls")]
143fn der_tlv(data: &[u8]) -> Option<(u8, &[u8], &[u8])> {
144    if data.is_empty() {
145        return None;
146    }
147    let tag = data[0];
148    let (len, consumed) = der_decode_len(&data[1..])?;
149    let start = 1 + consumed;
150    if data.len() < start + len {
151        return None;
152    }
153    Some((tag, &data[start..start + len], &data[start + len..]))
154}
155
156/// Extract lowercase subject names from a leaf certificate's DER bytes.
157///
158/// Returns every name the verifier should check against the allowlist:
159/// - Subject Common Name (OID 2.5.4.3, any DirectoryString encoding).
160/// - DNS Subject Alternative Names (GeneralName `[2] IMPLICIT IA5String`).
161///
162/// This is a focused, conservative parser that only touches the fields it
163/// needs and ignores everything else.  Malformed input silently yields
164/// whatever names have been collected so far — an unparseable cert
165/// produces an empty list and therefore **fails** the allowlist check
166/// (fail-closed).
167#[cfg(feature = "tls-rustls")]
168pub(crate) fn extract_cert_names(cert_der: &[u8]) -> Vec<String> {
169    try_extract_cert_names(cert_der).unwrap_or_default()
170}
171
172/// Public re-export of `extract_cert_names` for integration tests.
173///
174/// This function is only available under the `tls-rustls` feature and is
175/// intended for use in `tests/` integration tests that verify the DER
176/// cert-name parser in isolation.
177#[cfg(feature = "tls-rustls")]
178pub fn extract_cert_names_for_test(cert_der: &[u8]) -> Vec<String> {
179    extract_cert_names(cert_der)
180}
181
182#[cfg(feature = "tls-rustls")]
183fn try_extract_cert_names(cert_der: &[u8]) -> Option<Vec<String>> {
184    let mut names: Vec<String> = Vec::new();
185
186    // Certificate is SEQUENCE { TBSCertificate, signatureAlg, signatureBits }.
187    let (0x30, cert_body, _) = der_tlv(cert_der)? else {
188        return Some(names);
189    };
190    // First element of Certificate body is TBSCertificate (SEQUENCE).
191    let (0x30, tbs, _) = der_tlv(cert_body)? else {
192        return Some(names);
193    };
194
195    let mut p = tbs;
196
197    // Skip optional version [0] EXPLICIT (tag 0xA0).
198    if let Some((0xA0, _, rest)) = der_tlv(p) {
199        p = rest;
200    }
201    // Skip serialNumber INTEGER (tag 0x02).
202    let (0x02, _, rest) = der_tlv(p)? else {
203        return Some(names);
204    };
205    p = rest;
206    // Skip signature AlgorithmIdentifier (SEQUENCE, tag 0x30).
207    let (0x30, _, rest) = der_tlv(p)? else {
208        return Some(names);
209    };
210    p = rest;
211    // Skip issuer Name (SEQUENCE, tag 0x30).
212    let (0x30, _, rest) = der_tlv(p)? else {
213        return Some(names);
214    };
215    p = rest;
216    // Skip validity (SEQUENCE, tag 0x30).
217    let (0x30, _, rest) = der_tlv(p)? else {
218        return Some(names);
219    };
220    p = rest;
221
222    // Parse subject Name (SEQUENCE, tag 0x30) — extract CN.
223    let (0x30, subject, rest) = der_tlv(p)? else {
224        return Some(names);
225    };
226    p = rest;
227    // Walk RDNs: each RDN is SET (0x31) containing ATVs.
228    let mut rdns = subject;
229    while let Some((0x31, rdn_val, rest2)) = der_tlv(rdns) {
230        rdns = rest2;
231        let mut atvs = rdn_val;
232        while let Some((0x30, atv, rest3)) = der_tlv(atvs) {
233            atvs = rest3;
234            // ATV: OID (0x06) + DirectoryString value.
235            if let Some((0x06, oid_bytes, val_rest)) = der_tlv(atv)
236                && oid_bytes == [0x55, 0x04, 0x03]
237            {
238                // Accept any DirectoryString variant:
239                // UTF8String(0x0C), PrintableString(0x13),
240                // TeletexString(0x14), IA5String(0x16), BMPString(0x1E).
241                if let Some((_vtag, vval, _)) = der_tlv(val_rest)
242                    && let Ok(s) = std::str::from_utf8(vval)
243                    && !s.is_empty()
244                {
245                    names.push(s.to_ascii_lowercase());
246                }
247            }
248        }
249    }
250
251    // Skip subjectPublicKeyInfo (SEQUENCE, tag 0x30).
252    let (0x30, _, rest) = der_tlv(p)? else {
253        return Some(names);
254    };
255    p = rest;
256
257    // Skip optional issuerUniqueID [1] and subjectUniqueID [2].
258    if let Some((0x81, _, rest2)) = der_tlv(p) {
259        p = rest2;
260    }
261    if let Some((0x82, _, rest2)) = der_tlv(p) {
262        p = rest2;
263    }
264
265    // Look for [3] EXPLICIT Extensions (tag 0xA3).
266    while let Some((tag, val, rest)) = der_tlv(p) {
267        p = rest;
268        if tag != 0xA3 {
269            continue;
270        }
271        // Extensions are a SEQUENCE inside the [3] wrapper.
272        let (0x30, exts_body, _) = der_tlv(val)? else {
273            break;
274        };
275        let mut ext_p = exts_body;
276        while let Some((0x30, ext, rest2)) = der_tlv(ext_p) {
277            ext_p = rest2;
278            // Each Extension: SEQUENCE { OID, [critical BOOLEAN,] OCTET STRING }.
279            let (0x06, oid_bytes, ext_rest) = der_tlv(ext)? else {
280                continue;
281            };
282            // OID 2.5.29.17 (id-ce-subjectAltName) = 0x55 0x1D 0x11.
283            if oid_bytes != [0x55, 0x1D, 0x11] {
284                continue;
285            }
286            // Skip optional critical BOOLEAN (tag 0x01).
287            let san_octet_rest = if ext_rest.first() == Some(&0x01) {
288                der_tlv(ext_rest).map(|(_, _, r)| r).unwrap_or(ext_rest)
289            } else {
290                ext_rest
291            };
292            // OCTET STRING wrapping the actual SAN value.
293            let (0x04, octet_val, _) = der_tlv(san_octet_rest)? else {
294                continue;
295            };
296            // SubjectAltName ::= SEQUENCE OF GeneralName.
297            let (0x30, san_seq, _) = der_tlv(octet_val)? else {
298                continue;
299            };
300            let mut san_p = san_seq;
301            while let Some((gtag, gval, rest3)) = der_tlv(san_p) {
302                san_p = rest3;
303                // dNSName = [2] IMPLICIT IA5String, tag byte = 0x82.
304                if gtag == 0x82
305                    && let Ok(s) = std::str::from_utf8(gval)
306                    && !s.is_empty()
307                {
308                    names.push(s.to_ascii_lowercase());
309                }
310            }
311        }
312        break; // parsed extensions, stop
313    }
314    let _ = p; // silence unused-variable warning after the loop
315
316    Some(names)
317}
318
319// ─── PeerAllowlistVerifier ───────────────────────────────────────────────────
320
321/// A rustls [`ClientCertVerifier`] that enforces the `peer_allowlist`.
322///
323/// # Enforcement model
324///
325/// 1. **Chain validation** — delegates to rustls's built-in
326///    `WebPkiClientVerifier` which validates the client certificate chain
327///    against the configured CA trust anchors.  An expired, self-signed, or
328///    wrong-CA cert is rejected before the allowlist check runs.
329///
330/// 2. **Allowlist check** — extracts the leaf certificate's Subject Common
331///    Name (CN) and every DNS Subject Alternative Name (SAN).  At least one
332///    of those names must match an entry in the configured
333///    [`PeerAllowlist`] (case-insensitive, no wildcards).
334///
335/// # Construction
336///
337/// Returns an error if `allowlist` is empty.  An empty allowlist means "no
338/// peer is authorised", which is almost certainly a misconfiguration.
339/// Callers should validate the allowlist before calling `new`.
340///
341/// # Feature gate
342///
343/// Only available under the `tls-rustls` feature.
344///
345/// [`ClientCertVerifier`]: rustls::server::danger::ClientCertVerifier
346#[cfg(feature = "tls-rustls")]
347pub(crate) struct PeerAllowlistVerifier {
348    inner: std::sync::Arc<dyn rustls::server::danger::ClientCertVerifier>,
349    allowlist: PeerAllowlist,
350}
351
352#[cfg(feature = "tls-rustls")]
353impl PeerAllowlistVerifier {
354    /// Build a verifier from a root cert store and a non-empty allowlist.
355    ///
356    /// # Errors
357    ///
358    /// - `RepError::ConfigError` if `allowlist` is empty.
359    /// - `RepError::ConfigError` if the `WebPkiClientVerifier` builder fails
360    ///   (e.g. the root store is empty or malformed).
361    pub(crate) fn new(
362        root_store: std::sync::Arc<rustls::RootCertStore>,
363        allowlist: PeerAllowlist,
364    ) -> crate::error::Result<Self> {
365        if allowlist.is_empty() {
366            return Err(crate::error::RepError::ConfigError(
367                "PeerAllowlistVerifier requires a non-empty allowlist; an \
368                 empty allowlist means no peer is authorised, which is almost \
369                 certainly a misconfiguration. Add at least one expected peer \
370                 subject name."
371                    .into(),
372            ));
373        }
374        let provider =
375            std::sync::Arc::new(rustls::crypto::ring::default_provider());
376        let inner =
377            rustls::server::WebPkiClientVerifier::builder_with_provider(
378                root_store, provider,
379            )
380            .build()
381            .map_err(|e| {
382                crate::error::RepError::ConfigError(format!(
383                    "PeerAllowlistVerifier: WebPkiClientVerifier build \
384                     failed: {e}"
385                ))
386            })?;
387        Ok(Self { inner, allowlist })
388    }
389}
390
391#[cfg(feature = "tls-rustls")]
392impl std::fmt::Debug for PeerAllowlistVerifier {
393    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394        f.debug_struct("PeerAllowlistVerifier")
395            .field("allowlist_len", &self.allowlist.len())
396            .finish()
397    }
398}
399
400#[cfg(feature = "tls-rustls")]
401impl rustls::server::danger::ClientCertVerifier for PeerAllowlistVerifier {
402    fn offer_client_auth(&self) -> bool {
403        true
404    }
405
406    fn client_auth_mandatory(&self) -> bool {
407        true
408    }
409
410    fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] {
411        self.inner.root_hint_subjects()
412    }
413
414    fn verify_client_cert(
415        &self,
416        end_entity: &rustls::pki_types::CertificateDer<'_>,
417        intermediates: &[rustls::pki_types::CertificateDer<'_>],
418        now: rustls::pki_types::UnixTime,
419    ) -> std::result::Result<
420        rustls::server::danger::ClientCertVerified,
421        rustls::Error,
422    > {
423        // Step 1: CA-rooted chain validation via rustls WebPki.
424        self.inner.verify_client_cert(end_entity, intermediates, now)?;
425
426        // Step 2: extract CN + SAN DNS names from the leaf cert.
427        let names = extract_cert_names(end_entity.as_ref());
428
429        // Step 3: allowlist check — at least one name must match.
430        if !self.allowlist.contains_any(&names) {
431            let peer_names = if names.is_empty() {
432                "<no names found in cert>".to_string()
433            } else {
434                names.join(", ")
435            };
436            log::warn!(
437                "mTLS: rejecting peer — cert names [{}] not in allowlist",
438                peer_names
439            );
440            return Err(rustls::Error::General(format!(
441                "peer certificate names [{peer_names}] do not match any \
442                 entry in the configured peer_allowlist"
443            )));
444        }
445
446        log::debug!("mTLS: peer cert names {:?} admitted by allowlist", names);
447        Ok(rustls::server::danger::ClientCertVerified::assertion())
448    }
449
450    fn verify_tls12_signature(
451        &self,
452        message: &[u8],
453        cert: &rustls::pki_types::CertificateDer<'_>,
454        dss: &rustls::DigitallySignedStruct,
455    ) -> std::result::Result<
456        rustls::client::danger::HandshakeSignatureValid,
457        rustls::Error,
458    > {
459        self.inner.verify_tls12_signature(message, cert, dss)
460    }
461
462    fn verify_tls13_signature(
463        &self,
464        message: &[u8],
465        cert: &rustls::pki_types::CertificateDer<'_>,
466        dss: &rustls::DigitallySignedStruct,
467    ) -> std::result::Result<
468        rustls::client::danger::HandshakeSignatureValid,
469        rustls::Error,
470    > {
471        self.inner.verify_tls13_signature(message, cert, dss)
472    }
473
474    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
475        self.inner.supported_verify_schemes()
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn empty_allowlist_admits_no_one() {
485        let al = PeerAllowlist::default();
486        assert!(al.is_empty());
487        assert!(!al.contains("anyone"));
488        assert!(!al.contains_any(["a", "b", "c"]));
489    }
490
491    #[test]
492    fn case_insensitive_match() {
493        let al = PeerAllowlist::new(["node-1.cluster.example"]);
494        assert!(al.contains("node-1.cluster.example"));
495        assert!(al.contains("Node-1.Cluster.Example"));
496        assert!(al.contains("NODE-1.CLUSTER.EXAMPLE"));
497        assert!(!al.contains("node-2.cluster.example"));
498    }
499
500    #[test]
501    fn whitespace_and_empties_filtered() {
502        let al = PeerAllowlist::new(["  node-1  ", "", "   ", "node-2"]);
503        assert_eq!(al.len(), 2);
504        assert!(al.contains("node-1"));
505        assert!(al.contains("node-2"));
506    }
507
508    #[test]
509    fn no_wildcard_match() {
510        // *.cluster.example must NOT match node-7.cluster.example —
511        // wildcards are deliberately unsupported.
512        let al = PeerAllowlist::new(["*.cluster.example"]);
513        assert!(!al.contains("node-7.cluster.example"));
514        // The literal "*.cluster.example" string still matches
515        // itself, which is fine (and useless): the rustls cert
516        // verifier never produces a SAN that contains a literal
517        // asterisk.
518        assert!(al.contains("*.cluster.example"));
519    }
520
521    #[test]
522    fn duplicates_collapsed() {
523        let al = PeerAllowlist::new(["node-1", "NODE-1", " node-1 "]);
524        assert_eq!(al.len(), 1);
525    }
526
527    #[test]
528    fn contains_any_admits_first_matching() {
529        let al = PeerAllowlist::new(["node-2"]);
530        assert!(al.contains_any(["nope", "node-2", "another"]));
531        assert!(!al.contains_any(["nope", "another"]));
532    }
533
534    #[test]
535    fn iter_yields_sorted_lowercase_entries() {
536        let al = PeerAllowlist::new(["beta", "ALPHA", "Charlie"]);
537        let v: Vec<&str> = al.iter().collect();
538        assert_eq!(v, vec!["alpha", "beta", "charlie"]);
539    }
540
541    #[test]
542    fn contains_trims_input_whitespace() {
543        let al = PeerAllowlist::new(["node-1"]);
544        assert!(al.contains("  node-1  "));
545        assert!(al.contains("\tnode-1\n"));
546    }
547
548    #[test]
549    fn allowlist_clone_is_independent() {
550        let al1 = PeerAllowlist::new(["a", "b"]);
551        let al2 = al1.clone();
552        assert_eq!(al1.len(), al2.len());
553        assert!(al1.contains("a") && al2.contains("a"));
554    }
555}