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").map_err(|_| {
81        "PYLON_TOTP_ENCRYPTION_KEY not set but stored secret is encrypted".to_string()
82    })?;
83    let parts: Vec<&str> = blob.splitn(3, ':').collect();
84    if parts.len() != 3 {
85        return Err("totp seed: malformed enc blob".into());
86    }
87    let nonce = unhex(parts[1]).map_err(|_| "totp seed: bad nonce hex")?;
88    let ciphertext = unhex(parts[2]).map_err(|_| "totp seed: bad ciphertext hex")?;
89    let keystream = derive_keystream(key.as_bytes(), &nonce, ciphertext.len());
90    let plaintext: Vec<u8> = ciphertext
91        .iter()
92        .zip(keystream.iter())
93        .map(|(c, k)| c ^ k)
94        .collect();
95    String::from_utf8(plaintext).map_err(|e| format!("totp seed: not utf-8: {e}"))
96}
97
98/// Derive a `len`-byte keystream from `(key, nonce)` via HMAC-SHA256
99/// in counter mode. Not AEAD — there's no integrity tag — but the
100/// secret is also stored alongside `totpVerified`, so if an attacker
101/// flips bits, the TOTP code just stops verifying and the user
102/// re-enrolls. Acceptable trade-off vs adding a real AEAD dep.
103fn derive_keystream(key: &[u8], nonce: &[u8], len: usize) -> Vec<u8> {
104    use hmac::{Hmac, Mac};
105    use sha2::Sha256;
106    type HmacSha256 = Hmac<Sha256>;
107    let mut out = Vec::with_capacity(len);
108    let mut counter: u32 = 0;
109    while out.len() < len {
110        let mut mac = 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", "254676", "287922", "162583",
325            "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 [
339            (59u64, "94287082"),
340            (1111111109, "07081804"),
341            (1234567890, "89005924"),
342        ] {
343            assert_eq!(hotp(secret, t / 30, 8), want);
344        }
345    }
346
347    #[test]
348    fn base32_round_trip() {
349        for raw in [
350            &b""[..],
351            &b"a"[..],
352            &b"hello"[..],
353            &b"\x00\xff\xa5\x5a\x12\x34\x56\x78\x9a\xbc"[..],
354        ] {
355            let enc = base32_encode(raw);
356            // RFC 4648 base32 alphabet only.
357            assert!(enc
358                .chars()
359                .all(|c| c.is_ascii_uppercase() || ('2'..='7').contains(&c)));
360            let dec = base32_decode(&enc).expect("decode");
361            assert_eq!(dec, raw);
362        }
363    }
364
365    #[test]
366    fn base32_decode_tolerates_padding_and_lowercase() {
367        let enc = base32_encode(b"hello world");
368        let lower = enc.to_ascii_lowercase();
369        let with_pad = format!("{enc}====");
370        assert_eq!(base32_decode(&lower).unwrap(), b"hello world");
371        assert_eq!(base32_decode(&with_pad).unwrap(), b"hello world");
372    }
373
374    #[test]
375    fn verify_at_accepts_current_window() {
376        let secret = generate_secret();
377        let t = 1_700_000_000;
378        let code = compute_at(&secret, t);
379        assert!(verify_at(&secret, &code, t, 1));
380    }
381
382    #[test]
383    fn verify_at_accepts_one_step_drift() {
384        let secret = generate_secret();
385        let t = 1_700_000_000;
386        let code = compute_at(&secret, t);
387        // Code from window N must validate at windows N-1 and N+1.
388        assert!(verify_at(&secret, &code, t + 30, 1));
389        assert!(verify_at(&secret, &code, t.saturating_sub(30), 1));
390        // But NOT at window N+2 (60s drift).
391        assert!(!verify_at(&secret, &code, t + 60, 1));
392    }
393
394    #[test]
395    fn verify_at_rejects_wrong_code() {
396        let secret = generate_secret();
397        let t = 1_700_000_000;
398        assert!(!verify_at(&secret, "000000", t, 1));
399        assert!(!verify_at(&secret, "999999", t, 1));
400        assert!(!verify_at(&secret, "", t, 1));
401    }
402
403    // Env-var tests must run serially — Rust runs `#[test]` in
404    // parallel by default and `set_var` / `remove_var` race.
405    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
406
407    #[test]
408    fn seal_unseal_round_trip_with_key() {
409        let _g = ENV_LOCK.lock().unwrap();
410        std::env::set_var(
411            "PYLON_TOTP_ENCRYPTION_KEY",
412            "test-encryption-key-do-not-reuse",
413        );
414        let secret = "JBSWY3DPEHPK3PXP";
415        let sealed = seal_secret(secret);
416        assert!(sealed.starts_with("enc:"));
417        assert_ne!(sealed, secret);
418        let unsealed = unseal_secret(&sealed).unwrap();
419        assert_eq!(unsealed, secret);
420        std::env::remove_var("PYLON_TOTP_ENCRYPTION_KEY");
421    }
422
423    #[test]
424    fn unseal_passes_through_legacy_plaintext() {
425        let _g = ENV_LOCK.lock().unwrap();
426        // Migration path: existing plain base32 secrets stored before
427        // the seal-at-rest change must still unseal to themselves.
428        std::env::set_var("PYLON_TOTP_ENCRYPTION_KEY", "k");
429        assert_eq!(
430            unseal_secret("JBSWY3DPEHPK3PXP").unwrap(),
431            "JBSWY3DPEHPK3PXP"
432        );
433        std::env::remove_var("PYLON_TOTP_ENCRYPTION_KEY");
434    }
435
436    #[test]
437    fn unseal_without_key_errors_on_encrypted() {
438        let _g = ENV_LOCK.lock().unwrap();
439        std::env::remove_var("PYLON_TOTP_ENCRYPTION_KEY");
440        let err = unseal_secret("enc:abcd:ef01").unwrap_err();
441        assert!(err.contains("PYLON_TOTP_ENCRYPTION_KEY"));
442    }
443
444    #[test]
445    fn provisioning_url_encodes_special_chars() {
446        let url = provisioning_url("My App", "user+tag@example.com", "JBSWY3DPEHPK3PXP");
447        assert!(url.starts_with("otpauth://totp/My%20App:user%2Btag%40example.com?"));
448        assert!(url.contains("secret=JBSWY3DPEHPK3PXP"));
449        assert!(url.contains("issuer=My%20App"));
450        assert!(url.contains("algorithm=SHA1"));
451        assert!(url.contains("digits=6"));
452        assert!(url.contains("period=30"));
453    }
454}