Commit f65a2e840a8348c72f10ab7824dcdad062ce045e

Steven Salaun 2022-08-06T23:04:50

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

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<Vec<u8>, 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<Self, SecretParseError> {
+        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"),
+        }
+    }
+}