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