Skip to main content

ic_auth/portable/
rules.rs

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