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