Skip to main content

bitwarden_vault/
totp.rs

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