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#[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 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
208pub(super) fn build_subtitle_card(brand: Option<String>, number: Option<String>) -> String {
210 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 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}