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("certificate validity must start at or after issuance and have positive duration")]
31    InvalidCertWindow,
32    #[error("certificate TTL {ttl_ns} exceeds host maximum {max_ttl_ns}")]
33    CertTtlExceeded { ttl_ns: u64, max_ttl_ns: u64 },
34    #[error("delegated token TTL must be greater than zero")]
35    TokenTtlZero,
36    #[error("delegated token TTL {ttl_ns} exceeds host maximum {max_ttl_ns}")]
37    TokenTtlExceeded { ttl_ns: u64, max_ttl_ns: u64 },
38    #[error("delegated token TTL {token_ttl_ns} exceeds certificate duration {cert_ttl_ns}")]
39    TokenTtlOutlivesCert { token_ttl_ns: u64, cert_ttl_ns: u64 },
40    #[error("certificate grants must not be empty")]
41    GrantsEmpty,
42    #[error("certificate grants {found} exceed host maximum {max}")]
43    TooManyGrants { found: usize, max: usize },
44    #[error("grant for {role:?} has no scopes")]
45    EmptyGrantScopes { role: AuthRole },
46    #[error("grant for {role:?} has {found} scopes exceeding host maximum {max}")]
47    TooManyGrantScopes {
48        role: AuthRole,
49        found: usize,
50        max: usize,
51    },
52    #[error("issuer proof binding hash mismatch")]
53    IssuerProofBindingHashMismatch,
54    #[error(transparent)]
55    Canonical(#[from] CanonicalAuthError),
56}
57
58/// Validate Root binding, lifetime, canonical bounded grants, then issuer binding.
59/// Exact bound equality is accepted. Anonymous principals are not implicitly
60/// refused: identity eligibility, live policy, proof verification and clock
61/// acceptance remain separate host/verification obligations.
62pub fn validate_certificate_issuance(
63    cert: &DelegationCert,
64    expected_root: Principal,
65    limits: CertificateIssuanceLimits,
66) -> Result<(), CertificateIssuanceError> {
67    if cert.root_pid != expected_root {
68        return Err(CertificateIssuanceError::RootPidMismatch {
69            expected: expected_root,
70            found: cert.root_pid,
71        });
72    }
73    validate_certificate_lifetime(cert, limits.max_cert_ttl_ns, limits.max_token_ttl_ns)?;
74    validate_grants(&cert.grants, limits.max_grants, limits.max_scopes_per_grant)?;
75    if !issuer_binding_matches(cert)? {
76        return Err(CertificateIssuanceError::IssuerProofBindingHashMismatch);
77    }
78    Ok(())
79}
80
81// Internal causes let existing callers reuse the rules without changing their
82// public rejection surface or the placement of independent clock/crypto checks.
83pub(crate) enum CertificateLifetimeError {
84    IssuedAfterStart,
85    InvalidWindow,
86    CertTtlExceeded { ttl_ns: u64, max_ttl_ns: u64 },
87    TokenTtlZero,
88    TokenTtlExceeded { ttl_ns: u64, max_ttl_ns: u64 },
89    TokenTtlOutlivesCert { token_ttl_ns: u64, cert_ttl_ns: u64 },
90}
91
92pub(crate) fn positive_duration(start: u64, end: u64) -> Option<u64> {
93    end.checked_sub(start).filter(|duration| *duration != 0)
94}
95
96pub(crate) fn validate_certificate_lifetime(
97    cert: &DelegationCert,
98    max_cert_ttl_ns: u64,
99    max_token_ttl_ns: u64,
100) -> Result<(), CertificateLifetimeError> {
101    use CertificateLifetimeError as E;
102    if cert.issued_at_ns > cert.not_before_ns {
103        return Err(E::IssuedAfterStart);
104    }
105    let cert_ttl_ns =
106        positive_duration(cert.not_before_ns, cert.expires_at_ns).ok_or(E::InvalidWindow)?;
107    if cert_ttl_ns > max_cert_ttl_ns {
108        return Err(E::CertTtlExceeded {
109            ttl_ns: cert_ttl_ns,
110            max_ttl_ns: max_cert_ttl_ns,
111        });
112    }
113    if cert.max_token_ttl_ns == 0 {
114        return Err(E::TokenTtlZero);
115    }
116    if cert.max_token_ttl_ns > max_token_ttl_ns {
117        return Err(E::TokenTtlExceeded {
118            ttl_ns: cert.max_token_ttl_ns,
119            max_ttl_ns: max_token_ttl_ns,
120        });
121    }
122    if cert.max_token_ttl_ns > cert_ttl_ns {
123        return Err(E::TokenTtlOutlivesCert {
124            token_ttl_ns: cert.max_token_ttl_ns,
125            cert_ttl_ns,
126        });
127    }
128    Ok(())
129}
130
131impl From<CertificateLifetimeError> for CertificateIssuanceError {
132    fn from(error: CertificateLifetimeError) -> Self {
133        use CertificateLifetimeError as E;
134        match error {
135            E::IssuedAfterStart | E::InvalidWindow => Self::InvalidCertWindow,
136            E::CertTtlExceeded { ttl_ns, max_ttl_ns } => {
137                Self::CertTtlExceeded { ttl_ns, max_ttl_ns }
138            }
139            E::TokenTtlZero => Self::TokenTtlZero,
140            E::TokenTtlExceeded { ttl_ns, max_ttl_ns } => {
141                Self::TokenTtlExceeded { ttl_ns, max_ttl_ns }
142            }
143            E::TokenTtlOutlivesCert {
144                token_ttl_ns,
145                cert_ttl_ns,
146            } => Self::TokenTtlOutlivesCert {
147                token_ttl_ns,
148                cert_ttl_ns,
149            },
150        }
151    }
152}
153
154pub(crate) enum GrantValidationError {
155    Empty,
156    TooMany {
157        found: usize,
158        max: usize,
159    },
160    EmptyScopes {
161        role: AuthRole,
162    },
163    TooManyScopes {
164        role: AuthRole,
165        found: usize,
166        max: usize,
167    },
168    Canonical(CanonicalAuthError),
169}
170
171pub(crate) fn validate_grants(
172    grants: &[DelegatedRoleGrant],
173    max_grants: usize,
174    max_scopes_per_grant: usize,
175) -> Result<(), GrantValidationError> {
176    use GrantValidationError as E;
177    if grants.is_empty() {
178        return Err(E::Empty);
179    }
180    if grants.len() > max_grants {
181        return Err(E::TooMany {
182            found: grants.len(),
183            max: max_grants,
184        });
185    }
186    let mut previous = None;
187    for grant in grants {
188        let role = grant.target.as_str().as_bytes();
189        if previous.is_some_and(|previous| previous >= role) {
190            return Err(E::Canonical(CanonicalAuthError::NonCanonicalRoles));
191        }
192        previous = Some(role);
193        if grant.scopes.is_empty() {
194            return Err(E::EmptyScopes {
195                role: grant.target.clone(),
196            });
197        }
198        if grant.scopes.len() > max_scopes_per_grant {
199            return Err(E::TooManyScopes {
200                role: grant.target.clone(),
201                found: grant.scopes.len(),
202                max: max_scopes_per_grant,
203            });
204        }
205        validate_scopes(&grant.scopes).map_err(E::Canonical)?;
206    }
207    Ok(())
208}
209
210impl From<GrantValidationError> for CertificateIssuanceError {
211    fn from(error: GrantValidationError) -> Self {
212        use GrantValidationError as E;
213        match error {
214            E::Empty => Self::GrantsEmpty,
215            E::TooMany { found, max } => Self::TooManyGrants { found, max },
216            E::EmptyScopes { role } => Self::EmptyGrantScopes { role },
217            E::TooManyScopes { role, found, max } => Self::TooManyGrantScopes { role, found, max },
218            E::Canonical(error) => Self::Canonical(error),
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}