Skip to main content

bitwarden_vault/
totp.rs

1use std::{
2    collections::HashMap,
3    fmt::{self},
4    str::FromStr,
5};
6
7use bitwarden_crypto::CryptoError;
8use bitwarden_error::bitwarden_error;
9use chrono::{DateTime, Utc};
10use data_encoding::BASE32_NOPAD;
11use hmac::{Hmac, KeyInit, Mac};
12use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, percent_encode};
13use reqwest::Url;
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17use crate::CipherListView;
18
19type HmacSha1 = Hmac<sha1::Sha1>;
20type HmacSha256 = Hmac<sha2::Sha256>;
21type HmacSha512 = Hmac<sha2::Sha512>;
22
23const BASE32_CHARS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
24const STEAM_CHARS: &str = "23456789BCDFGHJKMNPQRTVWXY";
25
26const DEFAULT_ALGORITHM: TotpAlgorithm = TotpAlgorithm::Sha1;
27const DEFAULT_DIGITS: u32 = 6;
28const DEFAULT_PERIOD: u32 = 30;
29
30#[allow(missing_docs)]
31#[bitwarden_error(flat)]
32#[derive(Debug, Error)]
33pub enum TotpError {
34    #[error("Invalid otpauth")]
35    InvalidOtpauth,
36    #[error("Missing secret")]
37    MissingSecret,
38
39    #[error(transparent)]
40    Crypto(#[from] CryptoError),
41}
42
43#[allow(missing_docs)]
44#[derive(Serialize, Deserialize, Debug)]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
47#[bitwarden_ffi::wasm_record]
48pub struct TotpResponse {
49    /// Generated TOTP code
50    pub code: String,
51    /// Time period
52    pub period: u32,
53}
54
55/// Generate a OATH or RFC 6238 TOTP code from a provided key.
56///
57/// <https://datatracker.ietf.org/doc/html/rfc6238>
58///
59/// Key can be either:
60/// - A base32 encoded string
61/// - OTP Auth URI
62/// - Steam URI
63///
64/// Supports providing an optional time, and defaults to current system time if none is provided.
65///
66/// Arguments:
67/// - `key` - The key to generate the TOTP code from
68/// - `time` - The time in UTC to generate the TOTP code for, defaults to current system time
69pub fn generate_totp(key: String, time: Option<DateTime<Utc>>) -> Result<TotpResponse, TotpError> {
70    let params: Totp = key.parse()?;
71
72    let time = time.unwrap_or_else(Utc::now);
73
74    let otp = params.derive_otp(time.timestamp());
75
76    Ok(TotpResponse {
77        code: otp,
78        period: params.period,
79    })
80}
81
82/// Generate a OATH or RFC 6238 TOTP code from a provided CipherListView.
83///
84/// See [generate_totp] for more information.
85pub fn generate_totp_cipher_view(
86    view: CipherListView,
87    time: Option<DateTime<Utc>>,
88) -> Result<TotpResponse, TotpError> {
89    let key = view
90        .get_totp_key()?
91        .filter(|s| !s.is_empty())
92        .ok_or(TotpError::MissingSecret)?;
93
94    generate_totp(key, time)
95}
96
97#[allow(missing_docs)]
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub enum TotpAlgorithm {
100    Sha1,
101    Sha256,
102    Sha512,
103    Steam,
104}
105
106impl TotpAlgorithm {
107    // Derive the HMAC hash for the given algorithm
108    fn derive_hash(&self, key: &[u8], time: &[u8]) -> Vec<u8> {
109        fn compute_digest<D: Mac>(digest: D, time: &[u8]) -> Vec<u8> {
110            digest.chain_update(time).finalize().into_bytes().to_vec()
111        }
112
113        match self {
114            TotpAlgorithm::Sha1 => compute_digest(
115                HmacSha1::new_from_slice(key).expect("hmac new_from_slice should not fail"),
116                time,
117            ),
118            TotpAlgorithm::Sha256 => compute_digest(
119                HmacSha256::new_from_slice(key).expect("hmac new_from_slice should not fail"),
120                time,
121            ),
122            TotpAlgorithm::Sha512 => compute_digest(
123                HmacSha512::new_from_slice(key).expect("hmac new_from_slice should not fail"),
124                time,
125            ),
126            TotpAlgorithm::Steam => compute_digest(
127                HmacSha1::new_from_slice(key).expect("hmac new_from_slice should not fail"),
128                time,
129            ),
130        }
131    }
132}
133
134impl fmt::Display for TotpAlgorithm {
135    /// Display the algorithm as a string
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        f.write_str(match self {
138            TotpAlgorithm::Sha1 => "SHA1",
139            TotpAlgorithm::Sha256 => "SHA256",
140            TotpAlgorithm::Sha512 => "SHA512",
141            TotpAlgorithm::Steam => "SHA1",
142        })
143    }
144}
145
146/// TOTP representation broken down into its components.
147///
148/// Should generally be considered internal to the bitwarden-vault crate. Consumers should use one
149/// of the generate functions if they want to generate a TOTP code. Credential Exchange requires
150/// access to the individual components.
151#[allow(missing_docs)]
152#[derive(Debug)]
153pub struct Totp {
154    pub account: Option<String>,
155    pub algorithm: TotpAlgorithm,
156    pub digits: u32,
157    pub issuer: Option<String>,
158    pub period: u32,
159    pub secret: Vec<u8>,
160}
161
162impl Totp {
163    fn derive_otp(&self, time: i64) -> String {
164        let time = time / self.period as i64;
165
166        let hash = self
167            .algorithm
168            .derive_hash(&self.secret, time.to_be_bytes().as_ref());
169        let binary = derive_binary(hash);
170
171        if let TotpAlgorithm::Steam = self.algorithm {
172            derive_steam_otp(binary, self.digits)
173        } else {
174            let otp = binary % 10_u32.pow(self.digits);
175            format!("{1:00$}", self.digits as usize, otp)
176        }
177    }
178}
179
180impl FromStr for Totp {
181    type Err = TotpError;
182
183    /// Parses the provided key and returns the corresponding `Totp`.
184    ///
185    /// Key can be either:
186    /// - A base32 encoded string
187    /// - OTP Auth URI
188    /// - Steam URI
189    fn from_str(key: &str) -> Result<Self, Self::Err> {
190        let key = key.to_lowercase();
191
192        let params = if key.starts_with("otpauth://") {
193            let url = Url::parse(&key).map_err(|_| TotpError::InvalidOtpauth)?;
194            let decoded_path = percent_decode_str(url.path()).decode_utf8_lossy();
195            let label = decoded_path.strip_prefix("/");
196            let (issuer, account) = match label.and_then(|v| v.split_once(':')) {
197                Some((issuer, account)) => (Some(issuer.trim()), Some(account.trim())),
198                None => (None, label),
199            };
200
201            let parts: HashMap<_, _> = url.query_pairs().collect();
202
203            Totp {
204                account: account.map(|s| s.to_string()),
205                algorithm: parts
206                    .get("algorithm")
207                    .and_then(|v| match v.as_ref() {
208                        "sha1" => Some(TotpAlgorithm::Sha1),
209                        "sha256" => Some(TotpAlgorithm::Sha256),
210                        "sha512" => Some(TotpAlgorithm::Sha512),
211                        _ => None,
212                    })
213                    .unwrap_or(DEFAULT_ALGORITHM),
214                digits: parts
215                    .get("digits")
216                    .and_then(|v| v.parse().ok())
217                    .map(|v: u32| v.clamp(0, 10))
218                    .unwrap_or(DEFAULT_DIGITS),
219                issuer: parts
220                    .get("issuer")
221                    .map(|v| v.to_string())
222                    .or(issuer.map(|s| s.to_string())),
223                period: parts
224                    .get("period")
225                    .and_then(|v| v.parse().ok())
226                    .map(|v: u32| v.max(1))
227                    .unwrap_or(DEFAULT_PERIOD),
228                secret: decode_b32(
229                    &parts
230                        .get("secret")
231                        .map(|v| v.to_string())
232                        .ok_or(TotpError::MissingSecret)?,
233                ),
234            }
235        } else if let Some(secret) = key.strip_prefix("steam://") {
236            Totp {
237                account: None,
238                algorithm: TotpAlgorithm::Steam,
239                digits: 5,
240                issuer: None,
241                period: DEFAULT_PERIOD,
242                secret: decode_b32(secret),
243            }
244        } else {
245            Totp {
246                account: None,
247                algorithm: DEFAULT_ALGORITHM,
248                digits: DEFAULT_DIGITS,
249                issuer: None,
250                period: DEFAULT_PERIOD,
251                secret: decode_b32(&key),
252            }
253        };
254
255        Ok(params)
256    }
257}
258
259impl fmt::Display for Totp {
260    /// Formats the TOTP as an OTP Auth URI.
261    ///
262    /// Returns a steam::// URI if the algorithm is Steam.
263    /// Otherwise returns an otpauth:// URI according to the Key Uri Format Specification:
264    /// <https://docs.yubico.com/yesdk/users-manual/application-oath/uri-string-format.html>
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        let secret_b32 = BASE32_NOPAD.encode(&self.secret);
267
268        if let TotpAlgorithm::Steam = self.algorithm {
269            return write!(f, "steam://{secret_b32}");
270        }
271
272        let mut url = Url::parse("otpauth://totp").map_err(|_| fmt::Error)?;
273
274        // Strip out colons from issuer and account
275        let issuer = self.issuer.as_ref().map(|issuer| issuer.replace(":", ""));
276        let account = self
277            .account
278            .as_ref()
279            .map(|account| account.replace(":", ""));
280
281        let encoded_issuer = issuer
282            .as_ref()
283            .map(|issuer| percent_encode(issuer.as_bytes(), NON_ALPHANUMERIC));
284
285        let encoded_account = account
286            .as_ref()
287            .map(|account| percent_encode(account.as_bytes(), NON_ALPHANUMERIC));
288
289        let label = match (&encoded_issuer, &encoded_account) {
290            (Some(issuer), Some(account)) => format!("{issuer}:{account}"),
291            (None, Some(account)) => account.to_string(),
292            _ => String::new(),
293        };
294
295        url.set_path(&label);
296
297        let mut query_params = Vec::new();
298        query_params.push(format!("secret={secret_b32}"));
299
300        if let Some(issuer) = &encoded_issuer {
301            query_params.push(format!("issuer={issuer}"));
302        }
303
304        if self.period != DEFAULT_PERIOD {
305            query_params.push(format!("period={}", self.period));
306        }
307
308        if self.algorithm != DEFAULT_ALGORITHM {
309            query_params.push(format!("algorithm={}", self.algorithm));
310        }
311
312        if self.digits != DEFAULT_DIGITS {
313            query_params.push(format!("digits={}", self.digits));
314        }
315
316        url.set_query(Some(&query_params.join("&")));
317        url.fmt(f)
318    }
319}
320
321/// Derive the Steam OTP from the hash with the given number of digits.
322fn derive_steam_otp(binary: u32, digits: u32) -> String {
323    let mut full_code = binary & 0x7fffffff;
324
325    (0..digits)
326        .map(|_| {
327            let index = full_code as usize % STEAM_CHARS.len();
328            let char = STEAM_CHARS
329                .chars()
330                .nth(index)
331                .expect("Should always be within range");
332            full_code /= STEAM_CHARS.len() as u32;
333            char
334        })
335        .collect()
336}
337
338/// Derive the OTP from the hash with the given number of digits.
339fn derive_binary(hash: Vec<u8>) -> u32 {
340    let offset = (hash.last().unwrap_or(&0) & 15) as usize;
341
342    (((hash[offset] & 127) as u32) << 24)
343        | ((hash[offset + 1] as u32) << 16)
344        | ((hash[offset + 2] as u32) << 8)
345        | (hash[offset + 3] as u32)
346}
347
348/// This code is migrated from our javascript implementation and is not technically a correct base32
349/// decoder since we filter out various characters, and use exact chunking.
350fn decode_b32(s: &str) -> Vec<u8> {
351    let s = s.to_uppercase();
352
353    let mut bits = String::new();
354    for c in s.chars() {
355        if let Some(i) = BASE32_CHARS.find(c) {
356            bits.push_str(&format!("{i:05b}"));
357        }
358    }
359    let mut bytes = Vec::new();
360
361    for chunk in bits.as_bytes().chunks_exact(8) {
362        let byte_str = std::str::from_utf8(chunk).expect("The value is a valid string");
363        let byte = u8::from_str_radix(byte_str, 2).expect("The value is a valid binary string");
364        bytes.push(byte);
365    }
366
367    bytes
368}
369
370#[cfg(test)]
371mod tests {
372    use chrono::Utc;
373
374    use super::*;
375    use crate::{
376        CipherRepromptType,
377        cipher::cipher::{CipherListViewType, CopyableCipherFields},
378        login::LoginListView,
379    };
380
381    #[test]
382    fn test_decode_b32() {
383        let res = decode_b32("WQIQ25BRKZYCJVYP");
384        assert_eq!(res, vec![180, 17, 13, 116, 49, 86, 112, 36, 215, 15]);
385
386        let res = decode_b32("ABCD123");
387        assert_eq!(res, vec![0, 68, 61]);
388    }
389
390    #[test]
391    fn test_generate_totp() {
392        let cases = vec![
393            ("WQIQ25BRKZYCJVYP", "194506"), // valid base32
394            ("wqiq25brkzycjvyp", "194506"), // lowercase
395            ("PIUDISEQYA", "829846"),       // non padded
396            ("PIUDISEQYA======", "829846"), // padded
397            ("PIUD1IS!EQYA=", "829846"),    // sanitized
398            // Steam
399            ("steam://HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", "7W6CJ"),
400            ("StEam://HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", "7W6CJ"),
401            ("steam://ABCD123", "N26DF"),
402            // Various weird lengths
403            ("ddfdf", "932653"),
404            ("HJSGFJHDFDJDJKSDFD", "000034"),
405            ("xvdsfasdfasdasdghsgsdfg", "403786"),
406            ("KAKFJWOSFJ12NWL", "093430"),
407        ];
408
409        let time = Some(
410            DateTime::parse_from_rfc3339("2023-01-01T00:00:00.000Z")
411                .unwrap()
412                .with_timezone(&Utc),
413        );
414
415        for (key, expected_code) in cases {
416            let response = generate_totp(key.to_string(), time).unwrap();
417
418            assert_eq!(response.code, expected_code, "wrong code for key: {key}");
419            assert_eq!(response.period, 30);
420        }
421    }
422
423    #[test]
424    fn test_generate_otpauth() {
425        let key = "otpauth://totp/test-account?secret=WQIQ25BRKZYCJVYP".to_string();
426        let time = Some(
427            DateTime::parse_from_rfc3339("2023-01-01T00:00:00.000Z")
428                .unwrap()
429                .with_timezone(&Utc),
430        );
431        let response = generate_totp(key, time).unwrap();
432
433        assert_eq!(response.code, "194506".to_string());
434        assert_eq!(response.period, 30);
435    }
436
437    #[test]
438    fn test_generate_otpauth_no_label() {
439        let key = "otpauth://totp/?secret=WQIQ25BRKZYCJVYP";
440        let totp = Totp::from_str(key).unwrap();
441
442        assert_eq!(totp.account, Some("".to_string()));
443        assert_eq!(totp.issuer, None);
444    }
445
446    #[test]
447    fn test_generate_otpauth_uppercase() {
448        let key = "OTPauth://totp/test-account?secret=WQIQ25BRKZYCJVYP".to_string();
449        let time = Some(
450            DateTime::parse_from_rfc3339("2023-01-01T00:00:00.000Z")
451                .unwrap()
452                .with_timezone(&Utc),
453        );
454        let response = generate_totp(key, time).unwrap();
455
456        assert_eq!(response.code, "194506".to_string());
457        assert_eq!(response.period, 30);
458    }
459
460    #[test]
461    fn test_generate_otpauth_period() {
462        let key = "otpauth://totp/test-account?secret=WQIQ25BRKZYCJVYP&period=60".to_string();
463        let time = Some(
464            DateTime::parse_from_rfc3339("2023-01-01T00:00:00.000Z")
465                .unwrap()
466                .with_timezone(&Utc),
467        );
468        let response = generate_totp(key, time).unwrap();
469
470        assert_eq!(response.code, "730364".to_string());
471        assert_eq!(response.period, 60);
472    }
473
474    #[test]
475    fn test_generate_otpauth_algorithm_sha256() {
476        let key =
477            "otpauth://totp/test-account?secret=WQIQ25BRKZYCJVYP&algorithm=SHA256".to_string();
478        let time = Some(
479            DateTime::parse_from_rfc3339("2023-01-01T00:00:00.000Z")
480                .unwrap()
481                .with_timezone(&Utc),
482        );
483        let response = generate_totp(key, time).unwrap();
484
485        assert_eq!(response.code, "842615".to_string());
486        assert_eq!(response.period, 30);
487    }
488
489    #[test]
490    fn test_parse_totp_label_no_issuer() {
491        // If there is only one value in the label, it is the account
492        let key = "otpauth://totp/[email protected]?secret=WQIQ25BRKZYCJVYP";
493        let totp = Totp::from_str(key).unwrap();
494
495        assert_eq!(totp.account, Some("[email protected]".to_string()));
496        assert_eq!(totp.issuer, None);
497    }
498
499    #[test]
500    fn test_parse_totp_label_with_issuer() {
501        // If there are two values in the label, the first is the issuer, the second is the account
502        let key = "otpauth://totp/test-issuer:[email protected]?secret=WQIQ25BRKZYCJVYP";
503        let totp = Totp::from_str(key).unwrap();
504
505        assert_eq!(totp.account, Some("[email protected]".to_string()));
506        assert_eq!(totp.issuer, Some("test-issuer".to_string()));
507    }
508
509    #[test]
510    fn test_parse_totp_label_two_issuers() {
511        // If the label has an issuer and there is an issuer parameter, the parameter is chosen as
512        // the issuer
513        let key = "otpauth://totp/test-issuer:[email protected]?secret=WQIQ25BRKZYCJVYP&issuer=other-test-issuer";
514        let totp = Totp::from_str(key).unwrap();
515
516        assert_eq!(totp.account, Some("[email protected]".to_string()));
517        assert_eq!(totp.issuer, Some("other-test-issuer".to_string()));
518    }
519
520    #[test]
521    fn test_parse_totp_label_encoded_colon() {
522        // A url-encoded colon is a valid separator
523        let key = "otpauth://totp/test-issuer%[email protected]?secret=WQIQ25BRKZYCJVYP&issuer=test-issuer";
524        let totp = Totp::from_str(key).unwrap();
525
526        assert_eq!(totp.account, Some("[email protected]".to_string()));
527        assert_eq!(totp.issuer, Some("test-issuer".to_string()));
528    }
529
530    #[test]
531    fn test_parse_totp_label_encoded_characters() {
532        // The account and issuer can both be URL-encoded
533        let key = "otpauth://totp/test%20issuer:test-account%40example%2Ecom?secret=WQIQ25BRKZYCJVYP&issuer=test%20issuer";
534        let totp = Totp::from_str(key).unwrap();
535
536        assert_eq!(totp.account, Some("[email protected]".to_string()));
537        assert_eq!(totp.issuer, Some("test issuer".to_string()));
538    }
539
540    #[test]
541    fn test_parse_totp_label_account_spaces() {
542        // The account can have spaces before it
543        let key = "otpauth://totp/test-issuer:   [email protected]?secret=WQIQ25BRKZYCJVYP&issuer=test-issuer";
544        let totp = Totp::from_str(key).unwrap();
545
546        assert_eq!(totp.account, Some("[email protected]".to_string()));
547        assert_eq!(totp.issuer, Some("test-issuer".to_string()));
548    }
549
550    #[test]
551    fn test_totp_to_string_strips_colons() {
552        let totp = Totp {
553            account: Some("test:[email protected]".to_string()),
554            algorithm: DEFAULT_ALGORITHM,
555            digits: DEFAULT_DIGITS,
556            issuer: Some("Acme:Inc".to_string()),
557            period: DEFAULT_PERIOD,
558            secret: decode_b32("WQIQ25BRKZYCJVYP"),
559        };
560
561        let uri = totp.to_string();
562
563        // Verify colons are stripped from both issuer and account in the URI
564        assert!(!uri.contains("Acme:Inc"));
565        assert!(!uri.contains("test:account"));
566
567        // Verify that the stripped colons are replaced
568        assert!(uri.contains("AcmeInc"));
569        assert!(uri.contains("testaccount"));
570
571        let parsed = Totp::from_str(&uri).unwrap();
572        // Verify parsed values have colon removed
573        assert_eq!(parsed.issuer.unwrap(), "acmeinc");
574        assert_eq!(parsed.account.unwrap(), "[email protected]");
575    }
576
577    #[test]
578    fn test_totp_to_string_with_defaults() {
579        let totp = Totp {
580            account: Some("[email protected]".to_string()),
581            algorithm: DEFAULT_ALGORITHM,
582            digits: DEFAULT_DIGITS,
583            issuer: Some("Example".to_string()),
584            period: DEFAULT_PERIOD,
585            secret: decode_b32("WQIQ25BRKZYCJVYP"),
586        };
587
588        assert_eq!(
589            totp.to_string(),
590            "otpauth://totp/Example:test%40bitwarden%2Ecom?secret=WQIQ25BRKZYCJVYP&issuer=Example"
591        );
592    }
593
594    #[test]
595    fn test_totp_to_string_with_custom_period() {
596        let totp = Totp {
597            account: Some("[email protected]".to_string()),
598            algorithm: DEFAULT_ALGORITHM,
599            digits: DEFAULT_DIGITS,
600            issuer: Some("Example".to_string()),
601            period: 60,
602            secret: decode_b32("WQIQ25BRKZYCJVYP"),
603        };
604
605        assert_eq!(
606            totp.to_string(),
607            "otpauth://totp/Example:test%40bitwarden%2Ecom?secret=WQIQ25BRKZYCJVYP&issuer=Example&period=60"
608        );
609    }
610
611    #[test]
612    fn test_totp_to_string_sha256() {
613        let totp = Totp {
614            account: Some("[email protected]".to_string()),
615            algorithm: TotpAlgorithm::Sha256,
616            digits: DEFAULT_DIGITS,
617            issuer: Some("Example".to_string()),
618            period: DEFAULT_PERIOD,
619            secret: decode_b32("WQIQ25BRKZYCJVYP"),
620        };
621
622        assert_eq!(
623            totp.to_string(),
624            "otpauth://totp/Example:test%40bitwarden%2Ecom?secret=WQIQ25BRKZYCJVYP&issuer=Example&algorithm=SHA256"
625        );
626    }
627
628    #[test]
629    fn test_totp_to_string_encodes_spaces_in_issuer() {
630        let totp = Totp {
631            account: Some("[email protected]".to_string()),
632            algorithm: DEFAULT_ALGORITHM,
633            digits: DEFAULT_DIGITS,
634            issuer: Some("Acme Inc".to_string()),
635            period: DEFAULT_PERIOD,
636            secret: decode_b32("WQIQ25BRKZYCJVYP"),
637        };
638
639        assert_eq!(
640            totp.to_string(),
641            "otpauth://totp/Acme%20Inc:test%40bitwarden%2Ecom?secret=WQIQ25BRKZYCJVYP&issuer=Acme%20Inc"
642        );
643    }
644
645    #[test]
646    fn test_totp_to_string_encodes_special_characters_in_issuer() {
647        let totp = Totp {
648            account: Some("[email protected]".to_string()),
649            algorithm: DEFAULT_ALGORITHM,
650            digits: DEFAULT_DIGITS,
651            issuer: Some("Acme & Inc".to_string()),
652            period: DEFAULT_PERIOD,
653            secret: decode_b32("WQIQ25BRKZYCJVYP"),
654        };
655
656        assert_eq!(
657            totp.to_string(),
658            "otpauth://totp/Acme%20%26%20Inc:test%40bitwarden%2Ecom?secret=WQIQ25BRKZYCJVYP&issuer=Acme%20%26%20Inc"
659        );
660    }
661
662    #[test]
663    fn test_totp_to_string_no_issuer() {
664        let totp = Totp {
665            account: Some("[email protected]".to_string()),
666            algorithm: DEFAULT_ALGORITHM,
667            digits: DEFAULT_DIGITS,
668            issuer: None,
669            period: DEFAULT_PERIOD,
670            secret: decode_b32("WQIQ25BRKZYCJVYP"),
671        };
672
673        assert_eq!(
674            totp.to_string(),
675            "otpauth://totp/test%40bitwarden%2Ecom?secret=WQIQ25BRKZYCJVYP"
676        )
677    }
678
679    #[test]
680    fn test_totp_to_string_parse_roundtrip_with_special_chars() {
681        let original = Totp {
682            account: Some("[email protected]".to_string()),
683            algorithm: DEFAULT_ALGORITHM,
684            digits: DEFAULT_DIGITS,
685            issuer: Some("Acme & Inc".to_string()),
686            period: DEFAULT_PERIOD,
687            secret: decode_b32("WQIQ25BRKZYCJVYP"),
688        };
689
690        let uri = original.to_string();
691        let parsed = Totp::from_str(&uri).unwrap();
692
693        assert!(
694            parsed
695                .account
696                .unwrap()
697                .eq_ignore_ascii_case(&original.account.unwrap())
698        );
699        assert!(
700            parsed
701                .issuer
702                .unwrap()
703                .eq_ignore_ascii_case(&original.issuer.unwrap())
704        );
705        assert_eq!(parsed.algorithm, original.algorithm);
706        assert_eq!(parsed.digits, original.digits);
707        assert_eq!(parsed.period, original.period);
708        assert_eq!(parsed.secret, original.secret);
709    }
710
711    #[test]
712    fn test_display_steam() {
713        let totp = Totp {
714            account: None,
715            algorithm: TotpAlgorithm::Steam,
716            digits: 5,
717            issuer: None,
718            period: DEFAULT_PERIOD,
719            secret: vec![1, 2, 3, 4],
720        };
721        let secret_b32 = BASE32_NOPAD.encode(&totp.secret);
722        assert_eq!(totp.to_string(), format!("steam://{secret_b32}"));
723    }
724
725    #[test]
726    fn test_generate_totp_cipher_view() {
727        let view = CipherListView {
728            partial: false,
729            id: Some("090c19ea-a61a-4df6-8963-262b97bc6266".parse().unwrap()),
730            organization_id: None,
731            folder_id: None,
732            collection_ids: vec![],
733            name: "My test login".to_string(),
734            subtitle: "test_username".to_string(),
735            r#type: CipherListViewType::Login(LoginListView {
736                fido2_credentials: None,
737                has_fido2: true,
738                username: None,
739                totp: Some("DKWOW4PCP3MYFWLN53BLYAMYQEQJU4MJ".to_string()),
740                uris: None,
741            }),
742            favorite: false,
743            reprompt: CipherRepromptType::None,
744            organization_use_totp: true,
745            edit: true,
746            permissions: None,
747            view_password: true,
748            attachments: 0,
749            has_old_attachments: false,
750            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
751            deleted_date: None,
752            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
753            copyable_fields: vec![CopyableCipherFields::LoginTotp],
754            local_data: None,
755            archived_date: None,
756            #[cfg(feature = "wasm")]
757            notes: None,
758            #[cfg(feature = "wasm")]
759            fields: None,
760            #[cfg(feature = "wasm")]
761            attachment_names: None,
762        };
763
764        let time = DateTime::parse_from_rfc3339("2023-01-01T00:00:00.000Z")
765            .unwrap()
766            .with_timezone(&Utc);
767
768        let response = generate_totp_cipher_view(view, Some(time)).unwrap();
769        assert_eq!(response.code, "559388".to_string());
770        assert_eq!(response.period, 30);
771    }
772}