Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions BitwardenShared/Core/Vault/Services/CardTextParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -167,4 +168,23 @@ final class DefaultCardTextParser: CardTextParser {
}
return false
}

/// 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)
}

/// 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)
}
Comment on lines +172 to +189

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏️ Can you alphabetize these?

}
40 changes: 38 additions & 2 deletions BitwardenShared/Core/Vault/Services/CardTextParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ struct CardTextParserTests {
["4111-1111-1111-1111"],
["378282246310005"],
["4012888888881"],
["6011000990139424000"],
["6011000000000000001"],
["4111", "1111", "1111", "1111"],
["3782", "822463", "10005"],
["4111111111111111", "1234"],
Expand All @@ -38,7 +38,7 @@ struct CardTextParserTests {
"4111111111111111",
"378282246310005",
"4012888888881",
"6011000990139424000",
"6011000000000000001",
"4111111111111111",
"378282246310005",
"4111111111111111",
Expand Down Expand Up @@ -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() {
// 9400000000007: 13 digits, prefix 94 β†’ .other, Luhn sum = 20 βœ“.
let result = subject.parseCard(lines: ["9400000000007"])
#expect(result.cardNumber == "9400000000007")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
Loading