fix(auth): Werkzeug-scrypt-Hashes verifizierbar (Bestandskonten-Login)

Die Flask-Version installierte Werkzeug ungepinnt; Werkzeug >= 3 erzeugt
standardmaessig scrypt-Hashes ('scrypt:32768:8:1$salt$hex') statt
pbkdf2:sha256. Bestandskonten aus dieser Aera (u. a. der Produktiv-Admin)
konnten sich nach dem Rust-Cutover nicht anmelden, weil verify_password nur
pbkdf2 und Argon2 kannte.

- security.rs: verify_werkzeug_scrypt() (Parameter aus dem Hash-String,
  dklen aus der Digest-Laenge, Konstantzeit-Vergleich); wie bei pbkdf2 wird
  beim ersten erfolgreichen Login transparent auf Argon2id rehasht
- Unit-Test mit gegen hashlib.scrypt reproduziertem Referenzvektor
- Cargo.toml: scrypt 0.11 (RustCrypto, ohne Default-Features)
This commit is contained in:
Moe 2026-07-15 09:53:47 +02:00
parent 9b8d603e74
commit f73239211c
3 changed files with 90 additions and 0 deletions

40
Cargo.lock generated
View File

@ -320,6 +320,16 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
]
[[package]] [[package]]
name = "cmov" name = "cmov"
version = "0.5.4" version = "0.5.4"
@ -927,6 +937,15 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@ -958,6 +977,7 @@ dependencies = [
"pbkdf2", "pbkdf2",
"rand 0.8.7", "rand 0.8.7",
"reqwest", "reqwest",
"scrypt",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
@ -1582,12 +1602,32 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"pbkdf2",
"salsa20",
"sha2 0.10.9",
]
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"

View File

@ -20,6 +20,7 @@ deadpool-postgres = "0.14"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
argon2 = "0.5" argon2 = "0.5"
pbkdf2 = "0.12" pbkdf2 = "0.12"
scrypt = { version = "0.11", default-features = false }
sha2 = "0.10" sha2 = "0.10"
subtle = "2" subtle = "2"
rand = "0.8" rand = "0.8"

View File

@ -31,6 +31,12 @@ pub fn verify_password(stored: &str, password: &str) -> (bool, bool) {
if let Some(rest) = stored.strip_prefix("pbkdf2:sha256") { if let Some(rest) = stored.strip_prefix("pbkdf2:sha256") {
return (verify_werkzeug_pbkdf2(rest, password), true); return (verify_werkzeug_pbkdf2(rest, password), true);
} }
if let Some(rest) = stored.strip_prefix("scrypt:") {
// Werkzeug >= 3 Default: "scrypt:32768:8:1$salt$hex" (dklen=64).
// Die Flask-Aera installierte Werkzeug ungepinnt -- Konten ab ~2026-07
// haben daher scrypt- statt pbkdf2-Hashes (Hotfix 2026-07-15).
return (verify_werkzeug_scrypt(rest, password), true);
}
match PasswordHash::new(stored) { match PasswordHash::new(stored) {
Ok(parsed) => ( Ok(parsed) => (
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok(), Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok(),
@ -70,6 +76,38 @@ fn verify_werkzeug_pbkdf2(rest: &str, password: &str) -> bool {
out.ct_eq(&expected).into() out.ct_eq(&expected).into()
} }
/// rest = "<n>:<r>:<p>$<salt>$<hex-digest>" (Werkzeug-scrypt, dklen = Digest-Laenge).
fn verify_werkzeug_scrypt(rest: &str, password: &str) -> bool {
let mut parts = rest.splitn(3, '$');
let (Some(params), Some(salt), Some(expected_hex)) = (parts.next(), parts.next(), parts.next()) else {
return false;
};
let mut p = params.splitn(3, ':');
let (Some(n), Some(r), Some(pp)) = (
p.next().and_then(|v| v.parse::<u64>().ok()),
p.next().and_then(|v| v.parse::<u32>().ok()),
p.next().and_then(|v| v.parse::<u32>().ok()),
) else {
return false;
};
if !n.is_power_of_two() || n < 2 {
return false;
}
let log_n = n.trailing_zeros() as u8;
let Ok(expected) = hex::decode(expected_hex) else {
return false;
};
let Ok(sparams) = scrypt::Params::new(log_n, r, pp, expected.len()) else {
return false;
};
let mut out = vec![0u8; expected.len()];
if scrypt::scrypt(password.as_bytes(), salt.as_bytes(), &sparams, &mut out).is_err() {
return false;
}
use subtle::ConstantTimeEq;
out.ct_eq(&expected).into()
}
// ── Passwort-Policy ──────────────────────────────────────────────────────────── // ── Passwort-Policy ────────────────────────────────────────────────────────────
/// None wenn ok, sonst deutsche Fehlermeldung. Laenge vor Komplexitaet /// None wenn ok, sonst deutsche Fehlermeldung. Laenge vor Komplexitaet
/// (BSI ORP.4 / NIST SP 800-63B), nur reine Ziffernfolgen werden abgelehnt. /// (BSI ORP.4 / NIST SP 800-63B), nur reine Ziffernfolgen werden abgelehnt.
@ -169,6 +207,17 @@ mod tests {
assert!(!verify_password(h, "falsch").0); assert!(!verify_password(h, "falsch").0);
} }
#[test]
fn werkzeug_scrypt() {
// werkzeug.security.generate_password_hash("test-passwort-123") mit Werkzeug>=3
// reproduziert mit hashlib.scrypt(b'test-passwort-123', salt=b'abcdefgh', n=32768, r=8, p=1, dklen=64)
let h = "scrypt:32768:8:1$abcdefgh$f79ff45b82699a791b5bf9814df79bec4a15061000ab1b1df157bb9d1423b8724b69fffbeac3a44ba206182a85bf11e777beda0d512f48718edd12c74c619b11";
let (ok, rehash) = verify_password(h, "test-passwort-123");
assert!(rehash);
assert!(ok, "werkzeug-scrypt-hash muss verifizierbar sein");
assert!(!verify_password(h, "falsch").0);
}
#[test] #[test]
fn ssrf_guard() { fn ssrf_guard() {
assert!(endpoint_url_problem("").is_none()); assert!(endpoint_url_problem("").is_none());