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