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