1use 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#[derive(Clone)]
23pub struct SessionStore {
24 primary: ChaCha20Poly1305,
25 fallbacks: Vec<ChaCha20Poly1305>,
26}
27
28impl SessionStore {
29 pub fn new(key: &[u8; 32]) -> Self {
31 Self {
32 primary: ChaCha20Poly1305::new(key.into()),
33 fallbacks: Vec::new(),
34 }
35 }
36
37 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 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 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 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 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 pub fn clear_cookie(&self) -> String {
99 format!("{COOKIE_NAME}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0")
100 }
101
102 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 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 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 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 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 let before = SessionStore::new(&KEY_OLD);
247 let token = before.encode(&sample()).unwrap();
248
249 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 let new_only = SessionStore::new(&KEY_NEW);
263 assert_eq!(new_only.decode::<Sess>(&token).unwrap(), sample());
264 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 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}