Skip to main content

bitwarden_vault/
totp.rs

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