Skip to main content

pylon_auth/
totp.rs

1//! TOTP (RFC 6238) — time-based one-time passwords for two-factor auth.
2//!
3//! Standard 6-digit, 30-second window, HMAC-SHA1 — the format every
4//! authenticator app expects (Google Authenticator, 1Password, Authy,
5//! Bitwarden, Apple Passwords, etc.). Verification accepts the
6//! current window plus ±1 window of clock drift, matching the de
7//! facto standard tolerance.
8//!
9//! Wire format:
10//!   - **Secret**: 20 random bytes, base32-encoded (no padding) for
11//!     the QR/provisioning URL. Authenticator apps consume base32
12//!     uppercase alphanumeric — no `=` padding.
13//!   - **Provisioning URL**: `otpauth://totp/<issuer>:<account>?secret=<base32>&issuer=<issuer>`
14//!     — what you encode into a QR code or pass to the user's app
15//!     via deep link.
16//!
17//! Storage shape — pylon stores ONE secret per user along with a
18//! `verified: bool` flag. Enrollment is two-step: generate secret +
19//! show QR, then user posts a code to confirm they scanned it. Only
20//! after confirmation does TOTP gate subsequent logins.
21//!
22//! See `crates/router/src/routes/auth.rs` for the endpoints:
23//!   - POST /api/auth/totp/enroll      → returns secret + URL (NOT verified yet)
24//!   - POST /api/auth/totp/verify      → confirm enrollment with first code
25//!   - POST /api/auth/totp/disable     → revoke (requires current code)
26//!   - POST /api/auth/totp/challenge   → step 2 of login when 2FA enrolled
27
28use hmac::{Hmac, Mac};
29use sha1::Sha1;
30use std::time::{SystemTime, UNIX_EPOCH};
31
32type HmacSha1 = Hmac<Sha1>;
33
34// ---------------------------------------------------------------------------
35// At-rest encryption for TOTP secrets
36// ---------------------------------------------------------------------------
37//
38// TOTP secrets are 2FA seeds — one DB dump leaks every user's 2FA
39// indefinitely. We encrypt them with HMAC-SHA256 stream-cipher style
40// (no AEAD dep) keyed off `PYLON_TOTP_ENCRYPTION_KEY`. The encrypted
41// blob is what gets stored on the User row's `totpSecret` field.
42//
43// Output format: `enc:<nonce-hex>:<ciphertext-hex>`. Plain base32
44// secrets without the `enc:` prefix are still accepted on read for
45// migration — apps with existing plaintext seeds keep working until
46// the user re-enrolls.
47
48/// Encrypt a base32-encoded secret for at-rest storage. Stamps the
49/// `enc:` prefix so reads can distinguish encrypted from legacy.
50/// Apps that haven't set `PYLON_TOTP_ENCRYPTION_KEY` get the plain
51/// base32 back with a `tracing::warn!` once per process — better
52/// than refusing TOTP entirely.
53pub fn seal_secret(secret_b32: &str) -> String {
54    let key = match std::env::var("PYLON_TOTP_ENCRYPTION_KEY") {
55        Ok(k) if !k.is_empty() => k,
56        _ => {
57            warn_once();
58            return secret_b32.to_string();
59        }
60    };
61    use rand::RngCore;
62    let mut nonce = [0u8; 16];
63    rand::thread_rng().fill_bytes(&mut nonce);
64    let plaintext = secret_b32.as_bytes();
65    let keystream = derive_keystream(key.as_bytes(), &nonce, plaintext.len());
66    let ciphertext: Vec<u8> = plaintext
67        .iter()
68        .zip(keystream.iter())
69        .map(|(p, k)| p ^ k)
70        .collect();
71    format!("enc:{}:{}", hex(&nonce), hex(&ciphertext))
72}
73
74/// Reverse of [`seal_secret`]. Accepts both `enc:…` blobs and
75/// legacy plain base32 (returned as-is).
76pub fn unseal_secret(blob: &str) -> Result<String, String> {
77    if !blob.starts_with("enc:") {
78        return Ok(blob.to_string());
79    }
80    let key = std::env::var("PYLON_TOTP_ENCRYPTION_KEY")
81        .map_err(|_| "PYLON_TOTP_ENCRYPTION_KEY not set but stored secret is encrypted".to_string())?;
82    let parts: Vec<&str> = blob.splitn(3, ':').collect();
83    if parts.len() != 3 {
84        return Err("totp seed: malformed enc blob".into());
85    }
86    let nonce = unhex(parts[1]).map_err(|_| "totp seed: bad nonce hex")?;
87    let ciphertext = unhex(parts[2]).map_err(|_| "totp seed: bad ciphertext hex")?;
88    let keystream = derive_keystream(key.as_bytes(), &nonce, ciphertext.len());
89    let plaintext: Vec<u8> = ciphertext
90        .iter()
91        .zip(keystream.iter())
92        .map(|(c, k)| c ^ k)
93        .collect();
94    String::from_utf8(plaintext).map_err(|e| format!("totp seed: not utf-8: {e}"))
95}
96
97/// Derive a `len`-byte keystream from `(key, nonce)` via HMAC-SHA256
98/// in counter mode. Not AEAD — there's no integrity tag — but the
99/// secret is also stored alongside `totpVerified`, so if an attacker
100/// flips bits, the TOTP code just stops verifying and the user
101/// re-enrolls. Acceptable trade-off vs adding a real AEAD dep.
102fn derive_keystream(key: &[u8], nonce: &[u8], len: usize) -> Vec<u8> {
103    use hmac::{Hmac, Mac};
104    use sha2::Sha256;
105    type HmacSha256 = Hmac<Sha256>;
106    let mut out = Vec::with_capacity(len);
107    let mut counter: u32 = 0;
108    while out.len() < len {
109        let mut mac =
110            HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
111        mac.update(nonce);
112        mac.update(&counter.to_be_bytes());
113        let block = mac.finalize().into_bytes();
114        out.extend_from_slice(&block);
115        counter += 1;
116    }
117    out.truncate(len);
118    out
119}
120
121fn warn_once() {
122    use std::sync::Once;
123    static ONCE: Once = Once::new();
124    ONCE.call_once(|| {
125        tracing::warn!(
126            "[totp] PYLON_TOTP_ENCRYPTION_KEY is not set — 2FA seeds stored unencrypted. \
127             Set this env var to a 32+ random byte value to encrypt at rest."
128        );
129    });
130}
131
132fn hex(b: &[u8]) -> String {
133    use std::fmt::Write;
134    let mut s = String::with_capacity(b.len() * 2);
135    for x in b {
136        let _ = write!(s, "{x:02x}");
137    }
138    s
139}
140
141fn unhex(s: &str) -> Result<Vec<u8>, ()> {
142    if s.len() % 2 != 0 {
143        return Err(());
144    }
145    let mut out = Vec::with_capacity(s.len() / 2);
146    for chunk in s.as_bytes().chunks(2) {
147        let hi = match chunk[0] {
148            b'0'..=b'9' => chunk[0] - b'0',
149            b'a'..=b'f' => chunk[0] - b'a' + 10,
150            b'A'..=b'F' => chunk[0] - b'A' + 10,
151            _ => return Err(()),
152        };
153        let lo = match chunk[1] {
154            b'0'..=b'9' => chunk[1] - b'0',
155            b'a'..=b'f' => chunk[1] - b'a' + 10,
156            b'A'..=b'F' => chunk[1] - b'A' + 10,
157            _ => return Err(()),
158        };
159        out.push((hi << 4) | lo);
160    }
161    Ok(out)
162}
163
164/// 30-second window per RFC 6238 — the universally implemented choice.
165pub const TOTP_PERIOD_SECS: u64 = 30;
166
167/// 6 digits per RFC 6238 — what every authenticator app shows.
168pub const TOTP_DIGITS: u32 = 6;
169
170/// Generate a fresh TOTP secret (20 random bytes — RFC 4226 §4
171/// recommends ≥ 128 bits; 160 is the SHA-1 block size and the
172/// industry default).
173pub fn generate_secret() -> Vec<u8> {
174    use rand::RngCore;
175    let mut bytes = vec![0u8; 20];
176    rand::thread_rng().fill_bytes(&mut bytes);
177    bytes
178}
179
180/// Encode a secret into the base32 form authenticator apps expect.
181/// RFC 4648 base32 alphabet (uppercase A-Z + 2-7), NO padding.
182pub fn base32_encode(bytes: &[u8]) -> String {
183    const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
184    let mut out = String::with_capacity((bytes.len() * 8 + 4) / 5);
185    let mut buf: u32 = 0;
186    let mut bits: u8 = 0;
187    for &b in bytes {
188        buf = (buf << 8) | b as u32;
189        bits += 8;
190        while bits >= 5 {
191            bits -= 5;
192            let idx = ((buf >> bits) & 0x1F) as usize;
193            out.push(ALPHA[idx] as char);
194        }
195    }
196    if bits > 0 {
197        let idx = ((buf << (5 - bits)) & 0x1F) as usize;
198        out.push(ALPHA[idx] as char);
199    }
200    out
201}
202
203/// Decode a base32 string back to bytes. Tolerates lowercase + `=`
204/// padding so users can paste a secret in either form.
205pub fn base32_decode(input: &str) -> Result<Vec<u8>, String> {
206    let mut out = Vec::with_capacity(input.len() * 5 / 8);
207    let mut buf: u32 = 0;
208    let mut bits: u8 = 0;
209    for ch in input.chars() {
210        if ch == '=' || ch.is_whitespace() {
211            continue;
212        }
213        let v = match ch.to_ascii_uppercase() {
214            c @ 'A'..='Z' => (c as u32) - ('A' as u32),
215            c @ '2'..='7' => (c as u32) - ('2' as u32) + 26,
216            c => return Err(format!("base32: illegal char {c:?}")),
217        };
218        buf = (buf << 5) | v;
219        bits += 5;
220        if bits >= 8 {
221            bits -= 8;
222            out.push(((buf >> bits) & 0xFF) as u8);
223        }
224    }
225    Ok(out)
226}
227
228/// Build the provisioning URL the authenticator app consumes.
229/// `account` is typically the user's email; `issuer` is the app
230/// name. Both are URL-encoded so spaces / special chars work.
231///
232/// Format: `otpauth://totp/<issuer>:<account>?secret=<base32>&issuer=<issuer>&algorithm=SHA1&digits=6&period=30`
233pub fn provisioning_url(issuer: &str, account: &str, secret_b32: &str) -> String {
234    let issuer_enc = url_encode(issuer);
235    let account_enc = url_encode(account);
236    format!(
237        "otpauth://totp/{issuer_enc}:{account_enc}?secret={secret_b32}&issuer={issuer_enc}&algorithm=SHA1&digits=6&period=30"
238    )
239}
240
241/// Compute the TOTP code for a given secret + Unix-epoch second.
242/// Pure function — no clock access, so tests can pin the time.
243pub fn compute_at(secret: &[u8], unix_seconds: u64) -> String {
244    let counter = unix_seconds / TOTP_PERIOD_SECS;
245    hotp(secret, counter, TOTP_DIGITS)
246}
247
248/// Compute the current TOTP code (uses system clock).
249pub fn compute_now(secret: &[u8]) -> String {
250    let now = SystemTime::now()
251        .duration_since(UNIX_EPOCH)
252        .map(|d| d.as_secs())
253        .unwrap_or(0);
254    compute_at(secret, now)
255}
256
257/// Verify a code against the current window ± 1 step (90s of drift
258/// tolerance total). Constant-time comparison so a wrong-byte-at-
259/// position-N attacker can't time-side-channel the right code.
260///
261/// Returns `true` iff the code matches the current, previous, or
262/// next window.
263pub fn verify_now(secret: &[u8], code: &str) -> bool {
264    let now = SystemTime::now()
265        .duration_since(UNIX_EPOCH)
266        .map(|d| d.as_secs())
267        .unwrap_or(0);
268    verify_at(secret, code, now, 1)
269}
270
271/// Verify with explicit time + window-tolerance for tests / replay
272/// detection. `window` is the number of ±steps to allow (typically 1).
273pub fn verify_at(secret: &[u8], code: &str, unix_seconds: u64, window: i64) -> bool {
274    let counter = (unix_seconds / TOTP_PERIOD_SECS) as i64;
275    for delta in -window..=window {
276        let c = (counter + delta).max(0) as u64;
277        let expected = hotp(secret, c, TOTP_DIGITS);
278        if crate::constant_time_eq(expected.as_bytes(), code.as_bytes()) {
279            return true;
280        }
281    }
282    false
283}
284
285/// HOTP (RFC 4226) — the building block TOTP wraps. Public so apps
286/// that want raw HOTP (counter-based) can use it directly.
287pub fn hotp(secret: &[u8], counter: u64, digits: u32) -> String {
288    let mut mac = HmacSha1::new_from_slice(secret).expect("HMAC accepts any key length");
289    mac.update(&counter.to_be_bytes());
290    let result = mac.finalize().into_bytes();
291    // RFC 4226 §5.3 — dynamic truncation.
292    let offset = (result[result.len() - 1] & 0x0f) as usize;
293    let bin = ((result[offset] as u32 & 0x7f) << 24)
294        | ((result[offset + 1] as u32) << 16)
295        | ((result[offset + 2] as u32) << 8)
296        | (result[offset + 3] as u32);
297    let code = bin % 10u32.pow(digits);
298    format!("{:0>width$}", code, width = digits as usize)
299}
300
301fn url_encode(s: &str) -> String {
302    let mut out = String::with_capacity(s.len());
303    for b in s.bytes() {
304        match b {
305            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
306                out.push(b as char)
307            }
308            _ => out.push_str(&format!("%{b:02X}")),
309        }
310    }
311    out
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    /// RFC 4226 Appendix D test vector — secret = "12345678901234567890",
319    /// counter sequence 0..10, expected codes are well-known.
320    #[test]
321    fn hotp_matches_rfc4226_vectors() {
322        let secret = b"12345678901234567890";
323        let expected = [
324            "755224", "287082", "359152", "969429", "338314",
325            "254676", "287922", "162583", "399871", "520489",
326        ];
327        for (i, want) in expected.iter().enumerate() {
328            assert_eq!(hotp(secret, i as u64, 6), *want, "counter {i}");
329        }
330    }
331
332    /// RFC 6238 Appendix B vectors — TOTP at fixed seconds.
333    /// Secret = "12345678901234567890" (SHA-1 variant), digits = 8.
334    #[test]
335    fn totp_matches_rfc6238_vectors() {
336        let secret = b"12345678901234567890";
337        // (epoch_secs, expected_8_digit_code)
338        for (t, want) in [(59u64, "94287082"), (1111111109, "07081804"), (1234567890, "89005924")] {
339            assert_eq!(hotp(secret, t / 30, 8), want);
340        }
341    }
342
343    #[test]
344    fn base32_round_trip() {
345        for raw in [
346            &b""[..],
347            &b"a"[..],
348            &b"hello"[..],
349            &b"\x00\xff\xa5\x5a\x12\x34\x56\x78\x9a\xbc"[..],
350        ] {
351            let enc = base32_encode(raw);
352            // RFC 4648 base32 alphabet only.
353            assert!(enc.chars().all(|c| c.is_ascii_uppercase() || ('2'..='7').contains(&c)));
354            let dec = base32_decode(&enc).expect("decode");
355            assert_eq!(dec, raw);
356        }
357    }
358
359    #[test]
360    fn base32_decode_tolerates_padding_and_lowercase() {
361        let enc = base32_encode(b"hello world");
362        let lower = enc.to_ascii_lowercase();
363        let with_pad = format!("{enc}====");
364        assert_eq!(base32_decode(&lower).unwrap(), b"hello world");
365        assert_eq!(base32_decode(&with_pad).unwrap(), b"hello world");
366    }
367
368    #[test]
369    fn verify_at_accepts_current_window() {
370        let secret = generate_secret();
371        let t = 1_700_000_000;
372        let code = compute_at(&secret, t);
373        assert!(verify_at(&secret, &code, t, 1));
374    }
375
376    #[test]
377    fn verify_at_accepts_one_step_drift() {
378        let secret = generate_secret();
379        let t = 1_700_000_000;
380        let code = compute_at(&secret, t);
381        // Code from window N must validate at windows N-1 and N+1.
382        assert!(verify_at(&secret, &code, t + 30, 1));
383        assert!(verify_at(&secret, &code, t.saturating_sub(30), 1));
384        // But NOT at window N+2 (60s drift).
385        assert!(!verify_at(&secret, &code, t + 60, 1));
386    }
387
388    #[test]
389    fn verify_at_rejects_wrong_code() {
390        let secret = generate_secret();
391        let t = 1_700_000_000;
392        assert!(!verify_at(&secret, "000000", t, 1));
393        assert!(!verify_at(&secret, "999999", t, 1));
394        assert!(!verify_at(&secret, "", t, 1));
395    }
396
397    // Env-var tests must run serially — Rust runs `#[test]` in
398    // parallel by default and `set_var` / `remove_var` race.
399    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
400
401    #[test]
402    fn seal_unseal_round_trip_with_key() {
403        let _g = ENV_LOCK.lock().unwrap();
404        std::env::set_var("PYLON_TOTP_ENCRYPTION_KEY", "test-encryption-key-do-not-reuse");
405        let secret = "JBSWY3DPEHPK3PXP";
406        let sealed = seal_secret(secret);
407        assert!(sealed.starts_with("enc:"));
408        assert_ne!(sealed, secret);
409        let unsealed = unseal_secret(&sealed).unwrap();
410        assert_eq!(unsealed, secret);
411        std::env::remove_var("PYLON_TOTP_ENCRYPTION_KEY");
412    }
413
414    #[test]
415    fn unseal_passes_through_legacy_plaintext() {
416        let _g = ENV_LOCK.lock().unwrap();
417        // Migration path: existing plain base32 secrets stored before
418        // the seal-at-rest change must still unseal to themselves.
419        std::env::set_var("PYLON_TOTP_ENCRYPTION_KEY", "k");
420        assert_eq!(unseal_secret("JBSWY3DPEHPK3PXP").unwrap(), "JBSWY3DPEHPK3PXP");
421        std::env::remove_var("PYLON_TOTP_ENCRYPTION_KEY");
422    }
423
424    #[test]
425    fn unseal_without_key_errors_on_encrypted() {
426        let _g = ENV_LOCK.lock().unwrap();
427        std::env::remove_var("PYLON_TOTP_ENCRYPTION_KEY");
428        let err = unseal_secret("enc:abcd:ef01").unwrap_err();
429        assert!(err.contains("PYLON_TOTP_ENCRYPTION_KEY"));
430    }
431
432    #[test]
433    fn provisioning_url_encodes_special_chars() {
434        let url = provisioning_url("My App", "user+tag@example.com", "JBSWY3DPEHPK3PXP");
435        assert!(url.starts_with("otpauth://totp/My%20App:user%2Btag%40example.com?"));
436        assert!(url.contains("secret=JBSWY3DPEHPK3PXP"));
437        assert!(url.contains("issuer=My%20App"));
438        assert!(url.contains("algorithm=SHA1"));
439        assert!(url.contains("digits=6"));
440        assert!(url.contains("period=30"));
441    }
442}