From fe0a5a1725dbf6ca80051d28eb3044ea53dd6f03 Mon Sep 17 00:00:00 2001 From: Manwel Bugeja Date: Thu, 24 Apr 2025 13:55:35 +0200 Subject: [PATCH 1/5] add CAN-based PACE support without breaking MRZ compatibility --- Sources/NFCPassportReader/Errors.swift | 2 + .../NFCViewDisplayMessage.swift | 3 + Sources/NFCPassportReader/PACEHandler.swift | 50 ++++++++++---- .../PACEPasswordValidator.swift | 21 ++++++ .../NFCPassportReader/PassportReader.swift | 69 ++++++++++++++----- 5 files changed, 115 insertions(+), 30 deletions(-) create mode 100644 Sources/NFCPassportReader/PACEPasswordValidator.swift diff --git a/Sources/NFCPassportReader/Errors.swift b/Sources/NFCPassportReader/Errors.swift index b997b35d..9fbee6ea 100644 --- a/Sources/NFCPassportReader/Errors.swift +++ b/Sources/NFCPassportReader/Errors.swift @@ -42,6 +42,7 @@ public enum NFCPassportReaderError: Error { case InvalidDataPassed(String) case NotYetSupported(String) case Unknown(Error) + case InvalidCAN var value: String { switch self { @@ -77,6 +78,7 @@ public enum NFCPassportReaderError: Error { case .InvalidDataPassed(let reason) : return "Invalid data passed - \(reason)" case .NotYetSupported(let reason) : return "Not yet supported - \(reason)" case .Unknown(let error): return "Unknown error: \(error.localizedDescription)" + case .InvalidCAN: return "Invalid CAN format" } } } diff --git a/Sources/NFCPassportReader/NFCViewDisplayMessage.swift b/Sources/NFCPassportReader/NFCViewDisplayMessage.swift index 2539817d..f2fc88c6 100644 --- a/Sources/NFCPassportReader/NFCViewDisplayMessage.swift +++ b/Sources/NFCPassportReader/NFCViewDisplayMessage.swift @@ -15,6 +15,7 @@ public enum NFCViewDisplayMessage { case error(NFCPassportReaderError) case activeAuthentication case successfulRead + case requestPresentPassportForCAN } @available(iOS 13, macOS 10.15, *) @@ -48,6 +49,8 @@ extension NFCViewDisplayMessage { return "Authenticating....." case .successfulRead: return "Passport read successfully" + case .requestPresentPassportForCAN: + return "Hold your iPhone near an NFC enabled passport and enter your CAN." } } diff --git a/Sources/NFCPassportReader/PACEHandler.swift b/Sources/NFCPassportReader/PACEHandler.swift index 3be9a3f7..99114d92 100644 --- a/Sources/NFCPassportReader/PACEHandler.swift +++ b/Sources/NFCPassportReader/PACEHandler.swift @@ -35,6 +35,12 @@ extension PACEHandlerError: LocalizedError { } } +public enum PACEPasswordType { + case mrz + case can + // Other types could be added: PIN, PUK, etc. +} + @available(iOS 15, *) public class PACEHandler { @@ -72,7 +78,7 @@ public class PACEHandler { isPACESupported = true } - public func doPACE( mrzKey : String ) async throws { + public func doPACE(password: String, passwordType: PACEPasswordType = .mrz) async throws { guard isPACESupported else { throw NFCPassportReaderError.NotYetSupported( "PACE not supported" ) } @@ -88,8 +94,16 @@ public class PACEHandler { digestAlg = try paceInfo.getDigestAlgorithm() // Either SHA-1 or SHA-256. keyLength = try paceInfo.getKeyLength() // Get key length the enc cipher. Either 128, 192, or 256. - paceKeyType = PACEHandler.MRZ_PACE_KEY_REFERENCE - paceKey = try createPaceKey( from: mrzKey ) + // Set key reference based on password type + switch passwordType { + case .mrz: + paceKeyType = PACEHandler.MRZ_PACE_KEY_REFERENCE + case .can: + paceKeyType = PACEHandler.CAN_PACE_KEY_REFERENCE + } + + // Create PACE key with appropriate method based on type + paceKey = try createPaceKey(from: password, type: passwordType) // Temporary logging Logger.pace.debug("doPace - inpit parameters" ) @@ -100,7 +114,7 @@ public class PACEHandler { Logger.pace.debug("cipherAlg - \(self.cipherAlg)" ) Logger.pace.debug("digestAlg - \(self.digestAlg)" ) Logger.pace.debug("keyLength - \(self.keyLength)" ) - Logger.pace.debug("keyLength - \(mrzKey)" ) + Logger.pace.debug("keyLength - \(password)" ) Logger.pace.debug("paceKey - \(binToHexRep(self.paceKey, asArray:true))" ) // First start the initial auth call @@ -580,15 +594,27 @@ extension PACEHandler { } /// Computes a key seed based on an MRZ key - /// - Parameter the mrz key + /// - Parameters: + /// - password: The password to be used for PACE + /// - PACEPasswordType: The type of the password for example MRZ, CAN, etc /// - Returns a encoded key based on the mrz key that can be used for PACE - func createPaceKey( from mrzKey: String ) throws -> [UInt8] { - let buf: [UInt8] = Array(mrzKey.utf8) - let hash = calcSHA1Hash(buf) - - let smskg = SecureMessagingSessionKeyGenerator() - let key = try smskg.deriveKey(keySeed: hash, cipherAlgName: cipherAlg, keyLength: keyLength, nonce: nil, mode: .PACE_MODE, paceKeyReference: paceKeyType) - return key + func createPaceKey(from password: String, type: PACEPasswordType) throws -> [UInt8] { + switch type { + case .mrz: + let buf: [UInt8] = Array(password.utf8) + let hash = calcSHA1Hash(buf) + + let smskg = SecureMessagingSessionKeyGenerator() + let key = try smskg.deriveKey(keySeed: hash, cipherAlgName: cipherAlg, keyLength: keyLength, nonce: nil, mode: .PACE_MODE, paceKeyReference: paceKeyType) + return key + + case .can: + let canBytes: [UInt8] = Array(password.utf8) + + let smskg = SecureMessagingSessionKeyGenerator() + let key = try smskg.deriveKey(keySeed: canBytes, cipherAlgName: cipherAlg, keyLength: keyLength, nonce: nil, mode: .PACE_MODE, paceKeyReference: paceKeyType) + return key + } } /// Performs the ECDH PACE GM key agreement protocol by multiplying a private key with a public key diff --git a/Sources/NFCPassportReader/PACEPasswordValidator.swift b/Sources/NFCPassportReader/PACEPasswordValidator.swift new file mode 100644 index 00000000..caf1bc09 --- /dev/null +++ b/Sources/NFCPassportReader/PACEPasswordValidator.swift @@ -0,0 +1,21 @@ +// +// PACEPasswordValidator.swift +// NFCPassportReader +// +// Created by Manwel Bugeja Personal on 24/04/2025. +// + + +class PACEPasswordValidator { + static func validate(password: String, type: PACEPasswordType) throws { + switch type { + case .mrz: + // MRZ validation logic if needed + break + case .can: + guard password.count == 6 && password.allSatisfy({ $0.isNumber }) else { + throw NFCPassportReaderError.InvalidDataPassed("CAN must be 6 digits") + } + } + } +} diff --git a/Sources/NFCPassportReader/PassportReader.swift b/Sources/NFCPassportReader/PassportReader.swift index f7774eff..8c74bcce 100644 --- a/Sources/NFCPassportReader/PassportReader.swift +++ b/Sources/NFCPassportReader/PassportReader.swift @@ -62,6 +62,7 @@ public class PassportReader : NSObject { private var paceHandler : PACEHandler? private var mrzKey : String = "" private var dataAmountToReadOverride : Int? = nil + private var passwordType: PACEPasswordType = .mrz private var scanCompletedHandler: ((NFCPassportModel?, NFCPassportReaderError?)->())! private var nfcViewDisplayMessageHandler: ((NFCViewDisplayMessage) -> String?)? @@ -90,10 +91,37 @@ public class PassportReader : NSObject { dataAmountToReadOverride = amount } - public func readPassport( mrzKey : String, tags : [DataGroupId] = [], skipSecureElements : Bool = true, skipCA : Bool = false, skipPACE : Bool = false, useExtendedMode : Bool = false, customDisplayMessage : ((NFCViewDisplayMessage) -> String?)? = nil) async throws -> NFCPassportModel { - + public func readPassport( + mrzKey: String? = nil, + can: String? = nil, + tags: [DataGroupId] = [], + skipSecureElements: Bool = true, + skipCA: Bool = false, + skipPACE: Bool = false, + useExtendedMode: Bool = false, + customDisplayMessage: ((NFCViewDisplayMessage) -> String?)? = nil + ) async throws -> NFCPassportModel { + + // Determine password and type + let password: String + let passwordType: PACEPasswordType + + if let can = can { + password = can + passwordType = .can + } else if let mrz = mrzKey { + password = mrz + passwordType = .mrz + } else { + throw NFCPassportReaderError.InvalidDataPassed("Either `mrzKey` or `can` must be provided.") + } + + // Validate the password for the selected type + try PACEPasswordValidator.validate(password: password, type: passwordType) + self.passport = NFCPassportModel() - self.mrzKey = mrzKey + self.mrzKey = password + self.passwordType = passwordType self.skipCA = skipCA self.skipPACE = skipPACE self.useExtendedMode = useExtendedMode @@ -255,7 +283,7 @@ extension PassportReader : NFCTagReaderSessionDelegate { @available(iOS 15, *) extension PassportReader { - func startReading(tagReader : TagReader) async throws -> NFCPassportModel { + func startReading(tagReader: TagReader) async throws -> NFCPassportModel { trackingDelegate?.nfcTagDetected() if !skipPACE { @@ -263,25 +291,25 @@ extension PassportReader { trackingDelegate?.paceStarted() let data = try await tagReader.readCardAccess() - Logger.passportReader.debug( "Read CardAccess - data \(binToHexRep(data))" ) + Logger.passportReader.debug("Read CardAccess - data \(binToHexRep(data))") let cardAccess = try CardAccess(data) passport.cardAccess = cardAccess trackingDelegate?.readCardAccess(cardAccess: cardAccess) - Logger.passportReader.info( "Starting Password Authenticated Connection Establishment (PACE)" ) + Logger.passportReader.info("Starting Password Authenticated Connection Establishment (PACE)") - let paceHandler = try PACEHandler( cardAccess: cardAccess, tagReader: tagReader ) - try await paceHandler.doPACE(mrzKey: mrzKey ) + let paceHandler = try PACEHandler(cardAccess: cardAccess, tagReader: tagReader) + + try await paceHandler.doPACE(password: mrzKey, passwordType: passwordType) passport.PACEStatus = .success - Logger.passportReader.debug( "PACE Succeeded" ) + Logger.passportReader.debug("PACE Succeeded") trackingDelegate?.paceSucceeded() } catch { trackingDelegate?.paceFailed() - passport.PACEStatus = .failed - Logger.passportReader.error( "PACE Failed - falling back to BAC" ) + Logger.passportReader.error("PACE Failed - falling back to BAC") } _ = try await tagReader.selectPassportApplication() @@ -289,13 +317,18 @@ extension PassportReader { // If either PACE isn't supported, we failed whilst doing PACE or we didn't even attempt it, then fall back to BAC if passport.PACEStatus != .success { - do { - trackingDelegate?.bacStarted() - try await doBACAuthentication(tagReader : tagReader) - trackingDelegate?.bacSucceeded() - } catch { - trackingDelegate?.bacFailed() - throw error + if passwordType == .mrz { + do { + trackingDelegate?.bacStarted() + try await doBACAuthentication(tagReader : tagReader) + trackingDelegate?.bacSucceeded() + } catch { + trackingDelegate?.bacFailed() + throw error + } + } else { + Logger.passportReader.warning("BAC fallback not attempted: unsupported for passwordType \(String(describing: self.passwordType))") + throw NFCPassportReaderError.NotYetSupported("BAC fallback only supported for MRZ-based credentials") } } From 1f83c7e63785a058524a02248d48f659e2f4c59a Mon Sep 17 00:00:00 2001 From: Manwel Bugeja Date: Thu, 24 Apr 2025 14:35:15 +0200 Subject: [PATCH 2/5] improve error handling for PACE failure fallback --- .../NFCViewDisplayMessage.swift | 7 ++++++ .../NFCPassportReader/PassportReader.swift | 22 +++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Sources/NFCPassportReader/NFCViewDisplayMessage.swift b/Sources/NFCPassportReader/NFCViewDisplayMessage.swift index f2fc88c6..3ced0d27 100644 --- a/Sources/NFCPassportReader/NFCViewDisplayMessage.swift +++ b/Sources/NFCPassportReader/NFCViewDisplayMessage.swift @@ -40,6 +40,13 @@ extension NFCViewDisplayMessage { return "Connection error. Please try again." case NFCPassportReaderError.InvalidMRZKey: return "MRZ Key not valid for this document." + case NFCPassportReaderError.InvalidCAN: + return "CAN is not valid for this document." + case NFCPassportReaderError.InvalidDataPassed(let reason): + if reason.contains("CAN") { + return "Invalid CAN: \(reason)" + } + return "Invalid data: \(reason)" case NFCPassportReaderError.ResponseError(let description, let sw1, let sw2): return "Sorry, there was a problem reading the passport. \(description) - (0x\(sw1), 0x\(sw2)" default: diff --git a/Sources/NFCPassportReader/PassportReader.swift b/Sources/NFCPassportReader/PassportReader.swift index 8c74bcce..53fc77ff 100644 --- a/Sources/NFCPassportReader/PassportReader.swift +++ b/Sources/NFCPassportReader/PassportReader.swift @@ -309,7 +309,25 @@ extension PassportReader { } catch { trackingDelegate?.paceFailed() passport.PACEStatus = .failed - Logger.passportReader.error("PACE Failed - falling back to BAC") + Logger.passportReader.error("PACE Failed - determining next steps") + + // Check if this is a CAN-specific error + if let passportError = error as? NFCPassportReaderError { + if case .InvalidCAN = passportError { + throw passportError // Propagate InvalidCAN directly + } else if case .InvalidDataPassed(let reason) = passportError, reason.contains("CAN") { + throw NFCPassportReaderError.InvalidCAN // Convert to InvalidCAN for consistent error handling + } + } + + // For other errors with CAN, don't attempt BAC fallback + if passwordType == .can { + Logger.passportReader.error("CAN authentication failed and BAC fallback not supported for CAN") + throw NFCPassportReaderError.InvalidCAN + } + + // Otherwise, log that we're falling back to BAC + Logger.passportReader.warning("PACE Failed - falling back to BAC") } _ = try await tagReader.selectPassportApplication() @@ -328,7 +346,7 @@ extension PassportReader { } } else { Logger.passportReader.warning("BAC fallback not attempted: unsupported for passwordType \(String(describing: self.passwordType))") - throw NFCPassportReaderError.NotYetSupported("BAC fallback only supported for MRZ-based credentials") + throw NFCPassportReaderError.InvalidCAN } } From a018e417123f8060bae7e6237e099a571ddfc59f Mon Sep 17 00:00:00 2001 From: Manwel Bugeja Date: Thu, 24 Apr 2025 14:39:08 +0200 Subject: [PATCH 3/5] Made error message format same as others --- Sources/NFCPassportReader/Errors.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/NFCPassportReader/Errors.swift b/Sources/NFCPassportReader/Errors.swift index 9fbee6ea..eb3a3765 100644 --- a/Sources/NFCPassportReader/Errors.swift +++ b/Sources/NFCPassportReader/Errors.swift @@ -78,7 +78,7 @@ public enum NFCPassportReaderError: Error { case .InvalidDataPassed(let reason) : return "Invalid data passed - \(reason)" case .NotYetSupported(let reason) : return "Not yet supported - \(reason)" case .Unknown(let error): return "Unknown error: \(error.localizedDescription)" - case .InvalidCAN: return "Invalid CAN format" + case .InvalidCAN: return "InvalidCAN" } } } From 57c410ff59cdfef18bbbc2e8e0c7174380727515 Mon Sep 17 00:00:00 2001 From: Manwel Bugeja Date: Thu, 24 Apr 2025 14:40:38 +0200 Subject: [PATCH 4/5] update UI to demonstrate CAN-based PACE support --- .../NFCPassportReaderApp/Views/MainView.swift | 99 +++++++++++++++---- 1 file changed, 79 insertions(+), 20 deletions(-) diff --git a/Examples/Example_SPM/NFCPassportReaderApp/Views/MainView.swift b/Examples/Example_SPM/NFCPassportReaderApp/Views/MainView.swift index f6297fbf..9abaac68 100644 --- a/Examples/Example_SPM/NFCPassportReaderApp/Views/MainView.swift +++ b/Examples/Example_SPM/NFCPassportReaderApp/Views/MainView.swift @@ -29,6 +29,8 @@ struct MainView : View { @State private var showScanMRZ : Bool = false @State private var showSavedPassports : Bool = false @State private var gettingLogs : Bool = false + @State private var selectedPasswordType: PACEPasswordType = .mrz + @State private var canNumber = "" @State var page = 0 @@ -53,13 +55,49 @@ struct MainView : View { }).navigationTitle("Scan MRZ"), isActive: $showScanMRZ){ Text("") } VStack { - HStack { - Spacer() - Button(action: {self.showScanMRZ.toggle()}) { - Label("Scan MRZ", systemImage:"camera") - }.padding([.top, .trailing]) + // Authentication Method Picker + Picker("Authentication Method", selection: $selectedPasswordType) { + Text("MRZ").tag(PACEPasswordType.mrz) + Text("CAN").tag(PACEPasswordType.can) + } + .pickerStyle(SegmentedPickerStyle()) + .padding(.horizontal) + .padding(.top) + + // Show appropriate UI based on selection + if selectedPasswordType == .mrz { + HStack { + Spacer() + Button(action: {self.showScanMRZ.toggle()}) { + Label("Scan MRZ", systemImage:"camera") + }.padding([.top, .trailing]) + } + MRZEntryView() + } else { + VStack(alignment: .leading) { + Text("Card Access Number (CAN)") + .font(.headline) + .padding(.horizontal) + .padding(.top) + + TextField("Enter 6-digit CAN", text: $canNumber) + .keyboardType(.numberPad) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding(.horizontal) + .onChange(of: canNumber) { newValue in + // Limit to 6 digits and ensure only digits + if newValue.count > 6 { + canNumber = String(newValue.prefix(6)) + } + canNumber = newValue.filter { $0.isNumber } + } + + Text("The CAN is a 6-digit number printed on your document") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + } } - MRZEntryView() Button(action: { self.scanPassport() @@ -134,7 +172,12 @@ struct MainView : View { extension MainView { var isValid : Bool { - return settings.passportNumber.count >= 8 + // Updated to handle both MRZ and CAN validation + if selectedPasswordType == .mrz { + return settings.passportNumber.count >= 8 + } else { + return canNumber.count == 6 && canNumber.allSatisfy { $0.isNumber } + } } func parse( mrz:String ) -> (String, Date, Date)? { @@ -170,17 +213,26 @@ extension MainView { hideKeyboard() self.showDetails = false - let df = DateFormatter() - df.timeZone = TimeZone(secondsFromGMT: 0) - df.dateFormat = "YYMMdd" + // Key difference: Prepare parameters based on selected authentication type + let mrzKeyParam: String? + let canParam: String? - let pptNr = settings.passportNumber - let dob = df.string(from:settings.dateOfBirth) - let doe = df.string(from:settings.dateOfExpiry) - let useExtendedMode = settings.useExtendedMode - - let passportUtils = PassportUtils() - let mrzKey = passportUtils.getMRZKey( passportNumber: pptNr, dateOfBirth: dob, dateOfExpiry: doe) + if selectedPasswordType == .mrz { + let df = DateFormatter() + df.timeZone = TimeZone(secondsFromGMT: 0) + df.dateFormat = "YYMMdd" + + let pptNr = settings.passportNumber + let dob = df.string(from:settings.dateOfBirth) + let doe = df.string(from:settings.dateOfExpiry) + + let passportUtils = PassportUtils() + mrzKeyParam = passportUtils.getMRZKey(passportNumber: pptNr, dateOfBirth: dob, dateOfExpiry: doe) + canParam = nil + } else { + mrzKeyParam = nil + canParam = canNumber + } // Set the masterListURL on the Passport Reader to allow auto passport verification let masterListURL = Bundle.main.url(forResource: "masterList", withExtension: ".pem")! @@ -199,7 +251,9 @@ extension MainView { let customMessageHandler : (NFCViewDisplayMessage)->String? = { (displayMessage) in switch displayMessage { case .requestPresentPassport: - return "Hold your iPhone near an NFC enabled passport." + return selectedPasswordType == .mrz ? + "Hold your iPhone near an NFC enabled passport." : + "Hold your iPhone near the document and enter the CAN." default: // Return nil for all other messages so we use the provided default return nil @@ -207,7 +261,12 @@ extension MainView { } do { - let passport = try await passportReader.readPassport( mrzKey: mrzKey, useExtendedMode: useExtendedMode, customDisplayMessage:customMessageHandler) + let passport = try await passportReader.readPassport( + mrzKey: mrzKeyParam, + can: canParam, + useExtendedMode: settings.useExtendedMode, + customDisplayMessage: customMessageHandler + ) if let _ = passport.faceImageInfo { print( "Got face Image details") @@ -230,7 +289,7 @@ extension MainView { } } catch { self.alertTitle = "Oops" - self.alertTitle = error.localizedDescription + self.alertMessage = error.localizedDescription self.showingAlert = true } From fb3932e4c50c7735b67f058e433732a367791b14 Mon Sep 17 00:00:00 2001 From: Manwel Bugeja Date: Thu, 24 Apr 2025 15:12:58 +0200 Subject: [PATCH 5/5] Updated example to show MRZ usage backwards compatibility --- .../NFCPassportReaderApp/Views/MainView.swift | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/Examples/Example_SPM/NFCPassportReaderApp/Views/MainView.swift b/Examples/Example_SPM/NFCPassportReaderApp/Views/MainView.swift index 9abaac68..9298b8f9 100644 --- a/Examples/Example_SPM/NFCPassportReaderApp/Views/MainView.swift +++ b/Examples/Example_SPM/NFCPassportReaderApp/Views/MainView.swift @@ -248,25 +248,38 @@ extension MainView { appLogging.error( "Using version \(UIApplication.version)" ) Task { - let customMessageHandler : (NFCViewDisplayMessage)->String? = { (displayMessage) in - switch displayMessage { - case .requestPresentPassport: - return selectedPasswordType == .mrz ? - "Hold your iPhone near an NFC enabled passport." : - "Hold your iPhone near the document and enter the CAN." - default: - // Return nil for all other messages so we use the provided default - return nil + let customMessageHandler: (NFCViewDisplayMessage)->String? = { (displayMessage) in + switch displayMessage { + case .requestPresentPassport: + return selectedPasswordType == .mrz ? + "Hold your iPhone near an NFC enabled passport." : + "Hold your iPhone near the document and enter the CAN." + default: + // Return nil for all other messages so we use the provided default + return nil + } } - } - - do { - let passport = try await passportReader.readPassport( - mrzKey: mrzKeyParam, - can: canParam, - useExtendedMode: settings.useExtendedMode, - customDisplayMessage: customMessageHandler - ) + + do { + let passport: NFCPassportModel + + if selectedPasswordType == .mrz { + // Use the original API for MRZ to demonstrate backward compatibility + passport = try await passportReader.readPassport( + mrzKey: mrzKeyParam, + useExtendedMode: settings.useExtendedMode, + customDisplayMessage: customMessageHandler + ) + } else { + // Use the new API for CAN + passport = try await passportReader.readPassport( + mrzKey: nil, + can: canParam, + useExtendedMode: settings.useExtendedMode, + customDisplayMessage: customMessageHandler + ) + } + if let _ = passport.faceImageInfo { print( "Got face Image details")