Skip to main content

kipuka/auth/
name_match.rs

1//! Domain name and identity matching for TLS certificates (RFC 6125).
2//!
3//! Implements the rules for verifying that a TLS certificate is valid for
4//! a given reference identifier (hostname, IP address, or email address).
5//!
6//! ## RFC 6125 compliance
7//!
8//! - **Section 6.4.1**: case-insensitive comparison for DNS names.
9//! - **Section 6.4.3**: wildcard matching — only the leftmost label may be
10//!   a wildcard (`*`), no partial wildcards, wildcard does not match dots.
11//! - **Section 6.4.4**: if SANs are present, the subject CN MUST be ignored.
12//! - **Section 6.5.2**: IP address matching via iPAddress SAN entries.
13//!
14//! ## Usage in Kipuka
15//!
16//! - POP linking in `/simpleenroll` and `/simplereenroll` (mTLS client cert
17//!   identity vs. CSR subject) — see [`super::mtls`].
18//! - EST server certificate validation by clients (informational; the
19//!   actual TLS validation is done by rustls, but this module provides
20//!   the matching logic for EST-specific identity checks).
21
22use std::net::IpAddr;
23
24/// Check whether a certificate DNS name pattern matches a hostname.
25///
26/// RFC 6125 Section 6.4.3 — wildcard matching rules:
27///
28/// 1. Only the leftmost label may be a wildcard: `*.example.com` is valid,
29///    `foo.*.example.com` is NOT.
30/// 2. No partial wildcards: `f*.example.com` is NOT allowed.
31/// 3. The wildcard does not match across label boundaries (dots):
32///    `*.example.com` matches `foo.example.com` but NOT `foo.bar.example.com`.
33/// 4. The wildcard MUST NOT match the empty string: `*.example.com` does NOT
34///    match `example.com`.
35///
36/// RFC 6125 Section 6.4.1: comparison is case-insensitive (ASCII fold).
37///
38/// IDN/A-labels (punycode): both pattern and hostname are compared in their
39/// A-label (ASCII-compatible encoding) form.  This function does not perform
40/// U-label to A-label conversion; callers must ensure both inputs use the
41/// same encoding.
42pub fn matches_domain(pattern: &str, hostname: &str) -> bool {
43    let pattern = pattern.to_ascii_lowercase();
44    let hostname = hostname.to_ascii_lowercase();
45
46    // Reject empty inputs.
47    if pattern.is_empty() || hostname.is_empty() {
48        return false;
49    }
50
51    // Strip trailing dots for comparison.
52    let pattern = pattern.trim_end_matches('.');
53    let hostname = hostname.trim_end_matches('.');
54
55    // Non-wildcard: exact match.
56    if !pattern.starts_with("*.") {
57        // Reject patterns that contain a wildcard in any position other
58        // than the leftmost label (e.g., "foo.*.example.com").
59        if pattern.contains('*') {
60            return false;
61        }
62        return pattern == hostname;
63    }
64
65    // Wildcard matching: pattern starts with "*.".
66    let wildcard_suffix = &pattern[2..]; // everything after "*."
67
68    // The suffix must not be empty (reject "*." alone).
69    if wildcard_suffix.is_empty() {
70        return false;
71    }
72
73    // Reject partial wildcards (the entire leftmost label must be "*").
74    // Since we already checked starts_with("*."), and the leftmost label
75    // is everything before the first dot, this is satisfied.
76
77    // Reject patterns with wildcards in non-leftmost positions.
78    if wildcard_suffix.contains('*') {
79        return false;
80    }
81
82    // RFC 6125 §6.4.3: the wildcard MUST NOT match the empty string,
83    // so `*.example.com` does not match `example.com`.
84    // The hostname must have at least one label before the suffix.
85    match hostname.strip_suffix(wildcard_suffix) {
86        None => false,
87        Some(prefix) => {
88            // The prefix must end with a dot (the separator between the
89            // matched label and the suffix) and contain exactly one label
90            // (no additional dots, since wildcard doesn't cross boundaries).
91            if !prefix.ends_with('.') {
92                return false;
93            }
94            let matched_label = &prefix[..prefix.len() - 1];
95            // The matched label must be non-empty and contain no dots.
96            !matched_label.is_empty() && !matched_label.contains('.')
97        }
98    }
99}
100
101/// Check whether a certificate iPAddress SAN matches a client IP address.
102///
103/// RFC 6125 Section 6.5.2: iPAddress SANs contain the binary encoding
104/// of the IP address (4 bytes for IPv4, 16 bytes for IPv6).  Matching
105/// is an exact binary comparison — no CIDR or subnet matching.
106pub fn matches_ip(cert_ip: &IpAddr, client_ip: &IpAddr) -> bool {
107    cert_ip == client_ip
108}
109
110/// Check whether a certificate rfc822Name SAN matches an email address.
111///
112/// RFC 6125 Section 6.4.4 / RFC 5280 Section 4.2.1.6:
113///
114/// - The local-part (before `@`) is case-sensitive per RFC 5321.
115/// - The domain-part (after `@`) is case-insensitive.
116/// - If the pattern is a bare domain (no `@`), it matches any email
117///   address at that domain.
118pub fn matches_email(pattern: &str, email: &str) -> bool {
119    if pattern.is_empty() || email.is_empty() {
120        return false;
121    }
122
123    match (pattern.split_once('@'), email.split_once('@')) {
124        // Pattern has local-part: full match required.
125        (Some((pat_local, pat_domain)), Some((email_local, email_domain))) => {
126            // Local-part: case-sensitive per RFC 5321.
127            // Domain-part: case-insensitive.
128            pat_local == email_local && pat_domain.eq_ignore_ascii_case(email_domain)
129        }
130        // Pattern is bare domain: matches any address at that domain.
131        (None, Some((_email_local, email_domain))) => pattern.eq_ignore_ascii_case(email_domain),
132        // No '@' in the email — malformed.
133        _ => false,
134    }
135}
136
137/// Validate that a DER-encoded certificate is authorized for a given identity.
138///
139/// RFC 6125 Section 6.4.4: the validation algorithm is:
140///
141/// 1. If the certificate contains Subject Alternative Name (SAN) entries,
142///    check each entry against the expected identity.  The subject CN is
143///    ignored entirely when SANs are present.
144/// 2. If no SANs are present, fall back to the subject Common Name (CN).
145///    This fallback is deprecated by RFC 6125 but still widely used.
146///
147/// The expected identity may be a DNS hostname, an IP address, or an
148/// email address.  The function determines the type by attempting to
149/// parse as an IP address first, then checking for `@` (email), then
150/// treating it as a DNS name.
151///
152/// # Returns
153///
154/// * `Ok(true)` — the certificate matches the expected identity.
155/// * `Ok(false)` — the certificate does not match.
156/// * `Err(...)` — the certificate could not be parsed.
157pub fn validate_identity(cert_der: &[u8], expected: &str) -> Result<bool, String> {
158    if cert_der.is_empty() {
159        return Err("empty certificate DER".into());
160    }
161    if expected.is_empty() {
162        return Err("empty expected identity".into());
163    }
164
165    // Extract SANs from the certificate via synta_certificate.
166    let sans = extract_sans(cert_der);
167
168    if !sans.is_empty() {
169        // RFC 6125 §6.4.4: when SANs are present, check them exclusively.
170        let matched = check_sans_against_identity(&sans, expected);
171        return Ok(matched);
172    }
173
174    // No SANs — fall back to subject CN (deprecated per RFC 6125 §6.4.4).
175    let cn = extract_subject_cn(cert_der);
176    match cn {
177        Some(cn_value) => {
178            // Try DNS match on the CN.
179            if let Ok(ip) = expected.parse::<IpAddr>() {
180                // CN should not be used for IP matching per RFC 6125,
181                // but some legacy implementations do. We reject it.
182                let _ = ip;
183                Ok(false)
184            } else {
185                Ok(matches_domain(&cn_value, expected))
186            }
187        }
188        None => Ok(false),
189    }
190}
191
192// ── Internal helpers ─────────────────────────────────────────────────────────
193
194/// SAN entry types extracted from a certificate.
195#[derive(Debug, Clone)]
196#[allow(dead_code)]
197enum SanEntry {
198    /// dNSName (tag 2) — a DNS hostname or wildcard pattern.
199    Dns(String),
200    /// iPAddress (tag 7) — an IP address.
201    Ip(IpAddr),
202    /// rfc822Name (tag 1) — an email address.
203    Email(String),
204}
205
206/// Extract Subject Alternative Name entries from a DER-encoded certificate.
207///
208/// Parses the SAN extension (OID 2.5.29.17) from the TBSCertificate using
209/// `synta_certificate::Certificate::subject_alt_names()`, which returns
210/// `(tag_number, content_bytes)` pairs per RFC 5280 §4.2.1.6.
211fn extract_sans(cert_der: &[u8]) -> Vec<SanEntry> {
212    let cert = match synta_certificate::Certificate::from_der(cert_der) {
213        Ok(c) => c,
214        Err(_) => return Vec::new(),
215    };
216
217    cert.subject_alt_names()
218        .into_iter()
219        .filter_map(|(tag, content)| match tag {
220            synta_certificate::general_name::DNS_NAME => {
221                // dNSName [2] — raw IA5String bytes (DNS hostname).
222                std::str::from_utf8(&content)
223                    .ok()
224                    .map(|s| SanEntry::Dns(s.to_string()))
225            }
226            synta_certificate::general_name::RFC822_NAME => {
227                // rfc822Name [1] — raw IA5String bytes (email address).
228                std::str::from_utf8(&content)
229                    .ok()
230                    .map(|s| SanEntry::Email(s.to_string()))
231            }
232            synta_certificate::general_name::IP_ADDRESS => {
233                // iPAddress [7] — 4 bytes (IPv4) or 16 bytes (IPv6).
234                match content.len() {
235                    4 => Some(SanEntry::Ip(IpAddr::from(
236                        <[u8; 4]>::try_from(&content[..]).unwrap(),
237                    ))),
238                    16 => Some(SanEntry::Ip(IpAddr::from(
239                        <[u8; 16]>::try_from(&content[..]).unwrap(),
240                    ))),
241                    _ => None, // malformed iPAddress
242                }
243            }
244            _ => None, // otherName, directoryName, URI, etc. — not used for identity matching
245        })
246        .collect()
247}
248
249/// Extract the subject Common Name from a DER-encoded certificate.
250///
251/// Parses the certificate subject RDN sequence using
252/// `synta_certificate::parse_name_attrs()` and returns the value of the
253/// CN attribute (OID 2.5.4.3).  If multiple CN attributes are present,
254/// returns the last one (matching RFC 4519 / LDAP convention for multi-valued RDNs).
255fn extract_subject_cn(cert_der: &[u8]) -> Option<String> {
256    let cert = synta_certificate::Certificate::from_der(cert_der).ok()?;
257    let subject_bytes = cert.tbs_certificate.subject.0;
258    let attrs = synta_certificate::parse_name_attrs(subject_bytes);
259
260    // OID 2.5.4.3 = commonName (CN).
261    attrs
262        .into_iter()
263        .rev()
264        .find(|(oid, _)| oid == "2.5.4.3")
265        .map(|(_, value)| value)
266}
267
268/// Check a list of SAN entries against an expected identity.
269fn check_sans_against_identity(sans: &[SanEntry], expected: &str) -> bool {
270    // Determine the type of expected identity.
271    if let Ok(expected_ip) = expected.parse::<IpAddr>() {
272        // IP address: match against iPAddress SANs.
273        sans.iter()
274            .any(|san| matches!(san, SanEntry::Ip(ip) if matches_ip(ip, &expected_ip)))
275    } else if expected.contains('@') {
276        // Email address: match against rfc822Name SANs.
277        sans.iter()
278            .any(|san| matches!(san, SanEntry::Email(e) if matches_email(e, expected)))
279    } else {
280        // DNS hostname: match against dNSName SANs.
281        sans.iter()
282            .any(|san| matches!(san, SanEntry::Dns(d) if matches_domain(d, expected)))
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use std::net::{Ipv4Addr, Ipv6Addr};
290
291    // ── matches_domain (RFC 6125 §6.4.3) ────────────────────────────────
292
293    #[test]
294    fn exact_domain_match() {
295        assert!(matches_domain("example.com", "example.com"));
296        assert!(matches_domain("example.com", "EXAMPLE.COM"));
297        assert!(matches_domain("Example.Com", "example.com"));
298    }
299
300    #[test]
301    fn exact_domain_no_match() {
302        assert!(!matches_domain("example.com", "other.com"));
303        assert!(!matches_domain("example.com", "sub.example.com"));
304    }
305
306    #[test]
307    fn wildcard_basic_match() {
308        assert!(matches_domain("*.example.com", "foo.example.com"));
309        assert!(matches_domain("*.example.com", "bar.example.com"));
310        assert!(matches_domain("*.EXAMPLE.COM", "foo.example.com"));
311    }
312
313    #[test]
314    fn wildcard_does_not_match_parent() {
315        // RFC 6125 §6.4.3: wildcard MUST NOT match the empty string.
316        assert!(!matches_domain("*.example.com", "example.com"));
317    }
318
319    #[test]
320    fn wildcard_does_not_cross_dots() {
321        // RFC 6125 §6.4.3: wildcard does not match across label boundaries.
322        assert!(!matches_domain("*.example.com", "foo.bar.example.com"));
323    }
324
325    #[test]
326    fn partial_wildcard_rejected() {
327        // RFC 6125 §6.4.3: partial wildcards are NOT allowed.
328        assert!(!matches_domain("f*.example.com", "foo.example.com"));
329    }
330
331    #[test]
332    fn wildcard_in_non_leftmost_label_rejected() {
333        assert!(!matches_domain("foo.*.example.com", "foo.bar.example.com"));
334    }
335
336    #[test]
337    fn empty_inputs() {
338        assert!(!matches_domain("", "example.com"));
339        assert!(!matches_domain("example.com", ""));
340        assert!(!matches_domain("", ""));
341    }
342
343    #[test]
344    fn trailing_dots_normalized() {
345        assert!(matches_domain("example.com.", "example.com"));
346        assert!(matches_domain("example.com", "example.com."));
347        assert!(matches_domain("*.example.com.", "foo.example.com."));
348    }
349
350    #[test]
351    fn punycode_a_labels() {
352        // IDN domains in A-label form.
353        assert!(matches_domain(
354            "xn--nxasmq6b.example.com",
355            "xn--nxasmq6b.example.com"
356        ));
357        assert!(matches_domain("*.xn--nxasmq6b.com", "foo.xn--nxasmq6b.com"));
358    }
359
360    // ── matches_ip (RFC 6125 §6.5.2) ────────────────────────────────────
361
362    #[test]
363    fn ipv4_match() {
364        let cert_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
365        let client_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
366        assert!(matches_ip(&cert_ip, &client_ip));
367    }
368
369    #[test]
370    fn ipv4_no_match() {
371        let cert_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
372        let client_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2));
373        assert!(!matches_ip(&cert_ip, &client_ip));
374    }
375
376    #[test]
377    fn ipv6_match() {
378        let cert_ip = IpAddr::V6(Ipv6Addr::LOCALHOST);
379        let client_ip = IpAddr::V6(Ipv6Addr::LOCALHOST);
380        assert!(matches_ip(&cert_ip, &client_ip));
381    }
382
383    #[test]
384    fn ipv4_vs_ipv6_no_match() {
385        let cert_ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
386        let client_ip = IpAddr::V6(Ipv6Addr::LOCALHOST);
387        assert!(!matches_ip(&cert_ip, &client_ip));
388    }
389
390    // ── matches_email ────────────────────────────────────────────────────
391
392    #[test]
393    fn email_exact_match() {
394        assert!(matches_email("user@example.com", "user@example.com"));
395    }
396
397    #[test]
398    fn email_domain_case_insensitive() {
399        assert!(matches_email("user@Example.COM", "user@example.com"));
400    }
401
402    #[test]
403    fn email_local_part_case_sensitive() {
404        assert!(!matches_email("User@example.com", "user@example.com"));
405    }
406
407    #[test]
408    fn email_domain_only_pattern() {
409        assert!(matches_email("example.com", "anyone@example.com"));
410        assert!(matches_email("EXAMPLE.COM", "user@example.com"));
411    }
412
413    #[test]
414    fn email_no_match() {
415        assert!(!matches_email("user@example.com", "user@other.com"));
416        assert!(!matches_email("alice@example.com", "bob@example.com"));
417    }
418
419    #[test]
420    fn email_empty_inputs() {
421        assert!(!matches_email("", "user@example.com"));
422        assert!(!matches_email("user@example.com", ""));
423    }
424
425    // ── validate_identity (RFC 6125 §6.4.4) ─────────────────────────────
426
427    #[test]
428    fn validate_identity_rejects_empty_cert() {
429        assert!(validate_identity(&[], "example.com").is_err());
430    }
431
432    #[test]
433    fn validate_identity_rejects_empty_expected() {
434        assert!(validate_identity(&[0x30], "").is_err());
435    }
436}