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