OXYGENHUB
# 8eae2f0e255c oxygenhub / crates / hub / src / auth / totp.rs
3.7 KiB · Rust · last changed Write the forge's name as OxygenHub in everything people read 1 month ago source blame history raw
1
//! Time-based one-time passwords (RFC 6238, SHA-1, six digits, 30 s) and
2
//! recovery codes (hub spec §15.3).
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
/// The current code, for tests and the enrolment check.
27
pub fn current(secret: &str, now_s: u64) -> Option<String> {
28
    code_at(secret, now_s / 30)
29
}
30
31
/// Accept the code for this step and its neighbours (one step of drift).
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
/// `otpauth://` URI for authenticator apps.
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
/// The provisioning URI as an SVG QR code.
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
/// Ten recovery codes; stored as BLAKE3 hashes separated by commas.
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
/// Use a recovery code: the remaining stored codes if it matched.
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
        // RFC 6238 appendix B, SHA-1, secret "12345678901234567890", 59 s → 94287082 (8 digits); 6 digits are the last six.
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());
89
        assert!(qr_svg(&uri("OxygenHub", "andy", &new_secret())).unwrap().contains("<svg"));
90
    }
91
}