ocre/password.rs
1//! Password hashing (PBKDF2-HMAC-SHA256).
2//!
3//! Like Rails' `has_secure_password`, without bcrypt: Workers have no CPU
4//! budget for bcrypt or argon2 in WebAssembly (10 ms per request on the free
5//! plan), but WebCrypto (`crypto.subtle.deriveBits`) runs PBKDF2 natively,
6//! outside the WebAssembly module. Workers cap it at 100,000 iterations;
7//! Ocre uses the cap ([`ITERATIONS`]). Native builds (tests, tools) compute
8//! the same function in pure Rust.
9//!
10//! Digests are self-describing, like Django's:
11//! `pbkdf2_sha256$100000$<salt, base64>$<hash, base64>` (16-byte random salt,
12//! 32-byte hash, standard base64 without padding). The iteration count is
13//! stored, so it can grow later without invalidating existing passwords
14//! ([`iterations`] reads it back).
15//!
16//! Cost: one hash measured at 5.5 ms of CPU in `wrangler dev` (see the
17//! [cost model](https://ocre.rs/explanations/cost-model)), about half of the free plan's 10 ms per request, so
18//! only sign-up, login and password changes should hash. [`hash`] and
19//! [`verify`] each run PBKDF2 once. Passwords are never logged: errors name
20//! the operation only.
21//!
22//! ```rust,no_run
23//! use axum::{Form, extract::State};
24//! use ocre::{Ctx, Error, Result};
25//! # #[derive(serde::Deserialize)]
26//! # struct Login { email: String, password: String }
27//! # struct User { password_digest: String }
28//! # async fn find_by_email(_ctx: &Ctx, _email: &str) -> Result<Option<User>> { Ok(None) }
29//!
30//! // Sign up: store the digest, never the password.
31//! async fn sign_up(Form(form): Form<Login>) -> Result<String> {
32//! let password_digest = ocre::password::hash(&form.password).await?;
33//! Ok(password_digest) // INSERT INTO users (email, password_digest) ...
34//! }
35//!
36//! // Log in: compare in constant time.
37//! async fn log_in(State(ctx): State<Ctx>, Form(form): Form<Login>) -> Result<&'static str> {
38//! let user = find_by_email(&ctx, &form.email).await?.ok_or(Error::Unauthorized)?;
39//! if ocre::password::verify(&form.password, &user.password_digest).await? {
40//! Ok("signed in")
41//! } else {
42//! Err(Error::Unauthorized)
43//! }
44//! }
45//! ```
46
47use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
48
49use crate::{Error, Result, token::constant_time_eq};
50
51/// Iterations for new digests: 100,000, the most Workers' WebCrypto accepts.
52///
53/// OWASP recommends 600,000 for PBKDF2-HMAC-SHA256; the cap is the
54/// platform's. Digests store their own count, so raising it later keeps old
55/// digests verifiable.
56///
57/// # Examples
58///
59/// ```
60/// assert_eq!(ocre::password::ITERATIONS, 100_000);
61/// ```
62pub const ITERATIONS: u32 = 100_000;
63/// Identifies the algorithm at the start of a digest.
64const SCHEME: &str = "pbkdf2_sha256";
65const SALT_BYTES: usize = 16;
66const HASH_BYTES: usize = 32;
67
68/// Hashes `password` with a new random 16-byte salt into a self-describing digest.
69///
70/// The result looks like `pbkdf2_sha256$100000$<salt>$<hash>`; store it
71/// (e.g. in a `password_digest` column) and check passwords with [`verify`].
72/// Two calls with the same password give different digests (new salt).
73///
74/// Cost: one PBKDF2 run with [`ITERATIONS`], measured at 5.5 ms of CPU on
75/// Workers (see the [cost model](https://ocre.rs/explanations/cost-model)): about half of the free plan's 10 ms
76/// per request.
77///
78/// # Errors
79///
80/// On Workers, [`Error::Internal`] (500) when WebCrypto fails (the message
81/// names the step, never the password). Native builds never fail.
82///
83/// # Panics
84///
85/// If the platform's secure random generator is unavailable, which does not
86/// happen on supported targets.
87///
88/// # Examples
89///
90/// ```
91/// # pollster::block_on(async {
92/// let digest = ocre::password::hash("correct horse").await?;
93/// assert!(digest.starts_with("pbkdf2_sha256$100000$"));
94/// assert!(ocre::password::verify("correct horse", &digest).await?);
95/// # Ok::<(), ocre::Error>(())
96/// # }).unwrap();
97/// ```
98pub async fn hash(password: &str) -> Result<String> {
99 hash_with(password, ITERATIONS).await
100}
101
102async fn hash_with(password: &str, iterations: u32) -> Result<String> {
103 let salt = crate::token::random_bytes::<SALT_BYTES>();
104 let hash = derive(password, &salt, iterations).await?;
105 Ok(format!("{SCHEME}${iterations}${}${}", STANDARD_NO_PAD.encode(salt), STANDARD_NO_PAD.encode(hash)))
106}
107
108/// Whether `password` matches `digest` (made by [`hash`]), compared in constant time.
109///
110/// The password is hashed with the digest's own salt and iteration count, so
111/// digests made with an older [`ITERATIONS`] still verify. A wrong password
112/// is `Ok(false)`, not an error.
113///
114/// Cost: one PBKDF2 run with the stored iteration count (5.5 ms of CPU on
115/// Workers at 100,000). To keep timing from revealing which emails have
116/// accounts, verify against some digest even when the user is unknown.
117///
118/// # Errors
119///
120/// - [`Error::Internal`] (500) when `digest` is not in the
121/// `pbkdf2_sha256$<iterations>$<salt>$<hash>` format (corrupted data, not a
122/// failed login).
123/// - On Workers, [`Error::Internal`] when WebCrypto fails.
124///
125/// # Examples
126///
127/// ```
128/// # pollster::block_on(async {
129/// let digest = ocre::password::hash("correct horse").await?;
130/// assert!(ocre::password::verify("correct horse", &digest).await?);
131/// assert!(!ocre::password::verify("wrong horse", &digest).await?);
132/// assert!(ocre::password::verify("correct horse", "plaintext").await.is_err());
133/// # Ok::<(), ocre::Error>(())
134/// # }).unwrap();
135/// ```
136pub async fn verify(password: &str, digest: &str) -> Result<bool> {
137 let Some(parsed) = Parsed::from_digest(digest) else {
138 return Err(Error::internal(
139 "the password digest is not in the pbkdf2_sha256$<iterations>$<salt>$<hash> format",
140 ));
141 };
142 let hash = derive(password, &parsed.salt, parsed.iterations).await?;
143 Ok(constant_time_eq(&hash, &parsed.hash))
144}
145
146/// The iteration count stored in `digest`, or `None` when it is not in Ocre's format.
147///
148/// Use it to re-hash old passwords after a successful login when the count
149/// is lower than [`ITERATIONS`]. Parses only; no hashing.
150///
151/// # Examples
152///
153/// ```
154/// let digest = "pbkdf2_sha256$100000$c2FsdA$aGFzaA";
155/// assert_eq!(ocre::password::iterations(digest), Some(100_000));
156/// assert_eq!(ocre::password::iterations("$2b$12$bcrypt"), None);
157/// ```
158pub fn iterations(digest: &str) -> Option<u32> {
159 Parsed::from_digest(digest).map(|parsed| parsed.iterations)
160}
161
162struct Parsed {
163 iterations: u32,
164 salt: Vec<u8>,
165 hash: Vec<u8>,
166}
167
168impl Parsed {
169 fn from_digest(digest: &str) -> Option<Self> {
170 let mut parts = digest.split('$');
171 if parts.next()? != SCHEME {
172 return None;
173 }
174 let iterations = parts.next()?.parse().ok().filter(|&n| n > 0)?;
175 let salt = STANDARD_NO_PAD.decode(parts.next()?).ok()?;
176 let hash = STANDARD_NO_PAD.decode(parts.next()?).ok()?;
177 if parts.next().is_some() || salt.is_empty() || hash.is_empty() {
178 return None;
179 }
180 Some(Self { iterations, salt, hash })
181 }
182}
183
184/// PBKDF2-HMAC-SHA256, 32 bytes: WebCrypto on Workers.
185#[cfg(target_arch = "wasm32")]
186async fn derive(password: &str, salt: &[u8], iterations: u32) -> Result<Vec<u8>> {
187 crate::runtime::pbkdf2_sha256(password.as_bytes(), salt, iterations, HASH_BYTES).await
188}
189
190/// PBKDF2-HMAC-SHA256, 32 bytes: pure Rust on native targets.
191#[cfg(not(target_arch = "wasm32"))]
192async fn derive(password: &str, salt: &[u8], iterations: u32) -> Result<Vec<u8>> {
193 let mut hash = vec![0u8; HASH_BYTES];
194 pbkdf2::pbkdf2_hmac::<sha2::Sha256>(password.as_bytes(), salt, iterations, &mut hash);
195 Ok(hash)
196}
197
198#[cfg(test)]
199#[path = "../tests/password.rs"]
200mod tests;