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