Files
holy-flavor-rater/backend-rs/src/password.rs
T
2026-06-09 15:07:04 +02:00

104 lines
3.9 KiB
Rust

//! Django-compatible password verification.
//!
//! Django's default hasher stores `pbkdf2_sha256$<iterations>$<salt>$<b64hash>`,
//! where the hash is `base64(pbkdf2_hmac_sha256(password, salt, iterations))`
//! with the derived key length equal to the SHA-256 digest size (32 bytes).
//! Salt is used as raw UTF-8 bytes. We re-derive and compare in constant time,
//! matching Django's `check_password`.
//!
//! Only `pbkdf2_sha256` is supported (the project's hasher). Other algorithms
//! (argon2/bcrypt) would need their own branch — login would fall through to a
//! rejection for those, so keep issuance on Django if the hasher ever changes.
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use sha2::Sha256;
use subtle::ConstantTimeEq;
const ALGORITHM: &str = "pbkdf2_sha256";
const DK_LEN: usize = 32; // SHA-256 digest size
/// Matches Django's current `PBKDF2PasswordHasher.iterations`. Django reads the
/// iteration count back out of the encoded hash, so any value it supports
/// verifies — we use its default so it never needs to silently re-hash on login.
const ITERATIONS: u32 = 1_200_000;
/// Verify a plaintext password against a Django password hash string.
pub fn verify_password(password: &str, encoded: &str) -> bool {
let mut parts = encoded.splitn(4, '$');
let (Some(algo), Some(iter_s), Some(salt), Some(b64hash)) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return false;
};
if algo != ALGORITHM {
return false;
}
let Ok(iterations) = iter_s.parse::<u32>() else {
return false;
};
let Ok(expected) = B64.decode(b64hash) else {
return false;
};
let mut derived = [0u8; DK_LEN];
// pbkdf2_hmac writes the derived key; rounds == 0 is invalid for Django hashes.
if iterations == 0 {
return false;
}
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt.as_bytes(), iterations, &mut derived);
// Constant-time compare, length-checked first.
if expected.len() != derived.len() {
return false;
}
derived.ct_eq(expected.as_slice()).into()
}
/// Hash a plaintext password into Django's `pbkdf2_sha256$iter$salt$b64` format,
/// matching `make_password`. The salt is a random hex string (cryptographically
/// random via uuid v4); Django accepts any salt that contains no `$`.
pub fn hash_password(password: &str) -> String {
let salt = uuid::Uuid::new_v4().simple().to_string();
let mut derived = [0u8; DK_LEN];
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt.as_bytes(), ITERATIONS, &mut derived);
let b64 = B64.encode(derived);
format!("{ALGORITHM}${ITERATIONS}${salt}${b64}")
}
#[cfg(test)]
mod tests {
use super::*;
// Generated by Python `hashlib.pbkdf2_hmac` (the exact routine Django's
// PBKDF2PasswordHasher calls) and confirmed to verify under Django's
// `check_password`. Proves cross-backend hash compatibility.
const DJANGO_HASH: &str =
"pbkdf2_sha256$100000$abcdefghijklmnop$M4PTvkbfZw/Dmbx4RsTgMD5Oo8Mh7BdYlHcR+JM6rT0=";
const DJANGO_PLAINTEXT: &str = "correct horse battery staple";
#[test]
fn verifies_known_django_hash() {
assert!(verify_password(DJANGO_PLAINTEXT, DJANGO_HASH));
}
#[test]
fn rejects_wrong_password_for_django_hash() {
assert!(!verify_password("wrong password", DJANGO_HASH));
}
#[test]
fn hash_then_verify_round_trips() {
let encoded = hash_password("hunter2-correct");
assert!(encoded.starts_with("pbkdf2_sha256$1200000$"));
assert!(verify_password("hunter2-correct", &encoded));
assert!(!verify_password("hunter3", &encoded));
}
#[test]
fn rejects_malformed_and_unsupported() {
assert!(!verify_password("x", "not-a-hash"));
assert!(!verify_password("x", "bcrypt$12$whatever"));
assert!(!verify_password("x", "pbkdf2_sha256$0$salt$AAAA"));
}
}