Commit 2cb5ec2b7a2be8a9e1912a86fd85026cd903971a

Cléo Rebert 2022-08-08T17:56:32

Merge pull request #26 from steven89/rfc Rfc6238 struct

diff --git a/examples/rfc-6238.rs b/examples/rfc-6238.rs
new file mode 100644
index 0000000..12a9d59
--- /dev/null
+++ b/examples/rfc-6238.rs
@@ -0,0 +1,17 @@
+use totp_rs::{Rfc6238, TOTP};
+
+fn main () {
+    let mut rfc = Rfc6238::with_defaults(
+        "totp-sercret-123"
+    ).unwrap();
+
+    // optional, set digits, issuer, account_name
+    rfc.digits(8).unwrap();
+    rfc.issuer("issuer".to_string());
+    rfc.account_name("user-account".to_string());
+
+    // create a TOTP from rfc
+    let totp = TOTP::from_rfc6238(rfc).unwrap();
+    let code = totp.generate_current().unwrap();
+    println!("code: {}", code);
+}
diff --git a/src/lib.rs b/src/lib.rs
index 28d35b1..2696ea5 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -45,6 +45,10 @@
 //! # }
 //! ```
 
+mod rfc;
+
+pub use rfc::{Rfc6238, Rfc6238Error};
+
 use constant_time_eq::constant_time_eq;
 
 #[cfg(feature = "serde_support")]
@@ -131,6 +135,15 @@ pub enum TotpUrlError {
     AccountName,
 }
 
+impl From<Rfc6238Error> for TotpUrlError {
+    fn from(e: Rfc6238Error) -> Self {
+        match e {
+            Rfc6238Error::InvalidDigits => TotpUrlError::Digits,
+            Rfc6238Error::SecretTooSmall => TotpUrlError::Secret,
+        }
+    }
+}
+
 /// 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
 #[derive(Debug, Clone)]
 #[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
@@ -177,10 +190,14 @@ impl <T: AsRef<[u8]>> PartialEq for TOTP<T> {
 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
+    /// * `digits`: MUST be between 6 & 8
+    ///
     /// # 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: Option<String>, account_name: String) -> Result<TOTP<T>, TotpUrlError> {
+        crate::rfc::assert_digits(&digits)?;
         if issuer.is_some() && issuer.as_ref().unwrap().contains(':') {
             return Err(TotpUrlError::Issuer);
         }
@@ -198,6 +215,15 @@ impl<T: AsRef<[u8]>> TOTP<T> {
         })
     }
 
