Skip to main content

jerrycan_auth/
session.rs

1//! Session cookies: server-private state, ChaCha20-Poly1305 AEAD
2//! (confidential + tamper-evident). Wire format: `base64url(nonce[12] โ€– ciphertext+tag)`.
3//! The cookie is Secure/HttpOnly/SameSite=Lax by default (spec ยง4.4).
4
5use base64::Engine;
6use chacha20poly1305::aead::{Aead, KeyInit, OsRng};
7use chacha20poly1305::{ChaCha20Poly1305, Nonce};
8use jerrycan_core::{Error, Result};
9use rand::RngCore;
10use serde::{Serialize, de::DeserializeOwned};
11
12const COOKIE_NAME: &str = "jerrycan_session";
13
14/// Encrypts/decrypts session payloads with a per-store AEAD key.
15///
16/// Supports key rotation: `encode` always uses the primary key, while `decode`
17/// tries the primary first, then each retired fallback in order. This lets a
18/// deployment rotate `JERRYCAN_SECRET` without invalidating sessions/tokens
19/// minted under the previous key โ€” the old key is moved to `fallbacks` until it
20/// is fully retired (dropped from the list), at which point its ciphertexts stop
21/// decrypting.
22#[derive(Clone)]
23pub struct SessionStore {
24    primary: ChaCha20Poly1305,
25    fallbacks: Vec<ChaCha20Poly1305>,
26}
27
28impl SessionStore {
29    /// Single-key store (no rotation). Equivalent to `with_keys(key, &[])`.
30    pub fn new(key: &[u8; 32]) -> Self {
31        Self {
32            primary: ChaCha20Poly1305::new(key.into()),
33            fallbacks: Vec::new(),
34        }
35    }
36
37    /// Rotation-aware store: `encode` uses `primary`; `decode` tries `primary`
38    /// then each entry of `fallbacks` in order. The first key that authenticates
39    /// the ciphertext wins.
40    pub fn with_keys(primary: &[u8; 32], fallbacks: &[[u8; 32]]) -> Self {
41        Self {
42            primary: ChaCha20Poly1305::new(primary.into()),
43            fallbacks: fallbacks
44                .iter()
45                .map(|k| ChaCha20Poly1305::new(k.into()))
46                .collect(),
47        }
48    }
49
50    /// Serialize + encrypt to a base64url token (no padding).
51    pub fn encode<T: Serialize>(&self, value: &T) -> Result<String> {
52        let plaintext = serde_json::to_vec(value)
53            .map_err(|e| Error::internal(format!("session serialize: {e}")))?;
54        let mut nonce_bytes = [0u8; 12];
55        OsRng.fill_bytes(&mut nonce_bytes);
56        let nonce = Nonce::from_slice(&nonce_bytes);
57        let ciphertext = self
58            .primary
59            .encrypt(nonce, plaintext.as_ref())
60            .map_err(|_| Error::internal("session encrypt failed"))?;
61        let mut combined = Vec::with_capacity(12 + ciphertext.len());
62        combined.extend_from_slice(&nonce_bytes);
63        combined.extend_from_slice(&ciphertext);
64        Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(combined))
65    }
66
67    /// Decrypt + deserialize. Tries the primary key, then each rotation fallback
68    /// in order; the first key that authenticates wins. Any failure (bad base64,
69    /// short input, AEAD rejection under *every* key, JSON shape) is `JC0401` โ€”
70    /// an untrusted client value.
71    pub fn decode<T: DeserializeOwned>(&self, token: &str) -> Result<T> {
72        let combined = base64::engine::general_purpose::URL_SAFE_NO_PAD
73            .decode(token)
74            .map_err(|_| Error::unauthorized())?;
75        if combined.len() < 12 {
76            return Err(Error::unauthorized());
77        }
78        let (nonce_bytes, ciphertext) = combined.split_at(12);
79        let nonce = Nonce::from_slice(nonce_bytes);
80        // Primary first, then retired keys in order. AEAD authenticates each
81        // attempt, so a wrong key simply fails to decrypt (no false positives).
82        let plaintext = std::iter::once(&self.primary)
83            .chain(self.fallbacks.iter())
84            .find_map(|cipher| cipher.decrypt(nonce, ciphertext).ok())
85            .ok_or_else(Error::unauthorized)?;
86        serde_json::from_slice(&plaintext).map_err(|_| Error::unauthorized())
87    }
88
89    /// A `Set-Cookie` header value establishing the session (secure defaults).
90    pub fn set_cookie<T: Serialize>(&self, value: &T) -> Result<String> {
91        let token = self.encode(value)?;
92        Ok(format!(
93            "{COOKIE_NAME}={token}; HttpOnly; Secure; SameSite=Lax; Path=/"
94        ))
95    }
96
97    /// A `Set-Cookie` header value clearing the session.
98    pub fn clear_cookie(&self) -> String {
99        format!("{COOKIE_NAME}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0")
100    }
101
102    /// Extract the session cookie value from a `Cookie` request header.
103    /// Public so sibling crates (and the fuzz-smoke suite) can exercise the parser.
104    pub fn read_cookie(&self, cookie_header: &str) -> Option<String> {
105        cookie_header
106            .split(';')
107            .filter_map(|kv| kv.trim().split_once('='))
108            .find(|(k, _)| *k == COOKIE_NAME)
109            .map(|(_, v)| v.to_string())
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use serde::Deserialize;
117
118    #[derive(Serialize, Deserialize, PartialEq, Debug)]
119    struct Sess {
120        // Stringified pk (mirrors storage's TEXT owner_id): the identity round-trips
121        // whether the app's user pk is an integer or a uuid.
122        user_id: String,
123        role: String,
124    }
125
126    fn store() -> SessionStore {
127        SessionStore::new(&crate::derive_key(
128            b"a-very-long-development-secret-string!!",
129            "session",
130        ))
131    }
132
133    #[test]
134    fn encrypt_then_decrypt_round_trips() {
135        let s = store();
136        let token = s
137            .encode(&Sess {
138                user_id: "7".into(),
139                role: "admin".into(),
140            })
141            .unwrap();
142        let back: Sess = s.decode(&token).unwrap();
143        assert_eq!(
144            back,
145            Sess {
146                user_id: "7".into(),
147                role: "admin".into()
148            }
149        );
150    }
151
152    #[test]
153    fn tokens_are_opaque_and_nonce_randomized() {
154        let s = store();
155        let a = s
156            .encode(&Sess {
157                user_id: "1".into(),
158                role: "u".into(),
159            })
160            .unwrap();
161        let b = s
162            .encode(&Sess {
163                user_id: "1".into(),
164                role: "u".into(),
165            })
166            .unwrap();
167        assert_ne!(a, b, "fresh nonce per encode");
168        assert!(!a.contains("user_id"), "ciphertext is opaque: {a}");
169    }
170
171    #[test]
172    fn tampering_is_rejected() {
173        let s = store();
174        let mut token = s
175            .encode(&Sess {
176                user_id: "1".into(),
177                role: "u".into(),
178            })
179            .unwrap();
180        // Flip a character in the middle of the base64 payload.
181        let mid = token.len() / 2;
182        let bytes = flip_one_char(&token, mid);
183        token = bytes;
184        assert!(
185            s.decode::<Sess>(&token).is_err(),
186            "AEAD must reject tampering"
187        );
188    }
189
190    #[test]
191    fn a_wrong_key_cannot_decrypt() {
192        let a = store();
193        let token = a
194            .encode(&Sess {
195                user_id: "1".into(),
196                role: "u".into(),
197            })
198            .unwrap();
199        let other = SessionStore::new(&crate::derive_key(
200            b"a-totally-different-secret-of-length-32+",
201            "session",
202        ));
203        assert!(other.decode::<Sess>(&token).is_err());
204    }
205
206    #[test]
207    fn set_cookie_and_clear_cookie_have_secure_attributes() {
208        let s = store();
209        let set = s
210            .set_cookie(&Sess {
211                user_id: "1".into(),
212                role: "u".into(),
213            })
214            .unwrap();
215        assert!(set.starts_with("jerrycan_session="));
216        for attr in ["HttpOnly", "Secure", "SameSite=Lax", "Path=/"] {
217            assert!(set.contains(attr), "missing {attr}: {set}");
218        }
219        let clear = s.clear_cookie();
220        assert!(clear.contains("Max-Age=0"));
221    }
222
223    // Flips one base64 char to a different one (corrupts the token).
224    fn flip_one_char(s: &str, at: usize) -> String {
225        let mut chars: Vec<char> = s.chars().collect();
226        chars[at] = if chars[at] == 'A' { 'B' } else { 'A' };
227        chars.into_iter().collect()
228    }
229
230    // --- rotation (multi-key decrypt) ---
231
232    const KEY_OLD: [u8; 32] = [1u8; 32];
233    const KEY_NEW: [u8; 32] = [2u8; 32];
234    const KEY_STRANGER: [u8; 32] = [9u8; 32];
235
236    fn sample() -> Sess {
237        Sess {
238            user_id: "42".into(),
239            role: "user".into(),
240        }
241    }
242
243    #[test]
244    fn rotation_keeps_old_ciphertexts_decryptable_so_no_one_is_logged_out() {
245        // Encrypt under the OLD key (single-key store, pre-rotation).
246        let before = SessionStore::new(&KEY_OLD);
247        let token = before.encode(&sample()).unwrap();
248
249        // Rotate: NEW is primary, OLD becomes a retired fallback.
250        let after = SessionStore::with_keys(&KEY_NEW, &[KEY_OLD]);
251        let back: Sess = after
252            .decode(&token)
253            .expect("a session minted before rotation must still decrypt via fallback");
254        assert_eq!(back, sample());
255    }
256
257    #[test]
258    fn encode_after_rotation_uses_the_new_primary_not_a_fallback() {
259        let after = SessionStore::with_keys(&KEY_NEW, &[KEY_OLD]);
260        let token = after.encode(&sample()).unwrap();
261        // The NEW key alone (no fallbacks) must decrypt it: encode used primary.
262        let new_only = SessionStore::new(&KEY_NEW);
263        assert_eq!(new_only.decode::<Sess>(&token).unwrap(), sample());
264        // The OLD key alone must NOT decrypt it.
265        let old_only = SessionStore::new(&KEY_OLD);
266        assert!(old_only.decode::<Sess>(&token).is_err());
267    }
268
269    #[test]
270    fn a_key_in_neither_primary_nor_fallbacks_is_rejected_401() {
271        // A ciphertext from a stranger key (never primary, never retired).
272        let stranger = SessionStore::new(&KEY_STRANGER);
273        let token = stranger.encode(&sample()).unwrap();
274
275        let store = SessionStore::with_keys(&KEY_NEW, &[KEY_OLD]);
276        let err = store.decode::<Sess>(&token).unwrap_err();
277        assert_eq!(
278            err.code(),
279            "JC0401",
280            "fully-retired/unknown keys must invalidate (rotation is not forever)"
281        );
282    }
283
284    #[test]
285    fn new_with_no_fallbacks_matches_with_keys_empty() {
286        let a = SessionStore::new(&KEY_NEW);
287        let token = a.encode(&sample()).unwrap();
288        let b = SessionStore::with_keys(&KEY_NEW, &[]);
289        assert_eq!(b.decode::<Sess>(&token).unwrap(), sample());
290    }
291}