Skip to content
Draft
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
221 changes: 151 additions & 70 deletions app/ios/LiveMRZScannerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct LiveMRZScannerView: View {
@State private var lastMRZDetection: Date = Date()
@State private var parsedMRZ: QKMRZResult? = nil
@State private var scanComplete: Bool = false
@State private var overrideDocumentNumber: String? = nil // for TD1 overflow format (ID cards)
var onScanComplete: ((QKMRZResult) -> Void)? = nil
var onScanResultAsDict: (([String: Any]) -> Void)? = nil

Expand Down Expand Up @@ -60,12 +61,17 @@ struct LiveMRZScannerView: View {
}

private func mapVisionResultToDictionary(_ result: QKMRZResult) -> [String: Any] {

// using manually validated document number for TD1 documents with overflow format
// this is necessary for NFC chip authentication which requires the full document number
let documentNumber = overrideDocumentNumber ?? result.documentNumber

return [
"documentType": result.documentType,
"countryCode": result.countryCode,
"surnames": result.surnames,
"givenNames": result.givenNames,
"documentNumber": result.documentNumber,
"documentNumber": documentNumber, // using the overriden if available
"nationalityCountryCode": result.nationalityCountryCode,
"dateOfBirth": result.birthdate?.description ?? "",
"sex": result.sex ?? "",
Expand All @@ -80,44 +86,106 @@ struct LiveMRZScannerView: View {
]
}

private func correctBelgiumDocumentNumber(result: String) -> String? {
// Belgium TD1 format: IDBEL000001115<7027
let line1RegexPattern = "IDBEL(?<doc9>[A-Z0-9]{9})<(?<doc3>[A-Z0-9<]{3})(?<checkDigit>\\d)"
guard let line1Regex = try? NSRegularExpression(pattern: line1RegexPattern) else { return nil }
let line1Matcher = line1Regex.firstMatch(in: result, options: [], range: NSRange(location: 0, length: result.count))
/// Calculates the MRZ check digit using the ICAO 9303 standard
private func calculateMRZCheckDigit(_ input: String) -> Int {
let weights = [7, 3, 1]
var sum = 0

for (index, char) in input.enumerated() {
let value: Int
if char.isNumber {
value = Int(String(char)) ?? 0
} else if char.isLetter {
// mapping letters to values: A=10, B=11, ..., Z=35
value = Int(char.asciiValue ?? 0) - Int(Character("A").asciiValue ?? 0) + 10
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Check digit calculation incorrect for lowercase letters

The calculateMRZCheckDigit function uses char.isLetter which matches both uppercase and lowercase letters, but the value calculation assumes uppercase ASCII values ('A' = 65). For lowercase letters like 'a' (ASCII 97), the calculation produces 97 - 65 + 10 = 42 instead of the correct value 10. This would cause incorrect check digit results if OCR produces lowercase characters, potentially rejecting valid documents. While MRZ data is defined as uppercase per ICAO 9303, the function would silently produce wrong results rather than handling this case explicitly.

Fix in Cursor Fix in Web

} else if char == "<" {
value = 0
} else {
value = 0
}

if let line1Matcher = line1Matcher {
let doc9Range = line1Matcher.range(withName: "doc9")
let doc3Range = line1Matcher.range(withName: "doc3")
let checkDigitRange = line1Matcher.range(withName: "checkDigit")
let weight = weights[index % 3]
sum += value * weight
}

let doc9 = (result as NSString).substring(with: doc9Range)
let doc3 = (result as NSString).substring(with: doc3Range)
let checkDigit = (result as NSString).substring(with: checkDigitRange)
return sum % 10
}

if let cleanedDoc = cleanBelgiumDocumentNumber(doc9: doc9, doc3: doc3, checkDigit: checkDigit) {
let correctedMRZLine = "IDBEL\(cleanedDoc)\(checkDigit)"
return correctedMRZLine
}
/// Extracts and validates the document number from TD1 MRZ line 1, handling both standard and overflow formats.
/// TD1 format uses an overflow mechanism when document numbers exceed 9 digits.
/// Example overflow format: IDBEL595392450<8039<<<<<<<<<< where positions 6-14 contain the principal part (595392450),
/// position 15 contains the overflow indicator (<), positions 16-18 contain overflow digits (803), and position 19 contains the check digit (9).
/// The full document number becomes: 595392450803.
/// This overflow format can occur for any country using TD1 MRZ (ID cards).
private func extractAndValidateTD1DocumentNumber(line1: String) -> (documentNumber: String, isValid: Bool)? {
guard line1.count == 30 else { return nil }

// extracting positions 6-14 (9 characters - principal part)
let startIndex6 = line1.index(line1.startIndex, offsetBy: 5)
let endIndex14 = line1.index(line1.startIndex, offsetBy: 14)
let principalPart = String(line1[startIndex6..<endIndex14])

// checking position 15 for overflow indicator
let pos15Index = line1.index(line1.startIndex, offsetBy: 14)
let pos15 = line1[pos15Index]

if pos15 != "<" {
// handling standard format where position 15 is the check digit
let checkDigit = Int(String(pos15)) ?? -1
let calculatedCheck = calculateMRZCheckDigit(principalPart)
let isValid = (checkDigit == calculatedCheck)
print("[extractAndValidateTD1DocumentNumber] Standard format: \(principalPart), check=\(checkDigit), calculated=\(calculatedCheck), valid=\(isValid)")
return (principalPart, isValid)
}
return nil
}

private func cleanBelgiumDocumentNumber(doc9: String, doc3: String, checkDigit: String) -> String? {
// For Belgium TD1 format: IDBEL000001115<7027
// doc9 = "000001115" (9 digits)
// doc3 = "702" (3 digits after <)
// checkDigit = "7" (single check digit)
// handling overflow format: scanning positions 16+ until we hit <
let pos16Index = line1.index(line1.startIndex, offsetBy: 15)
let remainingPart = String(line1[pos16Index...])

// finding the overflow digits and the check digit
var overflowDigits = ""
var checkDigitChar: Character?

var cleanDoc9 = doc9
// Strip first 3 characters
let startIndex = cleanDoc9.index(cleanDoc9.startIndex, offsetBy: 3)
cleanDoc9 = String(cleanDoc9[startIndex...])
for char in remainingPart {
if char == "<" {
break
}
overflowDigits.append(char)
}

guard overflowDigits.count > 0 else {
print("[extractAndValidateTD1DocumentNumber] ERROR: No overflow digits found")
return nil
}

let fullDocumentNumber = cleanDoc9 + doc3
// extracting check digit (last character of overflow)
checkDigitChar = overflowDigits.last
let overflowWithoutCheck = String(overflowDigits.dropLast())

// constructing full document number: principal + overflow (without check digit)
let fullDocumentNumber = principalPart + overflowWithoutCheck

return fullDocumentNumber
// validating check digit against full document number
guard let checkDigitChar = checkDigitChar,
let checkDigit = Int(String(checkDigitChar)) else {
print("[extractAndValidateTD1DocumentNumber] ERROR: Invalid check digit")
return nil
}
let calculatedCheck = calculateMRZCheckDigit(fullDocumentNumber)
let isValid = (checkDigit == calculatedCheck)

#if DEBUG
print("[extractAndValidateTD1DocumentNumber] Overflow format:")
print(" Principal part (6-14): \(principalPart)")
print(" Overflow with check: \(overflowDigits)")
print(" Overflow without check: \(overflowWithoutCheck)")
print(" Full document number: \(fullDocumentNumber)")
print(" Check digit: \(checkDigit)")
print(" Calculated check: \(calculatedCheck)")
print(" Valid: \(isValid)")
#endif

return (fullDocumentNumber, isValid)
}
Comment on lines +114 to 189
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Solid TD1 overflow extraction logic with one concern: document numbers logged in production.

The extraction and validation logic correctly handles both standard TD1 format (9-digit document number with check digit at position 15) and the overflow format per ICAO 9303. The check digit validation against the reconstructed full document number is properly implemented.

However, lines 137-138 log the document number in production builds (outside #if DEBUG). Per coding guidelines, passport/document numbers are PII and should never be logged in production.

-            print("[extractAndValidateTD1DocumentNumber] Standard format: \(principalPart), check=\(checkDigit), calculated=\(calculatedCheck), valid=\(isValid)")
+            #if DEBUG
+            print("[extractAndValidateTD1DocumentNumber] Standard format: [REDACTED], check=\(checkDigit), calculated=\(calculatedCheck), valid=\(isValid)")
+            #endif
             return (principalPart, isValid)
🤖 Prompt for AI Agents
In app/ios/LiveMRZScannerView.swift around lines 114 to 189, remove or guard the
production logs that print document numbers (lines ~137-138 and any other prints
that expose principalPart/fullDocumentNumber) so PII is never logged in
non-DEBUG builds; wrap all diagnostic print statements that include document
numbers in #if DEBUG ... #endif blocks or replace them with non-PII status logs
(e.g., "document validation succeeded/failed") and, if needed, log only a
redacted/hashed identifier instead of the raw document number.


private func isValidMRZResult(_ result: QKMRZResult) -> Bool {
Expand All @@ -131,59 +199,69 @@ struct LiveMRZScannerView: View {
onScanResultAsDict?(mapVisionResultToDictionary(result))
}

private func processBelgiumDocument(result: String, parser: QKMRZParser) -> QKMRZResult? {
print("[LiveMRZScannerView] Processing Belgium document")
/// Processes TD1 documents (ID cards) by manually extracting and validating the document number using the overflow format handler,
/// then parses the remaining MRZ fields (name, dates, etc.) using QKMRZParser. This bypasses QKMRZParser's validation for the
/// document number field since it doesn't handle TD1 overflow format correctly.
private func processTD1DocumentWithOverflow(result: String, parser: QKMRZParser) -> QKMRZResult? {
print("[LiveMRZScannerView] Processing TD1 document with manual overflow validation")

guard let correctedBelgiumLine = correctBelgiumDocumentNumber(result: result) else {
print("[LiveMRZScannerView] Failed to correct Belgium document number")
return nil
}

// print("[LiveMRZScannerView] Belgium corrected line: \(correctedBelgiumLine)")

// Split MRZ into lines and replace the first line
let lines = result.components(separatedBy: "\n")
guard lines.count >= 3 else {
print("[LiveMRZScannerView] Invalid MRZ format - not enough lines")
return nil
}

let originalFirstLine = lines[0]
// print("[LiveMRZScannerView] Original first line: \(originalFirstLine)")

// Pad the corrected line to 30 characters (TD1 format)
let paddedCorrectedLine = correctedBelgiumLine.padding(toLength: 30, withPad: "<", startingAt: 0)
// print("[LiveMRZScannerView] Padded corrected line: \(paddedCorrectedLine)")
let line1 = lines[0]
print("[LiveMRZScannerView] Line 1: \(line1)")

// Reconstruct the MRZ with the corrected first line
var correctedLines = lines
correctedLines[0] = paddedCorrectedLine
let correctedMRZString = correctedLines.joined(separator: "\n")
// print("[LiveMRZScannerView] Corrected MRZ string: \(correctedMRZString)")
// extracting and validating document number manually using overflow format handler
guard let (documentNumber, isDocNumberValid) = extractAndValidateTD1DocumentNumber(line1: line1) else {
print("[LiveMRZScannerView] Failed to extract TD1 document number")
return nil
}

guard let belgiumMRZResult = parser.parse(mrzString: correctedMRZString) else {
print("[LiveMRZScannerView] Belgium MRZ result is not valid")
if !isDocNumberValid {
print("[LiveMRZScannerView] TD1 document number check digit is INVALID")
return nil
}

// print("[LiveMRZScannerView] Belgium MRZ result: \(belgiumMRZResult)")
print("[LiveMRZScannerView] TD1 document number validated: \(documentNumber) ✓")
Comment on lines +224 to +228
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove PII logging in TD1 overflow handler

The new TD1 overflow flow prints the MRZ line and validated document number with unconditional print calls, e.g. logging documentNumber at line 228, and the surrounding lines are not wrapped in #if DEBUG. The app/AGENTS.md checklist explicitly calls out “No sensitive data in logs (PII, credentials, tokens)”, but this code will emit passport/ID numbers whenever a TD1 document is scanned in production builds, leaking PII to device logs.

Useful? React with 👍 / 👎.


// Try the corrected MRZ first
if isValidMRZResult(belgiumMRZResult) {
return belgiumMRZResult
// parsing the original MRZ to get all other fields (name, birthdate, etc.)
// using QKMRZParser for non-documentNumber fields
guard let mrzResult = parser.parse(mrzString: result) else {
print("[LiveMRZScannerView] Failed to parse MRZ with QKMRZParser")
return nil
}

// If document number is still invalid, try single character correction
if !belgiumMRZResult.isDocumentNumberValid {
if let correctedResult = singleCorrectDocumentNumberInMRZ(result: correctedMRZString, docNumber: belgiumMRZResult.documentNumber, parser: parser) {
// print("[LiveMRZScannerView] Single correction successful: \(correctedResult)")
if isValidMRZResult(correctedResult) {
return correctedResult
}
}
// validating that other fields are also correct
if !mrzResult.isBirthdateValid || !mrzResult.isExpiryDateValid {
print("[LiveMRZScannerView] TD1 document has invalid birthdate or expiry date")
return nil
}

return nil
#if DEBUG
print("[LiveMRZScannerView] QKMRZParser extracted fields:")
print(" countryCode: \(mrzResult.countryCode)")
print(" surnames: \(mrzResult.surnames)")
print(" givenNames: \(mrzResult.givenNames)")
print(" birthdate: \(mrzResult.birthdate?.description ?? "nil")")
print(" sex: \(mrzResult.sex ?? "nil")")
print(" expiryDate: \(mrzResult.expiryDate?.description ?? "nil")")
print(" personalNumber: \(mrzResult.personalNumber)")
print(" Parser's documentNumber: \(mrzResult.documentNumber)")
print(" Our validated documentNumber: \(documentNumber)")
#endif

// storing the manually validated full document number
// this will be used for NFC chip authentication (BAC keys)
overrideDocumentNumber = documentNumber
#if DEBUG
print("[LiveMRZScannerView] Set overrideDocumentNumber to: \(documentNumber)")
#endif

// returning MRZ result, the document number will be overridden in mapVisionResultToDictionary
return mrzResult
}
Comment on lines +202 to 265
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

TD1 processing flow is well-structured, but production logs expose PII.

The processTD1DocumentWithOverflow function correctly:

  1. Validates document number using the overflow format handler
  2. Parses remaining fields via QKMRZParser
  3. Validates birthdate and expiry dates
  4. Stores the validated document number for NFC chip authentication

However, lines 206, 215, 228 log document numbers and MRZ line data in production builds. These contain PII and should be guarded with #if DEBUG or redacted.

 private func processTD1DocumentWithOverflow(result: String, parser: QKMRZParser) -> QKMRZResult? {
-        print("[LiveMRZScannerView] Processing TD1 document with manual overflow validation")
+        #if DEBUG
+        print("[LiveMRZScannerView] Processing TD1 document with manual overflow validation")
+        #endif

         let lines = result.components(separatedBy: "\n")
         guard lines.count >= 3 else {
-            print("[LiveMRZScannerView] Invalid MRZ format - not enough lines")
+            #if DEBUG
+            print("[LiveMRZScannerView] Invalid MRZ format - not enough lines")
+            #endif
             return nil
         }

         let line1 = lines[0]
-        print("[LiveMRZScannerView] Line 1: \(line1)")
+        #if DEBUG
+        print("[LiveMRZScannerView] Line 1: [REDACTED]")
+        #endif

         // extracting and validating document number manually using overflow format handler
         guard let (documentNumber, isDocNumberValid) = extractAndValidateTD1DocumentNumber(line1: line1) else {
-            print("[LiveMRZScannerView] Failed to extract TD1 document number")
+            #if DEBUG
+            print("[LiveMRZScannerView] Failed to extract TD1 document number")
+            #endif
             return nil
         }

         if !isDocNumberValid {
-            print("[LiveMRZScannerView] TD1 document number check digit is INVALID")
+            #if DEBUG
+            print("[LiveMRZScannerView] TD1 document number check digit is INVALID")
+            #endif
             return nil
         }

-        print("[LiveMRZScannerView] TD1 document number validated: \(documentNumber) ✓")
+        #if DEBUG
+        print("[LiveMRZScannerView] TD1 document number validated: [REDACTED] ✓")
+        #endif

         // parsing the original MRZ to get all other fields (name, birthdate, etc.)
         // using QKMRZParser for non-documentNumber fields
         guard let mrzResult = parser.parse(mrzString: result) else {
-            print("[LiveMRZScannerView] Failed to parse MRZ with QKMRZParser")
+            #if DEBUG
+            print("[LiveMRZScannerView] Failed to parse MRZ with QKMRZParser")
+            #endif
             return nil
         }

         // validating that other fields are also correct
         if !mrzResult.isBirthdateValid || !mrzResult.isExpiryDateValid {
-            print("[LiveMRZScannerView] TD1 document has invalid birthdate or expiry date")
+            #if DEBUG
+            print("[LiveMRZScannerView] TD1 document has invalid birthdate or expiry date")
+            #endif
             return nil
         }
🤖 Prompt for AI Agents
In app/ios/LiveMRZScannerView.swift around lines 202–265, several print
statements (notably at ~206, ~215, ~228) emit MRZ line data and the document
number in production; wrap all prints that expose PII (line1, parser
documentNumber, our validated documentNumber, and any raw MRZ content) in #if
DEBUG / #endif blocks or redact the sensitive values before logging, and ensure
overrideDocumentNumber assignment remains but its logging is only inside DEBUG;
update all debug prints in this function accordingly so no PII is logged in
release builds.


var body: some View {
Expand All @@ -208,10 +286,13 @@ struct LiveMRZScannerView: View {
return
}

// Handle Belgium documents (only if not already valid)
if doc.countryCode == "BEL" {
if let belgiumResult = processBelgiumDocument(result: result, parser: parser) {
handleValidMRZResult(belgiumResult)
// handling TD1 documents with potential overflow format (only if not already valid)
// TD1 format has 3 lines of 30 characters each
let lines = result.components(separatedBy: "\n")
if lines.count >= 3 && lines[0].count == 30 {
// trying overflow validation
if let td1Result = processTD1DocumentWithOverflow(result: result, parser: parser) {
handleValidMRZResult(td1Result)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

TD1 documents lose OCR correction fallback for document numbers

The old processBelgiumDocument function included a fallback to singleCorrectDocumentNumberInMRZ when the document number check digit was invalid (likely due to OCR errors). The new processTD1DocumentWithOverflow function lacks this fallback—when isDocNumberValid is false, it simply returns nil. Additionally, the main flow now returns early at line 297 for ALL TD1 documents (not just Belgium), preventing them from reaching the singleCorrectDocumentNumberInMRZ fallback at lines 301-308. This regression means TD1 documents with minor OCR errors in the document number that could have been corrected by single character replacement will now fail to scan.

Additional Locations (1)

Fix in Cursor Fix in Web

}
return
}
Expand Down
11 changes: 8 additions & 3 deletions app/ios/PassportReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,14 @@ class PassportReader: NSObject {
}

func pad( _ value : String, fieldLength:Int ) -> String {
// Pad out field lengths with < if they are too short
let paddedValue = (value + String(repeating: "<", count: fieldLength)).prefix(fieldLength)
return String(paddedValue)
// for values shorter than fieldLength, we pad with < characters
// for values equal to or longer than fieldLength, we use the value as-is (we don't truncate)
// this is critical for TD1 documents with overflow format where document numbers exceed 9 characters
if value.count >= fieldLength {
return value
}
let paddedValue = value + String(repeating: "<", count: fieldLength - value.count)
return paddedValue
}

func calcCheckSum( _ checkString : String ) -> Int {
Expand Down
9 changes: 6 additions & 3 deletions packages/mobile-sdk-alpha/src/processing/mrz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,12 @@ function validateTD3CheckDigits(lines: string[]): Omit<MRZValidation, 'format' |
}

export function checkScannedInfo(passportNumber: string, dateOfBirth: string, dateOfExpiry: string): boolean {
if (passportNumber.length > 9) {
return false;
}
// TD1 overflow format allows document numbers > 9 characters per ICAO 9303:
// When document numbers exceed 9 characters, the overflow digits are stored in the optional data
// field and the full document number is reconstructed during MRZ parsing.
// if (passportNumber.length > 9) {
// return false;
// }
if (dateOfBirth.length !== 6) {
return false;
}
Expand Down
Loading