From a5b97d1f3f2111ef24188607ca8ed88f5182d2fa Mon Sep 17 00:00:00 2001 From: timvisee Date: Mon, 2 Jan 2023 21:55:49 +0100 Subject: [PATCH 1/6] Add Steam algorithm behind 'steam' feature --- Cargo.toml | 1 + src/lib.rs | 62 ++++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ddef606..605bebf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ otpauth = ["url", "urlencoding"] qr = ["qrcodegen", "image", "base64", "otpauth"] serde_support = ["serde"] gen_secret = ["rand"] +steam = [] [dependencies] serde = { version = "1.0", features = ["derive"], optional = true } diff --git a/src/lib.rs b/src/lib.rs index 55ba5d5..d7c256a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -75,6 +75,10 @@ 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))] @@ -82,6 +86,8 @@ pub enum Algorithm { SHA1, SHA256, SHA512, + #[cfg(feature = "steam")] + Steam, } impl std::default::Default for Algorithm { @@ -96,6 +102,8 @@ impl fmt::Display for Algorithm { 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"), } } } @@ -114,6 +122,8 @@ impl Algorithm { 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), } } } @@ -384,13 +394,28 @@ impl TOTP { pub fn generate(&self, time: u64) -> String { let result: &[u8] = &self.sign(time); let offset = (result.last().unwrap() & 15) as usize; - let result = + #[allow(unused_mut)] + let mut result = u32::from_be_bytes(result[offset..offset + 4].try_into().unwrap()) & 0x7fff_ffff; - format!( - "{1:00$}", - self.digits, - result % 10_u32.pow(self.digits as u32) - ) + + match self.algorithm { + Algorithm::SHA1 | Algorithm::SHA256 | Algorithm::SHA512 => format!( + "{1:00$}", + self.digits, + result % 10_u32.pow(self.digits as u32) + ), + #[cfg(feature = "steam")] + Algorithm::Steam => (0..self.digits) + .map(|_| { + let c = STEAM_CHARS + .chars() + .nth(result as usize % STEAM_CHARS.len()) + .unwrap(); + result /= STEAM_CHARS.len() as u32; + c + }) + .collect(), + } } /// Returns the timestamp of the first second for the next step @@ -476,14 +501,6 @@ impl TOTP { fn parts_from_url>( url: S, ) -> Result<(Algorithm, usize, u8, u64, Vec, Option, String), TotpUrlError> { - let url = Url::parse(url.as_ref()).map_err(TotpUrlError::Url)?; - if url.scheme() != "otpauth" { - return Err(TotpUrlError::Scheme(url.scheme().to_string())); - } - if url.host() != Some(Host::Domain("totp")) { - return Err(TotpUrlError::Host(url.host().unwrap().to_string())); - } - let mut algorithm = Algorithm::SHA1; let mut digits = 6; let mut step = 30; @@ -491,6 +508,19 @@ impl TOTP { let mut issuer: Option = None; let mut account_name: String; + let url = Url::parse(url.as_ref()).map_err(TotpUrlError::Url)?; + if url.scheme() != "otpauth" { + return Err(TotpUrlError::Scheme(url.scheme().to_string())); + } + match url.host() { + Some(Host::Domain("totp")) => {} + #[cfg(feature = "steam")] + Some(Host::Domain("steam")) => algorithm = Algorithm::Steam, + _ => { + return Err(TotpUrlError::Host(url.host().unwrap().to_string())); + } + } + let path = url.path().trim_start_matches('/'); if path.contains(':') { let parts = path.split_once(':').unwrap(); @@ -510,6 +540,10 @@ impl TOTP { 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, From 3f7f91299fe7dd92a5531b6545070b1e8b3d3dfa Mon Sep 17 00:00:00 2001 From: timvisee Date: Tue, 3 Jan 2023 18:36:35 +0100 Subject: [PATCH 2/6] Generate proper URLs for Steam algorithm --- src/lib.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index d7c256a..fe368f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -598,6 +598,12 @@ impl TOTP { /// Secret will be base 32'd without padding, as per RFC. #[cfg(feature = "otpauth")] pub fn get_url(&self) -> String { + #[allow(unused_mut)] + let mut host = "totp"; + #[cfg(feature = "steam")] + if self.algorithm == Algorithm::Steam { + host = "steam"; + } let account_name: String = urlencoding::encode(self.account_name.as_str()).to_string(); let mut label: String = format!("{}?", account_name); if self.issuer.is_some() { @@ -607,7 +613,8 @@ impl TOTP { } format!( - "otpauth://totp/{}secret={}&digits={}&algorithm={}", + "otpauth://{}/{}secret={}&digits={}&algorithm={}", + host, label, self.get_secret_base32(), self.digits, From c5fd8207fb9a5a5b2e4fd5e7d3e7f49c8a3fd29a Mon Sep 17 00:00:00 2001 From: timvisee Date: Tue, 3 Jan 2023 18:49:47 +0100 Subject: [PATCH 3/6] Add TOTP::new_steam as custom provider --- src/custom_providers.rs | 45 +++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 46 insertions(+) create mode 100644 src/custom_providers.rs diff --git a/src/custom_providers.rs b/src/custom_providers.rs new file mode 100644 index 0000000..7da52e7 --- /dev/null +++ b/src/custom_providers.rs @@ -0,0 +1,45 @@ +#[cfg(feature = "steam")] +use crate::{Algorithm, TOTP}; + +#[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)` + /// + /// ```rust + /// use totp_rs::{Secret, TOTP}; + /// let secret = Secret::Encoded("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".to_string()); + /// let totp = TOTP::new_steam(secret.to_bytes().unwrap(), Some("username".to_string())); + /// ``` + pub fn new_steam(secret: Vec, account_name: Option) -> TOTP { + Self::new_unchecked( + Algorithm::Steam, + 5, + 1, + 30, + secret, + Some("Steam".into()), + account_name + .map(|n| format!("Steam:{}", n)) + .unwrap_or_else(|| "".into()), + ) + } + + #[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)` + /// + /// ```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) + } +} diff --git a/src/lib.rs b/src/lib.rs index fe368f4..82cecca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,6 +47,7 @@ //! # } //! ``` +mod custom_providers; mod rfc; mod secret; mod url_error; From 068b746a79372ba0d9da44c16124890b0389e485 Mon Sep 17 00:00:00 2001 From: timvisee Date: Wed, 4 Jan 2023 16:20:38 +0100 Subject: [PATCH 4/6] Parse otpauth URL with Steam as issuer as Steam TOTP --- src/lib.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 82cecca..3b08937 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -516,7 +516,10 @@ impl TOTP { match url.host() { Some(Host::Domain("totp")) => {} #[cfg(feature = "steam")] - Some(Host::Domain("steam")) => algorithm = Algorithm::Steam, + Some(Host::Domain("steam")) => { + algorithm = Algorithm::Steam; + digits = 5; + } _ => { return Err(TotpUrlError::Host(url.host().unwrap().to_string())); } @@ -570,6 +573,12 @@ impl TOTP { ) .ok_or_else(|| TotpUrlError::Secret(value.to_string()))?; } + #[cfg(feature = "steam")] + "issuer" if value.to_lowercase() == "steam" => { + algorithm = Algorithm::Steam; + digits = 5; + issuer = Some(value.into()); + } "issuer" => { let param_issuer = value .parse::() From 005ae37f70b8be79cc61144bdef1c370bb0f115f Mon Sep 17 00:00:00 2001 From: timvisee Date: Thu, 5 Jan 2023 14:22:02 +0100 Subject: [PATCH 5/6] List steam feature in README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b1393c4..ab8663f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ With optional feature "serde_support", library-defined types `TOTP` and `Algorit With optional feature "gen_secret", a secret will be generated for you to store in database. ### zeroize Securely zero secret information when the TOTP struct is dropped. +### steam +Add support for Steam TOTP tokens. # Examples From 5e61d1543247b37bd03aaacd1bfb0b4dda3bdd81 Mon Sep 17 00:00:00 2001 From: timvisee Date: Thu, 5 Jan 2023 17:14:06 +0100 Subject: [PATCH 6/6] Do not prefix Steam TOTP account name with Steam --- src/custom_providers.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/custom_providers.rs b/src/custom_providers.rs index 7da52e7..8f9487b 100644 --- a/src/custom_providers.rs +++ b/src/custom_providers.rs @@ -14,7 +14,7 @@ impl TOTP { /// let secret = Secret::Encoded("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".to_string()); /// let totp = TOTP::new_steam(secret.to_bytes().unwrap(), Some("username".to_string())); /// ``` - pub fn new_steam(secret: Vec, account_name: Option) -> TOTP { + pub fn new_steam(secret: Vec, account_name: String) -> TOTP { Self::new_unchecked( Algorithm::Steam, 5, @@ -22,9 +22,7 @@ impl TOTP { 30, secret, Some("Steam".into()), - account_name - .map(|n| format!("Steam:{}", n)) - .unwrap_or_else(|| "".into()), + account_name, ) }