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