diff --git a/Cargo.toml b/Cargo.toml index 43b4dee..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." @@ -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"] +serde_support = ["serde", "zeroize?/serde"] steam = [] [dependencies] @@ -32,7 +32,15 @@ hmac = "0.12" 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 } -qrcodegen-image = { version = "1.4", features = ["base64"], optional = true } +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 } + +[[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..21b3649 100644 --- a/examples/gen_secret.rs +++ b/examples/gen_secret.rs @@ -1,28 +1,18 @@ -#[cfg(all(feature = "gen_secret", feature = "otpauth"))] -use totp_rs::{Algorithm, Secret, Totp}; +use totp_rs::{Builder, Totp}; -#[cfg(all(feature = "gen_secret", feature = "otpauth"))] +#[cfg(not(feature = "otpauth"))] fn main() { - let secret = Secret::generate_secret(); + let totp: Totp = Builder::new().build().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() {} +#[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/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..16bba26 100644 --- a/examples/steam.rs +++ b/examples/steam.rs @@ -1,12 +1,21 @@ -#[cfg(feature = "steam")] -use totp_rs::{Secret, Totp}; +use base32; +use totp_rs::{Builder, Secret, Totp}; -#[cfg(feature = "steam")] #[cfg(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(), "user-account".to_string()); + let secret = base32::decode( + base32::Alphabet::Rfc4648 { padding: false }, + "OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG", + ) + .unwrap(); + let secret_b32 = Secret::Raw(secret); + + let totp_b32 = Builder::new_steam() + .with_secret(secret_b32.to_bytes().unwrap()) + .with_account_name("user-account".to_string()) + .build() + .unwrap(); println!( "base32 {} ; raw {}", @@ -19,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 {}", @@ -36,6 +47,3 @@ fn main() { totp_b32.generate_current().unwrap() ); } - -#[cfg(not(feature = "steam"))] -fn main() {} 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/algorithm.rs b/src/algorithm.rs new file mode 100644 index 0000000..3f8ff95 --- /dev/null +++ b/src/algorithm.rs @@ -0,0 +1,102 @@ +use hmac::Mac; +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")] +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. + 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 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 + 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..0babc37 --- /dev/null +++ b/src/builder.rs @@ -0,0 +1,519 @@ +use crate::error::TotpError; +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. +#[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, + pub(super) skew: u32, + pub(super) step_duration: u64, + + #[cfg(feature = "otpauth")] + pub(super) account_name: String, + #[cfg(feature = "otpauth")] + pub(super) issuer: Option, +} + +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() -> Self { + #[cfg(feature = "gen_secret")] + let mut secret = { + use rand::prelude::*; + + let mut rng = rand::rng(); + let mut secret: InnerSecret = vec![0; 20].into(); + rng.fill(&mut secret[..]); + + #[cfg(feature = "gen_secret")] + Some(secret) + }; + + #[cfg(not(feature = "gen_secret"))] + let mut secret = None; + + Builder { + algorithm: Algorithm::SHA1, + digits: 6, + secret: std::mem::take(&mut secret), + skew: 1, + step_duration: 30, + #[cfg(feature = "otpauth")] + account_name: "".to_string(), + #[cfg(feature = "otpauth")] + 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) -> 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) -> 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) -> Self { + self.secret = Some(secret.into()); + + 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) -> 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) -> 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) -> 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) -> Self { + self.issuer = issuer; + + self + } + + /// Consume the builder into a [Totp]. See [its method's docs](struct.Builder.html#impl-Builder) for reference about each values. + /// + /// # Example + /// + /// ```rust + /// # #[cfg(not(feature = "otpauth"))] { + /// use totp_rs::{Algorithm, Builder, Totp}; + /// + /// 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(); + /// # } + /// ``` + /// + /// # 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` 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)?; + + #[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)?; + + #[cfg(feature = "otpauth")] + { + crate::rfc::assert_issuer_valid(&self.issuer)?; + crate::rfc::assert_account_name_valid(&self.account_name)?; + } + + 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}; + /// + /// 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(); + /// ``` + pub fn build_noncompliant(mut self) -> Totp { + Totp { + algorithm: self.algorithm, + digits: self.digits, + skew: self.skew, + step: self.step_duration, + secret: std::mem::take(&mut self.secret).unwrap_or_default(), + + #[cfg(feature = "otpauth")] + issuer: std::mem::take(&mut self.issuer), + #[cfg(feature = "otpauth")] + account_name: std::mem::take(&mut self.account_name), + } + } +} + +#[cfg(test)] +mod tests { + use crate::error::TotpError; + use crate::secret::InnerSecret; + use crate::{Algorithm, Builder}; + + const GOOD_SECRET: &str = "01234567890123456789"; + 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.as_ref().is_some()); + assert_eq!(builder.secret.as_ref().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()); + let to_compare: InnerSecret = GOOD_SECRET.as_bytes().to_vec().into(); + + assert_eq!( + std::mem::take(&mut builder.secret.clone().unwrap()), + to_compare + ); + } + + #[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.to_secret_binary(), 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.to_secret_binary(), 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/custom_providers.rs b/src/custom_providers.rs index 8eca95b..70d10b0 100644 --- a/src/custom_providers.rs +++ b/src/custom_providers.rs @@ -1,62 +1,48 @@ #[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".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"); + assert_eq!( + url.as_str(), + "otpauth://steam/Steam:constantoine?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=5&algorithm=SHA1&issuer=Steam" + ); } } diff --git a/src/error.rs b/src/error.rs index 6e4087b..d6e2500 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,11 +6,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 +67,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..0da01df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,36 +10,51 @@ //! //! ```rust //! # #[cfg(feature = "otpauth")] { -//! use std::time::SystemTime; -//! use totp_rs::{Algorithm, Totp, Secret}; +//! use totp_rs::{Algorithm, Builder, Totp}; +//! +//! 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 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 token = totp.generate_current().unwrap(); //! println!("{}", token); //! # } //! ``` //! //! ```rust +//! # #[cfg(all(feature = "gen_secret", not(feature = "otpauth")))] { +//! use totp_rs::{Builder, Totp}; +//! +//! let totp: Totp = Builder::new(). +//! build(). +//! unwrap(); +//! +//! let token = totp.generate_current().unwrap(); +//! println!("{}", token); +//! +//! let secret = totp.to_secret_binary(); +//! # } +//! ``` +//! +//! ```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. +//! +//! 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 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 url = totp.to_url(); //! println!("{}", url); //! let code = totp.to_qr_base64().unwrap(); @@ -50,6 +65,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,8 +78,9 @@ mod url; #[cfg(feature = "qr")] 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; @@ -72,74 +90,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,48 +104,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: 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 `:` /// 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, -} - -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()) - } + pub(crate) account_name: String, } #[cfg(feature = "otpauth")] @@ -222,196 +154,26 @@ 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: usize, - skew: u8, - 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: usize, - skew: u8, - 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 + /// 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(), @@ -419,7 +181,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; @@ -430,17 +192,17 @@ 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) .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(), @@ -493,6 +255,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 { + 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 pub fn to_secret_base32(&self) -> String { base32::encode( @@ -505,15 +272,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. @@ -556,78 +314,27 @@ 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) - } - - #[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.secret.len(), 20); } #[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() @@ -641,45 +348,69 @@ 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_algorithm(Algorithm::SHA256) + .with_step_duration(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_algorithm(Algorithm::SHA512) + .with_step_duration(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(); - assert!(totp - .check_current(&totp.generate_current().unwrap()) - .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() + ); assert!(!totp.check_current("bogus").unwrap()); } #[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) ); @@ -688,7 +419,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); @@ -697,7 +431,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 9ab4579..628d221 100644 --- a/src/rfc.rs +++ b/src/rfc.rs @@ -1,186 +1,56 @@ -use crate::Algorithm; use crate::TotpError; -use crate::Totp; - -#[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 }) - } else { - Ok(()) +pub fn assert_digits(digits: u32) -> Result<(), TotpError> { + if !(6..=8).contains(&digits) { + 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: usize, - /// The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 1. - skew: u8, - /// 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: usize, 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: usize) -> 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(issuer) = issuer { + if issuer.contains(':') { + return Err(TotpError::InvalidIssuer { + value: issuer.clone(), + }); + } } + + Ok(()) } #[cfg(test)] mod tests { - use crate::{Rfc6238, TotpError, Totp}; + 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/secret.rs b/src/secret.rs index dfdb58f..9c26a79 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()); //! # } @@ -45,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()); //! # } @@ -62,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()); //! # } @@ -88,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 { @@ -169,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 950fc42..92dac79 100644 --- a/src/url.rs +++ b/src/url.rs @@ -1,4 +1,4 @@ -use crate::{Algorithm, TotpError, Totp}; +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; let url = Url::parse(url.as_ref()).map_err(TotpError::UrlParse)?; if url.scheme() != "otpauth" { @@ -44,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")) => { - algorithm = Algorithm::Steam; - } + Some(Host::Domain("steam")) => builder = Builder::new_steam(), _ => { return Err(TotpError::InvalidHost { host: url.host().unwrap().to_string(), @@ -56,101 +40,113 @@ impl crate::Totp { } } + builder.secret = None; + let path = url.path().trim_start_matches('/'); let path = urlencoding::decode(path) .map_err(|_| TotpError::AccountNameDecode { 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 - } "algorithm" => { - 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(), - }) + }); } - } + }; + + 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)?; - } - #[cfg(feature = "steam")] - "issuer" if value.to_lowercase() == "steam" => { - algorithm = Algorithm::Steam; - digits = 5; - issuer = Some(value.into()); + + builder = builder.with_secret(secret); } "issuer" => { let param_issuer: String = value.into(); - if issuer.is_some() && param_issuer.as_str() != issuer.as_ref().unwrap() { + + #[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() + { 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()); } _ => {} } } #[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 }); + if builder.algorithm == Algorithm::Steam { + builder = builder + .with_algorithm(Algorithm::Steam) + .with_digits(5) + .with_issuer(Some("Steam".to_string())); } - 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,13 +185,14 @@ impl crate::Totp { #[cfg(test)] mod tests { - use crate::{Algorithm, TotpError, Totp}; + 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")] + #[cfg(all(feature = "gen_secret", not(feature = "otpauth")))] fn default_values() { let totp = Totp::default(); assert_eq!(totp.algorithm, Algorithm::SHA1); @@ -204,94 +201,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,84 +218,62 @@ 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"); + 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"); + 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()); + 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()) } @@ -388,7 +283,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" @@ -405,7 +300,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" @@ -422,7 +317,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" @@ -438,16 +333,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()); } @@ -455,7 +347,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" @@ -468,36 +360,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 +378,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"); @@ -525,7 +394,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" @@ -541,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 { .. })); @@ -549,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 { .. })); @@ -557,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(),