Skip to main content

ados_protocol/
dashboard_session.rs

1//! Self-contained dashboard-access session tokens.
2//!
3//! A browser reaching a PAIRED agent's own web dashboard from off-box has no
4//! `X-ADOS-Key` (that key lives in the GCS, not in a casual visitor's browser).
5//! Rather than prompt for the raw key, the dashboard offers a short numeric PIN:
6//! the operator enters it, and the agent hands back one of these session tokens,
7//! which the dashboard then sends on every `/api/*` call as
8//! `X-ADOS-Dashboard-Session`. The native control front accepts a valid session
9//! as an alternative data-plane credential to `X-ADOS-Key`.
10//!
11//! Like [`crate::ws_ticket`], the token is **self-contained and HMAC-signed** —
12//! the minting surface and the validating surface (the same daemon here, but the
13//! shape matches the ws-ticket precedent) share no state, only the key. The key
14//! is derived from BOTH the pairing `api_key` AND the PIN record's random salt:
15//!
16//! ```text
17//! K = HMAC-SHA256(api_key, "ados-dashboard-session-v1" || salt)
18//! ```
19//!
20//! Folding the salt into the key is what makes a PIN reset revoke every live
21//! session: resetting the PIN writes a fresh salt, so `K` changes and every
22//! previously-minted token fails to verify. Re-pairing (a new `api_key`) revokes
23//! them too. The salt is never the pairing key itself, and is namespaced to this
24//! use by the domain-separation label.
25//!
26//! Wire form (pipe-delimited, like the ws ticket but with no scope field):
27//! `v1|<issued_at>|<expires_at>|<sig_hex>`, where the signature is
28//! `HMAC-SHA256(K, "v1|<issued_at>|<expires_at>")`.
29
30use std::time::{SystemTime, UNIX_EPOCH};
31
32use hmac::{Hmac, KeyInit, Mac};
33use sha2::Sha256;
34use thiserror::Error;
35
36type HmacSha256 = Hmac<Sha256>;
37
38/// Domain-separation label mixed (with the salt) into the pairing key to derive
39/// the session key.
40pub const SESSION_KEY_LABEL: &[u8] = b"ados-dashboard-session-v1";
41
42/// Default session lifetime in seconds (24 h): long enough that a dashboard is
43/// not re-prompting the operator through a work session, short enough that an
44/// abandoned browser tab does not stay authorized forever.
45pub const DEFAULT_TTL_SECONDS: i64 = 86_400;
46
47/// Hard cap on a requested session lifetime (7 days), the ceiling a silent
48/// activity-refresh re-mints under.
49pub const MAX_TTL_SECONDS: i64 = 604_800;
50
51#[derive(Debug, Error, PartialEq, Eq)]
52pub enum SessionError {
53    #[error("malformed dashboard session token")]
54    Malformed,
55    #[error("dashboard session timestamp is not an integer")]
56    BadTimestamp,
57    #[error("dashboard session signature is not valid hex")]
58    BadSignature,
59    #[error("dashboard session HMAC mismatch")]
60    HmacMismatch,
61    #[error("dashboard session expired")]
62    Expired,
63}
64
65/// A minted session: its compact string form plus the parsed expiry, so a mint
66/// surface can return `{ session, expires_at }` without re-parsing.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct DashboardSession {
69    pub token: String,
70    pub expires_at: i64,
71}
72
73/// Mints and verifies self-contained dashboard-access sessions, keyed by the
74/// pairing `api_key` + the PIN record's salt. Build one with
75/// [`DashboardSessionIssuer::from_api_key_and_salt`].
76#[derive(Clone)]
77pub struct DashboardSessionIssuer {
78    key: Vec<u8>,
79}
80
81impl DashboardSessionIssuer {
82    /// Derive the session key from the pairing `api_key` and the PIN salt under
83    /// the fixed label: `K = HMAC-SHA256(api_key, LABEL || salt)`. A fresh salt
84    /// (written on every PIN set/reset) yields a fresh `K`, so a reset revokes
85    /// every prior session.
86    pub fn from_api_key_and_salt(api_key: &str, salt: &[u8]) -> Self {
87        let mut mac =
88            HmacSha256::new_from_slice(api_key.as_bytes()).expect("HMAC accepts any key length");
89        mac.update(SESSION_KEY_LABEL);
90        mac.update(salt);
91        let key = mac.finalize().into_bytes().to_vec();
92        Self { key }
93    }
94
95    /// Mint a session valid for `ttl_seconds`, using the wall clock.
96    pub fn mint(&self, ttl_seconds: i64) -> DashboardSession {
97        self.mint_at(ttl_seconds, now_unix())
98    }
99
100    /// Deterministic mint core (explicit clock) for tests and cross-impl vectors.
101    pub fn mint_at(&self, ttl_seconds: i64, now: i64) -> DashboardSession {
102        // Saturating add so a far-future clock or a huge ttl cannot overflow
103        // (which would panic under the workspace's panic=abort).
104        let expires_at = now.saturating_add(ttl_seconds);
105        let payload = sign_payload(now, expires_at);
106        let signature = self.sign(&payload);
107        DashboardSession {
108            token: format!("{payload}|{signature}"),
109            expires_at,
110        }
111    }
112
113    /// Verify a session token: HMAC authenticity first, then expiry. `now` is
114    /// unix seconds.
115    pub fn verify(&self, token: &str, now: i64) -> Result<(), SessionError> {
116        let parts: Vec<&str> = token.split('|').collect();
117        if parts.len() != 4 || parts[0] != "v1" {
118            return Err(SessionError::Malformed);
119        }
120        let _issued: i64 = parts[1].parse().map_err(|_| SessionError::BadTimestamp)?;
121        let expires_at: i64 = parts[2].parse().map_err(|_| SessionError::BadTimestamp)?;
122        let sig = hex::decode(parts[3]).map_err(|_| SessionError::BadSignature)?;
123        // Recompute over the EXACT signed substring (the first three pipe fields),
124        // so reformatting never drifts from what was signed.
125        let payload = parts[..3].join("|");
126        let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
127        mac.update(payload.as_bytes());
128        // verify_slice is a constant-time comparison.
129        mac.verify_slice(&sig)
130            .map_err(|_| SessionError::HmacMismatch)?;
131        if now >= expires_at {
132            return Err(SessionError::Expired);
133        }
134        Ok(())
135    }
136
137    fn sign(&self, payload: &str) -> String {
138        let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
139        mac.update(payload.as_bytes());
140        hex::encode(mac.finalize().into_bytes())
141    }
142}
143
144/// The signed substring: `v1|<issued_at>|<expires_at>`.
145fn sign_payload(issued_at: i64, expires_at: i64) -> String {
146    format!("v1|{issued_at}|{expires_at}")
147}
148
149/// Unix seconds, clamped to 0 if the clock predates the epoch (a freshly booted
150/// SBC before NTP sync) so minting never aborts the process.
151pub fn now_unix() -> i64 {
152    SystemTime::now()
153        .duration_since(UNIX_EPOCH)
154        .map(|d| d.as_secs() as i64)
155        .unwrap_or(0)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    const SALT: &[u8] = b"a-random-salt-16";
163
164    #[test]
165    fn mint_round_trips_and_verifies() {
166        let issuer = DashboardSessionIssuer::from_api_key_and_salt("ados_secret", SALT);
167        let s = issuer.mint_at(86_400, 1000);
168        assert_eq!(s.expires_at, 87_400);
169        assert!(s.token.starts_with("v1|1000|87400|"));
170        assert!(issuer.verify(&s.token, 1000).is_ok());
171        assert!(issuer.verify(&s.token, 87_399).is_ok());
172    }
173
174    #[test]
175    fn rejects_expired_at_the_boundary() {
176        let issuer = DashboardSessionIssuer::from_api_key_and_salt("k", SALT);
177        let s = issuer.mint_at(30, 1000);
178        // now == expires_at is expired (matches the ws-ticket / plugin token `>=`).
179        assert_eq!(issuer.verify(&s.token, 1030), Err(SessionError::Expired));
180    }
181
182    #[test]
183    fn rejects_wrong_key() {
184        let a = DashboardSessionIssuer::from_api_key_and_salt("key-a", SALT);
185        let b = DashboardSessionIssuer::from_api_key_and_salt("key-b", SALT);
186        let s = a.mint_at(30, 1000);
187        assert_eq!(b.verify(&s.token, 1000), Err(SessionError::HmacMismatch));
188    }
189
190    #[test]
191    fn a_fresh_salt_revokes_prior_sessions() {
192        // The reset-kills-sessions invariant: the same api_key with a DIFFERENT
193        // salt (what a PIN reset writes) cannot verify a token minted under the
194        // old salt.
195        let old = DashboardSessionIssuer::from_api_key_and_salt("ados_secret", b"old-salt");
196        let new = DashboardSessionIssuer::from_api_key_and_salt("ados_secret", b"new-salt");
197        let s = old.mint_at(86_400, 1000);
198        assert!(
199            old.verify(&s.token, 1000).is_ok(),
200            "the old salt still verifies its own token"
201        );
202        assert_eq!(
203            new.verify(&s.token, 1000),
204            Err(SessionError::HmacMismatch),
205            "a reset (new salt) must revoke the old session"
206        );
207    }
208
209    #[test]
210    fn rejects_tampered_expiry() {
211        let issuer = DashboardSessionIssuer::from_api_key_and_salt("k", SALT);
212        let s = issuer.mint_at(30, 1000);
213        // Forge a longer expiry while keeping the original signature.
214        let parts: Vec<&str> = s.token.split('|').collect();
215        let forged = format!("v1|{}|99999|{}", parts[1], parts[3]);
216        assert_eq!(
217            issuer.verify(&forged, 1000),
218            Err(SessionError::HmacMismatch)
219        );
220    }
221
222    #[test]
223    fn rejects_malformed() {
224        let issuer = DashboardSessionIssuer::from_api_key_and_salt("k", SALT);
225        assert_eq!(issuer.verify("", 0), Err(SessionError::Malformed));
226        // A ws-ticket-shaped 5-field token is malformed here (no scope field).
227        assert_eq!(
228            issuer.verify("v1|s|1|2|ff", 0),
229            Err(SessionError::Malformed)
230        );
231        assert_eq!(issuer.verify("v2|1|2|ff", 0), Err(SessionError::Malformed));
232        assert_eq!(
233            issuer.verify("v1|notanint|2|ff", 0),
234            Err(SessionError::BadTimestamp)
235        );
236        assert_eq!(
237            issuer.verify("v1|1|2|nothex", 0),
238            Err(SessionError::BadSignature)
239        );
240    }
241
242    #[test]
243    fn ttl_saturates_rather_than_overflows() {
244        let issuer = DashboardSessionIssuer::from_api_key_and_salt("k", SALT);
245        let s = issuer.mint_at(i64::MAX, i64::MAX - 1);
246        assert_eq!(s.expires_at, i64::MAX);
247    }
248}