From db246ef0748d1ebca3ac928097475fcb964171aa Mon Sep 17 00:00:00 2001 From: Kang Date: Fri, 10 Apr 2026 15:40:25 +0800 Subject: [PATCH] chore(otp): vendor local hotp and totp crate --- src-tauri/.gitignore | 1 + src-tauri/Cargo.lock | 5 + src-tauri/Cargo.toml | 1 + src-tauri/crates/otp/Cargo.lock | 7 + src-tauri/crates/otp/Cargo.toml | 4 + src-tauri/crates/otp/src/alg/mod.rs | 116 ++++ src-tauri/crates/otp/src/alg/sha1.rs | 90 +++ src-tauri/crates/otp/src/alg/sha256.rs | 121 ++++ src-tauri/crates/otp/src/alg/sha512.rs | 148 +++++ src-tauri/crates/otp/src/encoding/base32.rs | 91 +++ src-tauri/crates/otp/src/encoding/hex.rs | 71 +++ src-tauri/crates/otp/src/encoding/mod.rs | 5 + src-tauri/crates/otp/src/encoding/url.rs | 71 +++ src-tauri/crates/otp/src/hmac.rs | 387 +++++++++++++ src-tauri/crates/otp/src/hotp.rs | 578 ++++++++++++++++++++ src-tauri/crates/otp/src/lib.rs | 63 +++ src-tauri/crates/otp/src/secret.rs | 56 ++ src-tauri/crates/otp/src/totp.rs | 470 ++++++++++++++++ 18 files changed, 2285 insertions(+) create mode 100644 src-tauri/crates/otp/Cargo.lock create mode 100644 src-tauri/crates/otp/Cargo.toml create mode 100644 src-tauri/crates/otp/src/alg/mod.rs create mode 100644 src-tauri/crates/otp/src/alg/sha1.rs create mode 100644 src-tauri/crates/otp/src/alg/sha256.rs create mode 100644 src-tauri/crates/otp/src/alg/sha512.rs create mode 100644 src-tauri/crates/otp/src/encoding/base32.rs create mode 100644 src-tauri/crates/otp/src/encoding/hex.rs create mode 100644 src-tauri/crates/otp/src/encoding/mod.rs create mode 100644 src-tauri/crates/otp/src/encoding/url.rs create mode 100644 src-tauri/crates/otp/src/hmac.rs create mode 100644 src-tauri/crates/otp/src/hotp.rs create mode 100644 src-tauri/crates/otp/src/lib.rs create mode 100644 src-tauri/crates/otp/src/secret.rs create mode 100644 src-tauri/crates/otp/src/totp.rs diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd681..9f1a0602 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -1,6 +1,7 @@ # Generated by Cargo # will have compiled files and executables /target/ +/crates/*/target/ # Generated by Tauri # will have schema files for capabilities auto-completion diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fbc31f44..2ba661fd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1346,6 +1346,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "dirs", + "dragonfly-otp", "encoding_rs", "font-kit", "hex", @@ -1379,6 +1380,10 @@ dependencies = [ "zip", ] +[[package]] +name = "dragonfly-otp" +version = "0.1.0" + [[package]] name = "dtoa" version = "1.0.11" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 891cad1f..9f0d94cb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +dragonfly-otp = { path = "crates/otp" } tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } diff --git a/src-tauri/crates/otp/Cargo.lock b/src-tauri/crates/otp/Cargo.lock new file mode 100644 index 00000000..c6a8c796 --- /dev/null +++ b/src-tauri/crates/otp/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "dragonfly-otp" +version = "0.1.0" diff --git a/src-tauri/crates/otp/Cargo.toml b/src-tauri/crates/otp/Cargo.toml new file mode 100644 index 00000000..b0130d75 --- /dev/null +++ b/src-tauri/crates/otp/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "dragonfly-otp" +version = "0.1.0" +edition = "2021" diff --git a/src-tauri/crates/otp/src/alg/mod.rs b/src-tauri/crates/otp/src/alg/mod.rs new file mode 100644 index 00000000..de86cfc2 --- /dev/null +++ b/src-tauri/crates/otp/src/alg/mod.rs @@ -0,0 +1,116 @@ +//! Cryptographic hash algorithm implementations for HMAC-based OTPs. +//! +//! This module provides support for SHA-1, SHA-256, and SHA-512 algorithms, +//! as defined in [RFC 4226] (HOTP) and [RFC 6238] (TOTP). +//! +//! It exposes a common [`Algorithm`] enum used for abstracting over the different hash functions, +//! with convenience methods for computing hashes in both raw bytes and hexadecimal formats. +//! +//! # Examples +//! ```rust +//! use otp::Algorithm; +//! +//! let data = b"The quick brown fox jumps over the lazy dog"; +//! let alg = Algorithm::SHA256; +//! +//! let hex_hash = alg.hash_hex(data); +//! println!("SHA-256: {}", hex_hash); +//! +//! let raw_hash = alg.hash_bytes(data); +//! assert_eq!(raw_hash.len(), 32); // 256-bit output +//! ``` +//! +//! [RFC 4226]: https://datatracker.ietf.org/doc/html/rfc4226 +//! [RFC 6238]: https://datatracker.ietf.org/doc/html/rfc6238 + +mod sha1; +mod sha256; +mod sha512; + +pub use self::sha1::sha1; +pub use self::sha256::sha256; +pub use self::sha512::sha512; + +/// Enumeration of supported cryptographic hash algorithms for use with HMAC. +/// +/// This enum allows users to choose between SHA-1, SHA-256, and SHA-512 +/// as required by OTP generation specifications. +/// +/// The default value is `SHA1`, which is the original algorithm used in HOTP. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub enum Algorithm { + #[default] + SHA1, + SHA256, + SHA512, +} + +impl Algorithm { + /// Hashes binary input data using the selected algorithm, returning the result as a hex string. + /// + /// # Arguments + /// * `data` - The input string to hash + /// + /// # Returns + /// A hexadecimal representation of the hash output. + /// + /// # Example + /// ```rust + /// use otp::Algorithm; + /// + /// let sha1_hash = Algorithm::SHA1.hash_hex(b""); + /// assert_eq!(sha1_hash.len(), 40); // 160-bit = 20 bytes = 40 hex chars + /// + /// let sha256_hash = Algorithm::SHA256.hash_hex(b""); + /// assert_eq!(sha256_hash.len(), 64); // 256-bit = 32 bytes = 64 hex chars + /// + /// let sha512_hash = Algorithm::SHA512.hash_hex(b""); + /// assert_eq!(sha512_hash.len(), 128); // 512-bit = 64 bytes = 128 hex chars + /// ``` + pub fn hash_hex(&self, data: &[u8]) -> String { + match self { + Algorithm::SHA1 => crate::encoding::hex::encode(&self::sha1(data)), + Algorithm::SHA256 => crate::encoding::hex::encode(&self::sha256(data)), + Algorithm::SHA512 => crate::encoding::hex::encode(&self::sha512(data)), + } + } + + /// Hashes binary input data using the selected algorithm, returning raw bytes. + /// + /// # Arguments + /// * `data` - A byte slice of input data to hash + /// + /// # Returns + /// A `Vec` containing the hash output. + /// + /// # Example + /// ```rust + /// use otp::Algorithm; + /// + /// let sha1_hash = Algorithm::SHA1.hash_bytes(b""); + /// assert_eq!(sha1_hash.len(), 20); // 160-bit = 20 bytes + /// + /// let sha256_hash = Algorithm::SHA256.hash_bytes(b""); + /// assert_eq!(sha256_hash.len(), 32); // 256-bit = 32 bytes + /// + /// let sha512_hash = Algorithm::SHA512.hash_bytes(b""); + /// assert_eq!(sha512_hash.len(), 64); // 512-bit = 64 bytes + /// ``` + pub fn hash_bytes(&self, data: &[u8]) -> Vec { + match self { + Algorithm::SHA1 => self::sha1(data).to_vec(), + Algorithm::SHA256 => self::sha256(data).to_vec(), + Algorithm::SHA512 => self::sha512(data).to_vec(), + } + } +} + +impl std::fmt::Display for Algorithm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Algorithm::SHA1 => f.write_str("SHA1"), + Algorithm::SHA256 => f.write_str("SHA256"), + Algorithm::SHA512 => f.write_str("SHA512"), + } + } +} diff --git a/src-tauri/crates/otp/src/alg/sha1.rs b/src-tauri/crates/otp/src/alg/sha1.rs new file mode 100644 index 00000000..614d1315 --- /dev/null +++ b/src-tauri/crates/otp/src/alg/sha1.rs @@ -0,0 +1,90 @@ +//! # Source: + +/// # Example: +/// ```rust +/// use otp::Algorithm; +/// +/// let alg = Algorithm::SHA1; +/// let hash = alg.hash_hex(b"The quick brown fox jumps over the lazy dog"); +/// assert_eq!("2fd4e1c67a2d28fced849ee1bb76e7391b93eb12", &hash); +/// ``` +pub fn sha1(input: &[u8]) -> [u8; 20] { + // SHA-1 constants + let mut state: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0]; + + const BLOCK_SIZE: usize = 64; + const PREP_ROUNDS: usize = 16; + const EXTENSION_ROUNDS: usize = 80; + const COMPRESSION_ROUNDS: usize = 80; + + let bit_len = (input.len() as u64) * 8; + let mut padded = input.to_vec(); + + // mark of the end of the original message. + padded.push(0x80); + + // pad with zeroes until the length mod 64 is = 56. + padded.resize(((padded.len() + BLOCK_SIZE) & !63_usize) - 8, 0x00); + + // reserves the last 8 bytes for the bit-length field. + padded.extend_from_slice(&bit_len.to_be_bytes()); + + // process 512-bit blocks + for chunk in padded.chunks(BLOCK_SIZE) { + let mut words = [0_u32; COMPRESSION_ROUNDS]; + + // w[0..16]: schedule preparation + for i in 0..PREP_ROUNDS { + words[i] = u32::from_be_bytes([ + chunk[i * 4], + chunk[i * 4 + 1], + chunk[i * 4 + 2], + chunk[i * 4 + 3], + ]); + } + + // w[16..80]: schedule extension + for i in PREP_ROUNDS..EXTENSION_ROUNDS { + words[i] = (words[i - 3] ^ words[i - 8] ^ words[i - 14] ^ words[i - 16]).rotate_left(1); + } + + let [mut a, mut b, mut c, mut d, mut e] = state; + + #[allow(clippy::needless_range_loop)] + for i in 0..COMPRESSION_ROUNDS { + let (f, k) = match i { + 0..20 => ((b & c) | (!b & d), 0x5A827999), + 20..40 => (b ^ c ^ d, 0x6ED9EBA1), + 40..60 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC), + _ => (b ^ c ^ d, 0xCA62C1D6), + }; + + let temp = a + .rotate_left(5) + .wrapping_add(f) + .wrapping_add(e) + .wrapping_add(k) + .wrapping_add(words[i]); + + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = temp; + } + + // next state + state[0] = state[0].wrapping_add(a); + state[1] = state[1].wrapping_add(b); + state[2] = state[2].wrapping_add(c); + state[3] = state[3].wrapping_add(d); + state[4] = state[4].wrapping_add(e); + } + + let mut digest = [0_u8; 20]; + for (i, word) in state.iter().enumerate() { + digest[i * 4..(i + 1) * 4].copy_from_slice(&word.to_be_bytes()); + } + + digest +} diff --git a/src-tauri/crates/otp/src/alg/sha256.rs b/src-tauri/crates/otp/src/alg/sha256.rs new file mode 100644 index 00000000..dd92e02b --- /dev/null +++ b/src-tauri/crates/otp/src/alg/sha256.rs @@ -0,0 +1,121 @@ +//! # Sources: + +#[rustfmt::skip] +const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, +]; + +/// # Example: +/// ```rust +/// use otp::Algorithm; +/// +/// let alg = Algorithm::SHA256; +/// +/// let empty_hash = alg.hash_hex(b""); +/// assert_eq!("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", &empty_hash); +/// +/// let hash = alg.hash_hex(b"The quick brown fox jumps over the lazy dog"); +/// assert_eq!("d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", &hash); +/// ``` +pub fn sha256(input: &[u8]) -> [u8; 32] { + #[rustfmt::skip] + let mut state: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]; + + const BLOCK_SIZE: usize = 64; + const PREP_ROUNDS: usize = 16; + const EXTENSION_ROUNDS: usize = 64; + const COMPRESSION_ROUNDS: usize = 64; + + let bit_len = (input.len() as u64) * 8; + + let mut padded = input.to_vec(); + + // mark of the end of the original message. + padded.push(0x80); + + // pad with zeroes until the length mod 64 is = 56. + padded.resize(((padded.len() + BLOCK_SIZE) & !63_usize) - 8, 0x00); + + // reserves the last 8 bytes for the bit-length field. + padded.extend_from_slice(&bit_len.to_be_bytes()); + + for chunk in padded.chunks(BLOCK_SIZE) { + let mut words = [0_u32; COMPRESSION_ROUNDS]; + + // w[0..16]: schedule preparation + for i in 0..PREP_ROUNDS { + words[i] = u32::from_be_bytes([ + chunk[i * 4], + chunk[i * 4 + 1], + chunk[i * 4 + 2], + chunk[i * 4 + 3], + ]); + } + + // w[16..64]: schedule extension + for i in PREP_ROUNDS..EXTENSION_ROUNDS { + #[rustfmt::skip] + let s0 = words[i - 15].rotate_right(7) ^ words[i - 15].rotate_right(18) ^ (words[i - 15] >> 3); + + #[rustfmt::skip] + let s1 = words[i - 2].rotate_right(17) ^ words[i - 2].rotate_right(19) ^ (words[i - 2] >> 10); + + words[i] = words[i - 16] + .wrapping_add(s0) + .wrapping_add(words[i - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = state; + + for i in 0..COMPRESSION_ROUNDS { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ ((!e) & g); + let temp1 = h + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(words[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = s0.wrapping_add(maj); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + + state[0] = state[0].wrapping_add(a); + state[1] = state[1].wrapping_add(b); + state[2] = state[2].wrapping_add(c); + state[3] = state[3].wrapping_add(d); + state[4] = state[4].wrapping_add(e); + state[5] = state[5].wrapping_add(f); + state[6] = state[6].wrapping_add(g); + state[7] = state[7].wrapping_add(h); + } + + let mut digest = [0_u8; 32]; + for (i, word) in state.iter().enumerate() { + digest[i * 4..(i + 1) * 4].copy_from_slice(&word.to_be_bytes()); + } + + digest +} diff --git a/src-tauri/crates/otp/src/alg/sha512.rs b/src-tauri/crates/otp/src/alg/sha512.rs new file mode 100644 index 00000000..7e0a476d --- /dev/null +++ b/src-tauri/crates/otp/src/alg/sha512.rs @@ -0,0 +1,148 @@ +//! # Sources: + +#[rustfmt::skip] +const K: [u64; 80] = [ + 0x428a2f98d728ae22, 0x7137449123ef65cd, 0xb5c0fbcfec4d3b2f, + 0xe9b5dba58189dbbc, 0x3956c25bf348b538, 0x59f111f1b605d019, + 0x923f82a4af194f9b, 0xab1c5ed5da6d8118, 0xd807aa98a3030242, + 0x12835b0145706fbe, 0x243185be4ee4b28c, 0x550c7dc3d5ffb4e2, + 0x72be5d74f27b896f, 0x80deb1fe3b1696b1, 0x9bdc06a725c71235, + 0xc19bf174cf692694, 0xe49b69c19ef14ad2, 0xefbe4786384f25e3, + 0x0fc19dc68b8cd5b5, 0x240ca1cc77ac9c65, 0x2de92c6f592b0275, + 0x4a7484aa6ea6e483, 0x5cb0a9dcbd41fbd4, 0x76f988da831153b5, + 0x983e5152ee66dfab, 0xa831c66d2db43210, 0xb00327c898fb213f, + 0xbf597fc7beef0ee4, 0xc6e00bf33da88fc2, 0xd5a79147930aa725, + 0x06ca6351e003826f, 0x142929670a0e6e70, 0x27b70a8546d22ffc, + 0x2e1b21385c26c926, 0x4d2c6dfc5ac42aed, 0x53380d139d95b3df, + 0x650a73548baf63de, 0x766a0abb3c77b2a8, 0x81c2c92e47edaee6, + 0x92722c851482353b, 0xa2bfe8a14cf10364, 0xa81a664bbc423001, + 0xc24b8b70d0f89791, 0xc76c51a30654be30, 0xd192e819d6ef5218, + 0xd69906245565a910, 0xf40e35855771202a, 0x106aa07032bbd1b8, + 0x19a4c116b8d2d0c8, 0x1e376c085141ab53, 0x2748774cdf8eeb99, + 0x34b0bcb5e19b48a8, 0x391c0cb3c5c95a63, 0x4ed8aa4ae3418acb, + 0x5b9cca4f7763e373, 0x682e6ff3d6b2b8a3, 0x748f82ee5defb2fc, + 0x78a5636f43172f60, 0x84c87814a1f0ab72, 0x8cc702081a6439ec, + 0x90befffa23631e28, 0xa4506cebde82bde9, 0xbef9a3f7b2c67915, + 0xc67178f2e372532b, 0xca273eceea26619c, 0xd186b8c721c0c207, + 0xeada7dd6cde0eb1e, 0xf57d4f7fee6ed178, 0x06f067aa72176fba, + 0x0a637dc5a2c898a6, 0x113f9804bef90dae, 0x1b710b35131c471b, + 0x28db77f523047d84, 0x32caab7b40c72493, 0x3c9ebe0a15c9bebc, + 0x431d67c49c100d4c, 0x4cc5d4becb3e42b6, 0x597f299cfc657e2a, + 0x5fcb6fab3ad6faec, 0x6c44198c4a475817, +]; + +/// # Example: +/// ```rust +/// use otp::Algorithm; +/// +/// let alg = Algorithm::SHA512; +/// +/// let empty_hash = alg.hash_hex(b""); +/// assert_eq!("cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", &empty_hash); +/// +/// let hash = alg.hash_hex(b"The quick brown fox jumps over the lazy dog"); +/// assert_eq!("07e547d9586f6a73f73fbac0435ed76951218fb7d0c8d788a309d785436bbb642e93a252a954f23912547d1e8a3b5ed6e1bfd7097821233fa0538f3db854fee6", &hash); +/// ``` +pub fn sha512(input: &[u8]) -> [u8; 64] { + #[rustfmt::skip] + let mut state: [u64; 8] = [ + 0x6a09e667f3bcc908, + 0xbb67ae8584caa73b, + 0x3c6ef372fe94f82b, + 0xa54ff53a5f1d36f1, + 0x510e527fade682d1, + 0x9b05688c2b3e6c1f, + 0x1f83d9abfb41bd6b, + 0x5be0cd19137e2179, + ]; + + const BLOCK_SIZE: usize = 128; + const PREP_ROUNDS: usize = 16; + const EXTENSION_ROUNDS: usize = 80; + const COMPRESSION_ROUNDS: usize = 80; + + let bit_len = (input.len() as u128) * 8; + + let mut padded = input.to_vec(); + + // mark of the end of the original message. + padded.push(0x80); + + // pad with zeroes until the length mod 128 is = 112. + padded.resize(((padded.len() + 128) & !127_usize) - 16, 0x00); + + // reserves the last 16 bytes for the bit-length field. + padded.extend_from_slice(&(bit_len.to_be_bytes())); + + for chunk in padded.chunks(BLOCK_SIZE) { + let mut words = [0_u64; COMPRESSION_ROUNDS]; + + // w[0..16]: schedule preparation + for i in 0..PREP_ROUNDS { + words[i] = u64::from_be_bytes([ + chunk[i * 8], + chunk[i * 8 + 1], + chunk[i * 8 + 2], + chunk[i * 8 + 3], + chunk[i * 8 + 4], + chunk[i * 8 + 5], + chunk[i * 8 + 6], + chunk[i * 8 + 7], + ]); + } + + // w[16..80]: schedule extension + for i in PREP_ROUNDS..EXTENSION_ROUNDS { + #[rustfmt::skip] + let s0 = words[i - 15].rotate_right(1) ^ words[i - 15].rotate_right(8) ^ (words[i - 15] >> 7); + + #[rustfmt::skip] + let s1 = words[i - 2].rotate_right(19) ^ words[i - 2].rotate_right(61) ^ (words[i - 2] >> 6); + + words[i] = words[i - 16] + .wrapping_add(s0) + .wrapping_add(words[i - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = state; + + for i in 0..COMPRESSION_ROUNDS { + let s1 = e.rotate_right(14) ^ e.rotate_right(18) ^ e.rotate_right(41); + let ch = (e & f) ^ ((!e) & g); + let temp1 = h + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(words[i]); + let s0 = a.rotate_right(28) ^ a.rotate_right(34) ^ a.rotate_right(39); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = s0.wrapping_add(maj); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + + state[0] = state[0].wrapping_add(a); + state[1] = state[1].wrapping_add(b); + state[2] = state[2].wrapping_add(c); + state[3] = state[3].wrapping_add(d); + state[4] = state[4].wrapping_add(e); + state[5] = state[5].wrapping_add(f); + state[6] = state[6].wrapping_add(g); + state[7] = state[7].wrapping_add(h); + } + + let mut digest = [0_u8; 64]; + for (i, word) in state.iter().enumerate() { + digest[i * 8..(i + 1) * 8].copy_from_slice(&word.to_be_bytes()); + } + + digest +} diff --git a/src-tauri/crates/otp/src/encoding/base32.rs b/src-tauri/crates/otp/src/encoding/base32.rs new file mode 100644 index 00000000..8e757e6b --- /dev/null +++ b/src-tauri/crates/otp/src/encoding/base32.rs @@ -0,0 +1,91 @@ +//! Base32 encoding/decoding without padding, using [RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648#section-6) alphabet. + +const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/// # Example +/// ```rust +/// use otp::encoding::base32; +/// +/// let bytes = b"any + old & data"; +/// let encoded = base32::encode(bytes); +/// assert_eq!("MFXHSIBLEBXWYZBAEYQGIYLUME", encoded.as_str()); +/// ``` +pub fn encode(data: &[u8]) -> String { + let mut encoded = String::with_capacity((data.len() * 8).div_ceil(5)); + + let mut buffer = 0_u16; + let mut bits_left = 0; + + for &byte in data { + buffer <<= 8; + buffer |= byte as u16; + bits_left += 8; + + while bits_left >= 5 { + let index = (buffer >> (bits_left - 5)) & 0x1F; + encoded.push(BASE32_ALPHABET[index as usize] as char); + bits_left -= 5; + } + } + + if bits_left > 0 { + let index = (buffer << (5 - bits_left)) & 0x1F; + encoded.push(BASE32_ALPHABET[index as usize] as char); + } + + encoded +} + +/// # Example: +/// ```rust +/// use otp::encoding::base32; +/// +/// let hello_world = "JBSWY3DPFQQHO33SNRSCC"; +/// let decoded = base32::decode(hello_world).expect("Decoding failed"); +/// assert_eq!(b"Hello, world!", decoded.as_slice()); +/// assert_eq!(hello_world, base32::encode(b"Hello, world!")); +/// +/// let hello_world_with_pad = "JBSWY3DPFQQHO33SNRSCC==="; +/// let result_invalid_err = base32::decode(hello_world_with_pad); +/// assert!(matches!(result_invalid_err, Err(base32::DecodeBase32Error::InvalidChar(_)))); +/// ``` +pub fn decode(data: &str) -> Result, DecodeBase32Error> { + let mut output = Vec::with_capacity((data.len() * 5) / 8); + + let mut buffer = 0_u32; + let mut bits_left = 0; + + for b in data.bytes() { + let val = match b { + b'A'..=b'Z' => b - b'A', + b'a'..=b'z' => b - b'a', + b'2'..=b'7' => b - b'2' + 26, + _ => return Err(DecodeBase32Error::InvalidChar(b as char)), + } as u32; + + buffer = (buffer << 5) | val; + bits_left += 5; + + if bits_left >= 8 { + output.push((buffer >> (bits_left - 8)) as u8); + bits_left -= 8; + } + } + + Ok(output) +} + +#[derive(Debug, Clone)] +pub enum DecodeBase32Error { + InvalidChar(char), +} + +impl std::fmt::Display for DecodeBase32Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DecodeBase32Error::InvalidChar(c) => write!(f, "invalid base32 character: '{c}'"), + } + } +} + +impl std::error::Error for DecodeBase32Error {} diff --git a/src-tauri/crates/otp/src/encoding/hex.rs b/src-tauri/crates/otp/src/encoding/hex.rs new file mode 100644 index 00000000..8864e15b --- /dev/null +++ b/src-tauri/crates/otp/src/encoding/hex.rs @@ -0,0 +1,71 @@ +//! Base16 encoding/decoding, using [RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648#section-8) alphabet. + +/// # Example: +/// ```rust +/// use otp::encoding::hex; +/// +/// let bytes: [u8; 4] = [9, 10, 11, 12]; +/// let encoded = hex::encode(&bytes); +/// assert_eq!("090a0b0c", encoded.as_str()); +/// ``` +pub fn encode(data: &[u8]) -> String { + data.iter() + .map(|&b| unsafe { + let i = 2 * b as usize; + HEX_BYTES.get_unchecked(i..i + 2) + }) + .collect() +} + +/// # Example: +/// ```rust +/// use otp::encoding::hex; +/// +/// let input = "090A0B0C"; +/// let decoded = hex::decode(input).expect("Decoding failed"); +/// assert_eq!([9, 10, 11, 12], decoded.as_slice()); +/// +/// let input_odd_err = "090A0B0CZ"; +/// let result_odd_err = hex::decode(input_odd_err); +/// assert!(matches!(result_odd_err, Err(hex::DecodeHexError::InvalidLength))); +/// +/// let input_parse_int_err = "090A0B0CZZ"; +/// let result_parse_int_err = hex::decode(input_parse_int_err); +/// assert!(matches!(result_parse_int_err, Err(hex::DecodeHexError::ParseInt(_)))); +/// ``` +pub fn decode(data: &str) -> Result, DecodeHexError> { + if data.len() & 1 != 0 { + Err(DecodeHexError::InvalidLength) + } else { + (0..data.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&data[i..i + 2], 16).map_err(DecodeHexError::ParseInt)) + .collect() + } +} + +#[derive(Debug)] +pub enum DecodeHexError { + InvalidLength, + ParseInt(std::num::ParseIntError), +} + +impl std::error::Error for DecodeHexError {} + +impl std::fmt::Display for DecodeHexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DecodeHexError::ParseInt(e) => e.fmt(f), + DecodeHexError::InvalidLength => "input has an odd number of bytes".fmt(f), + } + } +} + +const HEX_BYTES: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f\ + 202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f\ + 404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f\ + 606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f\ + 808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f\ + a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf\ + c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedf\ + e0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"; diff --git a/src-tauri/crates/otp/src/encoding/mod.rs b/src-tauri/crates/otp/src/encoding/mod.rs new file mode 100644 index 00000000..fd05a8be --- /dev/null +++ b/src-tauri/crates/otp/src/encoding/mod.rs @@ -0,0 +1,5 @@ +//! Utilities for encoding/decoding text. + +pub mod base32; +pub mod hex; +pub mod url; diff --git a/src-tauri/crates/otp/src/encoding/url.rs b/src-tauri/crates/otp/src/encoding/url.rs new file mode 100644 index 00000000..9aa51028 --- /dev/null +++ b/src-tauri/crates/otp/src/encoding/url.rs @@ -0,0 +1,71 @@ +//! URL encoding/decoding, using [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-2.1) (Percent-encoding). + +const SAFE_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"; + +/// # Example: +/// ```rust +/// use otp::encoding::url; +/// +/// let s = b"hello@example.com"; +/// let encoded = url::encode(s); +/// assert_eq!("hello%40example.com", encoded.as_str()); +/// ``` +pub fn encode(data: &[u8]) -> String { + data.as_ref() + .iter() + .flat_map(|&b| { + if SAFE_CHARS.contains(&b) { + vec![b as char].into_iter() + } else { + let hex = format!("%{b:02X}"); + hex.chars().collect::>().into_iter() + } + }) + .collect() +} + +#[derive(Debug)] +pub enum DecodeUrlError { + InvalidHex(String), + UnexpectedEnd, +} + +/// Percent-decodes a URL-encoded string. +pub fn decode(data: &str) -> Result { + let input = data.as_bytes(); + let mut output = String::with_capacity(input.len()); + let mut i = 0; + + while i < input.len() { + match input[i] { + b'%' => { + if i + 2 >= input.len() { + return Err(DecodeUrlError::UnexpectedEnd); + } + let hex = &input[i + 1..=i + 2]; + let hex_str = std::str::from_utf8(hex).unwrap_or(""); + let byte = u8::from_str_radix(hex_str, 16) + .map_err(|_| DecodeUrlError::InvalidHex(hex_str.to_string()))?; + output.push(byte as char); + i += 3; + } + b => { + output.push(b as char); + i += 1; + } + } + } + + Ok(output) +} + +impl std::fmt::Display for DecodeUrlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DecodeUrlError::InvalidHex(s) => write!(f, "invalid hex sequence '%{s}'"), + DecodeUrlError::UnexpectedEnd => write!(f, "unexpected end of percent-encoding"), + } + } +} + +impl std::error::Error for DecodeUrlError {} diff --git a/src-tauri/crates/otp/src/hmac.rs b/src-tauri/crates/otp/src/hmac.rs new file mode 100644 index 00000000..897159a9 --- /dev/null +++ b/src-tauri/crates/otp/src/hmac.rs @@ -0,0 +1,387 @@ +use crate::alg::Algorithm; + +const HMAC_INNER_PAD: u8 = 0x36; +const HMAC_OUTER_PAD: u8 = 0x5c; + +/// Computes the HMAC (Hash-based Message Authentication Code) for a given key and message +/// using the specified hashing algorithm. +/// +/// HMAC is a cryptographic mechanism defined in [RFC 2104] and commonly used in authentication +/// and data integrity protocols such as HOTP and TOTP. +/// +/// Internally, it computes: +/// +/// ```text +/// HMAC(key, message) = H((key ⊕ opad) || H((key ⊕ ipad) || message)) +/// ``` +/// +/// Where: +/// - `H` is the selected hashing function (e.g. SHA1, SHA256, SHA512) +/// - `ipad` is the inner padding (0x36 repeated to block size) +/// - `opad` is the outer padding (0x5c repeated to block size) +/// +/// # Parameters +/// +/// - `alg`: The hashing algorithm to use (e.g. `Algorithm::SHA1`, `SHA256`, `SHA512`) +/// - `key`: The secret key used for HMAC computation (will be hashed if longer than block size) +/// - `message`: The message to authenticate +/// +/// # Returns +/// +/// A `Vec` containing the raw HMAC digest. +/// +/// # Example +/// +/// ```rust +/// use otp::{hmac, Algorithm}; +/// +/// let key = b"secret key"; +/// let message = b"The quick brown fox"; +/// +/// let mac = hmac(Algorithm::SHA256, key, message); +/// assert_eq!(mac.len(), 32); // SHA256 produces 32-byte output +/// ``` +/// +/// [RFC 2104]: https://datatracker.ietf.org/doc/html/rfc2104 +pub fn hmac(alg: Algorithm, key: &[u8], message: &[u8]) -> Vec { + let block_size = match alg { + Algorithm::SHA1 | Algorithm::SHA256 => 64, + Algorithm::SHA512 => 128, + }; + + let mut key_block = vec![0_u8; block_size]; + + if key.len() > block_size { + let hashed = alg.hash_bytes(key); + key_block[..hashed.len()].copy_from_slice(&hashed); + } else { + key_block[..key.len()].copy_from_slice(key); + } + + let mut inner_key_pad = vec![0_u8; block_size]; + let mut outer_key_pad = vec![0_u8; block_size]; + + for i in 0..block_size { + inner_key_pad[i] = key_block[i] ^ HMAC_INNER_PAD; + outer_key_pad[i] = key_block[i] ^ HMAC_OUTER_PAD; + } + + let mut inner = Vec::with_capacity(block_size + message.len()); + inner.extend_from_slice(&inner_key_pad); + inner.extend_from_slice(message); + let inner_hash = alg.hash_bytes(&inner); + + let mut outer = Vec::with_capacity(block_size + inner_hash.len()); + outer.extend_from_slice(&outer_key_pad); + outer.extend_from_slice(&inner_hash); + + alg.hash_bytes(&outer) +} + +/// # Sources: +/// All the test case is found here: https://datatracker.ietf.org/doc/html/rfc2202#section-3 +#[cfg(test)] +mod hmac_sha1_tests { + use super::*; + use crate::encoding::hex::encode; + + #[test] + fn test_case_1() { + let key: Vec = (0..20).map(|_| 0x0b_u8).collect(); + let message = b"Hi There"; + + let hash_bytes = hmac(Algorithm::SHA1, &key, message); + + let actual = encode(&hash_bytes); + let expect = "b617318655057264e28bc0b6fb378c8ef146be00"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_2() { + let key: &[u8] = b"Jefe"; + let message = b"what do ya want for nothing?"; + + let hash_bytes = hmac(Algorithm::SHA1, key, message); + + let actual = encode(&hash_bytes); + let expect = "effcdf6ae5eb2fa2d27416d5f184df9c259a7c79"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_3() { + let key: Vec = (0..20).map(|_| 0xaa_u8).collect(); + let message: Vec = (0..50).map(|_| 0xdd_u8).collect(); + + let hash_bytes = hmac(Algorithm::SHA1, &key, &message); + + let actual = encode(&hash_bytes); + let expect = "125d7342b9ac11cd91a39af48aa17b4f63f175d3"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_4() { + let key = vec![ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, + ]; + let message: Vec = (0..50).map(|_| 0xcd_u8).collect(); + + let hash_bytes = hmac(Algorithm::SHA1, &key, &message); + + let actual = encode(&hash_bytes); + let expect = "4c9007f4026250c6bc8414f9bf50c86c2d7235da"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_5() { + let key: Vec = (0..20).map(|_| 0x0c_u8).collect(); + let message = b"Test With Truncation"; + + let hash_bytes = hmac(Algorithm::SHA1, &key, message); + + let actual = encode(&hash_bytes); + let expect = "4c1a03424b55e07fe7f27be1d58bb9324a9a5a04"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_6() { + let key: Vec = (0..80).map(|_| 0xaa_u8).collect(); + let message = b"Test Using Larger Than Block-Size Key - Hash Key First"; + + let hash_bytes = hmac(Algorithm::SHA1, &key, message); + + let actual = encode(&hash_bytes); + let expect = "aa4ae5e15272d00e95705637ce8a3b55ed402112"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_7() { + let key: Vec = (0..80).map(|_| 0xaa_u8).collect(); + let message = b"Test Using Larger Than Block-Size Key and Larger Than One Block-Size Data"; + + let hash_bytes = hmac(Algorithm::SHA1, &key, message); + + let actual = encode(&hash_bytes); + let expect = "e8e99d0f45237d786d6bbaa7965c7808bbff1a91"; + + assert_eq!(expect, actual); + } +} + +/// # Sources: +/// All the test case is found here: https://datatracker.ietf.org/doc/html/rfc4231#section-4 +#[cfg(test)] +mod hmac_sha256_tests { + use super::*; + use crate::encoding::hex::encode; + + #[test] + fn test_case_1() { + let key: Vec = (0..20).map(|_| 0x0b_u8).collect(); + let message = b"Hi There"; + + let hash_bytes = hmac(Algorithm::SHA256, &key, message); + + let actual = encode(&hash_bytes); + let expect = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_2() { + let key: &[u8] = b"Jefe"; + let message = b"what do ya want for nothing?"; + + let hash_bytes = hmac(Algorithm::SHA256, key, message); + + let actual = encode(&hash_bytes); + let expect = "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_3() { + let key: Vec = (0..20).map(|_| 0xaa_u8).collect(); + let message: Vec = (0..50).map(|_| 0xdd_u8).collect(); + + let hash_bytes = hmac(Algorithm::SHA256, &key, &message); + + let actual = encode(&hash_bytes); + let expect = "773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_4() { + let key = vec![ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, + ]; + let message: Vec = (0..50).map(|_| 0xcd_u8).collect(); + + let hash_bytes = hmac(Algorithm::SHA256, &key, &message); + + let actual = encode(&hash_bytes); + let expect = "82558a389a443c0ea4cc819899f2083a85f0faa3e578f8077a2e3ff46729665b"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_5() { + let key: Vec = (0..20).map(|_| 0x0c_u8).collect(); + let message = b"Test With Truncation"; + + let hash_bytes = hmac(Algorithm::SHA256, &key, message); + + let actual = encode(&hash_bytes); + let expect = "a3b6167473100ee06e0c796c2955552b"; + let (truncated, _) = actual.split_at(expect.len()); + + assert_eq!(expect, truncated); + } + + #[test] + fn test_case_6() { + let key: Vec = (0..131).map(|_| 0xaa_u8).collect(); + + let message = b"Test Using Larger Than Block-Size Key - Hash Key First"; + let hash_bytes = hmac(Algorithm::SHA256, &key, message); + + let actual = encode(&hash_bytes); + let expect = "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_7() { + let key: Vec = (0..131).map(|_| 0xaa_u8).collect(); + + let message = b"This is a test using a larger than block-size key and a larger than block-size data. The key needs to be hashed before being used by the HMAC algorithm."; + let hash_bytes = hmac(Algorithm::SHA256, &key, message); + + let actual = encode(&hash_bytes); + let expect = "9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2"; + + assert_eq!(expect, actual); + } +} + +/// # Sources: +/// All the test case is found here: https://datatracker.ietf.org/doc/html/rfc4231#section-4 +#[cfg(test)] +mod hmac_sha512_tests { + use super::*; + use crate::encoding::hex::encode; + + #[test] + fn test_case_1() { + let key: Vec = (0..20).map(|_| 0x0b_u8).collect(); + let message = b"Hi There"; + + let hash_bytes = hmac(Algorithm::SHA512, &key, message); + + let actual = encode(&hash_bytes); + let expect = "87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cdedaa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_2() { + let key: &[u8] = b"Jefe"; + let message = b"what do ya want for nothing?"; + + let hash_bytes = hmac(Algorithm::SHA512, key, message); + + let actual = encode(&hash_bytes); + let expect = "164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea2505549758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_3() { + let key: Vec = (0..20).map(|_| 0xaa_u8).collect(); + let message: Vec = (0..50).map(|_| 0xdd_u8).collect(); + + let hash_bytes = hmac(Algorithm::SHA512, &key, &message); + + let actual = encode(&hash_bytes); + let expect = "fa73b0089d56a284efb0f0756c890be9b1b5dbdd8ee81a3655f83e33b2279d39bf3e848279a722c806b485a47e67c807b946a337bee8942674278859e13292fb"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_4() { + let key = vec![ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, + ]; + let message: Vec = (0..50).map(|_| 0xcd_u8).collect(); + + let hash_bytes = hmac(Algorithm::SHA512, &key, &message); + + let actual = encode(&hash_bytes); + let expect = "b0ba465637458c6990e5a8c5f61d4af7e576d97ff94b872de76f8050361ee3dba91ca5c11aa25eb4d679275cc5788063a5f19741120c4f2de2adebeb10a298dd"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_5() { + let key: Vec = (0..20).map(|_| 0x0c_u8).collect(); + let message = b"Test With Truncation"; + + let hash_bytes = hmac(Algorithm::SHA512, &key, message); + + let actual = encode(&hash_bytes); + let expect = "415fad6271580a531d4179bc891d87a6"; + + let (truncated, _) = actual.split_at(expect.len()); + assert_eq!(expect, truncated); + } + + #[test] + fn test_case_6() { + let key: Vec = (0..131).map(|_| 0xaa_u8).collect(); + + let message = b"Test Using Larger Than Block-Size Key - Hash Key First"; + let hash_bytes = hmac(Algorithm::SHA512, &key, message); + + let actual = encode(&hash_bytes); + let expect = "80b24263c7c1a3ebb71493c1dd7be8b49b46d1f41b4aeec1121b013783f8f3526b56d037e05f2598bd0fd2215d6a1e5295e64f73f63f0aec8b915a985d786598"; + + assert_eq!(expect, actual); + } + + #[test] + fn test_case_7() { + let key: Vec = (0..131).map(|_| 0xaa_u8).collect(); + + let message = b"This is a test using a larger than block-size key and a larger than block-size data. The key needs to be hashed before being used by the HMAC algorithm."; + let hash_bytes = hmac(Algorithm::SHA512, &key, message); + + let actual = encode(&hash_bytes); + let expect = "e37b6a775dc87dbaa4dfa9f96e5e3ffddebd71f8867289865df5a32d20cdc944b6022cac3c4982b10d5eeb55c3e4de15134676fb6de0446065c97440fa8c6a58"; + + assert_eq!(expect, actual); + } +} diff --git a/src-tauri/crates/otp/src/hotp.rs b/src-tauri/crates/otp/src/hotp.rs new file mode 100644 index 00000000..c9ef07ea --- /dev/null +++ b/src-tauri/crates/otp/src/hotp.rs @@ -0,0 +1,578 @@ +use crate::{Algorithm, Secret, encoding, hmac}; + +pub struct Hotp { + alg: Algorithm, + issuer: String, + label: String, + digits: u8, + counter: u64, + secret: Secret, +} + +impl Default for Hotp { + fn default() -> Self { + Self { + alg: Algorithm::default(), + issuer: String::new(), + label: String::new(), + digits: 6, + counter: 0, + secret: Default::default(), + } + } +} + +impl Hotp { + /// Creates a new [`Hotp`] instance with the specified configuration. + /// + /// # Arguments + /// + /// * `alg` - The hashing algorithm to use (e.g., [`Algorithm::SHA1`], [`Algorithm::SHA256`], or [`Algorithm::SHA512`]). + /// * `issuer` - The name of the service or provider (e.g., `"GitHub"` or `"example.com"`). + /// * `label` - An identifier for the user account (e.g., `"alice@example.com"`). + /// * `digits` - Number of digits in the generated OTP (typically 6 or 8). + /// * `counter` - Initial counter value for HOTP generation. + /// * `secret` - The shared secret key used to generate the HMAC. + /// + /// # Returns + /// + /// Returns a new instance of [`Hotp`] configured with the provided parameters. + /// + /// # Example + /// + /// ```rust + /// use otp::{Hotp, Algorithm, Secret}; + /// + /// let hotp = Hotp::new( + /// Algorithm::SHA1, + /// "example".into(), + /// "alice@example.com".into(), + /// 6, + /// 0, + /// Secret::from_bytes(b"supersecret"), + /// ); + /// ``` + pub fn new( + alg: Algorithm, + issuer: String, + label: String, + digits: u8, + counter: u64, + secret: Secret, + ) -> Self { + Self { + alg, + issuer, + label, + digits, + counter, + secret, + } + } + + /// Generates the next OTP value and increments the internal counter. + /// + /// This method uses the current counter value, produces a new HOTP code, + /// then advances the internal counter by one. + /// + /// Internally uses `generate_at` and follows the [HOTP Algorithm] + /// specified in [RFC 4226]. + /// + /// # Returns + /// A numeric HOTP code as a `u32`. + /// + /// # Example + /// ```rust + /// let mut hotp = otp::Hotp::default(); + /// let otp = hotp.generate(); + /// println!("OTP: {}", otp); + /// ``` + /// + /// [HOTP Algorithm]: + /// [RFC 4226]: + pub fn generate(&mut self) -> u32 { + let otp = self.generate_at(self.counter); + self.counter += 1; + otp + } + + /// Generates an OTP value at a specific counter value, without modifying internal state. + /// + /// This method is useful for verifying or regenerating a known HOTP value at a given counter. + /// + /// It uses HMAC with the configured algorithm (SHA-1, SHA-256, etc.), then applies dynamic + /// truncation as described in [RFC 4226]. + /// + /// # Arguments + /// * `counter` - The counter value at which to generate the OTP + /// + /// # Returns + /// A numeric OTP code as a `u32`. + /// + /// # Example + /// ```rust + /// let hotp = otp::Hotp::default(); + /// let otp = hotp.generate_at(1234); + /// ``` + /// + /// # References + /// - [RFC 4226](https://datatracker.ietf.org/doc/html/rfc4226#section-5.3) + pub fn generate_at(&self, counter: u64) -> u32 { + let message = counter.to_be_bytes(); + + let hmac_result = hmac(self.alg, self.secret.as_bytes(), &message); + + let offset = (hmac_result[hmac_result.len() - 1] & 0x0f) as usize; + + let code = ((u32::from(hmac_result[offset]) & 0x7f) << 24) + | (u32::from(hmac_result[offset + 1]) << 16) + | (u32::from(hmac_result[offset + 2]) << 8) + | u32::from(hmac_result[offset + 3]); + + code % 10_u32.pow(self.digits as u32) + } + + /// Verifies a provided OTP code against a given counter value, allowing for a window of flexibility. + /// + /// This method compares the given `otp` with the expected values generated + /// at `counter - window` to `counter + window`. This accounts for clock drift + /// or synchronization delays. + /// + /// # Arguments + /// * `otp` - The OTP code to verify + /// * `counter` - The current known counter (typically stored server-side) + /// * `window` - How many counter steps before and after to check + /// + /// # Returns + /// `true` if a match is found within the window range, `false` otherwise. + /// + /// # Example + /// ```rust + /// let hotp = otp::Hotp::default(); + /// let otp = hotp.generate_at(5); + /// assert!(hotp.verify(otp, 5, 1)); // exact match + /// assert!(hotp.verify(otp, 6, 1)); // match in past window + /// assert!(!hotp.verify(otp, 10, 2)); // out of range + /// ``` + /// + /// # References + /// - [RFC 4226](https://datatracker.ietf.org/doc/html/rfc4226#section-5.4) + pub fn verify(&self, otp: u32, counter: u64, window: u64) -> bool { + if self.generate_at(counter) == otp { + return true; + } + + for i in 1..=window { + if counter >= i && self.generate_at(counter - i) == otp { + return true; + } + if self.generate_at(counter + i) == otp { + return true; + } + } + + false + } + + /// Generates a Key URI string in the format compatible with Google Authenticator and other TOTP/HOTP apps. + /// + /// This URI can be encoded as a QR code and scanned by authenticator apps (e.g., Google Authenticator, Authy) + /// to configure the OTP settings automatically. + /// + /// The URI format follows the [Key URI Format] specification: + /// + /// ```text + /// otpauth://TYPE/LABEL?PARAMETERS + /// ``` + /// + /// For example, a TOTP URI might look like: + /// + /// ```text + /// otpauth://totp/Example%3Aalice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30 + /// ``` + /// + /// # Format Details + /// - `TYPE`: Either `totp` or `hotp` + /// - `LABEL`: Usually `issuer:account`, URL-encoded + /// - `secret`: Base32-encoded secret key + /// - `issuer`: The provider or service name (optional, but recommended) + /// - `algorithm`: Hash function used (e.g., SHA1, SHA256, SHA512) + /// - `digits`: Number of digits in the OTP (typically 6 or 8) + /// - `period` (TOTP only): Time step in seconds (e.g., 30) + /// - `counter` (HOTP only): Current counter value + /// + /// # Returns + /// A `String` containing the `otpauth://` URI. + /// + /// # Example + /// ```rust + /// use otp::{Totp, Algorithm, Secret}; + /// + /// let totp = Totp::new( + /// Algorithm::SHA256, + /// "Example".into(), + /// "alice@example.com".into(), + /// 6, + /// 30, + /// Secret::from_bytes(b"supersecretkey") + /// ); + /// + /// let uri = totp.to_uri(); + /// assert!(uri.starts_with("otpauth://totp/")); + /// ``` + /// + /// [Key URI Format]: https://github.com/google/google-authenticator/wiki/Key-Uri-Format + pub fn to_uri(&self) -> String { + let secret = self.secret.into_base32(); + let label = if self.issuer().is_empty() { + encoding::url::encode(self.label().as_bytes()) + } else { + encoding::url::encode(format!("{}:{}", &self.issuer(), &self.label()).as_bytes()) + }; + let issuer = if !self.issuer().is_empty() { + format!( + "&issuer={}", + encoding::url::encode(self.issuer().as_bytes()) + ) + } else { + String::new() + }; + let digits = self.digits; + let counter = self.counter; + let alg = self.alg.to_string(); + + format!( + "otpauth://hotp/{label}?secret={secret}{issuer}&algorithm={alg}&digits={digits}&counter={counter}" + ) + } + + #[inline] + pub fn alg(&self) -> Algorithm { + self.alg + } + + #[inline] + pub fn issuer(&self) -> &str { + &self.issuer + } + + #[inline] + pub fn label(&self) -> &str { + &self.label + } + + #[inline] + pub fn digits(&self) -> u8 { + self.digits + } + + #[inline] + pub fn counter(&self) -> u64 { + self.counter + } + + #[inline] + pub fn secret(&self) -> &Secret { + &self.secret + } + + /// Parses a HOTP configuration from a URI string in the [Key URI Format]. + /// + /// This function supports URIs of the form: + /// `otpauth://hotp/{label}?secret={secret}&issuer={issuer}&algorithm={algorithm}&digits={digits}&counter={counter}` + /// + /// # Arguments + /// + /// * `uri` - A string slice containing the HOTP URI. + /// + /// # Returns + /// + /// Returns `Ok(Hotp)` if the URI is valid and can be parsed. Otherwise returns `Err(Error)` + /// indicating the reason for failure. + /// + /// # Errors + /// + /// This method returns an error in the following cases: + /// + /// - URI does not start with the `otpauth://hotp/` scheme. + /// - Missing or empty label in the URI. + /// - Missing or invalid query parameters (e.g., `secret`, `counter`). + /// - Unsupported or invalid algorithm name. + /// - Base32 decoding of the secret fails. + /// - Convert string errors (e.g., `counter`, `digits`). + /// - Invalid percent-encoding in the label or issuer. + /// + /// # Examples + /// + /// ```rust + /// use otp::Hotp; + /// + /// let uri = "otpauth://hotp/example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=example&algorithm=SHA1&digits=6&counter=1"; + /// let hotp = Hotp::from_uri(uri).unwrap(); + /// assert_eq!(hotp.issuer(), "example"); + /// assert_eq!(hotp.label(), "alice@example.com"); + /// ``` + /// + /// [Key URI Format]: https://github.com/google/google-authenticator/wiki/Key-Uri-Format + pub fn from_uri(uri: &str) -> Result { + let rest = uri + .strip_prefix("otpauth://hotp/") + .ok_or(ParseUriError::InvalidPrefix)?; + + let (label_encoded, queries) = rest.split_once('?').ok_or(ParseUriError::InvalidFormat)?; + if label_encoded.is_empty() { + return Err(ParseUriError::InvalidLabel); + } + + let label_decoded = + encoding::url::decode(label_encoded).map_err(|_| ParseUriError::InvalidLabel)?; + + let (issuer_from_label, label) = + if let Some((issuer, label)) = label_decoded.split_once(':') { + (Some(issuer), label.to_string()) + } else { + (None, label_decoded) + }; + + let params: std::collections::HashMap<&str, &str> = queries + .split('&') + .map(|param| match param.split_once('=') { + Some((key, val)) => (key, val), + None => (param, ""), + }) + .collect(); + + let digits = params.get("digits").map_or(Ok(6), |val| { + val.parse::().map_err(|_| ParseUriError::InvalidDigits) + })?; + + let counter = params + .get("counter") + .ok_or(ParseUriError::MissingCounter) + .and_then(|val| { + val.parse::() + .map_err(|_| ParseUriError::InvalidCounter) + })?; + + let secret = params + .get("secret") + .ok_or(ParseUriError::MissingSecret) + .and_then(|raw_secret| { + Secret::from_base32(raw_secret).map_err(|_| ParseUriError::InvalidSecret) + })?; + + let issuer_from_param = params + .get("issuer") + .map(|iss| encoding::url::decode(iss).map_err(|_| ParseUriError::InvalidIssuer)) + .transpose()?; + + let issuer = match (issuer_from_label, issuer_from_param) { + (None, None) => Ok(String::new()), + (None, Some(from_param)) => Ok(from_param), + (Some(from_label), None) => Ok(from_label.to_string()), + (Some(from_label), Some(from_param)) => { + if from_label != from_param { + Err(ParseUriError::IssuerMismatch) + } else { + Ok(from_param) + } + } + }?; + + let alg = params + .get("algorithm") + .map(|alg| { + let alg = alg.to_uppercase(); + match alg.as_str() { + "SHA1" => Ok(Algorithm::SHA1), + "SHA256" => Ok(Algorithm::SHA256), + "SHA512" => Ok(Algorithm::SHA512), + _ => Err(ParseUriError::InvalidAlgorithm), + } + }) + .transpose()?; + + Ok(Self::new( + alg.unwrap_or(Algorithm::SHA1), + issuer, + label, + digits, + counter, + secret, + )) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseUriError { + InvalidPrefix, + InvalidFormat, + InvalidLabel, + InvalidIssuer, + InvalidDigits, + InvalidCounter, + InvalidSecret, + InvalidAlgorithm, + IssuerMismatch, + MissingSecret, + MissingCounter, +} + +impl std::fmt::Display for ParseUriError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseUriError::InvalidPrefix => { + f.write_str("URI must start with 'otpauth://hotp/'. Missing or incorrect prefix.") + } + ParseUriError::InvalidFormat => { + f.write_str("URI has an incorrect general format. Ensure it follows 'otpauth://type/label?parameters'.") + } + ParseUriError::InvalidLabel => { + f.write_str("The label (account name) in the URI is invalid or missing. Ensure it's properly encoded.") + } + ParseUriError::InvalidIssuer => { + f.write_str("The 'issuer' parameter is invalid or missing a value. Ensure it's present and correctly encoded.") + } + ParseUriError::InvalidDigits => { + f.write_str("The 'digits' parameter is invalid. It must be a positive integer, typically 6 or 8.") + } + ParseUriError::InvalidSecret => { + f.write_str("The 'secret' parameter is invalid or not properly base32 encoded.") + } + ParseUriError::InvalidAlgorithm => { + f.write_str("The 'algorithm' parameter is invalid. Expected 'SHA1', 'SHA256', or 'SHA512'.") + } + ParseUriError::IssuerMismatch => { + f.write_str("The issuer specified in the label does not match the 'issuer' parameter.") + } + ParseUriError::MissingSecret => { + f.write_str("The 'secret' parameter is required but missing from the URI.") + } + ParseUriError::InvalidCounter => { + f.write_str("The 'counter' parameter is invalid. It must be a positive integer.") + }, + ParseUriError::MissingCounter => { + f.write_str("The 'counter' parameter is required but missing from the URI.") + }, + } + } +} + +impl std::error::Error for ParseUriError {} + +#[cfg(test)] +impl Eq for Hotp {} + +#[cfg(test)] +impl PartialEq for Hotp { + fn eq(&self, other: &Self) -> bool { + self.alg == other.alg + && self.issuer == other.issuer + && self.label == other.label + && self.digits == other.digits + && self.secret == other.secret + } +} + +#[cfg(test)] +impl std::fmt::Debug for Hotp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Hotp") + .field("alg", &self.alg.to_string()) + .field("issuer", &self.issuer) + .field("label", &self.label) + .field("digits", &self.digits) + .field("counter", &self.counter) + .field("secret", &self.secret) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_uri() { + let alg = Algorithm::SHA512; + let issuer = String::from("example"); + let label = String::from("alice@example.com"); + let digits = 6; + let counter = 0; + let secret = Secret::from_bytes(b"The quick brown fox jumps over the lazy dog"); + + let hotp = Hotp::new(alg, issuer, label, digits, counter, secret); + let hotp_uri = hotp.to_uri(); + + let hotp_from_uri = Hotp::from_uri(&hotp_uri).expect("parse error"); + + assert_eq!(hotp_uri, hotp_from_uri.to_uri(), "should have same uri"); + assert_eq!(hotp, hotp_from_uri, "should be equal"); + } + + #[test] + fn test_from_uri_with_invalid_prefix() { + let uri = + "otpauth://totp/issuer:alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1024"; + let result = Hotp::from_uri(uri); + assert!( + matches!(result, Err(ParseUriError::InvalidPrefix)), + "should be invalid prefix" + ); + } + + #[test] + fn test_from_uri_with_missing_counter() { + let uri = + "otpauth://hotp/issuer:alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1024"; + let result = Hotp::from_uri(uri); + assert!( + matches!(result, Err(ParseUriError::MissingCounter)), + "should be missing counter" + ); + } + + #[test] + fn test_from_uri_with_missing_secret() { + let uri = "otpauth://hotp/issuer:alice@example.com?algorithm=SHA1024&counter=69420"; + let result = Hotp::from_uri(uri); + assert!( + matches!(result, Err(ParseUriError::MissingSecret)), + "should be missing secret" + ); + } + + #[test] + fn test_from_uri_with_invalid_algorithm() { + let uri = "otpauth://hotp/issuer:alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1024&counter=69"; + let result = Hotp::from_uri(uri); + assert!( + matches!(result, Err(ParseUriError::InvalidAlgorithm)), + "should be invalid algorithm" + ); + } + + #[test] + fn test_from_uri_with_invalid_uri_encoding() { + let uri = "otpauth://hotp/issuer%ZZ:alice@example.com?secret=JBSWY3DPEHPK3PXP"; + let result = Hotp::from_uri(uri); + assert!( + matches!(result, Err(ParseUriError::InvalidLabel)), + "should be invalid label" + ); + } + + #[test] + fn test_from_uri_with_issuer_mismatch() { + let uri = "otpauth://hotp/javascript:alice@example.com?secret=JBSWY3DPEHPK3PXP&counter=69&issuer=rust"; + let result = Hotp::from_uri(uri); + assert!(matches!(result, Err(ParseUriError::IssuerMismatch))); + } + + #[test] + fn test_from_uri_with_invalid_format() { + let uri = "otpauth://hotp/javascript:alice@example.com&secret=JBSWY3DPEHPK3PXP&counter=69&issuer=rust"; + let result = Hotp::from_uri(uri); + assert!(matches!(result, Err(ParseUriError::InvalidFormat))); + } +} diff --git a/src-tauri/crates/otp/src/lib.rs b/src-tauri/crates/otp/src/lib.rs new file mode 100644 index 00000000..648db4f2 --- /dev/null +++ b/src-tauri/crates/otp/src/lib.rs @@ -0,0 +1,63 @@ +//! # `otp` — Rust Implementation of HMAC and Time based one-time passwords. +//! +//! This crate provides a fully self-contained implementation of the [HOTP (HMAC-based One-Time Password)](https://datatracker.ietf.org/doc/html/rfc4226) +//! and [TOTP (Time-based One-Time Password)](https://datatracker.ietf.org/doc/html/rfc6238). +//! +//! ## Features +//! - **HOTP**: Counter-based one-time password generator and validator. +//! - **TOTP**: Time-based one-time password generator and validator. +//! - **URI generation**: Generate otpauth-compatible URIs for use with QR code generation (e.g., Google Authenticator). +//! +//! ## Example (TOTP) +//! +//! ```rust +//! use otp::{Totp, Algorithm, Secret}; +//! +//! let totp = Totp::new( +//! Algorithm::SHA1, +//! "example.com".into(), +//! "user@example.com".into(), +//! 6, +//! 30, +//! Secret::from_bytes(b"my-secret"), +//! ); +//! +//! let timestamp = std::time::SystemTime::now() +//! .duration_since(std::time::UNIX_EPOCH) +//! .expect("Clock may have gone backwards") +//! .as_secs(); +//! let otp = totp.generate_at(timestamp); +//! +//! assert!(totp.verify(otp, timestamp, 1)); +//! +//! println!("{}", totp.to_uri()); +//! // "otpauth://totp/example.com%3Auser%40example.com?secret=NV4S243FMNZGK5A&issuer=example.com&algorithm=SHA1&digits=6&period=30" + +//! +//! ``` +//! +//! ## References +//! - [RFC 2104](https://datatracker.ietf.org/doc/html/rfc2104) — HMAC: Keyed-Hashing for Message Authentication +//! - [RFC 4226](https://datatracker.ietf.org/doc/html/rfc4226) — HOTP: An HMAC-Based One-Time Password Algorithm +//! - [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238) — TOTP: Time-Based One-Time Password Algorithm +//! - [RFC 3174](https://datatracker.ietf.org/doc/html/rfc3174/) — US Secure Hash Algorithm 1 (SHA1) +//! - [RFC 6234](https://datatracker.ietf.org/doc/html/rfc6234) — US Secure Hash Algorithms (SHA and SHA-based HMAC and HKDF) +//! - [RFC 2202](https://datatracker.ietf.org/doc/html/rfc2202) — Test Cases for HMAC-MD5 and HMAC-SHA-1 +//! - [RFC 4231](https://datatracker.ietf.org/doc/html/rfc4231) — Identifiers and Test Vectors for HMAC-SHA-224, HMAC-SHA-256, HMAC-SHA-384, and HMAC-SHA-512 +//! - [RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648) — The Base16, Base32, and Base64 Data Encodings +//! - [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) — Uniform Resource Identifier (URI): Generic Syntax +//! - [Key URI Format](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) — for QR-compatible URIs + +pub mod encoding; + +mod alg; +mod hmac; +mod hotp; +mod secret; +mod totp; + +pub use self::alg::Algorithm; +pub use self::hmac::hmac; +pub use self::hotp::Hotp; +pub use self::secret::Secret; +pub use self::totp::Totp; diff --git a/src-tauri/crates/otp/src/secret.rs b/src-tauri/crates/otp/src/secret.rs new file mode 100644 index 00000000..90fb4f6f --- /dev/null +++ b/src-tauri/crates/otp/src/secret.rs @@ -0,0 +1,56 @@ +use crate::encoding::{self, base32::DecodeBase32Error}; + +#[derive(Default, Clone)] +pub struct Secret(Vec); + +impl Secret { + pub fn from_bytes(bytes: &[u8]) -> Self { + Self(bytes.to_vec()) + } + + pub fn from_base32(secret: &str) -> Result { + encoding::base32::decode(secret).map(Self) + } + + pub fn into_base32(&self) -> String { + encoding::base32::encode(self.0.as_slice()) + } + + pub fn into_hex(&self) -> String { + encoding::hex::encode(self.0.as_slice()) + } + + pub fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl AsRef<[u8]> for Secret { + fn as_ref(&self) -> &[u8] { + self.as_bytes() + } +} + +#[cfg(test)] +impl Eq for Secret {} + +#[cfg(test)] +impl PartialEq for Secret { + fn eq(&self, other: &Self) -> bool { + self.as_bytes() == other.as_bytes() + } +} + +#[cfg(test)] +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Secret(hex[\"{}\"])", self.into_hex()) + } +} + +// #[cfg(not(test))] +// impl std::fmt::Debug for Secret { +// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +// f.write_str("Secret([REDACTED])") +// } +// } diff --git a/src-tauri/crates/otp/src/totp.rs b/src-tauri/crates/otp/src/totp.rs new file mode 100644 index 00000000..721c4ab0 --- /dev/null +++ b/src-tauri/crates/otp/src/totp.rs @@ -0,0 +1,470 @@ +use crate::{Algorithm, Secret, encoding, hotp::Hotp}; + +pub struct Totp { + period: u64, + hotp: Hotp, +} + +impl Default for Totp { + fn default() -> Self { + Self { + hotp: Hotp::default(), + period: 30, + } + } +} + +impl Totp { + /// Creates a new [`Totp`] instance with the specified configuration. + /// + /// Internally, this wraps an [`Hotp`] instance and uses time-based counter + /// calculations according to the specified period. + /// + /// # Arguments + /// + /// * `alg` - The hashing algorithm to use (e.g., [`Algorithm::SHA1`], [`Algorithm::SHA256`], or [`Algorithm::SHA512`]). + /// * `issuer` - The name of the service or provider (e.g., `"GitHub"` or `"example.com"`). + /// * `label` - An identifier for the user account (e.g., `"alice@example.com"`). + /// * `digits` - Number of digits in the generated OTP (typically 6 or 8). + /// * `period` - Time step duration in seconds (usually 30). + /// * `secret` - The shared secret key used to generate the HMAC. + /// + /// # Returns + /// + /// Returns a new instance of [`Totp`] configured with the provided parameters. + /// + /// # Example + /// + /// ```rust + /// use otp::{Totp, Algorithm, Secret}; + /// + /// let totp = Totp::new( + /// Algorithm::SHA1, + /// "example".into(), + /// "alice@example.com".into(), + /// 6, + /// 30, + /// Secret::from_bytes(b"supersecret"), + /// ); + /// ``` + pub fn new( + alg: Algorithm, + issuer: String, + label: String, + digits: u8, + period: u64, + secret: Secret, + ) -> Self { + Self { + period, + hotp: Hotp::new(alg, issuer, label, digits, Default::default(), secret), + } + } + + /// Generates a TOTP code for the current system time using the configured algorithm and secret. + /// + /// Internally, this method computes the number of time steps (counters) since the Unix epoch, + /// and uses that to derive the OTP value. + /// + /// # Returns + /// A numeric TOTP code as a `u32`. + /// + /// # Panics + /// Panics if system time is before the Unix epoch. + /// + /// # Example + /// ```rust + /// let totp = otp::Totp::default(); + /// let otp = totp.generate(); + /// println!("OTP: {}", otp); + /// ``` + /// + /// # References + /// - [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238#section-4) + pub fn generate(&self) -> u32 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Clock may have gone backwards") + .as_secs(); + + self.generate_at(now) + } + + /// Generates a TOTP code for a specific timestamp (in seconds since Unix epoch). + /// + /// This method is useful when simulating or verifying TOTP behavior + /// for a given point in time. + /// + /// # Arguments + /// * `timestamp_secs` - The Unix timestamp in seconds + /// + /// # Returns + /// A numeric TOTP code as a `u32`. + /// + /// # Example + /// ```rust + /// let totp = otp::Totp::default(); + /// let otp = totp.generate_at(1_600_000_000); // fixed timestamp + /// ``` + /// + /// # References + /// - [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238#section-4) + pub fn generate_at(&self, timestamp_secs: u64) -> u32 { + let counter = timestamp_secs / self.period; + self.hotp.generate_at(counter) + } + + /// Verifies whether a given OTP is valid for a timestamp, within a configurable window. + /// + /// This method accounts for small clock skews by checking OTP values generated + /// before and after the given timestamp by a number of time steps defined by `window`. + /// + /// # Arguments + /// * `otp` - The OTP value to check + /// * `timestamp_secs` - The Unix timestamp (in seconds) to check against + /// * `window` - The allowed time-step drift (in units of `period`) + /// + /// # Returns + /// `true` if the OTP is valid within the given window; otherwise, `false`. + /// + /// # Example + /// ```rust + /// let totp = otp::Totp::default(); + /// let timestamp = 1_600_000_000; + /// let otp = totp.generate_at(timestamp); + /// assert!(totp.verify(otp, timestamp + 20, 1)); // within window + /// ``` + /// + /// # References + /// - [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238#section-5.2) + pub fn verify(&self, otp: u32, timestamp_secs: u64, window: u64) -> bool { + let counter = timestamp_secs / self.period; + self.hotp.verify(otp, counter, window) + } + + /// Generates a Key URI string in the format compatible with Google Authenticator and other TOTP/HOTP apps. + /// + /// This URI can be encoded as a QR code and scanned by authenticator apps (e.g., Google Authenticator, Authy) + /// to configure the OTP settings automatically. + /// + /// The URI format follows the [Key URI Format] specification: + /// + /// ```text + /// otpauth://TYPE/LABEL?PARAMETERS + /// ``` + /// + /// For example, a TOTP URI might look like: + /// + /// ```text + /// otpauth://totp/Example%3Aalice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30 + /// ``` + /// + /// # Format Details + /// - `TYPE`: Either `totp` or `hotp` + /// - `LABEL`: Usually `issuer:account`, URL-encoded + /// - `secret`: Base32-encoded secret key + /// - `issuer`: The provider or service name (optional, but recommended) + /// - `algorithm`: Hash function used (e.g., SHA1, SHA256, SHA512) + /// - `digits`: Number of digits in the OTP (typically 6 or 8) + /// - `period` (TOTP only): Time step in seconds (e.g., 30) + /// - `counter` (HOTP only): Current counter value + /// + /// # Returns + /// A `String` containing the `otpauth://` URI. + /// + /// # Example + /// ```rust + /// use otp::{Totp, Algorithm, Secret}; + /// + /// let totp = Totp::new( + /// Algorithm::SHA256, + /// "Example".into(), + /// "alice@example.com".into(), + /// 6, + /// 30, + /// Secret::from_bytes(b"supersecretkey") + /// ); + /// + /// let uri = totp.to_uri(); + /// assert!(uri.starts_with("otpauth://totp/")); + /// ``` + /// + /// [Key URI Format]: https://github.com/google/google-authenticator/wiki/Key-Uri-Format + pub fn to_uri(&self) -> String { + let secret = self.secret().into_base32(); + let label = if self.issuer().is_empty() { + encoding::url::encode(self.label().as_bytes()) + } else { + encoding::url::encode(format!("{}:{}", &self.issuer(), &self.label()).as_bytes()) + }; + let issuer = if !self.issuer().is_empty() { + format!( + "&issuer={}", + encoding::url::encode(self.issuer().as_bytes()) + ) + } else { + String::new() + }; + let digits = self.digits(); + let period = self.period; + let alg = self.alg().to_string(); + + format!( + "otpauth://totp/{label}?secret={secret}{issuer}&algorithm={alg}&digits={digits}&period={period}" + ) + } + + #[inline] + pub fn alg(&self) -> Algorithm { + self.hotp.alg() + } + + #[inline] + pub fn issuer(&self) -> &str { + self.hotp.issuer() + } + + #[inline] + pub fn label(&self) -> &str { + self.hotp.label() + } + + #[inline] + pub fn digits(&self) -> u8 { + self.hotp.digits() + } + + #[inline] + pub fn secret(&self) -> &Secret { + self.hotp.secret() + } + + /// Parses a TOTP configuration from a URI string in the [Key URI Format]. + /// + /// This function supports URIs of the form: + /// `otpauth://totp/{label}?secret={secret}&issuer={issuer}&algorithm={algorithm}&digits={digits}&period={period}` + /// + /// # Arguments + /// + /// * `uri` - A string slice containing the TOTP URI. + /// + /// # Returns + /// + /// Returns `Ok(Totp)` if the URI is valid and can be parsed. Otherwise returns `Err(Error)` + /// indicating the reason for failure. + /// + /// # Errors + /// + /// This method returns an error in the following cases: + /// + /// - URI does not start with the `otpauth://totp/` scheme. + /// - Missing or empty label in the URI. + /// - Missing or invalid query parameters (e.g., `secret`). + /// - Unsupported or invalid algorithm name. + /// - Base32 decoding of the secret fails. + /// - Convert string errors (e.g., `period`, `digits`). + /// - Invalid percent-encoding in the label or issuer. + /// + /// # Examples + /// + /// ```rust + /// use otp::Totp; + /// + /// let uri = "otpauth://totp/example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=example&algorithm=SHA1&digits=6&period=30"; + /// let totp = Totp::from_uri(uri).unwrap(); + /// assert_eq!(totp.issuer(), "example"); + /// assert_eq!(totp.label(), "alice@example.com"); + /// ``` + /// + /// [Key URI Format]: https://github.com/google/google-authenticator/wiki/Key-Uri-Format + pub fn from_uri(uri: &str) -> Result { + let rest = uri + .strip_prefix("otpauth://totp/") + .ok_or(ParseUriError::InvalidPrefix)?; + + let (label_encoded, queries) = rest.split_once('?').ok_or(ParseUriError::InvalidFormat)?; + if label_encoded.is_empty() { + return Err(ParseUriError::InvalidLabel); + } + + let label_decoded = + encoding::url::decode(label_encoded).map_err(|_| ParseUriError::InvalidLabel)?; + + let (issuer_from_label, label) = + if let Some((issuer, label)) = label_decoded.split_once(':') { + (Some(issuer), label.to_string()) + } else { + (None, label_decoded) + }; + + let params: std::collections::HashMap<&str, &str> = queries + .split('&') + .map(|param| match param.split_once('=') { + Some((key, val)) => (key, val), + None => (param, ""), + }) + .collect(); + + let digits = params.get("digits").map_or(Ok(6), |val| { + val.parse::().map_err(|_| ParseUriError::InvalidDigits) + })?; + + let period = params.get("period").map_or(Ok(30), |val| { + val.parse::().map_err(|_| ParseUriError::InvalidPeriod) + })?; + + let secret = params + .get("secret") + .ok_or(ParseUriError::MissingSecret) + .and_then(|raw_secret| { + Secret::from_base32(raw_secret).map_err(|_| ParseUriError::InvalidSecret) + })?; + + let issuer_from_param = params + .get("issuer") + .map(|iss| encoding::url::decode(iss).map_err(|_| ParseUriError::InvalidIssuer)) + .transpose()?; + + let issuer = match (issuer_from_label, issuer_from_param) { + (None, None) => Ok(String::new()), + (None, Some(from_param)) => Ok(from_param), + (Some(from_label), None) => Ok(from_label.to_string()), + (Some(from_label), Some(from_param)) => { + if from_label != from_param { + Err(ParseUriError::IssuerMismatch) + } else { + Ok(from_param) + } + } + }?; + + let alg = params + .get("algorithm") + .map(|alg| { + let alg = alg.to_uppercase(); + match alg.as_str() { + "SHA1" => Ok(Algorithm::SHA1), + "SHA256" => Ok(Algorithm::SHA256), + "SHA512" => Ok(Algorithm::SHA512), + _ => Err(ParseUriError::InvalidAlgorithm), + } + }) + .transpose()?; + + Ok(Self::new( + alg.unwrap_or(Algorithm::SHA1), + issuer, + label, + digits, + period, + secret, + )) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseUriError { + InvalidPrefix, + InvalidFormat, + InvalidLabel, + InvalidIssuer, + InvalidDigits, + InvalidPeriod, + InvalidSecret, + InvalidAlgorithm, + IssuerMismatch, + MissingSecret, +} + +impl std::fmt::Display for ParseUriError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseUriError::InvalidPrefix => { + f.write_str("URI must start with 'otpauth://totp/'. Missing or incorrect prefix.") + } + ParseUriError::InvalidFormat => { + f.write_str("URI has an incorrect general format. Ensure it follows 'otpauth://type/label?parameters'.") + } + ParseUriError::InvalidLabel => { + f.write_str("The label (account name) in the URI is invalid or missing. Ensure it's properly encoded.") + } + ParseUriError::InvalidIssuer => { + f.write_str("The 'issuer' parameter is invalid or missing a value. Ensure it's present and correctly encoded.") + } + ParseUriError::InvalidDigits => { + f.write_str("The 'digits' parameter is invalid. It must be a positive integer, typically 6 or 8.") + } + ParseUriError::InvalidPeriod => { + f.write_str("The 'period' parameter is invalid. It must be a positive integer, typically 30 or 60.") + } + ParseUriError::InvalidSecret => { + f.write_str("The 'secret' parameter is invalid or not properly base32 encoded.") + } + ParseUriError::InvalidAlgorithm => { + f.write_str("The 'algorithm' parameter is invalid. Expected 'SHA1', 'SHA256', or 'SHA512'.") + } + ParseUriError::IssuerMismatch => { + f.write_str("The issuer specified in the label does not match the 'issuer' parameter.") + } + ParseUriError::MissingSecret => { + f.write_str("The 'secret' parameter is required but missing from the URI.") + } + } + } +} + +impl std::error::Error for ParseUriError {} + +#[cfg(test)] +impl Eq for Totp {} + +#[cfg(test)] +impl PartialEq for Totp { + fn eq(&self, other: &Self) -> bool { + self.hotp == other.hotp && self.period == other.period + } +} + +#[cfg(test)] +impl std::fmt::Debug for Totp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Totp") + .field("hotp", &self.hotp) + .field("period", &self.period) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_uri() { + let alg = Algorithm::SHA512; + let issuer = String::from(""); + let label = String::from("alice@example.com"); + let digits = 6; + let period = 30; + let secret = Secret::from_bytes(b"The quick brown fox jumps over the lazy dog"); + + let totp = Totp::new(alg, issuer, label, digits, period, secret); + let totp_uri = totp.to_uri(); + + let totp_from_uri = Totp::from_uri(&totp_uri).expect("should parse"); + + assert_eq!(totp_uri, totp_from_uri.to_uri(), "should generate same uri"); + assert_eq!(totp, totp_from_uri, "should be equal"); + } + + #[test] + fn test_from_uri_with_invalid_prefix() { + let uri = + "otpauth://hotp/issuer:alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1024"; + let result = Totp::from_uri(uri); + assert!( + matches!(result, Err(ParseUriError::InvalidPrefix)), + "should be invalid prefix" + ); + } +}