| 19bb31ff Andy McCall ↶ |
1 |
|
| 2 |
|
| 3 |
|
| 4 |
use hmac::{Hmac, Mac}; |
| 5 |
use sha1::Sha1; |
| 6 |
|
| 7 |
pub fn new_secret() -> String { |
| 8 |
let mut b = [0u8; 20]; |
| 9 |
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut b); |
| 10 |
base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &b) |
| 11 |
} |
| 12 |
|
| 13 |
fn code_at(secret: &str, counter: u64) -> Option<String> { |
| 14 |
let key = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, secret)?; |
| 15 |
let mut mac = Hmac::<Sha1>::new_from_slice(&key).ok()?; |
| 16 |
mac.update(&counter.to_be_bytes()); |
| 17 |
let digest = mac.finalize().into_bytes(); |
| 18 |
let offset = (digest[19] & 0x0f) as usize; |
| 19 |
let bin = ((digest[offset] as u32 & 0x7f) << 24) |
| 20 |
| ((digest[offset + 1] as u32) << 16) |
| 21 |
| ((digest[offset + 2] as u32) << 8) |
| 22 |
| digest[offset + 3] as u32; |
| 23 |
Some(format!("{:06}", bin % 1_000_000)) |
| 24 |
} |
| 25 |
|
| 26 |
|
| 27 |
pub fn current(secret: &str, now_s: u64) -> Option<String> { |
| 28 |
code_at(secret, now_s / 30) |
| 29 |
} |
| 30 |
|
| 31 |
|
| 32 |
pub fn verify(secret: &str, code: &str, now_s: u64) -> bool { |
| 33 |
let code = code.trim().replace(' ', ""); |
| 34 |
let step = now_s / 30; |
| 35 |
[step.wrapping_sub(1), step, step + 1].iter().any(|c| code_at(secret, *c).is_some_and(|x| x == code)) |
| 36 |
} |
| 37 |
|
| 38 |
|
| 39 |
pub fn uri(site: &str, username: &str, secret: &str) -> String { |
| 40 |
let label = format!("{site}:{username}").replace(' ', "%20"); |
| 41 |
format!("otpauth://totp/{label}?secret={secret}&issuer={}&algorithm=SHA1&digits=6&period=30", site.replace(' ', "%20")) |
| 42 |
} |
| 43 |
|
| 44 |
|
| 45 |
pub fn qr_svg(uri: &str) -> Option<String> { |
| 46 |
let code = qrcode::QrCode::new(uri.as_bytes()).ok()?; |
| 47 |
Some(code.render::<qrcode::render::svg::Color>().min_dimensions(180, 180).quiet_zone(true).build()) |
| 48 |
} |
| 49 |
|
| 50 |
|
| 51 |
pub fn new_recovery_codes() -> (Vec<String>, String) { |
| 52 |
let codes: Vec<String> = (0..10).map(|_| super::random_hex(5)).collect(); |
| 53 |
let stored = codes.iter().map(|c| hex::encode(blake3::hash(c.as_bytes()).as_bytes())).collect::<Vec<_>>().join(","); |
| 54 |
(codes, stored) |
| 55 |
} |
| 56 |
|
| 57 |
|
| 58 |
pub fn use_recovery_code(stored: &str, code: &str) -> Option<String> { |
| 59 |
let hash = hex::encode(blake3::hash(code.trim().as_bytes()).as_bytes()); |
| 60 |
let all: Vec<&str> = stored.split(',').filter(|s| !s.is_empty()).collect(); |
| 61 |
if !all.contains(&hash.as_str()) { |
| 62 |
return None; |
| 63 |
} |
| 64 |
Some(all.into_iter().filter(|h| *h != hash).collect::<Vec<_>>().join(",")) |
| 65 |
} |
| 66 |
|
| 67 |
#[cfg(test)] |
| 68 |
mod tests { |
| 69 |
use super::*; |
| 70 |
|
| 71 |
#[test] |
| 72 |
fn rfc_6238_vector() { |
| 73 |
|
| 74 |
let secret = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, b"12345678901234567890"); |
| 75 |
assert_eq!(current(&secret, 59).unwrap(), "287082"); |
| 76 |
assert_eq!(current(&secret, 1111111109).unwrap(), "081804"); |
| 77 |
assert!(verify(&secret, "287082", 59)); |
| 78 |
assert!(verify(&secret, "287 082", 75), "drift of one step"); |
| 79 |
assert!(!verify(&secret, "000000", 59)); |
| 80 |
} |
| 81 |
|
| 82 |
#[test] |
| 83 |
fn recovery_codes() { |
| 84 |
let (codes, stored) = new_recovery_codes(); |
| 85 |
assert_eq!(codes.len(), 10); |
| 86 |
let rest = use_recovery_code(&stored, &codes[3]).unwrap(); |
| 87 |
assert!(use_recovery_code(&rest, &codes[3]).is_none(), "a code is single use"); |
| 88 |
assert!(use_recovery_code(&rest, &codes[4]).is_some()); |
| 8d2caf12 Andy McCall ↶ |
89 |
assert!(qr_svg(&uri("OxygenHub", "andy", &new_secret())).unwrap().contains("<svg")); |
| 19bb31ff Andy McCall ↶ |
90 |
} |
| 91 |
} |