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