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