Security

Recovery codes

Two-factor recovery codes: a set made and shown once, stored only as hashes, each one usable a single time.

Version 1.0.0 · 3 functions · 2 crates

#auth#2fa#mfa#passwords

Functions

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

mfa_codes(count: int) → list

count new codes (1 to 50; 10 is usual), each 10 characters as xxxxx-xxxxx (about 49 bits): show them to the user once, then store mfa_hash of each and forget the codes.

mfa_hash(code: str) → str

The hash to store for a code (SHA-256, hex). Spaces, dashes and capitals do not count, so ABCDE FGHJK is abcde-fghjk.

mfa_check(code: str, hashes: list) → int

Which stored hash code matches: its position in hashes, or -1 for none. Remove that hash once it is used: each code works one time. Every hash is compared, in constant time, so the answer's timing tells nothing.

Get it

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

Or with curl:

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

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

main.qwe
use mfa_codes.qweruse mfa_codes, mfa_hash from mfa_codes      // Quillo 0.6: only these

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

[dependencies]
rand = "0.8"sha2 = "0.10"

Example

mfa_codes.qwe
// Two-factor recovery codes: shown once, kept as hashes, each used one time.////   quillo run mfa_codes.qwe      then open http://localhost:8080/setupuse "mfa_codes.qwer"route GET /setup {    let codes = mfa_codes(10)    // Store only the hashes (db.exec("insert into recovery_codes ...") in a real app).    write_file("data/recovery.json", json.stringify(codes | map((c) => mfa_hash(c))))    { show_once: codes, count: len(codes) }}route GET /use {    let codes = mfa_codes(10)    let stored = codes | map((c) => mfa_hash(c))    let typed = upper(replace(codes[3], "-", " "))          // as a user types it from paper    let at = mfa_check(typed, stored)    let left = [...slice(stored, 0, at), ...slice(stored, at + 1, len(stored))]    { matched: at, second_time: mfa_check(typed, left), wrong: mfa_check("aaaaa-bbbbb", stored), left: len(left) }}
$ quillo run mfa_codes.qwe$ curl 'localhost:8080/use'$ curl 'localhost:8080/setup'

Source

mfa_codes.qwer
//! Recovery codes for two-factor sign-in: a set made once, shown once, stored only as hashes, and each usable one time.//! version: 1.0.0//!//! [dependencies]//! rand = "0.8"//! sha2 = "0.10"use rand::Rng;use sha2::{Digest, Sha256};/// No 0, o, 1, i or l, so a code copied from paper is read right.const ALPHABET: &[u8] = b"23456789abcdefghjkmnpqrstuvwxyz";fn normal(code: &str) -> String {    code.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_lowercase()}fn hash(code: &str) -> String {    let n = normal(code);    Sha256::digest(format!("mfa-recovery:{}", n).as_bytes()).iter().map(|b| format!("{:02x}", b)).collect()}/// `count` new codes (1 to 50; 10 is usual), each 10 characters as `xxxxx-xxxxx` (about 49 bits): show them to the user once,/// then store mfa_hash of each and forget the codes.pub fn mfa_codes(count: i64) -> Vec<String> {    let mut rng = rand::rngs::OsRng;    (0..count.clamp(1, 50)).map(|_| {        let s: String = (0..10).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect();        format!("{}-{}", &s[..5], &s[5..])    }).collect()}/// The hash to store for a code (SHA-256, hex). Spaces, dashes and capitals do not count, so `ABCDE FGHJK` is `abcde-fghjk`.pub fn mfa_hash(code: &str) -> String {    hash(code)}/// Which stored hash `code` matches: its position in `hashes`, or -1 for none. Remove that hash once it is used: each code/// works one time. Every hash is compared, in constant time, so the answer's timing tells nothing.pub fn mfa_check(code: &str, hashes: Vec<String>) -> i64 {    if normal(code).len() < 8 {        return -1;    }    let h = hash(code);    let mut found = -1i64;    for (i, s) in hashes.iter().enumerate() {        let same = s.len() == h.len() && s.bytes().zip(h.bytes()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0;        if same && found < 0 {            found = i as i64;        }    }    found}
Thread
76Useful? Like it, or add to its thread below.

Thread 0 posts

On Recovery codes: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.