diff --git a/BitwardenShared/Core/Vault/Services/CardTextParser.swift b/BitwardenShared/Core/Vault/Services/CardTextParser.swift index 700a1498a4..9a20bacf38 100644 --- a/BitwardenShared/Core/Vault/Services/CardTextParser.swift +++ b/BitwardenShared/Core/Vault/Services/CardTextParser.swift @@ -92,6 +92,7 @@ final class DefaultCardTextParser: CardTextParser { guard digits.count >= 13, digits.count <= 19 else { return nil } // Reject sequences that look like dates or years (e.g. 8-digit numbers that match no Luhn prefix) guard !looksLikeDateOrYear(digits) else { return nil } + guard isLuhnValid(digits), hasValidLength(digits) else { return nil } return digits } @@ -167,4 +168,23 @@ final class DefaultCardTextParser: CardTextParser { } return false } + + /// Returns `true` if `digits` has a length valid for the brand detected from its leading digits. + private func hasValidLength(_ digits: String) -> Bool { + CardComponent.Brand.detect(from: digits).validDigitLengths.contains(digits.count) + } + + /// Returns `true` if `digits` satisfies the Luhn mod-10 checksum. + private func isLuhnValid(_ digits: String) -> Bool { + var sum = 0 + for (index, char) in digits.reversed().enumerated() { + guard var digit = char.wholeNumberValue else { return false } + if !index.isMultiple(of: 2) { + digit *= 2 + if digit > 9 { digit -= 9 } + } + sum += digit + } + return sum.isMultiple(of: 10) + } } diff --git a/BitwardenShared/Core/Vault/Services/CardTextParserTests.swift b/BitwardenShared/Core/Vault/Services/CardTextParserTests.swift index 926aef5016..94eb7c467f 100644 --- a/BitwardenShared/Core/Vault/Services/CardTextParserTests.swift +++ b/BitwardenShared/Core/Vault/Services/CardTextParserTests.swift @@ -27,7 +27,7 @@ struct CardTextParserTests { ["4111-1111-1111-1111"], ["378282246310005"], ["4012888888881"], - ["6011000990139424000"], + ["6011000000000000001"], ["4111", "1111", "1111", "1111"], ["3782", "822463", "10005"], ["4111111111111111", "1234"], @@ -38,7 +38,7 @@ struct CardTextParserTests { "4111111111111111", "378282246310005", "4012888888881", - "6011000990139424000", + "6011000000000000001", "4111111111111111", "378282246310005", "4111111111111111", @@ -123,4 +123,40 @@ struct CardTextParserTests { #expect(result.expirationMonth == 12) #expect(result.expirationYear == "2028") } + + // MARK: Tests – Luhn + Brand-Length Validation + + /// `parseCard(lines:)` rejects a 16-digit Visa-length number that fails the Luhn checksum. + @Test + func parseCard_luhnInvalid_rejectsCardNumber() { + // 4111111111111112 differs from the valid test card by one digit — Luhn sum becomes 41. + let result = subject.parseCard(lines: ["4111111111111112"]) + #expect(result.cardNumber == nil) + } + + /// `parseCard(lines:)` rejects a 16-digit number whose leading digits identify it as Amex + /// (expected length 15), even though it passes the Luhn checksum. + @Test + func parseCard_brandLengthMismatch_rejectsAmexWith16Digits() { + // 3400000000000000: starts with 34 (Amex prefix), 16 digits, Luhn sum = 10 ✓. + let result = subject.parseCard(lines: ["3400000000000000"]) + #expect(result.cardNumber == nil) + } + + /// `parseCard(lines:)` accepts a valid 14-digit Diners Club number. + @Test + func parseCard_validDinersClub_extractsCardNumber() { + // 30569309025904: standard Diners test card, 14 digits, Luhn sum = 50 ✓. + let result = subject.parseCard(lines: ["30569309025904"]) + #expect(result.cardNumber == "30569309025904") + } + + /// `parseCard(lines:)` accepts a Luhn-valid number whose brand is unrecognised, + /// provided its length falls within the generic 13–19 digit range. + @Test + func parseCard_unknownBrandLuhnValid_extractsCardNumber() { + // 9400000000003: 13 digits, prefix 94 → .other, Luhn sum = 20 ✓. + let result = subject.parseCard(lines: ["9400000000003"]) + #expect(result.cardNumber == "9400000000003") + } } diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponent.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponent.swift index d57c8514b5..8f778e56fb 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponent.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponent.swift @@ -267,6 +267,27 @@ extension CardComponent.Brand { } } +extension CardComponent.Brand { + /// The set of valid PAN lengths for this card brand. + /// + /// Used during card scanning to reject numbers whose digit count does not match + /// the brand inferred from their leading digits. + var validDigitLengths: Set { + switch self { + case .americanExpress: [15] + case .dinersClub: [14] + case .discover: [16, 17, 18, 19] + case .jcb: [15, 16, 17, 18, 19] + case .maestro: Set(12 ... 19) + case .mastercard: [16] + case .other: Set(13 ... 19) + case .ruPay: [16] + case .unionPay: Set(16 ... 19) + case .visa: [13, 16, 19] + } + } +} + extension CardComponent.Month: CaseIterable {} extension CardComponent.Month: Menuable { /// default state title for title type diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponentTests.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponentTests.swift index 576e949837..8406361bae 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponentTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponentTests.swift @@ -227,4 +227,56 @@ class CardComponentBrandTests: BitwardenTestCase { XCTAssertEqual(CardComponent.Brand.visa.formattedCardNumber("4111-1111-1111-1111"), "4111-1111-1111-1111") XCTAssertEqual(CardComponent.Brand.visa.formattedCardNumber("abcd"), "abcd") } + + // MARK: Tests – validDigitLengths + + /// `validDigitLengths` returns `[15]` for American Express. + func test_validDigitLengths_americanExpress() { + XCTAssertEqual(CardComponent.Brand.americanExpress.validDigitLengths, [15]) + } + + /// `validDigitLengths` returns `[14]` for Diners Club. + func test_validDigitLengths_dinersClub() { + XCTAssertEqual(CardComponent.Brand.dinersClub.validDigitLengths, [14]) + } + + /// `validDigitLengths` returns `[16, 17, 18, 19]` for Discover. + func test_validDigitLengths_discover() { + XCTAssertEqual(CardComponent.Brand.discover.validDigitLengths, [16, 17, 18, 19]) + } + + /// `validDigitLengths` returns `[15, 16, 17, 18, 19]` for JCB. + func test_validDigitLengths_jcb() { + XCTAssertEqual(CardComponent.Brand.jcb.validDigitLengths, [15, 16, 17, 18, 19]) + } + + /// `validDigitLengths` returns the full 12–19 range for Maestro. + func test_validDigitLengths_maestro() { + XCTAssertEqual(CardComponent.Brand.maestro.validDigitLengths, Set(12 ... 19)) + } + + /// `validDigitLengths` returns `[16]` for Mastercard. + func test_validDigitLengths_mastercard() { // swiftlint:disable:this inclusive_language + XCTAssertEqual(CardComponent.Brand.mastercard.validDigitLengths, [16]) + } + + /// `validDigitLengths` returns the full 13–19 range for unknown brands. + func test_validDigitLengths_other() { + XCTAssertEqual(CardComponent.Brand.other.validDigitLengths, Set(13 ... 19)) + } + + /// `validDigitLengths` returns `[16]` for RuPay. + func test_validDigitLengths_ruPay() { + XCTAssertEqual(CardComponent.Brand.ruPay.validDigitLengths, [16]) + } + + /// `validDigitLengths` returns `[16, 17, 18, 19]` for UnionPay. + func test_validDigitLengths_unionPay() { + XCTAssertEqual(CardComponent.Brand.unionPay.validDigitLengths, Set(16 ... 19)) + } + + /// `validDigitLengths` returns `[13, 16, 19]` for Visa. + func test_validDigitLengths_visa() { + XCTAssertEqual(CardComponent.Brand.visa.validDigitLengths, [13, 16, 19]) + } }