Skip to main content

kovra_core/
totp.rs

1//! TOTP seed custody & RFC-6238 code derivation (KOV-11, extends spec §1.3).
2//!
3//! kovra custodies a TOTP **seed** (the shared secret of an authenticator
4//! enrollment) and derives the current time-based one-time code on demand. The
5//! seed lives in a [`SecretValue`](crate::secret::SecretValue) and is sealed by
6//! the same AEAD path as a literal ([`crate::crypto`]); it is **never exported**.
7//! Exactly like a private key (KOV-12), the seed is used only *through* an
8//! operation — here, deriving a short-lived 6-digit code — and never crosses
9//! back into the caller's (or the model's) context (I11/I14), is never logged
10//! (I7/I12), and is never placed in `argv` (I6).
11//!
12//! This module is **pure**: it knows nothing about the vault, policy, the broker,
13//! or even the wall clock. [`code_at`] takes an explicit `unix_secs`, so the
14//! faces drive it through the existing [`Clock`](crate::clock::Clock) trait and
15//! tests pin a [`MockClock`](crate::clock::MockClock) to assert the RFC-6238
16//! known-answer vectors (Appendix B) deterministically, with no hardware.
17//!
18//! The implementation of HOTP/TOTP is in-crate on `hmac` + `sha1`/`sha2` — no
19//! external TOTP crate (closed decision, KOV-11). The face classifies the code
20//! op as an injection-class operation (broker-gated for `high`/`prod`, I3/I15).
21
22use hmac::{Hmac, Mac};
23use serde::{Deserialize, Serialize};
24
25use crate::error::CoreError;
26
27/// RFC-6238 default time step (seconds).
28pub const DEFAULT_PERIOD: u8 = 30;
29/// RFC-6238 default code length (digits).
30pub const DEFAULT_DIGITS: u8 = 6;
31
32/// The HMAC hash algorithm backing a TOTP enrollment (RFC-6238 §1.2). SHA1 is
33/// the default (Google-Authenticator compatible); SHA256/SHA512 are the other
34/// two registered algorithms.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
36#[serde(rename_all = "lowercase")]
37pub enum TotpAlgorithm {
38    /// HMAC-SHA1 — the RFC-6238 default.
39    #[default]
40    Sha1,
41    /// HMAC-SHA256.
42    Sha256,
43    /// HMAC-SHA512.
44    Sha512,
45}
46
47impl TotpAlgorithm {
48    /// Parse an algorithm name (`SHA1`/`SHA256`/`SHA512`, case-insensitive — the
49    /// `otpauth://` URI spelling).
50    pub fn parse(s: &str) -> Result<Self, CoreError> {
51        match s.to_ascii_uppercase().as_str() {
52            "SHA1" => Ok(TotpAlgorithm::Sha1),
53            "SHA256" => Ok(TotpAlgorithm::Sha256),
54            "SHA512" => Ok(TotpAlgorithm::Sha512),
55            other => Err(CoreError::Totp(format!(
56                "unknown TOTP algorithm `{other}` (expected SHA1|SHA256|SHA512)"
57            ))),
58        }
59    }
60
61    /// The canonical `otpauth://` spelling (`SHA1` / `SHA256` / `SHA512`).
62    pub fn as_str(&self) -> &'static str {
63        match self {
64            TotpAlgorithm::Sha1 => "SHA1",
65            TotpAlgorithm::Sha256 => "SHA256",
66            TotpAlgorithm::Sha512 => "SHA512",
67        }
68    }
69}
70
71/// The non-secret parameters of a TOTP enrollment. (The seed is held separately
72/// in a [`SecretValue`](crate::secret::SecretValue) — never here.)
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct TotpParams {
75    /// The HMAC hash algorithm.
76    pub algorithm: TotpAlgorithm,
77    /// The number of digits in a code (typically 6).
78    pub digits: u8,
79    /// The time step in seconds (typically 30).
80    pub period: u8,
81}
82
83impl Default for TotpParams {
84    fn default() -> Self {
85        Self {
86            algorithm: TotpAlgorithm::default(),
87            digits: DEFAULT_DIGITS,
88            period: DEFAULT_PERIOD,
89        }
90    }
91}
92
93/// The parsed result of an `otpauth://totp/...` enrollment URI: the raw seed
94/// bytes (base32-decoded) plus the non-secret parameters. The seed bytes are
95/// the caller's responsibility to seal immediately into a `SecretValue`.
96pub struct ParsedEnrollment {
97    /// The decoded shared-secret seed bytes.
98    pub seed: Vec<u8>,
99    /// The enrollment parameters.
100    pub params: TotpParams,
101}
102
103/// Derive the RFC-6238 TOTP code for `unix_secs`.
104///
105/// `T = floor(unix_secs / period)` is the moving factor; the code is the
106/// truncated HOTP (RFC 4226 §5.3) of `HMAC(seed, T)` taken modulo `10^digits`,
107/// left-padded to `digits`. Pure: no clock, no I/O — the face passes the time.
108///
109/// Returns an error for a degenerate parameter (`period == 0`, or `digits` not
110/// in `1..=9` so the modulus fits a `u32` truncation). The seed bytes are never
111/// echoed into the error (I12).
112pub fn code_at(
113    seed: &[u8],
114    unix_secs: u64,
115    algorithm: TotpAlgorithm,
116    digits: u8,
117    period: u8,
118) -> Result<String, CoreError> {
119    if period == 0 {
120        return Err(CoreError::Totp("period must be at least 1 second".into()));
121    }
122    if !(1..=9).contains(&digits) {
123        return Err(CoreError::Totp(format!(
124            "digits must be between 1 and 9 (got {digits})"
125        )));
126    }
127    let counter = unix_secs / period as u64;
128    let mac = hmac_counter(seed, counter, algorithm)?;
129    // Dynamic truncation (RFC 4226 §5.3): the low nibble of the last byte is an
130    // offset into the MAC; read 4 bytes there, mask the high bit, mod 10^digits.
131    let offset = (mac[mac.len() - 1] & 0x0f) as usize;
132    let bin = ((mac[offset] as u32 & 0x7f) << 24)
133        | ((mac[offset + 1] as u32) << 16)
134        | ((mac[offset + 2] as u32) << 8)
135        | (mac[offset + 3] as u32);
136    let modulus = 10u32.pow(digits as u32);
137    let code = bin % modulus;
138    Ok(format!("{code:0width$}", width = digits as usize))
139}
140
141/// Seconds left in the current RFC-6238 time window for `unix_secs`.
142///
143/// The active counter spans `[T*period, (T+1)*period)`; this returns how many
144/// whole seconds remain before it rolls over — `period - (unix_secs % period)`.
145/// It is therefore in `1..=period` (never `0`): at the instant the window opens
146/// the full `period` is left. Pure arithmetic, no clock and no I/O — the face
147/// passes the time, exactly like [`code_at`]. `period == 0` is degenerate and
148/// yields `0` (the same guard [`code_at`] rejects at derivation time).
149pub fn seconds_remaining(unix_secs: u64, period: u64) -> u64 {
150    if period == 0 {
151        return 0;
152    }
153    period - (unix_secs % period)
154}
155
156/// Decide, for the `--min-validity N` scripting path, whether the **current**
157/// window's code already has enough validity left to return immediately.
158///
159/// Returns `true` when `remaining` (the seconds left in the current window, as
160/// from [`seconds_remaining`]) is **strictly greater** than `min_validity`. When
161/// it is `false` the face must wait for the current window to end and derive the
162/// next code, so the returned code is guaranteed more than `min_validity` seconds
163/// of life. Pure: no clock, no I/O — the threshold comparison only.
164///
165/// With `min_validity == 0` this is `remaining > 0`, which is always `true` since
166/// [`seconds_remaining`] is in `1..=period` for a valid period — so `--min-validity 0`
167/// deterministically returns the current code (no boundary wait, no flakiness).
168pub fn returns_current(remaining: u64, min_validity: u64) -> bool {
169    remaining > min_validity
170}
171
172/// Compute `HMAC(seed, counter_be_bytes)` for the chosen algorithm, returning the
173/// raw MAC bytes. The 8-byte big-endian counter is the RFC 4226 message.
174///
175/// `new_from_slice` accepts any key length (HMAC pads/hashes the key as needed);
176/// it only errors on a pathological backend, which we map opaquely (I12, no seed).
177fn hmac_counter(seed: &[u8], counter: u64, algorithm: TotpAlgorithm) -> Result<Vec<u8>, CoreError> {
178    let msg = counter.to_be_bytes();
179    let init_err = || CoreError::Totp("hmac init".into());
180    let out = match algorithm {
181        TotpAlgorithm::Sha1 => {
182            let mut mac = <Hmac<sha1::Sha1>>::new_from_slice(seed).map_err(|_| init_err())?;
183            mac.update(&msg);
184            mac.finalize().into_bytes().to_vec()
185        }
186        TotpAlgorithm::Sha256 => {
187            let mut mac = <Hmac<sha2::Sha256>>::new_from_slice(seed).map_err(|_| init_err())?;
188            mac.update(&msg);
189            mac.finalize().into_bytes().to_vec()
190        }
191        TotpAlgorithm::Sha512 => {
192            let mut mac = <Hmac<sha2::Sha512>>::new_from_slice(seed).map_err(|_| init_err())?;
193            mac.update(&msg);
194            mac.finalize().into_bytes().to_vec()
195        }
196    };
197    Ok(out)
198}
199
200/// Decode an RFC 4648 base32 (`A-Z2-7`) seed string into raw bytes. Whitespace
201/// and `=` padding are ignored; the alphabet is case-insensitive (authenticator
202/// apps display uppercase, but users paste either case). Errors on any other
203/// character. The decoded bytes are the secret — never logged (I12).
204pub fn decode_base32(input: &str) -> Result<Vec<u8>, CoreError> {
205    const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
206    let mut bits: u32 = 0;
207    let mut nbits: u32 = 0;
208    let mut out = Vec::new();
209    for ch in input.chars() {
210        if ch == '=' || ch.is_whitespace() || ch == '-' {
211            continue;
212        }
213        let up = ch.to_ascii_uppercase() as u8;
214        let val = ALPHABET
215            .iter()
216            .position(|&c| c == up)
217            .ok_or_else(|| CoreError::Totp("seed is not valid base32 (A–Z, 2–7)".into()))?
218            as u32;
219        bits = (bits << 5) | val;
220        nbits += 5;
221        if nbits >= 8 {
222            nbits -= 8;
223            out.push((bits >> nbits) as u8);
224        }
225    }
226    if out.is_empty() {
227        return Err(CoreError::Totp("empty seed".into()));
228    }
229    Ok(out)
230}
231
232/// Parse an `otpauth://totp/<label>?secret=...&algorithm=...&digits=...&period=...`
233/// enrollment URI (the QR-code payload authenticator apps emit). Extracts the
234/// base32 `secret` and any overridden parameters, falling back to the RFC-6238
235/// defaults (SHA1 / 6 / 30). Only `type == totp` is accepted (`hotp` is event-
236/// based and out of scope). The URI itself is not a secret label, but the
237/// `secret` parameter is — it is returned as raw bytes for immediate sealing.
238pub fn parse_otpauth(uri: &str) -> Result<ParsedEnrollment, CoreError> {
239    let rest = uri
240        .strip_prefix("otpauth://totp/")
241        .ok_or_else(|| CoreError::Totp("not an `otpauth://totp/` URI".into()))?;
242    let query = rest.split_once('?').map(|(_, q)| q).unwrap_or("");
243    let mut secret: Option<String> = None;
244    let mut params = TotpParams::default();
245    for pair in query.split('&').filter(|p| !p.is_empty()) {
246        let (k, v) = pair
247            .split_once('=')
248            .ok_or_else(|| CoreError::Totp("malformed otpauth query parameter".into()))?;
249        match k.to_ascii_lowercase().as_str() {
250            "secret" => secret = Some(percent_decode(v)),
251            "algorithm" => params.algorithm = TotpAlgorithm::parse(&percent_decode(v))?,
252            "digits" => {
253                params.digits = percent_decode(v)
254                    .parse::<u8>()
255                    .map_err(|_| CoreError::Totp("digits must be a small integer".into()))?
256            }
257            "period" => {
258                params.period = percent_decode(v)
259                    .parse::<u8>()
260                    .map_err(|_| CoreError::Totp("period must be a small integer".into()))?
261            }
262            // issuer / counter / image / unknown keys are ignored.
263            _ => {}
264        }
265    }
266    let secret = secret.ok_or_else(|| CoreError::Totp("otpauth URI has no `secret`".into()))?;
267    let seed = decode_base32(&secret)?;
268    // Validate the parameters now so an enrollment with bad digits/period is
269    // rejected at add time, not first `code` time.
270    code_at(&seed, 0, params.algorithm, params.digits, params.period)?;
271    Ok(ParsedEnrollment { seed, params })
272}
273
274/// Minimal percent-decoding for `otpauth://` query values (e.g. `%20`, `%3D`).
275/// Sufficient for the small character set authenticator URIs use; leaves
276/// already-plain values untouched.
277fn percent_decode(s: &str) -> String {
278    let bytes = s.as_bytes();
279    let mut out = Vec::with_capacity(bytes.len());
280    let mut i = 0;
281    while i < bytes.len() {
282        if bytes[i] == b'%' && i + 2 < bytes.len() {
283            let hi = (bytes[i + 1] as char).to_digit(16);
284            let lo = (bytes[i + 2] as char).to_digit(16);
285            if let (Some(hi), Some(lo)) = (hi, lo) {
286                out.push((hi * 16 + lo) as u8);
287                i += 3;
288                continue;
289            }
290        }
291        out.push(bytes[i]);
292        i += 1;
293    }
294    String::from_utf8_lossy(&out).into_owned()
295}
296
297/// Ingest a manual seed entry: either a full `otpauth://totp/...` URI or a bare
298/// base32 seed string (which takes the RFC-6238 defaults). The face calls this
299/// on the value read from stdin / a hidden prompt (never argv, I6).
300pub fn parse_seed_input(input: &str) -> Result<ParsedEnrollment, CoreError> {
301    let trimmed = input.trim();
302    if trimmed.starts_with("otpauth://") {
303        parse_otpauth(trimmed)
304    } else {
305        let seed = decode_base32(trimmed)?;
306        Ok(ParsedEnrollment {
307            seed,
308            params: TotpParams::default(),
309        })
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    /// The RFC-6238 Appendix B test seed for SHA1 is the ASCII string
318    /// `"12345678901234567890"` (20 bytes). SHA256/SHA512 repeat it to the
319    /// algorithm's block size: 32 bytes for SHA256, 64 for SHA512.
320    fn sha1_seed() -> Vec<u8> {
321        b"12345678901234567890".to_vec()
322    }
323    fn sha256_seed() -> Vec<u8> {
324        b"12345678901234567890123456789012".to_vec()
325    }
326    fn sha512_seed() -> Vec<u8> {
327        b"1234567890123456789012345678901234567890123456789012345678901234".to_vec()
328    }
329
330    // RFC-6238 Appendix B — the published 8-digit known-answer vectors. We pin a
331    // fixed `unix_secs` (the table's "Time (sec)" column) and assert the exact
332    // code for each algorithm. This is the deterministic correctness gate.
333    #[test]
334    fn rfc6238_known_answer_vectors_sha1() {
335        // (unix_secs, expected 8-digit code) from RFC 6238 Appendix B, SHA1.
336        for (t, expected) in [
337            (59u64, "94287082"),
338            (1_111_111_109, "07081804"),
339            (1_111_111_111, "14050471"),
340            (1_234_567_890, "89005924"),
341            (2_000_000_000, "69279037"),
342            (20_000_000_000, "65353130"),
343        ] {
344            let code = code_at(&sha1_seed(), t, TotpAlgorithm::Sha1, 8, 30).unwrap();
345            assert_eq!(code, expected, "SHA1 vector at t={t}");
346        }
347    }
348
349    #[test]
350    fn rfc6238_known_answer_vectors_sha256() {
351        for (t, expected) in [
352            (59u64, "46119246"),
353            (1_111_111_109, "68084774"),
354            (1_234_567_890, "91819424"),
355            (20_000_000_000, "77737706"),
356        ] {
357            let code = code_at(&sha256_seed(), t, TotpAlgorithm::Sha256, 8, 30).unwrap();
358            assert_eq!(code, expected, "SHA256 vector at t={t}");
359        }
360    }
361
362    #[test]
363    fn rfc6238_known_answer_vectors_sha512() {
364        for (t, expected) in [
365            (59u64, "90693936"),
366            (1_111_111_109, "25091201"),
367            (1_234_567_890, "93441116"),
368            (20_000_000_000, "47863826"),
369        ] {
370            let code = code_at(&sha512_seed(), t, TotpAlgorithm::Sha512, 8, 30).unwrap();
371            assert_eq!(code, expected, "SHA512 vector at t={t}");
372        }
373    }
374
375    // The same derivation through the `Clock` trait at a fixed instant yields the
376    // same answer — the seam the CLI uses (MockClock → code_at) is deterministic.
377    #[test]
378    fn code_via_mock_clock_matches_vector() {
379        use crate::clock::{Clock, MockClock};
380        let clock = MockClock::at(59);
381        let code = code_at(&sha1_seed(), clock.unix_secs(), TotpAlgorithm::Sha1, 8, 30).unwrap();
382        assert_eq!(code, "94287082");
383    }
384
385    // The default 6-digit code is the last 6 of the 8-digit vector at t=59.
386    #[test]
387    fn default_six_digits_truncates_the_vector() {
388        let code = code_at(&sha1_seed(), 59, TotpAlgorithm::Sha1, 6, 30).unwrap();
389        assert_eq!(code, "287082");
390        assert_eq!(code.len(), 6);
391    }
392
393    // base32 decode round-trips a known RFC 4648 vector and is case-insensitive.
394    #[test]
395    fn base32_decode_known_vectors() {
396        assert_eq!(decode_base32("MFRGG===").unwrap(), b"abc");
397        assert_eq!(decode_base32("mfrgg").unwrap(), b"abc");
398        // `JBSWY3DPEHPK3PXP` is the canonical "Hello!\xde\xad\xbe\xef" sample.
399        assert_eq!(
400            decode_base32("JBSWY3DPEHPK3PXP").unwrap(),
401            b"Hello!\xde\xad\xbe\xef"
402        );
403        // whitespace/dashes (display grouping) are ignored
404        assert_eq!(decode_base32("MFRG G===").unwrap(), b"abc");
405        // a non-base32 char is rejected
406        assert!(decode_base32("0189!").is_err());
407        assert!(decode_base32("").is_err());
408    }
409
410    // An `otpauth://` URI round-trips: secret + overridden params parse, and the
411    // derived code matches the manual derivation from the same seed/params.
412    #[test]
413    fn otpauth_parse_round_trip() {
414        // Base32 of the RFC SHA1 seed "12345678901234567890" is
415        // "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ".
416        let uri = "otpauth://totp/ACME:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=ACME&algorithm=SHA1&digits=8&period=30";
417        let parsed = parse_otpauth(uri).unwrap();
418        assert_eq!(parsed.seed, sha1_seed());
419        assert_eq!(parsed.params.algorithm, TotpAlgorithm::Sha1);
420        assert_eq!(parsed.params.digits, 8);
421        assert_eq!(parsed.params.period, 30);
422        let code = code_at(
423            &parsed.seed,
424            59,
425            parsed.params.algorithm,
426            parsed.params.digits,
427            parsed.params.period,
428        )
429        .unwrap();
430        assert_eq!(code, "94287082");
431    }
432
433    // Defaults apply when the URI omits params; a bare base32 seed also defaults.
434    #[test]
435    fn otpauth_defaults_and_bare_seed() {
436        let uri = "otpauth://totp/x?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
437        let parsed = parse_otpauth(uri).unwrap();
438        assert_eq!(parsed.params, TotpParams::default());
439
440        let bare = parse_seed_input("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ").unwrap();
441        assert_eq!(bare.seed, sha1_seed());
442        assert_eq!(bare.params, TotpParams::default());
443        // a 6-digit default code derives without error
444        assert_eq!(bare.params.digits, 6);
445    }
446
447    #[test]
448    fn parse_seed_input_routes_uri_vs_bare() {
449        assert!(parse_seed_input("otpauth://totp/x?secret=MFRGG").is_ok());
450        assert!(parse_seed_input("MFRGG").is_ok());
451        // an hotp URI is refused (event-based, out of scope)
452        assert!(parse_seed_input("otpauth://hotp/x?secret=MFRGG").is_err());
453    }
454
455    // `seconds_remaining` returns how many whole seconds are left in the current
456    // window (`period - unix_secs % period`), in `1..=period`.
457    #[test]
458    fn seconds_remaining_counts_down_within_the_window() {
459        // period=30: at t=59 the window [30,60) has 1s left; at t=60 a fresh
460        // window opens with the full 30s; at t=75 the window [60,90) has 15s.
461        assert_eq!(seconds_remaining(59, 30), 1);
462        assert_eq!(seconds_remaining(60, 30), 30);
463        assert_eq!(seconds_remaining(75, 30), 15);
464        // The boundary instant always has the full period left, never 0.
465        assert_eq!(seconds_remaining(0, 30), 30);
466        assert_eq!(seconds_remaining(30, 30), 30);
467        // A degenerate period yields 0 (guarded; code_at rejects it).
468        assert_eq!(seconds_remaining(5, 0), 0);
469    }
470
471    // `returns_current` is the pure threshold for the `--min-validity N` path:
472    // strictly more validity than N means "use the current code"; otherwise wait.
473    #[test]
474    fn returns_current_thresholds_on_min_validity() {
475        // Strictly greater → use the current window's code.
476        assert!(returns_current(30, 0));
477        assert!(returns_current(11, 10));
478        assert!(returns_current(2, 1));
479        // Equal or less → must wait for the next window.
480        assert!(!returns_current(10, 10));
481        assert!(!returns_current(5, 10));
482        assert!(!returns_current(0, 0));
483        // `--min-validity 0` is always true for a real (>=1) remaining, so the
484        // current code is returned deterministically with no boundary wait.
485        for remaining in 1..=30 {
486            assert!(returns_current(remaining, 0));
487        }
488    }
489
490    #[test]
491    fn rejects_degenerate_params() {
492        assert!(code_at(b"seed", 0, TotpAlgorithm::Sha1, 6, 0).is_err()); // period 0
493        assert!(code_at(b"seed", 0, TotpAlgorithm::Sha1, 0, 30).is_err()); // 0 digits
494        assert!(code_at(b"seed", 0, TotpAlgorithm::Sha1, 10, 30).is_err()); // >9 digits
495    }
496
497    #[test]
498    fn algorithm_parse_round_trips() {
499        assert_eq!(TotpAlgorithm::parse("sha1").unwrap(), TotpAlgorithm::Sha1);
500        assert_eq!(
501            TotpAlgorithm::parse("SHA256").unwrap(),
502            TotpAlgorithm::Sha256
503        );
504        assert_eq!(TotpAlgorithm::Sha512.as_str(), "SHA512");
505        assert!(TotpAlgorithm::parse("md5").is_err());
506    }
507}