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