1use std::net::IpAddr;
31
32use synta_certificate::csr::CertificationRequest;
33
34use super::name_match::{matches_domain, matches_email, matches_ip};
35
36#[derive(Debug, Clone, Copy)]
41pub struct EnrollAuthzPolicy<'a> {
42 pub require_cn_match: bool,
45 pub require_san_match: bool,
48 pub permitted_dns_names: &'a [String],
51 pub permitted_ip_addresses: &'a [String],
54 pub permitted_emails: &'a [String],
57}
58
59impl EnrollAuthzPolicy<'_> {
60 pub fn is_noop(&self) -> bool {
62 !self.require_cn_match
63 && !self.require_san_match
64 && self.permitted_dns_names.is_empty()
65 && self.permitted_ip_addresses.is_empty()
66 && self.permitted_emails.is_empty()
67 }
68}
69
70pub fn authorize_csr(
77 csr: &CertificationRequest,
78 identity: &str,
79 policy: &EnrollAuthzPolicy,
80) -> Result<(), String> {
81 if policy.is_noop() {
82 return Ok(());
83 }
84
85 let cn = csr_subject_cn(csr);
86 let sans = csr_sans(csr);
87 check_names(cn.as_deref(), &sans, identity, policy)
88}
89
90fn check_names(
95 cn: Option<&str>,
96 sans: &[(u32, Vec<u8>)],
97 identity: &str,
98 policy: &EnrollAuthzPolicy,
99) -> Result<(), String> {
100 if (policy.require_cn_match || policy.require_san_match) && identity.is_empty() {
107 return Err(
108 "enrollment authorization requires an authenticated identity, but none was presented"
109 .to_string(),
110 );
111 }
112
113 if policy.require_cn_match {
114 match cn {
115 Some(cn) if cn.eq_ignore_ascii_case(identity) => {}
116 Some(cn) => {
117 return Err(format!(
118 "CSR Common Name {cn:?} does not match authenticated identity {identity:?}"
119 ));
120 }
121 None => {
122 return Err(
123 "require_cn_match is set but the CSR carries no Common Name".to_string()
124 );
125 }
126 }
127 }
128
129 if policy.require_san_match && !identity_in_sans(sans, identity) {
130 return Err(format!(
131 "authenticated identity {identity:?} is not present in the CSR subjectAltName"
132 ));
133 }
134
135 for (tag, content) in sans {
137 match *tag {
138 synta_certificate::general_name::DNS_NAME if !policy.permitted_dns_names.is_empty() => {
139 let name = std::str::from_utf8(content)
140 .map_err(|_| "CSR contains a non-UTF-8 dNSName SAN".to_string())?;
141 if !policy
142 .permitted_dns_names
143 .iter()
144 .any(|pat| matches_domain(pat, name))
145 {
146 return Err(format!("dNSName {name:?} is not permitted for this label"));
147 }
148 }
149 synta_certificate::general_name::RFC822_NAME if !policy.permitted_emails.is_empty() => {
150 let email = std::str::from_utf8(content)
151 .map_err(|_| "CSR contains a non-UTF-8 rfc822Name SAN".to_string())?;
152 if !policy
153 .permitted_emails
154 .iter()
155 .any(|pat| matches_email(pat, email))
156 {
157 return Err(format!(
158 "rfc822Name {email:?} is not permitted for this label"
159 ));
160 }
161 }
162 synta_certificate::general_name::IP_ADDRESS
163 if !policy.permitted_ip_addresses.is_empty() =>
164 {
165 let ip = san_ip(content)
166 .ok_or_else(|| "CSR contains a malformed iPAddress SAN".to_string())?;
167 let permitted = policy.permitted_ip_addresses.iter().any(|entry| {
168 entry
169 .parse::<IpAddr>()
170 .map(|p| matches_ip(&p, &ip))
171 .unwrap_or(false)
172 });
173 if !permitted {
174 return Err(format!("iPAddress {ip} is not permitted for this label"));
175 }
176 }
177 _ => {}
178 }
179 }
180
181 Ok(())
182}
183
184pub fn authorize_csr_der(
191 csr_der: &[u8],
192 identity: &str,
193 policy: &EnrollAuthzPolicy,
194) -> Result<(), String> {
195 if policy.is_noop() {
196 return Ok(());
197 }
198 let csr = CertificationRequest::from_der(csr_der)
199 .map_err(|e| format!("CSR parse failed during authorization: {e}"))?;
200 authorize_csr(&csr, identity, policy)
201}
202
203fn csr_subject_cn(csr: &CertificationRequest) -> Option<String> {
208 let subject_der = csr.certification_request_info.subject.to_der().ok()?;
209 synta_certificate::parse_name_attrs(&subject_der)
210 .into_iter()
211 .rev()
212 .find(|(oid, _)| oid == "2.5.4.3")
213 .map(|(_, value)| value)
214}
215
216fn csr_sans(csr: &CertificationRequest) -> Vec<(u32, Vec<u8>)> {
220 let Some(attrs) = csr.certification_request_info.attributes.as_ref() else {
221 return Vec::new();
222 };
223 for attr in attrs.elements() {
224 if attr.attr_type.components() != synta_certificate::oids::PKCS9_EXTENSION_REQUEST {
225 continue;
226 }
227 for value in attr.attr_values.elements() {
230 if let Some(san_value) = synta_certificate::find_extension_value(
231 value.as_bytes(),
232 synta_certificate::oids::SUBJECT_ALT_NAME,
233 ) {
234 return synta_certificate::parse_general_names(san_value);
235 }
236 }
237 }
238 Vec::new()
239}
240
241fn identity_in_sans(sans: &[(u32, Vec<u8>)], identity: &str) -> bool {
247 if let Ok(ip) = identity.parse::<IpAddr>() {
248 sans.iter().any(|(tag, content)| {
249 *tag == synta_certificate::general_name::IP_ADDRESS
250 && san_ip(content).is_some_and(|s| matches_ip(&s, &ip))
251 })
252 } else if identity.contains('@') {
253 sans.iter().any(|(tag, content)| {
254 *tag == synta_certificate::general_name::RFC822_NAME
255 && std::str::from_utf8(content).is_ok_and(|s| matches_email(s, identity))
256 })
257 } else {
258 sans.iter().any(|(tag, content)| {
259 *tag == synta_certificate::general_name::DNS_NAME
260 && std::str::from_utf8(content).is_ok_and(|s| matches_domain(s, identity))
261 })
262 }
263}
264
265fn san_ip(content: &[u8]) -> Option<IpAddr> {
267 match content.len() {
268 4 => Some(IpAddr::from(<[u8; 4]>::try_from(content).ok()?)),
269 16 => Some(IpAddr::from(<[u8; 16]>::try_from(content).ok()?)),
270 _ => None,
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use synta_certificate::general_name::{DNS_NAME, IP_ADDRESS, RFC822_NAME};
278
279 fn dns(name: &str) -> (u32, Vec<u8>) {
285 (DNS_NAME, name.as_bytes().to_vec())
286 }
287 fn email(addr: &str) -> (u32, Vec<u8>) {
288 (RFC822_NAME, addr.as_bytes().to_vec())
289 }
290 fn ipv4(a: u8, b: u8, c: u8, d: u8) -> (u32, Vec<u8>) {
291 (IP_ADDRESS, vec![a, b, c, d])
292 }
293
294 fn empty_policy() -> EnrollAuthzPolicy<'static> {
295 EnrollAuthzPolicy {
296 require_cn_match: false,
297 require_san_match: false,
298 permitted_dns_names: &[],
299 permitted_ip_addresses: &[],
300 permitted_emails: &[],
301 }
302 }
303
304 #[test]
305 fn noop_policy_allows_anything() {
306 let policy = empty_policy();
307 assert!(policy.is_noop());
308 let sans = [dns("device-a.example.com")];
309 assert!(check_names(Some("device-b.example.com"), &sans, "someone-else", &policy).is_ok());
310 }
311
312 #[test]
313 fn cn_match_accepts_matching_identity() {
314 let policy = EnrollAuthzPolicy {
315 require_cn_match: true,
316 ..empty_policy()
317 };
318 assert!(
319 check_names(
320 Some("device-a.example.com"),
321 &[],
322 "device-a.example.com",
323 &policy
324 )
325 .is_ok()
326 );
327 assert!(
329 check_names(
330 Some("device-a.example.com"),
331 &[],
332 "DEVICE-A.EXAMPLE.COM",
333 &policy
334 )
335 .is_ok()
336 );
337 }
338
339 #[test]
340 fn cn_match_rejects_mismatch_and_missing_cn() {
341 let policy = EnrollAuthzPolicy {
342 require_cn_match: true,
343 ..empty_policy()
344 };
345 assert!(
346 check_names(
347 Some("device-b.example.com"),
348 &[],
349 "device-a.example.com",
350 &policy
351 )
352 .is_err()
353 );
354 assert!(check_names(None, &[], "device-a.example.com", &policy).is_err());
356 }
357
358 #[test]
359 fn san_match_requires_identity_in_san() {
360 let policy = EnrollAuthzPolicy {
361 require_san_match: true,
362 ..empty_policy()
363 };
364 let sans = [dns("device-a.example.com"), dns("alt.example.com")];
365 assert!(check_names(Some("unused"), &sans, "device-a.example.com", &policy).is_ok());
366 assert!(check_names(Some("unused"), &sans, "device-x.example.com", &policy).is_err());
367 }
368
369 #[test]
370 fn san_match_covers_ip_and_email_identities() {
371 let policy = EnrollAuthzPolicy {
372 require_san_match: true,
373 ..empty_policy()
374 };
375 let sans = [ipv4(10, 0, 0, 1), email("device@example.com")];
376 assert!(check_names(None, &sans, "10.0.0.1", &policy).is_ok());
377 assert!(check_names(None, &sans, "10.0.0.2", &policy).is_err());
378 assert!(check_names(None, &sans, "device@example.com", &policy).is_ok());
379 assert!(check_names(None, &sans, "other@example.com", &policy).is_err());
380 }
381
382 #[test]
383 fn wildcard_san_covers_identity() {
384 let policy = EnrollAuthzPolicy {
386 require_san_match: true,
387 ..empty_policy()
388 };
389 let sans = [dns("*.example.com")];
390 assert!(check_names(None, &sans, "device-a.example.com", &policy).is_ok());
391 }
392
393 #[test]
394 fn permitted_dns_allowlist_bounds_the_namespace() {
395 let allow = vec!["*.example.com".to_string()];
396 let policy = EnrollAuthzPolicy {
397 permitted_dns_names: &allow,
398 ..empty_policy()
399 };
400 assert!(check_names(Some("cn"), &[dns("device-a.example.com")], "id", &policy).is_ok());
401 let mixed = [dns("device-a.example.com"), dns("device-a.evil.com")];
403 assert!(check_names(Some("cn"), &mixed, "id", &policy).is_err());
404 }
405
406 #[test]
407 fn permitted_ip_allowlist_matches_binary() {
408 let allow = vec!["10.0.0.0".to_string(), "10.0.0.1".to_string()];
409 let policy = EnrollAuthzPolicy {
410 permitted_ip_addresses: &allow,
411 ..empty_policy()
412 };
413 assert!(check_names(None, &[ipv4(10, 0, 0, 1)], "id", &policy).is_ok());
414 assert!(check_names(None, &[ipv4(10, 0, 0, 9)], "id", &policy).is_err());
415 }
416
417 #[test]
418 fn allowlist_ignores_san_types_without_a_list() {
419 let allow = vec!["*.example.com".to_string()];
422 let policy = EnrollAuthzPolicy {
423 permitted_dns_names: &allow,
424 ..empty_policy()
425 };
426 assert!(check_names(Some("cn"), &[email("anyone@anywhere.test")], "id", &policy).is_ok());
427 }
428}