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: i64,
121 role: String,
122 }
123
124 fn store() -> SessionStore {
125 SessionStore::new(&crate::derive_key(
126 b"a-very-long-development-secret-string!!",
127 "session",
128 ))
129 }
130
131 #[test]
132 fn encrypt_then_decrypt_round_trips() {
133 let s = store();
134 let token = s
135 .encode(&Sess {
136 user_id: 7,
137 role: "admin".into(),
138 })
139 .unwrap();
140 let back: Sess = s.decode(&token).unwrap();
141 assert_eq!(
142 back,
143 Sess {
144 user_id: 7,
145 role: "admin".into()
146 }
147 );
148 }
149
150 #[test]
151 fn tokens_are_opaque_and_nonce_randomized() {
152 let s = store();
153 let a = s
154 .encode(&Sess {
155 user_id: 1,
156 role: "u".into(),
157 })
158 .unwrap();
159 let b = s
160 .encode(&Sess {
161 user_id: 1,
162 role: "u".into(),
163 })
164 .unwrap();
165 assert_ne!(a, b, "fresh nonce per encode");
166 assert!(!a.contains("user_id"), "ciphertext is opaque: {a}");
167 }
168
169 #[test]
170 fn tampering_is_rejected() {
171 let s = store();
172 let mut token = s
173 .encode(&Sess {
174 user_id: 1,
175 role: "u".into(),
176 })
177 .unwrap();
178 let mid = token.len() / 2;
180 let bytes = flip_one_char(&token, mid);
181 token = bytes;
182 assert!(
183 s.decode::<Sess>(&token).is_err(),
184 "AEAD must reject tampering"
185 );
186 }
187
188 #[test]
189 fn a_wrong_key_cannot_decrypt() {
190 let a = store();
191 let token = a
192 .encode(&Sess {
193 user_id: 1,
194 role: "u".into(),
195 })
196 .unwrap();
197 let other = SessionStore::new(&crate::derive_key(
198 b"a-totally-different-secret-of-length-32+",
199 "session",
200 ));
201 assert!(other.decode::<Sess>(&token).is_err());
202 }
203
204 #[test]
205 fn set_cookie_and_clear_cookie_have_secure_attributes() {
206 let s = store();
207 let set = s
208 .set_cookie(&Sess {
209 user_id: 1,
210 role: "u".into(),
211 })
212 .unwrap();
213 assert!(set.starts_with("jerrycan_session="));
214 for attr in ["HttpOnly", "Secure", "SameSite=Lax", "Path=/"] {
215 assert!(set.contains(attr), "missing {attr}: {set}");
216 }
217 let clear = s.clear_cookie();
218 assert!(clear.contains("Max-Age=0"));
219 }
220
221 fn flip_one_char(s: &str, at: usize) -> String {
223 let mut chars: Vec<char> = s.chars().collect();
224 chars[at] = if chars[at] == 'A' { 'B' } else { 'A' };
225 chars.into_iter().collect()
226 }
227
228 const KEY_OLD: [u8; 32] = [1u8; 32];
231 const KEY_NEW: [u8; 32] = [2u8; 32];
232 const KEY_STRANGER: [u8; 32] = [9u8; 32];
233
234 fn sample() -> Sess {
235 Sess {
236 user_id: 42,
237 role: "user".into(),
238 }
239 }
240
241 #[test]
242 fn rotation_keeps_old_ciphertexts_decryptable_so_no_one_is_logged_out() {
243 let before = SessionStore::new(&KEY_OLD);
245 let token = before.encode(&sample()).unwrap();
246
247 let after = SessionStore::with_keys(&KEY_NEW, &[KEY_OLD]);
249 let back: Sess = after
250 .decode(&token)
251 .expect("a session minted before rotation must still decrypt via fallback");
252 assert_eq!(back, sample());
253 }
254
255 #[test]
256 fn encode_after_rotation_uses_the_new_primary_not_a_fallback() {
257 let after = SessionStore::with_keys(&KEY_NEW, &[KEY_OLD]);
258 let token = after.encode(&sample()).unwrap();
259 let new_only = SessionStore::new(&KEY_NEW);
261 assert_eq!(new_only.decode::<Sess>(&token).unwrap(), sample());
262 let old_only = SessionStore::new(&KEY_OLD);
264 assert!(old_only.decode::<Sess>(&token).is_err());
265 }
266
267 #[test]
268 fn a_key_in_neither_primary_nor_fallbacks_is_rejected_401() {
269 let stranger = SessionStore::new(&KEY_STRANGER);
271 let token = stranger.encode(&sample()).unwrap();
272
273 let store = SessionStore::with_keys(&KEY_NEW, &[KEY_OLD]);
274 let err = store.decode::<Sess>(&token).unwrap_err();
275 assert_eq!(
276 err.code(),
277 "JC0401",
278 "fully-retired/unknown keys must invalidate (rotation is not forever)"
279 );
280 }
281
282 #[test]
283 fn new_with_no_fallbacks_matches_with_keys_empty() {
284 let a = SessionStore::new(&KEY_NEW);
285 let token = a.encode(&sample()).unwrap();
286 let b = SessionStore::with_keys(&KEY_NEW, &[]);
287 assert_eq!(b.decode::<Sess>(&token).unwrap(), sample());
288 }
289}