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 pub code: String,
54 pub period: u32,
56}
57
58pub 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
85pub 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 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 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#[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 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 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 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
322fn 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
339fn 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
349fn 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"), ("wqiq25brkzycjvyp", "194506"), ("PIUDISEQYA", "829846"), ("PIUDISEQYA======", "829846"), ("PIUD1IS!EQYA=", "829846"), ("steam://HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", "7W6CJ"),
403 ("StEam://HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", "7W6CJ"),
404 ("steam://ABCD123", "N26DF"),
405 ("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 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 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 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 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 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 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 assert!(!uri.contains("Acme:Inc"));
568 assert!(!uri.contains("test:account"));
569
570 assert!(uri.contains("AcmeInc"));
572 assert!(uri.contains("testaccount"));
573
574 let parsed = Totp::from_str(&uri).unwrap();
575 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}