bitwarden_vault/
totp.rs

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