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, validate_scope_label};
7use sha2::{Digest, Sha256};
8use thiserror::Error;
9
10/// Request shape, narrowing and lifetime rejection; no cryptographic verification.
11#[derive(Debug, Error, Eq, PartialEq)]
12pub enum SessionRequestError {
13    #[error("session request has invalid scope bounds, duplicate scopes or zero TTL")]
14    InvalidRequest,
15    #[error("invalid session TTL policy, requested lifetime or expiry overflow")]
16    InvalidLifetime,
17    #[error("requested scopes are not granted for the protected receiver role")]
18    ScopeRejected,
19    #[error(transparent)]
20    Canonical(#[from] CanonicalAuthError),
21}
22
23/// Canonical narrowing request: 1..=16 unique scopes, at most 1024 scope bytes.
24/// Input order is normalized before hashing; duplicates are rejected.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct SessionRequest {
27    scopes: Vec<String>,
28    ttl_secs: Option<u64>,
29}
30impl SessionRequest {
31    pub fn new(
32        mut scopes: Vec<String>,
33        ttl_secs: Option<u64>,
34    ) -> Result<Self, SessionRequestError> {
35        check_scopes(&scopes)?;
36        scopes.sort_unstable();
37        if scopes.windows(2).any(|pair| pair[0] == pair[1]) || ttl_secs == Some(0) {
38            return Err(SessionRequestError::InvalidRequest);
39        }
40        Ok(Self { scopes, ttl_secs })
41    }
42    /// Exact requested TTL in seconds; None leaves selection to protected host policy.
43    pub const fn ttl_secs(&self) -> Option<u64> {
44        self.ttl_secs
45    }
46    pub fn scopes(&self) -> &[String] {
47        &self.scopes
48    }
49    /// Calculate a requested lease against protected TTL policy (maximum 1800s).
50    /// This authenticates nothing and does not cap login or authority deadlines.
51    /// The host supplies its clock and default/maximum policy, never the request.
52    pub fn expires_at_ns(
53        &self,
54        now_ns: u64,
55        default_ttl_secs: u64,
56        max_ttl_secs: u64,
57    ) -> Result<u64, SessionRequestError> {
58        validate_ttl_policy(default_ttl_secs, max_ttl_secs)?;
59        let ttl = self.ttl_secs.unwrap_or(default_ttl_secs);
60        if ttl == 0 || ttl > max_ttl_secs {
61            return Err(SessionRequestError::InvalidLifetime);
62        }
63        now_ns
64            .checked_add(ttl * crate::limits::NS_PER_SECOND)
65            .ok_or(SessionRequestError::InvalidLifetime)
66    }
67    pub(crate) fn check_granted_scopes(
68        &self,
69        granted: &[String],
70    ) -> Result<(), SessionRequestError> {
71        if self.scopes.iter().any(|scope| !granted.contains(scope)) {
72            return Err(SessionRequestError::ScopeRejected);
73        }
74        Ok(())
75    }
76    /// Exact Canic V1 request hash; None and an explicit default TTL differ.
77    pub fn hash(&self) -> [u8; 32] {
78        let mut hash = Sha256::new();
79        hash.update(b"canic-application-session-request-v1");
80        hash.update((self.scopes.len() as u64).to_be_bytes());
81        for scope in &self.scopes {
82            hash.update((scope.len() as u64).to_be_bytes());
83            hash.update(scope.as_bytes());
84        }
85        match self.ttl_secs {
86            None => hash.update([0]),
87            Some(ttl) => {
88                hash.update([1]);
89                hash.update(ttl.to_be_bytes());
90            }
91        }
92        hash.finalize().into()
93    }
94}
95
96pub(crate) fn validate_ttl_policy(default: u64, maximum: u64) -> Result<(), SessionRequestError> {
97    if default == 0 || default > maximum || maximum > crate::limits::MAX_SESSION_SECS {
98        return Err(SessionRequestError::InvalidLifetime);
99    }
100    Ok(())
101}
102
103pub(crate) fn check_scopes(scopes: &[String]) -> Result<(), SessionRequestError> {
104    if scopes.is_empty() || scopes.len() > crate::limits::MAX_SESSION_SCOPES {
105        return Err(SessionRequestError::InvalidRequest);
106    }
107    // The count bound and canonical 64-byte label bound imply <=1024 bytes.
108    for scope in scopes {
109        validate_scope_label(scope)?;
110    }
111    Ok(())
112}