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), ¶ms, 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}