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}