Skip to main content

bitwarden_vault/cipher/
card.rs

1use bitwarden_api_api::models::CipherCardModel;
2use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
3use bitwarden_crypto::{
4    CompositeEncryptable, CryptoError, Decryptable, EncString, KeyStoreContext,
5    PrimitiveEncryptable,
6};
7use serde::{Deserialize, Serialize};
8
9use super::cipher::{CipherKind, StrictDecrypt};
10use crate::{Cipher, VaultParseError, cipher::cipher::CopyableCipherFields};
11
12#[serde_with::skip_serializing_none]
13#[derive(Serialize, Deserialize, Debug, Clone)]
14#[serde(rename_all = "camelCase")]
15#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
16#[bitwarden_ffi::wasm_record]
17pub struct Card {
18    pub cardholder_name: Option<EncString>,
19    pub exp_month: Option<EncString>,
20    pub exp_year: Option<EncString>,
21    pub code: Option<EncString>,
22    pub brand: Option<EncString>,
23    pub number: Option<EncString>,
24}
25
26#[serde_with::skip_serializing_none]
27#[allow(missing_docs)]
28#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
29#[serde(rename_all = "camelCase", deny_unknown_fields)]
30#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
31#[bitwarden_ffi::wasm_record]
32pub struct CardView {
33    pub cardholder_name: Option<String>,
34    pub exp_month: Option<String>,
35    pub exp_year: Option<String>,
36    pub code: Option<String>,
37    pub brand: Option<String>,
38    pub number: Option<String>,
39}
40
41/// Minimal CardView only including the needed details for list views
42#[serde_with::skip_serializing_none]
43#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
44#[serde(rename_all = "camelCase", deny_unknown_fields)]
45#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
46#[bitwarden_ffi::wasm_record]
47pub struct CardListView {
48    /// The brand of the card, e.g. Visa, Mastercard, etc.
49    pub brand: Option<String>,
50}
51
52#[allow(missing_docs)]
53#[derive(Serialize, Deserialize)]
54pub enum CardBrand {
55    Visa,
56    Mastercard,
57    Amex,
58    Discover,
59    #[serde(rename = "Diners Club")]
60    DinersClub,
61    #[serde(rename = "JCB")]
62    Jcb,
63    Maestro,
64    UnionPay,
65    RuPay,
66    #[serde(untagged)]
67    Other,
68}
69
70impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, Card> for CardView {
71    fn encrypt_composite(
72        &self,
73        ctx: &mut KeyStoreContext<KeySlotIds>,
74        key: SymmetricKeySlotId,
75    ) -> Result<Card, CryptoError> {
76        Ok(Card {
77            cardholder_name: self.cardholder_name.encrypt(ctx, key)?,
78            exp_month: self.exp_month.encrypt(ctx, key)?,
79            exp_year: self.exp_year.encrypt(ctx, key)?,
80            code: self.code.encrypt(ctx, key)?,
81            brand: self.brand.encrypt(ctx, key)?,
82            number: self.number.encrypt(ctx, key)?,
83        })
84    }
85}
86
87impl Decryptable<KeySlotIds, SymmetricKeySlotId, CardListView> for Card {
88    fn decrypt(
89        &self,
90        ctx: &mut KeyStoreContext<KeySlotIds>,
91        key: SymmetricKeySlotId,
92    ) -> Result<CardListView, CryptoError> {
93        Ok(CardListView {
94            brand: self.brand.decrypt(ctx, key).ok().flatten(),
95        })
96    }
97}
98
99impl Decryptable<KeySlotIds, SymmetricKeySlotId, CardView> for Card {
100    fn decrypt(
101        &self,
102        ctx: &mut KeyStoreContext<KeySlotIds>,
103        key: SymmetricKeySlotId,
104    ) -> Result<CardView, CryptoError> {
105        Ok(CardView {
106            cardholder_name: self.cardholder_name.decrypt(ctx, key).ok().flatten(),
107            exp_month: self.exp_month.decrypt(ctx, key).ok().flatten(),
108            exp_year: self.exp_year.decrypt(ctx, key).ok().flatten(),
109            code: self.code.decrypt(ctx, key).ok().flatten(),
110            brand: self.brand.decrypt(ctx, key).ok().flatten(),
111            number: self.number.decrypt(ctx, key).ok().flatten(),
112        })
113    }
114}
115
116impl Decryptable<KeySlotIds, SymmetricKeySlotId, CardListView> for StrictDecrypt<&Card> {
117    fn decrypt(
118        &self,
119        ctx: &mut KeyStoreContext<KeySlotIds>,
120        key: SymmetricKeySlotId,
121    ) -> Result<CardListView, CryptoError> {
122        Ok(CardListView {
123            brand: self.0.brand.decrypt(ctx, key)?,
124        })
125    }
126}
127
128impl Decryptable<KeySlotIds, SymmetricKeySlotId, CardView> for StrictDecrypt<&Card> {
129    fn decrypt(
130        &self,
131        ctx: &mut KeyStoreContext<KeySlotIds>,
132        key: SymmetricKeySlotId,
133    ) -> Result<CardView, CryptoError> {
134        Ok(CardView {
135            cardholder_name: self.0.cardholder_name.decrypt(ctx, key)?,
136            exp_month: self.0.exp_month.decrypt(ctx, key)?,
137            exp_year: self.0.exp_year.decrypt(ctx, key)?,
138            code: self.0.code.decrypt(ctx, key)?,
139            brand: self.0.brand.decrypt(ctx, key)?,
140            number: self.0.number.decrypt(ctx, key)?,
141        })
142    }
143}
144
145impl TryFrom<CipherCardModel> for Card {
146    type Error = VaultParseError;
147
148    fn try_from(card: CipherCardModel) -> Result<Self, Self::Error> {
149        Ok(Self {
150            cardholder_name: EncString::try_from_optional(card.cardholder_name)?,
151            exp_month: EncString::try_from_optional(card.exp_month)?,
152            exp_year: EncString::try_from_optional(card.exp_year)?,
153            code: EncString::try_from_optional(card.code)?,
154            brand: EncString::try_from_optional(card.brand)?,
155            number: EncString::try_from_optional(card.number)?,
156        })
157    }
158}
159
160impl From<Card> for bitwarden_api_api::models::CipherCardModel {
161    fn from(card: Card) -> Self {
162        Self {
163            cardholder_name: card.cardholder_name.map(|n| n.to_string()),
164            brand: card.brand.map(|b| b.to_string()),
165            number: card.number.map(|n| n.to_string()),
166            exp_month: card.exp_month.map(|m| m.to_string()),
167            exp_year: card.exp_year.map(|y| y.to_string()),
168            code: card.code.map(|c| c.to_string()),
169        }
170    }
171}
172
173impl CipherKind for Card {
174    fn decrypt_subtitle(
175        &self,
176        ctx: &mut KeyStoreContext<KeySlotIds>,
177        key: SymmetricKeySlotId,
178    ) -> Result<String, CryptoError> {
179        let brand = self
180            .brand
181            .as_ref()
182            .map(|b| b.decrypt(ctx, key))
183            .transpose()?;
184        let number = self
185            .number
186            .as_ref()
187            .map(|n| n.decrypt(ctx, key))
188            .transpose()?;
189
190        Ok(build_subtitle_card(brand, number))
191    }
192
193    fn get_copyable_fields(&self, _: Option<&Cipher>) -> Vec<CopyableCipherFields> {
194        [
195            self.number
196                .as_ref()
197                .map(|_| CopyableCipherFields::CardNumber),
198            self.code
199                .as_ref()
200                .map(|_| CopyableCipherFields::CardSecurityCode),
201        ]
202        .into_iter()
203        .flatten()
204        .collect()
205    }
206}
207
208/// Builds the subtitle for a card cipher
209pub(super) fn build_subtitle_card(brand: Option<String>, number: Option<String>) -> String {
210    // Attempt to pre-allocate the string with the expected max-size
211    let mut subtitle =
212        String::with_capacity(brand.as_ref().map(|b| b.len()).unwrap_or_default() + 8);
213
214    if let Some(brand) = brand {
215        subtitle.push_str(&brand);
216    }
217
218    if let Some(number) = number {
219        let number_chars: Vec<_> = number.chars().collect();
220        let number_len = number_chars.len();
221        if number_len > 4 {
222            if !subtitle.is_empty() {
223                subtitle.push_str(", ");
224            }
225
226            // On AMEX cards we show 5 digits instead of 4
227            let digit_count = match number_chars[0..2] {
228                ['3', '4'] | ['3', '7'] => 5,
229                _ => 4,
230            };
231
232            subtitle.push('*');
233            subtitle.extend(number_chars.iter().skip(number_len - digit_count));
234        }
235    }
236
237    subtitle
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_build_subtitle_card_visa() {
246        let brand = Some("Visa".to_owned());
247        let number = Some("4111111111111111".to_owned());
248
249        let subtitle = build_subtitle_card(brand, number);
250        assert_eq!(subtitle, "Visa, *1111");
251    }
252
253    #[test]
254    fn test_build_subtitle_card_mastercard() {
255        let brand = Some("Mastercard".to_owned());
256        let number = Some("5555555555554444".to_owned());
257
258        let subtitle = build_subtitle_card(brand, number);
259        assert_eq!(subtitle, "Mastercard, *4444");
260    }
261
262    #[test]
263    fn test_build_subtitle_card_amex() {
264        let brand = Some("Amex".to_owned());
265        let number = Some("378282246310005".to_owned());
266
267        let subtitle = build_subtitle_card(brand, number);
268        assert_eq!(subtitle, "Amex, *10005");
269    }
270
271    #[test]
272    fn test_build_subtitle_card_underflow() {
273        let brand = Some("Mastercard".to_owned());
274        let number = Some("4".to_owned());
275
276        let subtitle = build_subtitle_card(brand, number);
277        assert_eq!(subtitle, "Mastercard");
278    }
279
280    #[test]
281    fn test_build_subtitle_card_only_brand() {
282        let brand = Some("Mastercard".to_owned());
283        let number = None;
284
285        let subtitle = build_subtitle_card(brand, number);
286        assert_eq!(subtitle, "Mastercard");
287    }
288
289    #[test]
290    fn test_build_subtitle_card_only_card() {
291        let brand = None;
292        let number = Some("5555555555554444".to_owned());
293
294        let subtitle = build_subtitle_card(brand, number);
295        assert_eq!(subtitle, "*4444");
296    }
297    #[test]
298    fn test_get_copyable_fields_code() {
299        let card = Card {
300            cardholder_name: None,
301            exp_month: None,
302            exp_year: None,
303            code: Some("2.6TpmzzaQHgYr+mXjdGLQlg==|vT8VhfvMlWSCN9hxGYftZ5rjKRsZ9ofjdlUCx5Gubnk=|uoD3/GEQBWKKx2O+/YhZUCzVkfhm8rFK3sUEVV84mv8=".parse().unwrap()),
304            brand: None,
305            number: None,
306        };
307
308        let copyable_fields = card.get_copyable_fields(None);
309
310        assert_eq!(
311            copyable_fields,
312            vec![CopyableCipherFields::CardSecurityCode]
313        );
314    }
315
316    #[test]
317    fn test_build_subtitle_card_unicode() {
318        let brand = Some("Visa".to_owned());
319        let number = Some("•••• 3278".to_owned());
320
321        let subtitle = build_subtitle_card(brand, number);
322        assert_eq!(subtitle, "Visa, *3278");
323    }
324
325    #[test]
326    fn test_get_copyable_fields_number() {
327        let card = Card {
328            cardholder_name: None,
329            exp_month: None,
330            exp_year: None,
331            code: None,
332            brand: None,
333            number: Some("2.6TpmzzaQHgYr+mXjdGLQlg==|vT8VhfvMlWSCN9hxGYftZ5rjKRsZ9ofjdlUCx5Gubnk=|uoD3/GEQBWKKx2O+/YhZUCzVkfhm8rFK3sUEVV84mv8=".parse().unwrap()),
334        };
335
336        let copyable_fields = card.get_copyable_fields(None);
337
338        assert_eq!(copyable_fields, vec![CopyableCipherFields::CardNumber]);
339    }
340}