totp_rs/
rfc.rs

1use crate::Algorithm;
2use crate::TotpUrlError;
3use crate::TOTP;
4
5#[cfg(feature = "serde_support")]
6use serde::{Deserialize, Serialize};
7
8/// Error returned when input is not compliant to [rfc-6238](https://tools.ietf.org/html/rfc6238).
9#[derive(Debug, Eq, PartialEq)]
10pub enum Rfc6238Error {
11    /// Implementations MUST extract a 6-digit code at a minimum and possibly 7 and 8-digit code.
12    InvalidDigits(usize),
13    /// The length of the shared secret MUST be at least 128 bits.
14    SecretTooSmall(usize),
15}
16
17impl std::error::Error for Rfc6238Error {}
18
19impl std::fmt::Display for Rfc6238Error {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Rfc6238Error::InvalidDigits(digits) => write!(
23                f,
24                "Implementations MUST extract a 6-digit code at a minimum and possibly 7 and 8-digit code. {} digits is not allowed",
25                digits,
26            ),
27            Rfc6238Error::SecretTooSmall(bits) => write!(
28                f,
29                "The length of the shared secret MUST be at least 128 bits. {} bits is not enough",
30                bits,
31            ),
32        }
33    }
34}
35
36// Check that the number of digits is RFC-compliant.
37// (between 6 and 8 inclusive).
38pub fn assert_digits(digits: &usize) -> Result<(), Rfc6238Error> {
39    if !(&6..=&8).contains(&digits) {
40        Err(Rfc6238Error::InvalidDigits(*digits))
41    } else {
42        Ok(())
43    }
44}
45
46// Check that the secret is AT LEAST 128 bits long, as per the RFC's requirements.
47// It is still RECOMMENDED to have an at least 160 bits long secret.
48pub fn assert_secret_length(secret: &[u8]) -> Result<(), Rfc6238Error> {
49    if secret.as_ref().len() < 16 {
50        Err(Rfc6238Error::SecretTooSmall(secret.as_ref().len() * 8))
51    } else {
52        Ok(())
53    }
54}
55
56/// [rfc-6238](https://tools.ietf.org/html/rfc6238) compliant set of options to create a [TOTP](struct.TOTP.html)
57///
58/// # Example
59/// ```
60/// use totp_rs::{Rfc6238, TOTP};
61///
62/// let mut rfc = Rfc6238::with_defaults(
63///     "totp-sercret-123".as_bytes().to_vec()
64/// ).unwrap();
65///
66/// // optional, set digits, issuer, account_name
67/// rfc.digits(8).unwrap();
68///
69/// let totp = TOTP::from_rfc6238(rfc).unwrap();
70/// ```
71#[derive(Debug, Clone)]
72#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
73pub struct Rfc6238 {
74    /// SHA-1
75    algorithm: Algorithm,
76    /// 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.
77    digits: usize,
78    /// The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 1.
79    skew: u8,
80    /// The recommended value per [rfc-6238](https://tools.ietf.org/html/rfc6238#section-5.2) is 30 seconds.
81    step: u64,
82    /// 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.
83    secret: Vec<u8>,
84    #[cfg(feature = "otpauth")]
85    #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
86    /// The "Github" part of "Github:constantoine@github.com". Must not contain a colon `:`
87    /// For example, the name of your service/website.
88    /// Not mandatory, but strongly recommended!
89    issuer: Option<String>,
90    #[cfg(feature = "otpauth")]
91    #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
92    /// The "constantoine@github.com" part of "Github:constantoine@github.com". Must not contain a colon `:`.
93    /// For example, the name of your user's account.
94    account_name: String,
95}
96
97impl Rfc6238 {
98    /// Create an [rfc-6238](https://tools.ietf.org/html/rfc6238) compliant set of options that can be turned into a [TOTP](struct.TOTP.html).
99    ///
100    /// # Errors
101    ///
102    /// will return a [Rfc6238Error](enum.Rfc6238Error.html) when
103    /// - `digits` is lower than 6 or higher than 8.
104    /// - `secret` is smaller than 128 bits (16 characters).
105    #[cfg(feature = "otpauth")]
106    pub fn new(
107        digits: usize,
108        secret: Vec<u8>,
109        issuer: Option<String>,
110        account_name: String,
111    ) -> Result<Rfc6238, Rfc6238Error> {
112        assert_digits(&digits)?;
113        assert_secret_length(secret.as_ref())?;
114
115        Ok(Rfc6238 {
116            algorithm: Algorithm::SHA1,
117            digits,
118            skew: 1,
119            step: 30,
120            secret,
121            issuer,
122            account_name,
123        })
124    }
125    #[cfg(not(feature = "otpauth"))]
126    pub fn new(digits: usize, secret: Vec<u8>) -> Result<Rfc6238, Rfc6238Error> {
127        assert_digits(&digits)?;
128        assert_secret_length(secret.as_ref())?;
129
130        Ok(Rfc6238 {
131            algorithm: Algorithm::SHA1,
132            digits,
133            skew: 1,
134            step: 30,
135            secret,
136        })
137    }
138
139    /// Create an [rfc-6238](https://tools.ietf.org/html/rfc6238) compliant set of options that can be turned into a [TOTP](struct.TOTP.html),
140    /// with a default value of 6 for `digits`, None `issuer` and an empty account.
141    ///
142    /// # Errors
143    ///
144    /// will return a [Rfc6238Error](enum.Rfc6238Error.html) when
145    /// - `digits` is lower than 6 or higher than 8.
146    /// - `secret` is smaller than 128 bits (16 characters).
147    #[cfg(feature = "otpauth")]
148    pub fn with_defaults(secret: Vec<u8>) -> Result<Rfc6238, Rfc6238Error> {
149        Rfc6238::new(6, secret, Some("".to_string()), "".to_string())
150    }
151
152    #[cfg(not(feature = "otpauth"))]
153    pub fn with_defaults(secret: Vec<u8>) -> Result<Rfc6238, Rfc6238Error> {
154        Rfc6238::new(6, secret)
155    }
156
157    /// Set the `digits`.
158    pub fn digits(&mut self, value: usize) -> Result<(), Rfc6238Error> {
159        assert_digits(&value)?;
160        self.digits = value;
161        Ok(())
162    }
163
164    #[cfg(feature = "otpauth")]
165    #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
166    /// Set the `issuer`.
167    pub fn issuer(&mut self, value: String) {
168        self.issuer = Some(value);
169    }
170
171    #[cfg(feature = "otpauth")]
172    #[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
173    /// Set the `account_name`.
174    pub fn account_name(&mut self, value: String) {
175        self.account_name = value;
176    }
177}
178
179#[cfg(not(feature = "otpauth"))]
180impl TryFrom<Rfc6238> for TOTP {
181    type Error = TotpUrlError;
182
183    /// Try to create a [TOTP](struct.TOTP.html) from a [Rfc6238](struct.Rfc6238.html) config.
184    fn try_from(rfc: Rfc6238) -> Result<Self, Self::Error> {
185        TOTP::new(rfc.algorithm, rfc.digits, rfc.skew, rfc.step, rfc.secret)
186    }
187}
188
189#[cfg(feature = "otpauth")]
190impl TryFrom<Rfc6238> for TOTP {
191    type Error = TotpUrlError;
192
193    /// Try to create a [TOTP](struct.TOTP.html) from a [Rfc6238](struct.Rfc6238.html) config.
194    fn try_from(rfc: Rfc6238) -> Result<Self, Self::Error> {
195        TOTP::new(
196            rfc.algorithm,
197            rfc.digits,
198            rfc.skew,
199            rfc.step,
200            rfc.secret,
201            rfc.issuer,
202            rfc.account_name,
203        )
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    #[cfg(feature = "otpauth")]
210    use crate::TotpUrlError;
211
212    use super::{Rfc6238, TOTP};
213
214    #[cfg(not(feature = "otpauth"))]
215    use super::Rfc6238Error;
216
217    #[cfg(not(feature = "otpauth"))]
218    use crate::Secret;
219
220    const GOOD_SECRET: &str = "01234567890123456789";
221    #[cfg(feature = "otpauth")]
222    const ISSUER: Option<&str> = None;
223    #[cfg(feature = "otpauth")]
224    const ACCOUNT: &str = "valid-account";
225    #[cfg(feature = "otpauth")]
226    const INVALID_ACCOUNT: &str = ":invalid-account";
227
228    #[test]
229    #[cfg(not(feature = "otpauth"))]
230    fn new_rfc_digits() {
231        for x in 0..=20 {
232            let rfc = Rfc6238::new(x, GOOD_SECRET.into());
233            if !(6..=8).contains(&x) {
234                assert!(rfc.is_err());
235                assert!(matches!(rfc.unwrap_err(), Rfc6238Error::InvalidDigits(_)));
236            } else {
237                assert!(rfc.is_ok());
238            }
239        }
240    }
241
242    #[test]
243    #[cfg(not(feature = "otpauth"))]
244    fn new_rfc_secret() {
245        let mut secret = String::from("");
246        for _ in 0..=20 {
247            secret = format!("{}{}", secret, "0");
248            let rfc = Rfc6238::new(6, secret.as_bytes().to_vec());
249            let rfc_default = Rfc6238::with_defaults(secret.as_bytes().to_vec());
250            if secret.len() < 16 {
251                assert!(rfc.is_err());
252                assert!(matches!(rfc.unwrap_err(), Rfc6238Error::SecretTooSmall(_)));
253                assert!(rfc_default.is_err());
254                assert!(matches!(
255                    rfc_default.unwrap_err(),
256                    Rfc6238Error::SecretTooSmall(_)
257                ));
258            } else {
259                assert!(rfc.is_ok());
260                assert!(rfc_default.is_ok());
261            }
262        }
263    }
264
265    #[test]
266    #[cfg(not(feature = "otpauth"))]
267    fn rfc_to_totp_ok() {
268        let rfc = Rfc6238::new(8, GOOD_SECRET.into()).unwrap();
269        let totp = TOTP::try_from(rfc);
270        assert!(totp.is_ok());
271        let otp = totp.unwrap();
272        assert_eq!(&otp.secret, GOOD_SECRET.as_bytes());
273        assert_eq!(otp.algorithm, crate::Algorithm::SHA1);
274        assert_eq!(otp.digits, 8);
275        assert_eq!(otp.skew, 1);
276        assert_eq!(otp.step, 30)
277    }
278
279    #[test]
280    #[cfg(not(feature = "otpauth"))]
281    fn rfc_to_totp_ok_2() {
282        let rfc = Rfc6238::with_defaults(
283            Secret::Encoded("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ".to_string())
284                .to_bytes()
285                .unwrap(),
286        )
287        .unwrap();
288        let totp = TOTP::try_from(rfc);
289        assert!(totp.is_ok());
290        let otp = totp.unwrap();
291        assert_eq!(otp.algorithm, crate::Algorithm::SHA1);
292        assert_eq!(otp.digits, 6);
293        assert_eq!(otp.skew, 1);
294        assert_eq!(otp.step, 30)
295    }
296
297    #[test]
298    #[cfg(feature = "otpauth")]
299    fn rfc_to_totp_fail() {
300        let rfc = Rfc6238::new(
301            8,
302            GOOD_SECRET.as_bytes().to_vec(),
303            ISSUER.map(str::to_string),
304            INVALID_ACCOUNT.to_string(),
305        )
306        .unwrap();
307        let totp = TOTP::try_from(rfc);
308        assert!(totp.is_err());
309        assert!(matches!(totp.unwrap_err(), TotpUrlError::AccountName(_)))
310    }
311
312    #[test]
313    #[cfg(feature = "otpauth")]
314    fn rfc_to_totp_ok() {
315        let rfc = Rfc6238::new(
316            8,
317            GOOD_SECRET.as_bytes().to_vec(),
318            ISSUER.map(str::to_string),
319            ACCOUNT.to_string(),
320        )
321        .unwrap();
322        let totp = TOTP::try_from(rfc);
323        assert!(totp.is_ok());
324    }
325
326    #[test]
327    #[cfg(feature = "otpauth")]
328    fn rfc_with_default_set_values() {
329        let mut rfc = Rfc6238::with_defaults(GOOD_SECRET.as_bytes().to_vec()).unwrap();
330        let ok = rfc.digits(8);
331        assert!(ok.is_ok());
332        assert_eq!(rfc.account_name, "");
333        assert_eq!(rfc.issuer, Some("".to_string()));
334        rfc.issuer("Github".to_string());
335        rfc.account_name("constantoine".to_string());
336        assert_eq!(rfc.account_name, "constantoine");
337        assert_eq!(rfc.issuer, Some("Github".to_string()));
338        assert_eq!(rfc.digits, 8)
339    }
340
341    #[test]
342    #[cfg(not(feature = "otpauth"))]
343    fn rfc_with_default_set_values() {
344        let mut rfc = Rfc6238::with_defaults(GOOD_SECRET.as_bytes().to_vec()).unwrap();
345        let fail = rfc.digits(4);
346        assert!(fail.is_err());
347        assert!(matches!(fail.unwrap_err(), Rfc6238Error::InvalidDigits(_)));
348        assert_eq!(rfc.digits, 6);
349        let ok = rfc.digits(8);
350        assert!(ok.is_ok());
351        assert_eq!(rfc.digits, 8)
352    }
353
354    #[test]
355    #[cfg(not(feature = "otpauth"))]
356    fn digits_error() {
357        let error = crate::Rfc6238Error::InvalidDigits(9);
358        assert_eq!(
359            error.to_string(),
360            "Implementations MUST extract a 6-digit code at a minimum and possibly 7 and 8-digit code. 9 digits is not allowed".to_string()
361        )
362    }
363
364    #[test]
365    #[cfg(not(feature = "otpauth"))]
366    fn secret_length_error() {
367        let error = Rfc6238Error::SecretTooSmall(120);
368        assert_eq!(
369            error.to_string(),
370            "The length of the shared secret MUST be at least 128 bits. 120 bits is not enough"
371                .to_string()
372        )
373    }
374}