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.
166    // TODO: Replace with real X.509 parsing via `x509-cert` or `synta_certificate`.
167    let sans = extract_sans(cert_der);
168
169    if !sans.is_empty() {
170        // RFC 6125 §6.4.4: when SANs are present, check them exclusively.
171        let matched = check_sans_against_identity(&sans, expected);
172        return Ok(matched);
173    }
174
175    // No SANs — fall back to subject CN (deprecated per RFC 6125 §6.4.4).
176    let cn = extract_subject_cn(cert_der);
177    match cn {
178        Some(cn_value) => {
179            // Try DNS match on the CN.
180            if let Ok(ip) = expected.parse::<IpAddr>() {
181                // CN should not be used for IP matching per RFC 6125,
182                // but some legacy implementations do. We reject it.
183                let _ = ip;
184                Ok(false)
185            } else {
186                Ok(matches_domain(&cn_value, expected))
187            }
188        }
189        None => Ok(false),
190    }
191}
192
193// ── Internal helpers ─────────────────────────────────────────────────────────
194
195/// SAN entry types extracted from a certificate.
196#[derive(Debug, Clone)]
197#[allow(dead_code)]
198enum SanEntry {
199    /// dNSName (tag 2) — a DNS hostname or wildcard pattern.
200    Dns(String),
201    /// iPAddress (tag 7) — an IP address.
202    Ip(IpAddr),
203    /// rfc822Name (tag 1) — an email address.
204    Email(String),
205}
206
207/// Extract Subject Alternative Name entries from a DER-encoded certificate.
208///
209/// TODO: Replace with real ASN.1 parsing.  This is a placeholder that
210/// returns an empty list; the real implementation needs to parse the
211/// SAN extension (OID 2.5.29.17) from the TBSCertificate extensions.
212fn extract_sans(_cert_der: &[u8]) -> Vec<SanEntry> {
213    // Placeholder — real implementation parses X.509 SAN extension.
214    Vec::new()
215}
216
217/// Extract the subject Common Name from a DER-encoded certificate.
218///
219/// TODO: Replace with real ASN.1 parsing.
220fn extract_subject_cn(_cert_der: &[u8]) -> Option<String> {
221    // Placeholder — real implementation parses the subject RDN sequence
222    // and extracts the CN attribute (OID 2.5.4.3).
223    None
224}
225
226/// Check a list of SAN entries against an expected identity.
227fn check_sans_against_identity(sans: &[SanEntry], expected: &str) -> bool {
228    // Determine the type of expected identity.
229    if let Ok(expected_ip) = expected.parse::<IpAddr>() {
230        // IP address: match against iPAddress SANs.
231        sans.iter()
232            .any(|san| matches!(san, SanEntry::Ip(ip) if matches_ip(ip, &expected_ip)))
233    } else if expected.contains('@') {
234        // Email address: match against rfc822Name SANs.
235        sans.iter()
236            .any(|san| matches!(san, SanEntry::Email(e) if matches_email(e, expected)))
237    } else {
238        // DNS hostname: match against dNSName SANs.
239        sans.iter()
240            .any(|san| matches!(san, SanEntry::Dns(d) if matches_domain(d, expected)))
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use std::net::{Ipv4Addr, Ipv6Addr};
248
249    // ── matches_domain (RFC 6125 §6.4.3) ────────────────────────────────
250
251    #[test]
252    fn exact_domain_match() {
253        assert!(matches_domain("example.com", "example.com"));
254        assert!(matches_domain("example.com", "EXAMPLE.COM"));
255        assert!(matches_domain("Example.Com", "example.com"));
256    }
257
258    #[test]
259    fn exact_domain_no_match() {
260        assert!(!matches_domain("example.com", "other.com"));
261        assert!(!matches_domain("example.com", "sub.example.com"));
262    }
263
264    #[test]
265    fn wildcard_basic_match() {
266        assert!(matches_domain("*.example.com", "foo.example.com"));
267        assert!(matches_domain("*.example.com", "bar.example.com"));
268        assert!(matches_domain("*.EXAMPLE.COM", "foo.example.com"));
269    }
270
271    #[test]
272    fn wildcard_does_not_match_parent() {
273        // RFC 6125 §6.4.3: wildcard MUST NOT match the empty string.
274        assert!(!matches_domain("*.example.com", "example.com"));
275    }
276
277    #[test]
278    fn wildcard_does_not_cross_dots() {
279        // RFC 6125 §6.4.3: wildcard does not match across label boundaries.
280        assert!(!matches_domain("*.example.com", "foo.bar.example.com"));
281    }
282
283    #[test]
284    fn partial_wildcard_rejected() {
285        // RFC 6125 §6.4.3: partial wildcards are NOT allowed.
286        assert!(!matches_domain("f*.example.com", "foo.example.com"));
287    }
288
289    #[test]
290    fn wildcard_in_non_leftmost_label_rejected() {
291        assert!(!matches_domain("foo.*.example.com", "foo.bar.example.com"));
292    }
293
294    #[test]
295    fn empty_inputs() {
296        assert!(!matches_domain("", "example.com"));
297        assert!(!matches_domain("example.com", ""));
298        assert!(!matches_domain("", ""));
299    }
300
301    #[test]
302    fn trailing_dots_normalized() {
303        assert!(matches_domain("example.com.", "example.com"));
304        assert!(matches_domain("example.com", "example.com."));
305        assert!(matches_domain("*.example.com.", "foo.example.com."));
306    }
307
308    #[test]
309    fn punycode_a_labels() {
310        // IDN domains in A-label form.
311        assert!(matches_domain(
312            "xn--nxasmq6b.example.com",
313            "xn--nxasmq6b.example.com"
314        ));
315        assert!(matches_domain("*.xn--nxasmq6b.com", "foo.xn--nxasmq6b.com"));
316    }
317
318    // ── matches_ip (RFC 6125 §6.5.2) ────────────────────────────────────
319
320    #[test]
321    fn ipv4_match() {
322        let cert_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
323        let client_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
324        assert!(matches_ip(&cert_ip, &client_ip));
325    }
326
327    #[test]
328    fn ipv4_no_match() {
329        let cert_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
330        let client_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2));
331        assert!(!matches_ip(&cert_ip, &client_ip));
332    }
333
334    #[test]
335    fn ipv6_match() {
336        let cert_ip = IpAddr::V6(Ipv6Addr::LOCALHOST);
337        let client_ip = IpAddr::V6(Ipv6Addr::LOCALHOST);
338        assert!(matches_ip(&cert_ip, &client_ip));
339    }
340
341    #[test]
342    fn ipv4_vs_ipv6_no_match() {
343        let cert_ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
344        let client_ip = IpAddr::V6(Ipv6Addr::LOCALHOST);
345        assert!(!matches_ip(&cert_ip, &client_ip));
346    }
347
348    // ── matches_email ────────────────────────────────────────────────────
349
350    #[test]
351    fn email_exact_match() {
352        assert!(matches_email("user@example.com", "user@example.com"));
353    }
354
355    #[test]
356    fn email_domain_case_insensitive() {
357        assert!(matches_email("user@Example.COM", "user@example.com"));
358    }
359
360    #[test]
361    fn email_local_part_case_sensitive() {
362        assert!(!matches_email("User@example.com", "user@example.com"));
363    }
364
365    #[test]
366    fn email_domain_only_pattern() {
367        assert!(matches_email("example.com", "anyone@example.com"));
368        assert!(matches_email("EXAMPLE.COM", "user@example.com"));
369    }
370
371    #[test]
372    fn email_no_match() {
373        assert!(!matches_email("user@example.com", "user@other.com"));
374        assert!(!matches_email("alice@example.com", "bob@example.com"));
375    }
376
377    #[test]
378    fn email_empty_inputs() {
379        assert!(!matches_email("", "user@example.com"));
380        assert!(!matches_email("user@example.com", ""));
381    }
382
383    // ── validate_identity (RFC 6125 §6.4.4) ─────────────────────────────
384
385    #[test]
386    fn validate_identity_rejects_empty_cert() {
387        assert!(validate_identity(&[], "example.com").is_err());
388    }
389
390    #[test]
391    fn validate_identity_rejects_empty_expected() {
392        assert!(validate_identity(&[0x30], "").is_err());
393    }
394}