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}