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