Commit 6b9f13f6a220acc86527ebf7f919cd3551de9ec4

Cléo Rebert 2022-08-08T20:54:16

Merge pull request #25 from steven89/secret Improve secret handling

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<T = Vec<u8>> {
     /// 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<T: AsRef<[u8]>> TOTP<T> {
     /// 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::<Vec<u8>>::from_url("otpauth://totp/GitHub:test").is_err());
         assert!(TOTP::<Vec<u8>>::from_url("otpauth://totp/GitHub:test:?secret=ABC&digits=8&period=60&algorithm=SHA256").is_err());
         assert!(TOTP::<Vec<u8>>::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<u8>),
+    /// 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<Vec<u8>, 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<Self, SecretParseError> {
+        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"),
+        }
+    }
+}