//! Time-based one-time passwords (RFC 6238, SHA-1, six digits, 30 s) and //! recovery codes (hub spec §15.3). use hmac::{Hmac, Mac}; use sha1::Sha1; pub fn new_secret() -> String { let mut b = [0u8; 20]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut b); base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &b) } fn code_at(secret: &str, counter: u64) -> Option { let key = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, secret)?; let mut mac = Hmac::::new_from_slice(&key).ok()?; mac.update(&counter.to_be_bytes()); let digest = mac.finalize().into_bytes(); let offset = (digest[19] & 0x0f) as usize; let bin = ((digest[offset] as u32 & 0x7f) << 24) | ((digest[offset + 1] as u32) << 16) | ((digest[offset + 2] as u32) << 8) | digest[offset + 3] as u32; Some(format!("{:06}", bin % 1_000_000)) } /// The current code, for tests and the enrolment check. pub fn current(secret: &str, now_s: u64) -> Option { code_at(secret, now_s / 30) } /// Accept the code for this step and its neighbours (one step of drift). pub fn verify(secret: &str, code: &str, now_s: u64) -> bool { let code = code.trim().replace(' ', ""); let step = now_s / 30; [step.wrapping_sub(1), step, step + 1].iter().any(|c| code_at(secret, *c).is_some_and(|x| x == code)) } /// `otpauth://` URI for authenticator apps. pub fn uri(site: &str, username: &str, secret: &str) -> String { let label = format!("{site}:{username}").replace(' ', "%20"); format!("otpauth://totp/{label}?secret={secret}&issuer={}&algorithm=SHA1&digits=6&period=30", site.replace(' ', "%20")) } /// The provisioning URI as an SVG QR code. pub fn qr_svg(uri: &str) -> Option { let code = qrcode::QrCode::new(uri.as_bytes()).ok()?; Some(code.render::().min_dimensions(180, 180).quiet_zone(true).build()) } /// Ten recovery codes; stored as BLAKE3 hashes separated by commas. pub fn new_recovery_codes() -> (Vec, String) { let codes: Vec = (0..10).map(|_| super::random_hex(5)).collect(); let stored = codes.iter().map(|c| hex::encode(blake3::hash(c.as_bytes()).as_bytes())).collect::>().join(","); (codes, stored) } /// Use a recovery code: the remaining stored codes if it matched. pub fn use_recovery_code(stored: &str, code: &str) -> Option { let hash = hex::encode(blake3::hash(code.trim().as_bytes()).as_bytes()); let all: Vec<&str> = stored.split(',').filter(|s| !s.is_empty()).collect(); if !all.contains(&hash.as_str()) { return None; } Some(all.into_iter().filter(|h| *h != hash).collect::>().join(",")) } #[cfg(test)] mod tests { use super::*; #[test] fn rfc_6238_vector() { // RFC 6238 appendix B, SHA-1, secret "12345678901234567890", 59 s → 94287082 (8 digits); 6 digits are the last six. let secret = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, b"12345678901234567890"); assert_eq!(current(&secret, 59).unwrap(), "287082"); assert_eq!(current(&secret, 1111111109).unwrap(), "081804"); assert!(verify(&secret, "287082", 59)); assert!(verify(&secret, "287 082", 75), "drift of one step"); assert!(!verify(&secret, "000000", 59)); } #[test] fn recovery_codes() { let (codes, stored) = new_recovery_codes(); assert_eq!(codes.len(), 10); let rest = use_recovery_code(&stored, &codes[3]).unwrap(); assert!(use_recovery_code(&rest, &codes[3]).is_none(), "a code is single use"); assert!(use_recovery_code(&rest, &codes[4]).is_some()); assert!(qr_svg(&uri("OxygenHub", "andy", &new_secret())).unwrap().contains("