Security

Pwned passwords

Have I Been Pwned's Pwned Passwords by k-anonymity: only 5 characters of the SHA-1 leave the server, the answer is checked here.

Version 1.0.0 · 3 functions · 1 crate

#passwords#auth#forms#breaches

Functions

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

pwned_range(password: str) → map

What to ask the API for password: { prefix, suffix, url }. url is https://api.pwnedpasswords.com/range/<prefix>; fetch it (with the Add-Padding: true header, so the answer's size says nothing) and give its text to pwned_count. Neither the password nor its whole hash is sent.

pwned_count(password: str, range_text: str) → intor an error

How many times password appears in breaches, read from range_text (the API's answer for its prefix: lines of SUFFIX:COUNT). 0 when it is not there; padding lines (count 0) are ignored. An error when the text is not such an answer.

pwned_sha1(password: str) → str

The SHA-1 of password in upper-case hex, as Pwned Passwords writes it: for the downloadable list, or a cache of ranges.

Get it

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

Or with curl:

$ curl -O https://quillolang.org/plugins/files/pwned.qwer$ curl -O https://quillolang.org/plugins/files/pwned.qwe$ curl -O https://quillolang.org/plugins/files/pwned_sample.txt

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

main.qwe
use pwned.qweruse pwned_range, pwned_count from pwned      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
sha1 = "0.10"

Example

pwned.qwe
// A password-change form that refuses passwords seen in data breaches.////   quillo run pwned.qwe      then open http://localhost:8080/check?password=password123//// A real app asks the API for the range (only five characters of the hash are sent)://   let range = http.get(pwned_range(password).url, { "add-padding": "true" })?.body// This example reads a sample answer for the prefix CBFDA instead, so it runs offline.use "pwned.qwer"fn range_for(password: str) {    let r = pwned_range(password)    if r.prefix == "CBFDA" { return read_file("pwned_sample.txt")? }    "00000000000000000000000000000000000:0"      // any other prefix: no breach in this sample}route GET /check ? { password: str } {    let seen = pwned_count(password, range_for(password))?    { prefix_sent: pwned_range(password).prefix, seen_in_breaches: seen, accepted: seen == 0 }}route GET /sample = { password123: pwned_count("password123", read_file("pwned_sample.txt")?)?, other: pwned_count("correct horse battery staple", read_file("pwned_sample.txt")?)? }
$ quillo run pwned.qwe$ curl 'localhost:8080/check?password=password123'$ curl 'localhost:8080/check?password=correct%20horse'

Source

pwned.qwer
//! Have I Been Pwned's Pwned Passwords, by k-anonymity: only the first 5 characters of the password's SHA-1 leave the server, and the answer is checked here; for sign-up and password-change forms.//! version: 1.0.0//!//! [dependencies]//! sha1 = "0.10"use sha1::{Digest, Sha1};use std::collections::HashMap;fn sha1_upper(text: &str) -> String {    Sha1::digest(text.as_bytes()).iter().map(|b| format!("{:02X}", b)).collect()}/// What to ask the API for `password`: `{ prefix, suffix, url }`. `url` is `https://api.pwnedpasswords.com/range/<prefix>`;/// fetch it (with the `Add-Padding: true` header, so the answer's size says nothing) and give its text to pwned_count./// Neither the password nor its whole hash is sent.pub fn pwned_range(password: &str) -> HashMap<String, String> {    let h = sha1_upper(password);    HashMap::from([        ("prefix".to_string(), h[..5].to_string()),        ("suffix".to_string(), h[5..].to_string()),        ("url".to_string(), format!("https://api.pwnedpasswords.com/range/{}", &h[..5])),    ])}/// How many times `password` appears in breaches, read from `range_text` (the API's answer for its prefix: lines of/// `SUFFIX:COUNT`). 0 when it is not there; padding lines (count 0) are ignored. An error when the text is not such an answer.pub fn pwned_count(password: &str, range_text: &str) -> Result<i64, String> {    let suffix = &sha1_upper(password)[5..];    let mut lines = 0;    for line in range_text.lines().take(100_000) {        let line = line.trim();        if line.is_empty() {            continue;        }        let (s, n) = line.split_once(':').ok_or_else(|| format!("`{}` is not SUFFIX:COUNT: is this the range answer?", line.chars().take(60).collect::<String>()))?;        lines += 1;        if s.trim().eq_ignore_ascii_case(suffix) {            return n.trim().parse::<i64>().map_err(|_| format!("`{}` is not a count", n));        }    }    if lines == 0 {        return Err("the range answer is empty".into());    }    Ok(0)}/// The SHA-1 of `password` in upper-case hex, as Pwned Passwords writes it: for the downloadable list, or a cache of ranges.pub fn pwned_sha1(password: &str) -> String {    sha1_upper(password)}
Thread
89Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.