1use 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
17pub const TOKEN_LEN: usize = 43;
19
20pub struct SessionToken(SecretString);
23
24impl SessionToken {
25 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 pub fn expose(&self) -> &str {
40 self.0.expose_secret()
41 }
42
43 pub fn hash(&self) -> TokenHash {
45 TokenHash::of(self.expose())
46 }
47}
48
49impl fmt::Debug for SessionToken {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 f.write_str("SessionToken(<redacted>)")
52 }
53}
54
55#[derive(Clone, Copy, Eq, Hash)]
57#[allow(
58 clippy::derived_hash_with_manual_eq,
59 reason = "constant-time eq agrees with byte-wise Hash"
60)]
61pub struct TokenHash([u8; 32]);
62
63impl TokenHash {
64 pub fn of(token: &str) -> Self {
66 Self(Sha256::digest(token.as_bytes()).into())
67 }
68
69 pub fn from_presented(presented: &str) -> Option<Self> {
72 is_well_formed(presented).then(|| Self::of(presented))
73 }
74
75 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
77 Self(bytes)
78 }
79
80 pub const fn as_bytes(&self) -> &[u8; 32] {
82 &self.0
83 }
84
85 pub fn to_hex(&self) -> String {
87 use fmt::Write as _;
88 self.0.iter().fold(String::with_capacity(64), |mut out, byte| {
89 let _ = write!(out, "{byte:02x}");
90 out
91 })
92 }
93
94 pub fn from_hex(hex: &str) -> Option<Self> {
96 if hex.len() != 64 || !hex.is_ascii() {
97 return None;
98 }
99 let mut bytes = [0_u8; 32];
100 for (slot, pair) in bytes.iter_mut().zip(hex.as_bytes().as_chunks::<2>().0) {
101 let pair = std::str::from_utf8(pair).ok()?;
102 *slot = u8::from_str_radix(pair, 16).ok()?;
103 }
104 Some(Self(bytes))
105 }
106}
107
108impl PartialEq for TokenHash {
109 fn eq(&self, other: &Self) -> bool {
110 self.0.ct_eq(&other.0).into()
111 }
112}
113
114impl fmt::Debug for TokenHash {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 write!(f, "TokenHash({}…)", &self.to_hex()[..8])
117 }
118}
119
120pub fn is_well_formed(value: &str) -> bool {
122 value.len() == TOKEN_LEN
123 && value
124 .bytes()
125 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
126}
127
128#[cfg(test)]
129mod tests {
130 use proptest::prelude::*;
131
132 use super::*;
133
134 #[test]
135 fn generated_tokens_are_43_base64url_chars_and_unique() {
136 let a = SessionToken::generate().unwrap();
137 let b = SessionToken::generate().unwrap();
138 assert_eq!(a.expose().len(), TOKEN_LEN);
139 assert!(is_well_formed(a.expose()));
140 assert_ne!(a.expose(), b.expose());
141 assert_eq!(URL_SAFE_NO_PAD.decode(a.expose()).unwrap().len(), 32);
142 }
143
144 #[test]
145 fn debug_output_never_contains_the_token() {
146 let token = SessionToken::generate().unwrap();
147 assert!(!format!("{token:?}").contains(token.expose()));
148 assert!(!format!("{:?}", token.hash()).contains(&token.hash().to_hex()));
149 }
150
151 #[test]
152 fn presented_tokens_are_validated_before_hashing() {
153 let token = SessionToken::generate().unwrap();
154 assert_eq!(TokenHash::from_presented(token.expose()), Some(token.hash()));
155 assert_eq!(TokenHash::from_presented(""), None);
156 assert_eq!(TokenHash::from_presented(&"a".repeat(42)), None);
157 assert_eq!(TokenHash::from_presented(&"a".repeat(44)), None);
158 assert_eq!(TokenHash::from_presented(&format!("{}=", "a".repeat(42))), None);
159 assert_eq!(TokenHash::from_presented(&format!("{}é", "a".repeat(41))), None);
160 }
161
162 #[test]
163 fn hash_matches_sha256() {
164 assert_eq!(
166 TokenHash::of("abc").to_hex(),
167 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
168 );
169 }
170
171 proptest! {
172 #[test]
173 fn hex_round_trips(bytes in any::<[u8; 32]>()) {
174 let hash = TokenHash::from_bytes(bytes);
175 prop_assert_eq!(TokenHash::from_hex(&hash.to_hex()), Some(hash));
176 }
177
178 #[test]
179 fn from_hex_never_panics(s in ".{0,80}") {
180 let _ = TokenHash::from_hex(&s);
181 }
182
183 #[test]
184 fn from_presented_accepts_only_well_formed(s in "[A-Za-z0-9_=+/ -]{40,46}") {
185 prop_assert_eq!(TokenHash::from_presented(&s).is_some(), is_well_formed(&s));
186 }
187 }
188}