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 CipherType::Passport => (
231 bitwarden_vault::CipherType::Passport,
232 None,
233 None,
234 None,
235 None,
236 None,
237 ),
238 CipherType::DriversLicense => (
239 bitwarden_vault::CipherType::DriversLicense,
240 None,
241 None,
242 None,
243 None,
244 None,
245 ),
246 };
247
248 Self {
249 id: None,
250 organization_id: None,
251 folder_id: value.folder_id.map(FolderId::new),
252 collection_ids: vec![],
253 key: None,
254 name: value.name,
255 notes: value.notes,
256 r#type: cipher_type,
257 login,
258 identity,
259 card,
260 secure_note,
261 ssh_key,
262 bank_account: None,
263 drivers_license: None,
264 passport: None,
265 favorite: value.favorite,
266 reprompt: CipherRepromptType::None,
267 organization_use_totp: true,
268 edit: true,
269 permissions: None,
270 view_password: true,
271 local_data: None,
272 attachments: None,
273 attachment_decryption_failures: None,
274 fields: {
275 let fields: Vec<FieldView> = value.fields.into_iter().map(Into::into).collect();
276 if fields.is_empty() {
277 None
278 } else {
279 Some(fields)
280 }
281 },
282 password_history: None,
283 creation_date: value.creation_date,
284 deleted_date: None,
285 revision_date: value.revision_date,
286 archived_date: None,
287 }
288 }
289}
290
291impl From<LoginUri> for bitwarden_vault::LoginUriView {
292 fn from(value: LoginUri) -> Self {
293 Self {
294 uri: value.uri,
295 r#match: value.r#match.and_then(|m| match m {
296 0 => Some(UriMatchType::Domain),
297 1 => Some(UriMatchType::Host),
298 2 => Some(UriMatchType::StartsWith),
299 3 => Some(UriMatchType::Exact),
300 4 => Some(UriMatchType::RegularExpression),
301 5 => Some(UriMatchType::Never),
302 _ => None,
303 }),
304 uri_checksum: None,
305 }
306 }
307}
308
309#[allow(missing_docs)]
310#[derive(Clone, Debug, PartialEq, Eq)]
311pub struct Field {
312 pub name: Option<String>,
313 pub value: Option<String>,
314 pub r#type: u8,
315 pub linked_id: Option<u32>,
316}
317
318#[allow(missing_docs)]
319#[derive(Clone)]
320#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
321pub enum CipherType {
322 Login(Box<Login>),
323 SecureNote(Box<SecureNote>),
324 Card(Box<Card>),
325 Identity(Box<Identity>),
326 SshKey(Box<SshKey>),
327 BankAccount,
328 Passport,
329 DriversLicense,
330}
331
332impl fmt::Display for CipherType {
333 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
334 match self {
335 CipherType::Login(_) => write!(f, "login"),
336 CipherType::SecureNote(_) => write!(f, "note"),
337 CipherType::Card(_) => write!(f, "card"),
338 CipherType::Identity(_) => write!(f, "identity"),
339 CipherType::SshKey(_) => write!(f, "ssh_key"),
340 CipherType::BankAccount => write!(f, "bank_account"),
341 CipherType::DriversLicense => write!(f, "drivers_license"),
342 CipherType::Passport => write!(f, "passport"),
343 }
344 }
345}
346
347#[allow(missing_docs)]
348#[derive(Clone)]
349#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
350pub struct Login {
351 pub username: Option<String>,
352 pub password: Option<String>,
353 pub login_uris: Vec<LoginUri>,
354 pub totp: Option<String>,
355
356 pub fido2_credentials: Option<Vec<Fido2Credential>>,
357}
358
359impl Login {
360 pub fn sanitize_uris(&mut self) {
366 for login_uri in &mut self.login_uris {
367 if let Some(uri) = &login_uri.uri {
368 login_uri.uri = Some(sanitize_uri(uri));
369 }
370 }
371 }
372}
373
374fn sanitize_uri(uri: &str) -> String {
382 if let Ok(parsed) = url::Url::parse(uri)
383 && parsed.has_host()
384 {
385 return uri.to_string();
386 }
387
388 let with_http = format!("http://{uri}");
389 if let Ok(parsed) = url::Url::parse(&with_http)
390 && let Some(host) = parsed.host()
391 && matches!(host, url::Host::Ipv4(_))
392 {
393 return with_http;
394 }
395
396 if !uri.starts_with("http://") || !uri.starts_with("https://") {
397 return format!("https://{uri}");
398 }
399
400 uri.to_string()
401}
402
403#[allow(missing_docs)]
404#[derive(Clone, Debug, PartialEq, Eq)]
405pub struct LoginUri {
406 pub uri: Option<String>,
407 pub r#match: Option<u8>,
408}
409
410#[allow(missing_docs)]
411#[derive(Clone)]
412#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
413pub struct Fido2Credential {
414 pub credential_id: String,
415 pub key_type: String,
416 pub key_algorithm: String,
417 pub key_curve: String,
418 pub key_value: String,
419 pub rp_id: String,
420 pub user_handle: Option<String>,
421 pub user_name: Option<String>,
422 pub counter: u32,
423 pub rp_name: Option<String>,
424 pub user_display_name: Option<String>,
425 pub discoverable: String,
426 pub creation_date: DateTime<Utc>,
427}
428
429impl From<Fido2Credential> for Fido2CredentialFullView {
430 fn from(value: Fido2Credential) -> Self {
431 Fido2CredentialFullView {
432 credential_id: value.credential_id,
433 key_type: value.key_type,
434 key_algorithm: value.key_algorithm,
435 key_curve: value.key_curve,
436 key_value: value.key_value,
437 rp_id: value.rp_id,
438 user_handle: value.user_handle,
439 user_name: value.user_name,
440 counter: value.counter.to_string(),
441 rp_name: value.rp_name,
442 user_display_name: value.user_display_name,
443 discoverable: value.discoverable,
444 creation_date: value.creation_date,
445 }
446 }
447}
448
449#[allow(missing_docs)]
450#[derive(Clone)]
451#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
452pub struct Card {
453 pub cardholder_name: Option<String>,
454 pub exp_month: Option<String>,
455 pub exp_year: Option<String>,
456 pub code: Option<String>,
457 pub brand: Option<String>,
458 pub number: Option<String>,
459}
460
461#[allow(missing_docs)]
462#[derive(Clone)]
463#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
464pub struct SecureNote {
465 pub r#type: SecureNoteType,
466}
467
468#[allow(missing_docs)]
469#[derive(Clone)]
470#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
471pub enum SecureNoteType {
472 Generic = 0,
473}
474
475#[allow(missing_docs)]
476#[derive(Clone, Default, Debug, PartialEq, Eq)]
477pub struct Identity {
478 pub title: Option<String>,
479 pub first_name: Option<String>,
480 pub middle_name: Option<String>,
481 pub last_name: Option<String>,
482 pub address1: Option<String>,
483 pub address2: Option<String>,
484 pub address3: Option<String>,
485 pub city: Option<String>,
486 pub state: Option<String>,
487 pub postal_code: Option<String>,
488 pub country: Option<String>,
489 pub company: Option<String>,
490 pub email: Option<String>,
491 pub phone: Option<String>,
492 pub ssn: Option<String>,
493 pub username: Option<String>,
494 pub passport_number: Option<String>,
495 pub license_number: Option<String>,
496}
497
498#[allow(missing_docs)]
499#[derive(Clone)]
500#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
501pub struct SshKey {
502 pub private_key: String,
504 pub public_key: String,
506 pub fingerprint: String,
508}
509
510#[cfg(test)]
511mod tests {
512 use bitwarden_vault::{CipherType as VaultCipherType, FieldType};
513 use chrono::{DateTime, Utc};
514
515 use super::*;
516
517 #[test]
518 fn test_importing_cipher_to_cipher_view_login() {
519 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
520 let test_folder_id = uuid::Uuid::new_v4();
521
522 let importing_cipher = ImportingCipher {
523 folder_id: Some(test_folder_id),
524 name: "Test Login".to_string(),
525 notes: Some("Test notes".to_string()),
526 r#type: CipherType::Login(Box::new(Login {
527 username: Some("[email protected]".to_string()),
528 password: Some("password123".to_string()),
529 login_uris: vec![LoginUri {
530 uri: Some("https://example.com".to_string()),
531 r#match: Some(0), }],
533 totp: Some("otpauth://totp/test".to_string()),
534 fido2_credentials: None,
535 })),
536 favorite: true,
537 reprompt: 1,
538 fields: vec![Field {
539 name: Some("CustomField".to_string()),
540 value: Some("CustomValue".to_string()),
541 r#type: 0,
542 linked_id: None,
543 }],
544 revision_date: test_date,
545 creation_date: test_date,
546 deleted_date: None,
547 };
548
549 let cipher_view: CipherView = importing_cipher.into();
550
551 assert_eq!(cipher_view.id, None);
552 assert_eq!(cipher_view.organization_id, None);
553 assert_eq!(
554 cipher_view.folder_id.unwrap().to_string(),
555 test_folder_id.to_string()
556 );
557 assert_eq!(cipher_view.name, "Test Login");
558 assert_eq!(cipher_view.notes.unwrap(), "Test notes");
559 assert_eq!(cipher_view.r#type, VaultCipherType::Login);
560 assert!(cipher_view.favorite);
561 assert_eq!(cipher_view.creation_date, test_date);
562 assert_eq!(cipher_view.revision_date, test_date);
563
564 let fields = cipher_view.fields.unwrap();
565 assert_eq!(fields.len(), 1);
566
567 let field = fields.first().unwrap();
568 assert_eq!(field.name, Some("CustomField".to_string()));
569 assert_eq!(field.value, Some("CustomValue".to_string()));
570 assert_eq!(field.r#type, FieldType::Text);
571 assert_eq!(field.linked_id, None);
572
573 let login = cipher_view.login.expect("Login should be present");
574 assert_eq!(login.username, Some("[email protected]".to_string()));
575 assert_eq!(login.password, Some("password123".to_string()));
576 assert_eq!(login.totp, Some("otpauth://totp/test".to_string()));
577
578 let uris = login.uris.expect("URIs should be present");
579 assert_eq!(uris.len(), 1);
580 assert_eq!(uris[0].uri, Some("https://example.com".to_string()));
581 assert_eq!(uris[0].r#match, Some(bitwarden_vault::UriMatchType::Domain));
582 }
583
584 #[test]
585 fn test_importing_cipher_to_cipher_view_secure_note() {
586 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
587
588 let importing_cipher = ImportingCipher {
589 folder_id: None,
590 name: "My Note".to_string(),
591 notes: Some("This is a secure note".to_string()),
592 r#type: CipherType::SecureNote(Box::new(SecureNote {
593 r#type: SecureNoteType::Generic,
594 })),
595 favorite: false,
596 reprompt: 0,
597 fields: vec![],
598 revision_date: test_date,
599 creation_date: test_date,
600 deleted_date: None,
601 };
602
603 let cipher_view: CipherView = importing_cipher.into();
604
605 assert_eq!(cipher_view.id, None);
607 assert_eq!(cipher_view.organization_id, None);
608 assert_eq!(cipher_view.folder_id, None);
609 assert_eq!(cipher_view.name, "My Note");
610 assert_eq!(cipher_view.notes, Some("This is a secure note".to_string()));
611 assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::SecureNote);
612 assert!(!cipher_view.favorite);
613 assert_eq!(cipher_view.creation_date, test_date);
614 assert_eq!(cipher_view.revision_date, test_date);
615
616 assert!(cipher_view.login.is_none());
618 assert!(cipher_view.identity.is_none());
619 assert!(cipher_view.card.is_none());
620 assert!(cipher_view.secure_note.is_some());
621 assert!(cipher_view.ssh_key.is_none());
622
623 let secure_note = cipher_view.secure_note.unwrap();
625 assert!(matches!(
626 secure_note.r#type,
627 bitwarden_vault::SecureNoteType::Generic
628 ));
629 }
630
631 #[test]
632 fn test_importing_cipher_to_cipher_view_card() {
633 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
634
635 let importing_cipher = ImportingCipher {
636 folder_id: None,
637 name: "My Credit Card".to_string(),
638 notes: Some("Credit card notes".to_string()),
639 r#type: CipherType::Card(Box::new(Card {
640 cardholder_name: Some("John Doe".to_string()),
641 brand: Some("Visa".to_string()),
642 number: Some("1234567812345678".to_string()),
643 exp_month: Some("12".to_string()),
644 exp_year: Some("2025".to_string()),
645 code: Some("123".to_string()),
646 })),
647 favorite: false,
648 reprompt: 0,
649 fields: vec![],
650 revision_date: test_date,
651 creation_date: test_date,
652 deleted_date: None,
653 };
654
655 let cipher_view: CipherView = importing_cipher.into();
656
657 assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::Card);
658 assert!(cipher_view.card.is_some());
659 assert!(cipher_view.login.is_none());
660
661 let card = cipher_view.card.unwrap();
662 assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
663 assert_eq!(card.brand, Some("Visa".to_string()));
664 assert_eq!(card.number, Some("1234567812345678".to_string()));
665 }
666
667 #[test]
668 fn test_importing_cipher_to_cipher_view_identity() {
669 let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
670
671 let importing_cipher = ImportingCipher {
672 folder_id: None,
673 name: "My Identity".to_string(),
674 notes: None,
675 r#type: CipherType::Identity(Box::new(Identity {
676 title: Some("Dr.".to_string()),
677 first_name: Some("Jane".to_string()),
678 last_name: Some("Smith".to_string()),
679 email: Some("[email protected]".to_string()),
680 ..Default::default()
681 })),
682 favorite: false,
683 reprompt: 0,
684 fields: vec![],
685 revision_date: test_date,
686 creation_date: test_date,
687 deleted_date: None,
688 };
689
690 let cipher_view: CipherView = importing_cipher.into();
691
692 assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::Identity);
693 assert!(cipher_view.identity.is_some());
694 assert!(cipher_view.login.is_none());
695
696 let identity = cipher_view.identity.unwrap();
697 assert_eq!(identity.title, Some("Dr.".to_string()));
698 assert_eq!(identity.first_name, Some("Jane".to_string()));
699 assert_eq!(identity.last_name, Some("Smith".to_string()));
700 assert_eq!(identity.email, Some("[email protected]".to_string()));
701 }
702
703 #[test]
704 fn test_sanitize_uri_valid_url_unchanged() {
705 assert_eq!(
706 sanitize_uri("https://bitwarden.com"),
707 "https://bitwarden.com"
708 );
709 assert_eq!(
710 sanitize_uri("https://bitwarden.com/path?q=1"),
711 "https://bitwarden.com/path?q=1"
712 );
713 assert_eq!(
714 sanitize_uri("http://192.168.0.1:8080"),
715 "http://192.168.0.1:8080"
716 );
717 }
718
719 #[test]
720 fn test_sanitize_uri_ip_address_gets_http() {
721 assert_eq!(sanitize_uri("192.168.0.1:8080"), "http://192.168.0.1:8080");
722 assert_eq!(sanitize_uri("10.0.0.1"), "http://10.0.0.1");
723 assert_eq!(
724 sanitize_uri("192.168.0.1:8080/path"),
725 "http://192.168.0.1:8080/path"
726 );
727 }
728
729 #[test]
730 fn test_sanitize_uri_non_ip_gets_https() {
731 assert_eq!(sanitize_uri("bitwarden.com"), "https://bitwarden.com");
732 assert_eq!(
733 sanitize_uri("bitwarden.co.uk/login"),
734 "https://bitwarden.co.uk/login"
735 );
736 }
737
738 #[test]
739 fn test_sanitize_uri_broken_http_prefix_unchanged() {
740 assert_eq!(
742 sanitize_uri("ht tp://bitwarden.com"),
743 "https://ht tp://bitwarden.com"
744 );
745 }
746
747 #[test]
748 fn test_sanitize_uri_schemeless_host_port() {
749 assert_eq!(sanitize_uri("localhost:8080"), "https://localhost:8080");
750 }
751
752 #[test]
753 fn test_sanitize_uri_scheme_on_string() {
754 assert_eq!(sanitize_uri("foo:bar"), "https://foo:bar");
755 }
756
757 #[test]
758 fn test_sanitize_uri_with_real_schemes_no_change() {
759 assert_eq!(
760 sanitize_uri("ftp://files.example.com"),
761 "ftp://files.example.com"
762 );
763 assert_eq!(
764 sanitize_uri("androidapp://com.example"),
765 "androidapp://com.example"
766 );
767 }
768
769 #[test]
770 fn test_login_sanitize_uris() {
771 let mut login = Login {
772 username: None,
773 password: None,
774 login_uris: vec![
775 LoginUri {
776 uri: Some("https://bitwarden.com".to_string()),
777 r#match: None,
778 },
779 LoginUri {
780 uri: Some("192.168.0.1:8080".to_string()),
781 r#match: None,
782 },
783 LoginUri {
784 uri: Some("example.com".to_string()),
785 r#match: None,
786 },
787 LoginUri {
788 uri: None,
789 r#match: None,
790 },
791 ],
792 totp: None,
793 fido2_credentials: None,
794 };
795
796 login.sanitize_uris();
797
798 assert_eq!(
799 login.login_uris[0].uri,
800 Some("https://bitwarden.com".to_string())
801 );
802 assert_eq!(
803 login.login_uris[1].uri,
804 Some("http://192.168.0.1:8080".to_string())
805 );
806 assert_eq!(
807 login.login_uris[2].uri,
808 Some("https://example.com".to_string())
809 );
810 assert_eq!(login.login_uris[3].uri, None);
811 }
812}