Skip to main content

totp_rs/
algorithm.rs

1use core::fmt;
2use core::str::FromStr;
3use hmac::Mac;
4
5#[cfg(feature = "alloc")]
6use alloc::string::String;
7
8type HmacSha1 = hmac::Hmac<sha1::Sha1>;
9type HmacSha256 = hmac::Hmac<sha2::Sha256>;
10type HmacSha512 = hmac::Hmac<sha2::Sha512>;
11
12/// Algorithm enum holds the three standards algorithms for TOTP as per the [reference implementation](https://tools.ietf.org/html/rfc6238#appendix-A)
13#[derive(Default, Debug, Copy, Clone, Eq, PartialEq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[cfg_attr(
16    all(feature = "serde", feature = "alloc"),
17    serde(try_from = "String", into = "String")
18)]
19#[non_exhaustive]
20pub enum Algorithm {
21    /// HMAC-SHA1 is the default algorithm of most TOTP implementations.
22    /// Some will outright silently ignore the algorithm parameter to force using SHA1, leading to confusion.
23    #[default]
24    SHA1,
25    /// HMAC-SHA256. Supported in theory according to [yubico](https://docs.yubico.com/yesdk/users-manual/application-oath/uri-string-format.html).
26    /// Ignored in practice by most.
27    SHA256,
28    /// HMAC-SHA512. Supported in theory according to [yubico](https://docs.yubico.com/yesdk/users-manual/application-oath/uri-string-format.html).
29    /// Ignored in practice by most.
30    SHA512,
31    #[cfg(feature = "steam")]
32    #[cfg_attr(docsrs, doc(cfg(feature = "steam")))]
33    #[cfg_attr(feature = "serde", serde(rename = "STEAM"))]
34    /// Steam TOTP token algorithm.
35    Steam,
36}
37
38impl Algorithm {
39    /// Returns a name for this algorithm.
40    pub const fn as_str(&self) -> &str {
41        match self {
42            Algorithm::SHA1 => "SHA1",
43            Algorithm::SHA256 => "SHA256",
44            Algorithm::SHA512 => "SHA512",
45            #[cfg(feature = "steam")]
46            Algorithm::Steam => "STEAM",
47        }
48    }
49}
50
51impl fmt::Display for Algorithm {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        f.write_str(self.as_str())
54    }
55}
56
57#[cfg(feature = "alloc")]
58#[cfg_attr(docsrs, doc(cfg(feature = "alloc")))]
59impl From<Algorithm> for String {
60    fn from(value: Algorithm) -> Self {
61        value.as_str().into()
62    }
63}
64
65#[cfg(feature = "alloc")]
66#[cfg_attr(docsrs, doc(cfg(feature = "alloc")))]
67impl TryFrom<String> for Algorithm {
68    type Error = UnsupportedAlgorithm;
69
70    fn try_from(value: String) -> Result<Self, Self::Error> {
71        Self::from_str(&value)
72    }
73}
74
75impl FromStr for Algorithm {
76    type Err = UnsupportedAlgorithm;
77
78    fn from_str(s: &str) -> Result<Self, Self::Err> {
79        if s.eq_ignore_ascii_case("SHA1") {
80            Ok(Self::SHA1)
81        } else if s.eq_ignore_ascii_case("SHA256") {
82            Ok(Self::SHA256)
83        } else if s.eq_ignore_ascii_case("SHA512") {
84            Ok(Self::SHA512)
85        } else {
86            #[cfg(feature = "steam")]
87            if s.eq_ignore_ascii_case("STEAM") {
88                return Ok(Self::Steam);
89            }
90
91            Err(UnsupportedAlgorithm {
92                #[cfg(feature = "alloc")]
93                algorithm: s.into(),
94            })
95        }
96    }
97}
98
99impl Algorithm {
100    fn hash<D>(key: &[u8], counter: u64) -> hmac::digest::Output<D>
101    where
102        D: Mac + hmac::digest::KeyInit,
103    {
104        let mut digest = D::new_from_slice(key).unwrap();
105        let data = counter.to_be_bytes();
106        digest.update(&data);
107        digest.finalize().into_bytes()
108    }
109
110    pub(crate) fn sign(&self, key: &[u8], counter: u64) -> impl AsRef<[u8]> {
111        match self {
112            Algorithm::SHA1 => Signature::SHA1(Algorithm::hash::<HmacSha1>(key, counter)),
113            Algorithm::SHA256 => Signature::SHA256(Algorithm::hash::<HmacSha256>(key, counter)),
114            Algorithm::SHA512 => Signature::SHA512(Algorithm::hash::<HmacSha512>(key, counter)),
115            #[cfg(feature = "steam")]
116            Algorithm::Steam => Signature::SHA1(Algorithm::hash::<HmacSha1>(key, counter)),
117        }
118    }
119}
120
121enum Signature {
122    SHA1(hmac::digest::Output<HmacSha1>),
123    SHA256(hmac::digest::Output<HmacSha256>),
124    SHA512(hmac::digest::Output<HmacSha512>),
125}
126
127impl AsRef<[u8]> for Signature {
128    fn as_ref(&self) -> &[u8] {
129        match self {
130            Signature::SHA1(inner) => inner.as_ref(),
131            Signature::SHA256(inner) => inner.as_ref(),
132            Signature::SHA512(inner) => inner.as_ref(),
133        }
134    }
135}
136
137#[derive(PartialEq, Eq)]
138#[non_exhaustive]
139pub struct UnsupportedAlgorithm {
140    #[cfg(feature = "alloc")]
141    algorithm: String,
142}
143
144impl core::fmt::Debug for UnsupportedAlgorithm {
145    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
146        <Self as core::fmt::Display>::fmt(self, f)
147    }
148}
149
150impl core::fmt::Display for UnsupportedAlgorithm {
151    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
152        f.write_str("Unsupported Algorithm")?;
153
154        #[cfg(feature = "alloc")]
155        write!(f, ": {}", self.algorithm)?;
156
157        Ok(())
158    }
159}
160
161impl core::error::Error for UnsupportedAlgorithm {}
162
163#[cfg(test)]
164mod tests {
165    use core::str::FromStr;
166
167    use super::{Algorithm, UnsupportedAlgorithm};
168
169    /// We exhaustively test against all algorithms.
170    const ALL_ALGORITHMS: &[Algorithm] = &[
171        Algorithm::SHA1,
172        Algorithm::SHA256,
173        Algorithm::SHA512,
174        #[cfg(feature = "steam")]
175        Algorithm::Steam,
176    ];
177
178    #[test]
179    fn from_str_unsupported() {
180        let algorithm = Algorithm::from_str("not a real algorithm");
181        assert!(matches!(algorithm, Err(UnsupportedAlgorithm { .. })));
182        let error = algorithm.unwrap_err();
183        assert!(format!("{:?}", error).starts_with("Unsupported Algorithm"));
184    }
185
186    #[cfg(feature = "alloc")]
187    #[test]
188    fn to_string_round_trip() {
189        for &alg in ALL_ALGORITHMS {
190            let to_string = String::from(alg);
191            let from_string = Algorithm::try_from(to_string);
192            assert_eq!(from_string, Ok(alg));
193        }
194    }
195
196    /// `Steam` is spelled `STEAM` everywhere the algorithm is named. v5 rendered
197    /// it as `SHA1`; only `Totp::to_url` still does, for wire compatibility.
198    #[cfg(feature = "steam")]
199    #[test]
200    fn steam_is_spelled_steam() {
201        assert_eq!(Algorithm::Steam.as_str(), "STEAM");
202        assert_eq!(Algorithm::from_str("STEAM"), Ok(Algorithm::Steam));
203        assert_eq!(Algorithm::from_str("steam"), Ok(Algorithm::Steam));
204    }
205
206    #[cfg(feature = "serde")]
207    #[test]
208    fn serde_repr_is_as_str() {
209        for alg in ALL_ALGORITHMS {
210            let name = alg.as_str();
211
212            #[cfg(feature = "alloc")]
213            serde_test::assert_tokens(alg, &[serde_test::Token::Str(name)]);
214
215            #[cfg(not(feature = "alloc"))]
216            serde_test::assert_tokens(
217                alg,
218                &[serde_test::Token::UnitVariant {
219                    name: "Algorithm",
220                    variant: name,
221                }],
222            );
223        }
224    }
225
226    #[cfg(all(feature = "serde", feature = "steam"))]
227    #[test]
228    fn serde_deserialize_case_sensitivity() {
229        #[cfg(feature = "alloc")]
230        serde_test::assert_de_tokens(&Algorithm::Steam, &[serde_test::Token::Str("steam")]);
231
232        #[cfg(not(feature = "alloc"))]
233        serde_test::assert_de_tokens_error::<Algorithm>(
234            &[serde_test::Token::UnitVariant {
235                name: "Algorithm",
236                variant: "steam",
237            }],
238            "unknown variant `steam`, expected one of `SHA1`, `SHA256`, `SHA512`, `STEAM`",
239        );
240    }
241
242    #[test]
243    fn signing_test() {
244        for &alg in ALL_ALGORITHMS {
245            let key = "TestSecretSuperSecret".as_bytes();
246            let data = 123456;
247
248            let signature = alg.sign(key, data);
249
250            assert!(!signature.as_ref().is_empty());
251        }
252    }
253}