From f65a2e840a8348c72f10ab7824dcdad062ce045e Mon Sep 17 00:00:00 2001 From: Steven Salaun Date: Sat, 6 Aug 2022 23:04:50 +0200 Subject: [PATCH] add `Secret` enum & `gen_secret` feature - Make the distinction between encoded/non-encoded secret clear, and allows for easy transformation betwen the two formats - add `gen_secret` feature to allow easy generation of CSPRNG secret, also add function to generate rfc recommended length secret --- Cargo.toml | 6 +- examples/gen_secret.rs | 26 ++++++ examples/secret.rs | 33 ++++++++ src/lib.rs | 2 + src/secret.rs | 185 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 examples/gen_secret.rs create mode 100644 examples/secret.rs create mode 100644 src/secret.rs 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..0d063c9 --- /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_rfc_secret(); + + let totp = TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + secret.as_bytes().unwrap(), + None, + "account".to_string(), + ).unwrap(); + + println!( + "secret plain: {} ; secret base32 {} ; code: {}", + secret, + secret.as_base32(), + totp.generate_current().unwrap() + ) +} diff --git a/examples/secret.rs b/examples/secret.rs new file mode 100644 index 0000000..abdda1e --- /dev/null +++ b/examples/secret.rs @@ -0,0 +1,33 @@ +use totp_rs::{Secret, TOTP, Algorithm}; + +fn main () { + // create TOTP from base32 secret + let secret_b32 = Secret::Base32(String::from("OBWGC2LOFVZXI4TJNZTS243FMNZGK5BNGEZDG")); + let totp_b32 = TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + secret_b32.as_bytes().unwrap(), + None, + "account".to_string(), + ).unwrap(); + + println!("base32 {} ; plain {}", secret_b32, secret_b32.as_plain().unwrap()); + println!("code from base32:\t{}", totp_b32.generate_current().unwrap()); + + // create TOTP from plain text secret + let secret_plain = Secret::Plain(String::from("plain-string-secret-123")); + let totp_plain = TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + secret_plain.as_bytes().unwrap(), + None, + "account".to_string(), + ).unwrap(); + + println!("plain {} ; base32 {}", secret_plain, secret_plain.as_base32()); + println!("code from plain text:\t{}", totp_plain.generate_current().unwrap()); +} diff --git a/src/lib.rs b/src/lib.rs index 7927289..1718439 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,8 @@ //! # } //! ``` +mod secret; +pub use secret::{Secret, SecretParseError}; pub use base32; use constant_time_eq::constant_time_eq; diff --git a/src/secret.rs b/src/secret.rs new file mode 100644 index 0000000..276ac78 --- /dev/null +++ b/src/secret.rs @@ -0,0 +1,185 @@ +use std::string::FromUtf8Error; + +use base32::{self, Alphabet}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SecretParseError { + ParseBase32, + Utf8Error(FromUtf8Error), +} + +/// Representation of a secret either in "plain text" or "base 32" encoded +/// +/// # Examples +/// +/// - Create a TOTP from a "plain text" secret +/// ``` +/// use totp_rs::{Secret, TOTP, Algorithm}; +/// +/// let secret = Secret::Plain(String::from("my-secret")); +/// let totp_plain = TOTP::new( +/// Algorithm::SHA1, +/// 6, +/// 1, +/// 30, +/// secret.as_bytes().unwrap(), +/// None, +/// "account".to_string(), +/// ).unwrap(); +/// +/// println!("code from plain text:\t{}", totp_plain.generate_current().unwrap()); +/// ``` +/// +/// - Create a TOTP from a base32 encoded secret +/// ``` +/// use totp_rs::{Secret, TOTP, Algorithm}; +/// +/// let secret = Secret::Base32(String::from("NV4S243FMNZGK5A")); +/// let totp_base32 = TOTP::new( +/// Algorithm::SHA1, +/// 6, +/// 1, +/// 30, +/// secret.as_bytes().unwrap(), +/// None, +/// "account".to_string(), +/// ).unwrap(); +/// +/// println!("code from base32:\t{}", totp_base32.generate_current().unwrap()); +/// +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Secret { + /// represent a non-encoded "plain text" secret + Plain(String), + /// represent a base32 encoded secret + Base32(String), +} + +impl Secret { + /// Get the inner String value of the enum variant + pub fn inner(&self) -> &String { + match self { + Secret::Plain(s) => s, + Secret::Base32(s) => s, + } + } + + /// Get the inner String value as a Vec of bytes + pub fn as_bytes(&self) -> Result, SecretParseError> { + match self { + Secret::Plain(s) => Ok(s.as_bytes().to_vec()), + Secret::Base32(s) => match base32::decode(Alphabet::RFC4648 { padding: false }, s) { + Some(bytes) => Ok(bytes), + None => Err(SecretParseError::ParseBase32), + }, + } + } + + /// Transforms a `Secret::Base32` into a `Secret::Plain` + pub fn as_plain(&self) -> Result { + match self { + Secret::Plain(_) => Ok(self.clone()), + Secret::Base32(s) => match base32::decode(Alphabet::RFC4648 { padding: false }, s) { + Some(buf) => match String::from_utf8(buf) { + Ok(str) => Ok(Secret::Plain(str)), + Err(e) => Err(SecretParseError::Utf8Error(e)), + }, + None => Err(SecretParseError::ParseBase32), + }, + } + } + + /// Transforms a `Secret::Plain` into a `Secret::Base32` + pub fn as_base32(&self) -> Self { + match self { + Secret::Plain(s) => Secret::Base32(base32::encode( + Alphabet::RFC4648 { padding: false }, + s.as_ref(), + )), + Secret::Base32(_) => self.clone(), + } + } + + /// ⚠️ requires feature `gen_secret` + /// + /// Generate a CSPRNG alpha-numeric string of length `size` + #[cfg(feature = "gen_secret")] + pub fn generate_secret(size: usize) -> Secret { + use rand::distributions::{Alphanumeric, DistString}; + Secret::Plain(Alphanumeric.sample_string(&mut rand::thread_rng(), size)) + } + + /// ⚠️ requires feature `gen_secret` + /// + /// Generate a CSPRNG alpha-numeric string of length 20, + /// the recomended size from [rfc-4226](https://tools.ietf.org/html/rfc4226) + /// + /// > The length of the shared secret MUST be at least 128 bits. + /// > This document RECOMMENDs a shared secret length of 160 bits. + #[cfg(feature = "gen_secret")] + pub fn generate_rfc_secret() -> Secret { + Secret::generate_secret(20) + } +} + +impl std::fmt::Display for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Secret::Plain(s) => write!(f, "{}", s), + Secret::Base32(s) => write!(f, "{}", s), + } + } +} + +#[cfg(test)] +mod tests { + use super::Secret; + + const PLAIN: &str = "plain-string-secret-123"; + 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, + ]; + + #[test] + fn secret_convert_base32_plain() { + let plain_str = String::from(PLAIN); + let base32_str = String::from(BASE32); + let secret_plain = Secret::Plain(plain_str.clone()); + let secret_base32 = Secret::Base32(base32_str.clone()); + + assert_eq!(&secret_plain.as_base32(), &secret_base32); + assert_eq!(&secret_plain.as_plain().unwrap(), &secret_plain); + + assert_eq!(&secret_base32.as_plain().unwrap(), &secret_plain); + assert_eq!(&secret_base32.as_base32(), &secret_base32); + } + + #[test] + fn secret_as_bytes() { + let plain_str = String::from(PLAIN); + let base32_str = String::from(BASE32); + assert_eq!(Secret::Plain(plain_str).as_bytes().unwrap(), BYTES.to_vec()); + assert_eq!(Secret::Base32(base32_str).as_bytes().unwrap(), BYTES.to_vec()); + } + + #[test] + #[cfg(feature = "gen_secret")] + fn secret_gen_secret() { + match Secret::generate_secret(10) { + Secret::Plain(secret) => assert_eq!(secret.len(), 10), + Secret::Base32(_) => panic!("should be plain"), + } + } + + #[test] + #[cfg(feature = "gen_secret")] + fn secret_gen_rfc_secret() { + match Secret::generate_rfc_secret() { + Secret::Plain(secret) => assert_eq!(secret.len(), 20), + Secret::Base32(_) => panic!("should be plain"), + } + } +}