From 3a64c268ca7bc5639d82ad62a92486bcb324ea91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9o=20Rebert?= Date: Fri, 20 Feb 2026 18:24:40 +0100 Subject: [PATCH 1/9] Add Builder. Totp::new will soon not be a thing anymore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cléo Rebert --- src/algorithm.rs | 67 ++++++++++++++++++ src/builder.rs | 181 +++++++++++++++++++++++++++++++++++++++++++++++ src/error.rs | 10 ++- src/lib.rs | 98 +++++-------------------- src/rfc.rs | 22 +++--- src/url.rs | 4 +- 6 files changed, 288 insertions(+), 94 deletions(-) create mode 100644 src/algorithm.rs create mode 100644 src/builder.rs diff --git a/src/algorithm.rs b/src/algorithm.rs new file mode 100644 index 0000000..6a776aa --- /dev/null +++ b/src/algorithm.rs @@ -0,0 +1,67 @@ +use hmac::Mac; +type HmacSha1 = hmac::Hmac; +type HmacSha256 = hmac::Hmac; +type HmacSha512 = hmac::Hmac; + +use std::fmt; + +/// Alphabet for Steam tokens. +#[cfg(feature = "steam")] +const STEAM_CHARS: &str = "23456789BCDFGHJKMNPQRTVWXY"; + +/// Algorithm enum holds the three standards algorithms for TOTP as per the [reference implementation](https://tools.ietf.org/html/rfc6238#appendix-A) +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))] +pub enum Algorithm { + /// HMAC-SHA1 is the default algorithm of most TOTP implementations. + /// Some will outright silently ignore the algorithm parameter to force using SHA1, leading to confusion. + SHA1, + /// HMAC-SHA256. Supported in theory according to [yubico](https://docs.yubico.com/yesdk/users-manual/application-oath/uri-string-format.html). + /// Ignored in practice by most. + SHA256, + /// HMAC-SHA512. Supported in theory according to [yubico](https://docs.yubico.com/yesdk/users-manual/application-oath/uri-string-format.html). + /// Ignored in practice by most. + SHA512, + #[cfg(feature = "steam")] + #[cfg_attr(docsrs, doc(cfg(feature = "steam")))] + /// Steam TOTP token algorithm. + Steam, +} + +impl Default for Algorithm { + fn default() -> Self { + Algorithm::SHA1 + } +} + +impl fmt::Display for Algorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Algorithm::SHA1 => f.write_str("SHA1"), + Algorithm::SHA256 => f.write_str("SHA256"), + Algorithm::SHA512 => f.write_str("SHA512"), + #[cfg(feature = "steam")] + Algorithm::Steam => f.write_str("SHA1"), + } + } +} + +impl Algorithm { + fn hash(mut digest: D, data: &[u8]) -> Vec + where + D: Mac, + { + digest.update(data); + digest.finalize().into_bytes().to_vec() + } + + pub(crate) fn sign(&self, key: &[u8], data: &[u8]) -> Vec { + match self { + Algorithm::SHA1 => Algorithm::hash(HmacSha1::new_from_slice(key).unwrap(), data), + Algorithm::SHA256 => Algorithm::hash(HmacSha256::new_from_slice(key).unwrap(), data), + Algorithm::SHA512 => Algorithm::hash(HmacSha512::new_from_slice(key).unwrap(), data), + #[cfg(feature = "steam")] + Algorithm::Steam => Algorithm::hash(HmacSha1::new_from_slice(key).unwrap(), data), + } + } +} diff --git a/src/builder.rs b/src/builder.rs new file mode 100644 index 0000000..6905a01 --- /dev/null +++ b/src/builder.rs @@ -0,0 +1,181 @@ +use crate::error::TotpError; +use crate::{Algorithm, Totp}; + +/// Builder used to build a [Totp] with sane defaults. +/// Because it contains the sensitive data of the HMAC secret, treat it accordingly. +#[cfg_attr(feature = "zeroize", derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop))] +pub struct Builder { + algorithm: Algorithm, + digits: u32, + secret: Option>, + skew: u32, + step_duration: u64, + + #[cfg(feature = "otpauth")] + account_name: String, + #[cfg(feature = "otpauth")] + issuer: Option, +} + +impl Builder { + /// New builder. + /// If `gen_secret` is enabled, [Self::new] will generate a new, safe-to-use, secret. + pub fn new() -> Builder { + #[cfg(feature = "gen_secret")] + let secret = { + use rand::Rng; + + let mut rng = rand::rng(); + let mut secret: [u8; 20] = Default::default(); + rng.fill(&mut secret[..]); + + Some(secret) + }; + + #[cfg(not(feature = "gen_secret"))] + let secret = None; + + Builder { + algorithm: Algorithm::SHA1, + digits: 6, + secret: secret, + skew: 1, + step_duration: 30, + } + } + + /// SHA-1 is the most widespread algorithm used, and for totp pursposes, SHA-1 hash collisions are [not a problem](https://tools.ietf.org/html/rfc4226#appendix-B.2) as HMAC-SHA-1 is not impacted. It's also the main one cited in [rfc-6238](https://tools.ietf.org/html/rfc6238#section-3) even though the [reference implementation](https://tools.ietf.org/html/rfc6238#appendix-A) permits the use of SHA-1, SHA-256 and SHA-512. Not all clients support other algorithms then SHA-1. + /// + /// Unless called, the default value will be Algorithm::SHA1. + pub fn with_algorithm(&mut self, algorithm: Algorithm) -> &mut Self { + self.algorithm = algorithm; + + self + } + + /// The number of digits composing the auth code. Per [rfc-4226](https://tools.ietf.org/html/rfc4226#section-5.3), this can oscilate between 6 and 8 digits. + /// + /// Unless called, the default value will be 6. + pub fn with_digits(&mut self, digits: u32) -> &mut Self { + self.digits = digits; + + self + } + + /// As per [rfc-4226](https://tools.ietf.org/html/rfc4226#section-4) the secret should come from a strong source, most likely a CSPRNG. It should be at least 128 bits, but 160 are recommended. + /// + /// Unless called, and if feature `gen_secret` is enabled, a random 160bits secret from a strong source will be the default value. + /// + /// If feature `gen_secret` is not enabled, then not calling this method will result in [Self::build] to fail. + pub fn with_secret(&mut self, secret: Vec) -> &mut Self { + self.secret = Some(secret); + + self + } + + /// Number of steps allowed as network delay. 1 would mean one step before current step and one step after are valids. The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 1. Anything more is sketchy, and anyone recommending more is, by definition, ugly and stupid. + /// + /// Unless called, the default value will be 1. + pub fn with_skew(&mut self, skew: u32) -> &mut Self { + self.skew = skew; + + self + } + + /// Duration in seconds of a step. The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 30 seconds. + /// + /// Unless called, the default value will be 30. + pub fn with_step_duration(&mut self, step_duration: u64) -> &mut Self { + self.step_duration = step_duration; + + self + } + + /// The "constantoine@github.com" part of "Github:constantoine@github.com". Must not contain a colon `:` + /// For example, the name of your user's account. + /// + /// Not calling this method will result in [Self::build] to fail. + #[cfg(feature = "otpauth")] + #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] + pub fn with_account_name(&mut self, account_name: String) -> &mut Self { + self.account_name = account_name; + + self + } + + /// The "Github" part of "Github:constantoine@github.com". Must not contain a colon `:` + /// For example, the name of your service/website. + /// Not mandatory, but strongly recommended! + /// + /// Unless called, an issuer will not be present. + #[cfg(feature = "otpauth")] + #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] + pub fn with_issuer(&mut self, issuer: Option) -> &mut Self { + self.issuer = issuer; + + self + } + + /// Consume the builder into a [Totp]. See [it's method's docs](struct.Builder.html#impl-Builder) for reference about each values. + /// + /// # Example + /// + /// ```rust + /// use totp_rs::{Builder, Algorithm}; + /// use rand::Rng; + /// + /// let mut rng = rand::rng(); + /// let mut secret: [u8; 20] = Default::default(); + /// rng.fill(&mut secret[..]); + /// + /// let totp = Builder::new(). + /// with_algorithm(Algorithm::SHA256). + /// with_secret(secret). + /// build(). + /// unwrap() + /// ``` + /// + /// # Errors + /// + /// - If the `digit` or `secret` size are invalid. + /// - If secret was not set using [Self::with_secret] and the feature `gen_secret` is not enabled. + /// - If `issuer` or `label` contain the character ':' (`otpauth`` feature). + pub fn build(self) -> Result { + let secret = self.secret.as_ref().ok_or(TotpError::SecretNotSet)?; + + crate::rfc::assert_digits(self.digits)?; + crate::rfc::assert_secret_length(secret)?; + + #[cfg(feature = "otpauth")] + { + if self.issuer.is_some() && self.issuer.as_ref().unwrap().contains(':') { + return Err(TotpError::InvalidIssuer { + value: issuer.as_ref().unwrap().to_string(), + }); + } + + if self.account_name.as_ref().contains(':') { + return Err(TotpError::InvalidAccountName { + value: account_name, + }); + } + } + + Ok(self.build_noncompliant()) + } + + pub fn build_noncompliant(self) -> Totp { + Totp { + algorithm: self.algorithm, + digits: self.digits, + skew: self.skew, + step: self.step_duration, + secret: self.secret.unwrap_or_default(), + + #[cfg(feature = "otpauth")] + issuer: self.issuer, + #[cfg(feature = "otpauth")] + account_name: self.account_name, + } + } +} diff --git a/src/error.rs b/src/error.rs index 6e4087b..6180257 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,5 @@ +use std::fmt::write; + #[cfg(feature = "otpauth")] use url::ParseError; @@ -6,11 +8,13 @@ use url::ParseError; pub enum TotpError { // === Parameter validation errors === /// Digits must be 6, 7, or 8. - InvalidDigits { digits: usize }, + InvalidDigits { digits: u32 }, /// Step is zero. InvalidStepZero, /// Secret is shorter than 128 bits (16 bytes). SecretTooShort { bits: usize }, + /// Secret was not set in builder. + SecretNotSet, // === URL parsing errors (otpauth feature) === #[cfg(feature = "otpauth")] @@ -65,6 +69,10 @@ impl std::fmt::Display for TotpError { write!(f, "Digits must be 6, 7, or 8, not {}", digits,) } TotpError::InvalidStepZero => write!(f, "Step cannot be 0."), + TotpError::SecretNotSet => write!( + f, + "Secret was not set in builder. Consider using the `gen_secret` feature" + ), #[cfg(feature = "otpauth")] TotpError::UrlParse(e) => write!(f, "Error parsing URL: {}", e), #[cfg(feature = "otpauth")] diff --git a/src/lib.rs b/src/lib.rs index 6d784a0..c8ae56e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,6 +50,8 @@ // enable `doc_cfg` feature for `docs.rs`. #![cfg_attr(docsrs, feature(doc_cfg))] +mod algorithm; +mod builder; mod custom_providers; mod error; mod rfc; @@ -61,6 +63,8 @@ mod url; #[cfg(feature = "qr")] pub use qrcodegen_image; +pub use builder::Builder; +pub use algorithm::Algorithm; pub use error::TotpError; pub use rfc::Rfc6238; pub use secret::{Secret, SecretParseError}; @@ -72,74 +76,8 @@ use serde::{Deserialize, Serialize}; use core::fmt; -use hmac::Mac; use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH}; -type HmacSha1 = hmac::Hmac; -type HmacSha256 = hmac::Hmac; -type HmacSha512 = hmac::Hmac; - -/// Alphabet for Steam tokens. -#[cfg(feature = "steam")] -const STEAM_CHARS: &str = "23456789BCDFGHJKMNPQRTVWXY"; - -/// Algorithm enum holds the three standards algorithms for TOTP as per the [reference implementation](https://tools.ietf.org/html/rfc6238#appendix-A) -#[derive(Debug, Copy, Clone, Eq, PartialEq)] -#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))] -pub enum Algorithm { - /// HMAC-SHA1 is the default algorithm of most TOTP implementations. - /// Some will outright silently ignore the algorithm parameter to force using SHA1, leading to confusion. - SHA1, - /// HMAC-SHA256. Supported in theory according to [yubico](https://docs.yubico.com/yesdk/users-manual/application-oath/uri-string-format.html). - /// Ignored in practice by most. - SHA256, - /// HMAC-SHA512. Supported in theory according to [yubico](https://docs.yubico.com/yesdk/users-manual/application-oath/uri-string-format.html). - /// Ignored in practice by most. - SHA512, - #[cfg(feature = "steam")] - #[cfg_attr(docsrs, doc(cfg(feature = "steam")))] - /// Steam TOTP token algorithm. - Steam, -} - -impl Default for Algorithm { - fn default() -> Self { - Algorithm::SHA1 - } -} - -impl fmt::Display for Algorithm { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Algorithm::SHA1 => f.write_str("SHA1"), - Algorithm::SHA256 => f.write_str("SHA256"), - Algorithm::SHA512 => f.write_str("SHA512"), - #[cfg(feature = "steam")] - Algorithm::Steam => f.write_str("SHA1"), - } - } -} - -impl Algorithm { - fn hash(mut digest: D, data: &[u8]) -> Vec - where - D: Mac, - { - digest.update(data); - digest.finalize().into_bytes().to_vec() - } - - fn sign(&self, key: &[u8], data: &[u8]) -> Vec { - match self { - Algorithm::SHA1 => Algorithm::hash(HmacSha1::new_from_slice(key).unwrap(), data), - Algorithm::SHA256 => Algorithm::hash(HmacSha256::new_from_slice(key).unwrap(), data), - Algorithm::SHA512 => Algorithm::hash(HmacSha512::new_from_slice(key).unwrap(), data), - #[cfg(feature = "steam")] - Algorithm::Steam => Algorithm::hash(HmacSha1::new_from_slice(key).unwrap(), data), - } - } -} - fn system_time() -> Result { let t = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); Ok(t) @@ -152,28 +90,28 @@ fn system_time() -> Result { pub struct Totp { /// SHA-1 is the most widespread algorithm used, and for totp pursposes, SHA-1 hash collisions are [not a problem](https://tools.ietf.org/html/rfc4226#appendix-B.2) as HMAC-SHA-1 is not impacted. It's also the main one cited in [rfc-6238](https://tools.ietf.org/html/rfc6238#section-3) even though the [reference implementation](https://tools.ietf.org/html/rfc6238#appendix-A) permits the use of SHA-1, SHA-256 and SHA-512. Not all clients support other algorithms then SHA-1 #[cfg_attr(feature = "zeroize", zeroize(skip))] - pub algorithm: Algorithm, + pub(crate) algorithm: Algorithm, /// The number of digits composing the auth code. Per [rfc-4226](https://tools.ietf.org/html/rfc4226#section-5.3), this can oscilate between 6 and 8 digits - pub digits: usize, + pub(crate) digits: u32, /// Number of steps allowed as network delay. 1 would mean one step before current step and one step after are valids. The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 1. Anything more is sketchy, and anyone recommending more is, by definition, ugly and stupid - pub skew: u8, + pub(crate) skew: u32, /// Duration in seconds of a step. The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 30 seconds - pub step: u64, + pub(crate) step: u64, /// As per [rfc-4226](https://tools.ietf.org/html/rfc4226#section-4) the secret should come from a strong source, most likely a CSPRNG. It should be at least 128 bits, but 160 are recommended /// /// non-encoded value - pub secret: Vec, + pub(crate) secret: Vec, #[cfg(feature = "otpauth")] #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] /// The "Github" part of "Github:constantoine@github.com". Must not contain a colon `:` /// For example, the name of your service/website. /// Not mandatory, but strongly recommended! - pub issuer: Option, + pub(crate) issuer: Option, #[cfg(feature = "otpauth")] #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] /// The "constantoine@github.com" part of "Github:constantoine@github.com". Must not contain a colon `:` /// For example, the name of your user's account. - pub account_name: String, + pub(crate) account_name: String, } impl PartialEq for Totp { @@ -363,12 +301,12 @@ impl Totp { /// Will return an error if the `digit` or `secret` size is invalid pub fn new( algorithm: Algorithm, - digits: usize, - skew: u8, + digits: u32, + skew: u32, step: u64, secret: Vec, ) -> Result { - crate::rfc::assert_digits(&digits)?; + crate::rfc::assert_digits(digits)?; crate::rfc::assert_secret_length(secret.as_ref())?; Ok(Self::new_unchecked(algorithm, digits, skew, step, secret)) } @@ -388,8 +326,8 @@ impl Totp { /// ``` pub fn new_unchecked( algorithm: Algorithm, - digits: usize, - skew: u8, + digits: u32, + skew: u32, step: u64, secret: Vec, ) -> Totp { @@ -430,8 +368,8 @@ impl Totp { match self.algorithm { Algorithm::SHA1 | Algorithm::SHA256 | Algorithm::SHA512 => format!( "{1:00$}", - self.digits, - result % 10_u32.pow(self.digits as u32) + self.digits as usize, + result % 10_u32.pow(self.digits) ), #[cfg(feature = "steam")] Algorithm::Steam => (0..self.digits) diff --git a/src/rfc.rs b/src/rfc.rs index 9ab4579..8fd8197 100644 --- a/src/rfc.rs +++ b/src/rfc.rs @@ -1,15 +1,15 @@ use crate::Algorithm; -use crate::TotpError; use crate::Totp; +use crate::TotpError; #[cfg(feature = "serde_support")] use serde::{Deserialize, Serialize}; // Check that the number of digits is RFC-compliant. // (between 6 and 8 inclusive). -pub fn assert_digits(digits: &usize) -> Result<(), TotpError> { - if !(&6..=&8).contains(&digits) { - Err(TotpError::InvalidDigits { digits: *digits }) +pub fn assert_digits(digits: u32) -> Result<(), TotpError> { + if !(6..=8).contains(&digits) { + Err(TotpError::InvalidDigits { digits: digits }) } else { Ok(()) } @@ -48,9 +48,9 @@ pub struct Rfc6238 { /// SHA-1 algorithm: Algorithm, /// The number of digits composing the auth code. Per [rfc-4226](https://tools.ietf.org/html/rfc4226#section-5.3), this can oscilate between 6 and 8 digits. - digits: usize, + digits: u32, /// The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 1. - skew: u8, + skew: u32, /// The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 30 seconds. step: u64, /// As per [rfc-4226](https://tools.ietf.org/html/rfc4226#section-4) the secret should come from a strong source, most likely a CSPRNG. It should be at least 128 bits, but 160 are recommended. @@ -97,8 +97,8 @@ impl Rfc6238 { }) } #[cfg(not(feature = "otpauth"))] - pub fn new(digits: usize, secret: Vec) -> Result { - assert_digits(&digits)?; + pub fn new(digits: u32, secret: Vec) -> Result { + assert_digits(digits)?; assert_secret_length(secret.as_ref())?; Ok(Rfc6238 { @@ -129,8 +129,8 @@ impl Rfc6238 { } /// Set the `digits`. - pub fn digits(&mut self, value: usize) -> Result<(), TotpError> { - assert_digits(&value)?; + pub fn digits(&mut self, value: u32) -> Result<(), TotpError> { + assert_digits(value)?; self.digits = value; Ok(()) } @@ -180,7 +180,7 @@ impl TryFrom for Totp { #[cfg(test)] mod tests { - use crate::{Rfc6238, TotpError, Totp}; + use crate::{Rfc6238, Totp, TotpError}; const GOOD_SECRET: &str = "01234567890123456789"; #[cfg(feature = "otpauth")] diff --git a/src/url.rs b/src/url.rs index 950fc42..de199a1 100644 --- a/src/url.rs +++ b/src/url.rs @@ -1,4 +1,4 @@ -use crate::{Algorithm, TotpError, Totp}; +use crate::{Algorithm, Totp, TotpError}; use url::{Host, Url}; @@ -189,7 +189,7 @@ impl crate::Totp { #[cfg(test)] mod tests { - use crate::{Algorithm, TotpError, Totp}; + use crate::{Algorithm, Totp, TotpError}; #[cfg(feature = "gen_secret")] use crate::{Rfc6238, Secret}; From e95b5110a57e5d6a82625e8da4b1bfa6dd9bdda1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9o=20Rebert?= Date: Fri, 20 Feb 2026 18:25:21 +0100 Subject: [PATCH 2/9] cargo fmt and remove unused use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cléo Rebert --- src/builder.rs | 4 ++-- src/error.rs | 2 -- src/lib.rs | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 6905a01..461fd10 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -93,7 +93,7 @@ impl Builder { /// The "constantoine@github.com" part of "Github:constantoine@github.com". Must not contain a colon `:` /// For example, the name of your user's account. - /// + /// /// Not calling this method will result in [Self::build] to fail. #[cfg(feature = "otpauth")] #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] @@ -106,7 +106,7 @@ impl Builder { /// The "Github" part of "Github:constantoine@github.com". Must not contain a colon `:` /// For example, the name of your service/website. /// Not mandatory, but strongly recommended! - /// + /// /// Unless called, an issuer will not be present. #[cfg(feature = "otpauth")] #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] diff --git a/src/error.rs b/src/error.rs index 6180257..d6e2500 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,3 @@ -use std::fmt::write; - #[cfg(feature = "otpauth")] use url::ParseError; diff --git a/src/lib.rs b/src/lib.rs index c8ae56e..0f0d56d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,8 +63,8 @@ mod url; #[cfg(feature = "qr")] pub use qrcodegen_image; -pub use builder::Builder; pub use algorithm::Algorithm; +pub use builder::Builder; pub use error::TotpError; pub use rfc::Rfc6238; pub use secret::{Secret, SecretParseError}; From f1ad6137e95e7953b3d44413b7b141796ff91042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9o=20Rebert?= Date: Thu, 26 Feb 2026 15:32:58 +0100 Subject: [PATCH 3/9] Add docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cléo Rebert --- src/builder.rs | 33 +++++++++++++++++++++++++++++---- src/lib.rs | 4 ++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 461fd10..a8ac1fc 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -41,6 +41,10 @@ impl Builder { secret: secret, skew: 1, step_duration: 30, + #[cfg(feature = "otpauth")] + account_name: None, + #[cfg(feature = "otpauth")] + issuer: "".to_string() } } @@ -116,19 +120,19 @@ impl Builder { self } - /// Consume the builder into a [Totp]. See [it's method's docs](struct.Builder.html#impl-Builder) for reference about each values. + /// Consume the builder into a [Totp]. See [its method's docs](struct.Builder.html#impl-Builder) for reference about each values. /// /// # Example /// /// ```rust - /// use totp_rs::{Builder, Algorithm}; + /// use totp_rs::{Algorithm, Builder, Totp}; /// use rand::Rng; /// /// let mut rng = rand::rng(); /// let mut secret: [u8; 20] = Default::default(); /// rng.fill(&mut secret[..]); /// - /// let totp = Builder::new(). + /// let totp: Totp = Builder::new(). /// with_algorithm(Algorithm::SHA256). /// with_secret(secret). /// build(). @@ -139,6 +143,7 @@ impl Builder { /// /// - If the `digit` or `secret` size are invalid. /// - If secret was not set using [Self::with_secret] and the feature `gen_secret` is not enabled. + /// - If `issuer` is not set/is an empty string (`otpauth`` feature). /// - If `issuer` or `label` contain the character ':' (`otpauth`` feature). pub fn build(self) -> Result { let secret = self.secret.as_ref().ok_or(TotpError::SecretNotSet)?; @@ -154,7 +159,7 @@ impl Builder { }); } - if self.account_name.as_ref().contains(':') { + if self.account_name.as_ref().is_empty || self.account_name.as_ref().contains(':') { return Err(TotpError::InvalidAccountName { value: account_name, }); @@ -164,6 +169,26 @@ impl Builder { Ok(self.build_noncompliant()) } + /// Consume the builder into a [Totp], without checking the values for RFC. See [its method's docs](struct.Builder.html#impl-Builder) for reference about each values. + /// + ///
Logical errors, such as a step_duration of 0, could cause other functions such as [Totp::generate] to panic.
+ /// + /// # Example + /// + /// ```rust + /// use totp_rs::{Algorithm, Builder, Totp}; + /// use rand::Rng; + /// + /// let mut rng = rand::rng(); + /// let mut secret: [u8; 20] = Default::default(); + /// rng.fill(&mut secret[..]); + /// + /// let totp: Totp = Builder::new(). + /// with_algorithm(Algorithm::SHA256). + /// with_secret(secret). + /// with_digits(10). // Not RFC-compliant. + /// build_noncompliant() + /// ``` pub fn build_noncompliant(self) -> Totp { Totp { algorithm: self.algorithm, diff --git a/src/lib.rs b/src/lib.rs index 0f0d56d..db93838 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -349,7 +349,7 @@ impl Totp { Totp::try_from(rfc) } - /// Will sign the given timestamp + /// Will sign the given timestamp. Most users will want to interact with [Self::generate] and [Self::generate_current]. pub fn sign(&self, time: u64) -> Vec { self.algorithm.sign( self.secret.as_ref(), @@ -357,7 +357,7 @@ impl Totp { ) } - /// Will generate a token given the provided timestamp in seconds + /// Will generate a token given the provided timestamp in seconds. pub fn generate(&self, time: u64) -> String { let result: &[u8] = &self.sign(time); let offset = (result.last().unwrap() & 15) as usize; From 01e8363def965aeef1a52466b2a1fdb4d930ead0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9o=20Rebert?= Date: Thu, 26 Feb 2026 18:53:19 +0100 Subject: [PATCH 4/9] More builder. Time to go home tho MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cléo Rebert --- Cargo.toml | 2 +- examples/rfc-6238.rs | 29 ---- examples/secret.rs | 81 ---------- examples/steam.rs | 7 +- examples/ttl.rs | 25 +-- src/builder.rs | 364 +++++++++++++++++++++++++++++++++++++---- src/lib.rs | 377 +++++++++++-------------------------------- src/rfc.rs | 329 +++---------------------------------- src/url.rs | 351 ++++++++++++---------------------------- 9 files changed, 577 insertions(+), 988 deletions(-) delete mode 100644 examples/rfc-6238.rs delete mode 100644 examples/secret.rs diff --git a/Cargo.toml b/Cargo.toml index 43b4dee..8761f6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -default = [] +default = ["otpauth"] otpauth = ["url", "urlencoding"] qr = ["dep:qrcodegen-image", "otpauth"] serde_support = ["serde"] diff --git a/examples/rfc-6238.rs b/examples/rfc-6238.rs deleted file mode 100644 index 5c52cf7..0000000 --- a/examples/rfc-6238.rs +++ /dev/null @@ -1,29 +0,0 @@ -use totp_rs::{Rfc6238, Totp}; - -#[cfg(feature = "otpauth")] -fn main() { - let mut rfc = Rfc6238::with_defaults("totp-sercret-123".as_bytes().to_vec()).unwrap(); - - // optional, set digits, issuer, account_name - rfc.digits(8).unwrap(); - rfc.issuer("issuer".to_string()); - rfc.account_name("user-account".to_string()); - - // create a TOTP from rfc - let totp = Totp::from_rfc6238(rfc).unwrap(); - let code = totp.generate_current().unwrap(); - println!("code: {}", code); -} - -#[cfg(not(feature = "otpauth"))] -fn main() { - let mut rfc = Rfc6238::with_defaults("totp-sercret-123".into()).unwrap(); - - // optional, set digits, issuer, account_name - rfc.digits(8).unwrap(); - - // create a TOTP from rfc - let totp = Totp::from_rfc6238(rfc).unwrap(); - let code = totp.generate_current().unwrap(); - println!("code: {}", code); -} diff --git a/examples/secret.rs b/examples/secret.rs deleted file mode 100644 index 62402d1..0000000 --- a/examples/secret.rs +++ /dev/null @@ -1,81 +0,0 @@ -use totp_rs::{Algorithm, Secret, Totp}; - -#[cfg(feature = "otpauth")] -fn main() { - // create TOTP from base32 secret - let secret_b32 = Secret::Encoded(String::from("OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG")); - let totp_b32 = Totp::new( - Algorithm::SHA1, - 6, - 1, - 30, - secret_b32.to_bytes().unwrap(), - Some("issuer".to_string()), - "user-account".to_string(), - ) - .unwrap(); - - println!( - "base32 {} ; raw {}", - secret_b32, - secret_b32.to_raw().unwrap() - ); - println!( - "code from base32:\t{}", - totp_b32.generate_current().unwrap() - ); - - // create TOTP from raw binary value - let secret = [ - 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x73, 0x65, - 0x63, 0x72, 0x65, 0x74, 0x2d, 0x31, 0x32, 0x33, - ]; - let secret_raw = Secret::Raw(secret.to_vec()); - let totp_raw = Totp::new( - Algorithm::SHA1, - 6, - 1, - 30, - secret_raw.to_bytes().unwrap(), - Some("issuer".to_string()), - "user-account".to_string(), - ) - .unwrap(); - - println!("raw {} ; base32 {}", secret_raw, secret_raw.to_encoded()); - println!( - "code from raw secret:\t{}", - totp_raw.generate_current().unwrap() - ); -} - -#[cfg(not(feature = "otpauth"))] -fn main() { - // create TOTP from base32 secret - let secret_b32 = Secret::Encoded(String::from("OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG")); - let totp_b32 = Totp::new(Algorithm::SHA1, 6, 1, 30, secret_b32.to_bytes().unwrap()).unwrap(); - - println!( - "base32 {} ; raw {}", - secret_b32, - secret_b32.to_raw().unwrap() - ); - println!( - "code from base32:\t{}", - totp_b32.generate_current().unwrap() - ); - - // create TOTP from raw binary value - let secret = [ - 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x73, 0x65, - 0x63, 0x72, 0x65, 0x74, 0x2d, 0x31, 0x32, 0x33, - ]; - let secret_raw = Secret::Raw(secret.to_vec()); - let totp_raw = Totp::new(Algorithm::SHA1, 6, 1, 30, secret_raw.to_bytes().unwrap()).unwrap(); - - println!("raw {} ; base32 {}", secret_raw, secret_raw.to_encoded()); - println!( - "code from raw secret:\t{}", - totp_raw.generate_current().unwrap() - ); -} diff --git a/examples/steam.rs b/examples/steam.rs index f9d5dc8..aa57c90 100644 --- a/examples/steam.rs +++ b/examples/steam.rs @@ -1,11 +1,12 @@ #[cfg(feature = "steam")] use totp_rs::{Secret, Totp}; +use base32; + -#[cfg(feature = "steam")] -#[cfg(feature = "otpauth")] fn main() { // create TOTP from base32 secret - let secret_b32 = Secret::Encoded(String::from("OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG")); + let secret = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, "OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG").unwrap(); + let secret_b32 = Secret::Encoded()); let totp_b32 = Totp::new_steam(secret_b32.to_bytes().unwrap(), "user-account".to_string()); println!( diff --git a/examples/ttl.rs b/examples/ttl.rs index dab2525..29b2743 100644 --- a/examples/ttl.rs +++ b/examples/ttl.rs @@ -1,8 +1,13 @@ -use totp_rs::{Algorithm, Totp}; +use totp_rs::Builder; + +const GOOD_SECRET: &[u8] = "TestSecretSuperSecret".as_bytes(); #[cfg(not(feature = "otpauth"))] fn main() { - let totp = Totp::new(Algorithm::SHA1, 6, 1, 30, "my-secret".as_bytes().to_vec()).unwrap(); + let totp = Builder::new() + .with_secret(GOOD_SECRET.into()) + .build() + .unwrap(); loop { println!( @@ -17,16 +22,12 @@ fn main() { #[cfg(feature = "otpauth")] fn main() { - let totp = Totp::new( - Algorithm::SHA1, - 6, - 1, - 30, - "my-secret".as_bytes().to_vec(), - Some("Github".to_string()), - "constantoine@github.com".to_string(), - ) - .unwrap(); + let totp = Builder::new() + .with_account_name("constantoine@github.com".to_string()) + .with_issuer(Some("Github".to_string())) + .with_secret(GOOD_SECRET.into()) + .build() + .unwrap(); loop { println!( diff --git a/src/builder.rs b/src/builder.rs index a8ac1fc..705dd7f 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -20,13 +20,15 @@ pub struct Builder { impl Builder { /// New builder. /// If `gen_secret` is enabled, [Self::new] will generate a new, safe-to-use, secret. + /// in case `gen_secret` is enabled, [Totp::default] will be equivalent to calling [Self::new] followed by [Self::build] in which case + /// After build, use [Totp::to_secret_binary] or [Totp::to_secret_base32] to retrieve the newly generated secret. pub fn new() -> Builder { #[cfg(feature = "gen_secret")] - let secret = { + let secret: Option> = { use rand::Rng; let mut rng = rand::rng(); - let mut secret: [u8; 20] = Default::default(); + let mut secret: Vec = vec![0; 20]; rng.fill(&mut secret[..]); Some(secret) @@ -42,16 +44,16 @@ impl Builder { skew: 1, step_duration: 30, #[cfg(feature = "otpauth")] - account_name: None, + account_name: "".to_string(), #[cfg(feature = "otpauth")] - issuer: "".to_string() + issuer: None, } } /// SHA-1 is the most widespread algorithm used, and for totp pursposes, SHA-1 hash collisions are [not a problem](https://tools.ietf.org/html/rfc4226#appendix-B.2) as HMAC-SHA-1 is not impacted. It's also the main one cited in [rfc-6238](https://tools.ietf.org/html/rfc6238#section-3) even though the [reference implementation](https://tools.ietf.org/html/rfc6238#appendix-A) permits the use of SHA-1, SHA-256 and SHA-512. Not all clients support other algorithms then SHA-1. /// /// Unless called, the default value will be Algorithm::SHA1. - pub fn with_algorithm(&mut self, algorithm: Algorithm) -> &mut Self { + pub fn with_algorithm(mut self, algorithm: Algorithm) -> Self { self.algorithm = algorithm; self @@ -60,7 +62,7 @@ impl Builder { /// The number of digits composing the auth code. Per [rfc-4226](https://tools.ietf.org/html/rfc4226#section-5.3), this can oscilate between 6 and 8 digits. /// /// Unless called, the default value will be 6. - pub fn with_digits(&mut self, digits: u32) -> &mut Self { + pub fn with_digits(mut self, digits: u32) -> Self { self.digits = digits; self @@ -71,7 +73,7 @@ impl Builder { /// Unless called, and if feature `gen_secret` is enabled, a random 160bits secret from a strong source will be the default value. /// /// If feature `gen_secret` is not enabled, then not calling this method will result in [Self::build] to fail. - pub fn with_secret(&mut self, secret: Vec) -> &mut Self { + pub fn with_secret(mut self, secret: Vec) -> Self { self.secret = Some(secret); self @@ -80,7 +82,7 @@ impl Builder { /// Number of steps allowed as network delay. 1 would mean one step before current step and one step after are valids. The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 1. Anything more is sketchy, and anyone recommending more is, by definition, ugly and stupid. /// /// Unless called, the default value will be 1. - pub fn with_skew(&mut self, skew: u32) -> &mut Self { + pub fn with_skew(mut self, skew: u32) -> Self { self.skew = skew; self @@ -89,7 +91,7 @@ impl Builder { /// Duration in seconds of a step. The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 30 seconds. /// /// Unless called, the default value will be 30. - pub fn with_step_duration(&mut self, step_duration: u64) -> &mut Self { + pub fn with_step_duration(mut self, step_duration: u64) -> Self { self.step_duration = step_duration; self @@ -101,7 +103,7 @@ impl Builder { /// Not calling this method will result in [Self::build] to fail. #[cfg(feature = "otpauth")] #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] - pub fn with_account_name(&mut self, account_name: String) -> &mut Self { + pub fn with_account_name(mut self, account_name: String) -> Self { self.account_name = account_name; self @@ -114,7 +116,7 @@ impl Builder { /// Unless called, an issuer will not be present. #[cfg(feature = "otpauth")] #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] - pub fn with_issuer(&mut self, issuer: Option) -> &mut Self { + pub fn with_issuer(mut self, issuer: Option) -> Self { self.issuer = issuer; self @@ -125,18 +127,17 @@ impl Builder { /// # Example /// /// ```rust + /// # #[cfg(not(feature = "otpauth"))] { /// use totp_rs::{Algorithm, Builder, Totp}; - /// use rand::Rng; /// - /// let mut rng = rand::rng(); - /// let mut secret: [u8; 20] = Default::default(); - /// rng.fill(&mut secret[..]); + /// let secret: Vec = vec![0; 20]; // You want an actual 20bytes of randomness here. /// /// let totp: Totp = Builder::new(). /// with_algorithm(Algorithm::SHA256). /// with_secret(secret). /// build(). - /// unwrap() + /// unwrap(); + /// # } /// ``` /// /// # Errors @@ -153,17 +154,8 @@ impl Builder { #[cfg(feature = "otpauth")] { - if self.issuer.is_some() && self.issuer.as_ref().unwrap().contains(':') { - return Err(TotpError::InvalidIssuer { - value: issuer.as_ref().unwrap().to_string(), - }); - } - - if self.account_name.as_ref().is_empty || self.account_name.as_ref().contains(':') { - return Err(TotpError::InvalidAccountName { - value: account_name, - }); - } + crate::rfc::assert_issuer_valid(&self.issuer)?; + crate::rfc::assert_account_name_valid(&self.account_name)?; } Ok(self.build_noncompliant()) @@ -172,22 +164,19 @@ impl Builder { /// Consume the builder into a [Totp], without checking the values for RFC. See [its method's docs](struct.Builder.html#impl-Builder) for reference about each values. /// ///
Logical errors, such as a step_duration of 0, could cause other functions such as [Totp::generate] to panic.
- /// + /// /// # Example /// /// ```rust /// use totp_rs::{Algorithm, Builder, Totp}; - /// use rand::Rng; /// - /// let mut rng = rand::rng(); - /// let mut secret: [u8; 20] = Default::default(); - /// rng.fill(&mut secret[..]); + /// let secret: Vec = Vec::new(); // You want an actual 20bytes of randomness here. /// /// let totp: Totp = Builder::new(). /// with_algorithm(Algorithm::SHA256). /// with_secret(secret). /// with_digits(10). // Not RFC-compliant. - /// build_noncompliant() + /// build_noncompliant(); /// ``` pub fn build_noncompliant(self) -> Totp { Totp { @@ -204,3 +193,312 @@ impl Builder { } } } + +#[cfg(test)] +mod tests { + use crate::error::TotpError; + use crate::{Algorithm, Builder}; + + const GOOD_SECRET: &str = "01234567890123456789"; + + #[cfg(not(feature = "gen_secret"))] + const SHORT_SECRET: &str = "tooshort"; + + // === Defaults === + + #[test] + #[cfg(not(feature = "otpauth"))] + fn defaults_without_secret() { + let builder = Builder::new(); + assert_eq!(builder.algorithm, Algorithm::SHA1); + assert_eq!(builder.digits, 6); + assert_eq!(builder.skew, 1); + assert_eq!(builder.step_duration, 30); + } + + #[test] + #[cfg(not(feature = "gen_secret"))] + fn defaults_secret_is_none_without_gen_secret() { + let builder = Builder::new(); + assert!(builder.secret.is_none()); + } + + #[test] + #[cfg(feature = "gen_secret")] + fn defaults_secret_is_generated_with_gen_secret() { + let builder = Builder::new(); + assert!(builder.secret.is_some()); + assert_eq!(builder.secret.unwrap().len(), 20); + } + + #[test] + #[cfg(feature = "otpauth")] + fn defaults_otpauth_fields() { + let builder = Builder::new(); + assert_eq!(builder.account_name, ""); + assert!(builder.issuer.is_none()); + } + + // === Setters === + + #[test] + fn with_algorithm() { + let builder = Builder::new().with_algorithm(Algorithm::SHA256); + assert_eq!(builder.algorithm, Algorithm::SHA256); + } + + #[test] + fn with_digits() { + let builder = Builder::new().with_digits(8); + assert_eq!(builder.digits, 8); + } + + #[test] + fn with_secret() { + let builder = Builder::new().with_secret(GOOD_SECRET.into()); + assert_eq!(builder.secret.unwrap(), GOOD_SECRET.as_bytes()); + } + + #[test] + fn with_skew() { + let builder = Builder::new().with_skew(2); + assert_eq!(builder.skew, 2); + } + + #[test] + fn with_step_duration() { + let builder = Builder::new().with_step_duration(60); + assert_eq!(builder.step_duration, 60); + } + + #[test] + #[cfg(feature = "otpauth")] + fn with_account_name() { + let builder = Builder::new().with_account_name("user@example.com".to_string()); + assert_eq!(builder.account_name, "user@example.com"); + } + + #[test] + #[cfg(feature = "otpauth")] + fn with_issuer() { + let builder = Builder::new().with_issuer(Some("Github".to_string())); + assert_eq!(builder.issuer, Some("Github".to_string())); + } + + // === build() success === + + #[test] + #[cfg(not(feature = "otpauth"))] + fn build_ok() { + let totp = Builder::new().with_secret(GOOD_SECRET.into()).build(); + assert!(totp.is_ok()); + let totp = totp.unwrap(); + assert_eq!(totp.algorithm, Algorithm::SHA1); + assert_eq!(totp.digits, 6); + assert_eq!(totp.skew, 1); + assert_eq!(totp.step, 30); + assert_eq!(totp.secret, GOOD_SECRET.as_bytes()); + } + + #[test] + #[cfg(not(feature = "otpauth"))] + fn build_with_all_fields() { + let totp = Builder::new() + .with_algorithm(Algorithm::SHA512) + .with_digits(8) + .with_skew(2) + .with_step_duration(60) + .with_secret(GOOD_SECRET.into()) + .build() + .unwrap(); + assert_eq!(totp.algorithm, Algorithm::SHA512); + assert_eq!(totp.digits, 8); + assert_eq!(totp.skew, 2); + assert_eq!(totp.step, 60); + } + + #[test] + #[cfg(feature = "otpauth")] + fn build_ok_otpauth() { + let result = Builder::new() + .with_secret(GOOD_SECRET.into()) + .with_account_name("user@example.com".to_string()) + .with_issuer(Some("Github".to_string())) + .build(); + assert!(result.is_ok()); + let totp = result.unwrap(); + assert_eq!(totp.account_name, "user@example.com"); + assert_eq!(totp.issuer, Some("Github".to_string())); + } + + #[test] + #[cfg(feature = "otpauth")] + fn build_ok_without_issuer() { + let result = Builder::new() + .with_secret(GOOD_SECRET.into()) + .with_account_name("user@example.com".to_string()) + .build(); + assert!(result.is_ok()); + } + + // === build() failures === + + #[test] + #[cfg(not(feature = "gen_secret"))] + fn build_fails_secret_not_set() { + let result = Builder::new().build(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), TotpError::SecretNotSet); + } + + #[test] + #[cfg(not(feature = "otpauth"))] + fn build_fails_secret_too_short() { + let builder = Builder::new().with_secret(SHORT_SECRET.into()); + let result = builder.build(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + TotpError::SecretTooShort { .. } + )); + } + + #[test] + #[cfg(not(feature = "otpauth"))] + fn build_fails_digits_too_low() { + let builder = Builder::new() + .with_secret(GOOD_SECRET.into()) + .with_digits(5); + let result = builder.build(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), TotpError::InvalidDigits { digits: 5 }); + } + + #[test] + #[cfg(not(feature = "otpauth"))] + fn build_fails_digits_too_high() { + let builder = Builder::new() + .with_secret(GOOD_SECRET.into()) + .with_digits(9); + let result = builder.build(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), TotpError::InvalidDigits { digits: 9 }); + } + + #[test] + #[cfg(feature = "otpauth")] + fn build_fails_empty_account_name() { + let result = Builder::new().with_secret(GOOD_SECRET.into()).build(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + TotpError::InvalidAccountName { .. } + )); + } + + #[test] + #[cfg(feature = "otpauth")] + fn build_fails_account_name_with_colon() { + let result = Builder::new() + .with_secret(GOOD_SECRET.into()) + .with_account_name("user:name".to_string()) + .build(); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + TotpError::InvalidAccountName { + value: "user:name".to_string() + } + ); + } + + #[test] + #[cfg(feature = "otpauth")] + fn build_fails_issuer_with_colon() { + let result = Builder::new() + .with_secret(GOOD_SECRET.into()) + .with_account_name("user@example.com".to_string()) + .with_issuer(Some("Iss:uer".to_string())) + .build(); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + TotpError::InvalidIssuer { + value: "Iss:uer".to_string() + } + ); + } + + // === build_noncompliant() === + + #[test] + fn build_noncompliant_allows_invalid_digits() { + let totp = Builder::new() + .with_secret(GOOD_SECRET.into()) + .with_digits(10) + .build_noncompliant(); + assert_eq!(totp.digits, 10); + } + + #[test] + fn build_noncompliant_allows_short_secret() { + let totp = Builder::new() + .with_secret(SHORT_SECRET.into()) + .build_noncompliant(); + assert_eq!(totp.secret, SHORT_SECRET.as_bytes()); + } + + #[test] + #[cfg(not(feature = "gen_secret"))] + fn build_noncompliant_no_secret_uses_empty_default() { + let totp = Builder::new().build_noncompliant(); + assert!(totp.secret.is_empty()); + } + + #[test] + #[cfg(feature = "gen_secret")] + fn build_noncompliant_no_secret_uses_generated() { + let totp = Builder::new().build_noncompliant(); + assert_eq!(totp.secret.len(), 20); + } + + #[test] + #[cfg(feature = "otpauth")] + fn build_noncompliant_allows_invalid_account_name() { + let totp = Builder::new() + .with_secret(GOOD_SECRET.into()) + .with_account_name("bad:name".to_string()) + .build_noncompliant(); + assert_eq!(totp.account_name, "bad:name"); + } + + // === Digits boundary values === + + #[test] + #[cfg(not(feature = "otpauth"))] + fn build_accepts_digits() { + for i in 6..=8 { + let builder = Builder::new() + .with_secret(GOOD_SECRET.into()) + .with_digits(i); + assert!(builder.build().is_ok()); + } + } + + // === Secret boundary === + + #[test] + #[cfg(not(feature = "otpauth"))] + fn build_accepts_exactly_16_byte_secret() { + let builder = Builder::new().with_secret(vec![0u8; 16]); + assert!(builder.build().is_ok()); + } + + #[test] + #[cfg(not(feature = "otpauth"))] + fn build_rejects_15_byte_secret() { + let result = Builder::new().with_secret(vec![0u8; 15]).build(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), TotpError::SecretTooShort { bits: 120 }); + } +} diff --git a/src/lib.rs b/src/lib.rs index db93838..4cb37ad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,20 +10,35 @@ //! //! ```rust //! # #[cfg(feature = "otpauth")] { -//! use std::time::SystemTime; -//! use totp_rs::{Algorithm, Totp, Secret}; +//! use totp_rs::{Algorithm, Builder, Totp}; //! -//! let totp = Totp::new( -//! Algorithm::SHA1, -//! 6, -//! 1, -//! 30, -//! Secret::Raw("TestSecretSuperSecret".as_bytes().to_vec()).to_bytes().unwrap(), -//! Some("Github".to_string()), -//! "constantoine@github.com".to_string(), -//! ).unwrap(); +//! let secret: Vec = vec![0; 20]; // You want an actual 20bytes of randomness here. +//! +//! let totp: Totp = Builder::new(). +//! with_algorithm(Algorithm::SHA256). +//! with_secret(secret). +//! with_account_name("constantoine@github.com".to_string()). +//! with_issuer(Some("Github".to_string())). +//! build(). +//! unwrap(); +//! +//! let token = totp.generate_current().unwrap(); +//! println!("{}", token); +//! # } +//! ``` +//! +//! ```rust +//! # #[cfg(all(feature = "gen_secret", not(feature = "otpauth")))] { +//! use totp_rs::Builder; +//! +//! let totp: Totp = Builder::new(). +//! build(). +//! unwrap(); +//! //! let token = totp.generate_current().unwrap(); //! println!("{}", token); +//! +//! let secret = totp.to_secret_binary(); //! # } //! ``` //! @@ -31,15 +46,15 @@ //! # #[cfg(feature = "qr")] { //! use totp_rs::{Algorithm, Totp}; //! -//! let totp = Totp::new( -//! Algorithm::SHA1, -//! 6, -//! 1, -//! 30, -//! "supersecret_topsecret".as_bytes().to_vec(), -//! Some("Github".to_string()), -//! "constantoine@github.com".to_string(), -//! ).unwrap(); +//! let secret: Vec = vec![0; 20]; // You want an actual 20bytes of randomness here. +//! +//! let totp: Totp = Builder::new(). +//! with_secret(secret). +//! with_account_name("constantoine@github.com".to_string()). +//! with_issuer(Some("Github".to_string())). +//! build(). +//! unwrap(); +//! //! let url = totp.to_url(); //! println!("{}", url); //! let code = totp.to_qr_base64().unwrap(); @@ -66,7 +81,6 @@ pub use qrcodegen_image; pub use algorithm::Algorithm; pub use builder::Builder; pub use error::TotpError; -pub use rfc::Rfc6238; pub use secret::{Secret, SecretParseError}; use constant_time_eq::constant_time_eq; @@ -160,195 +174,25 @@ impl core::fmt::Display for Totp { } } +/// Default as set in [Builder::new]. +/// This implementation shall remain, to avoid breaking compatibility. +/// Use [Self::to_secret_binary] or [Self::to_secret_base32] to retrieve the newly generated secret. #[cfg(all(feature = "gen_secret", not(feature = "otpauth")))] -// because `Default` is implemented regardless of `otpauth` feature we don't specify it here -#[cfg_attr(docsrs, doc(cfg(feature = "gen_secret")))] +#[cfg_attr( + docsrs, + doc(cfg(all(feature = "gen_secret", not(feature = "otpauth")))) +)] impl Default for Totp { fn default() -> Self { - return Totp::new( - Algorithm::SHA1, - 6, - 1, - 30, - Secret::generate_secret().to_bytes().unwrap(), - ) - .unwrap(); - } -} + use crate::Builder; -#[cfg(all(feature = "gen_secret", feature = "otpauth"))] -#[cfg_attr(docsrs, doc(cfg(feature = "gen_secret")))] -impl Default for Totp { - fn default() -> Self { - Totp::new( - Algorithm::SHA1, - 6, - 1, - 30, - Secret::generate_secret().to_bytes().unwrap(), - None, - "".to_string(), - ) - .unwrap() + Builder::new() + .build() + .expect("Default value for Builder should never fail") } } impl Totp { - #[cfg(feature = "otpauth")] - /// Will create a new instance of TOTP with given parameters. See [the doc](struct.Totp.html#fields) for reference as to how to choose those values - /// - /// # Description - /// * `secret`: expect a non-encoded value, to pass in base32 string use `Secret::Encoded(String)` - /// * `digits`: MUST be between 6 & 8 - /// * `secret`: Must have bitsize of at least 128 - /// * `account_name`: Must not contain `:` - /// * `issuer`: Must not contain `:` - /// - /// # Example - /// - /// ```rust - /// use totp_rs::{Secret, Totp, Algorithm}; - /// let secret = Secret::Encoded("OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG".to_string()); - /// let totp = Totp::new(Algorithm::SHA1, 6, 1, 30, secret.to_bytes().unwrap(), None, "".to_string()).unwrap(); - /// ``` - /// - /// # Errors - /// - /// Will return an error if the `digit` or `secret` size is invalid or if `issuer` or `label` contain the character ':' - pub fn new( - algorithm: Algorithm, - digits: usize, - skew: u8, - step: u64, - secret: Vec, - issuer: Option, - account_name: String, - ) -> Result { - crate::rfc::assert_digits(&digits)?; - crate::rfc::assert_secret_length(secret.as_ref())?; - if issuer.is_some() && issuer.as_ref().unwrap().contains(':') { - return Err(TotpError::InvalidIssuer { - value: issuer.as_ref().unwrap().to_string(), - }); - } - if account_name.contains(':') { - return Err(TotpError::InvalidAccountName { - value: account_name, - }); - } - Ok(Self::new_unchecked( - algorithm, - digits, - skew, - step, - secret, - issuer, - account_name, - )) - } - - #[cfg(feature = "otpauth")] - /// Will create a new instance of TOTP with given parameters. See [the doc](struct.Totp.html#fields) for reference as to how to choose those values. This is unchecked and does not check the `digits` and `secret` size - /// - /// # Description - /// * `secret`: expect a non-encoded value, to pass in base32 string use `Secret::Encoded(String)` - /// - /// # Example - /// - /// ```rust - /// use totp_rs::{Secret, Totp, Algorithm}; - /// let secret = Secret::Encoded("OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG".to_string()); - /// let totp = Totp::new_unchecked(Algorithm::SHA1, 6, 1, 30, secret.to_bytes().unwrap(), None, "".to_string()); - /// ``` - pub fn new_unchecked( - algorithm: Algorithm, - digits: usize, - skew: u8, - step: u64, - secret: Vec, - issuer: Option, - account_name: String, - ) -> Totp { - Totp { - algorithm, - digits, - skew, - step, - secret, - issuer, - account_name, - } - } - - #[cfg(not(feature = "otpauth"))] - /// Will create a new instance of TOTP with given parameters. See [the doc](struct.Totp.html#fields) for reference as to how to choose those values - /// - /// # Description - /// * `secret`: expect a non-encoded value, to pass in base32 string use `Secret::Encoded(String)` - /// * `digits`: MUST be between 6 & 8 - /// * `secret`: Must have bitsize of at least 128 - /// - /// # Example - /// - /// ```rust - /// use totp_rs::{Secret, Totp, Algorithm}; - /// let secret = Secret::Encoded("OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG".to_string()); - /// let totp = Totp::new(Algorithm::SHA1, 6, 1, 30, secret.to_bytes().unwrap()).unwrap(); - /// ``` - /// - /// # Errors - /// - /// Will return an error if the `digit` or `secret` size is invalid - pub fn new( - algorithm: Algorithm, - digits: u32, - skew: u32, - step: u64, - secret: Vec, - ) -> Result { - crate::rfc::assert_digits(digits)?; - crate::rfc::assert_secret_length(secret.as_ref())?; - Ok(Self::new_unchecked(algorithm, digits, skew, step, secret)) - } - - #[cfg(not(feature = "otpauth"))] - /// Will create a new instance of TOTP with given parameters. See [the doc](struct.Totp.html#fields) for reference as to how to choose those values. This is unchecked and does not check the `digits` and `secret` size - /// - /// # Description - /// * `secret`: expect a non-encoded value, to pass in base32 string use `Secret::Encoded(String)` - /// - /// # Example - /// - /// ```rust - /// use totp_rs::{Secret, Totp, Algorithm}; - /// let secret = Secret::Encoded("OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG".to_string()); - /// let totp = Totp::new_unchecked(Algorithm::SHA1, 6, 1, 30, secret.to_bytes().unwrap()); - /// ``` - pub fn new_unchecked( - algorithm: Algorithm, - digits: u32, - skew: u32, - step: u64, - secret: Vec, - ) -> Totp { - Totp { - algorithm, - digits, - skew, - step, - secret, - } - } - - /// Will create a new instance of TOTP from the given [Rfc6238](struct.Rfc6238.html) struct - /// - /// # Errors - /// - /// Will return an error in case issuer or label contain the character ':' - pub fn from_rfc6238(rfc: Rfc6238) -> Result { - Totp::try_from(rfc) - } - /// Will sign the given timestamp. Most users will want to interact with [Self::generate] and [Self::generate_current]. pub fn sign(&self, time: u64) -> Vec { self.algorithm.sign( @@ -431,6 +275,11 @@ impl Totp { Ok(self.check(token, t)) } + /// Will return a clone of the secret as raw bytes. + pub fn to_secret_binary(&self) -> Vec { + self.secret.clone() + } + /// Will return the base32 representation of the secret, which might be useful when users want to manually add the secret to their authenticator pub fn to_secret_base32(&self) -> String { base32::encode( @@ -443,15 +292,6 @@ impl Totp { #[cfg(feature = "qr")] #[cfg_attr(docsrs, doc(cfg(feature = "qr")))] impl Totp { - #[deprecated( - since = "5.3.0", - note = "to_qr was forcing the use of png as a base64. Use to_qr_base64 or to_qr_png instead. Will disappear in 6.0." - )] - pub fn to_qr(&self) -> Result { - let url = self.to_url(); - qrcodegen_image::draw_base64(&url) - } - /// Will return a qrcode to automatically add a TOTP as a base64 string. Needs feature `qr` to be enabled! /// Result will be in the form of a string containing a base64-encoded png, which you can embed in HTML without needing /// To store the png as a file. @@ -494,78 +334,28 @@ mod tests { #[cfg(feature = "gen_secret")] fn default_values() { let totp = Totp::default(); - assert_eq!(totp.algorithm, Algorithm::SHA1); - assert_eq!(totp.digits, 6); - assert_eq!(totp.skew, 1); - assert_eq!(totp.step, 30) - } + let totp_from_builder = Builder::new().build().unwrap(); - #[test] - #[cfg(not(feature = "otpauth"))] - fn comparison_different_algo() { - let reference = - Totp::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap(); - let test = Totp::new(Algorithm::SHA256, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap(); - assert_ne!(reference, test); - } - - #[test] - #[cfg(not(feature = "otpauth"))] - fn comparison_different_digits() { - let reference = - Totp::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap(); - let test = Totp::new(Algorithm::SHA1, 8, 1, 1, "TestSecretSuperSecret".into()).unwrap(); - assert_ne!(reference, test); - } - - #[test] - #[cfg(not(feature = "otpauth"))] - fn comparison_different_skew() { - let reference = - Totp::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap(); - let test = Totp::new(Algorithm::SHA1, 6, 0, 1, "TestSecretSuperSecret".into()).unwrap(); - assert_ne!(reference, test); - } - - #[test] - #[cfg(not(feature = "otpauth"))] - fn comparison_different_step() { - let reference = - Totp::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap(); - let test = Totp::new(Algorithm::SHA1, 6, 1, 30, "TestSecretSuperSecret".into()).unwrap(); - assert_ne!(reference, test); - } - - #[test] - #[cfg(not(feature = "otpauth"))] - fn comparison_different_secret() { - let reference = - Totp::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap(); - let test = Totp::new(Algorithm::SHA1, 6, 1, 1, "TestSecretDifferentSecret".into()).unwrap(); - assert_ne!(reference, test); - } - - #[test] - #[cfg(not(feature = "otpauth"))] - fn returns_base32() { - let totp = Totp::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap(); - assert_eq!( - totp.to_secret_base32().as_str(), - "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ" - ); + assert_eq!(totp, totp_from_builder); } #[test] #[cfg(not(feature = "otpauth"))] fn generate_token() { - let totp = Totp::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap(); + let totp = Builder::new() + .with_step_duration(1) + .with_secret("TestSecretSuperSecret".into()) + .build_noncompliant(); assert_eq!(totp.generate(1000).as_str(), "659761"); } #[test] #[cfg(not(feature = "otpauth"))] fn generate_token_current() { - let totp = Totp::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap(); + let totp = Builder::new() + .with_step_duration(1) + .with_secret("TestSecretSuperSecret".into()) + .build_noncompliant(); let time = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() @@ -579,35 +369,55 @@ mod tests { #[test] #[cfg(not(feature = "otpauth"))] fn generates_token_sha256() { - let totp = Totp::new(Algorithm::SHA256, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap(); + let totp = Builder::new() + .with_step_duration(1) + .with_skew(1) + .with_secret("TestSecretSuperSecret".into()) + .build_noncompliant(); assert_eq!(totp.generate(1000).as_str(), "076417"); } #[test] #[cfg(not(feature = "otpauth"))] fn generates_token_sha512() { - let totp = Totp::new(Algorithm::SHA512, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap(); + let totp = Builder::new() + .with_step_duration(1) + .with_skew(1) + .with_secret("TestSecretSuperSecret".into()) + .build_noncompliant(); assert_eq!(totp.generate(1000).as_str(), "473536"); } #[test] #[cfg(not(feature = "otpauth"))] fn checks_token() { - let totp = Totp::new(Algorithm::SHA1, 6, 0, 1, "TestSecretSuperSecret".into()).unwrap(); + let totp = Builder::new() + .with_step_duration(1) + .with_skew(0) + .with_secret("TestSecretSuperSecret".into()) + .build_noncompliant(); assert!(totp.check("659761", 1000)); } #[test] #[cfg(not(feature = "otpauth"))] fn checks_token_big_skew() { - let totp = Totp::new(Algorithm::SHA1, 6, 255, 1, "TestSecretSuperSecret".into()).unwrap(); + let totp = Builder::new() + .with_step_duration(1) + .with_skew(1000) + .with_secret("TestSecretSuperSecret".into()) + .build_noncompliant(); assert!(totp.check("659761", 1000)); } #[test] #[cfg(not(feature = "otpauth"))] fn checks_token_current() { - let totp = Totp::new(Algorithm::SHA1, 6, 0, 1, "TestSecretSuperSecret".into()).unwrap(); + let totp = Builder::new() + .with_step_duration(1) + .with_skew(0) + .with_secret("TestSecretSuperSecret".into()) + .build_noncompliant(); assert!(totp .check_current(&totp.generate_current().unwrap()) .unwrap()); @@ -617,7 +427,10 @@ mod tests { #[test] #[cfg(not(feature = "otpauth"))] fn checks_token_with_skew() { - let totp = Totp::new(Algorithm::SHA1, 6, 1, 1, "TestSecretSuperSecret".into()).unwrap(); + let totp = Builder::new() + .with_step_duration(1) + .with_secret("TestSecretSuperSecret".into()) + .build_noncompliant(); assert!( totp.check("174269", 1000) && totp.check("659761", 1000) && totp.check("260393", 1000) ); @@ -626,7 +439,10 @@ mod tests { #[test] #[cfg(not(feature = "otpauth"))] fn next_step() { - let totp = Totp::new(Algorithm::SHA1, 6, 1, 30, "TestSecretSuperSecret".into()).unwrap(); + let totp = Builder::new() + .with_step_duration(30) + .with_secret("TestSecretSuperSecret".into()) + .build_noncompliant(); assert!(totp.next_step(0) == 30); assert!(totp.next_step(29) == 30); assert!(totp.next_step(30) == 60); @@ -635,7 +451,10 @@ mod tests { #[test] #[cfg(not(feature = "otpauth"))] fn next_step_current() { - let totp = Totp::new(Algorithm::SHA1, 6, 1, 30, "TestSecretSuperSecret".into()).unwrap(); + let totp = Builder::new() + .with_step_duration(30) + .with_secret("TestSecretSuperSecret".into()) + .build_noncompliant(); let t = system_time().unwrap(); assert!(totp.next_step_current().unwrap() == totp.next_step(t)); } diff --git a/src/rfc.rs b/src/rfc.rs index 8fd8197..ce56553 100644 --- a/src/rfc.rs +++ b/src/rfc.rs @@ -1,186 +1,56 @@ -use crate::Algorithm; -use crate::Totp; use crate::TotpError; -#[cfg(feature = "serde_support")] -use serde::{Deserialize, Serialize}; - // Check that the number of digits is RFC-compliant. // (between 6 and 8 inclusive). pub fn assert_digits(digits: u32) -> Result<(), TotpError> { if !(6..=8).contains(&digits) { - Err(TotpError::InvalidDigits { digits: digits }) - } else { - Ok(()) + return Err(TotpError::InvalidDigits { digits: digits }); } + + Ok(()) } // Check that the secret is AT LEAST 128 bits long, as per the RFC's requirements. // It is still RECOMMENDED to have an at least 160 bits long secret. pub fn assert_secret_length(secret: &[u8]) -> Result<(), TotpError> { if secret.as_ref().len() < 16 { - Err(TotpError::SecretTooShort { + return Err(TotpError::SecretTooShort { bits: secret.as_ref().len() * 8, - }) - } else { - Ok(()) + }); } -} -/// [rfc-6238](https://tools.ietf.org/html/rfc6238) compliant set of options to create a [TOTP](struct.Totp.html) -/// -/// # Example -/// ``` -/// use totp_rs::{Rfc6238, Totp}; -/// -/// let mut rfc = Rfc6238::with_defaults( -/// "totp-sercret-123".as_bytes().to_vec() -/// ).unwrap(); -/// -/// // optional, set digits, issuer, account_name -/// rfc.digits(8).unwrap(); -/// -/// let totp = Totp::from_rfc6238(rfc).unwrap(); -/// ``` -#[derive(Debug, Clone)] -#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))] -pub struct Rfc6238 { - /// SHA-1 - algorithm: Algorithm, - /// The number of digits composing the auth code. Per [rfc-4226](https://tools.ietf.org/html/rfc4226#section-5.3), this can oscilate between 6 and 8 digits. - digits: u32, - /// The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 1. - skew: u32, - /// The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 30 seconds. - step: u64, - /// As per [rfc-4226](https://tools.ietf.org/html/rfc4226#section-4) the secret should come from a strong source, most likely a CSPRNG. It should be at least 128 bits, but 160 are recommended. - secret: Vec, - #[cfg(feature = "otpauth")] - #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] - /// The "Github" part of "Github:constantoine@github.com". Must not contain a colon `:` - /// For example, the name of your service/website. - /// Not mandatory, but strongly recommended! - issuer: Option, - #[cfg(feature = "otpauth")] - #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] - /// The "constantoine@github.com" part of "Github:constantoine@github.com". Must not contain a colon `:`. - /// For example, the name of your user's account. - account_name: String, + Ok(()) } -impl Rfc6238 { - /// Create an [rfc-6238](https://tools.ietf.org/html/rfc6238) compliant set of options that can be turned into a [TOTP](struct.Totp.html). - /// - /// # Errors - /// - /// will return a [TotpError](enum.TotpError.html) when - /// - `digits` is lower than 6 or higher than 8. - /// - `secret` is smaller than 128 bits (16 characters). - #[cfg(feature = "otpauth")] - pub fn new( - digits: usize, - secret: Vec, - issuer: Option, - account_name: String, - ) -> Result { - assert_digits(&digits)?; - assert_secret_length(secret.as_ref())?; - - Ok(Rfc6238 { - algorithm: Algorithm::SHA1, - digits, - skew: 1, - step: 30, - secret, - issuer, - account_name, - }) - } - #[cfg(not(feature = "otpauth"))] - pub fn new(digits: u32, secret: Vec) -> Result { - assert_digits(digits)?; - assert_secret_length(secret.as_ref())?; - - Ok(Rfc6238 { - algorithm: Algorithm::SHA1, - digits, - skew: 1, - step: 30, - secret, - }) - } - - /// Create an [rfc-6238](https://tools.ietf.org/html/rfc6238) compliant set of options that can be turned into a [TOTP](struct.Totp.html), - /// with a default value of 6 for `digits`, None `issuer` and an empty account. - /// - /// # Errors - /// - /// will return a [TotpError](enum.TotpError.html) when - /// - `digits` is lower than 6 or higher than 8. - /// - `secret` is smaller than 128 bits (16 characters). - #[cfg(feature = "otpauth")] - pub fn with_defaults(secret: Vec) -> Result { - Rfc6238::new(6, secret, Some("".to_string()), "".to_string()) - } - - #[cfg(not(feature = "otpauth"))] - pub fn with_defaults(secret: Vec) -> Result { - Rfc6238::new(6, secret) - } - - /// Set the `digits`. - pub fn digits(&mut self, value: u32) -> Result<(), TotpError> { - assert_digits(value)?; - self.digits = value; - Ok(()) - } - - #[cfg(feature = "otpauth")] - #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] - /// Set the `issuer`. - pub fn issuer(&mut self, value: String) { - self.issuer = Some(value); - } - - #[cfg(feature = "otpauth")] - #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] - /// Set the `account_name`. - pub fn account_name(&mut self, value: String) { - self.account_name = value; +// Checks that account_name is not empty AND doesn't contain `:`. +#[cfg(feature = "otpauth")] +pub fn assert_account_name_valid(account_name: &String) -> Result<(), TotpError> { + if account_name.is_empty() || account_name.contains(':') { + return Err(TotpError::InvalidAccountName { + value: account_name.clone(), + }); } -} -#[cfg(not(feature = "otpauth"))] -impl TryFrom for Totp { - type Error = TotpError; - - /// Try to create a [TOTP](struct.Totp.html) from a [Rfc6238](struct.Rfc6238.html) config. - fn try_from(rfc: Rfc6238) -> Result { - Totp::new(rfc.algorithm, rfc.digits, rfc.skew, rfc.step, rfc.secret) - } + Ok(()) } +// Checks that issuer is either unset (not recommended) or doesn't contain `:`. #[cfg(feature = "otpauth")] -impl TryFrom for Totp { - type Error = TotpError; - - /// Try to create a [TOTP](struct.Totp.html) from a [Rfc6238](struct.Rfc6238.html) config. - fn try_from(rfc: Rfc6238) -> Result { - Totp::new( - rfc.algorithm, - rfc.digits, - rfc.skew, - rfc.step, - rfc.secret, - rfc.issuer, - rfc.account_name, - ) +pub fn assert_issuer_valid(issuer: &Option) -> Result<(), TotpError> { + if let Some(ref issuer) = issuer { + if issuer.contains(':') { + return Err(TotpError::InvalidIssuer { + value: issuer.clone(), + }); + } } + + Ok(()) } #[cfg(test)] mod tests { - use crate::{Rfc6238, Totp, TotpError}; + use crate::{Totp, TotpError}; const GOOD_SECRET: &str = "01234567890123456789"; #[cfg(feature = "otpauth")] @@ -189,153 +59,4 @@ mod tests { const ACCOUNT: &str = "valid-account"; #[cfg(feature = "otpauth")] const INVALID_ACCOUNT: &str = ":invalid-account"; - - #[test] - #[cfg(not(feature = "otpauth"))] - fn new_rfc_digits() { - for x in 0..=20 { - let rfc = Rfc6238::new(x, GOOD_SECRET.into()); - if !(6..=8).contains(&x) { - assert!(rfc.is_err()); - assert!(matches!(rfc.unwrap_err(), TotpError::InvalidDigits { .. })); - } else { - assert!(rfc.is_ok()); - } - } - } - - #[test] - #[cfg(not(feature = "otpauth"))] - fn new_rfc_secret() { - let mut secret = String::from(""); - for _ in 0..=20 { - secret = format!("{}{}", secret, "0"); - let rfc = Rfc6238::new(6, secret.as_bytes().to_vec()); - let rfc_default = Rfc6238::with_defaults(secret.as_bytes().to_vec()); - if secret.len() < 16 { - assert!(rfc.is_err()); - assert!(matches!(rfc.unwrap_err(), TotpError::SecretTooShort { .. })); - assert!(rfc_default.is_err()); - assert!(matches!( - rfc_default.unwrap_err(), - TotpError::SecretTooShort { .. } - )); - } else { - assert!(rfc.is_ok()); - assert!(rfc_default.is_ok()); - } - } - } - - #[test] - #[cfg(not(feature = "otpauth"))] - fn rfc_to_totp_ok() { - let rfc = Rfc6238::new(8, GOOD_SECRET.into()).unwrap(); - let totp = Totp::try_from(rfc); - assert!(totp.is_ok()); - let otp = totp.unwrap(); - assert_eq!(&otp.secret, GOOD_SECRET.as_bytes()); - assert_eq!(otp.algorithm, crate::Algorithm::SHA1); - assert_eq!(otp.digits, 8); - assert_eq!(otp.skew, 1); - assert_eq!(otp.step, 30) - } - - #[test] - #[cfg(not(feature = "otpauth"))] - fn rfc_to_totp_ok_2() { - let rfc = Rfc6238::with_defaults( - crate::Secret::Encoded("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ".to_string()) - .to_bytes() - .unwrap(), - ) - .unwrap(); - let totp = Totp::try_from(rfc); - assert!(totp.is_ok()); - let otp = totp.unwrap(); - assert_eq!(otp.algorithm, crate::Algorithm::SHA1); - assert_eq!(otp.digits, 6); - assert_eq!(otp.skew, 1); - assert_eq!(otp.step, 30) - } - - #[test] - #[cfg(feature = "otpauth")] - fn rfc_to_totp_fail() { - let rfc = Rfc6238::new( - 8, - GOOD_SECRET.as_bytes().to_vec(), - ISSUER.map(str::to_string), - INVALID_ACCOUNT.to_string(), - ) - .unwrap(); - let totp = Totp::try_from(rfc); - assert!(totp.is_err()); - assert!(matches!( - totp.unwrap_err(), - TotpError::InvalidAccountName { .. } - )) - } - - #[test] - #[cfg(feature = "otpauth")] - fn rfc_to_totp_ok() { - let rfc = Rfc6238::new( - 8, - GOOD_SECRET.as_bytes().to_vec(), - ISSUER.map(str::to_string), - ACCOUNT.to_string(), - ) - .unwrap(); - let totp = Totp::try_from(rfc); - assert!(totp.is_ok()); - } - - #[test] - #[cfg(feature = "otpauth")] - fn rfc_with_default_set_values() { - let mut rfc = Rfc6238::with_defaults(GOOD_SECRET.as_bytes().to_vec()).unwrap(); - let ok = rfc.digits(8); - assert!(ok.is_ok()); - assert_eq!(rfc.account_name, ""); - assert_eq!(rfc.issuer, Some("".to_string())); - rfc.issuer("Github".to_string()); - rfc.account_name("constantoine".to_string()); - assert_eq!(rfc.account_name, "constantoine"); - assert_eq!(rfc.issuer, Some("Github".to_string())); - assert_eq!(rfc.digits, 8) - } - - #[test] - #[cfg(not(feature = "otpauth"))] - fn rfc_with_default_set_values() { - let mut rfc = Rfc6238::with_defaults(GOOD_SECRET.as_bytes().to_vec()).unwrap(); - let fail = rfc.digits(4); - assert!(fail.is_err()); - assert!(matches!(fail.unwrap_err(), TotpError::InvalidDigits { .. })); - assert_eq!(rfc.digits, 6); - let ok = rfc.digits(8); - assert!(ok.is_ok()); - assert_eq!(rfc.digits, 8) - } - - #[test] - #[cfg(not(feature = "otpauth"))] - fn digits_error() { - let error = crate::TotpError::InvalidDigits { digits: 9 }; - assert_eq!( - error.to_string(), - "Digits must be 6, 7, or 8, not 9".to_string() - ) - } - - #[test] - #[cfg(not(feature = "otpauth"))] - fn secret_length_error() { - let error = TotpError::SecretTooShort { bits: 120 }; - assert_eq!( - error.to_string(), - "The length of the shared secret MUST be at least 128 bits, got 120 bits".to_string() - ) - } } diff --git a/src/url.rs b/src/url.rs index de199a1..0707d72 100644 --- a/src/url.rs +++ b/src/url.rs @@ -1,4 +1,4 @@ -use crate::{Algorithm, Totp, TotpError}; +use crate::{Algorithm, Builder, Totp, TotpError}; use url::{Host, Url}; @@ -6,36 +6,22 @@ use url::{Host, Url}; impl crate::Totp { /// Generate a TOTP from the standard otpauth URL pub fn from_url>(url: S) -> Result { - let (algorithm, digits, skew, step, secret, issuer, account_name) = - Self::parts_from_url(url)?; - Totp::new(algorithm, digits, skew, step, secret, issuer, account_name) + let builder = Self::parts_from_url(url)?; + + builder.build() } /// Generate a TOTP from the standard otpauth URL, using `Totp::new_unchecked` internally pub fn from_url_unchecked>(url: S) -> Result { - let (algorithm, digits, skew, step, secret, issuer, account_name) = - Self::parts_from_url(url)?; - Ok(Totp::new_unchecked( - algorithm, - digits, - skew, - step, - secret, - issuer, - account_name, - )) + let builder = Self::parts_from_url(url)?; + Ok(builder.build_noncompliant()) } - /// Parse the TOTP parts from the standard otpauth URL - fn parts_from_url>( - url: S, - ) -> Result<(Algorithm, usize, u8, u64, Vec, Option, String), TotpError> { - let mut algorithm = Algorithm::SHA1; - let mut digits = 6; - let mut step = 30; - let mut secret = Vec::new(); - let mut issuer: Option = None; - let mut account_name: String; + /// Parse the TOTP parts from the standard otpauth URL. + /// It returns a builder with defaults values from [Builder::new] + info from the URL. + /// Notable exception: A password will not be supplied automatically if `gen_secret` is enabled. + fn parts_from_url>(url: S) -> Result { + let mut builder = Builder::new().with_secret(Vec::new()); let url = Url::parse(url.as_ref()).map_err(TotpError::UrlParse)?; if url.scheme() != "otpauth" { @@ -47,7 +33,7 @@ impl crate::Totp { Some(Host::Domain("totp")) => {} #[cfg(feature = "steam")] Some(Host::Domain("steam")) => { - algorithm = Algorithm::Steam; + builder = builder.with_algorithm(Algorithm::Steam); } _ => { return Err(TotpError::InvalidHost { @@ -62,28 +48,34 @@ impl crate::Totp { value: path.to_string(), })? .to_string(); + + let account_name: String; + let mut issuer: Option = None; if path.contains(':') { let parts = path.split_once(':').unwrap(); issuer = Some(parts.0.to_owned()); + builder = builder.with_issuer(issuer.clone()); account_name = parts.1.to_owned(); } else { account_name = path; } - account_name = urlencoding::decode(account_name.as_str()) + let account_name = urlencoding::decode(account_name.as_str()) .map_err(|_| TotpError::AccountNameDecode { value: account_name.to_string(), })? .to_string(); + builder = builder.with_account_name(account_name); + for (key, value) in url.query_pairs() { match key.as_ref() { #[cfg(feature = "steam")] - "algorithm" if algorithm == Algorithm::Steam => { - // Do not change used algorithm if this is Steam - } + // Do not change used algorithm if this is Steam + "algorithm" if algorithm == Algorithm::Steam => {} + #[cfg(not(feature = "steam"))] "algorithm" => { - algorithm = match value.as_ref() { + let algorithm = match value.as_ref() { "SHA1" => Algorithm::SHA1, "SHA256" => Algorithm::SHA256, "SHA512" => Algorithm::SHA512, @@ -92,48 +84,56 @@ impl crate::Totp { algorithm: value.to_string(), }) } - } + }; + + builder = builder.with_algorithm(algorithm); } "digits" => { - digits = value - .parse::() + let digits = value + .parse::() .map_err(|_| TotpError::InvalidDigitsURL { digits: value.to_string(), })?; + + builder = builder.with_digits(digits); } "period" => { - step = value - .parse::() - .map_err(|_| TotpError::InvalidStepURL { - step: value.to_string(), - })?; + let step_duration = + value + .parse::() + .map_err(|_| TotpError::InvalidStepURL { + step: value.to_string(), + })?; + + builder = builder.with_step_duration(step_duration); } "secret" => { - secret = base32::decode( + let secret = base32::decode( base32::Alphabet::Rfc4648 { padding: false }, value.as_ref(), ) .ok_or_else(|| TotpError::InvalidSecret)?; + + builder = builder.with_secret(secret); } #[cfg(feature = "steam")] "issuer" if value.to_lowercase() == "steam" => { - algorithm = Algorithm::Steam; - digits = 5; - issuer = Some(value.into()); + builder = builder.with_algorithm(Algorithm::Steam); } + #[cfg(not(feature = "steam"))] "issuer" => { let param_issuer: String = value.into(); - if issuer.is_some() && param_issuer.as_str() != issuer.as_ref().unwrap() { + if issuer.as_ref().is_some() + && param_issuer.as_str() != issuer.as_ref().unwrap() + { return Err(TotpError::IssuerMismatch { path: issuer.as_ref().unwrap().to_string(), query: param_issuer, }); } + issuer = Some(param_issuer); - #[cfg(feature = "steam")] - if issuer == Some("Steam".into()) { - algorithm = Algorithm::Steam; - } + builder = builder.with_issuer(issuer.clone()); } _ => {} } @@ -141,16 +141,13 @@ impl crate::Totp { #[cfg(feature = "steam")] if algorithm == Algorithm::Steam { - digits = 5; - step = 30; - issuer = Some("Steam".into()); - } - - if secret.is_empty() { - return Err(TotpError::SecretTooShort { bits: 0 }); + builder = builder + .with_algorithm(Algorithm::Steam) + .with_digits(5) + .with_issuer(Some(value.into())); } - Ok((algorithm, digits, 1, step, secret, issuer, account_name)) + Ok(builder) } /// Will generate a standard URL used to automatically add TOTP auths. Usually used with qr codes @@ -189,10 +186,11 @@ impl crate::Totp { #[cfg(test)] mod tests { - use crate::{Algorithm, Totp, TotpError}; + use crate::{Algorithm, Builder, Totp, TotpError}; - #[cfg(feature = "gen_secret")] - use crate::{Rfc6238, Secret}; + const GOOD_SECRET: &[u8] = "TestSecretSuperSecret".as_bytes(); + const GOOD_ISSUER: &str = "Github"; + const GOOD_ACCOUNT: &str = "constantoine@github.com"; #[test] #[cfg(feature = "gen_secret")] @@ -204,94 +202,14 @@ mod tests { assert_eq!(totp.step, 30) } - #[test] - fn new_wrong_issuer() { - let totp = Totp::new( - Algorithm::SHA1, - 6, - 1, - 1, - "TestSecretSuperSecret".as_bytes().to_vec(), - Some("Github:".to_string()), - "constantoine@github.com".to_string(), - ); - assert!(totp.is_err()); - assert!(matches!(totp.unwrap_err(), TotpError::InvalidIssuer { .. })); - } - - #[test] - fn new_wrong_account_name() { - let totp = Totp::new( - Algorithm::SHA1, - 6, - 1, - 1, - "TestSecretSuperSecret".as_bytes().to_vec(), - Some("Github".to_string()), - "constantoine:github.com".to_string(), - ); - assert!(totp.is_err()); - assert!(matches!( - totp.unwrap_err(), - TotpError::InvalidAccountName { .. } - )); - } - - #[test] - fn new_wrong_account_name_no_issuer() { - let totp = Totp::new( - Algorithm::SHA1, - 6, - 1, - 1, - "TestSecretSuperSecret".as_bytes().to_vec(), - None, - "constantoine:github.com".to_string(), - ); - assert!(totp.is_err()); - assert!(matches!( - totp.unwrap_err(), - TotpError::InvalidAccountName { .. } - )); - } - - #[test] - fn comparison_ok() { - let reference = Totp::new( - Algorithm::SHA1, - 6, - 1, - 1, - "TestSecretSuperSecret".as_bytes().to_vec(), - Some("Github".to_string()), - "constantoine@github.com".to_string(), - ) - .unwrap(); - let test = Totp::new( - Algorithm::SHA1, - 6, - 1, - 1, - "TestSecretSuperSecret".as_bytes().to_vec(), - Some("Github".to_string()), - "constantoine@github.com".to_string(), - ) - .unwrap(); - assert_eq!(reference, test); - } - #[test] fn url_for_secret_matches_sha1_without_issuer() { - let totp = Totp::new( - Algorithm::SHA1, - 6, - 1, - 30, - "TestSecretSuperSecret".as_bytes().to_vec(), - None, - "constantoine@github.com".to_string(), - ) - .unwrap(); + let totp = Builder::new() + .with_account_name(GOOD_ACCOUNT.into()) + .with_secret(GOOD_SECRET.into()) + .build() + .unwrap(); + let url = totp.to_url(); assert_eq!( url.as_str(), @@ -301,76 +219,43 @@ mod tests { #[test] fn url_for_secret_matches_sha1() { - let totp = Totp::new( - Algorithm::SHA1, - 6, - 1, - 30, - "TestSecretSuperSecret".as_bytes().to_vec(), - Some("Github".to_string()), - "constantoine@github.com".to_string(), - ) - .unwrap(); + let totp = Builder::new() + .with_algorithm(Algorithm::SHA1) + .with_account_name(GOOD_ACCOUNT.into()) + .with_issuer(Some(GOOD_ISSUER.into())) + .with_secret(GOOD_SECRET.into()) + .build() + .unwrap(); let url = totp.to_url(); assert_eq!(url.as_str(), "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&issuer=Github"); } #[test] fn url_for_secret_matches_sha256() { - let totp = Totp::new( - Algorithm::SHA256, - 6, - 1, - 30, - "TestSecretSuperSecret".as_bytes().to_vec(), - Some("Github".to_string()), - "constantoine@github.com".to_string(), - ) - .unwrap(); + let totp = Builder::new() + .with_algorithm(Algorithm::SHA256) + .with_account_name(GOOD_ACCOUNT.into()) + .with_issuer(Some(GOOD_ISSUER.into())) + .with_secret(GOOD_SECRET.into()) + .build() + .unwrap(); let url = totp.to_url(); assert_eq!(url.as_str(), "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA256&issuer=Github"); } #[test] fn url_for_secret_matches_sha512() { - let totp = Totp::new( - Algorithm::SHA512, - 6, - 1, - 30, - "TestSecretSuperSecret".as_bytes().to_vec(), - Some("Github".to_string()), - "constantoine@github.com".to_string(), - ) - .unwrap(); + let totp = Builder::new() + .with_algorithm(Algorithm::SHA512) + .with_account_name(GOOD_ACCOUNT.into()) + .with_issuer(Some(GOOD_ISSUER.into())) + .with_secret(GOOD_SECRET.into()) + .build() + .unwrap(); let url = totp.to_url(); assert_eq!(url.as_str(), "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA512&issuer=Github"); } - #[test] - #[cfg(feature = "gen_secret")] - fn ttl() { - let secret = Secret::default(); - let totp_rfc = Rfc6238::with_defaults(secret.to_bytes().unwrap()).unwrap(); - let totp = Totp::from_rfc6238(totp_rfc); - assert!(totp.is_ok()); - } - - #[test] - fn ttl_ok() { - let totp = Totp::new( - Algorithm::SHA512, - 6, - 1, - 1, - "TestSecretSuperSecret".as_bytes().to_vec(), - Some("Github".to_string()), - "constantoine@github.com".to_string(), - ) - .unwrap(); - assert!(totp.ttl().is_ok()); - } - #[test] fn from_url_err() { assert!(Totp::from_url("otpauth://hotp/123").is_err()); @@ -438,16 +323,13 @@ mod tests { #[test] fn from_url_to_url() { let totp = Totp::from_url("otpauth://totp/Github:constantoine%40github.com?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap(); - let totp_bis = Totp::new( - Algorithm::SHA1, - 6, - 1, - 30, - "TestSecretSuperSecret".as_bytes().to_vec(), - Some("Github".to_string()), - "constantoine@github.com".to_string(), - ) - .unwrap(); + let totp_bis = Builder::new() + .with_algorithm(Algorithm::SHA1) + .with_account_name(GOOD_ACCOUNT.into()) + .with_issuer(Some(GOOD_ISSUER.into())) + .with_secret(GOOD_SECRET.into()) + .build() + .unwrap(); assert_eq!(totp.to_url(), totp_bis.to_url()); } @@ -468,36 +350,16 @@ mod tests { assert_eq!(totp.step, 60); } - #[test] - fn from_url_issuer_special() { - let totp = Totp::from_url("otpauth://totp/Github%40:constantoine%40github.com?issuer=Github%40&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap(); - let totp_bis = Totp::new( - Algorithm::SHA1, - 6, - 1, - 30, - "TestSecretSuperSecret".as_bytes().to_vec(), - Some("Github@".to_string()), - "constantoine@github.com".to_string(), - ) - .unwrap(); - assert_eq!(totp.to_url(), totp_bis.to_url()); - assert_eq!(totp.issuer.as_ref().unwrap(), "Github@"); - } - #[test] fn from_url_account_name_issuer() { let totp = Totp::from_url("otpauth://totp/Github:constantoine?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap(); - let totp_bis = Totp::new( - Algorithm::SHA1, - 6, - 1, - 30, - "TestSecretSuperSecret".as_bytes().to_vec(), - Some("Github".to_string()), - "constantoine".to_string(), - ) - .unwrap(); + let totp_bis = Builder::new() + .with_algorithm(Algorithm::SHA1) + .with_account_name("constantoine".into()) + .with_issuer(Some(GOOD_ISSUER.into())) + .with_secret(GOOD_SECRET.into()) + .build() + .unwrap(); assert_eq!(totp.to_url(), totp_bis.to_url()); assert_eq!(totp.account_name, "constantoine"); assert_eq!(totp.issuer.as_ref().unwrap(), "Github"); @@ -506,16 +368,13 @@ mod tests { #[test] fn from_url_account_name_issuer_encoded() { let totp = Totp::from_url("otpauth://totp/Github%3Aconstantoine?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap(); - let totp_bis = Totp::new( - Algorithm::SHA1, - 6, - 1, - 30, - "TestSecretSuperSecret".as_bytes().to_vec(), - Some("Github".to_string()), - "constantoine".to_string(), - ) - .unwrap(); + let totp_bis = Builder::new() + .with_algorithm(Algorithm::SHA1) + .with_account_name("constantoine".into()) + .with_issuer(Some(GOOD_ISSUER.into())) + .with_secret(GOOD_SECRET.into()) + .build() + .unwrap(); assert_eq!(totp.to_url(), totp_bis.to_url()); assert_eq!(totp.account_name, "constantoine"); assert_eq!(totp.issuer.as_ref().unwrap(), "Github"); From 0ee23ecdd5ecf80d8d24519eeb13ab018896ac7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9o=20Rebert?= Date: Tue, 10 Mar 2026 16:59:48 +0100 Subject: [PATCH 5/9] Fix compilation and examples in some more places MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cléo Rebert --- Cargo.toml | 10 ++++++- examples/gen_secret.rs | 28 +++---------------- examples/steam.rs | 29 ++++++++++++-------- src/algorithm.rs | 2 +- src/builder.rs | 18 ++++++------- src/custom_providers.rs | 60 ++++++++++++++--------------------------- src/lib.rs | 16 +++++------ src/secret.rs | 26 +++++++----------- 8 files changed, 79 insertions(+), 110 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8761f6a..3965b09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -default = ["otpauth"] +default = [] otpauth = ["url", "urlencoding"] qr = ["dep:qrcodegen-image", "otpauth"] serde_support = ["serde"] @@ -36,3 +36,11 @@ constant_time_eq = "0.3" rand = { version = "0.9", features = ["thread_rng"], optional = true, default-features = false } zeroize = { version = "1.6", features = ["alloc", "derive"], optional = true } qrcodegen-image = { version = "1.4", features = ["base64"], optional = true } + +[[example]] +name = "steam" +required-features = ["steam"] + +[[example]] +name = "gen_secret" +required-features = ["gen_secret", "otpauth"] \ No newline at end of file diff --git a/examples/gen_secret.rs b/examples/gen_secret.rs index 1959d94..331553b 100644 --- a/examples/gen_secret.rs +++ b/examples/gen_secret.rs @@ -1,28 +1,8 @@ -#[cfg(all(feature = "gen_secret", feature = "otpauth"))] -use totp_rs::{Algorithm, Secret, Totp}; +#![cfg(not(feature = "otpauth"))] +use totp_rs::{Algorithm, Builder, Secret, Totp}; -#[cfg(all(feature = "gen_secret", feature = "otpauth"))] fn main() { - let secret = Secret::generate_secret(); + let totp = Builder::new().unwrap(); - let totp = Totp::new( - Algorithm::SHA1, - 6, - 1, - 30, - secret.to_bytes().unwrap(), - None, - "account".to_string(), - ) - .unwrap(); - - println!( - "secret raw: {} ; secret base32 {} ; code: {}", - secret, - secret.to_encoded(), - totp.generate_current().unwrap() - ) + println!("code: {}", totp.generate_current().unwrap()) } - -#[cfg(not(all(feature = "gen_secret", feature = "otpauth")))] -fn main() {} diff --git a/examples/steam.rs b/examples/steam.rs index aa57c90..a47b6c7 100644 --- a/examples/steam.rs +++ b/examples/steam.rs @@ -1,13 +1,21 @@ -#[cfg(feature = "steam")] -use totp_rs::{Secret, Totp}; use base32; +use totp_rs::{Builder, Secret, Totp}; - +#[cfg(feature = "otpauth")] fn main() { // create TOTP from base32 secret - let secret = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, "OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG").unwrap(); - let secret_b32 = Secret::Encoded()); - let totp_b32 = Totp::new_steam(secret_b32.to_bytes().unwrap(), "user-account".to_string()); + let secret = base32::decode( + base32::Alphabet::Rfc4648 { padding: false }, + "OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG", + ) + .unwrap(); + let secret_b32 = Secret::Encoded(s); + + let totp_b32 = Builder::new_steam() + .with_secret(secret_b32.to_bytes().unwrap()) + .with_account_name("user-account") + .build() + .unwrap(); println!( "base32 {} ; raw {}", @@ -20,12 +28,14 @@ fn main() { ); } -#[cfg(feature = "steam")] #[cfg(not(feature = "otpauth"))] fn main() { // create TOTP from base32 secret let secret_b32 = Secret::Encoded(String::from("OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG")); - let totp_b32 = Totp::new_steam(secret_b32.to_bytes().unwrap()); + let totp_b32 = Builder::new_steam() + .with_secret(secret_b32.to_bytes().unwrap()) + .build() + .unwrap(); println!( "base32 {} ; raw {}", @@ -37,6 +47,3 @@ fn main() { totp_b32.generate_current().unwrap() ); } - -#[cfg(not(feature = "steam"))] -fn main() {} diff --git a/src/algorithm.rs b/src/algorithm.rs index 6a776aa..412f343 100644 --- a/src/algorithm.rs +++ b/src/algorithm.rs @@ -7,7 +7,7 @@ use std::fmt; /// Alphabet for Steam tokens. #[cfg(feature = "steam")] -const STEAM_CHARS: &str = "23456789BCDFGHJKMNPQRTVWXY"; +pub(super) const STEAM_CHARS: &str = "23456789BCDFGHJKMNPQRTVWXY"; /// Algorithm enum holds the three standards algorithms for TOTP as per the [reference implementation](https://tools.ietf.org/html/rfc6238#appendix-A) #[derive(Debug, Copy, Clone, Eq, PartialEq)] diff --git a/src/builder.rs b/src/builder.rs index 705dd7f..d012402 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -5,24 +5,24 @@ use crate::{Algorithm, Totp}; /// Because it contains the sensitive data of the HMAC secret, treat it accordingly. #[cfg_attr(feature = "zeroize", derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop))] pub struct Builder { - algorithm: Algorithm, - digits: u32, - secret: Option>, - skew: u32, - step_duration: u64, + pub(super) algorithm: Algorithm, + pub(super) digits: u32, + pub(super) secret: Option>, + pub(super) skew: u32, + pub(super) step_duration: u64, #[cfg(feature = "otpauth")] - account_name: String, + pub(super) account_name: String, #[cfg(feature = "otpauth")] - issuer: Option, + pub(super) issuer: Option, } impl Builder { - /// New builder. + /// New Builder. /// If `gen_secret` is enabled, [Self::new] will generate a new, safe-to-use, secret. /// in case `gen_secret` is enabled, [Totp::default] will be equivalent to calling [Self::new] followed by [Self::build] in which case /// After build, use [Totp::to_secret_binary] or [Totp::to_secret_base32] to retrieve the newly generated secret. - pub fn new() -> Builder { + pub fn new() -> Self { #[cfg(feature = "gen_secret")] let secret: Option> = { use rand::Rng; diff --git a/src/custom_providers.rs b/src/custom_providers.rs index 8eca95b..93d171b 100644 --- a/src/custom_providers.rs +++ b/src/custom_providers.rs @@ -1,49 +1,29 @@ #[cfg(feature = "steam")] -use crate::{Algorithm, Totp}; +use crate::{Algorithm, Builder}; #[cfg(feature = "steam")] #[cfg_attr(docsrs, doc(cfg(feature = "steam")))] -impl Totp { - #[cfg(feature = "otpauth")] - /// Will create a new instance of TOTP using the Steam algorithm with given parameters. See [the doc](struct.Totp.html#fields) for reference as to how to choose those values - /// - /// # Description - /// * `secret`: expect a non-encoded value, to pass in base32 string use `Secret::Encoded(String)` - /// - /// # Example +impl Builder { + /// New Builder as created by [Self::new], that is then modified to have the `algorithm`, `digits`, `skew` and `step_duration` options + /// to the values Steam uses. /// - /// ```rust - /// use totp_rs::{Secret, Totp}; - /// let secret = Secret::Encoded("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".into()); - /// let totp = Totp::new_steam(secret.to_bytes().unwrap(), "username".into()); - /// ``` - pub fn new_steam(secret: Vec, account_name: String) -> Totp { - Self::new_unchecked( - Algorithm::Steam, - 5, - 1, - 30, - secret, - Some("Steam".into()), - account_name, - ) - } + /// If `otpauth` is enabled, will set `issuer` to `Some("Steam")`. + #[cfg(feature = "steam")] + #[cfg_attr(docsrs, doc(cfg(feature = "steam")))] + pub fn new_steam() -> Self { + let mut new = Self::new(); - #[cfg(not(feature = "otpauth"))] - /// Will create a new instance of TOTP using the Steam algorithm with given parameters. See [the doc](struct.Totp.html#fields) for reference as to how to choose those values - /// - /// # Description - /// * `secret`: expect a non-encoded value, to pass in base32 string use `Secret::Encoded(String)` - /// - /// # Example - /// - /// ```rust - /// use totp_rs::{Secret, Totp}; - /// let secret = Secret::Encoded("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".to_string()); - /// let totp = Totp::new_steam(secret.to_bytes().unwrap()); - /// ``` - pub fn new_steam(secret: Vec) -> Totp { - Self::new_unchecked(Algorithm::Steam, 5, 1, 30, secret) + new.algorithm = Algorithm::Steam; + new.digits = 5; + new.skew = 1; + new.step_duration = 30; + + #[cfg(feature = "otpauth")] + { + new.issuer = Some("Steam"); + } + + new } } diff --git a/src/lib.rs b/src/lib.rs index 4cb37ad..3816b49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,7 @@ //! with_issuer(Some("Github".to_string())). //! build(). //! unwrap(); -//! +//! //! let token = totp.generate_current().unwrap(); //! println!("{}", token); //! # } @@ -34,7 +34,7 @@ //! let totp: Totp = Builder::new(). //! build(). //! unwrap(); -//! +//! //! let token = totp.generate_current().unwrap(); //! println!("{}", token); //! @@ -54,7 +54,7 @@ //! with_issuer(Some("Github".to_string())). //! build(). //! unwrap(); -//! +//! //! let url = totp.to_url(); //! println!("{}", url); //! let code = totp.to_qr_base64().unwrap(); @@ -218,11 +218,11 @@ impl Totp { #[cfg(feature = "steam")] Algorithm::Steam => (0..self.digits) .map(|_| { - let c = STEAM_CHARS + let c = algorithm::STEAM_CHARS .chars() - .nth(result as usize % STEAM_CHARS.len()) + .nth(result as usize % algorithm::STEAM_CHARS.len()) .unwrap(); - result /= STEAM_CHARS.len() as u32; + result /= algorithm::STEAM_CHARS.len() as u32; c }) .collect(), @@ -370,8 +370,8 @@ mod tests { #[cfg(not(feature = "otpauth"))] fn generates_token_sha256() { let totp = Builder::new() + .with_algorithm(Algorithm::SHA256) .with_step_duration(1) - .with_skew(1) .with_secret("TestSecretSuperSecret".into()) .build_noncompliant(); assert_eq!(totp.generate(1000).as_str(), "076417"); @@ -381,8 +381,8 @@ mod tests { #[cfg(not(feature = "otpauth"))] fn generates_token_sha512() { let totp = Builder::new() + .with_algorithm(Algorithm::SHA512) .with_step_duration(1) - .with_skew(1) .with_secret("TestSecretSuperSecret".into()) .build_noncompliant(); assert_eq!(totp.generate(1000).as_str(), "473536"); diff --git a/src/secret.rs b/src/secret.rs index dfdb58f..730779b 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -5,20 +5,17 @@ //! - Create a TOTP from a "raw" secret //! ``` //! # #[cfg(not(feature = "otpauth"))] { -//! use totp_rs::{Secret, Totp, Algorithm}; +//! use totp_rs::{Algorithm, Builder, Secret}; //! //! let secret = [ //! 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x73, 0x65, //! 0x63, 0x72, 0x65, 0x74, 0x2d, 0x31, 0x32, 0x33, //! ]; //! let secret_raw = Secret::Raw(secret.to_vec()); -//! let totp_raw = Totp::new( -//! Algorithm::SHA1, -//! 6, -//! 1, -//! 30, -//! secret_raw.to_bytes().unwrap(), -//! ).unwrap(); +//! let totp_raw = Builder::new() +//! .with_secret(secret_raw.to_bytes().unwrap()) +//! .build() +//! .unwrap(); //! //! println!("code from raw secret:\t{}", totp_raw.generate_current().unwrap()); //! # } @@ -27,16 +24,13 @@ //! - Create a TOTP from a base32 encoded secret //! ``` //! # #[cfg(not(feature = "otpauth"))] { -//! use totp_rs::{Secret, Totp, Algorithm}; +//! use totp_rs::{Algorithm, Builder, Secret}; //! //! let secret_b32 = Secret::Encoded(String::from("OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG")); -//! let totp_b32 = Totp::new( -//! Algorithm::SHA1, -//! 6, -//! 1, -//! 30, -//! secret_b32.to_bytes().unwrap(), -//! ).unwrap(); +//! let totp_b32 = Builder::new() +//! .with_secret(secret_b32.to_bytes().unwrap()) +//! .build() +//! .unwrap(); //! //! println!("code from base32:\t{}", totp_b32.generate_current().unwrap()); //! # } From 3cd21bfb0f64c4149cd6122acfe5f9fc3d3dd7b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9o=20Rebert?= Date: Tue, 10 Mar 2026 17:57:58 +0100 Subject: [PATCH 6/9] Fix tests with --all-features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cléo Rebert --- examples/gen_secret.rs | 16 +++++++++++++--- examples/steam.rs | 4 ++-- src/algorithm.rs | 35 +++++++++++++++++++++++++++++++++++ src/builder.rs | 24 ++++++++++++++++-------- src/custom_providers.rs | 13 ++++++++----- src/lib.rs | 2 +- src/url.rs | 40 ++++++++++++++++++++-------------------- 7 files changed, 95 insertions(+), 39 deletions(-) diff --git a/examples/gen_secret.rs b/examples/gen_secret.rs index 331553b..21b3649 100644 --- a/examples/gen_secret.rs +++ b/examples/gen_secret.rs @@ -1,8 +1,18 @@ -#![cfg(not(feature = "otpauth"))] -use totp_rs::{Algorithm, Builder, Secret, Totp}; +use totp_rs::{Builder, Totp}; +#[cfg(not(feature = "otpauth"))] fn main() { - let totp = Builder::new().unwrap(); + let totp: Totp = Builder::new().build().unwrap(); + + println!("code: {}", totp.generate_current().unwrap()) +} + +#[cfg(feature = "otpauth")] +fn main() { + let totp: Totp = Builder::new() + .with_account_name("Constantoine".to_string()) + .build() + .unwrap(); println!("code: {}", totp.generate_current().unwrap()) } diff --git a/examples/steam.rs b/examples/steam.rs index a47b6c7..16bba26 100644 --- a/examples/steam.rs +++ b/examples/steam.rs @@ -9,11 +9,11 @@ fn main() { "OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG", ) .unwrap(); - let secret_b32 = Secret::Encoded(s); + let secret_b32 = Secret::Raw(secret); let totp_b32 = Builder::new_steam() .with_secret(secret_b32.to_bytes().unwrap()) - .with_account_name("user-account") + .with_account_name("user-account".to_string()) .build() .unwrap(); diff --git a/src/algorithm.rs b/src/algorithm.rs index 412f343..3f8ff95 100644 --- a/src/algorithm.rs +++ b/src/algorithm.rs @@ -3,7 +3,12 @@ type HmacSha1 = hmac::Hmac; type HmacSha256 = hmac::Hmac; type HmacSha512 = hmac::Hmac; +#[cfg(feature = "serde_support")] +use serde::{Deserialize, Serialize}; + +use std::error::Error; use std::fmt; +use std::str::FromStr; /// Alphabet for Steam tokens. #[cfg(feature = "steam")] @@ -12,6 +17,7 @@ pub(super) const STEAM_CHARS: &str = "23456789BCDFGHJKMNPQRTVWXY"; /// Algorithm enum holds the three standards algorithms for TOTP as per the [reference implementation](https://tools.ietf.org/html/rfc6238#appendix-A) #[derive(Debug, Copy, Clone, Eq, PartialEq)] #[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde_support", serde(try_from = "String", into = "String"))] pub enum Algorithm { /// HMAC-SHA1 is the default algorithm of most TOTP implementations. /// Some will outright silently ignore the algorithm parameter to force using SHA1, leading to confusion. @@ -46,6 +52,35 @@ impl fmt::Display for Algorithm { } } +impl From for String { + fn from(value: Algorithm) -> Self { + value.to_string() + } +} + +impl TryFrom for Algorithm { + type Error = Box; + + fn try_from(value: String) -> Result { + Self::from_str(&value) + } +} + +impl FromStr for Algorithm { + type Err = Box; + + fn from_str(s: &str) -> Result { + match s { + "SHA1" => Ok(Self::SHA1), + "SHA256" => Ok(Self::SHA256), + "SHA512" => Ok(Self::SHA512), + #[cfg(feature = "steam")] + "STEAM" => Ok(Self::Steam), + _ => Err(From::from(format!("Unknown feature: {}", s))), + } + } +} + impl Algorithm { fn hash(mut digest: D, data: &[u8]) -> Vec where diff --git a/src/builder.rs b/src/builder.rs index d012402..7985d71 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -5,6 +5,7 @@ use crate::{Algorithm, Totp}; /// Because it contains the sensitive data of the HMAC secret, treat it accordingly. #[cfg_attr(feature = "zeroize", derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop))] pub struct Builder { + #[cfg_attr(feature = "zeroize", zeroize(skip))] pub(super) algorithm: Algorithm, pub(super) digits: u32, pub(super) secret: Option>, @@ -149,6 +150,14 @@ impl Builder { pub fn build(self) -> Result { let secret = self.secret.as_ref().ok_or(TotpError::SecretNotSet)?; + #[cfg(feature = "steam")] + { + if self.algorithm != Algorithm::Steam { + crate::rfc::assert_digits(self.digits)?; + } + } + + #[cfg(not(feature = "steam"))] crate::rfc::assert_digits(self.digits)?; crate::rfc::assert_secret_length(secret)?; @@ -178,18 +187,18 @@ impl Builder { /// with_digits(10). // Not RFC-compliant. /// build_noncompliant(); /// ``` - pub fn build_noncompliant(self) -> Totp { + pub fn build_noncompliant(mut self) -> Totp { Totp { algorithm: self.algorithm, digits: self.digits, skew: self.skew, step: self.step_duration, - secret: self.secret.unwrap_or_default(), + secret: std::mem::take(&mut self.secret).unwrap_or_default(), #[cfg(feature = "otpauth")] - issuer: self.issuer, + issuer: std::mem::take(&mut self.issuer), #[cfg(feature = "otpauth")] - account_name: self.account_name, + account_name: std::mem::take(&mut self.account_name), } } } @@ -201,7 +210,6 @@ mod tests { const GOOD_SECRET: &str = "01234567890123456789"; - #[cfg(not(feature = "gen_secret"))] const SHORT_SECRET: &str = "tooshort"; // === Defaults === @@ -227,8 +235,8 @@ mod tests { #[cfg(feature = "gen_secret")] fn defaults_secret_is_generated_with_gen_secret() { let builder = Builder::new(); - assert!(builder.secret.is_some()); - assert_eq!(builder.secret.unwrap().len(), 20); + assert!(builder.secret.as_ref().is_some()); + assert_eq!(builder.secret.as_ref().unwrap().len(), 20); } #[test] @@ -256,7 +264,7 @@ mod tests { #[test] fn with_secret() { let builder = Builder::new().with_secret(GOOD_SECRET.into()); - assert_eq!(builder.secret.unwrap(), GOOD_SECRET.as_bytes()); + assert_eq!(builder.secret.as_ref().unwrap(), GOOD_SECRET.as_bytes()); } #[test] diff --git a/src/custom_providers.rs b/src/custom_providers.rs index 93d171b..4173e89 100644 --- a/src/custom_providers.rs +++ b/src/custom_providers.rs @@ -20,22 +20,25 @@ impl Builder { #[cfg(feature = "otpauth")] { - new.issuer = Some("Steam"); + new.issuer = Some("Steam".to_string()); } new } } -#[cfg(all(test, feature = "steam"))] +#[cfg(all(test, feature = "steam", feature = "otpauth"))] mod test { - #[cfg(feature = "otpauth")] - use super::*; + use crate::Builder; #[test] #[cfg(feature = "otpauth")] fn to_url_steam() { - let totp = Totp::new_steam("TestSecretSuperSecret".into(), "constantoine".into()); + let totp = Builder::new_steam() + .with_secret("TestSecretSuperSecret".into()) + .with_account_name("constantoine".into()) + .build() + .unwrap(); let url = totp.to_url(); assert_eq!(url.as_str(), "otpauth://steam/Steam:constantoine?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=5&algorithm=SHA1&issuer=Steam"); } diff --git a/src/lib.rs b/src/lib.rs index 3816b49..2c7c6c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,7 +44,7 @@ //! //! ```rust //! # #[cfg(feature = "qr")] { -//! use totp_rs::{Algorithm, Totp}; +//! use totp_rs::{Algorithm, Builder, Totp}; //! //! let secret: Vec = vec![0; 20]; // You want an actual 20bytes of randomness here. //! diff --git a/src/url.rs b/src/url.rs index 0707d72..38b79aa 100644 --- a/src/url.rs +++ b/src/url.rs @@ -21,7 +21,7 @@ impl crate::Totp { /// It returns a builder with defaults values from [Builder::new] + info from the URL. /// Notable exception: A password will not be supplied automatically if `gen_secret` is enabled. fn parts_from_url>(url: S) -> Result { - let mut builder = Builder::new().with_secret(Vec::new()); + let mut builder: Builder; let url = Url::parse(url.as_ref()).map_err(TotpError::UrlParse)?; if url.scheme() != "otpauth" { @@ -30,11 +30,9 @@ impl crate::Totp { }); } match url.host() { - Some(Host::Domain("totp")) => {} + Some(Host::Domain("totp")) => builder = Builder::new(), #[cfg(feature = "steam")] - Some(Host::Domain("steam")) => { - builder = builder.with_algorithm(Algorithm::Steam); - } + Some(Host::Domain("steam")) => builder = Builder::new_steam(), _ => { return Err(TotpError::InvalidHost { host: url.host().unwrap().to_string(), @@ -42,6 +40,8 @@ impl crate::Totp { } } + builder.secret = None; + let path = url.path().trim_start_matches('/'); let path = urlencoding::decode(path) .map_err(|_| TotpError::AccountNameDecode { @@ -71,14 +71,13 @@ impl crate::Totp { for (key, value) in url.query_pairs() { match key.as_ref() { #[cfg(feature = "steam")] - // Do not change used algorithm if this is Steam - "algorithm" if algorithm == Algorithm::Steam => {} - #[cfg(not(feature = "steam"))] "algorithm" => { - let algorithm = match value.as_ref() { - "SHA1" => Algorithm::SHA1, - "SHA256" => Algorithm::SHA256, - "SHA512" => Algorithm::SHA512, + let algorithm = match value.clone().to_lowercase().as_ref() { + "sha1" => Algorithm::SHA1, + "sha256" => Algorithm::SHA256, + "sha512" => Algorithm::SHA512, + #[cfg(feature = "steam")] + "steam" => Algorithm::Steam, _ => { return Err(TotpError::InvalidAlgorithm { algorithm: value.to_string(), @@ -116,13 +115,14 @@ impl crate::Totp { builder = builder.with_secret(secret); } - #[cfg(feature = "steam")] - "issuer" if value.to_lowercase() == "steam" => { - builder = builder.with_algorithm(Algorithm::Steam); - } - #[cfg(not(feature = "steam"))] "issuer" => { let param_issuer: String = value.into(); + + #[cfg(feature = "steam")] + if param_issuer.eq_ignore_ascii_case("steam") { + builder = builder.with_algorithm(Algorithm::Steam); + } + if issuer.as_ref().is_some() && param_issuer.as_str() != issuer.as_ref().unwrap() { @@ -140,11 +140,11 @@ impl crate::Totp { } #[cfg(feature = "steam")] - if algorithm == Algorithm::Steam { + if builder.algorithm == Algorithm::Steam { builder = builder .with_algorithm(Algorithm::Steam) .with_digits(5) - .with_issuer(Some(value.into())); + .with_issuer(Some("Steam".to_string())); } Ok(builder) @@ -193,7 +193,7 @@ mod tests { const GOOD_ACCOUNT: &str = "constantoine@github.com"; #[test] - #[cfg(feature = "gen_secret")] + #[cfg(all(feature = "gen_secret", not(feature = "otpauth")))] fn default_values() { let totp = Totp::default(); assert_eq!(totp.algorithm, Algorithm::SHA1); From cd1dc4fec35c111c238bd6b42592d5e5216506ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9o=20Rebert?= Date: Tue, 10 Mar 2026 18:03:59 +0100 Subject: [PATCH 7/9] Fix tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cléo Rebert --- src/lib.rs | 5 ++--- src/secret.rs | 26 ++++++++++---------------- src/url.rs | 1 - 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 2c7c6c6..5f4d7d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,7 +29,7 @@ //! //! ```rust //! # #[cfg(all(feature = "gen_secret", not(feature = "otpauth")))] { -//! use totp_rs::Builder; +//! use totp_rs::{Builder, Totp}; //! //! let totp: Totp = Builder::new(). //! build(). @@ -334,9 +334,8 @@ mod tests { #[cfg(feature = "gen_secret")] fn default_values() { let totp = Totp::default(); - let totp_from_builder = Builder::new().build().unwrap(); - assert_eq!(totp, totp_from_builder); + assert_eq!(totp.secret.len(), 20); } #[test] diff --git a/src/secret.rs b/src/secret.rs index 730779b..c4bee57 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -39,16 +39,13 @@ //! - Create a TOTP from a Generated Secret //! ``` //! # #[cfg(all(feature = "gen_secret", not(feature = "otpauth")))] { -//! use totp_rs::{Secret, Totp, Algorithm}; +//! use totp_rs::{Algorithm, Builder, Totp, Secret}; //! //! let secret_b32 = Secret::default(); -//! let totp_b32 = Totp::new( -//! Algorithm::SHA1, -//! 6, -//! 1, -//! 30, -//! secret_b32.to_bytes().unwrap(), -//! ).unwrap(); +//! let totp_b32 = Builder::new() +//! .with_secret(secret_b32.to_bytes().unwrap()) +//! .build() +//! .unwrap(); //! //! println!("code from base32:\t{}", totp_b32.generate_current().unwrap()); //! # } @@ -56,16 +53,13 @@ //! - Create a TOTP from a Generated Secret 2 //! ``` //! # #[cfg(all(feature = "gen_secret", not(feature = "otpauth")))] { -//! use totp_rs::{Secret, Totp, Algorithm}; +//! use totp_rs::{Algorithm, Builder, Totp, Secret }; //! //! let secret_b32 = Secret::generate_secret(); -//! let totp_b32 = Totp::new( -//! Algorithm::SHA1, -//! 6, -//! 1, -//! 30, -//! secret_b32.to_bytes().unwrap(), -//! ).unwrap(); +//! let totp_b32: Totp = Builder::new() +//! .with_secret(secret_b32.to_bytes().unwrap()) +//! .build() +//! .unwrap(); //! //! println!("code from base32:\t{}", totp_b32.generate_current().unwrap()); //! # } diff --git a/src/url.rs b/src/url.rs index 38b79aa..b57a35c 100644 --- a/src/url.rs +++ b/src/url.rs @@ -70,7 +70,6 @@ impl crate::Totp { for (key, value) in url.query_pairs() { match key.as_ref() { - #[cfg(feature = "steam")] "algorithm" => { let algorithm = match value.clone().to_lowercase().as_ref() { "sha1" => Algorithm::SHA1, From d857f0e2aa5fb310d58ae0861f1ca0e81fc07143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9o=20Rebert?= Date: Wed, 11 Mar 2026 14:17:46 +0100 Subject: [PATCH 8/9] Backport #82 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cléo Rebert --- Cargo.toml | 6 +++--- src/builder.rs | 30 ++++++++++++++++++------------ src/lib.rs | 4 ++-- src/secret.rs | 7 ++++++- src/url.rs | 10 +++++----- 5 files changed, 34 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3965b09..c5b5000 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,10 +18,10 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = [] +gen_secret = ["rand"] otpauth = ["url", "urlencoding"] qr = ["dep:qrcodegen-image", "otpauth"] serde_support = ["serde"] -gen_secret = ["rand"] steam = [] [dependencies] @@ -33,8 +33,8 @@ base32 = "0.5" urlencoding = { version = "2.1", optional = true} url = { version = "2.4", optional = true } constant_time_eq = "0.3" -rand = { version = "0.9", features = ["thread_rng"], optional = true, default-features = false } -zeroize = { version = "1.6", features = ["alloc", "derive"], optional = true } +rand = { version = "0.10", features = ["thread_rng"], optional = true, default-features = false } +zeroize = { version = "1.8", features = ["alloc", "derive"], optional = true } qrcodegen-image = { version = "1.4", features = ["base64"], optional = true } [[example]] diff --git a/src/builder.rs b/src/builder.rs index 7985d71..11746ac 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1,5 +1,5 @@ use crate::error::TotpError; -use crate::{Algorithm, Totp}; +use crate::{secret, Algorithm, Totp}; /// Builder used to build a [Totp] with sane defaults. /// Because it contains the sensitive data of the HMAC secret, treat it accordingly. @@ -8,7 +8,7 @@ pub struct Builder { #[cfg_attr(feature = "zeroize", zeroize(skip))] pub(super) algorithm: Algorithm, pub(super) digits: u32, - pub(super) secret: Option>, + pub(super) secret: Option, pub(super) skew: u32, pub(super) step_duration: u64, @@ -25,23 +25,24 @@ impl Builder { /// After build, use [Totp::to_secret_binary] or [Totp::to_secret_base32] to retrieve the newly generated secret. pub fn new() -> Self { #[cfg(feature = "gen_secret")] - let secret: Option> = { - use rand::Rng; + let mut secret = { + use rand::prelude::*; let mut rng = rand::rng(); - let mut secret: Vec = vec![0; 20]; + let mut secret: secret::InnerSecret = vec![0; 20].into(); rng.fill(&mut secret[..]); + #[cfg(feature = "gen_secret")] Some(secret) }; #[cfg(not(feature = "gen_secret"))] - let secret = None; + let mut secret = None; Builder { algorithm: Algorithm::SHA1, digits: 6, - secret: secret, + secret: std::mem::take(&mut secret), skew: 1, step_duration: 30, #[cfg(feature = "otpauth")] @@ -75,7 +76,7 @@ impl Builder { /// /// If feature `gen_secret` is not enabled, then not calling this method will result in [Self::build] to fail. pub fn with_secret(mut self, secret: Vec) -> Self { - self.secret = Some(secret); + self.secret = Some(secret.into()); self } @@ -206,10 +207,10 @@ impl Builder { #[cfg(test)] mod tests { use crate::error::TotpError; + use crate::secret; use crate::{Algorithm, Builder}; const GOOD_SECRET: &str = "01234567890123456789"; - const SHORT_SECRET: &str = "tooshort"; // === Defaults === @@ -264,7 +265,12 @@ mod tests { #[test] fn with_secret() { let builder = Builder::new().with_secret(GOOD_SECRET.into()); - assert_eq!(builder.secret.as_ref().unwrap(), GOOD_SECRET.as_bytes()); + let to_compare: secret::InnerSecret = GOOD_SECRET.as_bytes().to_vec().into(); + + assert_eq!( + std::mem::take(&mut builder.secret.clone().unwrap()), + to_compare + ); } #[test] @@ -305,7 +311,7 @@ mod tests { assert_eq!(totp.digits, 6); assert_eq!(totp.skew, 1); assert_eq!(totp.step, 30); - assert_eq!(totp.secret, GOOD_SECRET.as_bytes()); + assert_eq!(totp.to_secret_binary(), GOOD_SECRET.as_bytes()); } #[test] @@ -453,7 +459,7 @@ mod tests { let totp = Builder::new() .with_secret(SHORT_SECRET.into()) .build_noncompliant(); - assert_eq!(totp.secret, SHORT_SECRET.as_bytes()); + assert_eq!(totp.to_secret_binary(), SHORT_SECRET.as_bytes()); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 5f4d7d5..fac60fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -114,7 +114,7 @@ pub struct Totp { /// As per [rfc-4226](https://tools.ietf.org/html/rfc4226#section-4) the secret should come from a strong source, most likely a CSPRNG. It should be at least 128 bits, but 160 are recommended /// /// non-encoded value - pub(crate) secret: Vec, + pub(crate) secret: secret::InnerSecret, #[cfg(feature = "otpauth")] #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))] /// The "Github" part of "Github:constantoine@github.com". Must not contain a colon `:` @@ -277,7 +277,7 @@ impl Totp { /// Will return a clone of the secret as raw bytes. pub fn to_secret_binary(&self) -> Vec { - self.secret.clone() + std::mem::take(&mut self.secret.clone()) } /// Will return the base32 representation of the secret, which might be useful when users want to manually add the secret to their authenticator diff --git a/src/secret.rs b/src/secret.rs index c4bee57..9c26a79 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -76,6 +76,11 @@ pub enum SecretParseError { ParseBase32, } +#[cfg(not(feature = "zeroize"))] +pub(crate) type InnerSecret = Vec; +#[cfg(feature = "zeroize")] +pub(crate) type InnerSecret = zeroize::Zeroizing>; + impl std::error::Error for SecretParseError {} impl std::fmt::Display for SecretParseError { @@ -157,7 +162,7 @@ impl Secret { #[cfg(feature = "gen_secret")] #[cfg_attr(docsrs, doc(cfg(feature = "gen_secret")))] pub fn generate_secret() -> Secret { - use rand::Rng; + use rand::prelude::*; let mut rng = rand::rng(); let mut secret: [u8; 20] = Default::default(); diff --git a/src/url.rs b/src/url.rs index b57a35c..f557b9c 100644 --- a/src/url.rs +++ b/src/url.rs @@ -272,7 +272,7 @@ mod tests { Totp::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ") .unwrap(); assert_eq!( - totp.secret, + totp.to_secret_binary(), base32::decode( base32::Alphabet::Rfc4648 { padding: false }, "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ" @@ -289,7 +289,7 @@ mod tests { fn from_url_query() { let totp = Totp::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256").unwrap(); assert_eq!( - totp.secret, + totp.to_secret_binary(), base32::decode( base32::Alphabet::Rfc4648 { padding: false }, "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ" @@ -306,7 +306,7 @@ mod tests { fn from_url_query_sha512() { let totp = Totp::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA512").unwrap(); assert_eq!( - totp.secret, + totp.to_secret_binary(), base32::decode( base32::Alphabet::Rfc4648 { padding: false }, "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ" @@ -336,7 +336,7 @@ mod tests { fn from_url_unknown_param() { let totp = Totp::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256&foo=bar").unwrap(); assert_eq!( - totp.secret, + totp.to_secret_binary(), base32::decode( base32::Alphabet::Rfc4648 { padding: false }, "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ" @@ -383,7 +383,7 @@ mod tests { fn from_url_query_issuer() { let totp = Totp::from_url("otpauth://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256").unwrap(); assert_eq!( - totp.secret, + totp.to_secret_binary(), base32::decode( base32::Alphabet::Rfc4648 { padding: false }, "KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ" From 82f5f8e4c33745f86f108d7e75ef672597eb08d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9o=20Rebert?= Date: Wed, 11 Mar 2026 15:35:55 +0100 Subject: [PATCH 9/9] MSRV is now 2024/1.88 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cléo Rebert --- Cargo.toml | 10 +++++----- src/builder.rs | 11 ++++++----- src/custom_providers.rs | 5 ++++- src/lib.rs | 27 ++++----------------------- src/rfc.rs | 2 +- src/url.rs | 39 ++++++++++++++++++++++++++++----------- 6 files changed, 48 insertions(+), 46 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c5b5000..abd7c86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,8 @@ name = "totp-rs" version = "5.7.0" authors = ["Cleo Rebert "] -rust-version = "1.66" -edition = "2021" +rust-version = "1.88" +edition = "2024" readme = "README.md" license = "MIT" description = "RFC-compliant TOTP implementation with ease of use as a goal and additionnal QoL features." @@ -21,7 +21,7 @@ default = [] gen_secret = ["rand"] otpauth = ["url", "urlencoding"] qr = ["dep:qrcodegen-image", "otpauth"] -serde_support = ["serde"] +serde_support = ["serde", "zeroize?/serde"] steam = [] [dependencies] @@ -32,10 +32,10 @@ hmac = "0.12" base32 = "0.5" urlencoding = { version = "2.1", optional = true} url = { version = "2.4", optional = true } -constant_time_eq = "0.3" +constant_time_eq = "0.4" rand = { version = "0.10", features = ["thread_rng"], optional = true, default-features = false } +qrcodegen-image = { version = "1.5", features = ["base64"], optional = true } zeroize = { version = "1.8", features = ["alloc", "derive"], optional = true } -qrcodegen-image = { version = "1.4", features = ["base64"], optional = true } [[example]] name = "steam" diff --git a/src/builder.rs b/src/builder.rs index 11746ac..0babc37 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1,5 +1,6 @@ use crate::error::TotpError; -use crate::{secret, Algorithm, Totp}; +use crate::secret::InnerSecret; +use crate::{Algorithm, Totp}; /// Builder used to build a [Totp] with sane defaults. /// Because it contains the sensitive data of the HMAC secret, treat it accordingly. @@ -8,7 +9,7 @@ pub struct Builder { #[cfg_attr(feature = "zeroize", zeroize(skip))] pub(super) algorithm: Algorithm, pub(super) digits: u32, - pub(super) secret: Option, + pub(super) secret: Option, pub(super) skew: u32, pub(super) step_duration: u64, @@ -29,7 +30,7 @@ impl Builder { use rand::prelude::*; let mut rng = rand::rng(); - let mut secret: secret::InnerSecret = vec![0; 20].into(); + let mut secret: InnerSecret = vec![0; 20].into(); rng.fill(&mut secret[..]); #[cfg(feature = "gen_secret")] @@ -207,7 +208,7 @@ impl Builder { #[cfg(test)] mod tests { use crate::error::TotpError; - use crate::secret; + use crate::secret::InnerSecret; use crate::{Algorithm, Builder}; const GOOD_SECRET: &str = "01234567890123456789"; @@ -265,7 +266,7 @@ mod tests { #[test] fn with_secret() { let builder = Builder::new().with_secret(GOOD_SECRET.into()); - let to_compare: secret::InnerSecret = GOOD_SECRET.as_bytes().to_vec().into(); + let to_compare: InnerSecret = GOOD_SECRET.as_bytes().to_vec().into(); assert_eq!( std::mem::take(&mut builder.secret.clone().unwrap()), diff --git a/src/custom_providers.rs b/src/custom_providers.rs index 4173e89..70d10b0 100644 --- a/src/custom_providers.rs +++ b/src/custom_providers.rs @@ -40,6 +40,9 @@ mod test { .build() .unwrap(); let url = totp.to_url(); - assert_eq!(url.as_str(), "otpauth://steam/Steam:constantoine?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=5&algorithm=SHA1&issuer=Steam"); + assert_eq!( + url.as_str(), + "otpauth://steam/Steam:constantoine?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=5&algorithm=SHA1&issuer=Steam" + ); } } diff --git a/src/lib.rs b/src/lib.rs index fac60fa..0da01df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -128,26 +128,6 @@ pub struct Totp { pub(crate) account_name: String, } -impl PartialEq for Totp { - /// Will not check for issuer and account_name equality - /// As they aren't taken in account for token generation/token checking - fn eq(&self, other: &Self) -> bool { - if self.algorithm != other.algorithm { - return false; - } - if self.digits != other.digits { - return false; - } - if self.skew != other.skew { - return false; - } - if self.step != other.step { - return false; - } - constant_time_eq(self.secret.as_ref(), other.secret.as_ref()) - } -} - #[cfg(feature = "otpauth")] impl core::fmt::Display for Totp { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -417,9 +397,10 @@ mod tests { .with_skew(0) .with_secret("TestSecretSuperSecret".into()) .build_noncompliant(); - assert!(totp - .check_current(&totp.generate_current().unwrap()) - .unwrap()); + assert!( + totp.check_current(&totp.generate_current().unwrap()) + .unwrap() + ); assert!(!totp.check_current("bogus").unwrap()); } diff --git a/src/rfc.rs b/src/rfc.rs index ce56553..628d221 100644 --- a/src/rfc.rs +++ b/src/rfc.rs @@ -37,7 +37,7 @@ pub fn assert_account_name_valid(account_name: &String) -> Result<(), TotpError> // Checks that issuer is either unset (not recommended) or doesn't contain `:`. #[cfg(feature = "otpauth")] pub fn assert_issuer_valid(issuer: &Option) -> Result<(), TotpError> { - if let Some(ref issuer) = issuer { + if let Some(issuer) = issuer { if issuer.contains(':') { return Err(TotpError::InvalidIssuer { value: issuer.clone(), diff --git a/src/url.rs b/src/url.rs index f557b9c..92dac79 100644 --- a/src/url.rs +++ b/src/url.rs @@ -80,7 +80,7 @@ impl crate::Totp { _ => { return Err(TotpError::InvalidAlgorithm { algorithm: value.to_string(), - }) + }); } }; @@ -226,7 +226,10 @@ mod tests { .build() .unwrap(); let url = totp.to_url(); - assert_eq!(url.as_str(), "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&issuer=Github"); + assert_eq!( + url.as_str(), + "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&issuer=Github" + ); } #[test] @@ -239,7 +242,10 @@ mod tests { .build() .unwrap(); let url = totp.to_url(); - assert_eq!(url.as_str(), "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA256&issuer=Github"); + assert_eq!( + url.as_str(), + "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA256&issuer=Github" + ); } #[test] @@ -252,17 +258,22 @@ mod tests { .build() .unwrap(); let url = totp.to_url(); - assert_eq!(url.as_str(), "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA512&issuer=Github"); + assert_eq!( + url.as_str(), + "otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA512&issuer=Github" + ); } #[test] fn from_url_err() { assert!(Totp::from_url("otpauth://hotp/123").is_err()); assert!(Totp::from_url("otpauth://totp/GitHub:test").is_err()); - assert!(Totp::from_url( - "otpauth://totp/GitHub:test:?secret=ABC&digits=8&period=60&algorithm=SHA256" - ) - .is_err()); + assert!( + Totp::from_url( + "otpauth://totp/GitHub:test:?secret=ABC&digits=8&period=60&algorithm=SHA256" + ) + .is_err() + ); assert!(Totp::from_url("otpauth://totp/Github:constantoine%40github.com?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").is_err()) } @@ -399,7 +410,9 @@ mod tests { #[test] fn from_url_wrong_scheme() { - let totp = Totp::from_url("http://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256"); + let totp = Totp::from_url( + "http://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256", + ); assert!(totp.is_err()); let err = totp.unwrap_err(); assert!(matches!(err, TotpError::InvalidScheme { .. })); @@ -407,7 +420,9 @@ mod tests { #[test] fn from_url_wrong_algo() { - let totp = Totp::from_url("otpauth://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=MD5"); + let totp = Totp::from_url( + "otpauth://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=MD5", + ); assert!(totp.is_err()); let err = totp.unwrap_err(); assert!(matches!(err, TotpError::InvalidAlgorithm { .. })); @@ -415,7 +430,9 @@ mod tests { #[test] fn from_url_query_different_issuers() { - let totp = Totp::from_url("otpauth://totp/GitHub:test?issuer=Gitlab&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256"); + let totp = Totp::from_url( + "otpauth://totp/GitHub:test?issuer=Gitlab&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256", + ); assert!(totp.is_err()); assert!(matches!( totp.unwrap_err(),