diff --git a/Cargo.toml b/Cargo.toml index e428b89..c8ace63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ default = [] otpauth = ["url", "urlencoding"] qr = ["qrcodegen", "image", "base64", "otpauth"] serde_support = ["serde"] +gen_secret = ["rand"] [dependencies] serde = { version = "1.0", features = ["derive"], optional = true } @@ -27,8 +28,9 @@ sha-1 = "~0.10.0" hmac = "~0.12.1" base32 = "~0.4" urlencoding = { version = "^2.1.0", optional = true} -url = { version = "^2.2.2", optional = true } +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} -base64 = { version = "~0.13", optional = true } \ No newline at end of file +base64 = { version = "~0.13", optional = true } +rand = { version = "~0.8.5", optional = true } \ No newline at end of file diff --git a/examples/gen_secret.rs b/examples/gen_secret.rs new file mode 100644 index 0000000..d8c0878 --- /dev/null +++ b/examples/gen_secret.rs @@ -0,0 +1,26 @@ +#[cfg(not(feature = "gen_secret"))] +compile_error!("requires feature gen_secret"); + +use totp_rs::{Secret, TOTP, Algorithm}; + +fn main () { + + let secret = Secret::generate_secret(); + + 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() + ) +} diff --git a/examples/secret.rs b/examples/secret.rs new file mode 100644 index 0000000..5ff7276 --- /dev/null +++ b/examples/secret.rs @@ -0,0 +1,37 @@ +use totp_rs::{Secret, TOTP, Algorithm}; + +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(), + None, + "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(), + None, + "account".to_string(), + ).unwrap(); + + println!("raw {} ; base32 {}", secret_raw, secret_raw.to_encoded()); + println!("code from raw secret:\t{}", totp_raw.generate_current().unwrap()); +} diff --git a/src/lib.rs b/src/lib.rs index 7aee579..c33e800 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,12 +45,14 @@ //! # } //! ``` +mod secret; mod rfc; - mod url_error; -use url_error::TotpUrlError; +pub use secret::{Secret, SecretParseError}; +use url_error::TotpUrlError; pub use rfc::{Rfc6238, Rfc6238Error}; +pub use base32; use constant_time_eq::constant_time_eq; @@ -137,6 +139,8 @@ pub struct TOTP> { /// 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, /// 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: T, /// The "Github" part of "Github:constantoine@github.com". Must not contain a colon `:` /// For example, the name of your service/website. @@ -171,6 +175,13 @@ 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 /// /// # Description + /// * `secret`: expect a non-encoded value, to pass in base32 string use `Secret::Encoded(String)` + /// + /// ```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(); + /// ``` /// * `digits`: MUST be between 6 & 8 /// * `secret`: Must have bitsize of at least 128 /// * `account_name`: Must not contain `:` @@ -621,7 +632,7 @@ mod tests { 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:constantoine%40github.com?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").is_err()) - + } #[test] diff --git a/src/secret.rs b/src/secret.rs new file mode 100644 index 0000000..5bf517b --- /dev/null +++ b/src/secret.rs @@ -0,0 +1,180 @@ +use std::string::FromUtf8Error; +use base32::{self, Alphabet}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SecretParseError { + ParseBase32, + Utf8Error(FromUtf8Error), +} + +/// Representation of a secret either a "raw" \[u8\] or "base 32" encoded String +/// +/// # Examples +/// +/// - Create a TOTP from a "raw" secret +/// ``` +/// use totp_rs::{Secret, TOTP, Algorithm}; +/// +/// 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(), +/// None, +/// "account".to_string(), +/// ).unwrap(); +/// +/// println!("code from raw secret:\t{}", totp_raw.generate_current().unwrap()); +/// ``` +/// +/// - Create a TOTP from a base32 encoded secret +/// ``` +/// use totp_rs::{Secret, TOTP, Algorithm}; +/// +/// let secret_b32 = Secret::Encoded(String::from("OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG")); +/// let totp_b32 = TOTP::new( +/// Algorithm::SHA1, +/// 6, +/// 1, +/// 30, +/// secret_b32.to_bytes().unwrap(), +/// None, +/// "account".to_string(), +/// ).unwrap(); +/// +/// println!("code from base32:\t{}", totp_b32.generate_current().unwrap()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Secret { + /// represent a non-encoded "raw" secret + Raw(Vec), + /// represent a base32 encoded secret + Encoded(String), +} + +impl Secret { + + /// Get the inner String value as a Vec of bytes + pub fn to_bytes(&self) -> Result, SecretParseError> { + match self { + Secret::Raw(s) => Ok(s.to_vec()), + Secret::Encoded(s) => match base32::decode(Alphabet::RFC4648 { padding: false }, s) { + Some(bytes) => Ok(bytes), + None => Err(SecretParseError::ParseBase32), + }, + } + } + + /// Try to transform a `Secret::Encoded` into a `Secret::Raw` + pub fn to_raw(&self) -> Result { + match self { + Secret::Raw(_) => Ok(self.clone()), + Secret::Encoded(s) => match base32::decode(Alphabet::RFC4648 { padding: false }, s) { + Some(buf) => Ok(Secret::Raw(buf)), + None => Err(SecretParseError::ParseBase32), + }, + } + } + + /// Try to transforms a `Secret::Raw` into a `Secret::Encoded` + pub fn to_encoded(&self) -> Self { + match self { + Secret::Raw(s) => Secret::Encoded(base32::encode( + Alphabet::RFC4648 { padding: false }, + &s, + )), + Secret::Encoded(_) => self.clone(), + } + } + + /// ⚠️ requires feature `gen_secret` + /// + /// Generate a CSPRNG binary value of 160 bits, + /// the recomended size from [rfc-4226](https://www.rfc-editor.org/rfc/rfc4226#section-4) + /// + /// > The length of the shared secret MUST be at least 128 bits. + /// > This document RECOMMENDs a shared secret length of 160 bits. + /// + /// ⚠️ The generated secret is not guaranteed to be a valid UTF-8 sequence + #[cfg(feature = "gen_secret")] + pub fn generate_secret() -> Secret { + use rand::Rng; + + let mut rng = rand::thread_rng(); + let mut secret: [u8; 20] = Default::default(); + rng.fill(&mut secret); + Secret::Raw(secret.to_vec()) + } +} + +impl std::fmt::Display for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Secret::Raw(bytes) => { + let mut s: String = String::new(); + for b in bytes { + s = format!("{}{:02x}", &s, &b); + } + write!(f, "{}", s) + }, + Secret::Encoded(s) => write!(f, "{}", s), + } + } +} + +#[cfg(test)] +mod tests { + use super::Secret; + + const BASE32: &str = "OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG"; + const BYTES: [u8; 23] = [ + 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x73, 0x65, + 0x63, 0x72, 0x65, 0x74, 0x2d, 0x31, 0x32, 0x33, + ]; + const BYTES_DISPLAY: &str = "706c61696e2d737472696e672d7365637265742d313233"; + + #[test] + fn secret_display() { + let base32_str = String::from(BASE32); + let secret_raw = Secret::Raw(BYTES.to_vec()); + let secret_base32 = Secret::Encoded(base32_str.clone()); + println!("{}", secret_raw); + assert_eq!(secret_raw.to_string(), BYTES_DISPLAY.to_string()); + assert_eq!(secret_base32.to_string(), BASE32.to_string()); + } + + #[test] + fn secret_convert_base32_raw() { + let base32_str = String::from(BASE32); + let secret_raw = Secret::Raw(BYTES.to_vec()); + let secret_base32 = Secret::Encoded(base32_str.clone()); + + assert_eq!(&secret_raw.to_encoded(), &secret_base32); + assert_eq!(&secret_raw.to_raw().unwrap(), &secret_raw); + + assert_eq!(&secret_base32.to_raw().unwrap(), &secret_raw); + assert_eq!(&secret_base32.to_encoded(), &secret_base32); + } + + #[test] + fn secret_as_bytes() { + let base32_str = String::from(BASE32); + assert_eq!(Secret::Raw(BYTES.to_vec()).to_bytes().unwrap(), BYTES.to_vec()); + assert_eq!(Secret::Encoded(base32_str).to_bytes().unwrap(), BYTES.to_vec()); + } + + #[test] + #[cfg(feature = "gen_secret")] + fn secret_gen_secret() { + match Secret::generate_secret() { + Secret::Raw(secret) => assert_eq!(secret.len(), 20), + Secret::Encoded(_) => panic!("should be raw"), + } + } +}