Skip to main content

ullun/
signed_cookies.rs

1//! Tamper-proof cookies (feature `signing`)
2//!
3//! A signed cookie is readable by the browser but cannot be changed without the
4//! server noticing: the value is followed by an HMAC-SHA256 of the cookie name
5//! and value. Use it for small, non-secret state you want to keep out of a
6//! database (a theme, a feature-flag bucket, a "remember me" identifier that is
7//! checked again server-side). The content is **not** encrypted, never put
8//! secrets in it.
9//!
10//! ```
11//! use ullun::signed_cookies::CookieSigner;
12//!
13//! let signer = CookieSigner::new(b"a secret key of at least 32 bytes!!").unwrap();
14//! let signed = signer.sign("theme", "dark");
15//! assert_eq!(signer.verify("theme", &signed).as_deref(), Some("dark"));
16//!
17//! // tampering, or moving the value to another cookie, is detected
18//! assert_eq!(signer.verify("theme", &signed.replace("dark", "evil")), None);
19//! assert_eq!(signer.verify("role", &signed), None);
20//! ```
21//!
22//! In handlers use [`Response::signed_cookie`] and [`Request::signed_cookie`].
23//! To rotate the key, install the new one and keep the old one for verification
24//! with [`CookieSigner::previous_key`]: old cookies still verify, new ones are
25//! signed with the new key.
26
27use crate::cookies::Cookie;
28use crate::error::{Error, Result};
29use crate::request::Request;
30use crate::response::Response;
31use base64::engine::general_purpose::URL_SAFE_NO_PAD;
32use base64::Engine;
33use hmac::{Hmac, KeyInit, Mac};
34use sha2::Sha256;
35
36type HmacSha256 = Hmac<Sha256>;
37
38/// Signs and verifies cookie values
39#[derive(Clone)]
40pub struct CookieSigner {
41    key: Vec<u8>,
42    previous: Vec<Vec<u8>>,
43}
44
45impl std::fmt::Debug for CookieSigner {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("CookieSigner").finish_non_exhaustive()
48    }
49}
50
51impl CookieSigner {
52    /// `key` must be at least 32 bytes of randomness (see
53    /// [`random_bytes`](crate::security::random_bytes))
54    pub fn new(key: &[u8]) -> Result<Self> {
55        if key.len() < 32 {
56            return Err(Error::internal(
57                "Cookie signing key must be at least 32 bytes",
58            ));
59        }
60        Ok(Self {
61            key: key.to_vec(),
62            previous: Vec::new(),
63        })
64    }
65
66    /// An older key that still verifies cookies (key rotation)
67    pub fn previous_key(mut self, key: &[u8]) -> Result<Self> {
68        if key.len() < 32 {
69            return Err(Error::internal(
70                "Cookie signing key must be at least 32 bytes",
71            ));
72        }
73        self.previous.push(key.to_vec());
74        Ok(self)
75    }
76
77    fn mac(key: &[u8], name: &str, value: &str) -> HmacSha256 {
78        // HMAC accepts keys of any length
79        let mut m = HmacSha256::new_from_slice(key).expect("HMAC takes any key length");
80        m.update(name.as_bytes());
81        m.update(&[0]); // name and value cannot run into each other
82        m.update(value.as_bytes());
83        m
84    }
85
86    /// `value.signature`, safe to use as a cookie value
87    pub fn sign(&self, name: &str, value: &str) -> String {
88        let tag = Self::mac(&self.key, name, value).finalize().into_bytes();
89        format!("{}.{}", value, URL_SAFE_NO_PAD.encode(tag))
90    }
91
92    /// The original value if `signed` carries a valid signature for cookie `name`
93    pub fn verify(&self, name: &str, signed: &str) -> Option<String> {
94        let (value, tag) = signed.rsplit_once('.')?;
95        let tag = URL_SAFE_NO_PAD.decode(tag).ok()?;
96        std::iter::once(&self.key)
97            .chain(&self.previous)
98            .any(|key| Self::mac(key, name, value).verify_slice(&tag).is_ok())
99            .then(|| value.to_string())
100    }
101}
102
103impl Response {
104    /// Set a cookie whose value is signed with `signer`
105    pub fn signed_cookie(self, signer: &CookieSigner, cookie: Cookie) -> Self {
106        let signed = signer.sign(cookie.name(), cookie.value());
107        self.cookie(cookie.with_value(signed))
108    }
109}
110
111impl Request {
112    /// The value of a cookie set with [`Response::signed_cookie`], `None` when
113    /// it is absent or its signature does not verify
114    pub fn signed_cookie(&self, signer: &CookieSigner, name: &str) -> Option<String> {
115        signer.verify(name, self.cookies().get(name)?)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    const KEY: &[u8] = b"0123456789abcdef0123456789abcdef";
124
125    #[test]
126    fn roundtrip_and_tampering() {
127        let s = CookieSigner::new(KEY).unwrap();
128        let signed = s.sign("c", "v.with.dots");
129        assert_eq!(s.verify("c", &signed).as_deref(), Some("v.with.dots"));
130        assert_eq!(s.verify("c", &signed.replace('v', "w")), None);
131        assert_eq!(s.verify("other", &signed), None);
132        assert_eq!(s.verify("c", "nosignature"), None);
133        assert_eq!(s.verify("c", "value.!!!"), None);
134        assert_eq!(s.verify("c", ""), None);
135        // a different key does not verify
136        let other = CookieSigner::new(b"ffffffffffffffffffffffffffffffff").unwrap();
137        assert_eq!(other.verify("c", &signed), None);
138    }
139
140    #[test]
141    fn name_and_value_boundaries_are_unambiguous() {
142        let s = CookieSigner::new(KEY).unwrap();
143        // ("ab", "c") must not verify as ("a", "bc")
144        let signed = s.sign("ab", "c");
145        let tag = signed.rsplit_once('.').unwrap().1;
146        assert_eq!(s.verify("a", &format!("bc.{tag}")), None);
147    }
148
149    #[test]
150    fn short_keys_are_refused() {
151        assert!(CookieSigner::new(b"short").is_err());
152        assert!(CookieSigner::new(KEY)
153            .unwrap()
154            .previous_key(b"short")
155            .is_err());
156    }
157
158    #[test]
159    fn rotation_verifies_old_cookies_and_signs_with_the_new_key() {
160        let old = CookieSigner::new(KEY).unwrap();
161        let old_cookie = old.sign("c", "v");
162        let new_key = b"ffffffffffffffffffffffffffffffff";
163        let rotated = CookieSigner::new(new_key)
164            .unwrap()
165            .previous_key(KEY)
166            .unwrap();
167        assert_eq!(rotated.verify("c", &old_cookie).as_deref(), Some("v"));
168        let fresh = rotated.sign("c", "v");
169        assert_ne!(fresh, old_cookie);
170        // once the old key is dropped, old cookies stop verifying
171        let only_new = CookieSigner::new(new_key).unwrap();
172        assert_eq!(only_new.verify("c", &old_cookie), None);
173        assert_eq!(only_new.verify("c", &fresh).as_deref(), Some("v"));
174    }
175}