Skip to main content

sutegi_session/
lib.rs

1//! Signed-cookie sessions for sutegi.
2//!
3//! State lives in an HMAC-SHA256-signed cookie — stateless on the server, and
4//! tamper-evident (a modified payload fails verification and is discarded).
5//! The signing primitives are the audited RustCrypto `hmac`/`sha2` crates,
6//! pulled in only when you enable sutegi's `auth` feature.
7//!
8//! ```ignore
9//! let sessions = Sessions::new(b"a-32+ byte secret from your config");
10//!
11//! // in a handler:
12//! let mut s = sessions.load(req);
13//! s.set("user_id", Json::int(42));
14//! sessions.save(&s, json(200, &Json::obj(vec![("ok", Json::Bool(true))])))
15//! ```
16
17use std::collections::BTreeMap;
18
19use hmac::{Hmac, Mac};
20use sha2::Sha256;
21use sutegi_json::Json;
22use sutegi_web::{Request, Response};
23
24type HmacSha256 = Hmac<Sha256>;
25
26/// Session manager: holds the signing secret and cookie policy.
27pub struct Sessions {
28    secret: Vec<u8>,
29    cookie: String,
30    secure: bool,
31    max_age: Option<u64>,
32}
33
34impl Sessions {
35    /// Create a manager from a secret (use a long, random, configured value).
36    pub fn new(secret: &[u8]) -> Sessions {
37        Sessions {
38            secret: secret.to_vec(),
39            cookie: "sutegi_session".to_string(),
40            secure: true,
41            max_age: Some(86_400),
42        }
43    }
44
45    pub fn cookie_name(mut self, name: &str) -> Sessions {
46        self.cookie = name.to_string();
47        self
48    }
49
50    /// Drop the `Secure` attribute (for local `http://` development only).
51    pub fn insecure(mut self) -> Sessions {
52        self.secure = false;
53        self
54    }
55
56    pub fn max_age(mut self, secs: Option<u64>) -> Sessions {
57        self.max_age = secs;
58        self
59    }
60
61    fn sign(&self, msg: &[u8]) -> String {
62        let mut mac =
63            HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts any key length");
64        mac.update(msg);
65        to_hex(&mac.finalize().into_bytes())
66    }
67
68    /// Sign an arbitrary value into a `<value-hex>.<sig>` token (CSRF tokens,
69    /// password-reset links, …). Verify with [`verify_token`](Sessions::verify_token).
70    pub fn token(&self, value: &str) -> String {
71        let hex = to_hex(value.as_bytes());
72        format!("{}.{}", hex, self.sign(value.as_bytes()))
73    }
74
75    /// Verify a token from [`token`](Sessions::token); returns the original value.
76    pub fn verify_token(&self, token: &str) -> Option<String> {
77        let (hex, sig) = token.split_once('.')?;
78        let bytes = from_hex(hex)?;
79        if constant_time_eq(self.sign(&bytes).as_bytes(), sig.as_bytes()) {
80            String::from_utf8(bytes).ok()
81        } else {
82            None
83        }
84    }
85
86    /// Load and verify the session from the request cookie; returns an empty
87    /// session if absent, tampered, or malformed.
88    pub fn load(&self, req: &Request) -> Session {
89        if let Some(raw) = req.cookie(&self.cookie) {
90            if let Some((payload_hex, sig)) = raw.split_once('.') {
91                if let Some(bytes) = from_hex(payload_hex) {
92                    if constant_time_eq(self.sign(&bytes).as_bytes(), sig.as_bytes()) {
93                        if let Ok(s) = std::str::from_utf8(&bytes) {
94                            if let Ok(Json::Obj(map)) = Json::parse(s) {
95                                return Session {
96                                    data: map,
97                                    dirty: false,
98                                };
99                            }
100                        }
101                    }
102                }
103            }
104        }
105        Session {
106            data: BTreeMap::new(),
107            dirty: false,
108        }
109    }
110
111    /// Attach the signed session as a `Set-Cookie` on the response.
112    pub fn save(&self, session: &Session, resp: Response) -> Response {
113        let payload = Json::Obj(session.data.clone()).to_string();
114        let payload_hex = to_hex(payload.as_bytes());
115        let sig = self.sign(payload.as_bytes());
116        let mut cookie = format!(
117            "{}={}.{}; Path=/; HttpOnly; SameSite=Lax",
118            self.cookie, payload_hex, sig
119        );
120        if self.secure {
121            cookie.push_str("; Secure");
122        }
123        if let Some(age) = self.max_age {
124            cookie.push_str(&format!("; Max-Age={}", age));
125        }
126        resp.with_header("set-cookie", &cookie)
127    }
128
129    /// Expire the session cookie.
130    pub fn clear(&self, resp: Response) -> Response {
131        resp.with_header(
132            "set-cookie",
133            &format!("{}=; Path=/; Max-Age=0", self.cookie),
134        )
135    }
136}
137
138/// The session payload — a small JSON map.
139pub struct Session {
140    data: BTreeMap<String, Json>,
141    dirty: bool,
142}
143
144impl Session {
145    pub fn get(&self, key: &str) -> Option<&Json> {
146        self.data.get(key)
147    }
148    pub fn get_str(&self, key: &str) -> Option<&str> {
149        self.data.get(key).and_then(|j| j.as_str())
150    }
151    pub fn set(&mut self, key: &str, value: Json) {
152        self.data.insert(key.to_string(), value);
153        self.dirty = true;
154    }
155    pub fn remove(&mut self, key: &str) {
156        self.data.remove(key);
157        self.dirty = true;
158    }
159    pub fn clear(&mut self) {
160        self.data.clear();
161        self.dirty = true;
162    }
163    pub fn is_empty(&self) -> bool {
164        self.data.is_empty()
165    }
166    /// Whether the session was modified since loading (worth re-saving).
167    pub fn is_dirty(&self) -> bool {
168        self.dirty
169    }
170}
171
172fn to_hex(bytes: &[u8]) -> String {
173    let mut s = String::with_capacity(bytes.len() * 2);
174    for b in bytes {
175        s.push_str(&format!("{:02x}", b));
176    }
177    s
178}
179
180fn from_hex(s: &str) -> Option<Vec<u8>> {
181    if s.len() % 2 != 0 {
182        return None;
183    }
184    (0..s.len())
185        .step_by(2)
186        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
187        .collect()
188}
189
190fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
191    if a.len() != b.len() {
192        return false;
193    }
194    let mut diff = 0u8;
195    for i in 0..a.len() {
196        diff |= a[i] ^ b[i];
197    }
198    diff == 0
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    fn req_with_cookie(name: &str, value: &str) -> Request {
206        Request {
207            method: sutegi_web::Method::Get,
208            path: "/".into(),
209            query: String::new(),
210            version: "HTTP/1.1".into(),
211            headers: vec![("Cookie".into(), format!("{}={}", name, value))],
212            body: vec![],
213            peer: None,
214        }
215    }
216
217    /// Pull the cookie value out of a Set-Cookie response header.
218    fn cookie_value(resp: &Response, _name: &str) -> String {
219        let header = resp
220            .headers
221            .iter()
222            .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
223            .map(|(_, v)| v.clone())
224            .unwrap();
225        let kv = header.split(';').next().unwrap();
226        kv.split_once('=').unwrap().1.to_string()
227    }
228
229    #[test]
230    fn roundtrip_and_tamper() {
231        let s = Sessions::new(b"super-secret-key").insecure();
232        let mut sess = s.load(&req_with_cookie("x", "")); // empty
233        assert!(sess.is_empty());
234        sess.set("user_id", Json::int(42));
235
236        let resp = s.save(&sess, Response::new(200));
237        let cookie = cookie_value(&resp, "sutegi_session");
238
239        // Reload from the produced cookie.
240        let reloaded = s.load(&req_with_cookie("sutegi_session", &cookie));
241        assert_eq!(reloaded.get("user_id").and_then(Json::as_i64), Some(42));
242
243        // Tamper with the payload → signature fails → empty session.
244        let tampered = cookie.replacen(|c: char| c.is_ascii_hexdigit(), "0", 1);
245        let after = s.load(&req_with_cookie("sutegi_session", &tampered));
246        assert!(after.is_empty() || after.get("user_id").and_then(Json::as_i64) != Some(42));
247    }
248
249    #[test]
250    fn token_sign_verify() {
251        let s = Sessions::new(b"k");
252        let t = s.token("user:42");
253        assert_eq!(s.verify_token(&t).as_deref(), Some("user:42"));
254        assert!(s.verify_token("deadbeef.badsig").is_none());
255    }
256
257    #[test]
258    fn save_sets_cookie_attributes() {
259        let s = Sessions::new(b"secret"); // secure by default, 1-day max-age
260        let mut sess = s.load(&req_with_cookie("x", ""));
261        sess.set("k", Json::int(1));
262        let header = s
263            .save(&sess, Response::new(200))
264            .headers
265            .iter()
266            .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
267            .map(|(_, v)| v.clone())
268            .unwrap();
269        assert!(header.contains("HttpOnly"));
270        assert!(header.contains("SameSite=Lax"));
271        assert!(header.contains("Secure"));
272        assert!(header.contains("Max-Age=86400"));
273        assert!(header.starts_with("sutegi_session="));
274    }
275
276    #[test]
277    fn insecure_and_no_max_age_omit_attributes() {
278        let s = Sessions::new(b"secret").insecure().max_age(None);
279        let header = s
280            .save(
281                &Session {
282                    data: BTreeMap::new(),
283                    dirty: false,
284                },
285                Response::new(200),
286            )
287            .headers
288            .iter()
289            .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
290            .map(|(_, v)| v.clone())
291            .unwrap();
292        assert!(!header.contains("Secure"));
293        assert!(!header.contains("Max-Age"));
294    }
295
296    #[test]
297    fn custom_cookie_name_roundtrips() {
298        let s = Sessions::new(b"k").cookie_name("sid").insecure();
299        let mut sess = s.load(&req_with_cookie("sid", ""));
300        sess.set("user", Json::str("eneko"));
301        let cookie = cookie_value(&s.save(&sess, Response::new(200)), "sid");
302        let reloaded = s.load(&req_with_cookie("sid", &cookie));
303        assert_eq!(reloaded.get_str("user"), Some("eneko"));
304        // A session signed under "sid" is invisible under the default name.
305        assert!(s
306            .load(&req_with_cookie("sutegi_session", &cookie))
307            .is_empty());
308    }
309
310    #[test]
311    fn clear_expires_cookie() {
312        let s = Sessions::new(b"k");
313        let header = s
314            .clear(Response::new(200))
315            .headers
316            .iter()
317            .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
318            .map(|(_, v)| v.clone())
319            .unwrap();
320        assert!(header.contains("Max-Age=0"));
321    }
322
323    #[test]
324    fn session_mutation_tracks_dirty() {
325        let mut sess = Session {
326            data: BTreeMap::new(),
327            dirty: false,
328        };
329        assert!(!sess.is_dirty());
330        sess.set("a", Json::int(1));
331        assert!(sess.is_dirty());
332        assert_eq!(sess.get("a").and_then(Json::as_i64), Some(1));
333        sess.remove("a");
334        assert!(sess.get("a").is_none());
335        sess.set("b", Json::int(2));
336        sess.clear();
337        assert!(sess.is_empty());
338    }
339
340    #[test]
341    fn empty_payload_token_verification_fails_cleanly() {
342        let s = Sessions::new(b"k");
343        // No dot separator → None, not a panic.
344        assert!(s.verify_token("nosig").is_none());
345        // Odd-length hex → None.
346        assert!(s.verify_token("abc.sig").is_none());
347    }
348}