Skip to main content

roka_totp/
lib.rs

1#![deny(missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3
4//! Zero-dependency TOTP / HOTP implementation with optional QR code support.
5//!
6//! `roka-totp` is a from-scratch implementation of [RFC 6238] (TOTP) and
7//! [RFC 4226] (HOTP). It carries its own SHA-1, HMAC, and Base32 — no crypto
8//! crate is pulled in, no `unsafe` is used.
9//!
10//! # Quick start
11//!
12//! ```
13//! use roka_totp::{Totp, Secret};
14//!
15//! let secret = Secret::from_base32("JBSWY3DPEHPK3PXP")?;
16//! let totp = Totp::builder(secret)
17//!     .issuer("Acme")
18//!     .account("alice@example.com")
19//!     .build();
20//!
21//! let code: String = totp.code_at(0); // current 6-digit OTP at UNIX time 0
22//! assert_eq!(code, "282760");
23//!
24//! let uri = totp.uri();
25//! assert!(uri.starts_with("otpauth://totp/Acme:alice"));
26//! # Ok::<(), roka_totp::Error>(())
27//! ```
28//!
29//! # Highlights
30//!
31//! - **Zero external crate dependencies** — `std` only.
32//! - **No `unsafe`**.
33//! - **RFC test vectors verified** — SHA-1 (RFC 3174), HMAC (RFC 2202), HOTP
34//!   (RFC 4226 Appendix D), TOTP (RFC 6238 Appendix B).
35//! - **otpauth URI build** ready for QR pairing.
36//!
37//! [RFC 4226]: https://datatracker.ietf.org/doc/html/rfc4226
38//! [RFC 6238]: https://datatracker.ietf.org/doc/html/rfc6238
39
40mod base32;
41mod hmac;
42mod hotp;
43mod otpauth;
44mod sha1;
45mod totp;
46
47use std::time::{SystemTime, UNIX_EPOCH};
48
49/// Default time step in seconds (30 — the RFC 6238 standard).
50pub const DEFAULT_STEP: u64 = totp::DEFAULT_STEP;
51/// Default OTP digit count (6).
52pub const DEFAULT_DIGITS: u32 = totp::DEFAULT_DIGITS;
53
54/// Errors produced by `roka-totp`.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Error {
57    /// Base32 input was malformed.
58    InvalidBase32(String),
59    /// otpauth URI was malformed.
60    InvalidUri(&'static str),
61}
62
63impl core::fmt::Display for Error {
64    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
65        match self {
66            Error::InvalidBase32(s) => write!(f, "invalid base32: {s}"),
67            Error::InvalidUri(s) => write!(f, "invalid otpauth URI: {s}"),
68        }
69    }
70}
71
72impl std::error::Error for Error {}
73
74/// Hash algorithm used to derive OTPs.
75///
76/// Currently only SHA-1 is supported; SHA-256 / SHA-512 are reserved for a
77/// future release. (The otpauth URI standard supports all three.)
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum Algorithm {
80    /// SHA-1 — the RFC 6238 baseline and what every authenticator app supports.
81    #[default]
82    Sha1,
83}
84
85/// A TOTP / HOTP shared secret (raw bytes).
86///
87/// Wrap secret bytes in this newtype rather than passing `Vec<u8>` directly —
88/// this prevents accidentally treating a base32 string as raw bytes.
89#[derive(Clone, PartialEq, Eq)]
90pub struct Secret(Vec<u8>);
91
92impl Secret {
93    /// Decode a base32 string into a [`Secret`].
94    pub fn from_base32(s: &str) -> Result<Self, Error> {
95        base32::decode(s).map(Secret).map_err(Error::InvalidBase32)
96    }
97
98    /// Wrap raw secret bytes.
99    pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Self {
100        Secret(bytes.into())
101    }
102
103    /// Borrow the raw secret bytes.
104    pub fn as_bytes(&self) -> &[u8] {
105        &self.0
106    }
107
108    /// Encode the secret as base32 (no `=` padding, no grouping).
109    pub fn to_base32(&self) -> String {
110        let s = base32::encode(&self.0);
111        s.trim_end_matches('=').to_string()
112    }
113
114    /// Encode the secret as 4-character grouped base32, easy for humans to type.
115    pub fn to_base32_grouped(&self) -> String {
116        base32::encode_grouped(&self.0)
117    }
118}
119
120impl core::fmt::Debug for Secret {
121    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
122        // Don't leak the secret in Debug output.
123        write!(f, "Secret(<{} bytes>)", self.0.len())
124    }
125}
126
127/// HOTP — RFC 4226 counter-based one-time password.
128///
129/// Use this when the verifier and the prover share a monotonically increasing
130/// counter (e.g. hardware token with a button). For time-synchronized OTPs use
131/// [`Totp`] instead.
132#[derive(Debug, Clone)]
133pub struct Hotp {
134    secret: Secret,
135    digits: u32,
136    algorithm: Algorithm,
137}
138
139impl Hotp {
140    /// Create a new HOTP with default digits (6) and algorithm (SHA-1).
141    pub fn new(secret: Secret) -> Self {
142        Self {
143            secret,
144            digits: DEFAULT_DIGITS,
145            algorithm: Algorithm::default(),
146        }
147    }
148
149    /// Override the digit count (typically 6 or 8).
150    pub fn digits(mut self, digits: u32) -> Self {
151        self.digits = digits;
152        self
153    }
154
155    /// OTP at the given counter.
156    pub fn code_at(&self, counter: u64) -> String {
157        let _ = self.algorithm; // SHA-1 only for now
158        hotp::hotp(self.secret.as_bytes(), counter, self.digits)
159    }
160}
161
162/// TOTP — RFC 6238 time-based one-time password.
163///
164/// Construct via [`Totp::builder`]. See the [module-level docs](crate) for an
165/// end-to-end example.
166#[derive(Debug, Clone)]
167pub struct Totp {
168    secret: Secret,
169    issuer: String,
170    account: String,
171    digits: u32,
172    step: u64,
173    algorithm: Algorithm,
174}
175
176impl Totp {
177    /// Start configuring a TOTP. Defaults: digits=6, step=30s, algorithm=SHA-1.
178    pub fn builder(secret: Secret) -> TotpBuilder {
179        TotpBuilder {
180            secret,
181            issuer: String::new(),
182            account: String::new(),
183            digits: DEFAULT_DIGITS,
184            step: DEFAULT_STEP,
185            algorithm: Algorithm::default(),
186        }
187    }
188
189    /// The OTP at the given UNIX time (seconds since epoch).
190    pub fn code_at(&self, unix_time: u64) -> String {
191        let _ = self.algorithm;
192        totp::totp(self.secret.as_bytes(), unix_time, self.step, self.digits)
193    }
194
195    /// The OTP at the current system time.
196    pub fn code_now(&self) -> String {
197        self.code_at(unix_now())
198    }
199
200    /// Seconds remaining in the current TOTP window at the given UNIX time.
201    pub fn seconds_remaining_at(&self, unix_time: u64) -> u64 {
202        totp::seconds_remaining(unix_time, self.step)
203    }
204
205    /// Seconds remaining in the current TOTP window at the current system time.
206    pub fn seconds_remaining_now(&self) -> u64 {
207        self.seconds_remaining_at(unix_now())
208    }
209
210    /// Verify a user-supplied code against the current window ± `window` steps.
211    ///
212    /// Returns `Some(offset)` where `offset` is the window difference (0 means
213    /// the current window matched), or `None` if the code is invalid.
214    pub fn verify(&self, code: &str, unix_time: u64, window: u32) -> Option<i64> {
215        totp::verify(
216            self.secret.as_bytes(),
217            code,
218            unix_time,
219            self.step,
220            self.digits,
221            window as i64,
222        )
223    }
224
225    /// Issuer (the service name shown in authenticator apps).
226    pub fn issuer(&self) -> &str {
227        &self.issuer
228    }
229
230    /// Account (the user identity shown in authenticator apps).
231    pub fn account(&self) -> &str {
232        &self.account
233    }
234
235    /// The shared secret.
236    pub fn secret(&self) -> &Secret {
237        &self.secret
238    }
239
240    /// Build the `otpauth://totp/` URI suitable for QR code pairing.
241    pub fn uri(&self) -> String {
242        otpauth::build_uri(&self.issuer, &self.account, self.secret.as_bytes())
243    }
244}
245
246/// Builder for [`Totp`].
247pub struct TotpBuilder {
248    secret: Secret,
249    issuer: String,
250    account: String,
251    digits: u32,
252    step: u64,
253    algorithm: Algorithm,
254}
255
256impl TotpBuilder {
257    /// Set the issuer (service name).
258    pub fn issuer(mut self, s: impl Into<String>) -> Self {
259        self.issuer = s.into();
260        self
261    }
262
263    /// Set the account name.
264    pub fn account(mut self, s: impl Into<String>) -> Self {
265        self.account = s.into();
266        self
267    }
268
269    /// Override the digit count (default 6).
270    pub fn digits(mut self, digits: u32) -> Self {
271        self.digits = digits;
272        self
273    }
274
275    /// Override the time step in seconds (default 30).
276    pub fn step(mut self, step: u64) -> Self {
277        self.step = step;
278        self
279    }
280
281    /// Override the hash algorithm (currently SHA-1 only).
282    pub fn algorithm(mut self, algorithm: Algorithm) -> Self {
283        self.algorithm = algorithm;
284        self
285    }
286
287    /// Finalize the builder.
288    pub fn build(self) -> Totp {
289        Totp {
290            secret: self.secret,
291            issuer: self.issuer,
292            account: self.account,
293            digits: self.digits,
294            step: self.step,
295            algorithm: self.algorithm,
296        }
297    }
298}
299
300fn unix_now() -> u64 {
301    SystemTime::now()
302        .duration_since(UNIX_EPOCH)
303        .expect("system clock before UNIX epoch")
304        .as_secs()
305}