Skip to main content

ic_auth/
certificate_issuance.rs

1//! Pure certificate issuance rules, without signing, time or authority admission.
2//!
3//! The host supplies the expected Root and bounds from protected configuration.
4//! Success checks shape and binding bytes only; it does not authenticate a
5//! certificate, authorize issuance or install a delegation. No field is normalized.
6
7use crate::canonical::{CanonicalAuthError, issuer_proof_binding_hash, validate_scopes};
8use ic_auth_protocol_types::{AuthRole, DelegatedRoleGrant, DelegationCert, Principal};
9use thiserror::Error;
10
11/// Explicit host-selected issuance bounds; no Fleet or grant defaults are inferred.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub struct CertificateIssuanceLimits {
14    pub max_cert_ttl_ns: u64,
15    pub max_token_ttl_ns: u64,
16    pub max_grants: usize,
17    pub max_scopes_per_grant: usize,
18}
19
20/// Ordered shape rejection, separate from cryptographic and current-clock checks.
21/// Role grammar is already enforced by the passive `AuthRole` type at decoding
22/// or construction; scope syntax and exact grant ordering are checked here.
23#[derive(Debug, Eq, Error, PartialEq)]
24pub enum CertificateIssuanceError {
25    #[error("certificate Root mismatch (expected {expected}, found {found})")]
26    RootPidMismatch {
27        expected: Principal,
28        found: Principal,
29    },
30    #[error(transparent)]
31    Lifetime(#[from] CertificateLifetimeError),
32    #[error(transparent)]
33    Grants(#[from] GrantValidationError),
34    #[error("issuer proof binding hash mismatch")]
35    IssuerProofBindingHashMismatch,
36    #[error(transparent)]
37    Canonical(#[from] CanonicalAuthError),
38}
39
40/// Validate Root binding, lifetime, canonical bounded grants, then issuer binding.
41/// Exact bound equality is accepted. Anonymous principals are not implicitly
42/// refused: identity eligibility, live policy, proof verification and clock
43/// acceptance remain separate host/verification obligations.
44pub fn validate_certificate_issuance(
45    cert: &DelegationCert,
46    expected_root: Principal,
47    limits: CertificateIssuanceLimits,
48) -> Result<(), CertificateIssuanceError> {
49    if cert.root_pid != expected_root {
50        return Err(CertificateIssuanceError::RootPidMismatch {
51            expected: expected_root,
52            found: cert.root_pid,
53        });
54    }
55    validate_certificate_lifetime(cert, limits.max_cert_ttl_ns, limits.max_token_ttl_ns)?;
56    validate_grants(&cert.grants, limits.max_grants, limits.max_scopes_per_grant)?;
57    if !issuer_binding_matches(cert)? {
58        return Err(CertificateIssuanceError::IssuerProofBindingHashMismatch);
59    }
60    Ok(())
61}
62
63/// Ordered lifetime causes shared by issuance and delegation verification.
64/// Neither this error nor the shape validator establishes current authority.
65#[derive(Debug, Eq, Error, PartialEq)]
66pub enum CertificateLifetimeError {
67    #[error("certificate was issued after its validity start")]
68    IssuedAfterStart,
69    #[error("certificate validity must have positive duration")]
70    InvalidWindow,
71    #[error("certificate TTL {ttl_ns} exceeds host maximum {max_ttl_ns}")]
72    CertTtlExceeded { ttl_ns: u64, max_ttl_ns: u64 },
73    #[error("delegated token TTL must be greater than zero")]
74    TokenTtlZero,
75    #[error("delegated token TTL {ttl_ns} exceeds host maximum {max_ttl_ns}")]
76    TokenTtlExceeded { ttl_ns: u64, max_ttl_ns: u64 },
77    #[error("delegated token TTL {token_ttl_ns} exceeds certificate duration {cert_ttl_ns}")]
78    TokenTtlOutlivesCert { token_ttl_ns: u64, cert_ttl_ns: u64 },
79}
80
81pub(crate) fn positive_duration(start: u64, end: u64) -> Option<u64> {
82    end.checked_sub(start).filter(|duration| *duration != 0)
83}
84
85pub(crate) fn validate_certificate_lifetime(
86    cert: &DelegationCert,
87    max_cert_ttl_ns: u64,
88    max_token_ttl_ns: u64,
89) -> Result<(), CertificateLifetimeError> {
90    use CertificateLifetimeError as E;
91    if cert.issued_at_ns > cert.not_before_ns {
92        return Err(E::IssuedAfterStart);
93    }
94    let cert_ttl_ns =
95        positive_duration(cert.not_before_ns, cert.expires_at_ns).ok_or(E::InvalidWindow)?;
96    if cert_ttl_ns > max_cert_ttl_ns {
97        return Err(E::CertTtlExceeded {
98            ttl_ns: cert_ttl_ns,
99            max_ttl_ns: max_cert_ttl_ns,
100        });
101    }
102    if cert.max_token_ttl_ns == 0 {
103        return Err(E::TokenTtlZero);
104    }
105    if cert.max_token_ttl_ns > max_token_ttl_ns {
106        return Err(E::TokenTtlExceeded {
107            ttl_ns: cert.max_token_ttl_ns,
108            max_ttl_ns: max_token_ttl_ns,
109        });
110    }
111    if cert.max_token_ttl_ns > cert_ttl_ns {
112        return Err(E::TokenTtlOutlivesCert {
113            token_ttl_ns: cert.max_token_ttl_ns,
114            cert_ttl_ns,
115        });
116    }
117    Ok(())
118}
119
120/// A standalone grant-shape failure, before any certificate or token exists.
121#[derive(Debug, Eq, Error, PartialEq)]
122pub enum GrantValidationError {
123    #[error("grants are empty")]
124    Empty,
125    #[error("{found} grants exceed maximum {max}")]
126    TooMany { found: usize, max: usize },
127    #[error("grant for {role:?} has no scopes")]
128    EmptyScopes { role: AuthRole },
129    #[error("grant for {role:?} has {found} scopes; maximum is {max}")]
130    TooManyScopes {
131        role: AuthRole,
132        found: usize,
133        max: usize,
134    },
135    #[error(transparent)]
136    Canonical(CanonicalAuthError),
137}
138
139/// Validate nonempty grants against caller-owned cardinality bounds.
140///
141/// Checks the grant count first, then each role's strict byte ordering, scope
142/// count, label grammar and strict scope ordering in input order. It neither
143/// sorts nor grants authority. Typed roles must already be valid; hosts retain
144/// role projection and its error precedence at their own input boundary.
145pub fn validate_grants(
146    grants: &[DelegatedRoleGrant],
147    max_grants: usize,
148    max_scopes_per_grant: usize,
149) -> Result<(), GrantValidationError> {
150    use GrantValidationError as E;
151    if grants.is_empty() {
152        return Err(E::Empty);
153    }
154    if grants.len() > max_grants {
155        return Err(E::TooMany {
156            found: grants.len(),
157            max: max_grants,
158        });
159    }
160    let mut previous = None;
161    for grant in grants {
162        let role = grant.target.as_str().as_bytes();
163        if previous.is_some_and(|previous| previous >= role) {
164            return Err(E::Canonical(CanonicalAuthError::NonCanonicalRoles));
165        }
166        previous = Some(role);
167        if grant.scopes.is_empty() {
168            return Err(E::EmptyScopes {
169                role: grant.target.clone(),
170            });
171        }
172        if grant.scopes.len() > max_scopes_per_grant {
173            return Err(E::TooManyScopes {
174                role: grant.target.clone(),
175                found: grant.scopes.len(),
176                max: max_scopes_per_grant,
177            });
178        }
179        validate_scopes(&grant.scopes).map_err(E::Canonical)?;
180    }
181    Ok(())
182}
183
184// Both callers admit canonical parent scopes first: protected claims authority
185// through validate_grants, or the delegation certificate through cert_hash.
186pub(crate) fn grants_are_subset(
187    grants: &[DelegatedRoleGrant],
188    ceiling: &[DelegatedRoleGrant],
189) -> bool {
190    grants.iter().all(|child| {
191        grant_for_role(ceiling, &child.target)
192            .is_some_and(|parent| scopes_are_subset(&child.scopes, &parent.scopes))
193    })
194}
195
196pub(crate) fn grant_for_role<'a>(
197    grants: &'a [DelegatedRoleGrant],
198    role: &AuthRole,
199) -> Option<&'a DelegatedRoleGrant> {
200    grants.iter().find(|grant| &grant.target == role)
201}
202
203// The ceiling must already be canonical. Requested scopes need not be ordered
204// (ordinary authorization accepts host-required scopes in any order).
205pub(crate) fn scopes_are_subset(
206    requested: &[impl AsRef<str>],
207    ceiling: &[impl AsRef<str>],
208) -> bool {
209    requested.iter().all(|scope| {
210        let scope = scope.as_ref();
211        // The measured one/two-scope profile is cheaper with a direct scan;
212        // wider canonical ceilings avoid repeated full scans through search.
213        if ceiling.len() <= 2 {
214            ceiling.iter().any(|allowed| allowed.as_ref() == scope)
215        } else {
216            ceiling
217                .binary_search_by(|allowed| allowed.as_ref().cmp(scope))
218                .is_ok()
219        }
220    })
221}
222
223pub(crate) fn issuer_binding_matches(cert: &DelegationCert) -> Result<bool, CanonicalAuthError> {
224    Ok(issuer_proof_binding_hash(
225        cert.issuer_pid,
226        cert.issuer_proof_alg,
227        cert.issuer_proof_binding,
228    )? == cert.issuer_proof_binding_hash)
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn scope_narrowing_matches_set_inclusion_for_sparse_and_unordered_requests() {
237        for allowed in 0_u32..64 {
238            let ceiling: Vec<_> = (0..6)
239                .filter(|bit| allowed & (1 << bit) != 0)
240                .map(|bit| format!("scope{bit}"))
241                .collect();
242            for wanted in 0_u32..64 {
243                let mut requested: Vec<_> = (0..6)
244                    .filter(|bit| wanted & (1 << bit) != 0)
245                    .map(|bit| format!("scope{bit}"))
246                    .collect();
247                requested.reverse();
248                requested.extend(requested.clone());
249                assert_eq!(
250                    scopes_are_subset(&requested, &ceiling),
251                    wanted & !allowed == 0
252                );
253            }
254        }
255    }
256
257    #[test]
258    fn grant_narrowing_keeps_role_boundaries_and_missing_scope_refusals() {
259        let grant = |role: &str, scopes: &[&str]| DelegatedRoleGrant {
260            target: role.parse().unwrap(),
261            scopes: scopes.iter().map(|scope| (*scope).into()).collect(),
262        };
263        let ceiling = [grant("a", &["read", "write"]), grant("c", &["read"])];
264        for (requested, expected) in [
265            (vec![], true),
266            (vec![grant("a", &["write"]), grant("c", &["read"])], true),
267            (vec![grant("b", &["read"])], false),
268            (vec![grant("d", &["read"])], false),
269            (vec![grant("c", &["write"])], false),
270            (vec![grant("a", &["read", "read_all"])], false),
271        ] {
272            assert_eq!(grants_are_subset(&requested, &ceiling), expected);
273        }
274    }
275}