Skip to main content

session_guard/
token.rs

1//! Opaque session tokens and their stored hashes.
2//!
3//! A token is 32 bytes from the OS RNG, encoded as 43 characters of unpadded base64url. Nothing
4//! else is encoded in it. Only the SHA-256 of the token is ever stored, so a database leak does
5//! not leak usable cookies.
6
7use std::fmt;
8
9use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
10use secrecy::{ExposeSecret, SecretString};
11use sha2::{Digest, Sha256};
12use subtle::ConstantTimeEq;
13use zeroize::Zeroize;
14
15use crate::error::GuardError;
16
17/// Length of an encoded token in characters.
18pub const TOKEN_LEN: usize = 43;
19
20/// A freshly issued session token. The value is wiped from memory on drop and redacted from
21/// `Debug` output.
22pub struct SessionToken(SecretString);
23
24impl SessionToken {
25    /// Generates a new token from 32 bytes of OS randomness.
26    ///
27    /// # Errors
28    ///
29    /// [`GuardError::Random`] if the operating system RNG is unavailable.
30    pub fn generate() -> Result<Self, GuardError> {
31        let mut bytes = [0_u8; 32];
32        getrandom::fill(&mut bytes).map_err(|_| GuardError::Random)?;
33        let encoded = URL_SAFE_NO_PAD.encode(bytes);
34        bytes.zeroize();
35        Ok(Self(SecretString::from(encoded)))
36    }
37
38    /// Generates a token whose hash falls in `bucket` (its first byte), for stores that
39    /// [shard](crate::store::SessionStore::sharded) by bucket. Takes 256 draws on average.
40    ///
41    /// # Errors
42    ///
43    /// [`GuardError::Random`] if the operating system RNG is unavailable.
44    pub(crate) fn generate_in_bucket(bucket: u8) -> Result<Self, GuardError> {
45        loop {
46            let token = Self::generate()?;
47            if token.hash().as_bytes()[0] == bucket {
48                return Ok(token);
49            }
50        }
51    }
52
53    /// The cookie value. Avoid logging it.
54    pub fn expose(&self) -> &str {
55        self.0.expose_secret()
56    }
57
58    /// The hash under which this token is stored.
59    pub fn hash(&self) -> TokenHash {
60        TokenHash::of(self.expose())
61    }
62}
63
64impl fmt::Debug for SessionToken {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str("SessionToken(<redacted>)")
67    }
68}
69
70/// SHA-256 of a token. Comparison is constant-time.
71#[derive(Clone, Copy, Eq, Hash)]
72#[allow(
73    clippy::derived_hash_with_manual_eq,
74    reason = "constant-time eq agrees with byte-wise Hash"
75)]
76pub struct TokenHash([u8; 32]);
77
78impl TokenHash {
79    /// Hashes a token string without validating it.
80    pub fn of(token: &str) -> Self {
81        Self(Sha256::digest(token.as_bytes()).into())
82    }
83
84    /// Hashes a token presented by a client, or `None` if it cannot be a token we issued
85    /// (wrong length or alphabet). Avoids a store lookup for garbage cookies.
86    pub fn from_presented(presented: &str) -> Option<Self> {
87        is_well_formed(presented).then(|| Self::of(presented))
88    }
89
90    /// Wraps raw hash bytes read from a store.
91    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
92        Self(bytes)
93    }
94
95    /// Raw hash bytes, for stores.
96    pub const fn as_bytes(&self) -> &[u8; 32] {
97        &self.0
98    }
99
100    /// Lowercase hex, for stores that key by string.
101    pub fn to_hex(&self) -> String {
102        use fmt::Write as _;
103        self.0.iter().fold(String::with_capacity(64), |mut out, byte| {
104            let _ = write!(out, "{byte:02x}");
105            out
106        })
107    }
108
109    /// Parses [`TokenHash::to_hex`] output.
110    pub fn from_hex(hex: &str) -> Option<Self> {
111        if hex.len() != 64 || !hex.is_ascii() {
112            return None;
113        }
114        let mut bytes = [0_u8; 32];
115        for (slot, pair) in bytes.iter_mut().zip(hex.as_bytes().as_chunks::<2>().0) {
116            let pair = std::str::from_utf8(pair).ok()?;
117            *slot = u8::from_str_radix(pair, 16).ok()?;
118        }
119        Some(Self(bytes))
120    }
121}
122
123impl PartialEq for TokenHash {
124    fn eq(&self, other: &Self) -> bool {
125        self.0.ct_eq(&other.0).into()
126    }
127}
128
129impl fmt::Debug for TokenHash {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(f, "TokenHash({}…)", &self.to_hex()[..8])
132    }
133}
134
135/// Whether `value` has the shape of a token we issue: exactly 43 base64url characters.
136pub fn is_well_formed(value: &str) -> bool {
137    value.len() == TOKEN_LEN
138        && value
139            .bytes()
140            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
141}
142
143#[cfg(test)]
144mod tests {
145    use proptest::prelude::*;
146
147    use super::*;
148
149    #[test]
150    fn generated_tokens_are_43_base64url_chars_and_unique() {
151        let a = SessionToken::generate().unwrap();
152        let b = SessionToken::generate().unwrap();
153        assert_eq!(a.expose().len(), TOKEN_LEN);
154        assert!(is_well_formed(a.expose()));
155        assert_ne!(a.expose(), b.expose());
156        assert_eq!(URL_SAFE_NO_PAD.decode(a.expose()).unwrap().len(), 32);
157    }
158
159    #[test]
160    fn generate_in_bucket_lands_the_hash_in_the_bucket() {
161        for bucket in [0_u8, 1, 0x7f, 0xff] {
162            let token = SessionToken::generate_in_bucket(bucket).unwrap();
163            assert_eq!(token.hash().as_bytes()[0], bucket);
164            assert!(is_well_formed(token.expose()));
165        }
166    }
167
168    #[test]
169    fn debug_output_never_contains_the_token() {
170        let token = SessionToken::generate().unwrap();
171        assert!(!format!("{token:?}").contains(token.expose()));
172        assert!(!format!("{:?}", token.hash()).contains(&token.hash().to_hex()));
173    }
174
175    #[test]
176    fn presented_tokens_are_validated_before_hashing() {
177        let token = SessionToken::generate().unwrap();
178        assert_eq!(TokenHash::from_presented(token.expose()), Some(token.hash()));
179        assert_eq!(TokenHash::from_presented(""), None);
180        assert_eq!(TokenHash::from_presented(&"a".repeat(42)), None);
181        assert_eq!(TokenHash::from_presented(&"a".repeat(44)), None);
182        assert_eq!(TokenHash::from_presented(&format!("{}=", "a".repeat(42))), None);
183        assert_eq!(TokenHash::from_presented(&format!("{}é", "a".repeat(41))), None);
184    }
185
186    #[test]
187    fn hash_matches_sha256() {
188        // echo -n abc | sha256sum
189        assert_eq!(
190            TokenHash::of("abc").to_hex(),
191            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
192        );
193    }
194
195    proptest! {
196        #[test]
197        fn hex_round_trips(bytes in any::<[u8; 32]>()) {
198            let hash = TokenHash::from_bytes(bytes);
199            prop_assert_eq!(TokenHash::from_hex(&hash.to_hex()), Some(hash));
200        }
201
202        #[test]
203        fn from_hex_never_panics(s in ".{0,80}") {
204            let _ = TokenHash::from_hex(&s);
205        }
206
207        #[test]
208        fn from_presented_accepts_only_well_formed(s in "[A-Za-z0-9_=+/ -]{40,46}") {
209            prop_assert_eq!(TokenHash::from_presented(&s).is_some(), is_well_formed(&s));
210        }
211    }
212}