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