Skip to main content

kipuka/auth/
enroll_authz.rs

1//! Enrollment authorization: bind the authenticated requester to the CSR and
2//! enforce per-label name-authorization policy (NIAP CA PP FDP_ACF.1).
3//!
4//! RFC 7030 authenticates the *requester* (via mTLS, OTP, or GSSAPI) but says
5//! little about *what* that requester may ask for.  Without an explicit binding
6//! an OTP holder provisioned for `device-a.example.com` can submit a CSR whose
7//! subject/SAN names `device-b.example.com` and receive a valid certificate for
8//! an identity it was never authorized to hold.  This module closes that gap.
9//!
10//! Two independent controls, both driven by the per-label configuration:
11//!
12//! 1. **Identity binding** (`require_cn_match`, `require_san_match`) — the
13//!    authenticated identity MUST appear in the CSR, either as the subject
14//!    Common Name or as a Subject Alternative Name entry.
15//! 2. **Name authorization** (`permitted_dns_names`, `permitted_ip_addresses`,
16//!    `permitted_emails`) — an allowlist applied to the requested SANs.  When a
17//!    list is non-empty, every SAN of that type in the CSR MUST match at least
18//!    one entry, so a label can be constrained to a bounded namespace.
19//!
20//! The matching primitives are the RFC 6125 matchers in [`super::name_match`],
21//! shared with mTLS POP linking so identity comparison is consistent across the
22//! server (wildcards, case folding, IP binary compare, email local/domain
23//! case rules).
24//!
25//! When no control is configured (all fields false/empty), `authorize_csr` is a
26//! no-op — existing deployments keep their current behavior until an operator
27//! opts in.  NIAP-evaluated deployments set `require_cn_match` or
28//! `require_san_match` to satisfy FDP_ACF.1.
29
30use std::net::IpAddr;
31
32use synta_certificate::csr::CertificationRequest;
33
34use super::name_match::{matches_domain, matches_email, matches_ip};
35
36/// Per-label enrollment authorization policy.
37///
38/// Borrowed from an [`crate::routes::LabelExtractor`] for the lifetime of a
39/// single enrollment request; no allocation.
40#[derive(Debug, Clone, Copy)]
41pub struct EnrollAuthzPolicy<'a> {
42    /// Require the CSR subject Common Name to equal the authenticated identity
43    /// (case-insensitive).  FDP_ACF.1 identity binding.
44    pub require_cn_match: bool,
45    /// Require the authenticated identity to appear as a Subject Alternative
46    /// Name entry of the matching type.  FDP_ACF.1 identity binding.
47    pub require_san_match: bool,
48    /// Allowlist of permitted dNSName patterns (RFC 6125).  When non-empty,
49    /// every dNSName SAN in the CSR must match one entry.
50    pub permitted_dns_names: &'a [String],
51    /// Allowlist of permitted iPAddress SANs (textual form).  When non-empty,
52    /// every iPAddress SAN in the CSR must equal one entry.
53    pub permitted_ip_addresses: &'a [String],
54    /// Allowlist of permitted rfc822Name patterns.  When non-empty, every
55    /// rfc822Name SAN in the CSR must match one entry.
56    pub permitted_emails: &'a [String],
57}
58
59impl EnrollAuthzPolicy<'_> {
60    /// `true` when the policy enforces nothing (all controls off/empty).
61    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
70/// Authorize a parsed CSR against the authenticated identity and label policy.
71///
72/// Returns `Ok(())` when the request is authorized, or `Err(reason)` with a
73/// human-readable explanation suitable for a `403 Forbidden` response and an
74/// `enroll.reject` audit detail.  The reason never echoes secret material —
75/// only the requested names, which the client already knows.
76pub 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
90/// Pure authorization decision over already-extracted CSR identifiers.
91///
92/// Separated from [`authorize_csr`] so the policy logic can be unit-tested with
93/// hand-built identifiers without constructing and signing a full CSR.
94fn check_names(
95    cn: Option<&str>,
96    sans: &[(u32, Vec<u8>)],
97    identity: &str,
98    policy: &EnrollAuthzPolicy,
99) -> Result<(), String> {
100    // ── Identity binding (FDP_ACF.1) ───────────────────────────────────────
101    // An identity-binding policy is meaningless without an authenticated
102    // identity.  With an empty identity, an empty CSR Common Name would
103    // "match" it (both `""`) and let an unauthenticated requester satisfy
104    // `require_cn_match`.  Fail closed: when the label demands identity
105    // binding, require that an identity was actually presented.
106    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    // ── Name authorization allowlist (FDP_ACF.1) ───────────────────────────
136    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
184/// Authorize a DER-encoded CSR against the authenticated identity and label
185/// policy.  Convenience wrapper over [`authorize_csr`] for callers that hold
186/// raw CSR bytes rather than a parsed [`CertificationRequest`].
187///
188/// A malformed CSR is reported as an authorization failure (the enroll handler
189/// that parses it for issuance will surface the parse error in detail).
190pub 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
203/// Extract the subject Common Name (OID 2.5.4.3) from a CSR.
204///
205/// Returns the last CN when multiple are present, matching the LDAP/RFC 4519
206/// convention used by [`super::name_match`] for certificates.
207fn 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
216/// Extract Subject Alternative Names from a CSR's `extensionRequest` attribute
217/// (PKCS#9 OID 1.2.840.113549.1.9.14) as `(tag, content)` pairs, exactly as
218/// [`synta_certificate::parse_general_names`] returns for a certificate.
219fn 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        // extensionRequest values: SET OF Extensions; each value is a
228        // SEQUENCE OF Extension — exactly what find_extension_value scans.
229        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
241/// Whether the authenticated identity appears among the CSR SAN entries.
242///
243/// The identity type is inferred (IP → email → DNS) and matched against SAN
244/// entries of the corresponding type using the RFC 6125 matchers.  SAN entries
245/// act as the pattern (so a wildcard dNSName may cover the identity).
246fn 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
265/// Decode an iPAddress SAN content (4 or 16 raw bytes) into an [`IpAddr`].
266fn 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    // Tests target the pure `check_names` decision with hand-built identifiers,
280    // so the policy logic is exercised without constructing and signing a CSR.
281    // CSR parsing (`csr_subject_cn`/`csr_sans`) is covered by the integration
282    // suite that issues certificates end-to-end.
283
284    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        // Case-insensitive.
328        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        // No CN present at all is a rejection, not a bypass.
355        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        // A wildcard dNSName SAN in the CSR may cover the bound identity.
385        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        // Every dNSName must be permitted — one rogue name fails the request.
402        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        // permitted_dns_names is set but the CSR only has an email SAN; with no
420        // permitted_emails list, the email SAN is unconstrained.
421        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}