bitwarden_vault/cipher/
card.rs

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