From d6d08b1a1e20599e9254f1cfd5956bbf601319ed Mon Sep 17 00:00:00 2001 From: constantoine Date: Fri, 13 May 2022 14:06:25 +0200 Subject: [PATCH 1/4] Start working on v2 Signed-off-by: constantoine --- Cargo.toml | 2 +- src/lib.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 75f4e38..d056daf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ categories = ["authentication", "web-programming"] features = [ "qr", "serde_support", "otpauth" ] [features] -default = [] +default = ["otpauth"] qr = ["qrcodegen", "image", "base64"] serde_support = ["serde"] otpauth = ["url"] diff --git a/src/lib.rs b/src/lib.rs index ff14699..0a388c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -111,7 +111,6 @@ fn system_time() -> Result { Ok(t) } -#[cfg(feature = "otpauth")] #[derive(Debug)] pub enum TotpUrlError { Url(ParseError), @@ -121,6 +120,8 @@ pub enum TotpUrlError { Algorithm, Digits, Step, + Issuer, + Label, } /// TOTP holds informations as to how to generate an auth code and validate it. Its [secret](struct.TOTP.html#structfield.secret) field is sensitive data, treat it accordingly @@ -137,6 +138,12 @@ pub struct TOTP> { pub 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 pub secret: T, + /// The "Github" part of "Github:constantoine". Must not contain a color `:` + /// For example, the name of your service/website. + pub issuer: String, + /// The "alice@google.com" part of "Github:constantoine". Must not contain a color `:` + /// For example, the name of your user's account. + pub label: String } impl > PartialEq for TOTP { @@ -159,14 +166,26 @@ impl > PartialEq for TOTP { impl> TOTP { /// 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 - pub fn new(algorithm: Algorithm, digits: usize, skew: u8, step: u64, secret: T) -> TOTP { - TOTP { + /// + /// # Errors + /// + /// Will return an error in case issuer or label contain the character ':' + pub fn new(algorithm: Algorithm, digits: usize, skew: u8, step: u64, secret: T, issuer: String, label: String) -> Result, TotpUrlError> { + if issuer.contains(':') { + return Err(TotpUrlError::Issuer); + } + if label.contains(':') { + return Err(TotpUrlError::Label); + } + Ok(TOTP { algorithm, digits, skew, step, secret, - } + issuer, + label, + }) } /// Will sign the given timestamp @@ -223,7 +242,6 @@ impl> TOTP { } /// Generate a TOTP from the standard otpauth URL - #[cfg(feature = "otpauth")] pub fn from_url>(url: S) -> Result>, TotpUrlError> { let url = Url::parse(url.as_ref()).map_err(|err| TotpUrlError::Url(err))?; if url.scheme() != "otpauth" { @@ -237,6 +255,10 @@ impl> TOTP { let mut digits = 6; let mut step = 30; let mut secret = Vec::new(); + let issuer: String; + let label: String; + + for (key, value) in url.query_pairs() { match key.as_ref() { @@ -259,10 +281,19 @@ impl> TOTP { base32::decode(base32::Alphabet::RFC4648 { padding: false }, value.as_ref()) .ok_or(TotpUrlError::Secret)?; } + "issuer" => { + issuer = value.parse::().map_err(|_| TotpUrlError::Issuer)?; + } _ => {} } } + if issuer.contains(':') { + return Err(TotpUrlError::Issuer); + } + if label.contains(':') { + return Err(TotpUrlError::Label); + } if secret.is_empty() { return Err(TotpUrlError::Secret); } @@ -271,12 +302,17 @@ impl> TOTP { } /// Will generate a standard URL used to automatically add TOTP auths. Usually used with qr codes - pub fn get_url(&self, label: &str, issuer: &str) -> String { + /// + /// Label and issuer will be URL-encoded if needed be + /// Secret will be base 32'd without padding, as per RFC. + pub fn get_url(&self) -> String { + let label: String = url::form_urlencoded::byte_serialize(self.label.as_bytes()).collect(); + let issuer: String = url::form_urlencoded::byte_serialize(self.issuer.as_bytes()).collect(); format!( "otpauth://totp/{}?secret={}&issuer={}&digits={}&algorithm={}", - label.to_string(), + label, self.get_secret_base32(), - issuer.to_string(), + issuer, self.digits.to_string(), self.algorithm, ) From eba97c0ffac0d6eab80b53a8d6bd33bff1d0778d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9o=20Rebert?= Date: Sun, 15 May 2022 13:41:19 +0200 Subject: [PATCH 2/4] Finish work on v2 Signed-off-by: constantoine --- .gitignore | 1 + Cargo.toml | 11 ++- README.md | 75 ++++++++++---------- src/lib.rs | 196 ++++++++++++++++++++++++++++++++++------------------- 4 files changed, 173 insertions(+), 110 deletions(-) diff --git a/.gitignore b/.gitignore index 315330e..f427329 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.idea /target Cargo.lock .vscode/settings.json diff --git a/Cargo.toml b/Cargo.toml index d056daf..e150eee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "totp-rs" -version = "1.4.0" +version = "2.0.0" authors = ["Cleo Rebert "] edition = "2021" readme = "README.md" @@ -12,13 +12,12 @@ keywords = ["authentication", "2fa", "totp", "hmac", "otp"] categories = ["authentication", "web-programming"] [package.metadata.docs.rs] -features = [ "qr", "serde_support", "otpauth" ] +features = [ "qr", "serde_support" ] [features] -default = ["otpauth"] +default = [] qr = ["qrcodegen", "image", "base64"] serde_support = ["serde"] -otpauth = ["url"] [dependencies] serde = { version = "1.0", features = ["derive"], optional = true } @@ -26,8 +25,8 @@ sha2 = "~0.10.2" sha-1 = "~0.10.0" hmac = "~0.12.1" base32 = "~0.4" +url = "2.2.2" constant_time_eq = "~0.2.1" qrcodegen = { version = "~1.8", optional = true } image = { version = "~0.24.2", features = ["png"], optional = true, default-features = false} -base64 = { version = "~0.13", optional = true } -url = { version = "2.2.2", optional = true } \ No newline at end of file +base64 = { version = "~0.13", optional = true } \ No newline at end of file diff --git a/README.md b/README.md index 91abd4a..4f6ee51 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # totp-rs ![Build Status](https://github.com/constantoine/totp-rs/workflows/Rust/badge.svg) [![docs](https://docs.rs/totp-rs/badge.svg)](https://docs.rs/totp-rs) [![](https://img.shields.io/crates/v/totp-rs.svg)](https://crates.io/crates/totp-rs) [![codecov](https://codecov.io/gh/constantoine/totp-rs/branch/master/graph/badge.svg?token=Q50RAIFVWZ)](https://codecov.io/gh/constantoine/totp-rs) [![cargo-audit](https://github.com/constantoine/totp-rs/actions/workflows/security.yml/badge.svg)](https://github.com/constantoine/totp-rs/actions/workflows/security.yml) -This library permits the creation of 2FA authentification tokens per TOTP, the verification of said tokens, with configurable time skew, validity time of each token, algorithm and number of digits! Default features are kept as lightweight as possible to ensure small binaries and short compilation time +This library permits the creation of 2FA authentification tokens per TOTP, the verification of said tokens, with configurable time skew, validity time of each token, algorithm and number of digits! Default features are kept as lightweight as possible to ensure small binaries and short compilation time. + +It now supports parsing [otpauth URLs](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) into a totp object, with sane default values. Be aware that some authenticator apps will accept the `SHA256` and `SHA512` algorithms but silently fallback to `SHA1` which will make the `check()` function fail due to mismatched algorithms. @@ -12,33 +14,33 @@ With optional feature "qr", you can use it to generate a base64 png qrcode ### serde_support With optional feature "serde_support", library-defined types will be Deserialize-able and Serialize-able -### otpauth - -With optional feature "otpauth", Support to parse the TOTP parameter from `otpauth` URL - ## How to use --- Add it to your `Cargo.toml`: ```toml [dependencies] -totp-rs = "~1.4" +totp-rs = "^2.0" ``` You can then do something like: ```Rust use std::time::SystemTime; use totp_rs::{Algorithm, TOTP}; -let totp = TOTP::new( - Algorithm::SHA1, - 6, - 1, - 30, - "supersecret", -); -let url = totp.get_url("user@example.com", "my-org.com"); -println!("{}", url); -let token = totp.generate_current().unwrap(); -println!("{}", token); +fn main() { + let totp = TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + "supersecret", + Some("Github".to_string()), + "constantoine@github.com".to_string(), + ).unwrap(); + let url = totp.get_url(); + println!("{}", url); + let token = totp.generate_current().unwrap(); + println!("{}", token); +} ``` ### With qrcode generation @@ -46,29 +48,33 @@ println!("{}", token); Add it to your `Cargo.toml`: ```toml [dependencies.totp-rs] -version = "~1.4" +version = "^2.0" features = ["qr"] ``` You can then do something like: ```Rust use totp_rs::{Algorithm, TOTP}; -let totp = TOTP::new( - Algorithm::SHA1, - 6, - 1, - 30, - "supersecret", -); -let code = totp.get_qr("user@example.com", "my-org.com")?; -println!("{}", code); +fn main() { + let totp = TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + "supersecret", + Some("Github".to_string()), + "constantoine@github.com".to_string(), + ).unwrap(); + let code = totp.get_qr("user@example.com", "my-org.com")?; + println!("{}", code); +} ``` ### With serde support Add it to your `Cargo.toml`: ```toml [dependencies.totp-rs] -version = "~1.4" +version = "^2.0" features = ["serde_support"] ``` @@ -76,15 +82,16 @@ features = ["serde_support"] Add it to your `Cargo.toml`: ```toml -[dependencies.totp-rs] -version = "~1.4" -features = ["otpauth"] +[dependencies] +totp-rs = "^2.0" ``` You can then do something like: ```Rust use totp_rs::TOTP; -let otpauth = "otpauth://totp/GitHub:test?secret=ABC&issuer=GitHub"; -let totp = TOTP::from_url(otpauth).unwrap(); -println!("{}", totp.generate_current().unwrap()); +fn main() { + let otpauth = "otpauth://totp/GitHub:constantoine@github.com?secret=ABC&issuer=GitHub"; + let totp = TOTP::from_url(otpauth).unwrap(); + println!("{}", totp.generate_current().unwrap()); +} ``` \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 0a388c1..4dd8d5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,8 +18,10 @@ //! 1, //! 30, //! "supersecret", -//! ); -//! let url = totp.get_url("user@example.com", "my-org.com"); +//! Some("Github".to_string()), +//! "constantoine@github.com".to_string(), +//! ).unwrap(); +//! let url = totp.get_url(); //! println!("{}", url); //! let token = totp.generate_current().unwrap(); //! println!("{}", token); @@ -35,8 +37,10 @@ //! 1, //! 30, //! "supersecret", -//! ); -//! let code = totp.get_qr("user@example.com", "my-org.com").unwrap(); +//! Some("Github".to_string()), +//! "constantoine@github.com".to_string(), +//! ).unwrap(); +//! let code = totp.get_qr().unwrap(); //! println!("{}", code); //! # } //! ``` @@ -51,7 +55,6 @@ use core::fmt; #[cfg(feature = "qr")] use {base64, image::Luma, qrcodegen}; -#[cfg(feature = "otpauth")] use url::{Host, ParseError, Url}; use hmac::Mac; @@ -72,15 +75,15 @@ pub enum Algorithm { impl fmt::Display for Algorithm { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { + return match *self { Algorithm::SHA1 => { - return f.write_str("SHA1"); + f.write_str("SHA1") } Algorithm::SHA256 => { - return f.write_str("SHA256"); + f.write_str("SHA256") } Algorithm::SHA512 => { - return f.write_str("SHA512"); + f.write_str("SHA512") } } } @@ -89,7 +92,7 @@ impl fmt::Display for Algorithm { impl Algorithm { fn hash(mut digest: D, data: &[u8]) -> Vec where - D: hmac::Mac, + D: Mac, { digest.update(data); digest.finalize().into_bytes().to_vec() @@ -111,7 +114,7 @@ fn system_time() -> Result { Ok(t) } -#[derive(Debug)] +#[derive(Debug, Eq, PartialEq)] pub enum TotpUrlError { Url(ParseError), Scheme, @@ -121,7 +124,7 @@ pub enum TotpUrlError { Digits, Step, Issuer, - Label, + AccountName, } /// TOTP holds informations as to how to generate an auth code and validate it. Its [secret](struct.TOTP.html#structfield.secret) field is sensitive data, treat it accordingly @@ -138,15 +141,18 @@ pub struct TOTP> { pub 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 pub secret: T, - /// The "Github" part of "Github:constantoine". Must not contain a color `:` + /// The "Github" part of "Github:constantoine@github.com". Must not contain a colon `:` /// For example, the name of your service/website. - pub issuer: String, - /// The "alice@google.com" part of "Github:constantoine". Must not contain a color `:` + /// Not mandatory, but strongly recommended! + pub issuer: Option, + /// 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 label: String + 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; @@ -170,12 +176,12 @@ impl> TOTP { /// # Errors /// /// Will return an error in case issuer or label contain the character ':' - pub fn new(algorithm: Algorithm, digits: usize, skew: u8, step: u64, secret: T, issuer: String, label: String) -> Result, TotpUrlError> { - if issuer.contains(':') { + pub fn new(algorithm: Algorithm, digits: usize, skew: u8, step: u64, secret: T, issuer: Option, account_name: String) -> Result, TotpUrlError> { + if issuer.is_some() && issuer.as_ref().unwrap().contains(':') { return Err(TotpUrlError::Issuer); } - if label.contains(':') { - return Err(TotpUrlError::Label); + if account_name.contains(':') { + return Err(TotpUrlError::AccountName); } Ok(TOTP { algorithm, @@ -184,7 +190,7 @@ impl> TOTP { step, secret, issuer, - label, + account_name, }) } @@ -255,10 +261,17 @@ impl> TOTP { let mut digits = 6; let mut step = 30; let mut secret = Vec::new(); - let issuer: String; - let label: String; + let mut issuer: Option = None; + let account_name: String; - + let path = url.path(); + if path.contains(':') { + let parts = path.split_once(':').unwrap(); + issuer = Some(parts.0.to_owned()); + account_name = parts.1.trim_start_matches(':').to_owned(); + } else { + account_name = path.to_owned(); + } for (key, value) in url.query_pairs() { match key.as_ref() { @@ -282,23 +295,27 @@ impl> TOTP { .ok_or(TotpUrlError::Secret)?; } "issuer" => { - issuer = value.parse::().map_err(|_| TotpUrlError::Issuer)?; + let param_issuer = value.parse::().map_err(|_| TotpUrlError::Issuer)?; + if issuer.is_some() && param_issuer.as_str() != issuer.as_ref().unwrap() { + return Err(TotpUrlError::Issuer); + } + issuer = Some(param_issuer); } _ => {} } } - if issuer.contains(':') { + if issuer.is_some() && issuer.as_ref().unwrap().contains(':') { return Err(TotpUrlError::Issuer); } - if label.contains(':') { - return Err(TotpUrlError::Label); + if account_name.contains(':') { + return Err(TotpUrlError::AccountName); } if secret.is_empty() { return Err(TotpUrlError::Secret); } - Ok(TOTP::new(algorithm, digits, 1, step, secret)) + TOTP::new(algorithm, digits, 1, step, secret, issuer, account_name) } /// Will generate a standard URL used to automatically add TOTP auths. Usually used with qr codes @@ -306,13 +323,19 @@ impl> TOTP { /// Label and issuer will be URL-encoded if needed be /// Secret will be base 32'd without padding, as per RFC. pub fn get_url(&self) -> String { - let label: String = url::form_urlencoded::byte_serialize(self.label.as_bytes()).collect(); - let issuer: String = url::form_urlencoded::byte_serialize(self.issuer.as_bytes()).collect(); + let label: String; + let account_name: String = url::form_urlencoded::byte_serialize(self.account_name.as_bytes()).collect(); + if self.issuer.is_some() { + let issuer: String = url::form_urlencoded::byte_serialize(self.issuer.as_ref().unwrap().as_bytes()).collect(); + label = format!("{0}:{1}?issuer={0}&", issuer, account_name); + } else { + label = format!("{}?", account_name); + } + format!( - "otpauth://totp/{}?secret={}&issuer={}&digits={}&algorithm={}", + "otpauth://totp/{}secret={}&digits={}&algorithm={}", label, self.get_secret_base32(), - issuer, self.digits.to_string(), self.algorithm, ) @@ -330,10 +353,10 @@ impl> TOTP { /// /// It will also return an error in case it can't encode the qr into a png. This shouldn't happen unless either the qrcode library returns malformed data, or the image library doesn't encode the data correctly #[cfg(feature = "qr")] - pub fn get_qr(&self, label: &str, issuer: &str) -> Result> { + pub fn get_qr(&self) -> Result> { use image::ImageEncoder; - let url = self.get_url(label, issuer); + let url = self.get_url(); let mut vec = Vec::new(); let qr = qrcodegen::QrCode::encode_text(&url, qrcodegen::QrCodeEcc::Medium)?; let size = qr.size() as u32; @@ -394,84 +417,112 @@ impl> TOTP { mod tests { use super::*; + #[test] + fn new_wrong_issuer() { + let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github:".to_string()), "constantoine@github.com".to_string()); + assert_eq!(totp.is_err(), true); + assert_eq!(totp.unwrap_err(), TotpUrlError::Issuer); + } + + #[test] + fn new_wrong_account_name() { + let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine:github.com".to_string()); + assert_eq!(totp.is_err(), true); + assert_eq!(totp.unwrap_err(), TotpUrlError::AccountName); + } + + #[test] + fn new_wrong_account_name_no_issuer() { + let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", None, "constantoine:github.com".to_string()); + assert_eq!(totp.is_err(), true); + assert_eq!(totp.unwrap_err(), TotpUrlError::AccountName); + } + #[test] fn comparison_ok() { - let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); - let test = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); + let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); + let test = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert_eq!(reference, test); } #[test] fn comparison_different_algo() { - let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); - let test = TOTP::new(Algorithm::SHA256, 6, 1, 1, "TestSecret"); + let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); + let test = TOTP::new(Algorithm::SHA256, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert_ne!(reference, test); } #[test] fn comparison_different_digits() { - let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); - let test = TOTP::new(Algorithm::SHA1, 8, 1, 1, "TestSecret"); + let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); + let test = TOTP::new(Algorithm::SHA1, 8, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert_ne!(reference, test); } #[test] fn comparison_different_skew() { - let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); - let test = TOTP::new(Algorithm::SHA1, 6, 0, 1, "TestSecret"); + let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); + let test = TOTP::new(Algorithm::SHA1, 6, 0, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert_ne!(reference, test); } #[test] fn comparison_different_step() { - let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); - let test = TOTP::new(Algorithm::SHA1, 6, 1, 30, "TestSecret"); + let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); + let test = TOTP::new(Algorithm::SHA1, 6, 1, 30, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert_ne!(reference, test); } #[test] fn comparison_different_secret() { - let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); - let test = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretL"); + let reference = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); + let test = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecretL", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert_ne!(reference, test); } + #[test] + fn url_for_secret_matches_sha1_without_issuer() { + let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", None, "constantoine@github.com".to_string()).unwrap(); + let url = totp.get_url(); + assert_eq!(url.as_str(), "otpauth://totp/constantoine%40github.com?secret=KRSXG5CTMVRXEZLU&digits=6&algorithm=SHA1"); + } + #[test] fn url_for_secret_matches_sha1() { - let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); - let url = totp.get_url("test_url", "totp-rs"); - assert_eq!(url.as_str(), "otpauth://totp/test_url?secret=KRSXG5CTMVRXEZLU&issuer=totp-rs&digits=6&algorithm=SHA1"); + let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); + let url = totp.get_url(); + assert_eq!(url.as_str(), "otpauth://totp/Github:constantoine%40github.com?issuer=Github&secret=KRSXG5CTMVRXEZLU&digits=6&algorithm=SHA1"); } #[test] fn url_for_secret_matches_sha256() { - let totp = TOTP::new(Algorithm::SHA256, 6, 1, 1, "TestSecret"); - let url = totp.get_url("test_url", "totp-rs"); - assert_eq!(url.as_str(), "otpauth://totp/test_url?secret=KRSXG5CTMVRXEZLU&issuer=totp-rs&digits=6&algorithm=SHA256"); + let totp = TOTP::new(Algorithm::SHA256, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); + let url = totp.get_url(); + assert_eq!(url.as_str(), "otpauth://totp/Github:constantoine%40github.com?issuer=Github&secret=KRSXG5CTMVRXEZLU&digits=6&algorithm=SHA256"); } #[test] fn url_for_secret_matches_sha512() { - let totp = TOTP::new(Algorithm::SHA512, 6, 1, 1, "TestSecret"); - let url = totp.get_url("test_url", "totp-rs"); - assert_eq!(url.as_str(), "otpauth://totp/test_url?secret=KRSXG5CTMVRXEZLU&issuer=totp-rs&digits=6&algorithm=SHA512"); + let totp = TOTP::new(Algorithm::SHA512, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); + let url = totp.get_url(); + assert_eq!(url.as_str(), "otpauth://totp/Github:constantoine%40github.com?issuer=Github&secret=KRSXG5CTMVRXEZLU&digits=6&algorithm=SHA512"); } #[test] fn returns_base32() { - let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); + let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert_eq!(totp.get_secret_base32().as_str(), "KRSXG5CTMVRXEZLU"); } #[test] fn generate_token() { - let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); + let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert_eq!(totp.generate(1000).as_str(), "718996"); } #[test] fn generate_token_current() { - let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); + let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); let time = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH).unwrap() .as_secs(); @@ -480,19 +531,19 @@ mod tests { #[test] fn generates_token_sha256() { - let totp = TOTP::new(Algorithm::SHA256, 6, 1, 1, "TestSecret"); + let totp = TOTP::new(Algorithm::SHA256, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert_eq!(totp.generate(1000).as_str(), "480200"); } #[test] fn generates_token_sha512() { - let totp = TOTP::new(Algorithm::SHA512, 6, 1, 1, "TestSecret"); + let totp = TOTP::new(Algorithm::SHA512, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert_eq!(totp.generate(1000).as_str(), "850500"); } #[test] fn checks_token() { - let totp = TOTP::new(Algorithm::SHA1, 6, 0, 1, "TestSecret"); + let totp = TOTP::new(Algorithm::SHA1, 6, 0, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert!(totp.check("718996", 1000)); assert!(totp.check("712039", 2000)); assert!(!totp.check("527544", 2000)); @@ -501,28 +552,27 @@ mod tests { #[test] fn checks_token_current() { - let totp = TOTP::new(Algorithm::SHA1, 6, 0, 1, "TestSecret"); + let totp = TOTP::new(Algorithm::SHA1, 6, 0, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert!(totp.check_current(&totp.generate_current().unwrap()).unwrap()); assert!(!totp.check_current("bogus").unwrap()); } #[test] fn checks_token_with_skew() { - let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); + let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); assert!( totp.check("527544", 2000) && totp.check("712039", 2000) && totp.check("714250", 2000) ); } #[test] - #[cfg(feature = "otpauth")] 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()); } #[test] - #[cfg(feature = "otpauth")] fn from_url_default() { let totp = TOTP::>::from_url("otpauth://totp/GitHub:test?secret=ABC").unwrap(); assert_eq!(totp.secret, base32::decode(base32::Alphabet::RFC4648 { padding: false }, "ABC").unwrap()); @@ -533,7 +583,6 @@ mod tests { } #[test] - #[cfg(feature = "otpauth")] fn from_url_query() { let totp = TOTP::>::from_url("otpauth://totp/GitHub:test?secret=ABC&digits=8&period=60&algorithm=SHA256").unwrap(); assert_eq!(totp.secret, base32::decode(base32::Alphabet::RFC4648 { padding: false }, "ABC").unwrap()); @@ -543,19 +592,26 @@ mod tests { assert_eq!(totp.step, 60); } + #[test] + fn from_url_query_different_issuers() { + let totp = TOTP::>::from_url("otpauth://totp/GitHub:test?issuer=Gitlab&secret=ABC&digits=8&period=60&algorithm=SHA256"); + assert_eq!(totp.is_err(), true); + assert_eq!(totp.unwrap_err(), TotpUrlError::Issuer); + } + #[test] #[cfg(feature = "qr")] fn generates_qr() { use sha1::{Digest, Sha1}; - let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret"); - let qr = totp.get_qr("test_url", "totp-rs").unwrap(); + let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); + let qr = totp.get_qr().unwrap(); // Create hash from image let hash_digest = Sha1::digest(qr.as_bytes()); assert_eq!( format!("{:x}", hash_digest).as_str(), - "f671a5a553227a9565c6132024808123f2c9e5e3" + "b21a9d4bbb5bd0800bb6bff83a92a2e3314266a5" ); } } From ff6f562ab733c46c3327fd74fa7f3aeb7716478d Mon Sep 17 00:00:00 2001 From: constantoine Date: Fri, 20 May 2022 15:00:12 +0200 Subject: [PATCH 3/4] The return of otpauth Signed-off-by: constantoine --- Cargo.toml | 5 +++-- README.md | 13 +++++++------ src/lib.rs | 16 ++++++++++++++-- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e150eee..93bd8bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,8 @@ features = [ "qr", "serde_support" ] [features] default = [] -qr = ["qrcodegen", "image", "base64"] +otpauth = ["url"] +qr = ["qrcodegen", "image", "base64", "otpauth"] serde_support = ["serde"] [dependencies] @@ -25,7 +26,7 @@ sha2 = "~0.10.2" sha-1 = "~0.10.0" hmac = "~0.12.1" base32 = "~0.4" -url = "2.2.2" +url = { version = "2.2.2", optional = true } constant_time_eq = "~0.2.1" qrcodegen = { version = "~1.8", optional = true } image = { version = "~0.24.2", features = ["png"], optional = true, default-features = false} diff --git a/README.md b/README.md index 4f6ee51..8ef67f5 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ Be aware that some authenticator apps will accept the `SHA256` and `SHA512` algo ## Features --- ### qr -With optional feature "qr", you can use it to generate a base64 png qrcode +With optional feature "qr", you can use it to generate a base64 png qrcode. This will enable feature `otpauth` +### otpauth +With optional feature "otpauth", support parsing the TOTP parameters from an `otpauth` URL, and generating an `otpauth` URL ### serde_support -With optional feature "serde_support", library-defined types will be Deserialize-able and Serialize-able +With optional feature "serde_support", library-defined types `TOTP` and `Algorithm` and will be Deserialize-able and Serialize-able ## How to use --- @@ -36,8 +38,6 @@ fn main() { Some("Github".to_string()), "constantoine@github.com".to_string(), ).unwrap(); - let url = totp.get_url(); - println!("{}", url); let token = totp.generate_current().unwrap(); println!("{}", token); } @@ -82,8 +82,9 @@ features = ["serde_support"] Add it to your `Cargo.toml`: ```toml -[dependencies] -totp-rs = "^2.0" +[dependencies.totp-rs] +version = "^2.0" +features = ["otpauth"] ``` You can then do something like: ```Rust diff --git a/src/lib.rs b/src/lib.rs index 4dd8d5d..92fce39 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,8 +21,6 @@ //! Some("Github".to_string()), //! "constantoine@github.com".to_string(), //! ).unwrap(); -//! let url = totp.get_url(); -//! println!("{}", url); //! let token = totp.generate_current().unwrap(); //! println!("{}", token); //! ``` @@ -40,6 +38,8 @@ //! Some("Github".to_string()), //! "constantoine@github.com".to_string(), //! ).unwrap(); +//! let url = totp.get_url(); +//! println!("{}", url); //! let code = totp.get_qr().unwrap(); //! println!("{}", code); //! # } @@ -55,6 +55,7 @@ use core::fmt; #[cfg(feature = "qr")] use {base64, image::Luma, qrcodegen}; +#[cfg(feature = "otpauth")] use url::{Host, ParseError, Url}; use hmac::Mac; @@ -116,6 +117,7 @@ fn system_time() -> Result { #[derive(Debug, Eq, PartialEq)] pub enum TotpUrlError { + #[cfg(feature = "otpauth")] Url(ParseError), Scheme, Host, @@ -248,6 +250,7 @@ impl> TOTP { } /// Generate a TOTP from the standard otpauth URL + #[cfg(feature = "otpauth")] pub fn from_url>(url: S) -> Result>, TotpUrlError> { let url = Url::parse(url.as_ref()).map_err(|err| TotpUrlError::Url(err))?; if url.scheme() != "otpauth" { @@ -322,6 +325,7 @@ impl> TOTP { /// /// Label and issuer will be URL-encoded if needed be /// Secret will be base 32'd without padding, as per RFC. + #[cfg(feature = "otpauth")] pub fn get_url(&self) -> String { let label: String; let account_name: String = url::form_urlencoded::byte_serialize(self.account_name.as_bytes()).collect(); @@ -481,6 +485,7 @@ mod tests { } #[test] + #[cfg(feature = "otpauth")] fn url_for_secret_matches_sha1_without_issuer() { let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", None, "constantoine@github.com".to_string()).unwrap(); let url = totp.get_url(); @@ -488,6 +493,7 @@ mod tests { } #[test] + #[cfg(feature = "otpauth")] fn url_for_secret_matches_sha1() { let totp = TOTP::new(Algorithm::SHA1, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); let url = totp.get_url(); @@ -495,6 +501,7 @@ mod tests { } #[test] + #[cfg(feature = "otpauth")] fn url_for_secret_matches_sha256() { let totp = TOTP::new(Algorithm::SHA256, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); let url = totp.get_url(); @@ -502,6 +509,7 @@ mod tests { } #[test] + #[cfg(feature = "otpauth")] fn url_for_secret_matches_sha512() { let totp = TOTP::new(Algorithm::SHA512, 6, 1, 1, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); let url = totp.get_url(); @@ -566,6 +574,7 @@ mod tests { } #[test] + #[cfg(feature = "otpauth")] fn from_url_err() { assert!(TOTP::>::from_url("otpauth://hotp/123").is_err()); assert!(TOTP::>::from_url("otpauth://totp/GitHub:test").is_err()); @@ -573,6 +582,7 @@ mod tests { } #[test] + #[cfg(feature = "otpauth")] fn from_url_default() { let totp = TOTP::>::from_url("otpauth://totp/GitHub:test?secret=ABC").unwrap(); assert_eq!(totp.secret, base32::decode(base32::Alphabet::RFC4648 { padding: false }, "ABC").unwrap()); @@ -583,6 +593,7 @@ mod tests { } #[test] + #[cfg(feature = "otpauth")] fn from_url_query() { let totp = TOTP::>::from_url("otpauth://totp/GitHub:test?secret=ABC&digits=8&period=60&algorithm=SHA256").unwrap(); assert_eq!(totp.secret, base32::decode(base32::Alphabet::RFC4648 { padding: false }, "ABC").unwrap()); @@ -593,6 +604,7 @@ mod tests { } #[test] + #[cfg(feature = "otpauth")] fn from_url_query_different_issuers() { let totp = TOTP::>::from_url("otpauth://totp/GitHub:test?issuer=Gitlab&secret=ABC&digits=8&period=60&algorithm=SHA256"); assert_eq!(totp.is_err(), true); From 4f32055d8bade0495f5cd5f37e38a0b51948b714 Mon Sep 17 00:00:00 2001 From: constantoine Date: Fri, 20 May 2022 17:43:39 +0200 Subject: [PATCH 4/4] Add next_step and next_step_current methods Signed-off-by: constantoine --- src/lib.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 92fce39..f29533d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -216,6 +216,21 @@ impl> TOTP { ) } + /// Returns the timestamp of the first second for the next step + /// given the provided timestamp in seconds + pub fn next_step(&self, time: u64) -> u64 { + let step = time / self.step; + + (step + 1) * self.step + } + + /// Returns the timestamp of the first second of the next step + /// According to system time + pub fn next_step_current(&self)-> Result { + let t = system_time()?; + Ok(self.next_step(t)) + } + /// Generate a token from the current system time pub fn generate_current(&self) -> Result { let t = system_time()?; @@ -573,6 +588,21 @@ mod tests { ); } + #[test] + fn next_step() { + let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); + assert!(totp.next_step(0) == 30); + assert!(totp.next_step(29) == 30); + assert!(totp.next_step(30) == 60); + } + + #[test] + fn next_step_current() { + let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, "TestSecret", Some("Github".to_string()), "constantoine@github.com".to_string()).unwrap(); + let t = system_time().unwrap(); + assert!(totp.next_step_current().unwrap() == totp.next_step(t)); + } + #[test] #[cfg(feature = "otpauth")] fn from_url_err() {