Skip to main content

ic_auth/
session_request.rs

1//! Canonical session request identity, independent of proof verification or storage.
2//!
3//! Hashing a request does not authenticate a token, select host TTL policy or
4//! grant scopes. Session admission and replay remain separate engine obligations.
5
6use crate::canonical::CanonicalAuthError;
7use ic_auth_protocol_types::{AuthRole, DelegatedRoleGrant, ScopeLabel};
8use sha2::{Digest, Sha256};
9use thiserror::Error;
10
11/// Request shape, narrowing and lifetime rejection; no cryptographic verification.
12#[derive(Debug, Error, Eq, PartialEq)]
13pub enum SessionRequestError {
14    #[error("session request has invalid scope bounds, duplicate scopes or zero TTL")]
15    InvalidRequest,
16    #[error("invalid session TTL policy, requested lifetime or expiry overflow")]
17    InvalidLifetime,
18    #[error("requested scopes are not granted for the protected receiver role")]
19    ScopeRejected,
20    #[error(transparent)]
21    Canonical(#[from] CanonicalAuthError),
22}
23
24/// Checked current maximum session lifetime, in seconds (1..=1800).
25/// Authorization needs this bound, without an admission default.
26/// Construction cannot bypass range admission:
27/// ```compile_fail
28/// use ic_auth::session_request::SessionTtlLimit;
29/// let invalid = SessionTtlLimit(0);
30/// ```
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub struct SessionTtlLimit(u64);
33impl SessionTtlLimit {
34    pub fn new(seconds: u64) -> Result<Self, SessionRequestError> {
35        if seconds == 0 || seconds > crate::limits::MAX_SESSION_SECS {
36            return Err(SessionRequestError::InvalidLifetime);
37        }
38        Ok(Self(seconds))
39    }
40    pub const fn seconds(self) -> u64 {
41        self.0
42    }
43}
44
45/// Checked default and maximum TTL for fresh admission. Neither value is taken
46/// from a submitted request. Live authorization uses only `maximum()`.
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48pub struct SessionTtlPolicy {
49    default_secs: u64,
50    maximum: SessionTtlLimit,
51}
52impl SessionTtlPolicy {
53    pub fn new(default_secs: u64, maximum_secs: u64) -> Result<Self, SessionRequestError> {
54        let maximum = SessionTtlLimit::new(maximum_secs)?;
55        if default_secs == 0 || default_secs > maximum_secs {
56            return Err(SessionRequestError::InvalidLifetime);
57        }
58        Ok(Self {
59            default_secs,
60            maximum,
61        })
62    }
63    pub const fn default_secs(self) -> u64 {
64        self.default_secs
65    }
66    pub const fn maximum(self) -> SessionTtlLimit {
67        self.maximum
68    }
69}
70
71/// Canonical narrowing request: 1..=16 unique scopes, at most 1024 scope bytes.
72/// Input order is normalized before hashing; duplicates are rejected.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct SessionRequest {
75    scopes: Vec<ScopeLabel>,
76    ttl_secs: Option<u64>,
77}
78impl SessionRequest {
79    pub fn new(scopes: Vec<String>, ttl_secs: Option<u64>) -> Result<Self, SessionRequestError> {
80        check_scope_count(scopes.len())?;
81        let mut scopes = ScopeLabel::from_strings(scopes).map_err(CanonicalAuthError::from)?;
82        scopes.sort_unstable();
83        if scopes.windows(2).any(|pair| pair[0] == pair[1]) || ttl_secs == Some(0) {
84            return Err(SessionRequestError::InvalidRequest);
85        }
86        Ok(Self { scopes, ttl_secs })
87    }
88    /// Exact requested TTL in seconds; None leaves selection to protected host policy.
89    pub const fn ttl_secs(&self) -> Option<u64> {
90        self.ttl_secs
91    }
92    pub fn scopes(&self) -> &[ScopeLabel] {
93        &self.scopes
94    }
95    #[cfg(all(
96        feature = "sessions",
97        any(
98            feature = "portable-signature-verification",
99            feature = "certified-proof-verification"
100        )
101    ))]
102    pub(crate) fn into_scopes(self) -> Vec<String> {
103        self.scopes
104            .into_iter()
105            .map(ScopeLabel::into_string)
106            .collect()
107    }
108    /// Calculate a requested lease against protected TTL policy (maximum 1800s).
109    /// This authenticates nothing and does not cap login or authority deadlines.
110    /// The host supplies its clock and default/maximum policy, never the request.
111    pub fn expires_at_ns(
112        &self,
113        now_ns: u64,
114        policy: SessionTtlPolicy,
115    ) -> Result<u64, SessionRequestError> {
116        let ttl = self.ttl_secs.unwrap_or(policy.default_secs());
117        if ttl > policy.maximum().seconds() {
118            return Err(SessionRequestError::InvalidLifetime);
119        }
120        now_ns
121            .checked_add(ttl * crate::limits::NS_PER_SECOND)
122            .ok_or(SessionRequestError::InvalidLifetime)
123    }
124    fn check_granted_scopes(&self, granted: &[String]) -> Result<(), SessionRequestError> {
125        if !crate::certificate_issuance::scopes_are_subset(&self.scopes, granted) {
126            return Err(SessionRequestError::ScopeRejected);
127        }
128        Ok(())
129    }
130    /// Evaluate narrowing and requested TTL at this caller's protected snapshot.
131    /// Admission and verified claims both use this policy; each retains its own
132    /// authority checks and the verified lease additionally caps signed deadlines.
133    pub(crate) fn evaluate(
134        &self,
135        grants: &[DelegatedRoleGrant],
136        role: &AuthRole,
137        now_ns: u64,
138        policy: SessionTtlPolicy,
139    ) -> Result<u64, SessionRequestError> {
140        let grant = crate::certificate_issuance::grant_for_role(grants, role)
141            .ok_or(SessionRequestError::ScopeRejected)?;
142        self.check_granted_scopes(&grant.scopes)?;
143        self.expires_at_ns(now_ns, policy)
144    }
145    /// Exact Canic V1 request hash; None and an explicit default TTL differ.
146    pub fn hash(&self) -> [u8; 32] {
147        let mut hash = Sha256::new();
148        hash.update(b"canic-application-session-request-v1");
149        hash.update((self.scopes.len() as u64).to_be_bytes());
150        for scope in &self.scopes {
151            hash.update((scope.as_str().len() as u64).to_be_bytes());
152            hash.update(scope.as_str().as_bytes());
153        }
154        match self.ttl_secs {
155            None => hash.update([0]),
156            Some(ttl) => {
157                hash.update([1]);
158                hash.update(ttl.to_be_bytes());
159            }
160        }
161        hash.finalize().into()
162    }
163}
164
165#[cfg(feature = "session-restore")]
166pub(crate) fn check_scopes(scopes: &[String]) -> Result<(), SessionRequestError> {
167    check_scope_count(scopes.len())?;
168    // The count bound and canonical 64-byte label bound imply <=1024 bytes.
169    for scope in scopes {
170        crate::canonical::validate_scope_label(scope)?;
171    }
172    Ok(())
173}
174
175fn check_scope_count(count: usize) -> Result<(), SessionRequestError> {
176    if count == 0 || count > crate::limits::MAX_SESSION_SCOPES {
177        return Err(SessionRequestError::InvalidRequest);
178    }
179    Ok(())
180}