1#![doc = include_str!("../README.md")]
2
3use std::fmt;
4
5use bitwarden_vault::{
6 CipherRepromptType, CipherView, Fido2CredentialFullView, FieldView, FolderId, LoginUriView,
7 UriMatchType,
8};
9use chrono::{DateTime, Utc};
10use uuid::Uuid;
11
12#[cfg(feature = "uniffi")]
13uniffi::setup_scaffolding!();
14#[cfg(feature = "uniffi")]
15mod uniffi_support;
16
17mod csv;
18mod cxf;
19pub use cxf::Account;
20mod encrypted_json;
21mod exporter_client;
22mod json;
23mod models;
24pub use exporter_client::{ExporterClient, ExporterClientExt};
25mod error;
26mod export;
27pub use error::ExportError;
28
29#[allow(missing_docs)]
30#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
31#[cfg_attr(
32 feature = "wasm",
33 derive(serde::Serialize, serde::Deserialize, tsify::Tsify),
34 tsify(into_wasm_abi, from_wasm_abi)
35)]
36pub enum ExportFormat {
37 Csv,
38 Json,
39 EncryptedJson { password: String },
40}
41
42#[allow(missing_docs)]
47pub struct Folder {
48 pub id: Uuid,
49 pub name: String,
50}
51
52#[allow(missing_docs)]
57#[derive(Clone)]
58pub struct Cipher {
59 pub id: Uuid,
60 pub folder_id: Option<Uuid>,
61
62 pub name: String,
63 pub notes: Option<String>,
64
65 pub r#type: CipherType,
66
67 pub favorite: bool,
68 pub reprompt: u8,
69
70 pub fields: Vec<Field>,
71
72 pub revision_date: DateTime<Utc>,
73 pub creation_date: DateTime<Utc>,
74 pub deleted_date: Option<DateTime<Utc>>,
75}
76
77#[allow(missing_docs)]
82#[derive(Clone)]
83#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
84pub struct ImportingCipher {
85 pub folder_id: Option<Uuid>,
86
87 pub name: String,
88 pub notes: Option<String>,
89
90 pub r#type: CipherType,
91
92 pub favorite: bool,
93 pub reprompt: u8,
94
95 pub fields: Vec<Field>,
96
97 pub revision_date: DateTime<Utc>,
98 pub creation_date: DateTime<Utc>,
99 pub deleted_date: Option<DateTime<Utc>>,
100}
101
102impl From<Login> for bitwarden_vault::LoginView {
103 fn from(login: Login) -> Self {
104 let l: Vec<LoginUriView> = login
105 .login_uris
106 .into_iter()
107 .map(LoginUriView::from)
108 .collect();
109
110 bitwarden_vault::LoginView {
111 username: login.username,
112 password: login.password,
113 password_revision_date: None,
114 uris: if l.is_empty() { None } else { Some(l) },
115 totp: login.totp,
116 autofill_on_page_load: None,
117 fido2_credentials: None,
119 }
120 }
121}
122
123impl From<SecureNote> for bitwarden_vault::SecureNoteView {
124 fn from(secure_note: SecureNote) -> Self {
125 bitwarden_vault::SecureNoteView {
126 r#type: secure_note.r#type.into(),
127 }
128 }
129}
130
131impl From<Card> for bitwarden_vault::CardView {
132 fn from(card: Card) -> Self {
133 bitwarden_vault::CardView {
134 cardholder_name: card.cardholder_name,
135 brand: card.brand,
136 number: card.number,
137 exp_month: card.exp_month,
138 exp_year: card.exp_year,
139 code: card.code,
140 }
141 }
142}
143
144impl From<Identity> for bitwarden_vault::IdentityView {
145 fn from(identity: Identity) -> Self {
146 bitwarden_vault::IdentityView {
147 title: identity.title,
148 first_name: identity.first_name,
149 middle_name: identity.middle_name,
150 last_name: identity.last_name,
151 address1: identity.address1,
152 address2: identity.address2,
153 address3: identity.address3,
154 city: identity.city,
155 state: identity.state,
156 postal_code: identity.postal_code,
157 country: identity.country,
158 company: identity.company,
159 email: identity.email,
160 phone: identity.phone,
161 ssn: identity.ssn,
162 username: identity.username,
163 passport_number: identity.passport_number,
164 license_number: identity.license_number,
165 }
166 }
167}
168
169impl From<SshKey> for bitwarden_vault::SshKeyView {
170 fn from(ssh_key: SshKey) -> Self {
171 bitwarden_vault::SshKeyView {
172 private_key: ssh_key.private_key,
173 public_key: ssh_key.public_key,
174 fingerprint: ssh_key.fingerprint,
175 }
176 }
177}
178
179impl From<ImportingCipher> for CipherView {
180 fn from(value: ImportingCipher) -> Self {
181 let (cipher_type, login, identity, card, secure_note, ssh_key) = match value.r#type {
182 CipherType::Login(login) => (
183 bitwarden_vault::CipherType::Login,
184 Some((*login).into()),
185 None,
186 None,
187 None,
188 None,
189 ),
190 CipherType::SecureNote(secure_note) => (
191 bitwarden_vault::CipherType::SecureNote,
192 None,
193 None,
194 None,
195 Some((*secure_note).into()),
196 None,
197 ),
198 CipherType::Card(card) => (
199 bitwarden_vault::CipherType::Card,
200 None,
201 None,
202 Some((*card).into()),
203 None,
204 None,
205 ),
206 CipherType::Identity(identity) => (
207 bitwarden_vault::CipherType::Identity,
208 None,
209 Some((*identity).into()),
210 None,
211 None,
212 None,
213 ),
214 CipherType::SshKey(ssh_key) => (
215 bitwarden_vault::CipherType::SshKey,
216 None,
217 None,
218 None,
219 None,
220 Some((*ssh_key).into()),
221 ),
222 CipherType::BankAccount => (
223 bitwarden_vault::CipherType::BankAccount,
224 None,
225 None,
226 None,
227 None,
228 None,
229 ),
230 };
231
232 Self {
233 id: None,
234 organization_id: None,
235 folder_id: value.folder_id.map(FolderId::new),
236 collection_ids: vec![],
237 key: None,
238 name: value.name,
239 notes: value.notes,
240 r#type: cipher_type,
241 login,
242 identity,
243 card,
244 secure_note,
245 ssh_key,
246 bank_account: None,
247 favorite: value.favorite,
248 reprompt: CipherRepromptType::None,
249 organization_use_totp: true,
250 edit: true,
251 permissions: None,
252 view_password: true,
253 local_data: None,
254 attachments: None,
255 attachment_decryption_failures: None,
256 fields: {
257 let fields: Vec<FieldView> = value.fields.into_iter().map(Into::into).collect();
258 if fields.is_empty() {
259 None
260 } else {
261 Some(fields)
262 }
263 },
264 password_history: None,
265 creation_date: value.creation_date,
266 deleted_date: None,
267 revision_date: value.revision_date,
268 archived_date: None,
269 }
270 }
271}
272
273impl From<LoginUri> for bitwarden_vault::LoginUriView {
274 fn from(value: LoginUri) -> Self {
275 Self {
276 uri: value.uri,
277 r#match: value.r#match.and_then(|m| match m {
278 0 => Some(UriMatchType::Domain),
279 1 => Some(UriMatchType::Host),
280 2 => Some(UriMatchType::StartsWith),
281 3 => Some(UriMatchType::Exact),
282 4 => Some(UriMatchType::RegularExpression),
283 5 => Some(UriMatchType::Never),
284 _ => None,
285 }),
286 uri_checksum: None,
287 }
288 }
289}
290
291#[allow(missing_docs)]
292#[derive(Clone, Debug, PartialEq, Eq)]
293pub struct Field {
294 pub name: Option<String>,
295 pub value: Option<String>,
296 pub r#type: u8,
297 pub linked_id: Option<u32>,
298}
299
300#[allow(missing_docs)]
301#[derive(Clone)]
302#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
303pub enum CipherType {
304 Login(Box<Login>),
305 SecureNote(Box<SecureNote>),
306 Card(Box<Card>),
307 Identity(Box<Identity>),
308 SshKey(Box<SshKey>),
309 BankAccount,
310}
311
312impl fmt::Display for CipherType {
313 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
314 match self {
315 CipherType::Login(_) => write!(f, "login"),
316 CipherType::SecureNote(_) => write!(f, "note"),
317 CipherType::Card(_) => write!(f, "card"),
318 CipherType::Identity(_) => write!(f, "identity"),
319 CipherType::SshKey(_) => write!(f, "ssh_key"),
320 CipherType::BankAccount => write!(f, "bank_account"),
321 }
322 }
323}
324
325#[allow(missing_docs)]
326#[derive(Clone)]
327#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
328pub struct Login {
329 pub username: Option<String>,
330 pub password: Option<String>,
331 pub login_uris: Vec<LoginUri>,
332 pub totp: Option<String>,
333
334 pub fido2_credentials: Option<Vec<Fido2Credential>>,
335}
336
337impl Login {
338 pub fn sanitize_uris(&mut self) {
344 for login_uri in &mut self.login_uris {
345 if let Some(uri) = &login_uri.uri {
346 login_uri.uri = Some(sanitize_uri(uri));
347 }
348 }
349 }
350}
351
352fn sanitize_uri(uri: &str) -> String {
360 if let Ok(parsed) = url::Url::parse(uri)
361 && parsed.has_host()
362 {
363 return uri.to_string();
364 }
365
366 let with_http = format!("http://{uri}");
367 if let Ok(parsed) = url::Url::parse(&with_http)
368 && let Some(host) = parsed.host()
369 && matches!(host, url::Host::Ipv4(_))
370 {
371 return with_http;
372 }
373
374 if !uri.starts_with("http://") || !uri.starts_with("https://") {
375 return format!("https://{uri}");
376 }
377
378 uri.to_string()
379}
380
381#[allow(missing_docs)]
382#[derive(Clone, Debug, PartialEq, Eq)]
383pub struct LoginUri {
384 pub uri: Option<String>,
385 pub r#match: Option<u8>,
386}
387
388#[allow(missing_docs)]
389#[derive(Clone)]
390#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
391pub struct Fido2Credential {
392 pub credential_id: String,
393 pub key_type: String,
394 pub key_algorithm: String,
395 pub key_curve: String,
396 pub key_value: String,
397 pub rp_id: String,
398 pub user_handle: Option<String>,
399 pub user_name: Option<String>,
400 pub counter: u32,
401 pub rp_name: Option<String>,
402 pub user_display_name: Option<String>,
403 pub discoverable: String,
404 pub creation_date: DateTime<Utc>,
405}
406
407impl From<Fido2Credential> for Fido2CredentialFullView {
408 fn from(value: Fido2Credential) -> Self {
409 Fido2CredentialFullView {
410 credential_id: value.credential_id,
411 key_type: value.key_type,
412 key_algorithm: value.key_algorithm,
413 key_curve: value.key_curve,
414 key_value: value.key_value,
415 rp_id: value.rp_id,
416 user_handle: value.user_handle,
417 user_name: value.user_name,
418 counter: value.counter.to_string(),
419 rp_name: value.rp_name,
420 user_display_name: value.user_display_name,
421 discoverable: value.discoverable,
422 creation_date: value.creation_date,
423 }
424 }
425}
426
427#[allow(missing_docs)]
428#[derive(Clone)]
429#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
430pub struct Card {
431 pub cardholder_name: Option<String>,
432 pub exp_month: Option<String>,
433 pub exp_year: Option<String>,
434 pub code: Option<String>,
435 pub brand: Option<String>,
436 pub number: Option<String>,
437}
438
439#[allow(missing_docs)]
440#[derive(Clone)]
441#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
442pub struct SecureNote {
443 pub r#type: SecureNoteType,
444}
445
446#[allow(missing_docs)]
447#[derive(Clone)]
448#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
449pub enum SecureNoteType {
450 Generic = 0,
451}
452
453#[allow(missing_docs)]
454#[derive(Clone, Default, Debug, PartialEq, Eq)]
455pub struct Identity {
456 pub title: Option<String>,
457 pub first_name: Option<String>,
458 pub middle_name: Option<String>,
459 pub last_name: Option<String>,
460 pub address1: Option<String>,
461 pub address2: Option<String>,
462 pub address3: Option<String>,
463 pub city: Option<String>,
464 pub state: Option<String>,
465 pub postal_code: Option<String>,
466 pub country: Option<String>,
467 pub company: Option<String>,
468 pub email: Option<String>,
469 pub phone: Option<String>,
470 pub ssn: Option<String>,
471 pub username: Option<String>,
472 pub passport_number: Option<String>,
473 pub license_number: Option<String>,
474}
475
476#[allow(missing_docs)]
477#[derive(Clone)]
478#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
479pub struct SshKey {
480 pub private_key: String,
482 pub public_key: String,
484 pub fingerprint: String,
486}
487
488#[cfg(test)]
489mod tests {
490 use bitwarden_vault::{CipherType as VaultCipherType, FieldType};
491 use chrono::{DateTime, Utc};
492
493 use super::*;
494
495 #[test]
496 fn test_importing_cipher_to_cipher_view_login() {
497 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
498 let test_folder_id = uuid::Uuid::new_v4();
499
500 let importing_cipher = ImportingCipher {
501 folder_id: Some(test_folder_id),
502 name: "Test Login".to_string(),
503 notes: Some("Test notes".to_string()),
504 r#type: CipherType::Login(Box::new(Login {
505 username: Some("[email protected]".to_string()),
506 password: Some("password123".to_string()),
507 login_uris: vec![LoginUri {
508 uri: Some("https://example.com".to_string()),
509 r#match: Some(0), }],
511 totp: Some("otpauth://totp/test".to_string()),
512 fido2_credentials: None,
513 })),
514 favorite: true,
515 reprompt: 1,
516 fields: vec![Field {
517 name: Some("CustomField".to_string()),
518 value: Some("CustomValue".to_string()),
519 r#type: 0,
520 linked_id: None,
521 }],
522 revision_date: test_date,
523 creation_date: test_date,
524 deleted_date: None,
525 };
526
527 let cipher_view: CipherView = importing_cipher.into();
528
529 assert_eq!(cipher_view.id, None);
530 assert_eq!(cipher_view.organization_id, None);
531 assert_eq!(
532 cipher_view.folder_id.unwrap().to_string(),
533 test_folder_id.to_string()
534 );
535 assert_eq!(cipher_view.name, "Test Login");
536 assert_eq!(cipher_view.notes.unwrap(), "Test notes");
537 assert_eq!(cipher_view.r#type, VaultCipherType::Login);
538 assert!(cipher_view.favorite);
539 assert_eq!(cipher_view.creation_date, test_date);
540 assert_eq!(cipher_view.revision_date, test_date);
541
542 let fields = cipher_view.fields.unwrap();
543 assert_eq!(fields.len(), 1);
544
545 let field = fields.first().unwrap();
546 assert_eq!(field.name, Some("CustomField".to_string()));
547 assert_eq!(field.value, Some("CustomValue".to_string()));
548 assert_eq!(field.r#type, FieldType::Text);
549 assert_eq!(field.linked_id, None);
550
551 let login = cipher_view.login.expect("Login should be present");
552 assert_eq!(login.username, Some("[email protected]".to_string()));
553 assert_eq!(login.password, Some("password123".to_string()));
554 assert_eq!(login.totp, Some("otpauth://totp/test".to_string()));
555
556 let uris = login.uris.expect("URIs should be present");
557 assert_eq!(uris.len(), 1);
558 assert_eq!(uris[0].uri, Some("https://example.com".to_string()));
559 assert_eq!(uris[0].r#match, Some(bitwarden_vault::UriMatchType::Domain));
560 }
561
562 #[test]
563 fn test_importing_cipher_to_cipher_view_secure_note() {
564 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
565
566 let importing_cipher = ImportingCipher {
567 folder_id: None,
568 name: "My Note".to_string(),
569 notes: Some("This is a secure note".to_string()),
570 r#type: CipherType::SecureNote(Box::new(SecureNote {
571 r#type: SecureNoteType::Generic,
572 })),
573 favorite: false,
574 reprompt: 0,
575 fields: vec![],
576 revision_date: test_date,
577 creation_date: test_date,
578 deleted_date: None,
579 };
580
581 let cipher_view: CipherView = importing_cipher.into();
582
583 assert_eq!(cipher_view.id, None);
585 assert_eq!(cipher_view.organization_id, None);
586 assert_eq!(cipher_view.folder_id, None);
587 assert_eq!(cipher_view.name, "My Note");
588 assert_eq!(cipher_view.notes, Some("This is a secure note".to_string()));
589 assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::SecureNote);
590 assert!(!cipher_view.favorite);
591 assert_eq!(cipher_view.creation_date, test_date);
592 assert_eq!(cipher_view.revision_date, test_date);
593
594 assert!(cipher_view.login.is_none());
596 assert!(cipher_view.identity.is_none());
597 assert!(cipher_view.card.is_none());
598 assert!(cipher_view.secure_note.is_some());
599 assert!(cipher_view.ssh_key.is_none());
600
601 let secure_note = cipher_view.secure_note.unwrap();
603 assert!(matches!(
604 secure_note.r#type,
605 bitwarden_vault::SecureNoteType::Generic
606 ));
607 }
608
609 #[test]
610 fn test_importing_cipher_to_cipher_view_card() {
611 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
612
613 let importing_cipher = ImportingCipher {
614 folder_id: None,
615 name: "My Credit Card".to_string(),
616 notes: Some("Credit card notes".to_string()),
617 r#type: CipherType::Card(Box::new(Card {
618 cardholder_name: Some("John Doe".to_string()),
619 brand: Some("Visa".to_string()),
620 number: Some("1234567812345678".to_string()),
621 exp_month: Some("12".to_string()),
622 exp_year: Some("2025".to_string()),
623 code: Some("123".to_string()),
624 })),
625 favorite: false,
626 reprompt: 0,
627 fields: vec![],
628 revision_date: test_date,
629 creation_date: test_date,
630 deleted_date: None,
631 };
632
633 let cipher_view: CipherView = importing_cipher.into();
634
635 assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::Card);
636 assert!(cipher_view.card.is_some());
637 assert!(cipher_view.login.is_none());
638
639 let card = cipher_view.card.unwrap();
640 assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
641 assert_eq!(card.brand, Some("Visa".to_string()));
642 assert_eq!(card.number, Some("1234567812345678".to_string()));
643 }
644
645 #[test]
646 fn test_importing_cipher_to_cipher_view_identity() {
647 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
648
649 let importing_cipher = ImportingCipher {
650 folder_id: None,
651 name: "My Identity".to_string(),
652 notes: None,
653 r#type: CipherType::Identity(Box::new(Identity {
654 title: Some("Dr.".to_string()),
655 first_name: Some("Jane".to_string()),
656 last_name: Some("Smith".to_string()),
657 email: Some("[email protected]".to_string()),
658 ..Default::default()
659 })),
660 favorite: false,
661 reprompt: 0,
662 fields: vec![],
663 revision_date: test_date,
664 creation_date: test_date,
665 deleted_date: None,
666 };
667
668 let cipher_view: CipherView = importing_cipher.into();
669
670 assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::Identity);
671 assert!(cipher_view.identity.is_some());
672 assert!(cipher_view.login.is_none());
673
674 let identity = cipher_view.identity.unwrap();
675 assert_eq!(identity.title, Some("Dr.".to_string()));
676 assert_eq!(identity.first_name, Some("Jane".to_string()));
677 assert_eq!(identity.last_name, Some("Smith".to_string()));
678 assert_eq!(identity.email, Some("[email protected]".to_string()));
679 }
680
681 #[test]
682 fn test_sanitize_uri_valid_url_unchanged() {
683 assert_eq!(
684 sanitize_uri("https://bitwarden.com"),
685 "https://bitwarden.com"
686 );
687 assert_eq!(
688 sanitize_uri("https://bitwarden.com/path?q=1"),
689 "https://bitwarden.com/path?q=1"
690 );
691 assert_eq!(
692 sanitize_uri("http://192.168.0.1:8080"),
693 "http://192.168.0.1:8080"
694 );
695 }
696
697 #[test]
698 fn test_sanitize_uri_ip_address_gets_http() {
699 assert_eq!(sanitize_uri("192.168.0.1:8080"), "http://192.168.0.1:8080");
700 assert_eq!(sanitize_uri("10.0.0.1"), "http://10.0.0.1");
701 assert_eq!(
702 sanitize_uri("192.168.0.1:8080/path"),
703 "http://192.168.0.1:8080/path"
704 );
705 }
706
707 #[test]
708 fn test_sanitize_uri_non_ip_gets_https() {
709 assert_eq!(sanitize_uri("bitwarden.com"), "https://bitwarden.com");
710 assert_eq!(
711 sanitize_uri("bitwarden.co.uk/login"),
712 "https://bitwarden.co.uk/login"
713 );
714 }
715
716 #[test]
717 fn test_sanitize_uri_broken_http_prefix_unchanged() {
718 assert_eq!(
720 sanitize_uri("ht tp://bitwarden.com"),
721 "https://ht tp://bitwarden.com"
722 );
723 }
724
725 #[test]
726 fn test_sanitize_uri_schemeless_host_port() {
727 assert_eq!(sanitize_uri("localhost:8080"), "https://localhost:8080");
728 }
729
730 #[test]
731 fn test_sanitize_uri_scheme_on_string() {
732 assert_eq!(sanitize_uri("foo:bar"), "https://foo:bar");
733 }
734
735 #[test]
736 fn test_sanitize_uri_with_real_schemes_no_change() {
737 assert_eq!(
738 sanitize_uri("ftp://files.example.com"),
739 "ftp://files.example.com"
740 );
741 assert_eq!(
742 sanitize_uri("androidapp://com.example"),
743 "androidapp://com.example"
744 );
745 }
746
747 #[test]
748 fn test_login_sanitize_uris() {
749 let mut login = Login {
750 username: None,
751 password: None,
752 login_uris: vec![
753 LoginUri {
754 uri: Some("https://bitwarden.com".to_string()),
755 r#match: None,
756 },
757 LoginUri {
758 uri: Some("192.168.0.1:8080".to_string()),
759 r#match: None,
760 },
761 LoginUri {
762 uri: Some("example.com".to_string()),
763 r#match: None,
764 },
765 LoginUri {
766 uri: None,
767 r#match: None,
768 },
769 ],
770 totp: None,
771 fido2_credentials: None,
772 };
773
774 login.sanitize_uris();
775
776 assert_eq!(
777 login.login_uris[0].uri,
778 Some("https://bitwarden.com".to_string())
779 );
780 assert_eq!(
781 login.login_uris[1].uri,
782 Some("http://192.168.0.1:8080".to_string())
783 );
784 assert_eq!(
785 login.login_uris[2].uri,
786 Some("https://example.com".to_string())
787 );
788 assert_eq!(login.login_uris[3].uri, None);
789 }
790}