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