Skip to main content

ic_auth/
token_preparation.rs

1//! Pure delegated-token issuance over a host-admitted delegation proof.
2//!
3//! Preparation and finishing do not authenticate any proof or authorize signing.
4//! The host owns issuer approval, independently enrolled authority, authenticated
5//! identity, clock, operation custody, signing and certification-root publication.
6
7use crate::canonical::{CanonicalAuthError, cert_hash, claims_hash, validate_scopes};
8use ic_auth_protocol_types::{
9    AuthRole, DelegatedRoleGrant, DelegatedToken, DelegatedTokenClaims, DelegationAudience,
10    DelegationProof, IssuerProof, Principal,
11};
12use sha2::{Digest, Sha256};
13use thiserror::Error;
14
15/// Explicit host-selected request bounds; there are no implicit fleet defaults.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub struct TokenPreparationLimits {
18    pub max_grants: usize,
19    pub max_scopes_per_grant: usize,
20}
21
22/// Inputs resolved by the host before preparing an issuer-local token.
23pub struct PrepareDelegatedTokenInput<'a> {
24    /// Already installed/verified proof belonging to the authorized local issuer.
25    /// Never resolve this authority from client-submitted proof fields.
26    pub proof: &'a DelegationProof,
27    /// Exclusive protected authority deadline, capped by enrolled root policy.
28    pub authority_expires_at_ns: u64,
29    /// Host-retained operation identity. Retry must retain the original inputs,
30    /// including time, rather than rebuilding a different token for this ID.
31    pub operation_id: [u8; 32],
32    /// Authenticated presenter, also used as the subject; not a request assertion.
33    pub prepared_by: Principal,
34    pub audience: DelegationAudience,
35    pub grants: Vec<DelegatedRoleGrant>,
36    pub ttl_ns: u64,
37    pub ext: Option<Vec<u8>>,
38    pub now_ns: u64,
39    pub limits: TokenPreparationLimits,
40}
41
42/// Immutable claims, signing hash and attached Root proof. This is prepared
43/// material, not a verified token, signature, resource grant or session.
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct PreparedDelegatedToken {
46    claims: DelegatedTokenClaims,
47    claims_hash: [u8; 32],
48    proof: DelegationProof,
49}
50
51impl PreparedDelegatedToken {
52    pub const fn claims(&self) -> &DelegatedTokenClaims {
53        &self.claims
54    }
55
56    /// Canonical hash to pass to the host's authorized issuer-signature path.
57    pub const fn claims_hash(&self) -> [u8; 32] {
58        self.claims_hash
59    }
60
61    pub const fn proof(&self) -> &DelegationProof {
62        &self.proof
63    }
64}
65
66/// Ordered request rejection. Neither certificate bytes nor these shape checks
67/// establish the provenance of the host's supplied authority.
68#[derive(Debug, Eq, Error, PartialEq)]
69pub enum PrepareDelegatedTokenError {
70    #[error("delegation certificate is not yet valid")]
71    CertNotYetValid,
72    #[error("delegation certificate or local authority expired")]
73    CertExpired,
74    #[error("token TTL must be greater than zero")]
75    TokenTtlZero,
76    #[error("token TTL {ttl_ns} exceeds certificate maximum {max_ttl_ns}")]
77    TokenTtlExceeded { ttl_ns: u64, max_ttl_ns: u64 },
78    #[error("token expiry overflows")]
79    TokenExpiresAtOverflow,
80    #[error("token outlives certificate or local authority")]
81    TokenOutlivesCert,
82    #[error("token grants must not be empty")]
83    GrantsEmpty,
84    #[error("token grants {found} exceed host maximum {max}")]
85    TooManyGrants { found: usize, max: usize },
86    #[error("grant for {role:?} has no scopes")]
87    EmptyGrantScopes { role: AuthRole },
88    #[error("grant for {role:?} has {found} scopes exceeding host maximum {max}")]
89    TooManyGrantScopes {
90        role: AuthRole,
91        found: usize,
92        max: usize,
93    },
94    #[error("token audience is not a subset of certificate audience")]
95    AudienceNotSubset,
96    #[error("token grants are not a subset of certificate grants")]
97    GrantsNotSubset,
98    #[error(transparent)]
99    Canonical(#[from] CanonicalAuthError),
100}
101
102/// Prepare exact canonical claims without reading runtime state or signing.
103/// Expiry is checked against both certificate and supplied authority: an
104/// overlong request rejects rather than silently shortening the requested TTL.
105pub fn prepare_delegated_token(
106    input: PrepareDelegatedTokenInput<'_>,
107) -> Result<PreparedDelegatedToken, PrepareDelegatedTokenError> {
108    use PrepareDelegatedTokenError as Error;
109    let cert = &input.proof.cert;
110    let deadline = cert.expires_at_ns.min(input.authority_expires_at_ns);
111    if input.now_ns < cert.not_before_ns {
112        return Err(Error::CertNotYetValid);
113    }
114    if input.now_ns >= deadline {
115        return Err(Error::CertExpired);
116    }
117    if input.ttl_ns == 0 {
118        return Err(Error::TokenTtlZero);
119    }
120    if input.ttl_ns > cert.max_token_ttl_ns {
121        return Err(Error::TokenTtlExceeded {
122            ttl_ns: input.ttl_ns,
123            max_ttl_ns: cert.max_token_ttl_ns,
124        });
125    }
126    let expires_at_ns = input
127        .now_ns
128        .checked_add(input.ttl_ns)
129        .ok_or(Error::TokenExpiresAtOverflow)?;
130    if expires_at_ns > deadline {
131        return Err(Error::TokenOutlivesCert);
132    }
133    if input.grants.is_empty() {
134        return Err(Error::GrantsEmpty);
135    }
136    if input.grants.len() > input.limits.max_grants {
137        return Err(Error::TooManyGrants {
138            found: input.grants.len(),
139            max: input.limits.max_grants,
140        });
141    }
142    let mut previous = None;
143    for grant in &input.grants {
144        let role = grant.target.as_str().as_bytes();
145        if previous.is_some_and(|previous| previous >= role) {
146            return Err(CanonicalAuthError::NonCanonicalRoles.into());
147        }
148        previous = Some(role);
149        if grant.scopes.is_empty() {
150            return Err(Error::EmptyGrantScopes {
151                role: grant.target.clone(),
152            });
153        }
154        if grant.scopes.len() > input.limits.max_scopes_per_grant {
155            return Err(Error::TooManyGrantScopes {
156                role: grant.target.clone(),
157                found: grant.scopes.len(),
158                max: input.limits.max_scopes_per_grant,
159            });
160        }
161        validate_scopes(&grant.scopes)?;
162    }
163    if input.audience != cert.aud {
164        return Err(Error::AudienceNotSubset);
165    }
166    if !input.grants.iter().all(|child| {
167        cert.grants
168            .iter()
169            .find(|parent| parent.target == child.target)
170            .is_some_and(|parent| {
171                child
172                    .scopes
173                    .iter()
174                    .all(|scope| parent.scopes.contains(scope))
175            })
176    }) {
177        return Err(Error::GrantsNotSubset);
178    }
179    let cert_hash = cert_hash(cert)?;
180    let nonce = delegated_token_nonce(
181        input.prepared_by,
182        input.operation_id,
183        cert.issuer_pid,
184        cert_hash,
185    );
186    let claims = DelegatedTokenClaims {
187        presenter: input.prepared_by,
188        subject: input.prepared_by,
189        issuer_pid: cert.issuer_pid,
190        cert_hash,
191        issued_at_ns: input.now_ns,
192        expires_at_ns,
193        aud: input.audience,
194        grants: input.grants,
195        nonce,
196        ext: input.ext,
197    };
198    Ok(PreparedDelegatedToken {
199        claims_hash: claims_hash(&claims)?,
200        claims,
201        proof: input.proof.clone(),
202    })
203}
204
205fn delegated_token_nonce(
206    presenter: Principal,
207    operation_id: [u8; 32],
208    issuer: Principal,
209    cert_hash: [u8; 32],
210) -> [u8; 16] {
211    // Frozen Canic domain and raw principal framing; do not rebrand signed bytes.
212    let mut hash = Sha256::new();
213    hash.update(b"canic-token-nonce-v1");
214    hash.update(presenter.as_slice());
215    hash.update(operation_id);
216    hash.update(issuer.as_slice());
217    hash.update(cert_hash);
218    let digest: [u8; 32] = hash.finalize().into();
219    let mut nonce = [0; 16];
220    nonce.copy_from_slice(&digest[..16]);
221    nonce
222}
223
224/// Attach host-provided issuer material verbatim, without proof verification.
225/// Obtain and authorize that material through the host's signature service;
226/// recipients still need complete verification before admitting the token.
227pub fn finish_delegated_token(
228    prepared: PreparedDelegatedToken,
229    issuer_proof: IssuerProof,
230) -> DelegatedToken {
231    DelegatedToken {
232        claims: prepared.claims,
233        proof: prepared.proof,
234        issuer_proof,
235    }
236}