Skip to main content

bitwarden_vault/cipher/
card.rs

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