Skip to main content

tablo_test/
two_factor.rs

1//! Two-factor sign-in from a test's side: the secret and recovery codes the settings page shows,
2//! and the code an authenticator app holding that secret would show.
3//!
4//! The code is computed here, apart from the panel's own check, so a test that signs in with it
5//! pins RFC 6238 rather than agreeing with itself.
6
7use data_encoding::BASE32_NOPAD;
8use hmac::{Hmac, KeyInit, Mac};
9use sha1::Sha1;
10
11use crate::protocol::dom::{attr_value, strip_text, tags_where};
12
13/// The authenticator key a set-up shows, in base32, or `None` when the page shows no set-up.
14pub fn two_factor_secret(html: &str) -> Option<String> {
15    let (_, _, inner) = tags_where(html, |_, attrs| {
16        attrs
17            .iter()
18            .any(|(name, _)| name == "data-two-factor-secret")
19    })
20    .into_iter()
21    .next()?;
22    Some(strip_text(&inner))
23}
24
25/// The recovery codes the page shows, once, after a set-up or a regeneration; empty when it
26/// shows none.
27pub fn recovery_codes(html: &str) -> Vec<String> {
28    let Some((_, _, inner)) = tags_where(html, |_, attrs| {
29        attrs.iter().any(|(name, _)| name == "data-recovery-codes")
30    })
31    .into_iter()
32    .next() else {
33        return Vec::new();
34    };
35    tags_where(&inner, |tag, _| tag == "li")
36        .into_iter()
37        .map(|(_, _, code)| strip_text(&code))
38        .collect()
39}
40
41/// Which view of the settings page renders: `off`, `setup` or `on`, as its form's
42/// `data-two-factor` names it; `None` when the page shows recovery codes instead.
43pub fn two_factor_state(html: &str) -> Option<String> {
44    tags_where(html, |tag, attrs| {
45        tag == "form" && attr_value(attrs, "data-two-factor").is_some()
46    })
47    .into_iter()
48    .next()
49    .and_then(|(_, attrs, _)| attr_value(&attrs, "data-two-factor"))
50}
51
52/// The six-digit code an authenticator app holding the base32 `secret` shows at `unix_secs`.
53///
54/// # Panics
55///
56/// When `secret` is not base32.
57pub fn authenticator_code_at(secret: &str, unix_secs: u64) -> String {
58    let key = BASE32_NOPAD
59        .decode(secret.trim().as_bytes())
60        .expect("the secret is base32");
61    let mut mac = Hmac::<Sha1>::new_from_slice(&key).expect("HMAC takes a key of any length");
62    mac.update(&(unix_secs / 30).to_be_bytes());
63    let digest = mac.finalize().into_bytes();
64    let offset = usize::from(digest[19] & 0x0f);
65    let binary = u32::from_be_bytes([
66        digest[offset] & 0x7f,
67        digest[offset + 1],
68        digest[offset + 2],
69        digest[offset + 3],
70    ]);
71    format!("{:06}", binary % 1_000_000)
72}
73
74/// The code an authenticator app holding `secret` shows now.
75pub fn authenticator_code(secret: &str) -> String {
76    authenticator_code_at(secret, now())
77}
78
79/// Seconds since the Unix epoch.
80pub fn now() -> u64 {
81    std::time::SystemTime::now()
82        .duration_since(std::time::UNIX_EPOCH)
83        .expect("the clock is after 1970")
84        .as_secs()
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    /// The RFC 6238 SHA-1 vectors, truncated to six digits.
92    #[test]
93    fn the_code_matches_rfc_6238() {
94        let secret = BASE32_NOPAD.encode(b"12345678901234567890");
95        assert_eq!(authenticator_code_at(&secret, 59), "287082");
96        assert_eq!(authenticator_code_at(&secret, 1_111_111_109), "081804");
97        assert_eq!(authenticator_code_at(&secret, 2_000_000_000), "279037");
98    }
99}