Security

Legacy passwords

Check passwords against Django, Flask/Werkzeug and WordPress hashes, to sign in users moved from those apps and rehash them with Argon2.

Version 1.0.0 · 3 functions · 8 crates

#passwords#auth#migration#import

Functions

What use legacy_passwords.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use legacy_kind, legacy_verify from legacy_passwords (Quillo 0.6).

legacy_kind(hash: str) → str?

What kind of hash hash is: "django_pbkdf2_sha256", "django_pbkdf2_sha1", "werkzeug_pbkdf2", "werkzeug_scrypt", "wordpress_phpass" ($P$ and phpBB's $H$), "wordpress_bcrypt" ($wp$2y$, WordPress 6.8 and later), "bcrypt", or null.

legacy_verify(password: str, hash: str) → boolor an error

Whether password matches hash, of any kind legacy_kind knows. False for a wrong password; an error for a hash of another kind or a broken one (Django's argon2 and bcrypt_sha256 hashes are not read). Each check takes as long as the old app took: Django's 600,000 rounds, about a quarter of a second.

legacy_django_hash(password: str, salt: str, iterations: int) → stror an error

A Django-style pbkdf2_sha256$rounds$salt$hash for password with salt (letters and digits) and iterations (Django 5 uses 870,000): to make test users, or to keep writing hashes a Django app beside yours can read.

Get it

$ quillo install legacy_passwords               # legacy_passwords.qwer into this folder$ quillo install legacy_passwords --example     # and its example, legacy_passwords.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/legacy_passwords.qwer$ curl -O https://quillolang.org/plugins/files/legacy_passwords.qwe

Then, in your program, all of it or only what you call:

main.qwe
use legacy_passwords.qweruse legacy_kind, legacy_verify from legacy_passwords      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }sha2 = "0.10"sha1 = "0.10"md-5 = "0.10"hmac = "0.12"scrypt = { version = "0.11", default-features = false }bcrypt = "0.15"base64 = "0.22"

Example

legacy_passwords.qwe
// Signing in users moved from Django, Flask and WordPress, and moving each to Argon2 at their first sign-in.////   quillo run legacy_passwords.qwe      then open http://localhost:8080/loginuse "legacy_passwords.qwer"// Users imported from the old apps; every one of them has the password "hunter22".let imported = {    "ada@example.com": "pbkdf2_sha256$600000$Qx7rM2pLk9sTz4wB$lFgxcYiyCVJqMgHrH6ehgm+JAuCPG6MOEBJDeQwz6eo=",    "grace@example.com": "pbkdf2:sha256:600000$n8VbXq2Lr5Tz$0c772f8236ccfd7d2cd4fcb43ce9f973c2f9cba33acf80aa69be88bc950f1be9",    "linus@example.com": "scrypt:32768:8:1$Hk3sPq9WmZ1aLx7c$b1893f17c3be43489677c72e05b1eaf0da5184000b94a7dbe9c558c5c4b1f29b94ad2713a5db6ab5c6b6d439fef6996a791ab0c628b89f84190cec6cb011b48b",    "margaret@example.com": "$P$BaB3dE5gH.mccMVJmlyp7r9PZLhjXx1",    "alan@example.com": "$wp$2y$10$kyAYiLv5VJOlGNaouYMg5.//TjnpCDzR9BdWyv2j8NYFSWidfXCiG",}fn sign_in(email: str, password: str) {    let stored = imported[email] ?? null    if stored == null { return { ok: false } }    if starts_with(stored, "$argon2id$") { return { ok: crypto.verify_password(password, stored) } }    if !legacy_verify(password, stored)? { return { ok: false, from: legacy_kind(stored) } }    // Right: from now on keep an Argon2 hash (here only returned; a real app writes it to the users table).    { ok: true, from: legacy_kind(stored), rehashed: starts_with(crypto.hash_password(password), "$argon2id$") }}route GET /login = keys(imported) | map((email) => { email, ...sign_in(email, "hunter22") })route GET /wrong = keys(imported) | map((email) => sign_in(email, "hunter2").ok)route GET /make = { hash: legacy_django_hash("hunter22", "Qx7rM2pLk9sTz4wB", 600000)? }
$ quillo run legacy_passwords.qwe$ curl 'localhost:8080/login'$ curl 'localhost:8080/wrong'

Source

legacy_passwords.qwer
//! Check passwords against hashes from Django, Flask/Werkzeug and WordPress, to sign in users moved from those apps and rehash them with Quillo's Argon2.//! version: 1.0.0//!//! [dependencies]//! pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }//! sha2 = "0.10"//! sha1 = "0.10"//! md-5 = "0.10"//! hmac = "0.12"//! scrypt = { version = "0.11", default-features = false }//! bcrypt = "0.15"//! base64 = "0.22"use base64::engine::general_purpose::STANDARD;use base64::Engine;use hmac::{Hmac, Mac};use md5::{Digest, Md5};const ITOA64: &[u8] = b"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";/// At most this many PBKDF2 rounds are run for one check, so a hash planted in the database cannot hold a core for minutes.const MAX_ROUNDS: u32 = 5_000_000;fn same(a: &[u8], b: &[u8]) -> bool {    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0}fn unhex(s: &str) -> Option<Vec<u8>> {    if s.len() % 2 != 0 {        return None;    }    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()}fn rounds(s: &str) -> Result<u32, String> {    let n: u32 = s.parse().map_err(|_| format!("`{}` is not a number of rounds", s))?;    if n == 0 || n > MAX_ROUNDS {        return Err(format!("{} rounds is outside 1 to {}", n, MAX_ROUNDS));    }    Ok(n)}fn pbkdf2(alg: &str, password: &[u8], salt: &[u8], n: u32, len: usize) -> Result<Vec<u8>, String> {    let mut out = vec![0u8; len];    match alg {        "sha256" => pbkdf2::pbkdf2_hmac::<sha2::Sha256>(password, salt, n, &mut out),        "sha512" => pbkdf2::pbkdf2_hmac::<sha2::Sha512>(password, salt, n, &mut out),        "sha1" => pbkdf2::pbkdf2_hmac::<sha1::Sha1>(password, salt, n, &mut out),        other => return Err(format!("PBKDF2 with {} is not known here", other)),    }    Ok(out)}fn encode64(input: &[u8]) -> String {    let count = input.len();    let mut out = String::new();    let mut i = 0;    loop {        let mut v = input[i] as u32;        i += 1;        out.push(ITOA64[(v & 0x3f) as usize] as char);        if i < count {            v |= (input[i] as u32) << 8;        }        out.push(ITOA64[((v >> 6) & 0x3f) as usize] as char);        if i >= count {            break;        }        i += 1;        if i < count {            v |= (input[i] as u32) << 16;        }        out.push(ITOA64[((v >> 12) & 0x3f) as usize] as char);        if i >= count {            break;        }        i += 1;        out.push(ITOA64[((v >> 18) & 0x3f) as usize] as char);        if i >= count {            break;        }    }    out}fn phpass(password: &str, hash: &str) -> Result<bool, String> {    if hash.len() != 34 || !hash.is_ascii() {        return Err("a phpass hash is 34 characters".into());    }    let log2 = ITOA64.iter().position(|&c| c == hash.as_bytes()[3]).ok_or("not a phpass hash")?;    if !(7..=20).contains(&log2) {        return Err(format!("phpass with 2^{} rounds is outside 2^7 to 2^20", log2));    }    let salt = &hash.as_bytes()[4..12];    let mut h = Md5::new().chain_update(salt).chain_update(password).finalize();    for _ in 0..(1u32 << log2) {        h = Md5::new().chain_update(h).chain_update(password).finalize();    }    Ok(same(format!("{}{}", &hash[..12], encode64(&h)).as_bytes(), hash.as_bytes()))}/// What kind of hash `hash` is: "django_pbkdf2_sha256", "django_pbkdf2_sha1", "werkzeug_pbkdf2", "werkzeug_scrypt",/// "wordpress_phpass" (`$P$` and phpBB's `$H$`), "wordpress_bcrypt" (`$wp$2y$`, WordPress 6.8 and later), "bcrypt", or null.pub fn legacy_kind(hash: &str) -> Option<String> {    let h = hash.trim();    let k = if h.starts_with("pbkdf2_sha256$") {        "django_pbkdf2_sha256"    } else if h.starts_with("pbkdf2_sha1$") {        "django_pbkdf2_sha1"    } else if h.starts_with("pbkdf2:") {        "werkzeug_pbkdf2"    } else if h.starts_with("scrypt:") {        "werkzeug_scrypt"    } else if h.starts_with("$P$") || h.starts_with("$H$") {        "wordpress_phpass"    } else if h.starts_with("$wp$2") {        "wordpress_bcrypt"    } else if h.starts_with("$2a$") || h.starts_with("$2b$") || h.starts_with("$2y$") {        "bcrypt"    } else {        return None;    };    Some(k.to_string())}/// Whether `password` matches `hash`, of any kind legacy_kind knows. False for a wrong password; an error for a hash of/// another kind or a broken one (Django's argon2 and bcrypt_sha256 hashes are not read). Each check takes as long as the/// old app took: Django's 600,000 rounds, about a quarter of a second.pub fn legacy_verify(password: &str, hash: &str) -> Result<bool, String> {    let h = hash.trim();    let p = password.as_bytes();    match legacy_kind(h).as_deref() {        Some(k @ ("django_pbkdf2_sha256" | "django_pbkdf2_sha1")) => {            let parts: Vec<&str> = h.split('$').collect();            let [_, n, salt, expected] = parts[..] else { return Err("a Django hash is algorithm$rounds$salt$hash".into()) };            let expected = STANDARD.decode(expected).map_err(|_| "the Django hash's last part is not base64".to_string())?;            let alg = if k.ends_with("sha1") { "sha1" } else { "sha256" };            Ok(same(&pbkdf2(alg, p, salt.as_bytes(), rounds(n)?, expected.len().clamp(1, 64))?, &expected))        }        Some("werkzeug_pbkdf2") => {            let parts: Vec<&str> = h.split('$').collect();            let [method, salt, expected] = parts[..] else { return Err("a Werkzeug hash is method$salt$hash".into()) };            let m: Vec<&str> = method.split(':').collect();            let (alg, n) = match m[..] {                ["pbkdf2", alg, n] => (alg, rounds(n)?),                ["pbkdf2", alg] => (alg, 150_000),                _ => return Err(format!("`{}` is not a Werkzeug method", method)),            };            let expected = unhex(expected).ok_or("the Werkzeug hash's last part is not hex")?;            Ok(same(&pbkdf2(alg, p, salt.as_bytes(), n, expected.len().clamp(1, 64))?, &expected))        }        Some("werkzeug_scrypt") => {            let parts: Vec<&str> = h.split('$').collect();            let [method, salt, expected] = parts[..] else { return Err("a Werkzeug hash is method$salt$hash".into()) };            let m: Vec<&str> = method.split(':').collect();            let ["scrypt", n, r, pp] = m[..] else { return Err(format!("`{}` is not scrypt:n:r:p", method)) };            let (n, r, pp): (u64, u32, u32) = (n.parse().map_err(|_| "bad n")?, r.parse().map_err(|_| "bad r")?, pp.parse().map_err(|_| "bad p")?);            if !n.is_power_of_two() || n > (1 << 20) || r > 32 || pp > 16 || n * r as u64 * pp as u64 > (1 << 22) {                return Err("scrypt parameters too large for one check".into());            }            let expected = unhex(expected).ok_or("the Werkzeug hash's last part is not hex")?;            let params = scrypt::Params::new(n.trailing_zeros() as u8, r, pp, expected.len().clamp(10, 128)).map_err(|e| e.to_string())?;            let mut out = vec![0u8; expected.len().clamp(10, 128)];            scrypt::scrypt(p, salt.as_bytes(), &params, &mut out).map_err(|e| e.to_string())?;            Ok(same(&out, &expected))        }        Some("wordpress_phpass") => phpass(password, h),        Some("wordpress_bcrypt") => {            let mut m = Hmac::<sha2::Sha384>::new_from_slice(b"wp-sha384").expect("any key length");            m.update(p);            let pre = STANDARD.encode(m.finalize().into_bytes());            bcrypt::verify(pre, &h[3..]).map_err(|e| e.to_string())        }        Some("bcrypt") => bcrypt::verify(password, h).map_err(|e| e.to_string()),        _ => Err("not a hash kind this plugin reads".into()),    }}/// A Django-style `pbkdf2_sha256$rounds$salt$hash` for `password` with `salt` (letters and digits) and `iterations` (Django 5/// uses 870,000): to make test users, or to keep writing hashes a Django app beside yours can read.pub fn legacy_django_hash(password: &str, salt: &str, iterations: i64) -> Result<String, String> {    if salt.is_empty() || salt.contains('$') {        return Err("the salt is letters and digits, without `$`".into());    }    let n = rounds(&iterations.to_string())?;    Ok(format!("pbkdf2_sha256${}${}${}", n, salt, STANDARD.encode(pbkdf2("sha256", password.as_bytes(), salt.as_bytes(), n, 32)?)))}
Thread
77Useful? Like it, or add to its thread below.

Thread 0 posts

On Legacy passwords: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.