Skip to main content

isb_server/auth/
secret.rs

1//! Secrets: bearer tokens, their hashes, constant-time comparison, and
2//! argon2id password hashes.
3//!
4//! A token is 32 bytes from the OS (ring's `SystemRandom`, i.e. getrandom),
5//! base64url-encoded behind a prefix that says what it is (`isb_sess_`,
6//! `isb_tok_`, `isb_sa_`, `isb_ws_`, `isb_inv_`, `isb_rst_`, `isb_setup_`). It is shown once; the
7//! store keeps only its SHA-256. Looking a row up by that hash through an
8//! index leaks nothing useful (the input is 256 bits of randomness, so timing
9//! on the hash says nothing about any other token), and the row found is still
10//! compared with [`ct_eq`] before it is trusted.
11
12use 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/// What a token is for, and the prefix that marks it.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum TokenKind {
24    Session,
25    Api,
26    /// A superadmin token: the unix socket's reach over HTTP.
27    Superadmin,
28    /// An org's workspace token (docs/concepts/workspaces.md).
29    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
49/// `n` bytes from the OS.
50pub 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
58/// A new token of `kind`: the string to hand out once, and the hash to store.
59pub 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
66/// The stored form of a token: SHA-256 of the whole string, prefix included.
67pub 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
73/// True when `token` has `kind`'s prefix and a well-formed body (43
74/// base64url characters). Anything else is refused before touching the store.
75pub 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
83/// Compare two byte strings in time that depends only on their lengths.
84pub 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    // Keep the optimiser from turning the fold into an early exit.
93    std::hint::black_box(d) == 0
94}
95
96/// Shortest password accepted.
97pub const MIN_PASSWORD_LEN: usize = 12;
98/// Longest password accepted: argon2 takes anything, but a megabyte of
99/// "password" is a denial of service, not a secret.
100pub const MAX_PASSWORD_LEN: usize = 1024;
101
102/// [`password_policy`], with no minimum length under
103/// `ISB_DEV_WEAK_PASSWORDS` ([`super::dev`]).
104pub fn check_password_policy(pw: &str) -> Result<(), AuthError> {
105    password_policy(pw, super::dev::weak_passwords()?)
106}
107
108/// At least [`MIN_PASSWORD_LEN`] characters (`weak`: at least one), at most
109/// [`MAX_PASSWORD_LEN`] bytes.
110pub 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/// argon2id cost. The default is OWASP's recommendation (19 MiB, 2 passes,
128/// 1 lane), about 30 ms on a server core; tests use a cheap one.
129#[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    /// For tests only: fast, and useless against an offline attacker.
148    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
163/// Hash a password into a PHC string:
164/// `$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>` (standard base64, no
165/// padding), the format every argon2 implementation reads.
166pub 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
182/// Check a password against a PHC string written by [`hash_password`], with
183/// the cost recorded in it. A malformed hash never verifies.
184pub 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
221/// A hash of a random password at `cost`, verified against when the email is
222/// unknown, so a miss costs as much as a wrong password and timing does not
223/// say which half was wrong. Computed once per `cell`.
224pub 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        // Salted: the same password hashes differently.
278        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        // ISB_DEV_WEAK_PASSWORDS: any length, but never empty or huge.
298        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}