+    /// Will create a new instance of TOTP from the given [Rfc6238](struct.Rfc6238.html) struct
+    ///
+    /// # Errors
+    ///
+    /// Will return an error in case issuer or label contain the character ':'
+    pub fn from_rfc6238(rfc: Rfc6238<T>) -> Result<TOTP<T>, TotpUrlError> {
+        TOTP::try_from(rfc)
+    }
+
     /// Will sign the given timestamp
     pub fn sign(&self, time: u64) -> Vec<u8> {
         self.algorithm.sign(
@@ -333,12 +359,6 @@ impl<T: AsRef<[u8]>> TOTP<T> {
             }
         }
 
-        if issuer.is_some() && issuer.as_ref().unwrap().contains(':') {
-            return Err(TotpUrlError::Issuer);
-        }
-        if account_name.contains(':') {
-            return Err(TotpUrlError::AccountName);
-        }
         if secret.is_empty() {
             return Err(TotpUrlError::Secret);
         }
diff --git a/src/rfc.rs b/src/rfc.rs
new file mode 100644
index 0000000..b34bdaa
--- /dev/null
+++ b/src/rfc.rs
@@ -0,0 +1,261 @@
+use crate::Algorithm;
+use crate::TotpUrlError;
+use crate::TOTP;
+
+#[cfg(feature = "serde_support")]
+use serde::{Deserialize, Serialize};
+
+/// Data is not compliant to [rfc-6238](https://tools.ietf.org/html/rfc6238)
+#[derive(Debug, Eq, PartialEq)]
+pub enum Rfc6238Error {
+    /// Implementations MUST extract a 6-digit code at a minimum and possibly 7 and 8-digit code
+    InvalidDigits,
+    /// The length of the shared secret MUST be at least 128 bits
+    SecretTooSmall,
+}
+
+impl std::fmt::Display for Rfc6238Error {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Rfc6238Error::InvalidDigits => write!(
+                f,
+                "Implementations MUST extract a 6-digit code at a minimum and possibly 7 and 8-digit code"
+            ),
+            Rfc6238Error::SecretTooSmall => write!(
+                f,
+                "The length of the shared secret MUST be at least 128 bits"
+            ),
+        }
+    }
+}
+
+pub fn assert_digits(digits: &usize) -> Result<(), Rfc6238Error> {
+    if !(&6..=&8).contains(&digits) {
+        Err(Rfc6238Error::InvalidDigits)
+    } else {
+        Ok(())
+    }
+}
+
+/// [rfc-6238](https://tools.ietf.org/html/rfc6238) compliant set of options to create a [TOTP](struct.TOTP.html)
+///
+/// # Example
+/// ```
+/// use totp_rs::{Rfc6238, TOTP};
+///
+/// let mut rfc = Rfc6238::with_defaults(
+///     "totp-sercret-123"
+/// ).unwrap();
+///
+/// // optional, set digits, issuer, account_name
+/// rfc.digits(8).unwrap();
+/// rfc.issuer("issuer".to_string());
+/// rfc.account_name("user-account".to_string());
+///
+/// let totp = TOTP::from_rfc6238(rfc).unwrap();
+/// ```
+#[derive(Debug, Clone)]
+#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
+pub struct Rfc6238<T = Vec<u8>> {
+    /// SHA-1
+    algorithm: Algorithm,
+    /// The number of digits composing the auth code. Per [rfc-4226](https://tools.ietf.org/html/rfc4226#section-5.3), this can oscilate between 6 and 8 digits
+    digits: usize,
+    /// The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 1.
+    skew: u8,
+    /// The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 30 seconds
+    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
+    secret: T,
+    /// The "Github" part of "Github:constantoine@github.com". Must not contain a colon `:`
+    /// For example, the name of your service/website.
+    /// Not mandatory, but strongly recommended!
+    issuer: Option<String>,
+    /// The "constantoine@github.com" part of "Github:constantoine@github.com". Must not contain a colon `:`
+    /// For example, the name of your user's account.
+    account_name: String,
+}
+
+impl<T: AsRef<[u8]>> Rfc6238<T> {
+    /// Create an [rfc-6238](https://tools.ietf.org/html/rfc6238) compliant set of options that can be turned into a [TOTP](struct.TOTP.html)
+    ///
+    /// # Errors
+    ///
+    /// will return a [Rfc6238Error](enum.Rfc6238Error.html) when
+    /// - `digits` is lower than 6 or higher than 8
+    /// - `secret` is smaller than 128 bits (16 characters)
+    pub fn new(
+        digits: usize,
+        secret: T,
+        issuer: Option<String>,
+        account_name: String,
+    ) -> Result<Rfc6238<T>, Rfc6238Error> {
+        assert_digits(&digits)?;
+        if secret.as_ref().len() < 16 {
+            Err(Rfc6238Error::SecretTooSmall)
+        } else {
+            Ok(Rfc6238 {
+                algorithm: Algorithm::SHA1,
+                digits,
+                skew: 1,
+                step: 30,
+                secret,
+                issuer,
+                account_name,
+            })
+        }
+    }
+
+    /// Create an [rfc-6238](https://tools.ietf.org/html/rfc6238) compliant set of options that can be turned into a [TOTP](struct.TOTP.html),
+    /// with a default value of 6 for `digits`, None `issuer` and an empty account
+    ///
+    /// # Errors
+    ///
+    /// will return a [Rfc6238Error](enum.Rfc6238Error.html) when
+    /// - `digits` is lower than 6 or higher than 8
+    /// - `secret` is smaller than 128 bits (16 characters)
+    pub fn with_defaults(secret: T) -> Result<Rfc6238<T>, Rfc6238Error> {
+        Rfc6238::new(6, secret, None, "".to_string())
+    }
+
+    /// Set the `digits`
+    pub fn digits(&mut self, value:usize) -> Result<(), Rfc6238Error> {
+        assert_digits(&value)?;
+        self.digits = value;
+        Ok(())
+    }
+
+    /// Set the `issuer`
+    pub fn issuer(&mut self, value: String) {
+        self.issuer = Some(value);
+    }
+
+    /// Seet the `account_name`
+    pub fn account_name(&mut self, value: String) {
+        self.account_name = value;
+    }
+}
+
+impl<T: AsRef<[u8]>> TryFrom<Rfc6238<T>> for TOTP<T> {
+    type Error = TotpUrlError;
+
+    /// Try to create a [TOTP](struct.TOTP.html) from a [Rfc6238](struct.Rfc6238.html) config
+    fn try_from(rfc: Rfc6238<T>) -> Result<Self, Self::Error> {
+        TOTP::new(
+            rfc.algorithm,
+            rfc.digits,
+            rfc.skew,
+            rfc.step,
+            rfc.secret,
+            rfc.issuer,
+            rfc.account_name,
+        )
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::TotpUrlError;
+
+    use super::{Rfc6238, Rfc6238Error, TOTP};
+
+    const GOOD_SECRET: &str = "01234567890123456789";
+    const ISSUER: Option<&str> = None;
+    const ACCOUNT: &str = "valid-account";
+    const INVALID_ACCOUNT: &str = ":invalid-account";
+
+    #[test]
+    fn new_rfc_digits() {
+        for x in 0..=20 {
+            let rfc = Rfc6238::new(
+                x,
+                GOOD_SECRET.to_string(),
+                ISSUER.map(str::to_string),
+                ACCOUNT.to_string(),
+            );
+            if x < 6 || x > 8 {
+                assert!(rfc.is_err());
+                assert_eq!(rfc.unwrap_err(), Rfc6238Error::InvalidDigits)
+            } else {
+                assert!(rfc.is_ok());
+            }
+        }
+    }
+
+    #[test]
+    fn new_rfc_secret() {
+        let mut secret = String::from("");
+        for _ in 0..=20 {
+            secret = format!("{}{}", secret, "0");
+            let rfc = Rfc6238::new(
+                6,
+                secret.clone(),
+                ISSUER.map(str::to_string),
+                ACCOUNT.to_string(),
+            );
+            let rfc_default = Rfc6238::with_defaults(secret.clone());
+            if secret.len() < 16 {
+                assert!(rfc.is_err());
+                assert_eq!(rfc.unwrap_err(), Rfc6238Error::SecretTooSmall);
+                assert!(rfc_default.is_err());
+                assert_eq!(rfc_default.unwrap_err(), Rfc6238Error::SecretTooSmall);
+            } else {
+                assert!(rfc.is_ok());
+                assert!(rfc_default.is_ok());
+            }
+        }
+    }
+
+    #[test]
+    fn rfc_to_totp_ok() {
+        let rfc = Rfc6238::new(
+            8,
+            GOOD_SECRET.to_string(),
+            ISSUER.map(str::to_string),
+            ACCOUNT.to_string(),
+        )
+        .unwrap();
+        let totp = TOTP::try_from(rfc);
+        assert!(totp.is_ok());
+        let otp = totp.unwrap();
+        assert_eq!(&otp.secret, GOOD_SECRET);
+        assert_eq!(otp.algorithm, crate::Algorithm::SHA1);
+        assert_eq!(&otp.account_name, ACCOUNT);
+        assert_eq!(otp.digits, 8);
+        assert_eq!(otp.issuer, ISSUER.map(str::to_string));
+        assert_eq!(otp.skew, 1);
+        assert_eq!(otp.step, 30)
+    }
+
+    #[test]
+    fn rfc_to_totp_fail() {
+        let rfc = Rfc6238::new(
+            8,
+            GOOD_SECRET.to_string(),
+            ISSUER.map(str::to_string),
+            INVALID_ACCOUNT.to_string(),
+        )
+        .unwrap();
+        let totp = TOTP::try_from(rfc);
+        assert!(totp.is_err());
+        assert_eq!(totp.unwrap_err(), TotpUrlError::AccountName)
+    }
+
+    #[test]
+    fn rfc_with_default_set_values() {
+        let new_account = "new-account";
+        let new_issuer = String::from("new-issuer");
+        let mut rfc = Rfc6238::with_defaults(GOOD_SECRET.to_string()).unwrap();
+        rfc.issuer(new_issuer.clone());
+        assert_eq!(rfc.issuer, Some(new_issuer));
+        rfc.account_name(new_account.to_string());
+        assert_eq!(rfc.account_name, new_account.to_string());
+        let fail = rfc.digits(4);
+        assert!(fail.is_err());
+        assert_eq!(fail.unwrap_err(), Rfc6238Error::InvalidDigits);
+        assert_eq!(rfc.digits, 6);
+        let ok = rfc.digits(8);
+        assert!(ok.is_ok());
+        assert_eq!(rfc.digits, 8)
+    }
+}