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 pub code: String,
53 pub period: u32,
55}
56
57pub 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
84pub 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 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 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#[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 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 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 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
323fn 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
340fn 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
350fn 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"), ("wqiq25brkzycjvyp", "194506"), ("PIUDISEQYA", "829846"), ("PIUDISEQYA======", "829846"), ("PIUD1IS!EQYA=", "829846"), ("steam://HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", "7W6CJ"),
402 ("StEam://HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", "7W6CJ"),
403 ("steam://ABCD123", "N26DF"),
404 ("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 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 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 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 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 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 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 assert!(!uri.contains("Acme:Inc"));
567 assert!(!uri.contains("test:account"));
568
569 assert!(uri.contains("AcmeInc"));
571 assert!(uri.contains("testaccount"));
572
573 let parsed = Totp::from_str(&uri).unwrap();
574 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}