1use 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
65pub fn extract_base32(input: &str) -> SafeResult<Zeroizing<String>> {
69 Ok(parse_totp_config(input)?.secret)
70}
71
72pub 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
103pub fn generate_code(input: &str) -> SafeResult<String> {
109 generate_code_at(input, unix_timestamp())
110}
111
112pub 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
123pub fn seconds_remaining() -> u64 {
125 let ts = unix_timestamp();
126 30 - (ts % 30)
127}
128
129pub fn seconds_remaining_for(input: &str) -> SafeResult<u64> {
131 seconds_remaining_for_at(input, unix_timestamp())
132}
133
134pub 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
140fn 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 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 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
307fn 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 const KNOWN_B32: &str = "JBSWY3DPEHPK3PXP";
360
361 #[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 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 #[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 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 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 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 #[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}