Skip to main content

vault_core/
totp.rs

1//! RFC 6238 time-based one-time passwords.
2//!
3//! # Two callers, one generator
4//!
5//! Phase 19 added this module for **sub-user login**: UnENVerse checking a code
6//! its own user typed. Phase 22 added the mirror image — a TOTP seed the vault
7//! *stores on behalf of a third party*, the way Bitwarden and 1Password hold an
8//! authenticator entry, where UnENVerse produces the code and a website checks
9//! it.
10//!
11//! They share every line of arithmetic and deliberately nothing else. The login
12//! path is fixed at SHA-1/6 digits/30 seconds because that is what this product
13//! mints and there is no interoperability question; the stored-seed path is
14//! parameterised ([`Params`]) because the issuer chose those numbers years ago
15//! and a generator that cannot follow is a generator that produces confidently
16//! wrong codes. Only the login path has an anti-replay mark — see [`verify`] —
17//! because only the login path is a verifier.
18//!
19//! # Why this exists twice
20//!
21//! Phase 5.1 shipped a TOTP implementation and Phase 7 removed it, because
22//! nothing in the product ever reached it: there was no enrollment surface, no
23//! CLI verb and no UI, so the only thing the code did was carry a schema column.
24//! It comes back here with all three, and with the anti-replay rule that the
25//! original had — see [`verify`].
26//!
27//! # Sub-users only, deliberately
28//!
29//! The vault owner authenticates by deriving the SQLCipher key from the master
30//! password. There is no stored hash to check and therefore nothing for a second
31//! factor to gate: an attacker who can derive the key does not go through a login
32//! form, they open the file. Offering the owner a TOTP toggle would be a control
33//! that protects nothing while looking as though it protects everything.
34//!
35//! # No new crates
36//!
37//! `hmac` is already a dependency (`entropy.rs` builds HKDF on it), `sha1` is
38//! added for the one algorithm RFC 6238 pins for interoperability, and `sha2`
39//! was already here for the KDF — which is the whole cost of supporting the
40//! SHA-256 and SHA-512 variants a stored third-party seed may name. Base32 is
41//! forty lines and lives here rather than behind a crate, for the same reason
42//! HKDF does: the encoding is part of what a reader has to check.
43
44use hmac::{Hmac, Mac};
45use serde::{Deserialize, Serialize};
46use sha1::Sha1;
47use sha2::{Sha256, Sha512};
48use zeroize::Zeroizing;
49
50/// Digits in a generated code. Six is what every authenticator app assumes when
51/// the `otpauth://` URI omits the parameter, and omitting it is what keeps the
52/// QR-less manual-entry path working.
53pub const DIGITS: u32 = 6;
54
55/// Seconds per counter step. RFC 6238's recommended default.
56pub const STEP_SECS: u64 = 30;
57
58/// How many steps either side of the current one are accepted.
59///
60/// One step is 30 seconds, so a window of 1 tolerates a phone clock up to 30
61/// seconds out in either direction. Anything larger widens the replay window
62/// that [`verify`]'s `last_step` argument exists to close.
63pub const SKEW_STEPS: i64 = 1;
64
65/// Bytes of secret material. RFC 4226 requires at least 128 bits and recommends
66/// 160 — which is also the SHA-1 block output, so nothing is truncated.
67pub const SECRET_BYTES: usize = 20;
68
69type HmacSha1 = Hmac<Sha1>;
70type HmacSha256 = Hmac<Sha256>;
71type HmacSha512 = Hmac<Sha512>;
72
73/// Fewest digits a code may carry. RFC 4226 sets six as the floor.
74pub const MIN_DIGITS: u32 = 6;
75
76/// Most digits a code may carry.
77///
78/// Ten is where `u32` runs out: dynamic truncation yields a 31-bit number, so
79/// `10^10` already exceeds it and an eleventh digit would be a constant zero
80/// that looks like part of the code.
81pub const MAX_DIGITS: u32 = 10;
82
83/// Longest step a stored seed may name, in seconds.
84///
85/// An hour. Nothing real uses more, and the cap is what stops a pasted URI with
86/// `period=0` or `period=4294967295` from producing a division by zero or a
87/// code that never changes.
88pub const MAX_PERIOD_SECS: u64 = 3600;
89
90// ── Base32 (RFC 4648, no padding) ─────────────────────────────────────────────
91
92const B32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
93
94/// Encodes bytes as unpadded RFC 4648 base32.
95///
96/// Unpadded because `otpauth://` secrets are conventionally written without `=`,
97/// and several authenticators reject the padding rather than ignoring it.
98pub fn base32_encode(data: &[u8]) -> String {
99    let mut out = String::with_capacity(data.len().div_ceil(5) * 8);
100    let mut buffer: u32 = 0;
101    let mut bits: u32 = 0;
102    for &byte in data {
103        buffer = (buffer << 8) | u32::from(byte);
104        bits += 8;
105        while bits >= 5 {
106            bits -= 5;
107            out.push(char::from(B32_ALPHABET[((buffer >> bits) & 0x1f) as usize]));
108        }
109    }
110    if bits > 0 {
111        // Left-align the remaining bits in the final group, as RFC 4648 requires.
112        out.push(char::from(
113            B32_ALPHABET[((buffer << (5 - bits)) & 0x1f) as usize],
114        ));
115    }
116    out
117}
118
119/// Decodes unpadded (or padded) RFC 4648 base32, case-insensitively.
120///
121/// Spaces are stripped because this is what a human types back out of the
122/// grouped display the UI shows, and `=` is accepted because a user pasting a
123/// secret exported from somewhere else should not have to know the difference.
124pub fn base32_decode(s: &str) -> Result<Vec<u8>, String> {
125    let mut out = Vec::with_capacity(s.len() * 5 / 8);
126    let mut buffer: u32 = 0;
127    let mut bits: u32 = 0;
128    for ch in s.chars() {
129        if ch == '=' || ch.is_whitespace() || ch == '-' {
130            continue;
131        }
132        let up = ch.to_ascii_uppercase();
133        let val = B32_ALPHABET
134            .iter()
135            .position(|&c| c == up as u8)
136            .ok_or_else(|| format!("not base32: {ch:?}"))? as u32;
137        buffer = (buffer << 5) | val;
138        bits += 5;
139        if bits >= 8 {
140            bits -= 8;
141            out.push(((buffer >> bits) & 0xff) as u8);
142        }
143    }
144    Ok(out)
145}
146
147// ── Code generation ───────────────────────────────────────────────────────────
148
149/// HOTP (RFC 4226) for one counter value, SHA-1 and six digits.
150///
151/// The login path's shape. A stored third-party seed goes through
152/// [`hotp_with`], which is the same function with the two constants unpinned;
153/// this one stays because every caller in `users.rs` means exactly these
154/// numbers and spelling them out at each call site invites one of them to
155/// drift.
156pub fn hotp(secret: &[u8], counter: u64) -> String {
157    hotp_with(secret, counter, Algorithm::Sha1, DIGITS)
158}
159
160/// HOTP (RFC 4226) for one counter value, with the algorithm and digit count
161/// the issuer chose.
162///
163/// `digits` is clamped to [`MIN_DIGITS`]..=[`MAX_DIGITS`] rather than rejected:
164/// this is the innermost function and it is reached only through validated
165/// constructors, so a panic here would be a crash in a card renderer for a
166/// number a user typed. [`Params::validate`] is where a bad value is refused.
167pub fn hotp_with(secret: &[u8], counter: u64, algorithm: Algorithm, digits: u32) -> String {
168    // Dynamic truncation, RFC 4226 §5.3, over whichever digest the issuer picked.
169    // `new_from_slice` only fails for key lengths HMAC cannot take, and HMAC
170    // accepts any length, so none of these can fail in practice.
171    let digest: Vec<u8> = match algorithm {
172        Algorithm::Sha1 => {
173            let mut mac = HmacSha1::new_from_slice(secret).expect("HMAC accepts any key length");
174            mac.update(&counter.to_be_bytes());
175            mac.finalize().into_bytes().to_vec()
176        }
177        Algorithm::Sha256 => {
178            let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
179            mac.update(&counter.to_be_bytes());
180            mac.finalize().into_bytes().to_vec()
181        }
182        Algorithm::Sha512 => {
183            let mut mac = HmacSha512::new_from_slice(secret).expect("HMAC accepts any key length");
184            mac.update(&counter.to_be_bytes());
185            mac.finalize().into_bytes().to_vec()
186        }
187    };
188
189    let offset = (digest[digest.len() - 1] & 0x0f) as usize;
190    let binary = (u32::from(digest[offset]) & 0x7f) << 24
191        | u32::from(digest[offset + 1]) << 16
192        | u32::from(digest[offset + 2]) << 8
193        | u32::from(digest[offset + 3]);
194
195    let digits = digits.clamp(MIN_DIGITS, MAX_DIGITS);
196    let modulus = 10u32.pow(digits);
197    format!("{:0width$}", binary % modulus, width = digits as usize)
198}
199
200/// Steam Guard's five characters for one counter value.
201///
202/// The HMAC and the dynamic truncation are RFC 4226's, unchanged — see
203/// [`hotp_with`], whose first half this repeats. Only the rendering differs: the
204/// 31-bit value is written in base 26 over [`STEAM_ALPHABET`] instead of base 10.
205///
206/// Written out rather than folded into `hotp_with` because the two produce
207/// different *types* of answer from the same number, and a `digits`-shaped
208/// parameter that silently switched alphabets would be the kind of flag that
209/// gets passed wrongly once and then produces codes that look plausible.
210fn steam_with(secret: &[u8], counter: u64) -> String {
211    let mut mac = HmacSha1::new_from_slice(secret).expect("HMAC accepts any key length");
212    mac.update(&counter.to_be_bytes());
213    let digest = mac.finalize().into_bytes();
214
215    let offset = (digest[digest.len() - 1] & 0x0f) as usize;
216    let mut value = (u32::from(digest[offset]) & 0x7f) << 24
217        | u32::from(digest[offset + 1]) << 16
218        | u32::from(digest[offset + 2]) << 8
219        | u32::from(digest[offset + 3]);
220
221    let n = STEAM_ALPHABET.len() as u32;
222    let mut out = String::with_capacity(STEAM_DIGITS as usize);
223    for _ in 0..STEAM_DIGITS {
224        out.push(STEAM_ALPHABET[(value % n) as usize] as char);
225        value /= n;
226    }
227    out
228}
229
230/// The counter step for a Unix timestamp.
231pub fn step_at(unix_secs: u64) -> u64 {
232    unix_secs / STEP_SECS
233}
234
235/// Current Unix time in seconds.
236pub fn now_unix() -> u64 {
237    std::time::SystemTime::now()
238        .duration_since(std::time::UNIX_EPOCH)
239        .map(|d| d.as_secs())
240        .unwrap_or(0)
241}
242
243/// TOTP for a base32 secret at a given time. Exposed so a caller can show the
244/// user the code their authenticator should be showing — used by nothing that
245/// authenticates, only by diagnostics.
246pub fn totp_at(secret_b32: &str, unix_secs: u64) -> Result<String, String> {
247    let secret = base32_decode(secret_b32)?;
248    if secret.is_empty() {
249        return Err("empty TOTP secret".into());
250    }
251    Ok(hotp(&secret, step_at(unix_secs)))
252}
253
254// ── Verification ──────────────────────────────────────────────────────────────
255
256/// The outcome of checking a code, carrying the step that was accepted.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub struct Accepted {
259    /// The counter step the code matched. The caller **must** persist this and
260    /// pass it back as `last_step` next time.
261    pub step: u64,
262}
263
264/// Verifies a code against a secret, refusing anything at or before `last_step`.
265///
266/// # The anti-replay rule is the whole point
267///
268/// A ±1-step skew window means any given code is valid for ninety seconds. Over
269/// a network that is ninety seconds in which an observed code can be replayed by
270/// whoever saw it — which for a second factor is the exact attack it exists to
271/// stop. Refusing a step that has already been accepted reduces that to a single
272/// use, and it is why every caller has to store what [`Accepted::step`] returns.
273///
274/// `last_step` of `None` means the factor has never been used.
275pub fn verify(secret_b32: &str, code: &str, last_step: Option<u64>, now: u64) -> Option<Accepted> {
276    let secret = base32_decode(secret_b32).ok()?;
277    if secret.is_empty() {
278        return None;
279    }
280    // Users type codes with a space in the middle because that is how phones
281    // display them.
282    let code: String = code.chars().filter(|c| c.is_ascii_digit()).collect();
283    if code.len() != DIGITS as usize {
284        return None;
285    }
286
287    let current = step_at(now) as i64;
288    for delta in -SKEW_STEPS..=SKEW_STEPS {
289        let step = current + delta;
290        if step < 0 {
291            continue;
292        }
293        let step = step as u64;
294        if let Some(last) = last_step {
295            if step <= last {
296                continue;
297            }
298        }
299        if constant_time_eq(hotp(&secret, step).as_bytes(), code.as_bytes()) {
300            return Some(Accepted { step });
301        }
302    }
303    None
304}
305
306/// Length-aware, branch-free byte comparison.
307///
308/// Codes are six digits, so a timing oracle here leaks very little — but the
309/// same reasoning retired the legacy SHA-256 password comparison in Phase 5.1,
310/// and writing the fast version in a file about authentication invites the next
311/// reader to copy it somewhere it matters.
312fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
313    if a.len() != b.len() {
314        return false;
315    }
316    let mut diff = 0u8;
317    for (x, y) in a.iter().zip(b.iter()) {
318        diff |= x ^ y;
319    }
320    diff == 0
321}
322
323// ── Enrollment ────────────────────────────────────────────────────────────────
324
325/// Generates a fresh base32 secret from the OS CSPRNG.
326pub fn generate_secret() -> String {
327    use rand::RngCore;
328    let mut buf = [0u8; SECRET_BYTES];
329    rand::rngs::OsRng.fill_bytes(&mut buf);
330    base32_encode(&buf)
331}
332
333/// Builds the `otpauth://` URI an authenticator scans or imports.
334///
335/// The label is `issuer:account` and the `issuer` parameter repeats it, which is
336/// redundant by the spec and required in practice — several apps read only one
337/// of the two, and which one differs between them.
338pub fn otpauth_uri(issuer: &str, account: &str, secret_b32: &str) -> String {
339    format!(
340        "otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits={}&period={}",
341        pct(issuer),
342        pct(account),
343        secret_b32,
344        pct(issuer),
345        DIGITS,
346        STEP_SECS
347    )
348}
349
350/// Percent-encodes everything outside the unreserved set.
351///
352/// A username is user-supplied, so it reaches this function containing anything
353/// at all: a `?`, `&` or `#` in it would otherwise terminate the path and turn
354/// the rest of the username into query parameters of the caller's choosing.
355fn pct(s: &str) -> String {
356    let mut out = String::with_capacity(s.len());
357    for b in s.as_bytes() {
358        match b {
359            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
360                out.push(*b as char)
361            }
362            _ => out.push_str(&format!("%{b:02X}")),
363        }
364    }
365    out
366}
367
368/// Splits a secret into space-separated groups of four for manual entry.
369///
370/// The UI cannot render a QR code — the CSP does not allow the library that
371/// would draw one, and relaxing it to display a secret would be an odd trade —
372/// so the secret is typed by hand, and a 32-character unbroken string is typed
373/// wrong.
374pub fn grouped(secret_b32: &str) -> String {
375    secret_b32
376        .as_bytes()
377        .chunks(4)
378        .map(|c| String::from_utf8_lossy(c).to_string())
379        .collect::<Vec<_>>()
380        .join(" ")
381}
382
383// ── Stored third-party seeds (Phase 22) ───────────────────────────────────────
384//
385// Everything below serves the *other* caller: a seed the vault holds on behalf
386// of a website, from which UnENVerse produces a code the user types into that
387// website. Nothing here verifies anything, so nothing here has — or wants — the
388// anti-replay mark that `verify` above carries.
389
390/// The alphabet Steam Guard draws its five characters from.
391///
392/// Steam runs ordinary RFC 6238 arithmetic — SHA-1, a 30-second step — and then
393/// encodes the truncated value in base 26 instead of base 10. Nothing else about
394/// it differs, which is why it is a [`Kind`] rather than an [`Algorithm`]: the
395/// HMAC is the same, the rendering is not.
396pub const STEAM_ALPHABET: &[u8; 26] = b"23456789BCDFGHJKMNPQRTVWXY";
397
398/// How many characters a Steam code carries. Not configurable; Steam fixes it.
399pub const STEAM_DIGITS: u32 = 5;
400
401/// What a stored seed *is*, which decides what "the current code" means for it.
402///
403/// The three differ in where the counter comes from, not in the arithmetic:
404/// `Totp` divides the clock by the period, `Steam` does the same and renders the
405/// result in base 26, and `Hotp` has no clock at all and uses a number the vault
406/// stores and the user advances.
407///
408/// Phase 22 refused the last two by name. That refusal was right while nothing
409/// could store a counter — an `Hotp` entry with nowhere to keep its position
410/// shows a code that never changes and never works. Phase 22.2 gives it
411/// somewhere, so the refusal became a limitation instead of a safeguard.
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
413#[serde(rename_all = "lowercase")]
414pub enum Kind {
415    /// Time-based, RFC 6238. What an entry with no stored kind means.
416    #[default]
417    Totp,
418    /// Counter-based, RFC 4226. The counter lives on the entry.
419    Hotp,
420    /// Time-based like `Totp`, rendered in Steam's five-character alphabet.
421    Steam,
422}
423
424impl Kind {
425    /// The spelling stored on the entry and used in an `otpauth://` path.
426    pub fn as_str(self) -> &'static str {
427        match self {
428            Kind::Totp => "totp",
429            Kind::Hotp => "hotp",
430            Kind::Steam => "steam",
431        }
432    }
433
434    /// Reads that spelling, case-insensitively. `None` for anything else.
435    pub fn parse(raw: &str) -> Option<Self> {
436        match raw.trim().to_ascii_lowercase().as_str() {
437            "totp" => Some(Kind::Totp),
438            "hotp" => Some(Kind::Hotp),
439            "steam" => Some(Kind::Steam),
440            _ => None,
441        }
442    }
443
444    /// True when the code advances on its own, so a countdown means something.
445    ///
446    /// The UI asks this rather than comparing against `Kind::Hotp`: a card with
447    /// no clock must draw no countdown ring, and a card that draws one whose
448    /// number never moves is worse than one that draws none.
449    pub fn is_time_based(self) -> bool {
450        !matches!(self, Kind::Hotp)
451    }
452}
453
454/// The HMAC a stored seed names.
455///
456/// SHA-1 is the only value a login seed ever takes and the overwhelming
457/// majority of what issuers hand out. The other two exist because `otpauth://`
458/// can name them and a handful of issuers do: reading `algorithm=SHA256` and
459/// then generating SHA-1 codes produces six digits that are correct-looking,
460/// wrong, and give the user no way to tell which of the two ends is at fault.
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
462#[serde(rename_all = "UPPERCASE")]
463pub enum Algorithm {
464    #[default]
465    Sha1,
466    Sha256,
467    Sha512,
468}
469
470impl Algorithm {
471    /// The spelling `otpauth://` uses.
472    pub fn as_str(self) -> &'static str {
473        match self {
474            Algorithm::Sha1 => "SHA1",
475            Algorithm::Sha256 => "SHA256",
476            Algorithm::Sha512 => "SHA512",
477        }
478    }
479
480    /// Reads the spelling `otpauth://` uses, case- and dash-insensitively.
481    ///
482    /// `SHA-256` appears in real URIs even though the spec does not allow it.
483    /// Rejecting it would mean refusing a seed that every phone accepts.
484    pub fn parse(raw: &str) -> Option<Self> {
485        match raw.trim().to_ascii_uppercase().replace('-', "").as_str() {
486            "SHA1" => Some(Algorithm::Sha1),
487            "SHA256" => Some(Algorithm::Sha256),
488            "SHA512" => Some(Algorithm::Sha512),
489            _ => None,
490        }
491    }
492}
493
494/// The three numbers a stored seed is generated under.
495///
496/// Defaults are what an `otpauth://` URI means when it omits the parameter, and
497/// therefore what an entry that stores only a bare base32 seed means too.
498#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
499pub struct Params {
500    /// Time-based, counter-based or Steam. `#[serde(default)]` because every
501    /// entry written before Phase 22.2 has no such field and means `Totp`.
502    #[serde(default)]
503    pub kind: Kind,
504    pub algorithm: Algorithm,
505    pub digits: u32,
506    pub period: u64,
507    /// The next counter value an `Hotp` seed will use. Meaningless for the other
508    /// two kinds, and zero for them.
509    ///
510    /// It is *state*, not configuration — the only number in this struct that a
511    /// correct implementation writes back — which is why advancing it is an
512    /// explicit action rather than a side effect of reading a code. See
513    /// `advance` in `unv-cli/src/totp_cmd.rs`.
514    #[serde(default)]
515    pub counter: u64,
516}
517
518impl Default for Params {
519    fn default() -> Self {
520        Params {
521            kind: Kind::Totp,
522            algorithm: Algorithm::Sha1,
523            digits: DIGITS,
524            period: STEP_SECS,
525            counter: 0,
526        }
527    }
528}
529
530impl Params {
531    /// The parameters an entry's four stored fields mean, with anything
532    /// unusable falling back to the `otpauth://` default.
533    ///
534    /// **This is the only place a vault's TOTP fields become `Params`.** They
535    /// were read three separate ways before — `unv totp code` clamped, `unv
536    /// totp ls` did not, and the IPC command did neither — so one entry
537    /// carrying `totp_digits: 99` listed as a 99-digit credential, produced six
538    /// digits when asked for a code, and returned an error rather than a code to
539    /// any caller of `entry_totp_code` that had not clamped first. Three
540    /// readings of one field is three answers.
541    ///
542    /// A vault is untrusted input (invariant 4): these fields arrive from an
543    /// imported backup or a remote server as readily as from this CLI, and the
544    /// unions are not enforced at rest. Falling back rather than refusing is
545    /// what an omitted `otpauth://` parameter already means, and it keeps a
546    /// single mistyped number from making the whole entry unreadable.
547    ///
548    /// The twin is `totpParamsOf` in `src/ts/totp.ts` — the form needs these
549    /// before anything is saved — and the two are pinned by the `params`
550    /// section of `tests/fixtures/parity/totp-seeds.json`.
551    pub fn from_fields(
552        kind: Option<&str>,
553        algorithm: Option<&str>,
554        digits: Option<u64>,
555        period: Option<u64>,
556        counter: Option<u64>,
557    ) -> Self {
558        let d = Params::default();
559        let kind = kind.and_then(Kind::parse).unwrap_or(d.kind);
560        Params {
561            kind,
562            algorithm: algorithm.and_then(Algorithm::parse).unwrap_or(d.algorithm),
563            digits: digits
564                .filter(|n| (u64::from(MIN_DIGITS)..=u64::from(MAX_DIGITS)).contains(n))
565                .map(|n| n as u32)
566                .unwrap_or(d.digits),
567            period: period
568                .filter(|p| *p > 0 && *p <= MAX_PERIOD_SECS)
569                .unwrap_or(d.period),
570            // A counter is only ever read for an `Hotp` seed. Carrying one on a
571            // time-based entry would be a number that looks like state and is
572            // never used, which is the kind of field a later reader trusts.
573            counter: if kind == Kind::Hotp {
574                counter.unwrap_or(0)
575            } else {
576                0
577            },
578        }
579        .steam_normalised()
580    }
581
582    /// Forces the values Steam fixes, so nothing downstream has to special-case
583    /// them.
584    ///
585    /// Steam issues one shape — SHA-1, five characters, a 30-second step — and an
586    /// import that carried `digits: 6` from a generic exporter would otherwise
587    /// produce six characters no Steam login accepts.
588    fn steam_normalised(mut self) -> Self {
589        if self.kind == Kind::Steam {
590            self.algorithm = Algorithm::Sha1;
591            self.digits = STEAM_DIGITS;
592            self.period = STEP_SECS;
593            self.counter = 0;
594        }
595        self
596    }
597
598    /// Refuses a combination that cannot produce a code, naming the field.
599    ///
600    /// Called at every boundary a number can enter through — the URI parser, the
601    /// CLI flags, the IPC command — rather than at the generator, so that a
602    /// `period` of zero is a message about the value the user typed instead of a
603    /// division by zero three frames deeper.
604    pub fn validate(&self) -> Result<(), String> {
605        // Steam fixes its own shape, so there is nothing here for a user to get
606        // wrong — and its five characters are below `MIN_DIGITS`, which the
607        // check below would otherwise refuse.
608        if self.kind == Kind::Steam {
609            return if self.digits == STEAM_DIGITS {
610                Ok(())
611            } else {
612                Err(format!(
613                    "a Steam code is always {STEAM_DIGITS} characters, got {}",
614                    self.digits
615                ))
616            };
617        }
618        if !(MIN_DIGITS..=MAX_DIGITS).contains(&self.digits) {
619            return Err(format!(
620                "digits must be between {MIN_DIGITS} and {MAX_DIGITS}, got {}",
621                self.digits
622            ));
623        }
624        // An `Hotp` seed has no clock, so its period is meaningless rather than
625        // wrong. It is still range-checked, because a stored zero would divide
626        // by zero the moment somebody converted the entry to time-based.
627        if self.period == 0 || self.period > MAX_PERIOD_SECS {
628            return Err(format!(
629                "period must be between 1 and {MAX_PERIOD_SECS} seconds, got {}",
630                self.period
631            ));
632        }
633        Ok(())
634    }
635
636    /// True when these are the values an `otpauth://` URI may omit.
637    ///
638    /// Used to keep the stored entry honest: writing `algorithm: "SHA1"` onto
639    /// every entry would make the field look chosen when it was defaulted, and
640    /// the UI would then have to distinguish "the issuer said SHA-1" from "we
641    /// wrote SHA-1 because nobody said anything". They are the same thing, so
642    /// the field is simply absent.
643    pub fn is_default(&self) -> bool {
644        *self == Params::default()
645    }
646}
647
648/// A parsed seed: the base32 secret plus everything the URI said about it.
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650pub struct Stored {
651    /// Base32, uppercased, with the grouping spaces and any padding removed.
652    pub secret: String,
653    #[serde(flatten)]
654    pub params: Params,
655    /// The service, when the URI named one. Never overwrites an entry's
656    /// provider — see `unv-cli`'s `--totp` handling; it is offered, not applied.
657    pub issuer: Option<String>,
658    /// The account at that service, when the URI named one.
659    pub account: Option<String>,
660}
661
662impl Stored {
663    /// Rebuilds an `otpauth://` URI, for exporting the seed back to a phone.
664    ///
665    /// The URI **contains the secret**, so every caller of this is a
666    /// materialising path: it is refused to stdout without `--reveal`, exactly
667    /// as the seed itself is.
668    pub fn to_uri(&self, issuer_fallback: &str, account_fallback: &str) -> String {
669        let issuer = self.issuer.as_deref().unwrap_or(issuer_fallback);
670        let account = self.account.as_deref().unwrap_or(account_fallback);
671        let label = if issuer.is_empty() {
672            pct(account)
673        } else {
674            format!("{}:{}", pct(issuer), pct(account))
675        };
676        // Steam is written as `otpauth://totp/…&encoder=steam` rather than as
677        // `otpauth://steam/`: both spellings exist, Aegis reads either, and the
678        // `totp` path is the one every *other* authenticator will at least
679        // import as a working — if wrongly rendered — seed instead of rejecting
680        // the line outright.
681        let path = match self.params.kind {
682            Kind::Hotp => "hotp",
683            Kind::Totp | Kind::Steam => "totp",
684        };
685        let mut uri = format!("otpauth://{path}/{label}?secret={}", self.secret);
686        if !issuer.is_empty() {
687            uri.push_str(&format!("&issuer={}", pct(issuer)));
688        }
689        uri.push_str(&format!(
690            "&algorithm={}&digits={}",
691            self.params.algorithm.as_str(),
692            self.params.digits,
693        ));
694        match self.params.kind {
695            // A counter-based URI carries its position instead of a period. An
696            // exporter that dropped it would hand the next phone a seed starting
697            // from zero, which is a second factor that fails until it is resynced.
698            Kind::Hotp => uri.push_str(&format!("&counter={}", self.params.counter)),
699            Kind::Totp => uri.push_str(&format!("&period={}", self.params.period)),
700            Kind::Steam => uri.push_str(&format!("&period={}&encoder=steam", self.params.period)),
701        }
702        uri
703    }
704}
705
706/// Percent-decodes, leaving anything malformed alone.
707///
708/// A label that ends in a bare `%` is a typo in someone's export, not an attack,
709/// and dropping the whole seed over it helps nobody.
710fn pct_decode(s: &str) -> String {
711    let bytes = s.as_bytes();
712    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
713    let mut i = 0;
714    while i < bytes.len() {
715        if bytes[i] == b'%' && i + 2 < bytes.len() {
716            let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
717            if let Some(b) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
718                out.push(b);
719                i += 3;
720                continue;
721            }
722        }
723        // `+` is a form-encoding convention, not a URI one, but exports written
724        // by web tooling use it in the label and a literal plus in an account
725        // name is vanishingly rare next to a space that reads as one.
726        out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] });
727        i += 1;
728    }
729    String::from_utf8_lossy(&out).into_owned()
730}
731
732/// Normalises a base32 secret: uppercase, no spaces, dashes or padding.
733///
734/// The value that reaches the vault, so that two entries holding the same seed
735/// typed differently are byte-identical and their fingerprints match.
736pub fn normalize_b32(raw: &str) -> String {
737    raw.chars()
738        .filter(|c| !c.is_whitespace() && *c != '-' && *c != '=')
739        .flat_map(|c| c.to_uppercase())
740        .collect()
741}
742
743/// Reads either a bare base32 seed or a full `otpauth://` URI.
744///
745/// One entry point because the form field, the CLI flag and the paste handler
746/// all accept whichever the user has to hand, and a user who pastes a URI into a
747/// box labelled "secret" is doing the reasonable thing.
748pub fn parse_seed(input: &str) -> Result<Stored, String> {
749    let trimmed = input.trim();
750    if trimmed.is_empty() {
751        return Err("empty TOTP secret".into());
752    }
753    if trimmed.len() >= 8 && trimmed[..8].eq_ignore_ascii_case("otpauth:") {
754        return parse_otpauth(trimmed);
755    }
756    let secret = normalize_b32(trimmed);
757    validate_secret(&secret)?;
758    Ok(Stored {
759        secret,
760        params: Params::default(),
761        issuer: None,
762        account: None,
763    })
764}
765
766/// Rejects a secret that cannot produce a code, before it is stored.
767///
768/// Storing an unusable seed is worse than refusing it: the entry then shows a
769/// code field that is permanently blank, and the user has no way to tell a
770/// mistyped secret from a bug.
771fn validate_secret(secret: &str) -> Result<(), String> {
772    if secret.is_empty() {
773        return Err("empty TOTP secret".into());
774    }
775    let bytes = Zeroizing::new(base32_decode(secret)?);
776    if bytes.is_empty() {
777        return Err("TOTP secret decodes to no bytes".into());
778    }
779    Ok(())
780}
781
782/// Parses an `otpauth://totp/...` URI.
783///
784/// Deliberately strict about the scheme and the type, and forgiving about
785/// everything else: an unknown query parameter is ignored, an unreadable
786/// `digits` falls back to the default rather than failing, and only a missing or
787/// unusable `secret` is fatal. A URI is pasted from a third party's export and
788/// half of them are slightly wrong.
789pub fn parse_otpauth(uri: &str) -> Result<Stored, String> {
790    let rest = uri
791        .get(..10)
792        .filter(|p| p.eq_ignore_ascii_case("otpauth://"))
793        .and_then(|_| uri.get(10..))
794        .ok_or("not an otpauth:// URI")?;
795
796    let (path, query) = match rest.split_once('?') {
797        Some((p, q)) => (p, q),
798        None => (rest, ""),
799    };
800    let (kind, label) = match path.split_once('/') {
801        Some((k, l)) => (k, l),
802        None => (path, ""),
803    };
804    // Phase 22 refused everything but `totp` here, because an `hotp` seed with
805    // nowhere to keep its counter shows a code that never changes. The entry can
806    // hold a counter now, so the three kinds are read and anything else is still
807    // refused by name.
808    let kind = Kind::parse(kind).ok_or_else(|| {
809        format!("only otpauth://totp/, //hotp/ and //steam/ are supported, got 'otpauth://{kind}/'")
810    })?;
811
812    let mut secret = String::new();
813    let mut issuer_param: Option<String> = None;
814    let mut params = Params {
815        kind,
816        ..Params::default()
817    };
818    for pair in query.split('&').filter(|p| !p.is_empty()) {
819        let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
820        match k.to_ascii_lowercase().as_str() {
821            "secret" => secret = normalize_b32(&pct_decode(v)),
822            "issuer" => {
823                let decoded = pct_decode(v);
824                if !decoded.trim().is_empty() {
825                    issuer_param = Some(decoded.trim().to_string());
826                }
827            }
828            "algorithm" => {
829                if let Some(a) = Algorithm::parse(&pct_decode(v)) {
830                    params.algorithm = a;
831                }
832            }
833            "digits" => {
834                if let Ok(d) = pct_decode(v).trim().parse::<u32>() {
835                    if (MIN_DIGITS..=MAX_DIGITS).contains(&d) {
836                        params.digits = d;
837                    }
838                }
839            }
840            // RFC 4226 calls this the "initial counter value"; every exporter
841            // writes the *next* value to use, which is what the entry stores.
842            "counter" => {
843                if let Ok(c) = pct_decode(v).trim().parse::<u64>() {
844                    params.counter = c;
845                }
846            }
847            // Aegis writes `encoder=steam` on an `otpauth://totp/` URI rather
848            // than using the `steam` path, so a seed exported from it would
849            // otherwise import as an ordinary six-digit TOTP and produce codes
850            // Steam rejects. Recognised by this parameter and by the path — never
851            // by the issuer *name*, which is text a user can edit into anything.
852            "encoder" if pct_decode(v).trim().eq_ignore_ascii_case("steam") => {
853                params.kind = Kind::Steam;
854            }
855            "period" => {
856                if let Ok(p) = pct_decode(v).trim().parse::<u64>() {
857                    if p > 0 && p <= MAX_PERIOD_SECS {
858                        params.period = p;
859                    }
860                }
861            }
862            _ => {}
863        }
864    }
865
866    validate_secret(&secret)?;
867    // `encoder=steam` can arrive after `digits=6`, and the `steam` path arrives
868    // before any parameter at all, so the shape Steam fixes is forced once the
869    // whole query has been read rather than in whichever arm saw it first.
870    params = params.steam_normalised();
871    params.validate()?;
872
873    // The label is `issuer:account`, and the `issuer` query parameter repeats
874    // it. Where the two disagree the parameter wins — it is the one an exporter
875    // writes deliberately, while the label half is often whatever the user typed
876    // into their phone years ago.
877    let label = pct_decode(label);
878    let (label_issuer, account) = match label.split_once(':') {
879        Some((i, a)) => (
880            Some(i.trim().to_string()).filter(|s| !s.is_empty()),
881            a.trim().to_string(),
882        ),
883        None => (None, label.trim().to_string()),
884    };
885
886    Ok(Stored {
887        secret,
888        params,
889        issuer: issuer_param.or(label_issuer),
890        account: Some(account).filter(|a| !a.is_empty()),
891    })
892}
893
894/// The counter a seed uses at a given moment.
895///
896/// The one place the three kinds differ before the arithmetic starts: two divide
897/// the clock, one ignores it entirely and reads the number the vault stored.
898fn counter_at(params: &Params, unix_secs: u64) -> u64 {
899    match params.kind {
900        Kind::Hotp => params.counter,
901        Kind::Totp | Kind::Steam => unix_secs / params.period.max(1),
902    }
903}
904
905/// The code a stored seed produces at a given time.
906///
907/// For an `Hotp` seed the time is ignored and `params.counter` is used, so this
908/// is still the single entry point for every kind and no caller has to know
909/// which it holds.
910pub fn code_at(secret_b32: &str, params: &Params, unix_secs: u64) -> Result<String, String> {
911    code_for_counter(secret_b32, params, counter_at(params, unix_secs))
912}
913
914/// The code for one explicit counter value.
915///
916/// Exposed because "the next code" is this function at `counter + 1`, and
917/// deriving it that way keeps one arithmetic path rather than two that must
918/// agree.
919pub fn code_for_counter(secret_b32: &str, params: &Params, counter: u64) -> Result<String, String> {
920    params.validate()?;
921    // Zeroized on drop: this is the decoded seed, and it is the one value in
922    // this function that would still be readable in a heap dump afterwards.
923    let secret = Zeroizing::new(base32_decode(secret_b32)?);
924    if secret.is_empty() {
925        return Err("empty TOTP secret".into());
926    }
927    Ok(match params.kind {
928        Kind::Steam => steam_with(&secret, counter),
929        Kind::Totp | Kind::Hotp => hotp_with(&secret, counter, params.algorithm, params.digits),
930    })
931}
932
933/// The code that replaces the current one.
934///
935/// For a time-based seed that is the next step; for an `Hotp` seed it is the
936/// next counter, which is the value the *service* will expect after this one is
937/// spent. Both are the same operation, which is the point.
938pub fn next_code_at(secret_b32: &str, params: &Params, unix_secs: u64) -> Result<String, String> {
939    code_for_counter(secret_b32, params, counter_at(params, unix_secs) + 1)
940}
941
942/// Seconds until the current code is replaced.
943///
944/// Zero is never returned: at the instant of a step boundary the *new* code has
945/// a full period ahead of it, and a countdown that reads 0 for one second in
946/// every period is a countdown users report as a bug.
947pub fn remaining_secs(period: u64, unix_secs: u64) -> u64 {
948    let period = period.clamp(1, MAX_PERIOD_SECS);
949    period - (unix_secs % period)
950}
951
952/// A code and how long it has left — the shape the desktop app and the CLI both
953/// render.
954#[derive(Debug, Clone, Serialize, Deserialize)]
955pub struct LiveCode {
956    pub code: String,
957    /// The code that replaces `code`, when the caller asked for it.
958    ///
959    /// Absent unless requested: it is a second live credential, and a panel that
960    /// always painted one would put two working codes in every screenshot.
961    #[serde(skip_serializing_if = "Option::is_none")]
962    pub next_code: Option<String>,
963    /// What produced it, so a renderer knows whether a countdown means anything.
964    pub kind: Kind,
965    /// The counter this code was generated at. For an `Hotp` seed it is the
966    /// entry's stored position, which is what the user is deciding whether to
967    /// advance.
968    pub counter: u64,
969    /// Seconds until `code` is replaced, or **0 for an `Hotp` seed**, which has
970    /// no clock and whose code is replaced only when somebody advances it.
971    pub remaining_secs: u64,
972    /// Echoed back so a caller can draw a countdown without re-deriving it from
973    /// the entry, and so a stale response is recognisable as stale.
974    pub period: u64,
975    pub digits: u32,
976    pub algorithm: Algorithm,
977}
978
979/// The code a stored seed produces right now, with its countdown.
980pub fn live_code(secret_b32: &str, params: &Params) -> Result<LiveCode, String> {
981    live_code_with(secret_b32, params, false)
982}
983
984/// The same, optionally carrying the code that comes next.
985///
986/// `with_next` is a parameter rather than always-on because the next code is a
987/// second working credential with a longer life than the one on screen: a
988/// screenshot of a panel showing both is good for up to two periods rather than
989/// one. The authenticator screen puts it behind a toggle and the CLI behind
990/// `--next`.
991pub fn live_code_with(
992    secret_b32: &str,
993    params: &Params,
994    with_next: bool,
995) -> Result<LiveCode, String> {
996    let now = now_unix();
997    Ok(LiveCode {
998        code: code_at(secret_b32, params, now)?,
999        next_code: if with_next {
1000            Some(next_code_at(secret_b32, params, now)?)
1001        } else {
1002            None
1003        },
1004        kind: params.kind,
1005        counter: counter_at(params, now),
1006        // An `Hotp` code is not replaced by the passage of time, and a countdown
1007        // that never moves reads as a frozen UI. Zero says "no clock" and the
1008        // renderer draws no ring.
1009        remaining_secs: if params.kind.is_time_based() {
1010            remaining_secs(params.period, now)
1011        } else {
1012            0
1013        },
1014        period: params.period,
1015        digits: params.digits,
1016        algorithm: params.algorithm,
1017    })
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023
1024    #[test]
1025    fn base32_round_trips_every_remainder_length() {
1026        // The tail handling has a different shape for each input length mod 5,
1027        // and the left-align in the final group is the part that is easy to get
1028        // wrong — it is silent, and produces a secret an authenticator accepts
1029        // and then disagrees with.
1030        for n in 0..=16usize {
1031            let data: Vec<u8> = (0..n).map(|i| (i as u8).wrapping_mul(37)).collect();
1032            let enc = base32_encode(&data);
1033            assert!(
1034                enc.chars().all(|c| B32_ALPHABET.contains(&(c as u8))),
1035                "non-alphabet char in {enc}"
1036            );
1037            assert_eq!(
1038                base32_decode(&enc).unwrap(),
1039                data,
1040                "round trip failed at {n}"
1041            );
1042        }
1043    }
1044
1045    #[test]
1046    fn base32_matches_rfc_4648_vectors() {
1047        // Without a published vector this file could be self-consistently wrong,
1048        // and every authenticator on earth would disagree with it.
1049        for (input, expect) in [
1050            ("", ""),
1051            ("f", "MY"),
1052            ("fo", "MZXQ"),
1053            ("foo", "MZXW6"),
1054            ("foob", "MZXW6YQ"),
1055            ("fooba", "MZXW6YTB"),
1056            ("foobar", "MZXW6YTBOI"),
1057        ] {
1058            assert_eq!(
1059                base32_encode(input.as_bytes()),
1060                expect,
1061                "encoding {input:?}"
1062            );
1063        }
1064    }
1065
1066    #[test]
1067    fn hotp_matches_rfc_4226_vectors() {
1068        // RFC 4226 Appendix D, the ASCII secret "12345678901234567890".
1069        let secret = b"12345678901234567890";
1070        for (counter, expect) in [
1071            (0u64, "755224"),
1072            (1, "287082"),
1073            (2, "359152"),
1074            (3, "969429"),
1075            (4, "338314"),
1076            (5, "254676"),
1077            (6, "287922"),
1078            (7, "162583"),
1079            (8, "399871"),
1080            (9, "520489"),
1081        ] {
1082            assert_eq!(hotp(secret, counter), expect, "counter {counter}");
1083        }
1084    }
1085
1086    #[test]
1087    fn totp_matches_rfc_6238_sha1_vectors() {
1088        let secret = base32_encode(b"12345678901234567890");
1089        // RFC 6238 Appendix B, SHA-1 rows, truncated to our six digits.
1090        for (t, expect) in [
1091            (59u64, "287082"),
1092            (1111111109, "081804"),
1093            (1111111111, "050471"),
1094            (1234567890, "005924"),
1095            (2000000000, "279037"),
1096        ] {
1097            assert_eq!(totp_at(&secret, t).unwrap(), expect, "t={t}");
1098        }
1099    }
1100
1101    #[test]
1102    fn accepts_the_current_code_and_one_step_of_skew_either_way() {
1103        let secret = generate_secret();
1104        let now = 1_700_000_000u64;
1105        for offset in [-(STEP_SECS as i64), 0, STEP_SECS as i64] {
1106            let at = (now as i64 + offset) as u64;
1107            let code = totp_at(&secret, at).unwrap();
1108            assert!(
1109                verify(&secret, &code, None, now).is_some(),
1110                "offset {offset} should be inside the skew window"
1111            );
1112        }
1113        // Two steps out is outside the window.
1114        let far = totp_at(&secret, now + 2 * STEP_SECS).unwrap();
1115        assert!(verify(&secret, &far, None, now).is_none());
1116    }
1117
1118    #[test]
1119    fn refuses_a_code_whose_step_was_already_used() {
1120        // The replay this whole module exists to prevent: without the last_step
1121        // check, an observed code stays valid for the rest of its ninety-second
1122        // window and anyone who saw it can use it.
1123        let secret = generate_secret();
1124        let now = 1_700_000_000u64;
1125        let code = totp_at(&secret, now).unwrap();
1126
1127        let first = verify(&secret, &code, None, now).expect("first use accepted");
1128        assert_eq!(first.step, step_at(now));
1129        assert!(
1130            verify(&secret, &code, Some(first.step), now).is_none(),
1131            "the same code must not be accepted twice"
1132        );
1133        // And nor may the *earlier* code from the skew window, which is still
1134        // arithmetically valid but is a step the user has already moved past.
1135        let earlier = totp_at(&secret, now - STEP_SECS).unwrap();
1136        assert!(verify(&secret, &earlier, Some(first.step), now).is_none());
1137    }
1138
1139    #[test]
1140    fn tolerates_the_spacing_a_phone_displays() {
1141        let secret = generate_secret();
1142        let now = now_unix();
1143        let code = totp_at(&secret, now).unwrap();
1144        let spaced = format!("{} {}", &code[..3], &code[3..]);
1145        assert!(verify(&secret, &spaced, None, now).is_some());
1146    }
1147
1148    #[test]
1149    fn refuses_malformed_input_rather_than_panicking() {
1150        let secret = generate_secret();
1151        let now = now_unix();
1152        for bad in ["", "12345", "1234567", "abcdef", "!!!!!!"] {
1153            assert!(
1154                verify(&secret, bad, None, now).is_none(),
1155                "accepted {bad:?}"
1156            );
1157        }
1158        assert!(verify("not base32 ∅", "123456", None, now).is_none());
1159        assert!(verify("", "123456", None, now).is_none());
1160    }
1161
1162    #[test]
1163    fn otpauth_uri_escapes_a_username_that_would_break_the_query() {
1164        // A username is user-supplied. Unescaped, `a&issuer=Evil` would append a
1165        // parameter of the attacker's choosing to the URI a user is about to
1166        // paste into their authenticator.
1167        let uri = otpauth_uri("UnENVerse", "a&issuer=Evil?x=1", "ABCD");
1168        assert!(uri.contains("a%26issuer%3DEvil%3Fx%3D1"), "{uri}");
1169        assert_eq!(uri.matches("issuer=").count(), 1, "{uri}");
1170    }
1171
1172    #[test]
1173    fn grouped_secret_decodes_to_the_same_bytes_as_the_ungrouped_one() {
1174        // The UI shows the grouped form and users type it back with the spaces.
1175        let secret = generate_secret();
1176        assert_eq!(
1177            base32_decode(&grouped(&secret)).unwrap(),
1178            base32_decode(&secret).unwrap()
1179        );
1180    }
1181
1182    // ── Stored third-party seeds (Phase 22) ──────────────────────────────────
1183
1184    /// RFC 6238 Appendix B seeds. The three are different lengths on purpose:
1185    /// the spec's SHA-256 and SHA-512 vectors use 32- and 64-byte keys, and a
1186    /// generator that quietly truncated or padded would still match the SHA-1
1187    /// row and fail only for the users who have a SHA-512 seed.
1188    fn rfc6238_seed(algorithm: Algorithm) -> String {
1189        let ascii: &[u8] = match algorithm {
1190            Algorithm::Sha1 => b"12345678901234567890",
1191            Algorithm::Sha256 => b"12345678901234567890123456789012",
1192            Algorithm::Sha512 => {
1193                b"1234567890123456789012345678901234567890123456789012345678901234"
1194            }
1195        };
1196        base32_encode(ascii)
1197    }
1198
1199    #[test]
1200    fn code_at_matches_rfc_6238_for_all_three_algorithms() {
1201        // Without these the SHA-256 and SHA-512 arms could be self-consistently
1202        // wrong: they would produce six plausible digits that the issuer
1203        // rejects, and nothing in the app could tell the user which end was at
1204        // fault. Eight digits because that is the width the RFC tabulates —
1205        // which also exercises the non-default `digits`.
1206        let rows: [(u64, &str, &str, &str); 6] = [
1207            (59, "94287082", "46119246", "90693936"),
1208            (1111111109, "07081804", "68084774", "25091201"),
1209            (1111111111, "14050471", "67062674", "99943326"),
1210            (1234567890, "89005924", "91819424", "93441116"),
1211            (2000000000, "69279037", "90698825", "38618901"),
1212            (20000000000, "65353130", "77737706", "47863826"),
1213        ];
1214        for (t, sha1, sha256, sha512) in rows {
1215            for (algorithm, want) in [
1216                (Algorithm::Sha1, sha1),
1217                (Algorithm::Sha256, sha256),
1218                (Algorithm::Sha512, sha512),
1219            ] {
1220                let params = Params {
1221                    kind: Kind::Totp,
1222                    counter: 0,
1223                    algorithm,
1224                    digits: 8,
1225                    period: 30,
1226                };
1227                assert_eq!(
1228                    code_at(&rfc6238_seed(algorithm), &params, t).unwrap(),
1229                    want,
1230                    "{} at t={t}",
1231                    algorithm.as_str()
1232                );
1233            }
1234        }
1235    }
1236
1237    #[test]
1238    fn the_default_params_reproduce_the_login_paths_answer() {
1239        // `hotp` delegates to `hotp_with`, so the two must agree for every
1240        // secret or Phase 19's login would start failing the moment Phase 21
1241        // touched the generator.
1242        let secret = generate_secret();
1243        let now = 1_700_000_000u64;
1244        assert_eq!(
1245            code_at(&secret, &Params::default(), now).unwrap(),
1246            totp_at(&secret, now).unwrap()
1247        );
1248    }
1249
1250    #[test]
1251    fn a_bare_base32_seed_parses_with_the_omitted_defaults() {
1252        let stored = parse_seed("jbsw y3dp ehpk 3pxp").unwrap();
1253        // Normalised: the vault holds one spelling, so two entries carrying the
1254        // same seed typed differently fingerprint the same.
1255        assert_eq!(stored.secret, "JBSWY3DPEHPK3PXP");
1256        assert!(stored.params.is_default());
1257        assert_eq!(stored.issuer, None);
1258        assert_eq!(stored.account, None);
1259    }
1260
1261    #[test]
1262    fn an_otpauth_uri_parses_label_issuer_and_every_parameter() {
1263        let stored = parse_seed(
1264            "otpauth://totp/GitHub:darth%40example.com?secret=JBSWY3DPEHPK3PXP\
1265             &issuer=GitHub&algorithm=SHA256&digits=8&period=60",
1266        )
1267        .unwrap();
1268        assert_eq!(stored.secret, "JBSWY3DPEHPK3PXP");
1269        assert_eq!(stored.params.algorithm, Algorithm::Sha256);
1270        assert_eq!(stored.params.digits, 8);
1271        assert_eq!(stored.params.period, 60);
1272        assert_eq!(stored.issuer.as_deref(), Some("GitHub"));
1273        assert_eq!(stored.account.as_deref(), Some("darth@example.com"));
1274    }
1275
1276    #[test]
1277    fn the_issuer_parameter_beats_the_label_when_they_disagree() {
1278        // The label half is often years-old text a user typed into a phone; the
1279        // parameter is what an exporter wrote deliberately.
1280        let stored =
1281            parse_seed("otpauth://totp/Stale:me?secret=JBSWY3DPEHPK3PXP&issuer=Current").unwrap();
1282        assert_eq!(stored.issuer.as_deref(), Some("Current"));
1283        assert_eq!(stored.account.as_deref(), Some("me"));
1284    }
1285
1286    #[test]
1287    fn a_uri_with_no_secret_is_refused_rather_than_stored_unusable() {
1288        // Storing it would give the entry a code field that is permanently
1289        // blank, with nothing on screen distinguishing that from a bug.
1290        assert!(parse_seed("otpauth://totp/Acme:me?issuer=Acme").is_err());
1291        assert!(parse_seed("otpauth://totp/Acme:me?secret=").is_err());
1292        assert!(parse_seed("otpauth://totp/Acme:me?secret=!!!!").is_err());
1293    }
1294
1295    #[test]
1296    fn a_counter_based_uri_keeps_its_counter() {
1297        // Phase 22 refused this by name, because an `hotp` seed with nowhere to
1298        // keep its position produces a card whose code never changes. The entry
1299        // holds a counter now (Phase 22.2), so the URI is read — and the counter
1300        // is the half that must survive: an `hotp` seed imported at zero is a
1301        // second factor that fails until the account is resynced.
1302        let stored = parse_seed("otpauth://hotp/Acme:me?secret=JBSWY3DPEHPK3PXP&counter=7")
1303            .expect("hotp is read");
1304        assert_eq!(stored.params.kind, Kind::Hotp);
1305        assert_eq!(stored.params.counter, 7);
1306        assert!(!stored.params.kind.is_time_based());
1307
1308        // A scheme nothing here implements is still refused, and still by name.
1309        let err = parse_seed("otpauth://yubico/Acme:me?secret=JBSWY3DPEHPK3PXP")
1310            .expect_err("an unknown type must be refused");
1311        assert!(err.contains("yubico"), "{err}");
1312    }
1313
1314    #[test]
1315    fn a_counter_based_code_ignores_the_clock_and_advances_only_with_its_counter() {
1316        // The whole difference between the two kinds, asserted directly: time
1317        // moves and the code does not; the counter moves and it does.
1318        let p = Params {
1319            kind: Kind::Hotp,
1320            counter: 3,
1321            ..Params::default()
1322        };
1323        let at_zero = code_at("JBSWY3DPEHPK3PXP", &p, 0).unwrap();
1324        let much_later = code_at("JBSWY3DPEHPK3PXP", &p, 5_000_000).unwrap();
1325        assert_eq!(at_zero, much_later, "an hotp code must not move with time");
1326
1327        let next = Params { counter: 4, ..p };
1328        assert_ne!(at_zero, code_at("JBSWY3DPEHPK3PXP", &next, 0).unwrap());
1329        // And "the next code" is exactly the next counter, not the next step.
1330        assert_eq!(
1331            next_code_at("JBSWY3DPEHPK3PXP", &p, 0).unwrap(),
1332            code_at("JBSWY3DPEHPK3PXP", &next, 0).unwrap()
1333        );
1334        // No clock means no countdown; a ring that never moves reads as a
1335        // frozen UI, so the renderer is told there is nothing to draw.
1336        assert_eq!(live_code("JBSWY3DPEHPK3PXP", &p).unwrap().remaining_secs, 0);
1337    }
1338
1339    #[test]
1340    fn steam_matches_an_independent_implementation() {
1341        // Steam publishes no test vectors, so these were computed by a separate
1342        // implementation written from the algorithm description and checked
1343        // against this one at a pinned step — not by running this code twice.
1344        // Without fixed vectors the only check available is "the two agree
1345        // right now", which passes for two identical mistakes.
1346        let p = Params {
1347            kind: Kind::Steam,
1348            ..Params::default()
1349        }
1350        .steam_normalised();
1351        for (counter, want) in [(0u64, "VH8YJ"), (1, "2YXGV"), (59_636_971, "5TC5V")] {
1352            assert_eq!(
1353                code_for_counter("JBSWY3DPEHPK3PXP", &p, counter).unwrap(),
1354                want,
1355                "steam code at counter {counter}"
1356            );
1357        }
1358        // The same seed at the same counter under the ordinary renderer is a
1359        // different answer, which is the whole reason `Kind` exists.
1360        assert_ne!(
1361            code_for_counter("JBSWY3DPEHPK3PXP", &Params::default(), 0).unwrap(),
1362            "VH8YJ"
1363        );
1364    }
1365
1366    #[test]
1367    fn a_steam_seed_produces_five_characters_from_steams_alphabet() {
1368        let p = Params {
1369            kind: Kind::Steam,
1370            ..Params::default()
1371        }
1372        .steam_normalised();
1373        let code = code_at("JBSWY3DPEHPK3PXP", &p, 0).unwrap();
1374        assert_eq!(code.len(), STEAM_DIGITS as usize, "{code}");
1375        for c in code.chars() {
1376            assert!(
1377                STEAM_ALPHABET.contains(&(c as u8)),
1378                "{c} is not in Steam's alphabet"
1379            );
1380        }
1381        // Same HMAC underneath: it is the rendering that differs, which is why
1382        // this is a `Kind` and not an `Algorithm`.
1383        assert_ne!(code, hotp(&base32_decode("JBSWY3DPEHPK3PXP").unwrap(), 0));
1384        // It moves with the clock like any time-based seed.
1385        assert_ne!(code, code_at("JBSWY3DPEHPK3PXP", &p, 30).unwrap());
1386    }
1387
1388    #[test]
1389    fn steam_forces_its_own_shape_whatever_the_file_said() {
1390        // A generic exporter writes `digits: 6` beside a Steam seed. Six
1391        // characters is not something Steam accepts, and `MIN_DIGITS` would
1392        // otherwise refuse the five it does.
1393        let p = Params::from_fields(Some("steam"), Some("SHA512"), Some(8), Some(60), Some(9));
1394        assert_eq!(p.digits, STEAM_DIGITS);
1395        assert_eq!(p.algorithm, Algorithm::Sha1);
1396        assert_eq!(p.period, STEP_SECS);
1397        assert_eq!(p.counter, 0, "a time-based seed carries no counter");
1398        p.validate().expect("steam's own shape must validate");
1399
1400        // Aegis writes the encoder as a parameter on a `totp` URI rather than
1401        // using the `steam` path, and the parameter can arrive after `digits`.
1402        let stored =
1403            parse_seed("otpauth://totp/Steam:me?secret=JBSWY3DPEHPK3PXP&digits=6&encoder=steam")
1404                .expect("aegis's spelling is read");
1405        assert_eq!(stored.params.kind, Kind::Steam);
1406        assert_eq!(stored.params.digits, STEAM_DIGITS);
1407    }
1408
1409    #[test]
1410    fn every_kind_round_trips_through_its_uri() {
1411        for (kind, counter) in [(Kind::Totp, 0), (Kind::Hotp, 42), (Kind::Steam, 0)] {
1412            let stored = Stored {
1413                secret: "JBSWY3DPEHPK3PXP".into(),
1414                params: Params {
1415                    kind,
1416                    counter,
1417                    ..Params::default()
1418                }
1419                .steam_normalised(),
1420                issuer: Some("Acme".into()),
1421                account: Some("me".into()),
1422            };
1423            let back = parse_seed(&stored.to_uri("", "")).expect("its own URI parses");
1424            assert_eq!(back.params.kind, kind, "kind for {kind:?}");
1425            assert_eq!(back.params.counter, counter, "counter for {kind:?}");
1426            assert_eq!(back.secret, stored.secret);
1427        }
1428    }
1429
1430    #[test]
1431    fn nonsense_parameters_fall_back_to_the_defaults_instead_of_failing() {
1432        // Half of the URIs people paste come out of someone else's export and
1433        // are slightly wrong. Losing the seed over an unreadable `digits` is a
1434        // worse outcome than generating six digits.
1435        let stored = parse_seed(
1436            "otpauth://totp/Acme:me?secret=JBSWY3DPEHPK3PXP&digits=nine&period=0\
1437             &algorithm=WHIRLPOOL&unknown=1",
1438        )
1439        .unwrap();
1440        assert!(stored.params.is_default());
1441    }
1442
1443    #[test]
1444    fn a_lowercase_scheme_and_a_sha_dash_256_still_parse() {
1445        let stored =
1446            parse_seed("OTPAUTH://TOTP/Acme:me?secret=jbswy3dpehpk3pxp&algorithm=sha-256").unwrap();
1447        assert_eq!(stored.secret, "JBSWY3DPEHPK3PXP");
1448        assert_eq!(stored.params.algorithm, Algorithm::Sha256);
1449    }
1450
1451    #[test]
1452    fn to_uri_round_trips_through_the_parser() {
1453        // The exported URI is what a user scans into a replacement phone. One
1454        // that does not read back is a lockout discovered at the worst moment.
1455        let original = parse_seed(
1456            "otpauth://totp/Acme%20Corp:d%40e.com?secret=JBSWY3DPEHPK3PXP\
1457             &issuer=Acme%20Corp&algorithm=SHA512&digits=7&period=45",
1458        )
1459        .unwrap();
1460        let round = parse_seed(&original.to_uri("", "")).unwrap();
1461        assert_eq!(round, original);
1462    }
1463
1464    #[test]
1465    fn to_uri_falls_back_to_the_entrys_own_names_and_escapes_them() {
1466        // The fallbacks are the entry's provider and account, which are vault
1467        // data and therefore untrusted (CLAUDE.md invariant 4). Unescaped,
1468        // `a&issuer=Evil` would append a parameter of the writer's choosing.
1469        let stored = parse_seed("JBSWY3DPEHPK3PXP").unwrap();
1470        let uri = stored.to_uri("Acme", "a&issuer=Evil");
1471        assert!(uri.contains("a%26issuer%3DEvil"), "{uri}");
1472        assert_eq!(uri.matches("issuer=").count(), 1, "{uri}");
1473        assert_eq!(parse_seed(&uri).unwrap().secret, stored.secret);
1474    }
1475
1476    #[test]
1477    fn remaining_secs_counts_down_a_full_period_and_never_reaches_zero() {
1478        // A countdown that reads 0 for one second in every period gets reported
1479        // as a bug; at a step boundary the *new* code has a full period ahead.
1480        assert_eq!(remaining_secs(30, 0), 30);
1481        assert_eq!(remaining_secs(30, 1), 29);
1482        assert_eq!(remaining_secs(30, 29), 1);
1483        assert_eq!(remaining_secs(30, 30), 30);
1484        assert_eq!(remaining_secs(60, 119), 1);
1485        // A period of zero cannot divide; the clamp is what stops a malformed
1486        // stored entry from panicking a card renderer.
1487        assert_eq!(remaining_secs(0, 12345), 1);
1488    }
1489
1490    #[test]
1491    fn params_refuse_the_values_that_cannot_produce_a_code() {
1492        for bad in [
1493            Params {
1494                digits: 5,
1495                ..Default::default()
1496            },
1497            Params {
1498                digits: 11,
1499                ..Default::default()
1500            },
1501            Params {
1502                period: 0,
1503                ..Default::default()
1504            },
1505            Params {
1506                period: MAX_PERIOD_SECS + 1,
1507                ..Default::default()
1508            },
1509        ] {
1510            assert!(bad.validate().is_err(), "{bad:?} should be refused");
1511            assert!(code_at("JBSWY3DPEHPK3PXP", &bad, 0).is_err());
1512        }
1513        assert!(Params::default().validate().is_ok());
1514    }
1515
1516    #[test]
1517    fn live_code_agrees_with_code_at_and_its_own_countdown() {
1518        let stored = parse_seed("JBSWY3DPEHPK3PXP").unwrap();
1519        let live = live_code(&stored.secret, &stored.params).unwrap();
1520        assert_eq!(live.code.len(), stored.params.digits as usize);
1521        assert!(live.remaining_secs >= 1 && live.remaining_secs <= live.period);
1522        // The code is the one for the step the countdown belongs to. Deriving
1523        // them from two different `now` readings is how a card shows a code that
1524        // expires a second later.
1525        let step_start = now_unix() + live.remaining_secs - live.period;
1526        assert_eq!(
1527            code_at(&stored.secret, &stored.params, step_start).unwrap(),
1528            live.code
1529        );
1530    }
1531}