1use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23use base64::engine::general_purpose::URL_SAFE_NO_PAD;
24use base64::Engine;
25use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
26use ring::digest::{digest, SHA256};
27use ring::rand::{SecureRandom, SystemRandom};
28
29#[derive(Debug, PartialEq)]
32pub enum CookieError {
33 Malformed,
35 BadSeal,
37 Expired,
39}
40
41impl std::fmt::Display for CookieError {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 let s = match self {
44 Self::Malformed => "malformed cookie",
45 Self::BadSeal => "cookie failed authentication",
46 Self::Expired => "cookie expired",
47 };
48 f.write_str(s)
49 }
50}
51
52pub struct CookieSealer {
54 key_bytes: [u8; 32],
55 rng: SystemRandom,
56}
57
58impl CookieSealer {
59 pub fn new(secret: &str) -> Self {
62 let d = digest(&SHA256, secret.as_bytes());
63 let mut key_bytes = [0u8; 32];
64 key_bytes.copy_from_slice(d.as_ref());
65 Self {
66 key_bytes,
67 rng: SystemRandom::new(),
68 }
69 }
70
71 fn key(&self) -> LessSafeKey {
72 LessSafeKey::new(
73 UnboundKey::new(&AES_256_GCM, &self.key_bytes)
74 .expect("AES-256-GCM key from 32 bytes is always valid"),
75 )
76 }
77
78 pub fn seal(&self, payload: &[u8], ttl: Duration) -> String {
84 let expiry = now_unix().saturating_add(ttl.as_secs());
85
86 let mut plaintext = Vec::with_capacity(8 + payload.len());
87 plaintext.extend_from_slice(&expiry.to_be_bytes());
88 plaintext.extend_from_slice(payload);
89
90 let mut nonce_bytes = [0u8; NONCE_LEN];
91 self.rng
92 .fill(&mut nonce_bytes)
93 .expect("system RNG must produce a nonce");
94 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
95
96 self.key()
97 .seal_in_place_append_tag(nonce, Aad::empty(), &mut plaintext)
98 .expect("sealing never fails for a valid key/nonce");
99
100 let mut out = Vec::with_capacity(NONCE_LEN + plaintext.len());
101 out.extend_from_slice(&nonce_bytes);
102 out.extend_from_slice(&plaintext);
103 URL_SAFE_NO_PAD.encode(out)
104 }
105
106 pub fn open(&self, value: &str) -> Result<Vec<u8>, CookieError> {
111 let raw = URL_SAFE_NO_PAD
112 .decode(value.as_bytes())
113 .map_err(|_| CookieError::Malformed)?;
114 if raw.len() < NONCE_LEN + 16 + 8 {
116 return Err(CookieError::Malformed);
117 }
118 let (nonce_bytes, sealed) = raw.split_at(NONCE_LEN);
119 let mut in_out = sealed.to_vec();
120 let nonce =
121 Nonce::try_assume_unique_for_key(nonce_bytes).map_err(|_| CookieError::Malformed)?;
122
123 let plaintext = self
124 .key()
125 .open_in_place(nonce, Aad::empty(), &mut in_out)
126 .map_err(|_| CookieError::BadSeal)?;
127
128 if plaintext.len() < 8 {
129 return Err(CookieError::BadSeal);
130 }
131 let mut expiry_bytes = [0u8; 8];
132 expiry_bytes.copy_from_slice(&plaintext[..8]);
133 let expiry = u64::from_be_bytes(expiry_bytes);
134 if now_unix() > expiry {
135 return Err(CookieError::Expired);
136 }
137 Ok(plaintext[8..].to_vec())
138 }
139}
140
141#[allow(dead_code)]
144#[derive(Debug, Clone, Copy)]
145pub enum SameSite {
146 Strict,
147 Lax,
148 None,
149}
150
151impl SameSite {
152 fn as_str(&self) -> &'static str {
153 match self {
154 Self::Strict => "Strict",
155 Self::Lax => "Lax",
156 Self::None => "None",
157 }
158 }
159}
160
161pub struct CookieAttrs<'a> {
163 pub path: &'a str,
164 pub max_age: Option<u64>,
167 pub http_only: bool,
168 pub secure: bool,
169 pub same_site: SameSite,
170}
171
172impl Default for CookieAttrs<'_> {
173 fn default() -> Self {
174 Self {
175 path: "/",
176 max_age: None,
177 http_only: true,
178 secure: true,
179 same_site: SameSite::Lax,
180 }
181 }
182}
183
184pub fn build_set_cookie(name: &str, value: &str, attrs: &CookieAttrs) -> String {
186 let mut s = format!("{}={}; Path={}", name, value, attrs.path);
187 if let Some(max_age) = attrs.max_age {
188 s.push_str(&format!("; Max-Age={}", max_age));
189 }
190 if attrs.http_only {
191 s.push_str("; HttpOnly");
192 }
193 if attrs.secure {
194 s.push_str("; Secure");
195 }
196 s.push_str(&format!("; SameSite={}", attrs.same_site.as_str()));
197 s
198}
199
200pub fn path_covers(cookie_path: &str, request_path: &str) -> bool {
209 let cp = cookie_path.trim_end_matches('/');
210 if cp.is_empty() {
211 return true;
212 }
213 request_path == cp || request_path.starts_with(&format!("{}/", cp))
214}
215
216pub fn read_cookie<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
218 for pair in cookie_header.split(';') {
219 let pair = pair.trim();
220 if let Some((k, v)) = pair.split_once('=') {
221 if k == name {
222 return Some(v);
223 }
224 }
225 }
226 None
227}
228
229pub fn delete_cookie(name: &str, path: &str) -> String {
231 build_set_cookie(
232 name,
233 "",
234 &CookieAttrs {
235 path,
236 max_age: Some(0),
237 ..Default::default()
238 },
239 )
240}
241
242fn now_unix() -> u64 {
243 SystemTime::now()
244 .duration_since(UNIX_EPOCH)
245 .map(|d| d.as_secs())
246 .unwrap_or(0)
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn test_seal_open_round_trip() {
255 let sealer = CookieSealer::new("my-signing-secret");
256 let payload = br#"{"sub":"alice","tok":"xyz"}"#;
257 let cookie = sealer.seal(payload, Duration::from_secs(3600));
258 assert_eq!(sealer.open(&cookie).unwrap(), payload);
259 }
260
261 #[test]
262 fn test_wrong_key_fails() {
263 let a = CookieSealer::new("secret-a");
264 let b = CookieSealer::new("secret-b");
265 let cookie = a.seal(b"hello", Duration::from_secs(60));
266 assert_eq!(b.open(&cookie), Err(CookieError::BadSeal));
267 }
268
269 #[test]
270 fn test_tamper_fails() {
271 let sealer = CookieSealer::new("k");
272 let mut cookie = sealer.seal(b"hello", Duration::from_secs(60));
273 let mid = cookie.len() / 2;
275 let ch = cookie.as_bytes()[mid];
276 let replacement = if ch == b'A' { 'B' } else { 'A' };
277 cookie.replace_range(mid..mid + 1, &replacement.to_string());
278 assert!(matches!(
279 sealer.open(&cookie),
280 Err(CookieError::BadSeal) | Err(CookieError::Malformed)
281 ));
282 }
283
284 #[test]
285 fn test_expiry_enforced() {
286 let sealer = CookieSealer::new("k");
287 let cookie = sealer.seal(b"hello", Duration::from_secs(0));
288 std::thread::sleep(Duration::from_millis(1100));
290 assert_eq!(sealer.open(&cookie), Err(CookieError::Expired));
291 }
292
293 #[test]
294 fn test_malformed() {
295 let sealer = CookieSealer::new("k");
296 assert_eq!(sealer.open("not base64!!!"), Err(CookieError::Malformed));
297 assert_eq!(sealer.open("aGVsbG8"), Err(CookieError::Malformed)); }
299
300 #[test]
301 fn test_path_covers() {
302 assert!(path_covers("/", "/anything"));
303 assert!(path_covers("", "/anything"));
304 assert!(path_covers("/app_a", "/app_a"));
305 assert!(path_covers("/app_a", "/app_a/callback"));
306 assert!(path_covers("/app_a/", "/app_a/callback"));
307 assert!(!path_covers("/app_a", "/app_ab"));
309 assert!(!path_covers("/app_a", "/app_b/callback"));
310 }
311
312 #[test]
313 fn test_read_cookie() {
314 assert_eq!(read_cookie("a=1; session=xyz; b=2", "session"), Some("xyz"));
315 assert_eq!(read_cookie("a=1", "session"), None);
316 assert_eq!(read_cookie("session=abc", "session"), Some("abc"));
317 }
318
319 #[test]
320 fn test_build_set_cookie() {
321 let c = build_set_cookie(
322 "session",
323 "val",
324 &CookieAttrs {
325 path: "/app",
326 max_age: Some(3600),
327 http_only: true,
328 secure: true,
329 same_site: SameSite::Lax,
330 },
331 );
332 assert_eq!(
333 c,
334 "session=val; Path=/app; Max-Age=3600; HttpOnly; Secure; SameSite=Lax"
335 );
336 assert_eq!(
337 delete_cookie("session", "/"),
338 "session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
339 );
340 }
341}