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::drbg::otp::{hotp, totp, HashAlgorithm};
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
159pub(crate) fn 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 *encode* (RFC 4648 §5 alphabet, A-Z + 2-7, no padding,
191/// uppercase). Used internally to format TOTP shared secrets for
192/// `otpauth://` URIs; the same alphabet is consumed by [`base32_decode`]
193/// on the import side. Made `pub` so `origin-tools/origin-pass` can
194/// reuse the SDK's encoder (no crate-local base32 dep needed).
195pub fn base32_encode(data: &[u8]) -> String {
196    const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
197    let mut out = String::new();
198    let mut buffer: u64 = 0;
199    let mut bits = 0u32;
200
201    for &byte in data {
202        buffer = (buffer << 8) | (byte as u64);
203        bits += 8;
204        while bits >= 5 {
205            bits -= 5;
206            let idx = ((buffer >> bits) & 0x1F) as usize;
207            out.push(ALPHABET[idx] as char);
208        }
209    }
210    if bits > 0 {
211        let idx = ((buffer << (5 - bits)) & 0x1F) as usize;
212        out.push(ALPHABET[idx] as char);
213    }
214    out
215}
216
217/// Minimal base32 *decode* (RFC 4648 §5 alphabet, A-Z + 2-7). Strips
218/// trailing `=` padding if present (`otpauth://` URIs omit padding;
219/// we accept both forms). Uppercase only — lowercase input returns
220/// `Err`.
221///
222/// Inverse of [`base32_encode`]. Used by
223/// `origin-tools/origin-pass`'s `cmd_import_qr` to recover the raw
224/// shared secret from an `otpauth://` URI's `secret=` query parameter.
225pub fn base32_decode(s: &str) -> Result<Vec<u8>, String> {
226    const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
227    let mut acc: u64 = 0;
228    let mut bits = 0u32;
229    let mut out = Vec::with_capacity(s.len() * 5 / 8);
230    for byte in s.bytes() {
231        if byte == b'=' {
232            break; // padding terminates the stream.
233        }
234        let v = match ALPHABET.iter().position(|&c| c == byte) {
235            Some(p) => p as u64,
236            None => return Err(format!("invalid base32 character: {:?}", byte as char)),
237        };
238        acc = (acc << 5) | v;
239        bits += 5;
240        if bits >= 8 {
241            bits -= 8;
242            out.push(((acc >> bits) & 0xff) as u8);
243        }
244    }
245    Ok(out)
246}
247
248/// Minimal percent-encoding for URI components.
249fn url_encode(s: &str) -> String {
250    let mut out = String::new();
251    for byte in s.bytes() {
252        match byte {
253            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
254                out.push(byte as char);
255            }
256            _ => {
257                out.push_str(&format!("%{:02X}", byte));
258            }
259        }
260    }
261    out
262}
263
264// ---------------------------------------------------------------------------
265// Tests
266// ---------------------------------------------------------------------------
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    // RFC 4226 Appendix D test vectors (SHA-1, 6 digits)
273    const RFC_SECRET: &[u8] = b"12345678901234567890";
274
275    #[test]
276    fn rfc4226_test_vectors() {
277        let expected = [
278            755224, 287082, 359152, 969429, 338314, 254676, 287922, 162583, 399871, 520489,
279        ];
280        for (counter, &expected_code) in expected.iter().enumerate() {
281            let code = hotp(RFC_SECRET, counter as u64, 6, HashAlgorithm::Sha1);
282            assert_eq!(code, expected_code, "HOTP mismatch at counter {counter}");
283        }
284    }
285
286    #[test]
287    fn totp_deterministic() {
288        let secret = b"test-secret";
289        let code1 = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
290        let code2 = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
291        assert_eq!(code1, code2);
292    }
293
294    #[test]
295    fn totp_different_timestamps() {
296        let secret = b"test-secret";
297        // Two timestamps in different 30-second windows
298        let code1 = totp(secret, 1718000000, 30, 6, HashAlgorithm::Sha256);
299        let code2 = totp(secret, 1718000031, 30, 6, HashAlgorithm::Sha256);
300        // Very unlikely to be equal
301        assert_ne!(code1, code2);
302    }
303
304    #[test]
305    fn digits_6_7_8() {
306        let secret = b"test-secret";
307        let c6 = hotp(secret, 0, 6, HashAlgorithm::Sha256);
308        let c7 = hotp(secret, 0, 7, HashAlgorithm::Sha256);
309        let c8 = hotp(secret, 0, 8, HashAlgorithm::Sha256);
310        assert!(c6 < 1_000_000, "6-digit code out of range");
311        assert!(c7 < 10_000_000, "7-digit code out of range");
312        assert!(c8 < 100_000_000, "8-digit code out of range");
313    }
314
315    #[test]
316    fn format_code_leading_zeros() {
317        assert_eq!(format_code(42, 6), "000042");
318        assert_eq!(format_code(123456, 6), "123456");
319        assert_eq!(format_code(7, 8), "00000007");
320    }
321
322    #[test]
323    fn base32_encoding() {
324        // Known vector: "Hello" → "JBSWY3DP"
325        assert_eq!(base32_encode(b"Hello"), "JBSWY3DP");
326        assert_eq!(base32_encode(b"\x00"), "AA");
327        assert_eq!(base32_encode(b"f"), "MY");
328    }
329
330    #[test]
331    fn base32_round_trip_and_rfc4648_vectors() {
332        // Round-trip the custom vectors preserved from `base32_encoding`.
333        assert_eq!(base32_decode("JBSWY3DP").unwrap(), b"Hello");
334        assert_eq!(base32_decode("AA").unwrap(), b"\x00");
335        assert_eq!(base32_decode("MY").unwrap(), b"f");
336
337        // RFC 4648 §10 canonical test vectors (no padding — otpauth:// form).
338        // These are the canonical outputs of the standard upper-case RFC 4648
339        // base32 alphabet, with `=` padding stripped (otpauth:// convention).
340        assert_eq!(base32_encode(b"f"), "MY"); //  8 bits -> 2 chars
341        assert_eq!(base32_encode(b"fo"), "MZXQ"); // 16 bits -> 4 chars
342        assert_eq!(base32_encode(b"foo"), "MZXW6"); // 24 bits -> 5 chars (4 bits dropped)
343        assert_eq!(base32_encode(b"foob"), "MZXW6YQ"); // 32 bits -> 7 chars
344        assert_eq!(base32_encode(b"fooba"), "MZXW6YTB"); // 40 bits -> 8 chars (full byte boundary)
345        assert_eq!(base32_encode(b"foobar"), "MZXW6YTBOI"); // 48 bits -> 10 chars
346
347        // Decode round-trips the canonical outputs back to original bytes.
348        assert_eq!(base32_decode("MZXW6").unwrap(), b"foo");
349        assert_eq!(base32_decode("MZXW6YQ").unwrap(), b"foob");
350        assert_eq!(base32_decode("MZXW6YTB").unwrap(), b"fooba");
351        assert_eq!(base32_decode("MZXW6YTBOI").unwrap(), b"foobar");
352
353        // Padding tolerated if present (RFC 4648 §5 strict with `=` padding).
354        assert_eq!(base32_decode("MZXW6YQ=").unwrap(), b"foob");
355        assert_eq!(base32_decode("MY======").unwrap(), b"f");
356
357        // Rejection: invalid character.
358        assert!(base32_decode("JBSWY3D!").is_err());
359        // Rejection: lowercase (RFC 4648 requires uppercase A-Z).
360        assert!(base32_decode("jbswy3dp").is_err());
361    }
362
363    #[test]
364    fn hotp_uri_format() {
365        let uri = hotp_uri(
366            b"secret",
367            "TestApp",
368            "user@example.com",
369            0,
370            6,
371            HashAlgorithm::Sha256,
372        );
373        assert!(uri.starts_with("otpauth://hotp/"));
374        assert!(uri.contains("algorithm=SHA256"));
375        assert!(uri.contains("counter=0"));
376        assert!(uri.contains("digits=6"));
377    }
378
379    #[test]
380    fn totp_uri_format() {
381        let uri = totp_uri(
382            b"secret",
383            "TestApp",
384            "user@example.com",
385            30,
386            6,
387            HashAlgorithm::Sha256,
388        );
389        assert!(uri.starts_with("otpauth://totp/"));
390        assert!(uri.contains("period=30"));
391    }
392
393    #[test]
394    fn different_algorithms_different_codes() {
395        let secret = b"test-secret";
396        let c_sha1 = hotp(secret, 0, 6, HashAlgorithm::Sha1);
397        let c_sha256 = hotp(secret, 0, 6, HashAlgorithm::Sha256);
398        let c_sha512 = hotp(secret, 0, 6, HashAlgorithm::Sha512);
399        // All three should be different
400        assert_ne!(c_sha1, c_sha256);
401        assert_ne!(c_sha256, c_sha512);
402    }
403}