Skip to main content

bitwarden_vault/cipher/
passport.rs

1use bitwarden_api_api::models::CipherPassportModel;
2use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
3use bitwarden_crypto::{
4    CompositeEncryptable, CryptoError, Decryptable, EncString, KeyStoreContext,
5    PrimitiveEncryptable,
6};
7use chrono::NaiveDate;
8use serde::{Deserialize, Serialize};
9#[cfg(feature = "wasm")]
10use tsify::Tsify;
11
12use super::cipher::CipherKind;
13use crate::{Cipher, VaultParseError, cipher::cipher::CopyableCipherFields};
14
15#[derive(Serialize, Deserialize, Debug, Clone)]
16#[serde(rename_all = "camelCase")]
17#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
18#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
19pub struct Passport {
20    pub surname: Option<EncString>,
21    pub given_name: Option<EncString>,
22    pub date_of_birth: Option<EncString>,
23    pub sex: Option<EncString>,
24    pub birth_place: Option<EncString>,
25    pub nationality: Option<EncString>,
26    pub issuing_country: Option<EncString>,
27    pub passport_number: Option<EncString>,
28    pub passport_type: Option<EncString>,
29    pub national_identification_number: Option<EncString>,
30    pub issuing_authority: Option<EncString>,
31    pub issue_date: Option<EncString>,
32    pub expiration_date: Option<EncString>,
33}
34
35#[allow(missing_docs)]
36#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
37#[serde(rename_all = "camelCase", deny_unknown_fields)]
38#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
39#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
40pub struct PassportView {
41    pub surname: Option<String>,
42    pub given_name: Option<String>,
43    pub date_of_birth: Option<NaiveDate>,
44    pub sex: Option<String>,
45    pub birth_place: Option<String>,
46    pub nationality: Option<String>,
47    pub issuing_country: Option<String>,
48    pub passport_number: Option<String>,
49    pub passport_type: Option<String>,
50    pub national_identification_number: Option<String>,
51    pub issuing_authority: Option<String>,
52    pub issue_date: Option<NaiveDate>,
53    pub expiration_date: Option<NaiveDate>,
54}
55
56impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, Passport> for PassportView {
57    fn encrypt_composite(
58        &self,
59        ctx: &mut KeyStoreContext<KeySlotIds>,
60        key: SymmetricKeySlotId,
61    ) -> Result<Passport, CryptoError> {
62        Ok(Passport {
63            surname: self.surname.encrypt(ctx, key)?,
64            given_name: self.given_name.encrypt(ctx, key)?,
65            date_of_birth: self
66                .date_of_birth
67                .map(|d| d.to_string())
68                .encrypt(ctx, key)?,
69            sex: self.sex.encrypt(ctx, key)?,
70            birth_place: self.birth_place.encrypt(ctx, key)?,
71            nationality: self.nationality.encrypt(ctx, key)?,
72            issuing_country: self.issuing_country.encrypt(ctx, key)?,
73            passport_number: self.passport_number.encrypt(ctx, key)?,
74            passport_type: self.passport_type.encrypt(ctx, key)?,
75            national_identification_number: self
76                .national_identification_number
77                .encrypt(ctx, key)?,
78            issuing_authority: self.issuing_authority.encrypt(ctx, key)?,
79            issue_date: self.issue_date.map(|d| d.to_string()).encrypt(ctx, key)?,
80            expiration_date: self
81                .expiration_date
82                .map(|d| d.to_string())
83                .encrypt(ctx, key)?,
84        })
85    }
86}
87
88impl Decryptable<KeySlotIds, SymmetricKeySlotId, PassportView> for Passport {
89    fn decrypt(
90        &self,
91        ctx: &mut KeyStoreContext<KeySlotIds>,
92        key: SymmetricKeySlotId,
93    ) -> Result<PassportView, CryptoError> {
94        Ok(PassportView {
95            surname: self.surname.decrypt(ctx, key).ok().flatten(),
96            given_name: self.given_name.decrypt(ctx, key).ok().flatten(),
97            date_of_birth: self
98                .date_of_birth
99                .decrypt(ctx, key)
100                .ok()
101                .flatten()
102                .and_then(|s: String| s.parse().ok()),
103            sex: self.sex.decrypt(ctx, key).ok().flatten(),
104            birth_place: self.birth_place.decrypt(ctx, key).ok().flatten(),
105            nationality: self.nationality.decrypt(ctx, key).ok().flatten(),
106            issuing_country: self.issuing_country.decrypt(ctx, key).ok().flatten(),
107            passport_number: self.passport_number.decrypt(ctx, key).ok().flatten(),
108            passport_type: self.passport_type.decrypt(ctx, key).ok().flatten(),
109            national_identification_number: self
110                .national_identification_number
111                .decrypt(ctx, key)
112                .ok()
113                .flatten(),
114            issuing_authority: self.issuing_authority.decrypt(ctx, key).ok().flatten(),
115            issue_date: self
116                .issue_date
117                .decrypt(ctx, key)
118                .ok()
119                .flatten()
120                .and_then(|s: String| s.parse().ok()),
121            expiration_date: self
122                .expiration_date
123                .decrypt(ctx, key)
124                .ok()
125                .flatten()
126                .and_then(|s: String| s.parse().ok()),
127        })
128    }
129}
130
131impl CipherKind for Passport {
132    fn decrypt_subtitle(
133        &self,
134        ctx: &mut KeyStoreContext<KeySlotIds>,
135        key: SymmetricKeySlotId,
136    ) -> Result<String, CryptoError> {
137        let given_name: Option<String> = self
138            .given_name
139            .as_ref()
140            .map(|g| g.decrypt(ctx, key))
141            .transpose()?;
142        let surname: Option<String> = self
143            .surname
144            .as_ref()
145            .map(|s| s.decrypt(ctx, key))
146            .transpose()?;
147        let issuing_country: Option<String> = self
148            .issuing_country
149            .as_ref()
150            .map(|s| s.decrypt(ctx, key))
151            .transpose()?;
152        Ok(build_subtitle_passport(
153            given_name,
154            surname,
155            issuing_country,
156        ))
157    }
158
159    fn get_copyable_fields(&self, _: Option<&Cipher>) -> Vec<CopyableCipherFields> {
160        [
161            self.given_name
162                .as_ref()
163                .map(|_| CopyableCipherFields::PassportGivenName),
164            self.surname
165                .as_ref()
166                .map(|_| CopyableCipherFields::PassportSurname),
167            self.passport_number
168                .as_ref()
169                .map(|_| CopyableCipherFields::PassportPassportNumber),
170            self.national_identification_number
171                .as_ref()
172                .map(|_| CopyableCipherFields::PassportNationalIdentificationNumber),
173        ]
174        .into_iter()
175        .flatten()
176        .collect()
177    }
178}
179
180/// Builds the subtitle for a passport cipher
181pub(super) fn build_subtitle_passport(
182    given_name: Option<String>,
183    surname: Option<String>,
184    issuing_country: Option<String>,
185) -> String {
186    let mut subtitle = String::new();
187
188    if let Some(given_name) = given_name {
189        subtitle.push_str(&given_name);
190    }
191    if let Some(surname) = surname {
192        if !subtitle.is_empty() && !surname.is_empty() {
193            subtitle.push(' ');
194        }
195        subtitle.push_str(&surname);
196    }
197
198    if let Some(issuing_country) = issuing_country {
199        if !subtitle.is_empty() && !issuing_country.is_empty() {
200            subtitle.push_str(", ");
201        }
202        subtitle.push_str(&issuing_country);
203    }
204
205    subtitle
206}
207
208impl TryFrom<CipherPassportModel> for Passport {
209    type Error = VaultParseError;
210
211    fn try_from(passport: CipherPassportModel) -> Result<Self, Self::Error> {
212        Ok(Self {
213            surname: EncString::try_from_optional(passport.surname)?,
214            given_name: EncString::try_from_optional(passport.given_name)?,
215            date_of_birth: EncString::try_from_optional(passport.date_of_birth)?,
216            sex: EncString::try_from_optional(passport.sex)?,
217            birth_place: EncString::try_from_optional(passport.birth_place)?,
218            nationality: EncString::try_from_optional(passport.nationality)?,
219            issuing_country: EncString::try_from_optional(passport.issuing_country)?,
220            passport_number: EncString::try_from_optional(passport.passport_number)?,
221            passport_type: EncString::try_from_optional(passport.passport_type)?,
222            national_identification_number: EncString::try_from_optional(
223                passport.national_identification_number,
224            )?,
225            issuing_authority: EncString::try_from_optional(passport.issuing_authority)?,
226            issue_date: EncString::try_from_optional(passport.issue_date)?,
227            expiration_date: EncString::try_from_optional(passport.expiration_date)?,
228        })
229    }
230}
231
232impl From<Passport> for CipherPassportModel {
233    fn from(passport: Passport) -> Self {
234        Self {
235            surname: passport.surname.map(|n| n.to_string()),
236            given_name: passport.given_name.map(|n| n.to_string()),
237            date_of_birth: passport.date_of_birth.map(|n| n.to_string()),
238            sex: passport.sex.map(|n| n.to_string()),
239            birth_place: passport.birth_place.map(|n| n.to_string()),
240            nationality: passport.nationality.map(|n| n.to_string()),
241            issuing_country: passport.issuing_country.map(|n| n.to_string()),
242            passport_number: passport.passport_number.map(|n| n.to_string()),
243            passport_type: passport.passport_type.map(|n| n.to_string()),
244            national_identification_number: passport
245                .national_identification_number
246                .map(|n| n.to_string()),
247            issuing_authority: passport.issuing_authority.map(|n| n.to_string()),
248            issue_date: passport.issue_date.map(|n| n.to_string()),
249            expiration_date: passport.expiration_date.map(|n| n.to_string()),
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use bitwarden_core::key_management::create_test_crypto_with_user_key;
257    use bitwarden_crypto::{SymmetricCryptoKey, SymmetricKeyAlgorithm};
258
259    use super::*;
260    use crate::cipher::cipher::CopyableCipherFields;
261
262    const TEST_VECTOR_PASSPORT_KEY: &str =
263        "eG6fDUuqSQFpX6ACdw2u65PkLKDiOk9aQfUgUC/BTU3+UFpAPP/CtE7b+ZdeK3SC2z5+rWvhm537jV3qIbzIBA==";
264    const TEST_VECTOR_PASSPORT_JSON: &str = r#"{"surname":"2.eQaSEApRodSjkXYijwVKog==|2gQz6ddMxUt3p400Axj43w==|XR6WmolSiIMD/DhF0SBZr7Qjb56fgSlQWyH8fb8LiL4=","givenName":"2.hS8vFkgoKxe31l8tOJT6vA==|DuciBPcIw4TgX7kobPc6dQ==|oRbxAqSJx5T5KBcPLhHhhJnQKjE5FjORIe9K41rXJKg=","dateOfBirth":"2.EQAdb/+PO6qGNTqYaufPYw==|dbUIcfU2w+zrtbxKcx1j2w==|F7AVClD4oXjS+1bqZtF8ociq/Gk8gPCi7cvtvjRNhF4=","sex":"2.pz3uUyCEKc5+1njtMm3lwg==|vB2Nyw9Dp/xKoTt/qPe30Q==|GuVXp0o6dxpV3iEdi2ifR5dTTd8XsiGE7LYFrpKVIIs=","birthPlace":"2.lGurnrRwJlAjugpdo9CdxQ==|kHdvvlXOsdgUNAMfUvIMoQ==|7pX2hZqP9S8srwEEzlZ2ZZCC2LUUzTiaHT/WBrPzb4A=","nationality":"2.0+Mjb6gfkirfw/cmHNIT8g==|GFLjpeEbID6jBS3a7/vvEA==|k0v1ArEI+iApsSj9UtucdsWD4hoM6vUjlHvRCI7bIz8=","issuingCountry":"2.PQhrBI1z8AJSzU9C07RkFA==|5OP/JHxFhEF/P8sKaTmwuA==|07Bfry57nZRyejesniXXt4xhgr4CN2JOSLVUHoDRL8Q=","passportNumber":"2.W+F0jcn8wY3W5d91zPwMTQ==|ny+5nUgNuiZ3SJR3Xbvsgg==|haWOAdcDbhs95hOsFknn5ABFbYm9B0Z7DoLN1hLaYEk=","passportType":"2.HO4v3tQ0QjZfazo5NxleZA==|fra59Rz9NKyrhoasK0UrEg==|n30Gb2pqLWtM8dzkfhsels76k9+7oWWqicK4kA10Hlw=","nationalIdentificationNumber":"2.+8kGWruSfQMZdXeB3X2s8Q==|KJp8B2lLNrmBFsCjN0u5dw==|zgW3uKX75k8/oFiEJEoXbcqeqiNI2jZZEeR8VQjF4jE=","issuingAuthority":"2.vDHido+bznW03M/CM9uY8g==|ryQ4ohHijQeEI8Fh3Rv1tA1eeCUqCsMuIGdGawJzSmo=|XbH99VX1LqpgVwzom6aHTsEUgoe5NekwRi0Fg7fKD8o=","issueDate":"2.rvY/6OVEEGxhLVMgFhT4gQ==|9/g9m7753eM2B3m6raLghw==|IsVUOXJ5r/J/P5M2kq5MJ7viDVZbkMP2opggVCiImmY=","expirationDate":"2.yIMuc7J5yxerSuZaYY0sbQ==|VIGDsMYFoUmJaVZ/ZR+gtg==|rMWvQHK24zUqZQnJgTNL58W71/ZJA/Dx3ernx4JRVVQ="}"#;
265
266    fn test_passport_view() -> PassportView {
267        PassportView {
268            surname: Some("Doe".to_string()),
269            given_name: Some("Jane".to_string()),
270            date_of_birth: NaiveDate::from_ymd_opt(1990, 1, 1),
271            sex: Some("F".to_string()),
272            birth_place: Some("New York".to_string()),
273            nationality: Some("American".to_string()),
274            issuing_country: Some("US".to_string()),
275            passport_number: Some("P12345678".to_string()),
276            passport_type: Some("P".to_string()),
277            national_identification_number: Some("123-45-6789".to_string()),
278            issuing_authority: Some("US State Department".to_string()),
279            issue_date: NaiveDate::from_ymd_opt(2020, 1, 1),
280            expiration_date: NaiveDate::from_ymd_opt(2030, 1, 1),
281        }
282    }
283
284    #[test]
285    #[ignore]
286    fn generate_test_vector() {
287        let key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
288        let key_b64 = key.to_base64();
289        let key_store = create_test_crypto_with_user_key(key);
290        let key_slot = SymmetricKeySlotId::User;
291        let mut ctx = key_store.context();
292
293        let encrypted = test_passport_view()
294            .encrypt_composite(&mut ctx, key_slot)
295            .unwrap();
296        let json = serde_json::to_string(&encrypted).unwrap();
297
298        println!("const TEST_VECTOR_PASSPORT_KEY: &str = \"{key_b64}\";");
299        println!("const TEST_VECTOR_PASSPORT_JSON: &str = r#\"{json}\"#;");
300    }
301
302    #[test]
303    fn test_recorded_passport_test_vector() {
304        let key = SymmetricCryptoKey::try_from(TEST_VECTOR_PASSPORT_KEY.to_string())
305            .expect("valid test key");
306        let key_store = create_test_crypto_with_user_key(key);
307        let key_slot = SymmetricKeySlotId::User;
308        let mut ctx = key_store.context();
309
310        let encrypted: Passport =
311            serde_json::from_str(TEST_VECTOR_PASSPORT_JSON).expect("valid test vector JSON");
312        let decrypted: PassportView = encrypted
313            .decrypt(&mut ctx, key_slot)
314            .expect("Passport has changed in a backwards-incompatible way. Existing encrypted data must remain decryptable. If a new format is needed, create a new version instead of modifying the existing one.");
315
316        assert_eq!(decrypted, test_passport_view());
317    }
318
319    #[test]
320    fn test_subtitle_passport() {
321        let key = SymmetricCryptoKey::try_from("hvBMMb1t79YssFZkpetYsM3deyVuQv4r88Uj9gvYe0+G8EwxvW3v1iywVmSl61iwzd17JW5C/ivzxSP2C9h7Tw==".to_string()).unwrap();
322        let key_store = create_test_crypto_with_user_key(key);
323        let key = SymmetricKeySlotId::User;
324        let mut ctx = key_store.context();
325
326        let given_name_encrypted = "Jane".to_owned().encrypt(&mut ctx, key).unwrap();
327        let surname_encrypted = "Doe".to_owned().encrypt(&mut ctx, key).unwrap();
328
329        let passport = Passport {
330            surname: Some(surname_encrypted),
331            given_name: Some(given_name_encrypted),
332            date_of_birth: None,
333            sex: None,
334            birth_place: None,
335            nationality: None,
336            issuing_country: None,
337            passport_number: None,
338            passport_type: None,
339            national_identification_number: None,
340            issuing_authority: None,
341            issue_date: None,
342            expiration_date: None,
343        };
344
345        assert_eq!(
346            passport.decrypt_subtitle(&mut ctx, key).unwrap(),
347            "Jane Doe".to_string()
348        );
349    }
350
351    #[test]
352    fn test_subtitle_passport_with_issuing_country() {
353        let key = SymmetricCryptoKey::try_from("hvBMMb1t79YssFZkpetYsM3deyVuQv4r88Uj9gvYe0+G8EwxvW3v1iywVmSl61iwzd17JW5C/ivzxSP2C9h7Tw==".to_string()).unwrap();
354        let key_store = create_test_crypto_with_user_key(key);
355        let key = SymmetricKeySlotId::User;
356        let mut ctx = key_store.context();
357
358        let given_name_encrypted = "Jane".to_owned().encrypt(&mut ctx, key).unwrap();
359        let surname_encrypted = "Doe".to_owned().encrypt(&mut ctx, key).unwrap();
360        let issuing_country_encrypted = "US".to_owned().encrypt(&mut ctx, key).unwrap();
361
362        let passport = Passport {
363            surname: Some(surname_encrypted),
364            given_name: Some(given_name_encrypted),
365            date_of_birth: None,
366            sex: None,
367            birth_place: None,
368            nationality: None,
369            issuing_country: Some(issuing_country_encrypted),
370            passport_number: None,
371            passport_type: None,
372            national_identification_number: None,
373            issuing_authority: None,
374            issue_date: None,
375            expiration_date: None,
376        };
377
378        assert_eq!(
379            passport.decrypt_subtitle(&mut ctx, key).unwrap(),
380            "Jane Doe, US".to_string()
381        );
382    }
383
384    #[test]
385    fn test_build_subtitle_passport() {
386        // All fields present
387        assert_eq!(
388            build_subtitle_passport(
389                Some("Jane".to_string()),
390                Some("Doe".to_string()),
391                Some("US".to_string()),
392            ),
393            "Jane Doe, US"
394        );
395
396        // Names only, no issuing country
397        assert_eq!(
398            build_subtitle_passport(Some("Jane".to_string()), Some("Doe".to_string()), None),
399            "Jane Doe"
400        );
401
402        // Issuing country only
403        assert_eq!(
404            build_subtitle_passport(None, None, Some("US".to_string())),
405            "US"
406        );
407
408        // Surname and issuing country, no given name
409        assert_eq!(
410            build_subtitle_passport(None, Some("Doe".to_string()), Some("US".to_string())),
411            "Doe, US"
412        );
413
414        // Empty strings are treated as absent for separators
415        assert_eq!(
416            build_subtitle_passport(
417                Some("".to_string()),
418                Some("".to_string()),
419                Some("US".to_string()),
420            ),
421            "US"
422        );
423        assert_eq!(
424            build_subtitle_passport(
425                Some("Jane".to_string()),
426                Some("".to_string()),
427                Some("US".to_string()),
428            ),
429            "Jane, US"
430        );
431
432        // Nothing present
433        assert_eq!(build_subtitle_passport(None, None, None), "");
434    }
435
436    #[test]
437    fn test_get_copyable_fields_passport() {
438        let enc_str: EncString = "2.tMIugb6zQOL+EuOizna1wQ==|W5dDLoNJtajN68yeOjrr6w==|qS4hwJB0B0gNLI0o+jxn+sKMBmvtVgJCRYNEXBZoGeE=".parse().unwrap();
439
440        let passport = Passport {
441            surname: Some(enc_str.clone()),
442            given_name: Some(enc_str.clone()),
443            date_of_birth: Some(enc_str.clone()),
444            sex: Some(enc_str.clone()),
445            birth_place: Some(enc_str.clone()),
446            nationality: Some(enc_str.clone()),
447            issuing_country: Some(enc_str.clone()),
448            passport_number: Some(enc_str.clone()),
449            passport_type: Some(enc_str.clone()),
450            national_identification_number: Some(enc_str.clone()),
451            issuing_authority: Some(enc_str.clone()),
452            issue_date: Some(enc_str.clone()),
453            expiration_date: Some(enc_str),
454        };
455
456        let copyable_fields = passport.get_copyable_fields(None);
457        assert_eq!(
458            copyable_fields,
459            vec![
460                CopyableCipherFields::PassportGivenName,
461                CopyableCipherFields::PassportSurname,
462                CopyableCipherFields::PassportPassportNumber,
463                CopyableCipherFields::PassportNationalIdentificationNumber,
464            ]
465        );
466    }
467}