Skip to main content

ic_auth/portable/
rules.rs

1//! Common portable claims checks; neither proof verification nor session admission.
2
3use super::{PortableEncodingError, browser_principal, validate_shape};
4use crate::certificate_issuance::{grants_are_subset, positive_duration, validate_grants};
5use ic_auth_protocol_types::{
6    AudienceId, DelegatedRoleGrant, PortableClaims, PortableMechanism, Principal,
7};
8use thiserror::Error;
9
10/// Protected runtime and active enrollment inputs, never inferred from claims.
11/// The host must select current, unrevoked authority under its transaction lock.
12/// Generation, login revocation, replay and atomic session writes remain host/
13/// session-engine obligations; this context cannot attest its own provenance.
14#[derive(Clone, Copy, Debug)]
15pub struct PortableClaimsContext<'a> {
16    pub audience: &'a AudienceId,
17    pub issuer: Principal,
18    pub mechanism: PortableMechanism,
19    pub key_binding_id: [u8; 32],
20    pub grants: &'a [DelegatedRoleGrant],
21    /// Actual authenticated ingress sender, not an account subject or request field.
22    pub caller: Principal,
23    pub now_ns: u64,
24    pub authority_not_before_ns: u64,
25    pub authority_expires_at_ns: u64,
26    pub enrollment_expires_at_ns: u64,
27    /// Immutable login start floor, including the host's qualified reset margin.
28    pub min_login_started_at_ns: u64,
29    pub min_proof_issued_at_ns: u64,
30    /// Positive host limits, capped at 86,400 and 60 seconds respectively.
31    pub max_login_ttl_ns: u64,
32    pub max_proof_ttl_ns: u64,
33}
34
35impl PortableClaimsContext<'_> {
36    // Shared structural authority policy, independent of clock, proof lifetime,
37    // caller and session liveness. Each path preserves its own refusal type.
38    pub(crate) fn authority_shape_valid(&self) -> bool {
39        self.issuer != Principal::anonymous()
40            && self.authority_not_before_ns < self.authority_expires_at_ns
41            && validate_grants(
42                self.grants,
43                crate::limits::MAX_GRANTS,
44                crate::limits::MAX_SCOPES_PER_GRANT,
45            )
46            .is_ok()
47    }
48
49    /// Advance both admission cutoffs after losing session/replay/revocation state.
50    /// Uses this context's trusted boot time and deployment-qualified skew bound;
51    /// the host must reject unavailable/rolled-back clocks before calling this.
52    /// Floors never decrease, and overflow leaves both unchanged. This only
53    /// calculates policy: the host must retain these values atomically with state
54    /// invalidation, authenticate fresh enrollment and fail closed until ready.
55    /// Do not call per request or discard the resulting floors after startup.
56    pub fn advance_reset_floors(&mut self, clock_skew_ns: u64) -> Result<(), PortableClaimsError> {
57        let proof_floor = self
58            .now_ns
59            .checked_add(1)
60            .ok_or(PortableClaimsError::InvalidContext)?;
61        let login_floor = proof_floor
62            .checked_add(clock_skew_ns)
63            .ok_or(PortableClaimsError::InvalidContext)?;
64        self.min_login_started_at_ns = self.min_login_started_at_ns.max(login_floor);
65        self.min_proof_issued_at_ns = self.min_proof_issued_at_ns.max(proof_floor);
66        Ok(())
67    }
68}
69
70/// Pure rejection of claims against protected context; success is not admission.
71#[derive(Debug, Error, Eq, PartialEq)]
72pub enum PortableClaimsError {
73    #[error("invalid protected portable claims context")]
74    InvalidContext,
75    #[error("portable audience does not match protected authority")]
76    AudienceMismatch,
77    #[error("portable issuer does not match protected authority")]
78    IssuerMismatch,
79    #[error("portable mechanism does not match protected authority")]
80    MechanismMismatch,
81    #[error("portable key binding does not match protected authority")]
82    KeyMismatch,
83    #[error("browser key does not identify the authenticated caller")]
84    CallerMismatch,
85    #[error("portable grants exceed protected authority")]
86    GrantsExceeded,
87    #[error("portable login or proof window is invalid")]
88    InvalidWindow,
89    #[error("portable proof is not yet valid")]
90    NotYetValid,
91    #[error("portable proof has expired")]
92    Expired,
93    #[error("portable login or issuance predates the protected reset floor")]
94    BeforeReset,
95    #[error("portable authority or enrollment is not current")]
96    StaleAuthority,
97    #[error(transparent)]
98    Encoding(#[from] PortableEncodingError),
99}
100
101/// Check both mechanisms' caller, protected bindings, grants and exact time bounds.
102///
103/// Success authenticates nothing by itself. Refusal may precede cryptography;
104/// notably `NotYetValid` is a bounded retry hint, never evidence of a valid proof.
105/// No skew grace is added to admission. The host incorporates its qualified skew
106/// bound into reset floors and tombstone retention, not the clock supplied here.
107pub fn validate_claims_context(
108    claims: &PortableClaims,
109    context: &PortableClaimsContext<'_>,
110) -> Result<(), PortableClaimsError> {
111    use PortableClaimsError as Error;
112    if context.max_login_ttl_ns == 0
113        || context.max_login_ttl_ns > crate::limits::MAX_LOGIN_NS
114        || context.max_proof_ttl_ns == 0
115        || context.max_proof_ttl_ns > crate::limits::MAX_PROOF_NS
116        || !context.authority_shape_valid()
117        || context.enrollment_expires_at_ns == 0
118    {
119        return Err(Error::InvalidContext);
120    }
121    validate_shape(claims)?;
122    if claims.audience != *context.audience {
123        return Err(Error::AudienceMismatch);
124    }
125    if claims.issuer != context.issuer {
126        return Err(Error::IssuerMismatch);
127    }
128    if claims.mechanism != context.mechanism {
129        return Err(Error::MechanismMismatch);
130    }
131    if claims.key_binding_id != context.key_binding_id {
132        return Err(Error::KeyMismatch);
133    }
134    if browser_principal(&claims.browser_public_key) != context.caller {
135        return Err(Error::CallerMismatch);
136    }
137    if !grants_are_subset(&claims.grants, context.grants) {
138        return Err(Error::GrantsExceeded);
139    }
140    if positive_duration(claims.login_started_at_ns, claims.login_deadline_ns)
141        .is_none_or(|ttl| ttl > context.max_login_ttl_ns)
142        || positive_duration(claims.issued_at_ns, claims.admission_expires_at_ns)
143            .is_none_or(|ttl| ttl > context.max_proof_ttl_ns)
144        || claims.login_started_at_ns > claims.issued_at_ns
145        || claims.admission_expires_at_ns > claims.login_deadline_ns
146    {
147        return Err(Error::InvalidWindow);
148    }
149    if claims.login_started_at_ns < context.min_login_started_at_ns
150        || claims.issued_at_ns < context.min_proof_issued_at_ns
151    {
152        return Err(Error::BeforeReset);
153    }
154    if context.now_ns < context.authority_not_before_ns
155        || context.now_ns >= context.authority_expires_at_ns
156        || context.now_ns >= context.enrollment_expires_at_ns
157        || claims.issued_at_ns < context.authority_not_before_ns
158    {
159        return Err(Error::StaleAuthority);
160    }
161    if context.now_ns < claims.issued_at_ns {
162        return Err(Error::NotYetValid);
163    }
164    if context.now_ns >= claims.admission_expires_at_ns {
165        return Err(Error::Expired);
166    }
167    Ok(())
168}