Skip to main content

tsafe_core/
totp.rs

1//! TOTP (Time-based One-Time Password) — RFC 6238 code generation and secret management.
2//!
3//! Secrets are stored in the vault as base32-encoded TOTP seeds.  All intermediate
4//! strings holding the decoded secret use [`Zeroizing`] to ensure the raw bytes are
5//! wiped from heap memory when they go out of scope.
6
7use hmac::{Hmac, Mac};
8use sha1::Sha1;
9use sha2::{Sha256, Sha512};
10use zeroize::Zeroizing;
11
12use crate::errors::{SafeError, SafeResult};
13
14type HmacSha1 = Hmac<Sha1>;
15type HmacSha256 = Hmac<Sha256>;
16type HmacSha512 = Hmac<Sha512>;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19enum TotpAlgorithm {
20    Sha1,
21    Sha256,
22    Sha512,
23}
24
25impl TotpAlgorithm {
26    fn parse(value: &str) -> SafeResult<Self> {
27        match value.to_ascii_uppercase().as_str() {
28            "SHA1" | "SHA-1" => Ok(Self::Sha1),
29            "SHA256" | "SHA-256" => Ok(Self::Sha256),
30            "SHA512" | "SHA-512" => Ok(Self::Sha512),
31            other => Err(SafeError::InvalidVault {
32                reason: format!("unsupported TOTP algorithm '{other}'"),
33            }),
34        }
35    }
36
37    fn as_uri_str(self) -> &'static str {
38        match self {
39            Self::Sha1 => "SHA1",
40            Self::Sha256 => "SHA256",
41            Self::Sha512 => "SHA512",
42        }
43    }
44}
45
46#[derive(Debug)]
47struct TotpConfig {
48    secret: Zeroizing<String>,
49    algorithm: TotpAlgorithm,
50    digits: u32,
51    period: u64,
52}
53
54impl TotpConfig {
55    fn default_for_secret(secret: String) -> Self {
56        Self {
57            secret: Zeroizing::new(secret),
58            algorithm: TotpAlgorithm::Sha1,
59            digits: 6,
60            period: 30,
61        }
62    }
63}
64
65/// Parse base32 secret from either a raw base32 string or an otpauth:// URI.
66/// Returns bare base32 string (uppercase, no spaces), wrapped in `Zeroizing`
67/// so the secret is wiped from memory when the caller drops it.
68pub fn extract_base32(input: &str) -> SafeResult<Zeroizing<String>> {
69    Ok(parse_totp_config(input)?.secret)
70}
71
72/// Build a canonical `otpauth://` URI from raw base32 or pasted URI input.
73///
74/// Existing URI algorithm, digits, and period parameters are preserved unless
75/// the caller supplies an explicit override.
76pub fn provisioning_uri(
77    label: &str,
78    input: &str,
79    algorithm: Option<&str>,
80    digits: Option<u32>,
81    period: Option<u64>,
82) -> SafeResult<String> {
83    let mut config = parse_totp_config(input)?;
84    if let Some(algorithm) = algorithm {
85        config.algorithm = TotpAlgorithm::parse(algorithm)?;
86    }
87    if let Some(digits) = digits {
88        config.digits = validate_digits(digits)?;
89    }
90    if let Some(period) = period {
91        config.period = validate_period(period)?;
92    }
93
94    Ok(format!(
95        "otpauth://totp/{label}?secret={}&algorithm={}&digits={}&period={}",
96        config.secret.as_str(),
97        config.algorithm.as_uri_str(),
98        config.digits,
99        config.period
100    ))
101}
102
103/// Compute the current TOTP code for a raw base32 secret or `otpauth://` URI.
104///
105/// Raw base32 input uses the common SHA1 / 6 digits / 30 seconds profile.
106/// `otpauth://` input honors `algorithm`, `digits`, and `period` query
107/// parameters, defaulting omitted values to SHA1 / 6 / 30.
108pub fn generate_code(input: &str) -> SafeResult<String> {
109    generate_code_at(input, unix_timestamp())
110}
111
112/// Compute a TOTP code at a specific Unix timestamp.
113///
114/// This deterministic API is useful for tests and non-wall-clock consumers.
115pub fn generate_code_at(input: &str, timestamp: u64) -> SafeResult<String> {
116    let config = parse_totp_config(input)?;
117    let key_bytes = decode_base32(&config.secret)?;
118    let counter = timestamp / config.period;
119    let code = hotp(&key_bytes, counter, config.digits, config.algorithm)?;
120    Ok(format_code(code, config.digits))
121}
122
123/// Seconds remaining in the current 30-second TOTP window.
124pub fn seconds_remaining() -> u64 {
125    let ts = unix_timestamp();
126    30 - (ts % 30)
127}
128
129/// Seconds remaining in the current TOTP window for a raw base32 secret or URI.
130pub fn seconds_remaining_for(input: &str) -> SafeResult<u64> {
131    seconds_remaining_for_at(input, unix_timestamp())
132}
133
134/// Seconds remaining in a TOTP window for a specific Unix timestamp.
135pub fn seconds_remaining_for_at(input: &str, timestamp: u64) -> SafeResult<u64> {
136    let config = parse_totp_config(input)?;
137    Ok(config.period - (timestamp % config.period))
138}
139
140// ── internal ──────────────────────────────────────────────────────────────────
141
142fn unix_timestamp() -> u64 {
143    std::time::SystemTime::now()
144        .duration_since(std::time::UNIX_EPOCH)
145        .map(|d| d.as_secs())
146        .unwrap_or(0)
147}
148
149fn decode_base32(s: &str) -> SafeResult<Vec<u8>> {
150    // Try without padding first, then with padding.
151    let bytes = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, s)
152        .or_else(|| base32::decode(base32::Alphabet::Rfc4648 { padding: true }, s))
153        .ok_or_else(|| SafeError::InvalidVault {
154            reason: "invalid TOTP base32 secret".into(),
155        })?;
156    // An empty input decodes to zero bytes, which is a degenerate HMAC key that
157    // still "works" — reject it so an empty seed cannot be stored.
158    if bytes.is_empty() {
159        return Err(SafeError::InvalidVault {
160            reason: "TOTP secret must not be empty".into(),
161        });
162    }
163    Ok(bytes)
164}
165
166fn parse_totp_config(input: &str) -> SafeResult<TotpConfig> {
167    if input.starts_with("otpauth://") {
168        parse_otpauth_uri(input)
169    } else {
170        let normalised = normalise_base32(input);
171        decode_base32(&normalised)?;
172        Ok(TotpConfig::default_for_secret(normalised))
173    }
174}
175
176fn parse_otpauth_uri(input: &str) -> SafeResult<TotpConfig> {
177    let query_start = input.find('?').ok_or_else(|| SafeError::InvalidVault {
178        reason: "otpauth:// URI has no query string".into(),
179    })?;
180    let query = &input[query_start + 1..];
181
182    let mut secret = None;
183    let mut algorithm = TotpAlgorithm::Sha1;
184    let mut digits = 6;
185    let mut period = 30;
186
187    for pair in query.split('&') {
188        let Some((key, value)) = pair.split_once('=') else {
189            continue;
190        };
191        let value = decode_query_component(value)?;
192        match key.to_ascii_lowercase().as_str() {
193            "secret" if secret.is_none() => {
194                let normalised = normalise_base32(&value);
195                decode_base32(&normalised)?;
196                secret = Some(Zeroizing::new(normalised));
197            }
198            "algorithm" => {
199                algorithm = TotpAlgorithm::parse(&value)?;
200            }
201            "digits" => {
202                digits = parse_digits(&value)?;
203            }
204            "period" => {
205                period = parse_period(&value)?;
206            }
207            _ => {}
208        }
209    }
210
211    let secret = secret.ok_or_else(|| SafeError::InvalidVault {
212        reason: "otpauth:// URI is missing the 'secret' parameter".into(),
213    })?;
214
215    Ok(TotpConfig {
216        secret,
217        algorithm,
218        digits,
219        period,
220    })
221}
222
223fn normalise_base32(raw: &str) -> String {
224    raw.chars()
225        .filter(|c| !c.is_whitespace() && *c != '-')
226        .map(|c| c.to_ascii_uppercase())
227        .collect()
228}
229
230fn decode_query_component(input: &str) -> SafeResult<String> {
231    let bytes = input.as_bytes();
232    let mut out = Vec::with_capacity(bytes.len());
233    let mut i = 0;
234    while i < bytes.len() {
235        match bytes[i] {
236            b'%' if i + 2 < bytes.len() => {
237                let high = from_hex(bytes[i + 1])?;
238                let low = from_hex(bytes[i + 2])?;
239                out.push((high << 4) | low);
240                i += 3;
241            }
242            b'%' => {
243                return Err(SafeError::InvalidVault {
244                    reason: "invalid percent encoding in otpauth:// URI".into(),
245                });
246            }
247            b'+' => {
248                out.push(b' ');
249                i += 1;
250            }
251            byte => {
252                out.push(byte);
253                i += 1;
254            }
255        }
256    }
257    String::from_utf8(out).map_err(|e| SafeError::InvalidVault {
258        reason: format!("otpauth:// URI parameter is not UTF-8: {e}"),
259    })
260}
261
262fn from_hex(byte: u8) -> SafeResult<u8> {
263    match byte {
264        b'0'..=b'9' => Ok(byte - b'0'),
265        b'a'..=b'f' => Ok(byte - b'a' + 10),
266        b'A'..=b'F' => Ok(byte - b'A' + 10),
267        _ => Err(SafeError::InvalidVault {
268            reason: "invalid percent encoding in otpauth:// URI".into(),
269        }),
270    }
271}
272
273fn parse_digits(value: &str) -> SafeResult<u32> {
274    let digits = value.parse::<u32>().map_err(|_| SafeError::InvalidVault {
275        reason: format!("invalid TOTP digits '{value}'"),
276    })?;
277    validate_digits(digits)
278}
279
280fn validate_digits(digits: u32) -> SafeResult<u32> {
281    if (1..=10).contains(&digits) {
282        Ok(digits)
283    } else {
284        Err(SafeError::InvalidVault {
285            reason: "TOTP digits must be between 1 and 10".into(),
286        })
287    }
288}
289
290fn parse_period(value: &str) -> SafeResult<u64> {
291    let period = value.parse::<u64>().map_err(|_| SafeError::InvalidVault {
292        reason: format!("invalid TOTP period '{value}'"),
293    })?;
294    validate_period(period)
295}
296
297fn validate_period(period: u64) -> SafeResult<u64> {
298    if period > 0 {
299        Ok(period)
300    } else {
301        Err(SafeError::InvalidVault {
302            reason: "TOTP period must be at least 1 second".into(),
303        })
304    }
305}
306
307/// RFC 4226 HOTP: HMAC + dynamic truncation.
308fn hotp(key: &[u8], counter: u64, digits: u32, algorithm: TotpAlgorithm) -> SafeResult<u64> {
309    let counter_bytes = counter.to_be_bytes();
310    let result = hmac_digest(key, &counter_bytes, algorithm)?;
311
312    let offset = (result[result.len() - 1] & 0x0f) as usize;
313    let code = u32::from_be_bytes([
314        result[offset] & 0x7f,
315        result[offset + 1],
316        result[offset + 2],
317        result[offset + 3],
318    ]);
319
320    let modulus = 10u64.pow(digits);
321    Ok(u64::from(code) % modulus)
322}
323
324fn hmac_digest(key: &[u8], counter_bytes: &[u8], algorithm: TotpAlgorithm) -> SafeResult<Vec<u8>> {
325    match algorithm {
326        TotpAlgorithm::Sha1 => {
327            let mut mac = HmacSha1::new_from_slice(key).map_err(hmac_key_error)?;
328            mac.update(counter_bytes);
329            Ok(mac.finalize().into_bytes().to_vec())
330        }
331        TotpAlgorithm::Sha256 => {
332            let mut mac = HmacSha256::new_from_slice(key).map_err(hmac_key_error)?;
333            mac.update(counter_bytes);
334            Ok(mac.finalize().into_bytes().to_vec())
335        }
336        TotpAlgorithm::Sha512 => {
337            let mut mac = HmacSha512::new_from_slice(key).map_err(hmac_key_error)?;
338            mac.update(counter_bytes);
339            Ok(mac.finalize().into_bytes().to_vec())
340        }
341    }
342}
343
344fn hmac_key_error(e: hmac::digest::InvalidLength) -> SafeError {
345    SafeError::InvalidVault {
346        reason: format!("HMAC key error: {e}"),
347    }
348}
349
350fn format_code(code: u64, digits: u32) -> String {
351    format!("{code:0>width$}", width = digits as usize)
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    /// Well-known base32 secret from RFC 6238 test vectors.
359    const KNOWN_B32: &str = "JBSWY3DPEHPK3PXP";
360
361    // ── extract_base32 ────────────────────────────────────────────────────────
362
363    #[test]
364    fn extract_base32_plain_returns_normalised() {
365        let result = extract_base32(KNOWN_B32).unwrap();
366        assert_eq!(*result, KNOWN_B32);
367    }
368
369    #[test]
370    fn extract_base32_lowercase_is_normalised_to_upper() {
371        let result = extract_base32(&KNOWN_B32.to_lowercase()).unwrap();
372        assert_eq!(*result, KNOWN_B32);
373    }
374
375    #[test]
376    fn extract_base32_strips_spaces_and_hyphens() {
377        // Authenticator apps often display secrets with spaces or hyphens for readability.
378        let spaced = "JBSWY 3DP-EHPK 3PXP";
379        let result = extract_base32(spaced).unwrap();
380        assert_eq!(*result, KNOWN_B32);
381    }
382
383    #[test]
384    fn extract_base32_parses_otpauth_uri() {
385        let uri = format!("otpauth://totp/Alice?secret={KNOWN_B32}&issuer=Example");
386        let result = extract_base32(&uri).unwrap();
387        assert_eq!(*result, KNOWN_B32);
388    }
389
390    #[test]
391    fn extract_base32_otpauth_uri_secret_case_insensitive_param_name() {
392        let uri = format!("otpauth://totp/Alice?SECRET={KNOWN_B32}");
393        let result = extract_base32(&uri).unwrap();
394        assert_eq!(*result, KNOWN_B32);
395    }
396
397    #[test]
398    fn extract_base32_otpauth_uri_missing_query_string_errors() {
399        let result = extract_base32("otpauth://totp/Alice");
400        assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
401    }
402
403    #[test]
404    fn extract_base32_otpauth_uri_missing_secret_param_errors() {
405        let result = extract_base32("otpauth://totp/Alice?issuer=Example");
406        assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
407    }
408
409    #[test]
410    fn extract_base32_invalid_base32_chars_errors() {
411        let result = extract_base32("!!!NOT-VALID-BASE32!!!");
412        assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
413    }
414
415    #[test]
416    fn provisioning_uri_preserves_otpauth_parameters() {
417        let seed = encode_base32(b"12345678901234567890123456789012");
418        let input =
419            format!("otpauth://totp/Alice?secret={seed}&algorithm=SHA256&digits=8&period=60");
420
421        let uri = provisioning_uri("GITHUB_2FA", &input, None, None, None).unwrap();
422
423        assert_eq!(
424            uri,
425            format!("otpauth://totp/GITHUB_2FA?secret={seed}&algorithm=SHA256&digits=8&period=60")
426        );
427    }
428
429    #[test]
430    fn provisioning_uri_overrides_otpauth_parameters() {
431        let seed = encode_base32(b"12345678901234567890123456789012");
432        let input =
433            format!("otpauth://totp/Alice?secret={seed}&algorithm=SHA256&digits=8&period=60");
434
435        let uri = provisioning_uri("GITHUB_2FA", &input, Some("SHA1"), Some(6), Some(30)).unwrap();
436
437        assert_eq!(
438            uri,
439            format!("otpauth://totp/GITHUB_2FA?secret={seed}&algorithm=SHA1&digits=6&period=30")
440        );
441    }
442
443    // ── generate_code ─────────────────────────────────────────────────────────
444
445    #[test]
446    fn generate_code_returns_six_digit_string() {
447        let code = generate_code(KNOWN_B32).unwrap();
448        assert_eq!(
449            code.len(),
450            6,
451            "TOTP code must be exactly 6 chars, got {code:?}"
452        );
453        assert!(
454            code.chars().all(|c| c.is_ascii_digit()),
455            "TOTP code must be all digits, got {code:?}"
456        );
457    }
458
459    #[test]
460    fn generate_code_is_stable_within_same_30s_window() {
461        // Two calls within the same 30-second window must return identical codes.
462        // Tiny probability of racing across a window boundary — acceptable for CI.
463        let a = generate_code(KNOWN_B32).unwrap();
464        let b = generate_code(KNOWN_B32).unwrap();
465        assert_eq!(a, b, "codes differed between two rapid calls");
466    }
467
468    #[test]
469    fn generate_code_rejects_invalid_base32() {
470        let result = generate_code("!!!INVALID!!!");
471        assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
472    }
473
474    #[test]
475    fn generate_code_zero_pads_to_six_digits() {
476        // JBSWY3DPEHPK3PXP is a known secret; verify we get a zero-padded string.
477        // We can't pin the exact value without controlling time, but we can verify
478        // the format contract: always exactly 6 decimal digits, potentially with
479        // leading zeros.
480        for _ in 0..3 {
481            let code = generate_code(KNOWN_B32).unwrap();
482            let n: u32 = code.parse().expect("should parse as integer");
483            assert!(n < 1_000_000, "code {n} must be < 1_000_000");
484        }
485    }
486
487    #[test]
488    fn generate_code_at_matches_rfc6238_vectors() {
489        // RFC 6238 Appendix B uses these ASCII seeds:
490        // SHA1:   "12345678901234567890"
491        // SHA256: "12345678901234567890123456789012"
492        // SHA512: "1234567890123456789012345678901234567890123456789012345678901234"
493        let seed_sha1 = encode_base32(b"12345678901234567890");
494        let seed_sha256 = encode_base32(b"12345678901234567890123456789012");
495        let seed_sha512 =
496            encode_base32(b"1234567890123456789012345678901234567890123456789012345678901234");
497        let vectors = [
498            (59, "94287082", "46119246", "90693936"),
499            (1_111_111_109, "07081804", "68084774", "25091201"),
500            (1_111_111_111, "14050471", "67062674", "99943326"),
501            (1_234_567_890, "89005924", "91819424", "93441116"),
502            (2_000_000_000, "69279037", "90698825", "38618901"),
503            (20_000_000_000, "65353130", "77737706", "47863826"),
504        ];
505
506        for (timestamp, sha1, sha256, sha512) in vectors {
507            assert_eq!(
508                generate_code_at(&format_uri(&seed_sha1, "SHA1", 8, 30), timestamp).unwrap(),
509                sha1
510            );
511            assert_eq!(
512                generate_code_at(&format_uri(&seed_sha256, "SHA256", 8, 30), timestamp).unwrap(),
513                sha256
514            );
515            assert_eq!(
516                generate_code_at(&format_uri(&seed_sha512, "SHA512", 8, 30), timestamp).unwrap(),
517                sha512
518            );
519        }
520    }
521
522    #[test]
523    fn generate_code_at_honors_digits_parameter() {
524        let seed = encode_base32(b"12345678901234567890");
525        let uri = format_uri(&seed, "SHA1", 8, 30);
526
527        assert_eq!(generate_code_at(&uri, 59).unwrap(), "94287082");
528    }
529
530    #[test]
531    fn generate_code_at_honors_period_parameter() {
532        let seed = encode_base32(b"12345678901234567890");
533        let uri = format_uri(&seed, "SHA1", 8, 60);
534
535        assert_eq!(generate_code_at(&uri, 59).unwrap(), "84755224");
536        assert_eq!(generate_code_at(&uri, 60).unwrap(), "94287082");
537    }
538
539    #[test]
540    fn generate_code_at_parses_lowercase_otpauth_parameters() {
541        let seed = encode_base32(b"12345678901234567890123456789012");
542        let uri = format!("otpauth://totp/Alice?secret={seed}&algorithm=sha256&digits=8&period=30");
543
544        assert_eq!(generate_code_at(&uri, 59).unwrap(), "46119246");
545    }
546
547    #[test]
548    fn generate_code_at_rejects_invalid_otpauth_parameters() {
549        let seed = encode_base32(b"12345678901234567890");
550
551        for uri in [
552            format!("otpauth://totp/Alice?secret={seed}&algorithm=MD5"),
553            format!("otpauth://totp/Alice?secret={seed}&digits=0"),
554            format!("otpauth://totp/Alice?secret={seed}&digits=11"),
555            format!("otpauth://totp/Alice?secret={seed}&period=0"),
556            format!("otpauth://totp/Alice?secret={seed}&period=abc"),
557        ] {
558            let result = generate_code_at(&uri, 59);
559            assert!(
560                matches!(result, Err(SafeError::InvalidVault { .. })),
561                "expected invalid parameter error for {uri:?}, got {result:?}"
562            );
563        }
564    }
565
566    #[test]
567    fn seconds_remaining_for_honors_period_parameter() {
568        let seed = encode_base32(b"12345678901234567890");
569        let uri = format_uri(&seed, "SHA1", 6, 60);
570
571        assert_eq!(seconds_remaining_for_at(&uri, 59).unwrap(), 1);
572        assert_eq!(seconds_remaining_for_at(&uri, 60).unwrap(), 60);
573    }
574
575    // ── seconds_remaining ─────────────────────────────────────────────────────
576
577    #[test]
578    fn seconds_remaining_is_in_range_1_to_30() {
579        let secs = seconds_remaining();
580        assert!(
581            (1..=30).contains(&secs),
582            "seconds_remaining() returned {secs}, expected 1..=30"
583        );
584    }
585
586    fn encode_base32(bytes: &[u8]) -> String {
587        base32::encode(base32::Alphabet::Rfc4648 { padding: false }, bytes)
588    }
589
590    fn format_uri(secret: &str, algorithm: &str, digits: u32, period: u64) -> String {
591        format!(
592            "otpauth://totp/Alice?secret={secret}&algorithm={algorithm}&digits={digits}&period={period}"
593        )
594    }
595}