ados_protocol/
dashboard_session.rs1use std::time::{SystemTime, UNIX_EPOCH};
31
32use hmac::{Hmac, KeyInit, Mac};
33use sha2::Sha256;
34use thiserror::Error;
35
36type HmacSha256 = Hmac<Sha256>;
37
38pub const SESSION_KEY_LABEL: &[u8] = b"ados-dashboard-session-v1";
41
42pub const DEFAULT_TTL_SECONDS: i64 = 86_400;
46
47pub 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#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct DashboardSession {
69 pub token: String,
70 pub expires_at: i64,
71}
72
73#[derive(Clone)]
77pub struct DashboardSessionIssuer {
78 key: Vec<u8>,
79}
80
81impl DashboardSessionIssuer {
82 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 pub fn mint(&self, ttl_seconds: i64) -> DashboardSession {
97 self.mint_at(ttl_seconds, now_unix())
98 }
99
100 pub fn mint_at(&self, ttl_seconds: i64, now: i64) -> DashboardSession {
102 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 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 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 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
144fn sign_payload(issued_at: i64, expires_at: i64) -> String {
146 format!("v1|{issued_at}|{expires_at}")
147}
148
149pub 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 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 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 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 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}