kanidmd_lib/credential/
totp.rs

1use crypto_glue::{
2    hmac_s1::{HmacSha1, HmacSha1Key},
3    hmac_s256::{HmacSha256, HmacSha256Key},
4    hmac_s512::{HmacSha512, HmacSha512Key},
5    traits::Mac,
6};
7use kanidm_proto::internal::{TotpAlgo as ProtoTotpAlgo, TotpSecret as ProtoTotp};
8use rand::RngExt;
9use std::convert::{TryFrom, TryInto};
10use std::time::{Duration, SystemTime};
11
12use crate::be::dbvalue::{DbTotpAlgoV1, DbTotpV1};
13
14// This is the same size as an AES256KEY making it infeasible to bruteforce.
15const SECRET_SIZE_BYTES: usize = 32;
16pub const TOTP_DEFAULT_STEP: u64 = 30;
17
18#[derive(Debug, PartialEq, Eq)]
19pub enum TotpError {
20    InvalidKeyError,
21    HmacError,
22    TimeError,
23}
24
25#[repr(u32)]
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum TotpDigits {
28    Six = 1_000_000,
29    Eight = 100_000_000,
30}
31
32impl TryFrom<u8> for TotpDigits {
33    type Error = ();
34
35    fn try_from(value: u8) -> Result<Self, Self::Error> {
36        match value {
37            6 => Ok(TotpDigits::Six),
38            8 => Ok(TotpDigits::Eight),
39            _ => Err(()),
40        }
41    }
42}
43
44#[allow(clippy::from_over_into)]
45impl Into<u8> for TotpDigits {
46    fn into(self) -> u8 {
47        match self {
48            TotpDigits::Six => 6,
49            TotpDigits::Eight => 8,
50        }
51    }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Copy)]
55pub enum TotpAlgo {
56    Sha1,
57    Sha256,
58    Sha512,
59}
60
61impl TotpAlgo {
62    pub(crate) fn digest(self, key_bytes: &[u8], counter: u64) -> Result<Vec<u8>, TotpError> {
63        let hmac = match self {
64            TotpAlgo::Sha1 => {
65                let mut key = HmacSha1Key::default();
66
67                if key_bytes.len() > key.as_slice().len() {
68                    return Err(TotpError::InvalidKeyError);
69                }
70
71                #[allow(clippy::indexing_slicing)]
72                let key_ref = &mut key.as_mut_slice()[..key_bytes.len()];
73                key_ref.copy_from_slice(key_bytes);
74
75                let mut hmac = HmacSha1::new(&key);
76                hmac.update(&counter.to_be_bytes());
77                hmac.finalize().into_bytes().to_vec()
78            }
79            TotpAlgo::Sha256 => {
80                let mut key = HmacSha256Key::default();
81
82                if key_bytes.len() > key.as_slice().len() {
83                    return Err(TotpError::InvalidKeyError);
84                }
85
86                #[allow(clippy::indexing_slicing)]
87                let key_ref = &mut key.as_mut_slice()[..key_bytes.len()];
88                key_ref.copy_from_slice(key_bytes);
89
90                let mut hmac = HmacSha256::new(&key);
91                hmac.update(&counter.to_be_bytes());
92                hmac.finalize().into_bytes().to_vec()
93            }
94            TotpAlgo::Sha512 => {
95                let mut key = HmacSha512Key::default();
96
97                if key_bytes.len() > key.as_slice().len() {
98                    return Err(TotpError::InvalidKeyError);
99                }
100
101                #[allow(clippy::indexing_slicing)]
102                let key_ref = &mut key.as_mut_slice()[..key_bytes.len()];
103                key_ref.copy_from_slice(key_bytes);
104
105                let mut hmac = HmacSha512::new(&key);
106                hmac.update(&counter.to_be_bytes());
107                hmac.finalize().into_bytes().to_vec()
108            }
109        };
110
111        Ok(hmac)
112    }
113}
114
115/// <https://tools.ietf.org/html/rfc6238> which relies on <https://tools.ietf.org/html/rfc4226>
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct Totp {
118    secret: Vec<u8>,
119    pub(crate) step: u64,
120    algo: TotpAlgo,
121    digits: TotpDigits,
122}
123
124impl TryFrom<DbTotpV1> for Totp {
125    type Error = ();
126
127    fn try_from(value: DbTotpV1) -> Result<Self, Self::Error> {
128        let algo = match value.algo {
129            DbTotpAlgoV1::S1 => TotpAlgo::Sha1,
130            DbTotpAlgoV1::S256 => TotpAlgo::Sha256,
131            DbTotpAlgoV1::S512 => TotpAlgo::Sha512,
132        };
133        // Default.
134        let digits = TotpDigits::try_from(value.digits.unwrap_or(6))?;
135
136        Ok(Totp {
137            secret: value.key,
138            step: value.step,
139            algo,
140            digits,
141        })
142    }
143}
144
145impl TryFrom<ProtoTotp> for Totp {
146    type Error = ();
147
148    fn try_from(value: ProtoTotp) -> Result<Self, Self::Error> {
149        Ok(Totp {
150            secret: value.secret,
151            algo: match value.algo {
152                ProtoTotpAlgo::Sha1 => TotpAlgo::Sha1,
153                ProtoTotpAlgo::Sha256 => TotpAlgo::Sha256,
154                ProtoTotpAlgo::Sha512 => TotpAlgo::Sha512,
155            },
156            step: value.step,
157            digits: TotpDigits::try_from(value.digits)?,
158        })
159    }
160}
161
162impl Totp {
163    pub fn new(secret: Vec<u8>, step: u64, algo: TotpAlgo, digits: TotpDigits) -> Self {
164        Totp {
165            secret,
166            step,
167            algo,
168            digits,
169        }
170    }
171
172    // Create a new token with secure key and algo.
173    pub fn generate_secure(step: u64) -> Self {
174        let mut rng = rand::rng();
175        let secret: Vec<u8> = (0..SECRET_SIZE_BYTES).map(|_| rng.random()).collect();
176        let algo = TotpAlgo::Sha256;
177        let digits = TotpDigits::Six;
178        Totp {
179            secret,
180            step,
181            algo,
182            digits,
183        }
184    }
185
186    pub(crate) fn to_dbtotpv1(&self) -> DbTotpV1 {
187        DbTotpV1 {
188            label: "totp".to_string(),
189            key: self.secret.clone(),
190            step: self.step,
191            algo: match self.algo {
192                TotpAlgo::Sha1 => DbTotpAlgoV1::S1,
193                TotpAlgo::Sha256 => DbTotpAlgoV1::S256,
194                TotpAlgo::Sha512 => DbTotpAlgoV1::S512,
195            },
196            digits: Some(self.digits.into()),
197        }
198    }
199
200    fn digest(&self, counter: u64) -> Result<u32, TotpError> {
201        let hmac = self.algo.digest(&self.secret, counter)?;
202        // Now take the hmac and encode it as hotp expects.
203        // https://tools.ietf.org/html/rfc4226#page-7
204        let offset = hmac
205            .last()
206            .map(|v| (v & 0xf) as usize)
207            .ok_or(TotpError::HmacError)?;
208
209        // This is based on "dynamic truncation" where the offset into
210        // the hmac is dynamic based on the last byte of the hmac output.
211        // Since the array is u8, and we & with 0x0F, the value of offset
212        // must be in the range 0 to 15. All hmac outputs are 20 bytes
213        // or greater, so 15 + 4 == 19 as the upper bound will always
214        // be within the bounds of the hmac array.
215        // As a result, this is safe to slice.
216        #[allow(clippy::indexing_slicing)]
217        let bytes: [u8; 4] = hmac[offset..offset + 4]
218            .try_into()
219            .map_err(|_| TotpError::HmacError)?;
220
221        let otp = u32::from_be_bytes(bytes);
222        // Treat as a u31, this masks the first bit.
223        // then modulo based on the number of digits requested.
224        // * For 6 digits modulo 1_000_000
225        // * For 8 digits modulo 100_000_000
226        // Based on this 9 is max digits.
227        Ok((otp & 0x7fff_ffff) % (self.digits as u32))
228    }
229
230    pub fn do_totp_duration_from_epoch(&self, time: &Duration) -> Result<u32, TotpError> {
231        let secs = time.as_secs();
232        // do the window calculation
233        let counter = secs / self.step;
234        self.digest(counter)
235    }
236
237    pub fn do_totp(&self, time: &SystemTime) -> Result<u32, TotpError> {
238        let dur = time
239            .duration_since(SystemTime::UNIX_EPOCH)
240            .map_err(|_| TotpError::TimeError)?;
241        self.do_totp_duration_from_epoch(&dur)
242    }
243
244    pub fn verify(&self, chal: u32, time: Duration) -> bool {
245        let secs = time.as_secs();
246        let counter = secs / self.step;
247        // Any error becomes a failure.
248        self.digest(counter).map(|v1| v1 == chal).unwrap_or(false)
249            || self
250                .digest(counter - 1)
251                .map(|v2| v2 == chal)
252                .unwrap_or(false)
253    }
254
255    pub fn to_proto(&self, accountname: &str, issuer: &str) -> ProtoTotp {
256        ProtoTotp {
257            accountname: accountname.to_string(),
258            issuer: issuer.to_string(),
259            secret: self.secret.clone(),
260            step: self.step,
261            algo: match self.algo {
262                TotpAlgo::Sha1 => ProtoTotpAlgo::Sha1,
263                TotpAlgo::Sha256 => ProtoTotpAlgo::Sha256,
264                TotpAlgo::Sha512 => ProtoTotpAlgo::Sha512,
265            },
266            digits: self.digits.into(),
267        }
268    }
269
270    pub fn is_legacy_algo(&self) -> bool {
271        matches!(&self.algo, TotpAlgo::Sha1)
272    }
273
274    pub fn downgrade_to_legacy(self) -> Self {
275        Totp {
276            secret: self.secret,
277            step: self.step,
278            algo: TotpAlgo::Sha1,
279            digits: self.digits,
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use std::time::Duration;
287
288    use crate::credential::totp::{Totp, TotpAlgo, TotpDigits, TotpError, TOTP_DEFAULT_STEP};
289
290    #[test]
291    fn hotp_basic() {
292        let otp_sha1 = Totp::new(vec![0], 30, TotpAlgo::Sha1, TotpDigits::Six);
293        assert_eq!(otp_sha1.digest(0), Ok(328482));
294        let otp_sha256 = Totp::new(vec![0], 30, TotpAlgo::Sha256, TotpDigits::Six);
295        assert_eq!(otp_sha256.digest(0), Ok(356306));
296        let otp_sha512 = Totp::new(vec![0], 30, TotpAlgo::Sha512, TotpDigits::Six);
297        assert_eq!(otp_sha512.digest(0), Ok(674061));
298    }
299
300    fn do_test(
301        key: &[u8],
302        algo: TotpAlgo,
303        secs: u64,
304        step: u64,
305        digits: TotpDigits,
306        expect: &Result<u32, TotpError>,
307    ) {
308        let otp = Totp::new(key.to_vec(), step, algo, digits);
309        let d = Duration::from_secs(secs);
310        let r = otp.do_totp_duration_from_epoch(&d);
311        debug!(
312            "key: {:?}, algo: {:?}, time: {:?}, step: {:?}, expect: {:?} == {:?}",
313            key, algo, secs, step, expect, r
314        );
315        assert_eq!(&r, expect);
316    }
317
318    #[test]
319    fn totp_sha1_vectors() {
320        do_test(
321            &[0x00, 0x00, 0x00, 0x00],
322            TotpAlgo::Sha1,
323            1585368920,
324            TOTP_DEFAULT_STEP,
325            TotpDigits::Six,
326            &Ok(728926),
327        );
328        do_test(
329            &[0x00, 0x00, 0x00, 0x00],
330            TotpAlgo::Sha1,
331            1585368920,
332            TOTP_DEFAULT_STEP,
333            TotpDigits::Eight,
334            &Ok(74728926),
335        );
336        do_test(
337            &[0x00, 0xaa, 0xbb, 0xcc],
338            TotpAlgo::Sha1,
339            1585369498,
340            TOTP_DEFAULT_STEP,
341            TotpDigits::Six,
342            &Ok(985074),
343        );
344    }
345
346    #[test]
347    fn totp_sha256_vectors() {
348        do_test(
349            &[0x00, 0x00, 0x00, 0x00],
350            TotpAlgo::Sha256,
351            1585369682,
352            TOTP_DEFAULT_STEP,
353            TotpDigits::Six,
354            &Ok(795483),
355        );
356        do_test(
357            &[0x00, 0x00, 0x00, 0x00],
358            TotpAlgo::Sha256,
359            1585369682,
360            TOTP_DEFAULT_STEP,
361            TotpDigits::Eight,
362            &Ok(11795483),
363        );
364        do_test(
365            &[0x00, 0xaa, 0xbb, 0xcc],
366            TotpAlgo::Sha256,
367            1585369689,
368            TOTP_DEFAULT_STEP,
369            TotpDigits::Six,
370            &Ok(728402),
371        );
372    }
373
374    #[test]
375    fn totp_sha512_vectors() {
376        do_test(
377            &[0x00, 0x00, 0x00, 0x00],
378            TotpAlgo::Sha512,
379            1585369775,
380            TOTP_DEFAULT_STEP,
381            TotpDigits::Six,
382            &Ok(587735),
383        );
384        do_test(
385            &[0x00, 0x00, 0x00, 0x00],
386            TotpAlgo::Sha512,
387            1585369775,
388            TOTP_DEFAULT_STEP,
389            TotpDigits::Eight,
390            &Ok(14587735),
391        );
392        do_test(
393            &[0x00, 0xaa, 0xbb, 0xcc],
394            TotpAlgo::Sha512,
395            1585369780,
396            TOTP_DEFAULT_STEP,
397            TotpDigits::Six,
398            &Ok(952181),
399        );
400    }
401
402    #[test]
403    fn totp_allow_one_previous() {
404        let key = vec![0x00, 0xaa, 0xbb, 0xcc];
405        let secs = 1585369780;
406        let otp = Totp::new(key, TOTP_DEFAULT_STEP, TotpAlgo::Sha512, TotpDigits::Six);
407        let d = Duration::from_secs(secs);
408        // Step
409        assert!(otp.verify(952181, d));
410        // Step - 1
411        assert!(otp.verify(685469, d));
412        // This is step - 2
413        assert!(!otp.verify(217213, d));
414        // This is step + 1
415        assert!(!otp.verify(972806, d));
416    }
417}