mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
104 lines
3.9 KiB
Rust
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"));
|
|
}
|
|
}
|