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("[email protected]", "[email protected]"));
353 }
354
355 #[test]
356 fn email_domain_case_insensitive() {
357 assert!(matches_email("[email protected]", "[email protected]"));
358 }
359
360 #[test]
361 fn email_local_part_case_sensitive() {
362 assert!(!matches_email("[email protected]", "[email protected]"));
363 }
364
365 #[test]
366 fn email_domain_only_pattern() {
367 assert!(matches_email("example.com", "[email protected]"));
368 assert!(matches_email("EXAMPLE.COM", "[email protected]"));
369 }
370
371 #[test]
372 fn email_no_match() {
373 assert!(!matches_email("[email protected]", "[email protected]"));
374 assert!(!matches_email("[email protected]", "[email protected]"));
375 }
376
377 #[test]
378 fn email_empty_inputs() {
379 assert!(!matches_email("", "[email protected]"));
380 assert!(!matches_email("[email protected]", ""));
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}