Skip to main content

origin_crypto_sdk/drbg/
otp.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! HMAC-based One-Time Password (HOTP) and Time-based One-Time Password (TOTP).
4//!
5//! Implements RFC 4226 (HOTP) and RFC 6238 (TOTP) — ported from the spork/cryp
6//! Python prototypes into proper Rust with constant-time HMAC and zeroization.
7//!
8//! # Quick start
9//!
10//! ```
11//! use origin_crypto_sdk::otp::{hotp, totp};
12//!
13//! let secret = b"my-secret-key-1234";
14//!
15//! // HOTP: counter-based
16//! let code = hotp(secret, 0, 6, HashAlgorithm::Sha256);
17//!
18//! // TOTP: time-based (30-second step, starting from Unix epoch)
19//! let code = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
20//! ```
21
22use hmac::{Hmac, Mac};
23use sha1::Sha1;
24use sha2::{Sha256, Sha512};
25
26// ---------------------------------------------------------------------------
27// Hash algorithm selection
28// ---------------------------------------------------------------------------
29
30/// Hash algorithm for HOTP/TOTP computation.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum HashAlgorithm {
33    /// SHA-1 (legacy compatibility only — do not use for new applications).
34    Sha1,
35    /// SHA-256 (recommended).
36    Sha256,
37    /// SHA-512 (high security).
38    Sha512,
39}
40
41// ---------------------------------------------------------------------------
42// HOTP — RFC 4226
43// ---------------------------------------------------------------------------
44
45/// Compute an HOTP code (RFC 4226).
46///
47/// # Arguments
48/// * `secret` — shared secret key
49/// * `counter` — moving factor (must be synchronised between prover and verifier)
50/// * `digits` — number of digits in the output code (6, 7, or 8)
51/// * `algo` — hash algorithm
52///
53/// # Returns
54/// The HOTP code as a `u32` (e.g. `123456` for 6 digits).
55pub fn hotp(secret: &[u8], counter: u64, digits: u32, algo: HashAlgorithm) -> u32 {
56    // Encode counter as 8-byte big-endian
57    let counter_bytes = counter.to_be_bytes();
58
59    // HMAC(secret, counter)
60    let hmac_result = compute_hmac(secret, &counter_bytes, algo);
61
62    // Dynamic truncation (RFC 4226 §5.4)
63    let offset = (hmac_result.last().expect("HMAC output non-empty") & 0x0F) as usize;
64    let truncated = u32::from_be_bytes([
65        hmac_result[offset],
66        hmac_result[offset + 1],
67        hmac_result[offset + 2],
68        hmac_result[offset + 3],
69    ]) & 0x7FFF_FFFF;
70
71    truncated % (10_u32.pow(digits))
72}
73
74// ---------------------------------------------------------------------------
75// TOTP — RFC 6238
76// ---------------------------------------------------------------------------
77
78/// Compute a TOTP code (RFC 6238).
79///
80/// # Arguments
81/// * `secret` — shared secret key
82/// * `timestamp` — Unix timestamp (seconds since epoch)
83/// * `time_step` — time step in seconds (typically 30)
84/// * `digits` — number of digits in the output code (6, 7, or 8)
85/// * `algo` — hash algorithm
86///
87/// # Returns
88/// The TOTP code as a `u32`.
89pub fn totp(
90    secret: &[u8],
91    timestamp: u64,
92    time_step: u64,
93    digits: u32,
94    algo: HashAlgorithm,
95) -> u32 {
96    let counter = timestamp / time_step;
97    hotp(secret, counter, digits, algo)
98}
99
100/// Format a TOTP/HOTP code with leading zeros.
101pub fn format_code(code: u32, digits: u32) -> String {
102    format!("{:0>width$}", code, width = digits as usize)
103}
104
105// ---------------------------------------------------------------------------
106// URI generation (otpauth:// scheme — Google Authenticator compatible)
107// ---------------------------------------------------------------------------
108
109/// Generate an `otpauth://hotp/` URI for QR provisioning.
110pub fn hotp_uri(
111    secret: &[u8],
112    issuer: &str,
113    account: &str,
114    counter: u64,
115    digits: u32,
116    algo: HashAlgorithm,
117) -> String {
118    let secret_b32 = base32_encode(secret);
119    let algo_name = algo_str(algo);
120    format!(
121        "otpauth://hotp/{}:{}?secret={}&issuer={}&counter={}&digits={}&algorithm={}",
122        url_encode(issuer),
123        url_encode(account),
124        secret_b32,
125        url_encode(issuer),
126        counter,
127        digits,
128        algo_name,
129    )
130}
131
132/// Generate an `otpauth://totp/` URI for QR provisioning.
133pub fn totp_uri(
134    secret: &[u8],
135    issuer: &str,
136    account: &str,
137    period: u64,
138    digits: u32,
139    algo: HashAlgorithm,
140) -> String {
141    let secret_b32 = base32_encode(secret);
142    let algo_name = algo_str(algo);
143    format!(
144        "otpauth://totp/{}:{}?secret={}&issuer={}&period={}&digits={}&algorithm={}",
145        url_encode(issuer),
146        url_encode(account),
147        secret_b32,
148        url_encode(issuer),
149        period,
150        digits,
151        algo_name,
152    )
153}
154
155// ---------------------------------------------------------------------------
156// Internal helpers
157// ---------------------------------------------------------------------------
158
159fn compute_hmac(secret: &[u8], message: &[u8], algo: HashAlgorithm) -> Vec<u8> {
160    match algo {
161        HashAlgorithm::Sha1 => {
162            type HmacSha1 = Hmac<Sha1>;
163            let mut mac = HmacSha1::new_from_slice(secret).expect("HMAC accepts any key length");
164            mac.update(message);
165            mac.finalize().into_bytes().to_vec()
166        }
167        HashAlgorithm::Sha256 => {
168            type HmacSha256 = Hmac<Sha256>;
169            let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
170            mac.update(message);
171            mac.finalize().into_bytes().to_vec()
172        }
173        HashAlgorithm::Sha512 => {
174            type HmacSha512 = Hmac<Sha512>;
175            let mut mac = HmacSha512::new_from_slice(secret).expect("HMAC accepts any key length");
176            mac.update(message);
177            mac.finalize().into_bytes().to_vec()
178        }
179    }
180}
181
182fn algo_str(algo: HashAlgorithm) -> &'static str {
183    match algo {
184        HashAlgorithm::Sha1 => "SHA1",
185        HashAlgorithm::Sha256 => "SHA256",
186        HashAlgorithm::Sha512 => "SHA512",
187    }
188}
189
190/// Minimal base32 encoding (no padding, uppercase).
191fn base32_encode(data: &[u8]) -> String {
192    const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
193    let mut out = String::new();
194    let mut buffer: u64 = 0;
195    let mut bits = 0u32;
196
197    for &byte in data {
198        buffer = (buffer << 8) | (byte as u64);
199        bits += 8;
200        while bits >= 5 {
201            bits -= 5;
202            let idx = ((buffer >> bits) & 0x1F) as usize;
203            out.push(ALPHABET[idx] as char);
204        }
205    }
206    if bits > 0 {
207        let idx = ((buffer << (5 - bits)) & 0x1F) as usize;
208        out.push(ALPHABET[idx] as char);
209    }
210    out
211}
212
213/// Minimal percent-encoding for URI components.
214fn url_encode(s: &str) -> String {
215    let mut out = String::new();
216    for byte in s.bytes() {
217        match byte {
218            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
219                out.push(byte as char);
220            }
221            _ => {
222                out.push_str(&format!("%{:02X}", byte));
223            }
224        }
225    }
226    out
227}
228
229// ---------------------------------------------------------------------------
230// Tests
231// ---------------------------------------------------------------------------
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    // RFC 4226 Appendix D test vectors (SHA-1, 6 digits)
238    const RFC_SECRET: &[u8] = b"12345678901234567890";
239
240    #[test]
241    fn rfc4226_test_vectors() {
242        let expected = [
243            755224, 287082, 359152, 969429, 338314, 254676, 287922, 162583, 399871, 520489,
244        ];
245        for (counter, &expected_code) in expected.iter().enumerate() {
246            let code = hotp(RFC_SECRET, counter as u64, 6, HashAlgorithm::Sha1);
247            assert_eq!(code, expected_code, "HOTP mismatch at counter {counter}");
248        }
249    }
250
251    #[test]
252    fn totp_deterministic() {
253        let secret = b"test-secret";
254        let code1 = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
255        let code2 = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
256        assert_eq!(code1, code2);
257    }
258
259    #[test]
260    fn totp_different_timestamps() {
261        let secret = b"test-secret";
262        // Two timestamps in different 30-second windows
263        let code1 = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
264        let code2 = totp(secret, 1718000031, 30, 6, HashAlgorithm::Sha256);
265        // Very unlikely to be equal
266        assert_ne!(code1, code2);
267    }
268
269    #[test]
270    fn digits_6_7_8() {
271        let secret = b"test-secret";
272        let c6 = hotp(secret, 0, 6, HashAlgorithm::Sha256);
273        let c7 = hotp(secret, 0, 7, HashAlgorithm::Sha256);
274        let c8 = hotp(secret, 0, 8, HashAlgorithm::Sha256);
275        assert!(c6 < 1_000_000, "6-digit code out of range");
276        assert!(c7 < 10_000_000, "7-digit code out of range");
277        assert!(c8 < 100_000_000, "8-digit code out of range");
278    }
279
280    #[test]
281    fn format_code_leading_zeros() {
282        assert_eq!(format_code(42, 6), "000042");
283        assert_eq!(format_code(123456, 6), "123456");
284        assert_eq!(format_code(7, 8), "00000007");
285    }
286
287    #[test]
288    fn base32_encoding() {
289        // Known vector: "Hello" → "JBSWY3DP"
290        assert_eq!(base32_encode(b"Hello"), "JBSWY3DP");
291        assert_eq!(base32_encode(b"\x00"), "AA");
292        assert_eq!(base32_encode(b"f"), "MY");
293    }
294
295    #[test]
296    fn hotp_uri_format() {
297        let uri = hotp_uri(
298            b"secret",
299            "TestApp",
300            "user@example.com",
301            0,
302            6,
303            HashAlgorithm::Sha256,
304        );
305        assert!(uri.starts_with("otpauth://hotp/"));
306        assert!(uri.contains("algorithm=SHA256"));
307        assert!(uri.contains("counter=0"));
308        assert!(uri.contains("digits=6"));
309    }
310
311    #[test]
312    fn totp_uri_format() {
313        let uri = totp_uri(
314            b"secret",
315            "TestApp",
316            "user@example.com",
317            30,
318            6,
319            HashAlgorithm::Sha256,
320        );
321        assert!(uri.starts_with("otpauth://totp/"));
322        assert!(uri.contains("period=30"));
323    }
324
325    #[test]
326    fn different_algorithms_different_codes() {
327        let secret = b"test-secret";
328        let c_sha1 = hotp(secret, 0, 6, HashAlgorithm::Sha1);
329        let c_sha256 = hotp(secret, 0, 6, HashAlgorithm::Sha256);
330        let c_sha512 = hotp(secret, 0, 6, HashAlgorithm::Sha512);
331        // All three should be different
332        assert_ne!(c_sha1, c_sha256);
333        assert_ne!(c_sha256, c_sha512);
334    }
335}