Skip to main content

ntex_tls/x509/
auth.rs

1//! Client certificate authentication checks.
2//!
3//! Policy-free building blocks used by the broker: name matching against the subject CN or a
4//! SAN kind, thumbprint allow-lists and validity period checks. Trust (chain) verification is
5//! in [`crate::x509::trust`]. Every failure is a distinct [`Failure`] so callers can map it to their
6//! own error codes.
7//!
8//! Decisions:
9//! - Name comparison is case-insensitive (Unicode lowercase of both sides).
10//! - Subject matching uses the **first** CN attribute only.
11//! - SAN matching succeeds if **any** entry of the kind matches.
12//! - IP matching compares parsed addresses (`::0:1` equals `::1`); SAN IP entries that are
13//!   not 4 or 16 octets are ignored when matching, but rejected by [`field`].
14//! - Thumbprints are hex, compared case-insensitively; exactly 40 characters selects SHA-1,
15//!   anything else SHA-256 (see [`crate::x509::digest::Algorithm::for_hex_thumbprint`]).
16use std::net::IpAddr;
17use std::time::SystemTime;
18
19use crate::x509::{Certificate, digest::Algorithm};
20
21/// Certificate field used as the client name.
22#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
23pub enum NameField {
24    /// First subject common name.
25    Subject,
26    /// SAN `dNSName`.
27    Dns,
28    /// SAN `uniformResourceIdentifier`.
29    Uri,
30    /// SAN `iPAddress`.
31    Ip,
32    /// SAN `rfc822Name`.
33    Email,
34}
35
36/// Authentication check failure.
37#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
38pub enum Failure {
39    /// Client name does not match the certificate field.
40    Mismatch(NameField),
41    /// Client name is not an IP address (for [`NameField::Ip`]).
42    InvalidClientIp,
43    /// More than one SAN entry of the kind (see [`field`]).
44    MultipleSan(NameField),
45    /// SAN `iPAddress` entry is neither 4 nor 16 octets.
46    MalformedSanIp,
47    /// Current time is before `notBefore`.
48    NotYetValid,
49    /// Current time is after `notAfter`.
50    Expired,
51    /// No allowed thumbprint matches the certificate.
52    ThumbprintMismatch,
53}
54
55/// Checks that `name` matches the certificate `field`.
56///
57/// # Errors
58/// [`Failure::Mismatch`], or [`Failure::InvalidClientIp`] if `field` is [`NameField::Ip`] and
59/// `name` is not an IP address.
60pub fn name_matches(cert: &Certificate, field: NameField, name: &str) -> Result<(), Failure> {
61    let eq = |v: &str| v.to_lowercase() == name.to_lowercase();
62    let san = cert.san();
63    let matched = match field {
64        NameField::Subject => cert.subject_cn().is_some_and(eq),
65        NameField::Dns => san.dns.iter().any(|v| eq(v)),
66        NameField::Uri => san.uri.iter().any(|v| eq(v)),
67        NameField::Email => san.email.iter().any(|v| eq(v)),
68        NameField::Ip => {
69            let ip: IpAddr = name.parse().map_err(|_| Failure::InvalidClientIp)?;
70            san.ip.iter().any(|v| v.addr() == Some(ip))
71        }
72    };
73    if matched {
74        Ok(())
75    } else {
76        Err(Failure::Mismatch(field))
77    }
78}
79
80/// Returns the client name stored in the certificate `field`, lowercased.
81///
82/// `None` if the field is absent. IP addresses are formatted with [`IpAddr`]'s `Display`.
83///
84/// # Errors
85/// [`Failure::MultipleSan`] if there is more than one SAN entry of the kind,
86/// [`Failure::MalformedSanIp`] for an invalid `iPAddress` entry.
87pub fn field(cert: &Certificate, field: NameField) -> Result<Option<String>, Failure> {
88    fn single<T>(v: &[T], field: NameField) -> Result<Option<&T>, Failure> {
89        match v {
90            [] => Ok(None),
91            [v] => Ok(Some(v)),
92            _ => Err(Failure::MultipleSan(field)),
93        }
94    }
95    let san = cert.san();
96    Ok(match field {
97        NameField::Subject => cert.subject_cn().map(str::to_lowercase),
98        NameField::Dns => single(&san.dns, field)?.map(|v| v.to_lowercase()),
99        NameField::Uri => single(&san.uri, field)?.map(|v| v.to_lowercase()),
100        NameField::Email => single(&san.email, field)?.map(|v| v.to_lowercase()),
101        NameField::Ip => match single(&san.ip, field)? {
102            Some(ip) => Some(ip.addr().ok_or(Failure::MalformedSanIp)?.to_string()),
103            None => None,
104        },
105    })
106}
107
108/// Checks that `now` is within the certificate validity period (bounds inclusive) and
109/// returns `notAfter`.
110///
111/// # Errors
112/// [`Failure::NotYetValid`] or [`Failure::Expired`].
113pub fn validity(cert: &Certificate, now: SystemTime) -> Result<SystemTime, Failure> {
114    if now < cert.not_before() {
115        Err(Failure::NotYetValid)
116    } else if now > cert.not_after() {
117        Err(Failure::Expired)
118    } else {
119        Ok(cert.not_after())
120    }
121}
122
123/// Returns `true` if any of the hex `thumbprints` matches the certificate.
124pub fn thumbprint_matches<'a>(
125    cert: &Certificate,
126    thumbprints: impl IntoIterator<Item = &'a str>,
127) -> bool {
128    thumbprints.into_iter().any(|t| {
129        let alg = Algorithm::for_hex_thumbprint(t);
130        hex::decode(t).is_ok_and(|t| t.as_slice() == cert.digest(alg))
131    })
132}
133
134/// Checks validity period and thumbprint allow-list, returns `notAfter`.
135///
136/// # Errors
137/// [`Failure::NotYetValid`], [`Failure::Expired`] or [`Failure::ThumbprintMismatch`].
138pub fn verify_thumbprint<'a>(
139    cert: &Certificate,
140    thumbprints: impl IntoIterator<Item = &'a str>,
141    now: SystemTime,
142) -> Result<SystemTime, Failure> {
143    let expires = validity(cert, now)?;
144    if thumbprint_matches(cert, thumbprints) {
145        Ok(expires)
146    } else {
147        Err(Failure::ThumbprintMismatch)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use std::time::Duration;
154
155    use super::*;
156    use crate::x509::testgen::{self, San};
157
158    fn cert(san: &San<'_>) -> Certificate {
159        testgen::cert("Client1", false, None, &testgen::key(), san, (-1, 1))
160    }
161
162    /// STATEMENT: Name matching normalizes case and addresses but rejects ambiguous SAN fields.
163    ///
164    /// HOW:
165    /// - Generate subject-only and multi-SAN certificates and check matches and field errors.
166    #[test]
167    fn names() {
168        let c = cert(&San::default());
169        assert_eq!(name_matches(&c, NameField::Subject, "CLIENT1"), Ok(()));
170        assert_eq!(
171            name_matches(&c, NameField::Dns, "client1"),
172            Err(Failure::Mismatch(NameField::Dns))
173        );
174        assert_eq!(
175            name_matches(&c, NameField::Ip, "x"),
176            Err(Failure::InvalidClientIp)
177        );
178        assert_eq!(field(&c, NameField::Subject), Ok(Some("client1".into())));
179        assert_eq!(field(&c, NameField::Email), Ok(None));
180
181        let c = cert(&San {
182            dns: &["A.b", "c.d"],
183            email: &["X@y"],
184            ip: &["::1".parse().unwrap()],
185            ..San::default()
186        });
187        assert_eq!(name_matches(&c, NameField::Dns, "c.D"), Ok(()));
188        assert_eq!(name_matches(&c, NameField::Ip, "::0:1"), Ok(()));
189        assert_eq!(
190            field(&c, NameField::Dns),
191            Err(Failure::MultipleSan(NameField::Dns))
192        );
193        assert_eq!(field(&c, NameField::Ip), Ok(Some("::1".into())));
194        assert_eq!(field(&c, NameField::Email), Ok(Some("x@y".into())));
195    }
196
197    /// STATEMENT: Thumbprint authorization requires both a matching digest and valid dates.
198    ///
199    /// HOW:
200    /// - Check both hash algorithms, malformed/mismatched values, and times outside validity.
201    #[test]
202    fn thumbprints_and_validity() {
203        let c = cert(&San::default());
204        let now = SystemTime::now();
205        let sha1 = c.thumbprint(Algorithm::Sha1).to_uppercase();
206        let sha256 = c.thumbprint(Algorithm::Sha256);
207        assert_eq!(
208            verify_thumbprint(&c, [sha1.as_str()], now),
209            Ok(c.not_after())
210        );
211        assert_eq!(
212            verify_thumbprint(&c, ["zz", sha256.as_str()], now),
213            Ok(c.not_after())
214        );
215        assert_eq!(
216            verify_thumbprint(&c, [&sha256[2..]], now),
217            Err(Failure::ThumbprintMismatch)
218        );
219        let day = Duration::from_hours(48);
220        assert_eq!(
221            verify_thumbprint(&c, [sha1.as_str()], now - day),
222            Err(Failure::NotYetValid)
223        );
224        assert_eq!(
225            verify_thumbprint(&c, [sha1.as_str()], now + day),
226            Err(Failure::Expired)
227        );
228    }
229}