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 the 2026 review,
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}