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(claims, context)
118}
119
120// DecodedPortableClaims alone may skip shape admission: its owned claims have
121// passed the canonical decoder and cannot be edited by callers.
122#[cfg(any(
123    feature = "portable-signature-verification",
124    feature = "certified-proof-verification"
125))]
126pub(super) fn validate_decoded_context(
127    claims: &PortableClaims,
128    context: &PortableClaimsContext<'_>,
129) -> Result<(), PortableClaimsError> {
130    validate_context(context)?;
131    validate_bindings(claims, context)
132}
133
134fn validate_context(context: &PortableClaimsContext<'_>) -> Result<(), PortableClaimsError> {
135    use PortableClaimsError as Error;
136    let authority = &context.authority;
137    if context.max_login_ttl_ns == 0
138        || context.max_login_ttl_ns > crate::limits::MAX_LOGIN_NS
139        || context.max_proof_ttl_ns == 0
140        || context.max_proof_ttl_ns > crate::limits::MAX_PROOF_NS
141        || !authority.authority_shape_valid()
142        || authority.enrollment_expires_at_ns == 0
143    {
144        return Err(Error::InvalidContext);
145    }
146    Ok(())
147}
148
149fn validate_bindings(
150    claims: &PortableClaims,
151    context: &PortableClaimsContext<'_>,
152) -> Result<(), PortableClaimsError> {
153    use PortableClaimsError as Error;
154    let authority = &context.authority;
155    if claims.audience != *authority.audience {
156        return Err(Error::AudienceMismatch);
157    }
158    if claims.issuer != authority.issuer {
159        return Err(Error::IssuerMismatch);
160    }
161    if claims.mechanism != authority.mechanism {
162        return Err(Error::MechanismMismatch);
163    }
164    if claims.key_binding_id != authority.key_binding_id {
165        return Err(Error::KeyMismatch);
166    }
167    if browser_principal(&claims.browser_public_key) != authority.caller {
168        return Err(Error::CallerMismatch);
169    }
170    if !grants_are_subset(&claims.grants, authority.grants.as_slice()) {
171        return Err(Error::GrantsExceeded);
172    }
173    if positive_duration(claims.login_started_at_ns, claims.login_deadline_ns)
174        .is_none_or(|ttl| ttl > context.max_login_ttl_ns)
175        || positive_duration(claims.issued_at_ns, claims.admission_expires_at_ns)
176            .is_none_or(|ttl| ttl > context.max_proof_ttl_ns)
177        || claims.login_started_at_ns > claims.issued_at_ns
178        || claims.admission_expires_at_ns > claims.login_deadline_ns
179    {
180        return Err(Error::InvalidWindow);
181    }
182    if claims.login_started_at_ns < authority.min_login_started_at_ns
183        || claims.issued_at_ns < context.min_proof_issued_at_ns
184    {
185        return Err(Error::BeforeReset);
186    }
187    if authority.now_ns < authority.authority_not_before_ns
188        || authority.now_ns >= authority.authority_expires_at_ns
189        || authority.now_ns >= authority.enrollment_expires_at_ns
190        || claims.issued_at_ns < authority.authority_not_before_ns
191    {
192        return Err(Error::StaleAuthority);
193    }
194    if authority.now_ns < claims.issued_at_ns {
195        return Err(Error::NotYetValid);
196    }
197    if authority.now_ns >= claims.admission_expires_at_ns {
198        return Err(Error::Expired);
199    }
200    Ok(())
201}