mirror of
https://github.com/nyakang/nyaterm.git
synced 2026-09-22 00:01:30 +00:00
chore(otp): vendor local hotp and totp crate
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+5
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
Generated
+7
@@ -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"
|
||||
@@ -0,0 +1,4 @@
|
||||
[package]
|
||||
name = "dragonfly-otp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
@@ -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<u8>` 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<u8> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! # Source: <https://datatracker.ietf.org/doc/html/rfc3174/>
|
||||
|
||||
/// # 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
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! # Sources: <https://datatracker.ietf.org/doc/html/rfc6234>
|
||||
|
||||
#[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
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! # Sources: <https://datatracker.ietf.org/doc/html/rfc6234>
|
||||
|
||||
#[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
|
||||
}
|
||||
@@ -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<Vec<u8>, 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 {}
|
||||
@@ -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<Vec<u8>, 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";
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Utilities for encoding/decoding text.
|
||||
|
||||
pub mod base32;
|
||||
pub mod hex;
|
||||
pub mod url;
|
||||
@@ -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::<Vec<_>>().into_iter()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DecodeUrlError {
|
||||
InvalidHex(String),
|
||||
UnexpectedEnd,
|
||||
}
|
||||
|
||||
/// Percent-decodes a URL-encoded string.
|
||||
pub fn decode(data: &str) -> Result<String, DecodeUrlError> {
|
||||
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 {}
|
||||
@@ -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<u8>` 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<u8> {
|
||||
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<u8> = (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<u8> = (0..20).map(|_| 0xaa_u8).collect();
|
||||
let message: Vec<u8> = (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<u8> = (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<u8> = (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<u8> = (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<u8> = (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<u8> = (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<u8> = (0..20).map(|_| 0xaa_u8).collect();
|
||||
let message: Vec<u8> = (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<u8> = (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<u8> = (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<u8> = (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<u8> = (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<u8> = (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<u8> = (0..20).map(|_| 0xaa_u8).collect();
|
||||
let message: Vec<u8> = (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<u8> = (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<u8> = (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<u8> = (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<u8> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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]: <https://datatracker.ietf.org/doc/html/rfc4226>
|
||||
/// [RFC 4226]: <https://datatracker.ietf.org/doc/html/rfc4226#section-5.3>
|
||||
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<Self, ParseUriError> {
|
||||
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::<u8>().map_err(|_| ParseUriError::InvalidDigits)
|
||||
})?;
|
||||
|
||||
let counter = params
|
||||
.get("counter")
|
||||
.ok_or(ParseUriError::MissingCounter)
|
||||
.and_then(|val| {
|
||||
val.parse::<u64>()
|
||||
.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)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,56 @@
|
||||
use crate::encoding::{self, base32::DecodeBase32Error};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Secret(Vec<u8>);
|
||||
|
||||
impl Secret {
|
||||
pub fn from_bytes(bytes: &[u8]) -> Self {
|
||||
Self(bytes.to_vec())
|
||||
}
|
||||
|
||||
pub fn from_base32(secret: &str) -> Result<Self, DecodeBase32Error> {
|
||||
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])")
|
||||
// }
|
||||
// }
|
||||
@@ -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<Self, ParseUriError> {
|
||||
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::<u8>().map_err(|_| ParseUriError::InvalidDigits)
|
||||
})?;
|
||||
|
||||
let period = params.get("period").map_or(Ok(30), |val| {
|
||||
val.parse::<u64>().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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user