1use std::sync::OnceLock;
13
14use argon2::{Algorithm, Argon2, Params, Version};
15use base64::Engine;
16use base64::engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD};
17use ring::rand::{SecureRandom, SystemRandom};
18
19use super::AuthError;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum TokenKind {
24 Session,
25 Api,
26 Superadmin,
28 Workspace,
30 Invitation,
31 PasswordReset,
32 Setup,
33}
34
35impl TokenKind {
36 pub fn prefix(self) -> &'static str {
37 match self {
38 TokenKind::Session => "isb_sess_",
39 TokenKind::Api => "isb_tok_",
40 TokenKind::Superadmin => "isb_sa_",
41 TokenKind::Workspace => "isb_ws_",
42 TokenKind::Invitation => "isb_inv_",
43 TokenKind::PasswordReset => "isb_rst_",
44 TokenKind::Setup => "isb_setup_",
45 }
46 }
47}
48
49pub fn random_bytes<const N: usize>() -> Result<[u8; N], AuthError> {
51 let mut b = [0u8; N];
52 SystemRandom::new()
53 .fill(&mut b)
54 .map_err(|_| AuthError::Internal("the OS random source failed".into()))?;
55 Ok(b)
56}
57
58pub fn new_token(kind: TokenKind) -> Result<(String, Vec<u8>), AuthError> {
60 let raw: [u8; 32] = random_bytes()?;
61 let token = format!("{}{}", kind.prefix(), URL_SAFE_NO_PAD.encode(raw));
62 let h = hash_token(&token);
63 Ok((token, h))
64}
65
66pub fn hash_token(token: &str) -> Vec<u8> {
68 ring::digest::digest(&ring::digest::SHA256, token.as_bytes())
69 .as_ref()
70 .to_vec()
71}
72
73pub fn well_formed(token: &str, kind: TokenKind) -> bool {
76 token.strip_prefix(kind.prefix()).is_some_and(|b| {
77 b.len() == 43
78 && b.bytes()
79 .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
80 })
81}
82
83pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
85 if a.len() != b.len() {
86 return false;
87 }
88 let mut d = 0u8;
89 for (x, y) in a.iter().zip(b) {
90 d |= x ^ y;
91 }
92 std::hint::black_box(d) == 0
94}
95
96pub const MIN_PASSWORD_LEN: usize = 12;
98pub const MAX_PASSWORD_LEN: usize = 1024;
101
102pub fn check_password_policy(pw: &str) -> Result<(), AuthError> {
105 password_policy(pw, super::dev::weak_passwords()?)
106}
107
108pub fn password_policy(pw: &str, weak: bool) -> Result<(), AuthError> {
111 if weak && pw.is_empty() {
112 return Err(AuthError::Invalid("password is empty".into()));
113 }
114 if !weak && pw.chars().count() < MIN_PASSWORD_LEN {
115 return Err(AuthError::Invalid(format!(
116 "password too short: at least {MIN_PASSWORD_LEN} characters"
117 )));
118 }
119 if pw.len() > MAX_PASSWORD_LEN {
120 return Err(AuthError::Invalid(format!(
121 "password too long: at most {MAX_PASSWORD_LEN} bytes"
122 )));
123 }
124 Ok(())
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct PasswordCost {
131 pub m_kib: u32,
132 pub t: u32,
133 pub p: u32,
134}
135
136impl Default for PasswordCost {
137 fn default() -> Self {
138 PasswordCost {
139 m_kib: Params::DEFAULT_M_COST,
140 t: Params::DEFAULT_T_COST,
141 p: Params::DEFAULT_P_COST,
142 }
143 }
144}
145
146impl PasswordCost {
147 pub fn insecure_fast() -> Self {
149 PasswordCost {
150 m_kib: 64,
151 t: 1,
152 p: 1,
153 }
154 }
155}
156
157fn argon(cost: PasswordCost) -> Result<Argon2<'static>, AuthError> {
158 let params = Params::new(cost.m_kib, cost.t, cost.p, Some(32))
159 .map_err(|e| AuthError::Internal(format!("argon2 parameters: {e}")))?;
160 Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
161}
162
163pub fn hash_password(pw: &str, cost: PasswordCost) -> Result<String, AuthError> {
167 let salt: [u8; 16] = random_bytes()?;
168 let mut out = [0u8; 32];
169 argon(cost)?
170 .hash_password_into(pw.as_bytes(), &salt, &mut out)
171 .map_err(|e| AuthError::Internal(format!("argon2: {e}")))?;
172 Ok(format!(
173 "$argon2id$v=19$m={},t={},p={}${}${}",
174 cost.m_kib,
175 cost.t,
176 cost.p,
177 STANDARD_NO_PAD.encode(salt),
178 STANDARD_NO_PAD.encode(out)
179 ))
180}
181
182pub fn verify_password(pw: &str, phc: &str) -> bool {
185 let Some((cost, salt, want)) = parse_phc(phc) else {
186 return false;
187 };
188 let Ok(a) = argon(cost) else { return false };
189 let mut got = vec![0u8; want.len()];
190 a.hash_password_into(pw.as_bytes(), &salt, &mut got).is_ok() && ct_eq(&got, &want)
191}
192
193fn parse_phc(phc: &str) -> Option<(PasswordCost, Vec<u8>, Vec<u8>)> {
194 let mut parts = phc.split('$');
195 if !parts.next()?.is_empty() || parts.next()? != "argon2id" || parts.next()? != "v=19" {
196 return None;
197 }
198 let mut cost = PasswordCost {
199 m_kib: 0,
200 t: 0,
201 p: 0,
202 };
203 for kv in parts.next()?.split(',') {
204 let (k, v) = kv.split_once('=')?;
205 let v: u32 = v.parse().ok()?;
206 match k {
207 "m" => cost.m_kib = v,
208 "t" => cost.t = v,
209 "p" => cost.p = v,
210 _ => return None,
211 }
212 }
213 let salt = STANDARD_NO_PAD.decode(parts.next()?).ok()?;
214 let hash = STANDARD_NO_PAD.decode(parts.next()?).ok()?;
215 if parts.next().is_some() || hash.len() < 16 {
216 return None;
217 }
218 Some((cost, salt, hash))
219}
220
221pub fn dummy_hash(cell: &OnceLock<String>, cost: PasswordCost) -> &str {
225 cell.get_or_init(|| {
226 let pw = URL_SAFE_NO_PAD.encode(random_bytes::<18>().unwrap_or([7; 18]));
227 hash_password(&pw, cost).unwrap_or_default()
228 })
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn tokens() {
237 let (t, h) = new_token(TokenKind::Session).unwrap();
238 assert!(t.starts_with("isb_sess_"));
239 assert!(well_formed(&t, TokenKind::Session));
240 assert!(!well_formed(&t, TokenKind::Api));
241 assert_eq!(h, hash_token(&t));
242 assert_eq!(h.len(), 32);
243 let (t2, _) = new_token(TokenKind::Session).unwrap();
244 assert_ne!(t, t2);
245 assert!(!well_formed("isb_sess_short", TokenKind::Session));
246 assert!(!well_formed(
247 &format!("isb_tok_{}", "!".repeat(43)),
248 TokenKind::Api
249 ));
250 }
251
252 #[test]
253 fn constant_time_compare() {
254 assert!(ct_eq(b"abc", b"abc"));
255 assert!(!ct_eq(b"abc", b"abd"));
256 assert!(!ct_eq(b"abc", b"ab"));
257 assert!(ct_eq(b"", b""));
258 let a = hash_token("x");
259 let mut b = a.clone();
260 assert!(ct_eq(&a, &b));
261 b[31] ^= 1;
262 assert!(!ct_eq(&a, &b));
263 b[31] ^= 1;
264 b[0] ^= 0x80;
265 assert!(!ct_eq(&a, &b));
266 }
267
268 #[test]
269 fn passwords() {
270 let c = PasswordCost::insecure_fast();
271 let h = hash_password("correct horse battery", c).unwrap();
272 assert!(h.starts_with("$argon2id$v=19$m=64,t=1,p=1$"));
273 assert!(verify_password("correct horse battery", &h));
274 assert!(!verify_password("correct horse batterz", &h));
275 assert!(!verify_password("correct horse battery", "garbage"));
276 assert!(!verify_password("x", ""));
277 assert_ne!(h, hash_password("correct horse battery", c).unwrap());
279 let cell = OnceLock::new();
280 let d = dummy_hash(&cell, c).to_string();
281 assert!(!verify_password("a", &d));
282 assert_eq!(dummy_hash(&cell, c), d);
283 }
284
285 #[test]
286 fn default_cost_is_owasp() {
287 let h = hash_password("correct horse battery", PasswordCost::default()).unwrap();
288 assert!(h.starts_with("$argon2id$v=19$m=19456,t=2,p=1$"));
289 assert!(verify_password("correct horse battery", &h));
290 }
291
292 #[test]
293 fn policy() {
294 assert!(check_password_policy("short").is_err());
295 assert!(check_password_policy("exactly12chr").is_ok());
296 assert!(check_password_policy(&"x".repeat(2000)).is_err());
297 assert!(password_policy("password", true).is_ok());
299 assert!(password_policy("password", false).is_err());
300 assert!(password_policy("", true).is_err());
301 assert!(password_policy(&"x".repeat(2000), true).is_err());
302 }
303}