Skip to main content

ullun/
private_cookies.rs

1//! Encrypted cookies (feature `encryption`)
2//!
3//! A private cookie is unreadable and tamper-proof: the value is encrypted and
4//! authenticated with XChaCha20-Poly1305, and the cookie name is bound to the
5//! ciphertext so a value cannot be moved to another cookie. Use it for state
6//! the browser must not see (a user id, a flash message). Prefer a server-side
7//! [`session`](crate::session) for anything large: cookies are limited to about
8//! 4 KB.
9//!
10//! ```
11//! use ullun::private_cookies::CookieCipher;
12//!
13//! let cipher = CookieCipher::new(b"a secret key of exactly 32 bytes").unwrap();
14//! let sealed = cipher.seal("flash", "saved!").unwrap();
15//! assert!(!sealed.contains("saved"));
16//! assert_eq!(cipher.open("flash", &sealed).as_deref(), Some("saved!"));
17//!
18//! // another cookie name, or a changed value, does not open
19//! assert_eq!(cipher.open("role", &sealed), None);
20//! ```
21//!
22//! In handlers use [`Response::private_cookie`] and [`Request::private_cookie`].
23//! To rotate the key, install the new one and keep the old one for opening with
24//! [`CookieCipher::previous_key`].
25
26use 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/// Encrypts and decrypts cookie values
50#[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    /// `key` must be exactly 32 bytes of randomness (see
64    /// [`random_bytes`])
65    pub fn new(key: &[u8]) -> Result<Self> {
66        Ok(Self {
67            key: cipher_for(key)?,
68            previous: Vec::new(),
69        })
70    }
71
72    /// An older key that still opens cookies (key rotation)
73    pub fn previous_key(mut self, key: &[u8]) -> Result<Self> {
74        self.previous.push(cipher_for(key)?);
75        Ok(self)
76    }
77
78    /// Encrypt `value` for cookie `name`; the result is safe to use as a cookie value
79    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    /// The original value if `sealed` was produced by [`seal`](Self::seal) for
99    /// cookie `name` and was not modified
100    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    /// Set a cookie whose value is encrypted with `cipher`
125    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    /// The value of a cookie set with [`Response::private_cookie`], `None` when
133    /// it is absent or does not decrypt
134    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}