| 426 |
426 |
|
/// future algorithm migration — when one lands, add a dispatch table
|
| 427 |
427 |
|
/// instead of swapping the default instance under the verifier's feet.
|
| 428 |
428 |
|
pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
|
| 429 |
|
- |
let parsed_hash = PasswordHash::new(hash)
|
| 430 |
|
- |
.map_err(|e| AppError::Internal(anyhow::anyhow!("parse password hash: {e}")))?;
|
| 431 |
|
- |
|
| 432 |
|
- |
let algorithm = Algorithm::try_from(parsed_hash.algorithm)
|
| 433 |
|
- |
.map_err(|e| AppError::Internal(anyhow::anyhow!("unexpected password hash algorithm: {e}")))?;
|
| 434 |
|
- |
let version = parsed_hash
|
| 435 |
|
- |
.version
|
| 436 |
|
- |
.map(Version::try_from)
|
| 437 |
|
- |
.transpose()
|
| 438 |
|
- |
.map_err(|e| AppError::Internal(anyhow::anyhow!("unexpected password hash version: {e}")))?
|
| 439 |
|
- |
.unwrap_or(Version::V0x13);
|
| 440 |
|
- |
let params = Params::try_from(&parsed_hash)
|
| 441 |
|
- |
.map_err(|e| AppError::Internal(anyhow::anyhow!("parse password hash params: {e}")))?;
|
|
429 |
+ |
// A stored hash that won't parse (corruption, or a non-Argon2 algorithm we
|
|
430 |
+ |
// don't verify) is a server-side data problem, not a 500 for the user: treat
|
|
431 |
+ |
// it as a non-match so login simply fails, and log it for ops. Returning an
|
|
432 |
+ |
// Internal error here would also be a (third-order) account oracle — it
|
|
433 |
+ |
// distinguishes "valid account, bad stored hash" from "valid account, wrong
|
|
434 |
+ |
// password" by status code (SEC minor, Run #23).
|
|
435 |
+ |
let reject = |what: &str, e: &dyn std::fmt::Display| {
|
|
436 |
+ |
tracing::error!(event = "password_hash_unverifiable", reason = %what, error = %e,
|
|
437 |
+ |
"stored password hash could not be parsed; treating as non-match");
|
|
438 |
+ |
Ok(false)
|
|
439 |
+ |
};
|
|
440 |
+ |
|
|
441 |
+ |
let parsed_hash = match PasswordHash::new(hash) {
|
|
442 |
+ |
Ok(h) => h,
|
|
443 |
+ |
Err(e) => return reject("parse", &e),
|
|
444 |
+ |
};
|
|
445 |
+ |
let algorithm = match Algorithm::try_from(parsed_hash.algorithm) {
|
|
446 |
+ |
Ok(a) => a,
|
|
447 |
+ |
Err(e) => return reject("algorithm", &e),
|
|
448 |
+ |
};
|
|
449 |
+ |
let version = match parsed_hash.version.map(Version::try_from).transpose() {
|
|
450 |
+ |
Ok(v) => v.unwrap_or(Version::V0x13),
|
|
451 |
+ |
Err(e) => return reject("version", &e),
|
|
452 |
+ |
};
|
|
453 |
+ |
let params = match Params::try_from(&parsed_hash) {
|
|
454 |
+ |
Ok(p) => p,
|
|
455 |
+ |
Err(e) => return reject("params", &e),
|
|
456 |
+ |
};
|
| 442 |
457 |
|
|
| 443 |
458 |
|
Ok(Argon2::new(algorithm, version, params)
|
| 444 |
459 |
|
.verify_password(password.as_bytes(), &parsed_hash)
|
| 649 |
664 |
|
}
|
| 650 |
665 |
|
|
| 651 |
666 |
|
#[test]
|
|
667 |
+ |
fn verify_password_unparseable_hash_is_non_match_not_error() {
|
|
668 |
+ |
// A corrupt / non-Argon2 stored hash must fail login cleanly (Ok(false)),
|
|
669 |
+ |
// not 500 — avoids an availability bug and an account oracle (SEC, Run #23).
|
|
670 |
+ |
for bad in ["", "not-a-phc-string", "$argon2id$garbage", "$2y$10$abcdefghijklmnopqrstuv"] {
|
|
671 |
+ |
assert_eq!(verify_password("any", bad).unwrap(), false, "hash {bad:?} should be a non-match");
|
|
672 |
+ |
}
|
|
673 |
+ |
}
|
|
674 |
+ |
|
|
675 |
+ |
#[test]
|
| 652 |
676 |
|
fn hash_password_different_each_time() {
|
| 653 |
677 |
|
let h1 = hash_password("same_password").unwrap();
|
| 654 |
678 |
|
let h2 = hash_password("same_password").unwrap();
|