1use crate::cookies::Cookie;
27use crate::error::{Error, Result};
28use crate::request::Request;
29use crate::response::Response;
30use crate::security::random_bytes;
31use base64::engine::general_purpose::URL_SAFE_NO_PAD;
32use base64::Engine;
33use chacha20poly1305::aead::{Aead, Payload};
34use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
35
36const KEY_LEN: usize = 32;
37const NONCE_LEN: usize = 24;
38
39fn cipher_for(key: &[u8]) -> Result<XChaCha20Poly1305> {
40 if key.len() != KEY_LEN {
41 return Err(Error::internal(
42 "Cookie encryption key must be exactly 32 bytes",
43 ));
44 }
45 XChaCha20Poly1305::new_from_slice(key)
46 .map_err(|_| Error::internal("Cookie encryption key must be exactly 32 bytes"))
47}
48
49#[derive(Clone)]
51pub struct CookieCipher {
52 key: XChaCha20Poly1305,
53 previous: Vec<XChaCha20Poly1305>,
54}
55
56impl std::fmt::Debug for CookieCipher {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.debug_struct("CookieCipher").finish_non_exhaustive()
59 }
60}
61
62impl CookieCipher {
63 pub fn new(key: &[u8]) -> Result<Self> {
66 Ok(Self {
67 key: cipher_for(key)?,
68 previous: Vec::new(),
69 })
70 }
71
72 pub fn previous_key(mut self, key: &[u8]) -> Result<Self> {
74 self.previous.push(cipher_for(key)?);
75 Ok(self)
76 }
77
78 pub fn seal(&self, name: &str, value: &str) -> Result<String> {
80 let nonce_bytes = random_bytes(NONCE_LEN)?;
81 let nonce = XNonce::try_from(nonce_bytes.as_slice())
82 .map_err(|_| Error::internal("Invalid cookie nonce"))?;
83 let ciphertext = self
84 .key
85 .encrypt(
86 &nonce,
87 Payload {
88 msg: value.as_bytes(),
89 aad: name.as_bytes(),
90 },
91 )
92 .map_err(|_| Error::internal("Cookie encryption failed"))?;
93 let mut out = nonce_bytes;
94 out.extend_from_slice(&ciphertext);
95 Ok(URL_SAFE_NO_PAD.encode(out))
96 }
97
98 pub fn open(&self, name: &str, sealed: &str) -> Option<String> {
101 let raw = URL_SAFE_NO_PAD.decode(sealed).ok()?;
102 if raw.len() < NONCE_LEN {
103 return None;
104 }
105 let (nonce, ciphertext) = raw.split_at(NONCE_LEN);
106 let nonce = XNonce::try_from(nonce).ok()?;
107 std::iter::once(&self.key)
108 .chain(&self.previous)
109 .find_map(|key| {
110 key.decrypt(
111 &nonce,
112 Payload {
113 msg: ciphertext,
114 aad: name.as_bytes(),
115 },
116 )
117 .ok()
118 })
119 .and_then(|plain| String::from_utf8(plain).ok())
120 }
121}
122
123impl Response {
124 pub fn private_cookie(self, cipher: &CookieCipher, cookie: Cookie) -> Result<Self> {
126 let sealed = cipher.seal(cookie.name(), cookie.value())?;
127 Ok(self.cookie(cookie.with_value(sealed)))
128 }
129}
130
131impl Request {
132 pub fn private_cookie(&self, cipher: &CookieCipher, name: &str) -> Option<String> {
135 cipher.open(name, self.cookies().get(name)?)
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 const KEY: &[u8] = b"0123456789abcdef0123456789abcdef";
144 const OTHER: &[u8] = b"ffffffffffffffffffffffffffffffff";
145
146 #[test]
147 fn roundtrip_hides_the_value() {
148 let c = CookieCipher::new(KEY).unwrap();
149 let sealed = c.seal("c", "secret value").unwrap();
150 assert!(!sealed.contains("secret"));
151 assert_eq!(c.open("c", &sealed).as_deref(), Some("secret value"));
152 assert_eq!(c.open("c", &c.seal("c", "").unwrap()).as_deref(), Some(""));
153 }
154
155 #[test]
156 fn sealing_twice_gives_different_ciphertexts() {
157 let c = CookieCipher::new(KEY).unwrap();
158 assert_ne!(c.seal("c", "v").unwrap(), c.seal("c", "v").unwrap());
159 }
160
161 #[test]
162 fn tampering_and_wrong_name_or_key_are_rejected() {
163 let c = CookieCipher::new(KEY).unwrap();
164 let sealed = c.seal("c", "v").unwrap();
165 assert_eq!(c.open("other", &sealed), None);
166 assert_eq!(CookieCipher::new(OTHER).unwrap().open("c", &sealed), None);
167 let mut raw = URL_SAFE_NO_PAD.decode(&sealed).unwrap();
168 *raw.last_mut().unwrap() ^= 1;
169 assert_eq!(c.open("c", &URL_SAFE_NO_PAD.encode(raw)), None);
170 assert_eq!(c.open("c", "!!!"), None);
171 assert_eq!(c.open("c", "short"), None);
172 assert_eq!(c.open("c", ""), None);
173 }
174
175 #[test]
176 fn keys_must_be_32_bytes() {
177 assert!(CookieCipher::new(b"short").is_err());
178 assert!(CookieCipher::new(&[0u8; 33]).is_err());
179 assert!(CookieCipher::new(KEY).unwrap().previous_key(b"x").is_err());
180 }
181
182 #[test]
183 fn rotation_opens_old_cookies_and_seals_with_the_new_key() {
184 let old = CookieCipher::new(KEY).unwrap().seal("c", "v").unwrap();
185 let rotated = CookieCipher::new(OTHER).unwrap().previous_key(KEY).unwrap();
186 assert_eq!(rotated.open("c", &old).as_deref(), Some("v"));
187 let fresh = rotated.seal("c", "v").unwrap();
188 let only_new = CookieCipher::new(OTHER).unwrap();
189 assert_eq!(only_new.open("c", &old), None);
190 assert_eq!(only_new.open("c", &fresh).as_deref(), Some("v"));
191 }
192}