From f73239211c499b0855094fc2877f5235b9125cb9 Mon Sep 17 00:00:00 2001 From: Moe Date: Wed, 15 Jul 2026 09:53:47 +0200 Subject: [PATCH] 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) --- Cargo.lock | 40 ++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/security.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 4148e22..fa41d12 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -320,6 +320,16 @@ dependencies = [ "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]] name = "cmov" version = "0.5.4" @@ -927,6 +937,15 @@ dependencies = [ "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]] name = "ipnet" version = "2.12.0" @@ -958,6 +977,7 @@ dependencies = [ "pbkdf2", "rand 0.8.7", "reqwest", + "scrypt", "serde", "serde_json", "sha2 0.10.9", @@ -1582,12 +1602,32 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "serde" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index 4bb1e9e..7595142 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ deadpool-postgres = "0.14" chrono = { version = "0.4", features = ["serde"] } argon2 = "0.5" pbkdf2 = "0.12" +scrypt = { version = "0.11", default-features = false } sha2 = "0.10" subtle = "2" rand = "0.8" diff --git a/src/security.rs b/src/security.rs index a7c32a5..cd58bd5 100755 --- a/src/security.rs +++ b/src/security.rs @@ -31,6 +31,12 @@ pub fn verify_password(stored: &str, password: &str) -> (bool, bool) { if let Some(rest) = stored.strip_prefix("pbkdf2:sha256") { 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) { Ok(parsed) => ( 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() } +/// rest = "::

$$" (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::().ok()), + p.next().and_then(|v| v.parse::().ok()), + p.next().and_then(|v| v.parse::().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 ──────────────────────────────────────────────────────────── /// None wenn ok, sonst deutsche Fehlermeldung. Laenge vor Komplexitaet /// (BSI ORP.4 / NIST SP 800-63B), nur reine Ziffernfolgen werden abgelehnt. @@ -169,6 +207,17 @@ mod tests { 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] fn ssrf_guard() { assert!(endpoint_url_problem("").is_none());