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    /// Advance both admission cutoffs after losing session/replay/revocation state.
37    /// Uses this context's trusted boot time and deployment-qualified skew bound;
38    /// the host must reject unavailable/rolled-back clocks before calling this.
39    /// Floors never decrease, and overflow leaves both unchanged. This only
40    /// calculates policy: the host must retain these values atomically with state
41    /// invalidation, authenticate fresh enrollment and fail closed until ready.
42    /// Do not call per request or discard the resulting floors after startup.
43    pub fn advance_reset_floors(&mut self, clock_skew_ns: u64) -> Result<(), PortableClaimsError> {
44        let proof_floor = self
45            .now_ns
46            .checked_add(1)
47            .ok_or(PortableClaimsError::InvalidContext)?;
48        let login_floor = proof_floor
49            .checked_add(clock_skew_ns)
50            .ok_or(PortableClaimsError::InvalidContext)?;
51        self.min_login_started_at_ns = self.min_login_started_at_ns.max(login_floor);
52        self.min_proof_issued_at_ns = self.min_proof_issued_at_ns.max(proof_floor);
53        Ok(())
54    }
55}
56
57/// Pure rejection of claims against protected context; success is not admission.
58#[derive(Debug, Error, Eq, PartialEq)]
59pub enum PortableClaimsError {
60    #[error("invalid protected portable claims context")]
61    InvalidContext,
62    #[error("portable audience does not match protected authority")]
63    AudienceMismatch,
64    #[error("portable issuer does not match protected authority")]
65    IssuerMismatch,
66    #[error("portable mechanism does not match protected authority")]
67    MechanismMismatch,
68    #[error("portable key binding does not match protected authority")]
69    KeyMismatch,
70    #[error("browser key does not identify the authenticated caller")]
71    CallerMismatch,
72    #[error("portable grants exceed protected authority")]
73    GrantsExceeded,
74    #[error("portable login or proof window is invalid")]
75    InvalidWindow,
76    #[error("portable proof is not yet valid")]
77    NotYetValid,
78    #[error("portable proof has expired")]
79    Expired,
80    #[error("portable login or issuance predates the protected reset floor")]
81    BeforeReset,
82    #[error("portable authority or enrollment is not current")]
83    StaleAuthority,
84    #[error(transparent)]
85    Encoding(#[from] PortableEncodingError),
86}
87
88/// Check both mechanisms' caller, protected bindings, grants and exact time bounds.
89///
90/// Success authenticates nothing by itself. Refusal may precede cryptography;
91/// notably `NotYetValid` is a bounded retry hint, never evidence of a valid proof.
92/// No skew grace is added to admission. The host incorporates its qualified skew
93/// bound into reset floors and tombstone retention, not the clock supplied here.
94pub fn validate_claims_context(
95    claims: &PortableClaims,
96    context: &PortableClaimsContext<'_>,
97) -> Result<(), PortableClaimsError> {
98    use PortableClaimsError as Error;
99    if context.max_login_ttl_ns == 0
100        || context.max_login_ttl_ns > crate::limits::MAX_LOGIN_NS
101        || context.max_proof_ttl_ns == 0
102        || context.max_proof_ttl_ns > crate::limits::MAX_PROOF_NS
103        || context.issuer == Principal::anonymous()
104        || context.authority_not_before_ns >= context.authority_expires_at_ns
105        || context.enrollment_expires_at_ns == 0
106        || validate_grants(
107            context.grants,
108            crate::limits::MAX_GRANTS,
109            crate::limits::MAX_SCOPES_PER_GRANT,
110        )
111        .is_err()
112    {
113        return Err(Error::InvalidContext);
114    }
115    validate_shape(claims)?;
116    if claims.audience != *context.audience {
117        return Err(Error::AudienceMismatch);
118    }
119    if claims.issuer != context.issuer {
120        return Err(Error::IssuerMismatch);
121    }
122    if claims.mechanism != context.mechanism {
123        return Err(Error::MechanismMismatch);
124    }
125    if claims.key_binding_id != context.key_binding_id {
126        return Err(Error::KeyMismatch);
127    }
128    if browser_principal(&claims.browser_public_key) != context.caller {
129        return Err(Error::CallerMismatch);
130    }
131    if !grants_are_subset(&claims.grants, context.grants) {
132        return Err(Error::GrantsExceeded);
133    }
134    if positive_duration(claims.login_started_at_ns, claims.login_deadline_ns)
135        .is_none_or(|ttl| ttl > context.max_login_ttl_ns)
136        || positive_duration(claims.issued_at_ns, claims.admission_expires_at_ns)
137            .is_none_or(|ttl| ttl > context.max_proof_ttl_ns)
138        || claims.login_started_at_ns > claims.issued_at_ns
139        || claims.admission_expires_at_ns > claims.login_deadline_ns
140    {
141        return Err(Error::InvalidWindow);
142    }
143    if claims.login_started_at_ns < context.min_login_started_at_ns
144        || claims.issued_at_ns < context.min_proof_issued_at_ns
145    {
146        return Err(Error::BeforeReset);
147    }
148    if context.now_ns < context.authority_not_before_ns
149        || context.now_ns >= context.authority_expires_at_ns
150        || context.now_ns >= context.enrollment_expires_at_ns
151        || claims.issued_at_ns < context.authority_not_before_ns
152    {
153        return Err(Error::StaleAuthority);
154    }
155    if context.now_ns < claims.issued_at_ns {
156        return Err(Error::NotYetValid);
157    }
158    if context.now_ns >= claims.admission_expires_at_ns {
159        return Err(Error::Expired);
160    }
161    Ok(())
162}