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    /// Protected policy floor; a local timestamp alone cannot fence lost state.
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 90 days 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
50/// Pure rejection of claims against protected context; success is not admission.
51#[derive(Debug, Error, Eq, PartialEq)]
52pub enum PortableClaimsError {
53    #[error("invalid protected portable claims context")]
54    InvalidContext,
55    #[error("portable audience does not match protected authority")]
56    AudienceMismatch,
57    #[error("portable issuer does not match protected authority")]
58    IssuerMismatch,
59    #[error("portable mechanism does not match protected authority")]
60    MechanismMismatch,
61    #[error("portable key binding does not match protected authority")]
62    KeyMismatch,
63    #[error("browser key does not identify the authenticated caller")]
64    CallerMismatch,
65    #[error("portable grants exceed protected authority")]
66    GrantsExceeded,
67    #[error("portable login or proof window is invalid")]
68    InvalidWindow,
69    #[error("portable proof is not yet valid")]
70    NotYetValid,
71    #[error("portable proof has expired")]
72    Expired,
73    #[error("portable login or issuance predates the protected reset floor")]
74    BeforeReset,
75    #[error("portable authority or enrollment is not current")]
76    StaleAuthority,
77    #[error(transparent)]
78    Encoding(#[from] PortableEncodingError),
79}
80
81/// Check both mechanisms' caller, protected bindings, grants and exact time bounds.
82///
83/// Success authenticates nothing by itself. Refusal may precede cryptography;
84/// notably `NotYetValid` is a bounded retry hint, never evidence of a valid proof.
85/// No skew grace is added. The host must preserve revocation/replay state and
86/// fail closed after state loss; these timestamp checks cannot replace it.
87pub fn validate_claims_context(
88    claims: &PortableClaims,
89    context: &PortableClaimsContext<'_>,
90) -> Result<(), PortableClaimsError> {
91    validate_context(context)?;
92    validate_shape(claims)?;
93    validate_bindings(
94        claims,
95        browser_principal(&claims.browser_public_key),
96        context,
97    )
98}
99
100// DecodedPortableClaims alone may skip shape admission: its owned claims have
101// passed the canonical decoder and cannot be edited by callers.
102#[cfg(any(
103    feature = "portable-signature-verification",
104    feature = "certified-proof-verification"
105))]
106pub(super) fn validate_decoded_context(
107    claims: &PortableClaims,
108    browser_caller: Principal,
109    context: &PortableClaimsContext<'_>,
110) -> Result<(), PortableClaimsError> {
111    validate_context(context)?;
112    validate_bindings(claims, browser_caller, context)
113}
114
115fn validate_context(context: &PortableClaimsContext<'_>) -> Result<(), PortableClaimsError> {
116    use PortableClaimsError as Error;
117    let authority = &context.authority;
118    if context.max_login_ttl_ns == 0
119        || context.max_login_ttl_ns > crate::limits::MAX_LOGIN_NS
120        || context.max_proof_ttl_ns == 0
121        || context.max_proof_ttl_ns > crate::limits::MAX_PROOF_NS
122        || !authority.authority_shape_valid()
123        || authority.enrollment_expires_at_ns == 0
124    {
125        return Err(Error::InvalidContext);
126    }
127    Ok(())
128}
129
130fn validate_bindings(
131    claims: &PortableClaims,
132    browser_caller: Principal,
133    context: &PortableClaimsContext<'_>,
134) -> Result<(), PortableClaimsError> {
135    use PortableClaimsError as Error;
136    let authority = &context.authority;
137    if claims.audience != *authority.audience {
138        return Err(Error::AudienceMismatch);
139    }
140    if claims.issuer != authority.issuer {
141        return Err(Error::IssuerMismatch);
142    }
143    if claims.mechanism != authority.mechanism {
144        return Err(Error::MechanismMismatch);
145    }
146    if claims.key_binding_id != authority.key_binding_id {
147        return Err(Error::KeyMismatch);
148    }
149    if browser_caller != authority.caller {
150        return Err(Error::CallerMismatch);
151    }
152    if !grants_are_subset(&claims.grants, authority.grants.as_slice()) {
153        return Err(Error::GrantsExceeded);
154    }
155    if positive_duration(claims.login_started_at_ns, claims.login_deadline_ns)
156        .is_none_or(|ttl| ttl > context.max_login_ttl_ns)
157        || positive_duration(claims.issued_at_ns, claims.admission_expires_at_ns)
158            .is_none_or(|ttl| ttl > context.max_proof_ttl_ns)
159        || claims.login_started_at_ns > claims.issued_at_ns
160        || claims.admission_expires_at_ns > claims.login_deadline_ns
161    {
162        return Err(Error::InvalidWindow);
163    }
164    if claims.login_started_at_ns < authority.min_login_started_at_ns
165        || claims.issued_at_ns < context.min_proof_issued_at_ns
166    {
167        return Err(Error::BeforeReset);
168    }
169    if authority.now_ns < authority.authority_not_before_ns
170        || authority.now_ns >= authority.authority_expires_at_ns
171        || authority.now_ns >= authority.enrollment_expires_at_ns
172        || claims.issued_at_ns < authority.authority_not_before_ns
173    {
174        return Err(Error::StaleAuthority);
175    }
176    if authority.now_ns < claims.issued_at_ns {
177        return Err(Error::NotYetValid);
178    }
179    if authority.now_ns >= claims.admission_expires_at_ns {
180        return Err(Error::Expired);
181    }
182    Ok(())
183}