From 98745b006d5c946a899cdc9255db73781dcb792e Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Mon, 2 Nov 2026 17:43:20 +0200 Subject: [PATCH 1/7] Add Latvian ID card support --- README.md | 29 +++- Sources/nfclib/CardActions/CardCommands.swift | 9 ++ .../CardActions/CardCommandsInternal.swift | 7 +- Sources/nfclib/CardActions/CardInfo.swift | 77 ++++++---- Sources/nfclib/CardActions/CardType.swift | 45 ++++++ Sources/nfclib/CardActions/IDCardError.swift | 7 +- Sources/nfclib/CardActions/Idemia.swift | 115 +++------------ Sources/nfclib/CardActions/IdemiaCard.swift | 124 ++++++++++++++++ Sources/nfclib/CardActions/LatviaIdemia.swift | 137 ++++++++++++++++++ Sources/nfclib/CardActions/Thales.swift | 3 +- Sources/nfclib/Operations/NFCConnection.swift | 42 +++++- .../Operations/OperationReadCertificate.swift | 11 +- .../Operations/OperationReadPublicData.swift | 10 +- .../nfclib/Operations/OperationSignHash.swift | 12 +- .../Operations/OperationUnblockPin.swift | 13 +- .../nfclib/Utilities/CertificateSubject.swift | 81 +++++++++++ .../nfclib/Utilities/PersonalCodeParser.swift | 72 +++++++++ .../OperationAuthenticateWithWebEID.swift | 13 +- mvoting-nfc/nfc-demo/CardInfoView.swift | 15 +- mvoting-nfc/nfc-demo/CardOperations.swift | 30 ++-- mvoting-nfc/nfc-demo/ContentView.swift | 106 ++++++++++---- mvoting-nfc/nfc-demo/Operator.swift | 40 +++-- 22 files changed, 786 insertions(+), 212 deletions(-) create mode 100644 Sources/nfclib/CardActions/CardType.swift create mode 100644 Sources/nfclib/CardActions/IdemiaCard.swift create mode 100644 Sources/nfclib/CardActions/LatviaIdemia.swift create mode 100644 Sources/nfclib/Utilities/CertificateSubject.swift create mode 100644 Sources/nfclib/Utilities/PersonalCodeParser.swift diff --git a/README.md b/README.md index d1b5d20..38f21d7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The NFC-ID library is not intended for public use. It is a low-level technical l The NFC-ID library was originally developed within the m-valimiste project, based on the need to use the ID card inside the m-Voting client application. -Currently supports Estonian eID cards. Please submit an issue if you want to request support for your country's eID card. +Currently supports Estonian and Latvian eID cards. Please submit an issue if you want to request support for your country's eID card. # Demo Application Run Guide - Open **mvoting-nfc/nfc-demo.xcodeproj**. The demo app uses the Swift package at the repository root. @@ -66,39 +66,52 @@ public func isNFCSupported() -> Bool Asynchronously reads public information about the cardholder from the card: ```swift -public func readPublicInfo(CAN: String) async throws -> CardInfo +public func readPublicInfo(CAN: String, allowedCardTypes: Set) async throws -> CardInfo ``` Asynchronously reads the authentication certificate from the card: ```swift -public func readAuthenticationCertificate(CAN: String) async throws -> SecCertificate +public func readAuthenticationCertificate(CAN: String, allowedCardTypes: Set) async throws -> SecCertificate ``` Asynchronously reads the signing certificate from the card: ```swift -public func readSigningCertificate(CAN: String) async throws -> SecCertificate +public func readSigningCertificate(CAN: String, allowedCardTypes: Set) async throws -> SecCertificate ``` Retrieves data required for WebEID authentication, using the provided credentials and challenge: ```swift -public func loadWebEIDAuthenticationData(CAN: String, pin1: String, challenge: String, origin: String) async throws -> WebEidData +public func loadWebEIDAuthenticationData(CAN: String, pin1: SecureData, challenge: String, origin: String, allowedCardTypes: Set) async throws -> WebEidData ``` Performs a signing operation using a precomputed hash (only SHA-384 supported) and the PIN2 code: ```swift -public func sign(CAN: String, hash: Data, pin2: String) async throws -> Data +public func sign(CAN: String, hash: Data, pin2: SecureData, allowedCardTypes: Set) async throws -> Data ``` Unblocks PIN1 using the PUK code and sets a new PIN: ```swift -public func unblockPin1(CAN: String, puk: String, newCode: String) async throws +public func unblockPin1(CAN: String, puk: SecureData, newCode: SecureData, allowedCardTypes: Set) async throws ``` Unblocks PIN2 using the PUK code and sets a new PIN: ```swift -public func unblockPin2(CAN: String, puk: String, newCode: String) async throws +public func unblockPin2(CAN: String, puk: SecureData, newCode: SecureData, allowedCardTypes: Set) async throws ``` +Every operation takes an `allowedCardTypes` set, defaulting to `CardType.estonianCards` +(`[.id1, .thales]`). Pass `CardType.allCards` to also accept Latvian cards: + +```swift +let cardInfo = try await OperationReadPublicData().startReading( + CAN: can, + allowedCardTypes: CardType.allCards +) +``` + +A card whose type is excluded, or whose ATS the library does not recognise, fails with +`IdCardError.cardNotSupported`. + # Logging The library logs through Apple's unified logging system (`OSLog`), under the subsystem `ee.ria.nfc-iOS-lib`. Most of it is harmless diagnostics – operation steps, reader status, errors. A separate set of logs (plaintext APDUs with **PIN1, PIN2, PUK**, session keys, and cryptographic intermediates) is sensitive and stays off unless you turn it on while debugging. diff --git a/Sources/nfclib/CardActions/CardCommands.swift b/Sources/nfclib/CardActions/CardCommands.swift index ee7baff..fdb6b30 100644 --- a/Sources/nfclib/CardActions/CardCommands.swift +++ b/Sources/nfclib/CardActions/CardCommands.swift @@ -51,6 +51,15 @@ public extension CodeType { */ public protocol CardCommands: Sendable { var canChangePUK: Bool { get } + + /** + * Identifies the card model this instance talks to. + * + * Compile-time constant per implementation — reading it performs no card I/O, so callers + * can branch on it without first calling `readPublicData()`. + */ + var cardType: CardType { get } + /** * Reads public data from the card. * diff --git a/Sources/nfclib/CardActions/CardCommandsInternal.swift b/Sources/nfclib/CardActions/CardCommandsInternal.swift index 41cde83..a1c1463 100644 --- a/Sources/nfclib/CardActions/CardCommandsInternal.swift +++ b/Sources/nfclib/CardActions/CardCommandsInternal.swift @@ -31,7 +31,12 @@ extension CardCommandsInternal { var size = 0xE5 if let fci = TLV(from: try await select(p1Byte: p1Byte, p2Byte: 0x04, file: file)) { for record in TLV.sequenceOfRecords(from: fci.value) ?? [] where record.tag == 0x80 || record.tag == 0x81 { - size = Int(record.value[0]) << 8 | Int(record.value[1]) + let sizeBytes = Bytes(record.value) + switch sizeBytes.count { + case 1: size = Int(sizeBytes[0]) + case 2: size = Int(sizeBytes[0]) << 8 | Int(sizeBytes[1]) + default: continue + } } } var data = Data() diff --git a/Sources/nfclib/CardActions/CardInfo.swift b/Sources/nfclib/CardActions/CardInfo.swift index e9b0901..f025044 100644 --- a/Sources/nfclib/CardActions/CardInfo.swift +++ b/Sources/nfclib/CardActions/CardInfo.swift @@ -7,10 +7,22 @@ public struct CardInfo: Sendable, Hashable { public var givenName: String public var surname: String public var personalCode: String + /// Cardholder's citizenship as held in the on-card personal-data file. Empty for cards that + /// do not expose it over NFC — the Latvian card, where ``issuingCountry`` is read instead. public var citizenship: String public var documentNumber: String + /// Expiry date of the physical document, as printed on the card. Empty for cards that do not + /// expose it over NFC — the Latvian card, where ``certificateExpiryDate`` is read instead. public var dateOfExpiry: String public var dateAndPlaceOfBirth: String + /// ISO 3166-1 alpha-2 country code of the certificate issuer (subject RDN `C`). This is the + /// issuing country, *not* the cardholder's citizenship: a foreign resident's card still + /// carries the issuer's code. Empty for cards whose personal data is read from EF files. + public var issuingCountry: String + /// `notAfter` of the on-card authentication certificate. Typically shorter than the document + /// expiry. Set only for cards whose personal data comes from the certificate. + public var certificateExpiryDate: Date? + public var cardType: CardType public init( givenName: String = "", @@ -19,7 +31,10 @@ public struct CardInfo: Sendable, Hashable { citizenship: String = "", documentNumber: String = "", dateOfExpiry: String = "", - dateAndPlaceOfBirth: String = "" + dateAndPlaceOfBirth: String = "", + issuingCountry: String = "", + certificateExpiryDate: Date? = nil, + cardType: CardType = .thales ) { self.givenName = givenName self.surname = surname @@ -28,51 +43,51 @@ public struct CardInfo: Sendable, Hashable { self.documentNumber = documentNumber self.dateOfExpiry = dateOfExpiry self.dateAndPlaceOfBirth = dateAndPlaceOfBirth + self.issuingCountry = issuingCountry + self.certificateExpiryDate = certificateExpiryDate + self.cardType = cardType } - public var dateOfBirth: Date? { CardInfo.dateOfBirth(fromPersonalCode: personalCode) } - - public static func dateOfBirth(fromPersonalCode personalCode: String) -> Date? { - let digits = Array(personalCode) - guard digits.count >= 7, digits.prefix(7).allSatisfy(\.isWholeNumber) else { return nil } - - let century: Int - switch digits[0].wholeNumberValue { - case 1, 2: century = 1800 - case 3, 4: century = 1900 - case 5, 6: century = 2000 - case 7, 8: century = 2100 - default: return nil + public var dateOfBirth: Date? { + switch cardType { + case .latviaIdemia: return PersonalCodeParser.latvianDateOfBirth(personalCode) + case .id1, .thales: return PersonalCodeParser.estonianDateOfBirth(personalCode) } + } - guard let yearInCentury = Int(String(digits[1...2])), - let month = Int(String(digits[3...4])), - let day = Int(String(digits[5...6])) else { return nil } - - var components = DateComponents() - components.year = century + yearInCentury - components.month = month - components.day = day - - let calendar = Calendar(identifier: .gregorian) - guard let date = calendar.date(from: components) else { return nil } + public static func dateOfBirth(fromPersonalCode personalCode: String) -> Date? { + PersonalCodeParser.estonianDateOfBirth(personalCode) + } - let rounded = calendar.dateComponents([.year, .month, .day], from: date) - guard rounded.year == components.year, - rounded.month == month, - rounded.day == day else { return nil } - return date + public static func dateOfBirth(fromLatvianPersonalCode personalCode: String) -> Date? { + PersonalCodeParser.latvianDateOfBirth(personalCode) } public var formattedDescription: String { - """ + var description = """ Name: \(givenName) \(surname) Personal Code: \(personalCode) Citizenship: \(citizenship) Document number: \(documentNumber) Date of Expiry: \(dateOfExpiry) """ + if !issuingCountry.isEmpty { + description += "\nIssuing Country: \(issuingCountry)" + } + if let certificateExpiryDate { + description += "\nCertificate Expiry: \(CardInfo.dateFormatter.string(from: certificateExpiryDate))" + } + return description } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "dd.MM.yyyy" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + return formatter + }() } public enum CardField: Int, Sendable { diff --git a/Sources/nfclib/CardActions/CardType.swift b/Sources/nfclib/CardActions/CardType.swift new file mode 100644 index 0000000..ff6ea42 --- /dev/null +++ b/Sources/nfclib/CardActions/CardType.swift @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Foundation + +public enum CardType: String, Sendable, CaseIterable { + case id1 + case thales + case latviaIdemia +} + +public extension CardType { + /** + * The card types accepted when a caller passes no explicit allow-list. + * + * An integrator who genuinely wants every supported card type must say so via ``allCards``, + * so a future release of this library adding a country variant cannot silently widen the set + * of cards that the integrator's app accepts. + */ + static let estonianCards: Set = [.id1, .thales] + + static let allCards: Set = Set(CardType.allCases) +} + +extension CardType { + private static let knownHistoricalBytes: [(bytes: Bytes, cardType: CardType)] = [ + (Bytes(hex: "00 12 23 3F 53 65 49 44 0F 90 00"), .id1), + (Bytes(hex: "00 12 23 3F 54 65 49 44 32 0F 90 00"), .id1), + // Android lists only D8; this repo's Thales ATR carries B8 in the same position. Both + // are kept until a real tap settles it — matching neither would reject every Thales card. + (Bytes(hex: "80 31 B8 53 65 49 44 64 B0 85 05 10 12 23 3F"), .thales), + (Bytes(hex: "80 31 D8 53 65 49 44 64 B0 85 05 10 12 23 3F"), .thales), + (Bytes(hex: "00 12 42 8F 53 65 49 44 0F 90 00"), .latviaIdemia), + (Bytes(hex: "00 12 42 8F 54 65 49 44 32 0F 90 00"), .latviaIdemia) + ] + + init?(historicalBytes: Data?) { + guard let historicalBytes else { return nil } + let bytes = Bytes(historicalBytes) + guard let match = CardType.knownHistoricalBytes.first(where: { $0.bytes == bytes }) else { + return nil + } + self = match.cardType + } +} diff --git a/Sources/nfclib/CardActions/IDCardError.swift b/Sources/nfclib/CardActions/IDCardError.swift index 8c86147..17bda1c 100644 --- a/Sources/nfclib/CardActions/IDCardError.swift +++ b/Sources/nfclib/CardActions/IDCardError.swift @@ -8,7 +8,9 @@ public enum IdCardError: Error { wrongPIN(codeType: CodeType, triesLeft: Int), invalidNewPIN, sessionError, - notActivated + notActivated, + /// Exception when id card is not supported. + cardNotSupported } public enum IdCardInternalError: Error { @@ -59,7 +61,6 @@ public enum IdCardInternalError: Error { .notSupportedCodeType, .authenticationFailed, .invalidTag, - .cardNotSupported, .nfcNotSupported, .connectionFailed, .multipleTagsDetected, @@ -73,6 +74,8 @@ public enum IdCardInternalError: Error { return .sessionError case .notActivated: return .notActivated + case .cardNotSupported: + return .cardNotSupported case .canAuthenticationFailed: return .wrongCAN case .pinVerificationFailed(let codeType): diff --git a/Sources/nfclib/CardActions/Idemia.swift b/Sources/nfclib/CardActions/Idemia.swift index 6613459..cf7f74c 100644 --- a/Sources/nfclib/CardActions/Idemia.swift +++ b/Sources/nfclib/CardActions/Idemia.swift @@ -5,53 +5,38 @@ import Foundation private let ATR = Bytes(hex: "3B DB 96 00 80 B1 FE 45 1F 83 00 12 23 3F 53 65 49 44 0F 90 00 F1") private let ATRv2 = Bytes(hex: "3B DC 96 00 80 B1 FE 45 1F 83 00 12 23 3F 54 65 49 44 32 0F 90 00 C3") -private let kAID = Bytes(hex: "A0 00 00 00 77 01 08 00 07 00 00 FE 00 00 01 00") -private let kAIDQSCD = Bytes(hex: "51 53 43 44 20 41 70 70 6C 69 63 61 74 69 6F 6E") -private let kAIDOberthur = Bytes(hex: "E8 28 BD 08 0F F2 50 4F 54 20 41 57 50") -private let AUTHKEY: UInt8 = 0x81 -private let SIGNKEY: UInt8 = 0x9F -extension CodeType { - fileprivate var aid: Bytes { - switch self { - case .pin1: return kAID - case .pin2: return kAIDQSCD - case .puk: return kAID - } - } - fileprivate var pinRef: UInt8 { - switch self { - case .pin1: return 0x01 - case .pin2: return 0x85 - case .puk: return 0x02 - } - } -} - -final class Idemia: CardCommandsInternal { +final class Idemia: IdemiaCard { let canChangePUK: Bool = true let reader: CardReader let fillChar: UInt8 = 0xFF + let cardType: CardType = .id1 + + let authKeyRef: UInt8 = 0x81 + let signKeyRef: UInt8 = 0x9F + let authAlgorithm: Bytes = [0xFF, 0x20, 0x08, 0x00] + let signAlgorithm: Bytes = [0xFF, 0x15, 0x08, 0x00] + let decryptAlgorithm: Bytes = [0xFF, 0x30, 0x04, 0x00] - required init?(reader: CardReader, atr: Bytes) { + init?(reader: CardReader, atr: Bytes) { guard atr == ATR || atr == ATRv2 else { return nil } self.reader = reader } - required init?(reader: CardReader, aid: Bytes) { - guard aid == kAID else { + init?(reader: CardReader, aid: Bytes) { + guard aid == IdemiaAID.main else { return nil } self.reader = reader } - required init?(reader: CardReader, selectAID: Bool) async { + init?(reader: CardReader, selectAID: Bool) async { self.reader = reader if (selectAID) { do { - _ = try await select(file: kAID) + _ = try await select(file: IdemiaAID.main) } catch { return nil } @@ -61,9 +46,9 @@ final class Idemia: CardCommandsInternal { // MARK: - Public Data func readPublicData() async throws -> CardInfo { - _ = try await select(file: kAID) + _ = try await select(file: IdemiaAID.main) _ = try await select(p1Byte: 0x01, file: [0x50, 0x00]) - var personalData = CardInfo() + var personalData = CardInfo(cardType: cardType) for recordNr: UInt8 in 1...8 { let data = try await readFile(p1Byte: 0x02, file: [0x50, recordNr]) let record = String(data: data, encoding: .utf8) ?? "-" @@ -82,78 +67,12 @@ final class Idemia: CardCommandsInternal { } func readAuthenticationCertificate() async throws -> Data { - _ = try await select(file: kAID) + _ = try await select(file: IdemiaAID.main) return try await readFile(p1Byte: 0x09, file: [0xAD, 0xF1, 0x34, 0x01]) } func readSignatureCertificate() async throws -> Data { - _ = try await select(file: kAID) + _ = try await select(file: IdemiaAID.main) return try await readFile(p1Byte: 0x09, file: [0xAD, 0xF2, 0x34, 0x1F]) } - - // MARK: - PIN & PUK Management - - func readCodeTryCounterRecord(_ type: CodeType) async throws -> (retryCount: UInt8, pinActive: Bool) { - _ = try await select(file: type.aid) - let ref = type.pinRef & ~0x80 - let data = try await reader.sendAPDU(ins: 0xCB, p1Byte: 0x3F, p2Byte: 0xFF, data: - [0x4D, 0x08, 0x70, 0x06, 0xBF, 0x81, ref, 0x02, 0xA0, 0x80], leByte: 0x00) - if let info = TLV(from: data), info.tag == 0x70, - let tag = TLV(from: info.value), tag.tag == 0xBF8100 | UInt32(ref), - let a0Value = TLV(from: tag.value), a0Value.tag == 0xA0, - let records = TLV.sequenceOfRecords(from: a0Value.value) { - for record in records where record.tag == 0x9B { - guard let counter = record.value.first else { continue } - return (counter, true) - } - } - throw IdCardInternalError.invalidResponse(message: "Missing PIN counter record") - } - - func changeCode(_ type: CodeType, to code: SecureData, verifyCode: SecureData) async throws { - _ = try await select(file: type.aid) - try await changeCode(type.pinRef, codeType: type, to: code, verifyCode: verifyCode) - } - - func verifyCode(_ type: CodeType, code: SecureData) async throws { - try await verifyCode(type.pinRef, codeType: type, code: code) - } - - func unblockCode(_ type: CodeType, puk: SecureData, newCode: SecureData) async throws { - guard type != .puk else { - throw IdCardInternalError.notSupportedCodeType - } - try await verifyCode(.puk, code: puk) - if type == .pin2 { - _ = try await select(file: type.aid) - } - try await unblockCode(type.pinRef, codeType: type, puk: nil, newCode: newCode) - } - - // MARK: - Authentication & Signing - - func authenticate(for hash: any DataType, withPin1 pin1: SecureData) async throws -> Data { - _ = try await select(file: kAIDOberthur) - try await verifyCode(.pin1, code: pin1) - try await setSecEnv(mode: 0xA4, algo: [0xFF, 0x20, 0x08, 0x00], keyRef: AUTHKEY) - var paddedHash = Data(repeating: 0x00, count: max(48, hash.count) - hash.count) - paddedHash.append(contentsOf: hash) - return try await reader.sendAPDU(ins: 0x88, data: paddedHash, leByte: 0x00) - } - - func calculateSignature(for hash: any DataType, withPin2 pin2: SecureData) async throws -> Data { - _ = try await select(file: kAIDQSCD) - try await verifyCode(.pin2, code: pin2) - try await setSecEnv(mode: 0xB6, algo: [0xFF, 0x15, 0x08, 0x00], keyRef: SIGNKEY) - var paddedHash = Data(repeating: 0x00, count: max(48, hash.count) - hash.count) - paddedHash.append(contentsOf: hash) - return try await reader.sendAPDU(ins: 0x2A, p1Byte: 0x9E, p2Byte: 0x9A, data: paddedHash, leByte: 0x00) - } - - func decryptData(_ hash: any DataType, withPin1 pin1: SecureData) async throws -> Data { - _ = try await select(file: kAIDOberthur) - try await verifyCode(.pin1, code: pin1) - try await setSecEnv(mode: 0xB8, algo: [0xFF, 0x30, 0x04, 0x00], keyRef: AUTHKEY) - return try await reader.sendAPDU(ins: 0x2A, p1Byte: 0x80, p2Byte: 0x86, data: [0x00] + hash, leByte: 0x00) - } } diff --git a/Sources/nfclib/CardActions/IdemiaCard.swift b/Sources/nfclib/CardActions/IdemiaCard.swift new file mode 100644 index 0000000..8585a44 --- /dev/null +++ b/Sources/nfclib/CardActions/IdemiaCard.swift @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Foundation + +enum IdemiaAID { + static let main = Bytes(hex: "A0 00 00 00 77 01 08 00 07 00 00 FE 00 00 01 00") + static let qscd = Bytes(hex: "51 53 43 44 20 41 70 70 6C 69 63 61 74 69 6F 6E") + static let oberthur = Bytes(hex: "E8 28 BD 08 0F F2 50 4F 54 20 41 57 50") +} + +/** + * Commands shared by the IDEMIA cards. PIN management, authentication, signing and decryption + * are identical across them; only the key references and MSE algorithm references vary, so + * conformers supply those as values. Personal data and certificate layout differ, so those stay + * requirements of ``CardCommands``. + */ +protocol IdemiaCard: CardCommandsInternal { + var authKeyRef: UInt8 { get } + var signKeyRef: UInt8 { get } + var authAlgorithm: Bytes { get } + var signAlgorithm: Bytes { get } + var decryptAlgorithm: Bytes { get } +} + +extension IdemiaCard { + + // MARK: - PIN & PUK Management + + func readCodeTryCounterRecord(_ type: CodeType) async throws -> (retryCount: UInt8, pinActive: Bool) { + _ = try await select(file: aid(for: type)) + let ref = pinRef(for: type) & ~0x80 + let data = try await reader.sendAPDU(ins: 0xCB, p1Byte: 0x3F, p2Byte: 0xFF, data: + [0x4D, 0x08, 0x70, 0x06, 0xBF, 0x81, ref, 0x02, 0xA0, 0x80], leByte: 0x00) + if let info = TLV(from: data), info.tag == 0x70, + let tag = TLV(from: info.value), tag.tag == 0xBF8100 | UInt32(ref), + let a0Value = TLV(from: tag.value), a0Value.tag == 0xA0, + let records = TLV.sequenceOfRecords(from: a0Value.value) { + for record in records where record.tag == 0x9B { + guard let counter = record.value.first else { continue } + return (counter, true) + } + } + throw IdCardInternalError.invalidResponse( + message: "Missing PIN counter record for \(cardType.rawValue)") + } + + func changeCode(_ type: CodeType, to code: SecureData, verifyCode: SecureData) async throws { + _ = try await select(file: aid(for: type)) + try await changeCode(pinRef(for: type), codeType: type, to: code, verifyCode: verifyCode) + } + + func verifyCode(_ type: CodeType, code: SecureData) async throws { + try await verifyCode(pinRef(for: type), codeType: type, code: code) + } + + func unblockCode(_ type: CodeType, puk: SecureData, newCode: SecureData) async throws { + guard type != .puk else { + throw IdCardInternalError.notSupportedCodeType + } + // PUK is associated with the MAIN AID context, so VERIFY PUK must run there. Make this + // self-contained instead of relying on the caller leaving MAIN active — otherwise calls + // composed after readCodeTryCounterRecord(.pin2)/calculateSignature would silently fail + // with a "wrong PUK" error. + _ = try await select(file: IdemiaAID.main) + try await verifyCode(.puk, code: puk) + if type == .pin2 { + _ = try await select(file: IdemiaAID.qscd) + } + try await unblockCode(pinRef(for: type), codeType: type, puk: nil, newCode: newCode) + } + + // MARK: - Authentication & Signing + + func authenticate(for hash: any DataType, withPin1 pin1: SecureData) async throws -> Data { + _ = try await select(file: IdemiaAID.oberthur) + try await verifyCode(.pin1, code: pin1) + try await setSecEnv(mode: 0xA4, algo: authAlgorithm, keyRef: authKeyRef) + return try await reader.sendAPDU(ins: 0x88, data: padWithZeroes(hash), leByte: 0x00) + } + + func calculateSignature(for hash: any DataType, withPin2 pin2: SecureData) async throws -> Data { + _ = try await select(file: IdemiaAID.qscd) + try await verifyCode(.pin2, code: pin2) + try await setSecEnv(mode: 0xB6, algo: signAlgorithm, keyRef: signKeyRef) + return try await reader.sendAPDU( + ins: 0x2A, p1Byte: 0x9E, p2Byte: 0x9A, data: padWithZeroes(hash), leByte: 0x00) + } + + func decryptData(_ hash: any DataType, withPin1 pin1: SecureData) async throws -> Data { + _ = try await select(file: IdemiaAID.oberthur) + try await verifyCode(.pin1, code: pin1) + try await setSecEnv(mode: 0xB8, algo: decryptAlgorithm, keyRef: authKeyRef) + return try await reader.sendAPDU(ins: 0x2A, p1Byte: 0x80, p2Byte: 0x86, data: [0x00] + hash, leByte: 0x00) + } + + // MARK: - Helpers + + private func aid(for type: CodeType) -> Bytes { + switch type { + case .pin1, .puk: return IdemiaAID.main + case .pin2: return IdemiaAID.qscd + } + } + + private func pinRef(for type: CodeType) -> UInt8 { + switch type { + case .pin1: return 0x01 + case .pin2: return 0x85 + case .puk: return 0x02 + } + } + + /** + * @return zero padded hash with 48 byte length or same hash if it's longer than 48 bytes. + * Zeros go in front: the card reads the input as a big-endian integer, which leading zero + * bytes leave unchanged, while padding on the right would change the value. + */ + private func padWithZeroes(_ hash: any DataType) -> Data { + var padded = Data(repeating: 0x00, count: max(0, 48 - hash.count)) + padded.append(contentsOf: hash) + return padded + } +} diff --git a/Sources/nfclib/CardActions/LatviaIdemia.swift b/Sources/nfclib/CardActions/LatviaIdemia.swift new file mode 100644 index 0000000..1ea46d7 --- /dev/null +++ b/Sources/nfclib/CardActions/LatviaIdemia.swift @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Foundation + +/** + * Latvian eID card NFC token. + * + * Built on the same IDEMIA chip as the Estonian card, so PACE, secure messaging, and PIN + * operations come from ``IdemiaCard``. Overrides personal-data and certificate reads, and uses + * different key references for authentication and signing. + */ +final class LatviaIdemia: IdemiaCard { + private static let personalCodeDF: Bytes = [0x50, 0x00] + private static let personalCodeEF: Bytes = [0x50, 0x01] + private static let authenticationCertificatePath: Bytes = [0xAD, 0xF1, 0x34, 0x01] + private static let signatureCertificatePath: Bytes = [0xAD, 0xF2, 0x34, 0x1F] + private static let maximumFileSize = 0x8000 + + let canChangePUK: Bool = true + let reader: CardReader + let fillChar: UInt8 = 0xFF + let cardType: CardType = .latviaIdemia + + let authKeyRef: UInt8 = 0x82 + let signKeyRef: UInt8 = 0x9E + + /** + * LV uses 1-byte algorithm identifiers in the MSE algorithm-reference DO, vs the 4-byte + * `FF xx xx xx` form used by Estonian IDEMIA: + * - `0x04` — ECC auth / decrypt + * - `0x54` — ECC sign + */ + let authAlgorithm: Bytes = [0x04] + let signAlgorithm: Bytes = [0x54] + let decryptAlgorithm: Bytes = [0x04] + + init(reader: CardReader) { + self.reader = reader + } + + // MARK: - Public Data + + /** + * Read personal data from the auth certificate subject and EF 0x5001 (personal code). + * Latvian eID cards store only the personal code in EF files (DF 0x5000 / EF 0x5001). + * Name, issuing country, and document number are extracted from the auth certificate subject: + * - OID 2.5.4.4 (surname) + * - OID 2.5.4.42 (givenName) + * - OID 2.5.4.5 (serialNumber) — format "PNOLV-{personalCode}", used as the document + * number because the card carries no separate document-number field + * - C (2.5.4.6) — issuing country of the certificate authority. Mapped to + * ``CardInfo/issuingCountry``, NOT to citizenship: the value is the CA's country + * (always "LV" here), and a foreign resident could in principle hold an LV-issued card. + * Citizenship is left empty because the LV card does not expose it over NFC. + * Cert expiry comes from the certificate's notAfter; document expiry is not exposed by + * Latvian IDEMIA cards over NFC, so it is left nil. + */ + func readPublicData() async throws -> CardInfo { + let personalCode = try await readPersonalCode() + + let certificateBytes = try await readAuthenticationCertificate() + let certificate = try convertBytesToX509Certificate(certificateBytes) + let (_, notAfter) = try validateCertificateStructure(certificate) + let subject = try CertificateSubject(derEncodedCertificate: certificateBytes) + + return CardInfo( + givenName: subject.givenName, + surname: subject.surname, + personalCode: personalCode, + documentNumber: subject.serialNumber, + issuingCountry: subject.country, + certificateExpiryDate: notAfter, + cardType: cardType + ) + } + + func readAuthenticationCertificate() async throws -> Data { + try await readCertificate(path: LatviaIdemia.authenticationCertificatePath) + } + + func readSignatureCertificate() async throws -> Data { + try await readCertificate(path: LatviaIdemia.signatureCertificatePath) + } + + // MARK: - Reads + + private func readPersonalCode() async throws -> String { + // Read personal code from EF 0x5001 + _ = try await select(file: IdemiaAID.main) + _ = try await select(p1Byte: 0x01, file: LatviaIdemia.personalCodeDF) + _ = try await select(p1Byte: 0x02, file: LatviaIdemia.personalCodeEF) + let record = try await reader.sendAPDU(ins: 0xB0, leByte: 0x00) + + // Strip trailing 0xFF (CardOS unused-space marker on fixed-size EFs) before UTF-8 + // decoding — 0xFF is not valid UTF-8, so leaving it in fails the decode outright. + let trimmed = record.prefix(while: { $0 != fillChar }) + guard let personalCode = String(data: Data(trimmed), encoding: .utf8) else { + throw IdCardInternalError.failedReadingField(.personalCode) + } + let trimmedCode = personalCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedCode.isEmpty else { + throw IdCardInternalError.failedReadingField(.personalCode) + } + return trimmedCode + } + + /** + * Read a certificate from the currently selected path. Selecting with P2 = 0x0C returns no + * file size, so the read runs until the card reports the offset is past the end of the file. + */ + private func readCertificate(path: Bytes) async throws -> Data { + _ = try await select(file: IdemiaAID.main) + _ = try await select(p1Byte: 0x09, file: path) + + var data = Data() + while data.count < LatviaIdemia.maximumFileSize { + let chunk: Data + do { + chunk = try await reader.sendAPDU( + ins: 0xB0, + p1Byte: UInt8(data.count >> 8), + p2Byte: UInt8(truncatingIfNeeded: data.count), + leByte: 0x00 + ) + } catch IdCardInternalError.swError(0x6B00) where !data.isEmpty { + return data + } + guard !chunk.isEmpty else { + throw IdCardInternalError.invalidResponse( + message: "Card returned no certificate data at offset \(data.count)") + } + data.append(chunk) + } + throw IdCardInternalError.invalidResponse(message: "Certificate file exceeds addressable size") + } +} diff --git a/Sources/nfclib/CardActions/Thales.swift b/Sources/nfclib/CardActions/Thales.swift index 0e01e39..ada4cd3 100644 --- a/Sources/nfclib/CardActions/Thales.swift +++ b/Sources/nfclib/CardActions/Thales.swift @@ -23,6 +23,7 @@ final class Thales: CardCommandsInternal { let canChangePUK: Bool = false let reader: CardReader let fillChar: UInt8 = 0x00 + let cardType: CardType = .thales init?(reader: any CardReader, atr: Bytes) throws { guard atr == Thales.ATR else { @@ -54,7 +55,7 @@ final class Thales: CardCommandsInternal { func readPublicData() async throws -> CardInfo { _ = try await select(file: Thales.kAID) _ = try await select(p1Byte: 0x08, file: [0xDF, 0xDD]) - var personalData = CardInfo() + var personalData = CardInfo(cardType: cardType) for recordNr: UInt8 in 1...8 { let data = try await readFile(p1Byte: 0x02, file: [0x50, recordNr]) let record = String(data: Data(data), encoding: .utf8) ?? "-" diff --git a/Sources/nfclib/Operations/NFCConnection.swift b/Sources/nfclib/Operations/NFCConnection.swift index 203aa4e..bab825a 100644 --- a/Sources/nfclib/Operations/NFCConnection.swift +++ b/Sources/nfclib/Operations/NFCConnection.swift @@ -41,11 +41,46 @@ public class NFCConnection { return tag } + /** + * Detect the card by ATS historical bytes and return the matching implementation. + * + * - Parameters: + * - tag: The connected ISO 7816 tag. + * - CAN: The card access number printed on the card. + * - allowedCardTypes: required restriction on accepted card types. Use + * ``CardType/allCards`` to opt in to every supported card type. + * - Throws: ``IdCardInternalError/cardNotSupported`` if the ATS is unknown, or the detected + * card type is not in `allowedCardTypes`. + */ @MainActor - public func getCardCommands(_: NFCTagReaderSession, tag: NFCISO7816Tag, CAN: String) async throws -> CardCommands { - let initialSelectedAID = tag.initialSelectedAID + public func getCardCommands( + _: NFCTagReaderSession, + tag: NFCISO7816Tag, + CAN: String, + allowedCardTypes: Set = CardType.estonianCards + ) async throws -> CardCommands { + if let historicalBytes = tag.historicalBytes { + guard let detectedCardType = CardType(historicalBytes: historicalBytes), + allowedCardTypes.contains(detectedCardType) else { + throw IdCardInternalError.cardNotSupported + } + let reader = try await CardReaderNFC(tag, CAN: CAN) + switch detectedCardType { + case .latviaIdemia: return LatviaIdemia(reader: reader) + case .id1, .thales: return try await cardCommandsBySelectedAID(reader: reader, tag: tag) + } + } + let reader = try await CardReaderNFC(tag, CAN: CAN) - guard let aid = Bytes(hex: initialSelectedAID) else { + let commands = try await cardCommandsBySelectedAID(reader: reader, tag: tag) + guard allowedCardTypes.contains(commands.cardType) else { + throw IdCardInternalError.cardNotSupported + } + return commands + } + + private func cardCommandsBySelectedAID(reader: CardReader, tag: NFCISO7816Tag) async throws -> CardCommands { + guard let aid = Bytes(hex: tag.initialSelectedAID) else { throw IdCardInternalError.connectionFailed } @@ -69,4 +104,3 @@ public class NFCConnection { throw IdCardInternalError.cardNotSupported } } - diff --git a/Sources/nfclib/Operations/OperationReadCertificate.swift b/Sources/nfclib/Operations/OperationReadCertificate.swift index 0408579..e731c7d 100644 --- a/Sources/nfclib/Operations/OperationReadCertificate.swift +++ b/Sources/nfclib/Operations/OperationReadCertificate.swift @@ -31,8 +31,13 @@ public class OperationReadCertificate: NSObject { private let nfcMessage: String = "Please place your ID card against the smart device" private let connection = NFCConnection() private var continuation: CheckedContinuation? + private var allowedCardTypes: Set = CardType.estonianCards - public func startReading(CAN: String, certUsage: CertificateUsage) async throws -> SecCertificate { + public func startReading( + CAN: String, + certUsage: CertificateUsage, + allowedCardTypes: Set = CardType.estonianCards + ) async throws -> SecCertificate { return try await withCheckedThrowingContinuation { continuation in guard self.continuation == nil else { @@ -48,6 +53,7 @@ public class OperationReadCertificate: NSObject { self.CAN = CAN self.certUsage = certUsage + self.allowedCardTypes = allowedCardTypes session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: DispatchQueue.main) session?.alertMessage = nfcMessage session?.begin() @@ -72,7 +78,8 @@ extension OperationReadCertificate: @MainActor NFCTagReaderSessionDelegate { do { session.alertMessage = "Hold your ID card against your smart device until the data is read" let tag = try await connection.setup(session, tags: tags) - let cardCommands = try await connection.getCardCommands(session, tag: tag, CAN: CAN) + let cardCommands = try await connection.getCardCommands( + session, tag: tag, CAN: CAN, allowedCardTypes: allowedCardTypes) do { switch certUsage { case .auth: diff --git a/Sources/nfclib/Operations/OperationReadPublicData.swift b/Sources/nfclib/Operations/OperationReadPublicData.swift index b721737..d2eacb9 100644 --- a/Sources/nfclib/Operations/OperationReadPublicData.swift +++ b/Sources/nfclib/Operations/OperationReadPublicData.swift @@ -10,8 +10,12 @@ import CoreNFC private let nfcMessage: String = "Please place your ID card against the smart device" private let connection = NFCConnection() private var continuation: CheckedContinuation? + private var allowedCardTypes: Set = CardType.estonianCards - public func startReading(CAN: String) async throws -> CardInfo { + public func startReading( + CAN: String, + allowedCardTypes: Set = CardType.estonianCards + ) async throws -> CardInfo { return try await withCheckedThrowingContinuation { continuation in guard self.continuation == nil else { continuation.resume(throwing: IdCardInternalError.operationInProgress) @@ -25,6 +29,7 @@ import CoreNFC } self.CAN = CAN + self.allowedCardTypes = allowedCardTypes session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: DispatchQueue.main) session?.alertMessage = nfcMessage session?.begin() @@ -44,7 +49,8 @@ extension OperationReadPublicData: @MainActor NFCTagReaderSessionDelegate { do { session.alertMessage = "Hold your ID card against your smart device until the data is read" let tag = try await connection.setup(session, tags: tags) - let cardCommands = try await connection.getCardCommands(session, tag: tag, CAN: CAN) + let cardCommands = try await connection.getCardCommands( + session, tag: tag, CAN: CAN, allowedCardTypes: allowedCardTypes) let cardInfo = try await cardCommands.readPublicData() finish(.success(cardInfo)) diff --git a/Sources/nfclib/Operations/OperationSignHash.swift b/Sources/nfclib/Operations/OperationSignHash.swift index 4931458..8efe66e 100644 --- a/Sources/nfclib/Operations/OperationSignHash.swift +++ b/Sources/nfclib/Operations/OperationSignHash.swift @@ -13,8 +13,14 @@ public class OperationSignHash: NSObject { private let nfcMessage: String = "Please place your ID card against the smart device" private var continuation: CheckedContinuation? private var connection = NFCConnection() + private var allowedCardTypes: Set = CardType.estonianCards - public func startSigning(CAN: String, PIN2: SecureData, hash: Data) async throws -> Data { + public func startSigning( + CAN: String, + PIN2: SecureData, + hash: Data, + allowedCardTypes: Set = CardType.estonianCards + ) async throws -> Data { return try await withCheckedThrowingContinuation { continuation in guard self.continuation == nil else { @@ -30,6 +36,7 @@ public class OperationSignHash: NSObject { self.CAN = CAN self.PIN = PIN2 self.hashToSign = hash + self.allowedCardTypes = allowedCardTypes session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: DispatchQueue.main) updateAlertMessage(step: 0) @@ -72,7 +79,8 @@ extension OperationSignHash: @MainActor NFCTagReaderSessionDelegate { let pin2 = PIN let tag = try await connection.setup(session, tags: tags) updateAlertMessage(step: 2) - let cardCommands = try await connection.getCardCommands(session, tag: tag, CAN: CAN) + let cardCommands = try await connection.getCardCommands( + session, tag: tag, CAN: CAN, allowedCardTypes: allowedCardTypes) updateAlertMessage(step: 3) let signatureValue = try await cardCommands.calculateSignature(for: hashToSign, withPin2: pin2) finish(.success(signatureValue)) diff --git a/Sources/nfclib/Operations/OperationUnblockPin.swift b/Sources/nfclib/Operations/OperationUnblockPin.swift index 1caf169..bff6230 100644 --- a/Sources/nfclib/Operations/OperationUnblockPin.swift +++ b/Sources/nfclib/Operations/OperationUnblockPin.swift @@ -19,8 +19,15 @@ public class OperationUnblockPin: NSObject { private let nfcMessage: String = "Please place your ID card against the smart device" private let connection = NFCConnection() private var continuation: CheckedContinuation? + private var allowedCardTypes: Set = CardType.estonianCards - public func startReading(CAN: String, codeType: CodeType, puk: SecureData, newPin: SecureData) async throws { + public func startReading( + CAN: String, + codeType: CodeType, + puk: SecureData, + newPin: SecureData, + allowedCardTypes: Set = CardType.estonianCards + ) async throws { return try await withCheckedThrowingContinuation { continuation in guard self.continuation == nil else { @@ -38,6 +45,7 @@ public class OperationUnblockPin: NSObject { self.codeType = codeType self.puk = puk self.newPin = newPin + self.allowedCardTypes = allowedCardTypes session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: DispatchQueue.main) session?.alertMessage = nfcMessage session?.begin() @@ -65,7 +73,8 @@ extension OperationUnblockPin: @MainActor NFCTagReaderSessionDelegate { do { session.alertMessage = "Hold your ID card against your smart device until the data is read" let tag = try await self.connection.setup(session, tags: tags) - let cardCommands = try await self.connection.getCardCommands(session, tag: tag, CAN: self.CAN) + let cardCommands = try await self.connection.getCardCommands( + session, tag: tag, CAN: self.CAN, allowedCardTypes: self.allowedCardTypes) try await cardCommands.unblockCode(codeType, puk: puk, newCode: newPin) finish(.success(())) diff --git a/Sources/nfclib/Utilities/CertificateSubject.swift b/Sources/nfclib/Utilities/CertificateSubject.swift new file mode 100644 index 0000000..639528e --- /dev/null +++ b/Sources/nfclib/Utilities/CertificateSubject.swift @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Foundation +import CryptoTokenKit + +/** + * Subject fields of an X.509 certificate, read from the DER directly because iOS publishes no + * per-RDN accessor and `SecCertificateCopyNormalizedSubjectSequence` returns a DER-encoded name + * rather than decoded strings. + */ +struct CertificateSubject { + let surname: String + let givenName: String + let country: String + let serialNumber: String + + private enum AttributeOID { + static let surname = "550404" // 2.5.4.4 + static let givenName = "55042a" // 2.5.4.42 + static let serialNumber = "550405" // 2.5.4.5 + static let country = "550406" // 2.5.4.6 + } + + init(derEncodedCertificate certificate: Data) throws { + let attributes = try CertificateSubject.subjectAttributes(of: certificate) + guard let surname = attributes[AttributeOID.surname], + let givenName = attributes[AttributeOID.givenName] else { + throw CertificateConversionError.malformed(field: "subject name") + } + self.surname = surname + self.givenName = givenName + country = attributes[AttributeOID.country] ?? "" + serialNumber = attributes[AttributeOID.serialNumber] ?? "" + } + + /** + * Extract the subject's RDN values by attribute-type OID, keyed by lower-case hex as + * `toHex` renders it. Where an OID appears more than once the first occurrence wins. + */ + private static func subjectAttributes(of certificate: Data) throws -> [String: String] { + guard let outer = TKBERTLVRecord(from: certificate), + let tbsCertificate = TKBERTLVRecord.sequenceOfRecords(from: outer.value)?.first, + var fields = TKBERTLVRecord.sequenceOfRecords(from: tbsCertificate.value) else { + throw CertificateConversionError.malformed(field: "certificate") + } + + if fields.first?.tag == 0xA0 { + fields.removeFirst() + } + guard fields.count > 4, fields[4].tag == 0x30 else { + throw CertificateConversionError.malformed(field: "subject") + } + + guard let relativeNames = TKBERTLVRecord.sequenceOfRecords(from: fields[4].value) else { + throw CertificateConversionError.malformed(field: "subject") + } + var attributes: [String: String] = [:] + for relativeName in relativeNames { + for attribute in TKBERTLVRecord.sequenceOfRecords(from: relativeName.value) ?? [] { + guard let pair = TKBERTLVRecord.sequenceOfRecords(from: attribute.value), + pair.count >= 2, pair[0].tag == 0x06, + let value = directoryString(pair[1]) else { continue } + let objectIdentifier = pair[0].value.toHex + if attributes[objectIdentifier] == nil { + attributes[objectIdentifier] = value + } + } + } + return attributes + } + + private static func directoryString(_ record: TKTLVRecord) -> String? { + switch record.tag { + case 0x1E: return String(data: record.value, encoding: .utf16BigEndian) + case 0x14: return String(data: record.value, encoding: .isoLatin1) + case 0x0C, 0x13, 0x16: return String(data: record.value, encoding: .utf8) + default: return nil + } + } +} diff --git a/Sources/nfclib/Utilities/PersonalCodeParser.swift b/Sources/nfclib/Utilities/PersonalCodeParser.swift new file mode 100644 index 0000000..85cf493 --- /dev/null +++ b/Sources/nfclib/Utilities/PersonalCodeParser.swift @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Foundation + +enum PersonalCodeParser { + + static func estonianDateOfBirth(_ personalCode: String) -> Date? { + let digits = Array(personalCode) + guard digits.count >= 7, digits.prefix(7).allSatisfy(\.isASCIIDigit) else { return nil } + + let century: Int + switch digits[0].wholeNumberValue { + case 1, 2: century = 1800 + case 3, 4: century = 1900 + case 5, 6: century = 2000 + case 7, 8: century = 2100 + default: return nil + } + + guard let yearInCentury = Int(String(digits[1...2])), + let month = Int(String(digits[3...4])), + let day = Int(String(digits[5...6])) else { return nil } + + return gregorianDate(year: century + yearInCentury, month: month, day: day) + } + + /** + * Parse date of birth from Latvian personal code. + * + * Old format (pre-2017): DDMMYY-CZZZQ where C is century digit (0=18xx, 1=19xx, 2=20xx) + * New format (from 2017): starts with "32", DOB not encoded — returns nil. + */ + static func latvianDateOfBirth(_ personalCode: String) -> Date? { + let digits = Array(personalCode.replacing("-", with: "")) + guard digits.count >= 7, digits.prefix(7).allSatisfy(\.isASCIIDigit) else { return nil } + guard !digits.starts(with: ["3", "2"]) else { return nil } + + let century: Int + switch digits[6].wholeNumberValue { + case 0: century = 1800 + case 1: century = 1900 + case 2: century = 2000 + default: return nil + } + + guard let day = Int(String(digits[0...1])), + let month = Int(String(digits[2...3])), + let yearInCentury = Int(String(digits[4...5])) else { return nil } + + return gregorianDate(year: century + yearInCentury, month: month, day: day) + } + + private static func gregorianDate(year: Int, month: Int, day: Int) -> Date? { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? calendar.timeZone + guard let date = calendar.date(from: components) else { return nil } + + let rounded = calendar.dateComponents([.year, .month, .day], from: date) + guard rounded.year == year, rounded.month == month, rounded.day == day else { return nil } + return date + } +} + +private extension Character { + var isASCIIDigit: Bool { isASCII && isNumber } +} diff --git a/Sources/nfclib/WebEID/OperationAuthenticateWithWebEID.swift b/Sources/nfclib/WebEID/OperationAuthenticateWithWebEID.swift index 2dc1f79..47a4fd1 100644 --- a/Sources/nfclib/WebEID/OperationAuthenticateWithWebEID.swift +++ b/Sources/nfclib/WebEID/OperationAuthenticateWithWebEID.swift @@ -20,15 +20,23 @@ public class OperationAuthenticateWithWebEID: NSObject { private let challenge: String private let origin: String private let connection = NFCConnection() + private let allowedCardTypes: Set private var session: NFCTagReaderSession? private var continuation: CheckedContinuation? - public init(CAN: String, pin1: SecureData, challenge: String, origin: String) { + public init( + CAN: String, + pin1: SecureData, + challenge: String, + origin: String, + allowedCardTypes: Set = CardType.estonianCards + ) { self.CAN = CAN self.pin1 = pin1 self.challenge = challenge self.origin = origin + self.allowedCardTypes = allowedCardTypes } public func startReading() async throws -> WebEidData { @@ -83,7 +91,8 @@ extension OperationAuthenticateWithWebEID: @MainActor NFCTagReaderSessionDelegat updateAlertMessage(step: 1) let tag = try await connection.setup(session, tags: tags) updateAlertMessage(step: 2) - let cardCommands = try await connection.getCardCommands(session, tag: tag, CAN: CAN) + let cardCommands = try await connection.getCardCommands( + session, tag: tag, CAN: CAN, allowedCardTypes: allowedCardTypes) updateAlertMessage(step: 3) let certBytes = try await cardCommands.readAuthenticationCertificate() diff --git a/mvoting-nfc/nfc-demo/CardInfoView.swift b/mvoting-nfc/nfc-demo/CardInfoView.swift index 031ecd3..d48a6c5 100644 --- a/mvoting-nfc/nfc-demo/CardInfoView.swift +++ b/mvoting-nfc/nfc-demo/CardInfoView.swift @@ -9,11 +9,22 @@ struct CardInfoView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { + Text("Card: \(cardInfo.cardType.rawValue)") Text("Given Name: \(cardInfo.givenName)") Text("Surname: \(cardInfo.surname)") Text("Personal Code: \(cardInfo.personalCode)") - Text("Citizenship: \(cardInfo.citizenship)") - Text("Date of Expiry: \(cardInfo.dateOfExpiry)") + if !cardInfo.citizenship.isEmpty { + Text("Citizenship: \(cardInfo.citizenship)") + } + if !cardInfo.issuingCountry.isEmpty { + Text("Issuing Country: \(cardInfo.issuingCountry)") + } + if !cardInfo.dateOfExpiry.isEmpty { + Text("Date of Expiry: \(cardInfo.dateOfExpiry)") + } + if let certificateExpiryDate = cardInfo.certificateExpiryDate { + Text("Certificate Expiry: \(certificateExpiryDate.formatted(date: .numeric, time: .omitted))") + } } .padding() } diff --git a/mvoting-nfc/nfc-demo/CardOperations.swift b/mvoting-nfc/nfc-demo/CardOperations.swift index ce2a575..5a55018 100644 --- a/mvoting-nfc/nfc-demo/CardOperations.swift +++ b/mvoting-nfc/nfc-demo/CardOperations.swift @@ -19,24 +19,30 @@ public protocol CardOperations { /// Asynchronously reads the public information from the card. /// - /// - Parameter CAN: A `String` representing the Card Access Number. + /// - Parameters: + /// - CAN: A `String` representing the Card Access Number. + /// - allowedCardTypes: The card models to accept. /// - Returns: A `CardInfo` object containing the read public information. /// - Throws: An error if the reading fails. - func readPublicInfo(CAN: String) async throws -> CardInfo + func readPublicInfo(CAN: String, allowedCardTypes: Set) async throws -> CardInfo /// Asynchronously reads the authentication certificate from the card. /// - /// - Parameter CAN: A `String` representing the Card Access Number. + /// - Parameters: + /// - CAN: A `String` representing the Card Access Number. + /// - allowedCardTypes: The card models to accept. /// - Returns: A `SecCertificate` object representing the authentication certificate. /// - Throws: An error if the reading fails. - func readAuthenticationCertificate(CAN: String) async throws -> SecCertificate + func readAuthenticationCertificate(CAN: String, allowedCardTypes: Set) async throws -> SecCertificate /// Asynchronously reads the signing certificate from the card. /// - /// - Parameter CAN: A `String` representing the Card Access Number. + /// - Parameters: + /// - CAN: A `String` representing the Card Access Number. + /// - allowedCardTypes: The card models to accept. /// - Returns: A `SecCertificate` object representing the signing certificate. /// - Throws: An error if the reading fails. - func readSigningCertificate(CAN: String) async throws -> SecCertificate + func readSigningCertificate(CAN: String, allowedCardTypes: Set) async throws -> SecCertificate /// Fetch data for WebEID authentication using the provided credentials and challenge. /// @@ -45,9 +51,10 @@ public protocol CardOperations { /// - pin1: A `String` representing PIN1. /// - challenge: A `String` representing the authentication challenge. /// - origin: A `String` representing the origin of the authentication request. + /// - allowedCardTypes: The card models to accept. /// - Returns: A `WebEidData` object containing the result of the authentication. /// - Throws: An error if the authentication fails. - func loadWebEIDAuthenticationData(CAN: String, pin1: SecureData, challenge: String, origin: String) async throws -> WebEidData + func loadWebEIDAuthenticationData(CAN: String, pin1: SecureData, challenge: String, origin: String, allowedCardTypes: Set) async throws -> WebEidData /// Performs a signing operation using the provided hash and PIN. /// @@ -55,9 +62,10 @@ public protocol CardOperations { /// - CAN: A `String` representing the Card Access Number. /// - hash: A `Data` object representing the hash to be signed. /// - pin2: A `String` representing the second PIN. + /// - allowedCardTypes: The card models to accept. /// - Returns: A `Data` object containing the signature. /// - Throws: An error if the signing operation fails. - func sign(CAN: String, hash: Data, pin2: SecureData) async throws -> Data + func sign(CAN: String, hash: Data, pin2: SecureData, allowedCardTypes: Set) async throws -> Data /// Unblocks a PIN1 using the PUK code. /// @@ -65,8 +73,9 @@ public protocol CardOperations { /// - CAN: A `String` representing the Card Access Number. /// - puk: The current PUK code for verification. /// - newCode: The new PIN code. + /// - allowedCardTypes: The card models to accept. /// - Throws: An error if the operation fails. - func unblockPin1(CAN: String, puk: SecureData, newCode: SecureData) async throws + func unblockPin1(CAN: String, puk: SecureData, newCode: SecureData, allowedCardTypes: Set) async throws /// Unblocks a PIN2 using the PUK code. /// @@ -74,6 +83,7 @@ public protocol CardOperations { /// - CAN: A `String` representing the Card Access Number. /// - puk: The current PUK code for verification. /// - newCode: The new PIN code. + /// - allowedCardTypes: The card models to accept. /// - Throws: An error if the operation fails. - func unblockPin2(CAN: String, puk: SecureData, newCode: SecureData) async throws + func unblockPin2(CAN: String, puk: SecureData, newCode: SecureData, allowedCardTypes: Set) async throws } diff --git a/mvoting-nfc/nfc-demo/ContentView.swift b/mvoting-nfc/nfc-demo/ContentView.swift index e0eacf3..e3e50ff 100644 --- a/mvoting-nfc/nfc-demo/ContentView.swift +++ b/mvoting-nfc/nfc-demo/ContentView.swift @@ -20,8 +20,13 @@ struct ContentView: View { @State private var pin2: SecureData = SecureData([0x00]) @State private var puk: SecureData = SecureData([0x00]) @State private var selectedMethod: HashMethod = .sha254 + @State private var allowLatvianCards: Bool = false let methods = HashMethod.allCases + private var allowedCardTypes: Set { + allowLatvianCards ? CardType.allCards : CardType.estonianCards + } + var body: some View { ScrollView { VStack(spacing: 10) { @@ -70,6 +75,9 @@ struct ContentView: View { .padding() } .padding() + Toggle("Accept Latvian ID cards", isOn: $allowLatvianCards) + .padding(.horizontal) + Text("Available operations") .font(.system(size: 30)) VStack(spacing: 40) { @@ -78,7 +86,7 @@ struct ContentView: View { return } Task { - await viewModel.readPublicInfo(can: can) + await viewModel.readPublicInfo(can: can, allowedCardTypes: allowedCardTypes) } } @@ -87,7 +95,7 @@ struct ContentView: View { return } Task { - await viewModel.readAuthenticationCertificate(can: can) + await viewModel.readAuthenticationCertificate(can: can, allowedCardTypes: allowedCardTypes) } } @@ -96,7 +104,7 @@ struct ContentView: View { return } Task { - await viewModel.readSigningCertificate(can: can) + await viewModel.readSigningCertificate(can: can, allowedCardTypes: allowedCardTypes) } } @@ -108,7 +116,7 @@ struct ContentView: View { return } Task { - await viewModel.authenticate(can: can, pin1: pin1, challenge: challenge, origin: origin) + await viewModel.authenticate(can: can, pin1: pin1, challenge: challenge, origin: origin, allowedCardTypes: allowedCardTypes) } } @@ -118,7 +126,7 @@ struct ContentView: View { return } Task { - await viewModel.sign(can: can, pin2: pin2) + await viewModel.sign(can: can, pin2: pin2, allowedCardTypes: allowedCardTypes) } } Button("Unblock PIN1") { @@ -127,7 +135,7 @@ struct ContentView: View { pin1.count >= 4 else { return } Task { - await viewModel.unblockPin1(can: can, puk: puk, newCode: pin1) + await viewModel.unblockPin1(can: can, puk: puk, newCode: pin1, allowedCardTypes: allowedCardTypes) } } Button("Unblock PIN2") { @@ -136,7 +144,7 @@ struct ContentView: View { pin2.count >= 5 else { return } Task { - await viewModel.unblockPin2(can: can, puk: puk, newCode: pin2) + await viewModel.unblockPin2(can: can, puk: puk, newCode: pin2, allowedCardTypes: allowedCardTypes) } } } @@ -144,7 +152,13 @@ struct ContentView: View { .padding() VStack(spacing: 20) { + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .textSelection(.enabled) + } Text("Card data") + Text(viewModel.cardInfo.map { "Card: \($0.cardType.rawValue)" } ?? "") Text(viewModel.cardInfo?.formattedDescription ?? "") .textSelection(.enabled) Text("Web-eid data") @@ -231,6 +245,23 @@ extension ContentView { var signingResult: String? var hashedData: Data? var hashedDataString: String? + var errorMessage: String? + + private func report(_ error: Error) { + guard let idCardError = error as? IdCardError else { + errorMessage = "Failed: \(error)" + return + } + switch idCardError { + case .cardNotSupported: errorMessage = "Card not supported" + case .wrongCAN: errorMessage = "Wrong CAN" + case .wrongPIN(let codeType, let triesLeft): + errorMessage = "Wrong \(codeType.name), \(triesLeft) tries left" + case .invalidNewPIN: errorMessage = "New PIN rejected by the card" + case .notActivated: errorMessage = "Card not activated" + case .sessionError: errorMessage = "Session failed" + } + } func computeHash(_ selectedMethod: HashMethod, _ dataToHashString: String) { guard let dataToHash = dataToHashString.data(using: .utf8) else { return } @@ -246,75 +277,88 @@ extension ContentView { hashedData = data } - func readPublicInfo(can: String) async { + func readPublicInfo(can: String, allowedCardTypes: Set) async { + errorMessage = nil do { - let cardInfo = try await cardOperator.readPublicInfo(CAN: can) + let cardInfo = try await cardOperator.readPublicInfo( + CAN: can, allowedCardTypes: allowedCardTypes) self.cardInfo = cardInfo - } catch { - + report(error) } } - func readAuthenticationCertificate(can: String) async { + func readAuthenticationCertificate(can: String, allowedCardTypes: Set) async { + errorMessage = nil do { - let cert = try await cardOperator.readAuthenticationCertificate(CAN: can) + let cert = try await cardOperator.readAuthenticationCertificate( + CAN: can, allowedCardTypes: allowedCardTypes) guard let certSummary = SecCertificateCopySubjectSummary(cert) as? String else { self.authCert = "Failed!" return } self.authCert = "summary: \(certSummary)" } catch { - // Handle error here + report(error) } } - func readSigningCertificate(can: String) async { + func readSigningCertificate(can: String, allowedCardTypes: Set) async { + errorMessage = nil do { - let cert = try await cardOperator.readSigningCertificate(CAN: can) + let cert = try await cardOperator.readSigningCertificate( + CAN: can, allowedCardTypes: allowedCardTypes) guard let certSummary = SecCertificateCopySubjectSummary(cert) as? String else { self.signingCert = "Failed!" return } self.signingCert = "summary: \(certSummary)" } catch { - // Handle error here + report(error) } } - func sign(can: String, pin2: SecureData) async { + func sign(can: String, pin2: SecureData, allowedCardTypes: Set) async { guard let data = hashedData else { return } + errorMessage = nil do { - let signResult = try await cardOperator.sign(CAN: can, hash: data, pin2: pin2) - let stringResult = signResult.hexStr - self.signingResult = stringResult + let signResult = try await cardOperator.sign( + CAN: can, hash: data, pin2: pin2, allowedCardTypes: allowedCardTypes) + self.signingResult = signResult.hexStr } catch { - // Handle error here + report(error) } } - func authenticate(can: String, pin1: SecureData, challenge: String, origin: String) async { + func authenticate(can: String, pin1: SecureData, challenge: String, origin: String, allowedCardTypes: Set) async { + errorMessage = nil do { - let webEidResult = try await cardOperator.loadWebEIDAuthenticationData(CAN: can, pin1: pin1, challenge: challenge, origin: origin) + let webEidResult = try await cardOperator.loadWebEIDAuthenticationData( + CAN: can, pin1: pin1, challenge: challenge, origin: origin, + allowedCardTypes: allowedCardTypes) self.webEidData = webEidResult } catch { - // Handle error here + report(error) } } - func unblockPin1(can: String, puk: SecureData, newCode: SecureData) async { + func unblockPin1(can: String, puk: SecureData, newCode: SecureData, allowedCardTypes: Set) async { + errorMessage = nil do { - try await cardOperator.unblockPin1(CAN: can, puk: puk, newCode: newCode) + try await cardOperator.unblockPin1( + CAN: can, puk: puk, newCode: newCode, allowedCardTypes: allowedCardTypes) } catch { - // Handle error here + report(error) } } - func unblockPin2(can: String, puk: SecureData, newCode: SecureData) async { + func unblockPin2(can: String, puk: SecureData, newCode: SecureData, allowedCardTypes: Set) async { + errorMessage = nil do { - try await cardOperator.unblockPin2(CAN: can, puk: puk, newCode: newCode) + try await cardOperator.unblockPin2( + CAN: can, puk: puk, newCode: newCode, allowedCardTypes: allowedCardTypes) } catch { - // Handle error here + report(error) } } } diff --git a/mvoting-nfc/nfc-demo/Operator.swift b/mvoting-nfc/nfc-demo/Operator.swift index 826e0c6..d7e1276 100644 --- a/mvoting-nfc/nfc-demo/Operator.swift +++ b/mvoting-nfc/nfc-demo/Operator.swift @@ -20,9 +20,10 @@ extension Operator: CardOperations { NFCTagReaderSession.readingAvailable } - public func readPublicInfo(CAN: String) async throws -> CardInfo { + public func readPublicInfo(CAN: String, allowedCardTypes: Set) async throws -> CardInfo { do { - let result = try await OperationReadPublicData().startReading(CAN: CAN) + let result = try await OperationReadPublicData() + .startReading(CAN: CAN, allowedCardTypes: allowedCardTypes) return result } catch { guard let e = error as? IdCardInternalError else { @@ -32,9 +33,10 @@ extension Operator: CardOperations { } } - public func readAuthenticationCertificate(CAN: String) async throws -> SecCertificate { + public func readAuthenticationCertificate(CAN: String, allowedCardTypes: Set) async throws -> SecCertificate { do { - let cert = try await OperationReadCertificate().startReading(CAN: CAN, certUsage: .auth) + let cert = try await OperationReadCertificate() + .startReading(CAN: CAN, certUsage: .auth, allowedCardTypes: allowedCardTypes) return cert } catch { guard let e = error as? IdCardInternalError else { @@ -44,9 +46,10 @@ extension Operator: CardOperations { } } - public func readSigningCertificate(CAN: String) async throws -> SecCertificate { + public func readSigningCertificate(CAN: String, allowedCardTypes: Set) async throws -> SecCertificate { do { - let cert = try await OperationReadCertificate().startReading(CAN: CAN, certUsage: .sign) + let cert = try await OperationReadCertificate() + .startReading(CAN: CAN, certUsage: .sign, allowedCardTypes: allowedCardTypes) return cert } catch { guard let e = error as? IdCardInternalError else { @@ -56,9 +59,15 @@ extension Operator: CardOperations { } } - public func loadWebEIDAuthenticationData(CAN: String, pin1: SecureData, challenge: String, origin: String) async throws -> WebEidData { + public func loadWebEIDAuthenticationData(CAN: String, pin1: SecureData, challenge: String, origin: String, allowedCardTypes: Set) async throws -> WebEidData { do { - let webEidData = try await OperationAuthenticateWithWebEID(CAN: CAN, pin1: pin1, challenge: challenge, origin: origin).startReading() + let webEidData = try await OperationAuthenticateWithWebEID( + CAN: CAN, + pin1: pin1, + challenge: challenge, + origin: origin, + allowedCardTypes: allowedCardTypes + ).startReading() return webEidData } catch { guard let e = error as? IdCardInternalError else { @@ -68,9 +77,10 @@ extension Operator: CardOperations { } } - public func sign(CAN: String, hash: Data, pin2: SecureData) async throws -> Data { + public func sign(CAN: String, hash: Data, pin2: SecureData, allowedCardTypes: Set) async throws -> Data { do { - let signature = try await OperationSignHash().startSigning(CAN: CAN, PIN2: pin2, hash: hash) + let signature = try await OperationSignHash() + .startSigning(CAN: CAN, PIN2: pin2, hash: hash, allowedCardTypes: allowedCardTypes) return signature } catch { guard let e = error as? IdCardInternalError else { @@ -80,9 +90,10 @@ extension Operator: CardOperations { } } - public func unblockPin1(CAN: String, puk: SecureData, newCode: SecureData) async throws { + public func unblockPin1(CAN: String, puk: SecureData, newCode: SecureData, allowedCardTypes: Set) async throws { do { - try await OperationUnblockPin().startReading(CAN: CAN, codeType: .pin1, puk: puk, newPin: newCode) + try await OperationUnblockPin().startReading( + CAN: CAN, codeType: .pin1, puk: puk, newPin: newCode, allowedCardTypes: allowedCardTypes) } catch { guard let e = error as? IdCardInternalError else { throw IdCardError.sessionError @@ -91,9 +102,10 @@ extension Operator: CardOperations { } } - public func unblockPin2(CAN: String, puk: SecureData, newCode: SecureData) async throws { + public func unblockPin2(CAN: String, puk: SecureData, newCode: SecureData, allowedCardTypes: Set) async throws { do { - try await OperationUnblockPin().startReading(CAN: CAN, codeType: .pin2, puk: puk, newPin: newCode) + try await OperationUnblockPin().startReading( + CAN: CAN, codeType: .pin2, puk: puk, newPin: newCode, allowedCardTypes: allowedCardTypes) } catch { guard let e = error as? IdCardInternalError else { throw IdCardError.sessionError From 6d132b70ed665762cff9ca848e5df3d61baab9f7 Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Fri, 6 Nov 2026 14:52:18 +0200 Subject: [PATCH 2/7] Code review fixes --- Sources/nfclib/CardActions/CardInfo.swift | 6 +- Sources/nfclib/CardActions/CardType.swift | 20 +-- Sources/nfclib/CardActions/Idemia.swift | 152 +++++++++++------- Sources/nfclib/CardActions/IdemiaCard.swift | 124 -------------- .../nfclib/CardActions/IdemiaEstonia.swift | 78 +++++++++ ...{LatviaIdemia.swift => IdemiaLatvia.swift} | 25 ++- Sources/nfclib/CardActions/Thales.swift | 2 +- Sources/nfclib/Operations/NFCConnection.swift | 12 +- 8 files changed, 204 insertions(+), 215 deletions(-) delete mode 100644 Sources/nfclib/CardActions/IdemiaCard.swift create mode 100644 Sources/nfclib/CardActions/IdemiaEstonia.swift rename Sources/nfclib/CardActions/{LatviaIdemia.swift => IdemiaLatvia.swift} (85%) diff --git a/Sources/nfclib/CardActions/CardInfo.swift b/Sources/nfclib/CardActions/CardInfo.swift index f025044..dcd7efc 100644 --- a/Sources/nfclib/CardActions/CardInfo.swift +++ b/Sources/nfclib/CardActions/CardInfo.swift @@ -34,7 +34,7 @@ public struct CardInfo: Sendable, Hashable { dateAndPlaceOfBirth: String = "", issuingCountry: String = "", certificateExpiryDate: Date? = nil, - cardType: CardType = .thales + cardType: CardType = .thalesEstonia ) { self.givenName = givenName self.surname = surname @@ -50,8 +50,8 @@ public struct CardInfo: Sendable, Hashable { public var dateOfBirth: Date? { switch cardType { - case .latviaIdemia: return PersonalCodeParser.latvianDateOfBirth(personalCode) - case .id1, .thales: return PersonalCodeParser.estonianDateOfBirth(personalCode) + case .idemiaLatvia: return PersonalCodeParser.latvianDateOfBirth(personalCode) + case .idemiaEstonia, .thalesEstonia: return PersonalCodeParser.estonianDateOfBirth(personalCode) } } diff --git a/Sources/nfclib/CardActions/CardType.swift b/Sources/nfclib/CardActions/CardType.swift index ff6ea42..8f15ca9 100644 --- a/Sources/nfclib/CardActions/CardType.swift +++ b/Sources/nfclib/CardActions/CardType.swift @@ -4,9 +4,9 @@ import Foundation public enum CardType: String, Sendable, CaseIterable { - case id1 - case thales - case latviaIdemia + case idemiaEstonia + case thalesEstonia + case idemiaLatvia } public extension CardType { @@ -17,21 +17,21 @@ public extension CardType { * so a future release of this library adding a country variant cannot silently widen the set * of cards that the integrator's app accepts. */ - static let estonianCards: Set = [.id1, .thales] + static let estonianCards: Set = [.idemiaEstonia, .thalesEstonia] static let allCards: Set = Set(CardType.allCases) } extension CardType { private static let knownHistoricalBytes: [(bytes: Bytes, cardType: CardType)] = [ - (Bytes(hex: "00 12 23 3F 53 65 49 44 0F 90 00"), .id1), - (Bytes(hex: "00 12 23 3F 54 65 49 44 32 0F 90 00"), .id1), + (Bytes(hex: "00 12 23 3F 53 65 49 44 0F 90 00"), .idemiaEstonia), + (Bytes(hex: "00 12 23 3F 54 65 49 44 32 0F 90 00"), .idemiaEstonia), // Android lists only D8; this repo's Thales ATR carries B8 in the same position. Both // are kept until a real tap settles it — matching neither would reject every Thales card. - (Bytes(hex: "80 31 B8 53 65 49 44 64 B0 85 05 10 12 23 3F"), .thales), - (Bytes(hex: "80 31 D8 53 65 49 44 64 B0 85 05 10 12 23 3F"), .thales), - (Bytes(hex: "00 12 42 8F 53 65 49 44 0F 90 00"), .latviaIdemia), - (Bytes(hex: "00 12 42 8F 54 65 49 44 32 0F 90 00"), .latviaIdemia) + (Bytes(hex: "80 31 B8 53 65 49 44 64 B0 85 05 10 12 23 3F"), .thalesEstonia), + (Bytes(hex: "80 31 D8 53 65 49 44 64 B0 85 05 10 12 23 3F"), .thalesEstonia), + (Bytes(hex: "00 12 42 8F 53 65 49 44 0F 90 00"), .idemiaLatvia), + (Bytes(hex: "00 12 42 8F 54 65 49 44 32 0F 90 00"), .idemiaLatvia) ] init?(historicalBytes: Data?) { diff --git a/Sources/nfclib/CardActions/Idemia.swift b/Sources/nfclib/CardActions/Idemia.swift index cf7f74c..7248a1b 100644 --- a/Sources/nfclib/CardActions/Idemia.swift +++ b/Sources/nfclib/CardActions/Idemia.swift @@ -3,76 +3,114 @@ import Foundation -private let ATR = Bytes(hex: "3B DB 96 00 80 B1 FE 45 1F 83 00 12 23 3F 53 65 49 44 0F 90 00 F1") -private let ATRv2 = Bytes(hex: "3B DC 96 00 80 B1 FE 45 1F 83 00 12 23 3F 54 65 49 44 32 0F 90 00 C3") - -final class Idemia: IdemiaCard { - let canChangePUK: Bool = true - let reader: CardReader - let fillChar: UInt8 = 0xFF - let cardType: CardType = .id1 - - let authKeyRef: UInt8 = 0x81 - let signKeyRef: UInt8 = 0x9F - let authAlgorithm: Bytes = [0xFF, 0x20, 0x08, 0x00] - let signAlgorithm: Bytes = [0xFF, 0x15, 0x08, 0x00] - let decryptAlgorithm: Bytes = [0xFF, 0x30, 0x04, 0x00] - - init?(reader: CardReader, atr: Bytes) { - guard atr == ATR || atr == ATRv2 else { - return nil +enum IdemiaAID { + static let main = Bytes(hex: "A0 00 00 00 77 01 08 00 07 00 00 FE 00 00 01 00") + static let qscd = Bytes(hex: "51 53 43 44 20 41 70 70 6C 69 63 61 74 69 6F 6E") + static let oberthur = Bytes(hex: "E8 28 BD 08 0F F2 50 4F 54 20 41 57 50") +} + +protocol Idemia: CardCommandsInternal { + var authKeyRef: UInt8 { get } + var signKeyRef: UInt8 { get } + var authAlgorithm: Bytes { get } + var signAlgorithm: Bytes { get } + var decryptAlgorithm: Bytes { get } +} + +extension Idemia { + + // MARK: - PIN & PUK Management + + func readCodeTryCounterRecord(_ type: CodeType) async throws -> (retryCount: UInt8, pinActive: Bool) { + _ = try await select(file: aid(for: type)) + let ref = pinRef(for: type) & ~0x80 + let data = try await reader.sendAPDU(ins: 0xCB, p1Byte: 0x3F, p2Byte: 0xFF, data: + [0x4D, 0x08, 0x70, 0x06, 0xBF, 0x81, ref, 0x02, 0xA0, 0x80], leByte: 0x00) + if let info = TLV(from: data), info.tag == 0x70, + let tag = TLV(from: info.value), tag.tag == 0xBF8100 | UInt32(ref), + let a0Value = TLV(from: tag.value), a0Value.tag == 0xA0, + let records = TLV.sequenceOfRecords(from: a0Value.value) { + for record in records where record.tag == 0x9B { + guard let counter = record.value.first else { continue } + return (counter, true) + } } - self.reader = reader + throw IdCardInternalError.invalidResponse( + message: "Missing PIN counter record for \(cardType.rawValue)") } - init?(reader: CardReader, aid: Bytes) { - guard aid == IdemiaAID.main else { - return nil - } - self.reader = reader + func changeCode(_ type: CodeType, to code: SecureData, verifyCode: SecureData) async throws { + _ = try await select(file: aid(for: type)) + try await changeCode(pinRef(for: type), codeType: type, to: code, verifyCode: verifyCode) } - init?(reader: CardReader, selectAID: Bool) async { - self.reader = reader - if (selectAID) { - do { - _ = try await select(file: IdemiaAID.main) - } catch { - return nil - } + func verifyCode(_ type: CodeType, code: SecureData) async throws { + try await verifyCode(pinRef(for: type), codeType: type, code: code) + } + + func unblockCode(_ type: CodeType, puk: SecureData, newCode: SecureData) async throws { + guard type != .puk else { + throw IdCardInternalError.notSupportedCodeType + } + // PUK is associated with the MAIN AID context, so VERIFY PUK must run there. Make this + // self-contained instead of relying on the caller leaving MAIN active — otherwise calls + // composed after readCodeTryCounterRecord(.pin2)/calculateSignature would silently fail + // with a "wrong PUK" error. + _ = try await select(file: IdemiaAID.main) + try await verifyCode(.puk, code: puk) + if type == .pin2 { + _ = try await select(file: IdemiaAID.qscd) } + try await unblockCode(pinRef(for: type), codeType: type, puk: nil, newCode: newCode) } - // MARK: - Public Data + // MARK: - Authentication & Signing - func readPublicData() async throws -> CardInfo { - _ = try await select(file: IdemiaAID.main) - _ = try await select(p1Byte: 0x01, file: [0x50, 0x00]) - var personalData = CardInfo(cardType: cardType) - for recordNr: UInt8 in 1...8 { - let data = try await readFile(p1Byte: 0x02, file: [0x50, recordNr]) - let record = String(data: data, encoding: .utf8) ?? "-" - switch recordNr { - case 1: personalData.surname = record - case 2: personalData.givenName = record - case 4: personalData.citizenship = !record.isEmpty ? record : "-" - case 5: personalData.dateAndPlaceOfBirth = record - case 6: personalData.personalCode = record - case 7: personalData.documentNumber = record - case 8: personalData.dateOfExpiry = record.replacing(" ", with: ".") - default: break - } + func authenticate(for hash: any DataType, withPin1 pin1: SecureData) async throws -> Data { + _ = try await select(file: IdemiaAID.oberthur) + try await verifyCode(.pin1, code: pin1) + try await setSecEnv(mode: 0xA4, algo: authAlgorithm, keyRef: authKeyRef) + return try await reader.sendAPDU(ins: 0x88, data: padWithZeroes(hash), leByte: 0x00) + } + + func calculateSignature(for hash: any DataType, withPin2 pin2: SecureData) async throws -> Data { + _ = try await select(file: IdemiaAID.qscd) + try await verifyCode(.pin2, code: pin2) + try await setSecEnv(mode: 0xB6, algo: signAlgorithm, keyRef: signKeyRef) + return try await reader.sendAPDU( + ins: 0x2A, p1Byte: 0x9E, p2Byte: 0x9A, data: padWithZeroes(hash), leByte: 0x00) + } + + func decryptData(_ hash: any DataType, withPin1 pin1: SecureData) async throws -> Data { + _ = try await select(file: IdemiaAID.oberthur) + try await verifyCode(.pin1, code: pin1) + try await setSecEnv(mode: 0xB8, algo: decryptAlgorithm, keyRef: authKeyRef) + return try await reader.sendAPDU(ins: 0x2A, p1Byte: 0x80, p2Byte: 0x86, data: [0x00] + hash, leByte: 0x00) + } + + // MARK: - Helpers + + private func aid(for type: CodeType) -> Bytes { + switch type { + case .pin1, .puk: return IdemiaAID.main + case .pin2: return IdemiaAID.qscd } - return personalData } - func readAuthenticationCertificate() async throws -> Data { - _ = try await select(file: IdemiaAID.main) - return try await readFile(p1Byte: 0x09, file: [0xAD, 0xF1, 0x34, 0x01]) + private func pinRef(for type: CodeType) -> UInt8 { + switch type { + case .pin1: return 0x01 + case .pin2: return 0x85 + case .puk: return 0x02 + } } - func readSignatureCertificate() async throws -> Data { - _ = try await select(file: IdemiaAID.main) - return try await readFile(p1Byte: 0x09, file: [0xAD, 0xF2, 0x34, 0x1F]) + /** + * @return zero padded hash with 48 byte length or same hash if it's longer than 48 bytes. + */ + private func padWithZeroes(_ hash: any DataType) -> Data { + var padded = Data(repeating: 0x00, count: max(0, 48 - hash.count)) + padded.append(contentsOf: hash) + return padded } } diff --git a/Sources/nfclib/CardActions/IdemiaCard.swift b/Sources/nfclib/CardActions/IdemiaCard.swift deleted file mode 100644 index 8585a44..0000000 --- a/Sources/nfclib/CardActions/IdemiaCard.swift +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-FileCopyrightText: Estonian Information System Authority -// SPDX-License-Identifier: LGPL-2.1-or-later - -import Foundation - -enum IdemiaAID { - static let main = Bytes(hex: "A0 00 00 00 77 01 08 00 07 00 00 FE 00 00 01 00") - static let qscd = Bytes(hex: "51 53 43 44 20 41 70 70 6C 69 63 61 74 69 6F 6E") - static let oberthur = Bytes(hex: "E8 28 BD 08 0F F2 50 4F 54 20 41 57 50") -} - -/** - * Commands shared by the IDEMIA cards. PIN management, authentication, signing and decryption - * are identical across them; only the key references and MSE algorithm references vary, so - * conformers supply those as values. Personal data and certificate layout differ, so those stay - * requirements of ``CardCommands``. - */ -protocol IdemiaCard: CardCommandsInternal { - var authKeyRef: UInt8 { get } - var signKeyRef: UInt8 { get } - var authAlgorithm: Bytes { get } - var signAlgorithm: Bytes { get } - var decryptAlgorithm: Bytes { get } -} - -extension IdemiaCard { - - // MARK: - PIN & PUK Management - - func readCodeTryCounterRecord(_ type: CodeType) async throws -> (retryCount: UInt8, pinActive: Bool) { - _ = try await select(file: aid(for: type)) - let ref = pinRef(for: type) & ~0x80 - let data = try await reader.sendAPDU(ins: 0xCB, p1Byte: 0x3F, p2Byte: 0xFF, data: - [0x4D, 0x08, 0x70, 0x06, 0xBF, 0x81, ref, 0x02, 0xA0, 0x80], leByte: 0x00) - if let info = TLV(from: data), info.tag == 0x70, - let tag = TLV(from: info.value), tag.tag == 0xBF8100 | UInt32(ref), - let a0Value = TLV(from: tag.value), a0Value.tag == 0xA0, - let records = TLV.sequenceOfRecords(from: a0Value.value) { - for record in records where record.tag == 0x9B { - guard let counter = record.value.first else { continue } - return (counter, true) - } - } - throw IdCardInternalError.invalidResponse( - message: "Missing PIN counter record for \(cardType.rawValue)") - } - - func changeCode(_ type: CodeType, to code: SecureData, verifyCode: SecureData) async throws { - _ = try await select(file: aid(for: type)) - try await changeCode(pinRef(for: type), codeType: type, to: code, verifyCode: verifyCode) - } - - func verifyCode(_ type: CodeType, code: SecureData) async throws { - try await verifyCode(pinRef(for: type), codeType: type, code: code) - } - - func unblockCode(_ type: CodeType, puk: SecureData, newCode: SecureData) async throws { - guard type != .puk else { - throw IdCardInternalError.notSupportedCodeType - } - // PUK is associated with the MAIN AID context, so VERIFY PUK must run there. Make this - // self-contained instead of relying on the caller leaving MAIN active — otherwise calls - // composed after readCodeTryCounterRecord(.pin2)/calculateSignature would silently fail - // with a "wrong PUK" error. - _ = try await select(file: IdemiaAID.main) - try await verifyCode(.puk, code: puk) - if type == .pin2 { - _ = try await select(file: IdemiaAID.qscd) - } - try await unblockCode(pinRef(for: type), codeType: type, puk: nil, newCode: newCode) - } - - // MARK: - Authentication & Signing - - func authenticate(for hash: any DataType, withPin1 pin1: SecureData) async throws -> Data { - _ = try await select(file: IdemiaAID.oberthur) - try await verifyCode(.pin1, code: pin1) - try await setSecEnv(mode: 0xA4, algo: authAlgorithm, keyRef: authKeyRef) - return try await reader.sendAPDU(ins: 0x88, data: padWithZeroes(hash), leByte: 0x00) - } - - func calculateSignature(for hash: any DataType, withPin2 pin2: SecureData) async throws -> Data { - _ = try await select(file: IdemiaAID.qscd) - try await verifyCode(.pin2, code: pin2) - try await setSecEnv(mode: 0xB6, algo: signAlgorithm, keyRef: signKeyRef) - return try await reader.sendAPDU( - ins: 0x2A, p1Byte: 0x9E, p2Byte: 0x9A, data: padWithZeroes(hash), leByte: 0x00) - } - - func decryptData(_ hash: any DataType, withPin1 pin1: SecureData) async throws -> Data { - _ = try await select(file: IdemiaAID.oberthur) - try await verifyCode(.pin1, code: pin1) - try await setSecEnv(mode: 0xB8, algo: decryptAlgorithm, keyRef: authKeyRef) - return try await reader.sendAPDU(ins: 0x2A, p1Byte: 0x80, p2Byte: 0x86, data: [0x00] + hash, leByte: 0x00) - } - - // MARK: - Helpers - - private func aid(for type: CodeType) -> Bytes { - switch type { - case .pin1, .puk: return IdemiaAID.main - case .pin2: return IdemiaAID.qscd - } - } - - private func pinRef(for type: CodeType) -> UInt8 { - switch type { - case .pin1: return 0x01 - case .pin2: return 0x85 - case .puk: return 0x02 - } - } - - /** - * @return zero padded hash with 48 byte length or same hash if it's longer than 48 bytes. - * Zeros go in front: the card reads the input as a big-endian integer, which leading zero - * bytes leave unchanged, while padding on the right would change the value. - */ - private func padWithZeroes(_ hash: any DataType) -> Data { - var padded = Data(repeating: 0x00, count: max(0, 48 - hash.count)) - padded.append(contentsOf: hash) - return padded - } -} diff --git a/Sources/nfclib/CardActions/IdemiaEstonia.swift b/Sources/nfclib/CardActions/IdemiaEstonia.swift new file mode 100644 index 0000000..5521958 --- /dev/null +++ b/Sources/nfclib/CardActions/IdemiaEstonia.swift @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Foundation + +private let ATR = Bytes(hex: "3B DB 96 00 80 B1 FE 45 1F 83 00 12 23 3F 53 65 49 44 0F 90 00 F1") +private let ATRv2 = Bytes(hex: "3B DC 96 00 80 B1 FE 45 1F 83 00 12 23 3F 54 65 49 44 32 0F 90 00 C3") + +final class IdemiaEstonia: Idemia { + let canChangePUK: Bool = true + let reader: CardReader + let fillChar: UInt8 = 0xFF + let cardType: CardType = .idemiaEstonia + + let authKeyRef: UInt8 = 0x81 + let signKeyRef: UInt8 = 0x9F + let authAlgorithm: Bytes = [0xFF, 0x20, 0x08, 0x00] + let signAlgorithm: Bytes = [0xFF, 0x15, 0x08, 0x00] + let decryptAlgorithm: Bytes = [0xFF, 0x30, 0x04, 0x00] + + init?(reader: CardReader, atr: Bytes) { + guard atr == ATR || atr == ATRv2 else { + return nil + } + self.reader = reader + } + + init?(reader: CardReader, aid: Bytes) { + guard aid == IdemiaAID.main else { + return nil + } + self.reader = reader + } + + init?(reader: CardReader, selectAID: Bool) async { + self.reader = reader + if (selectAID) { + do { + _ = try await select(file: IdemiaAID.main) + } catch { + return nil + } + } + } + + // MARK: - Public Data + + func readPublicData() async throws -> CardInfo { + _ = try await select(file: IdemiaAID.main) + _ = try await select(p1Byte: 0x01, file: [0x50, 0x00]) + var personalData = CardInfo(cardType: cardType) + for recordNr: UInt8 in 1...8 { + let data = try await readFile(p1Byte: 0x02, file: [0x50, recordNr]) + let record = String(data: data, encoding: .utf8) ?? "-" + switch recordNr { + case 1: personalData.surname = record + case 2: personalData.givenName = record + case 4: personalData.citizenship = !record.isEmpty ? record : "-" + case 5: personalData.dateAndPlaceOfBirth = record + case 6: personalData.personalCode = record + case 7: personalData.documentNumber = record + case 8: personalData.dateOfExpiry = record.replacing(" ", with: ".") + default: break + } + } + return personalData + } + + func readAuthenticationCertificate() async throws -> Data { + _ = try await select(file: IdemiaAID.main) + return try await readFile(p1Byte: 0x09, file: [0xAD, 0xF1, 0x34, 0x01]) + } + + func readSignatureCertificate() async throws -> Data { + _ = try await select(file: IdemiaAID.main) + return try await readFile(p1Byte: 0x09, file: [0xAD, 0xF2, 0x34, 0x1F]) + } +} diff --git a/Sources/nfclib/CardActions/LatviaIdemia.swift b/Sources/nfclib/CardActions/IdemiaLatvia.swift similarity index 85% rename from Sources/nfclib/CardActions/LatviaIdemia.swift rename to Sources/nfclib/CardActions/IdemiaLatvia.swift index 1ea46d7..f75f6bd 100644 --- a/Sources/nfclib/CardActions/LatviaIdemia.swift +++ b/Sources/nfclib/CardActions/IdemiaLatvia.swift @@ -7,10 +7,10 @@ import Foundation * Latvian eID card NFC token. * * Built on the same IDEMIA chip as the Estonian card, so PACE, secure messaging, and PIN - * operations come from ``IdemiaCard``. Overrides personal-data and certificate reads, and uses + * operations come from ``Idemia``. Overrides personal-data and certificate reads, and uses * different key references for authentication and signing. */ -final class LatviaIdemia: IdemiaCard { +final class IdemiaLatvia: Idemia { private static let personalCodeDF: Bytes = [0x50, 0x00] private static let personalCodeEF: Bytes = [0x50, 0x01] private static let authenticationCertificatePath: Bytes = [0xAD, 0xF1, 0x34, 0x01] @@ -20,14 +20,15 @@ final class LatviaIdemia: IdemiaCard { let canChangePUK: Bool = true let reader: CardReader let fillChar: UInt8 = 0xFF - let cardType: CardType = .latviaIdemia + let cardType: CardType = .idemiaLatvia let authKeyRef: UInt8 = 0x82 let signKeyRef: UInt8 = 0x9E /** - * LV uses 1-byte algorithm identifiers in the MSE algorithm-reference DO, vs the 4-byte - * `FF xx xx xx` form used by Estonian IDEMIA: + * Algorithm references for the MSE algorithm-reference DO, sent as a single byte here where + * the Estonian profile is sent the 4-byte `FF xx xx xx` form. That is what each profile is + * known to accept, not a documented country rule: * - `0x04` — ECC auth / decrypt * - `0x54` — ECC sign */ @@ -76,11 +77,11 @@ final class LatviaIdemia: IdemiaCard { } func readAuthenticationCertificate() async throws -> Data { - try await readCertificate(path: LatviaIdemia.authenticationCertificatePath) + try await readCertificate(path: IdemiaLatvia.authenticationCertificatePath) } func readSignatureCertificate() async throws -> Data { - try await readCertificate(path: LatviaIdemia.signatureCertificatePath) + try await readCertificate(path: IdemiaLatvia.signatureCertificatePath) } // MARK: - Reads @@ -88,8 +89,8 @@ final class LatviaIdemia: IdemiaCard { private func readPersonalCode() async throws -> String { // Read personal code from EF 0x5001 _ = try await select(file: IdemiaAID.main) - _ = try await select(p1Byte: 0x01, file: LatviaIdemia.personalCodeDF) - _ = try await select(p1Byte: 0x02, file: LatviaIdemia.personalCodeEF) + _ = try await select(p1Byte: 0x01, file: IdemiaLatvia.personalCodeDF) + _ = try await select(p1Byte: 0x02, file: IdemiaLatvia.personalCodeEF) let record = try await reader.sendAPDU(ins: 0xB0, leByte: 0x00) // Strip trailing 0xFF (CardOS unused-space marker on fixed-size EFs) before UTF-8 @@ -105,16 +106,12 @@ final class LatviaIdemia: IdemiaCard { return trimmedCode } - /** - * Read a certificate from the currently selected path. Selecting with P2 = 0x0C returns no - * file size, so the read runs until the card reports the offset is past the end of the file. - */ private func readCertificate(path: Bytes) async throws -> Data { _ = try await select(file: IdemiaAID.main) _ = try await select(p1Byte: 0x09, file: path) var data = Data() - while data.count < LatviaIdemia.maximumFileSize { + while data.count < IdemiaLatvia.maximumFileSize { let chunk: Data do { chunk = try await reader.sendAPDU( diff --git a/Sources/nfclib/CardActions/Thales.swift b/Sources/nfclib/CardActions/Thales.swift index ada4cd3..1d4dd1d 100644 --- a/Sources/nfclib/CardActions/Thales.swift +++ b/Sources/nfclib/CardActions/Thales.swift @@ -23,7 +23,7 @@ final class Thales: CardCommandsInternal { let canChangePUK: Bool = false let reader: CardReader let fillChar: UInt8 = 0x00 - let cardType: CardType = .thales + let cardType: CardType = .thalesEstonia init?(reader: any CardReader, atr: Bytes) throws { guard atr == Thales.ATR else { diff --git a/Sources/nfclib/Operations/NFCConnection.swift b/Sources/nfclib/Operations/NFCConnection.swift index bab825a..4d87cbb 100644 --- a/Sources/nfclib/Operations/NFCConnection.swift +++ b/Sources/nfclib/Operations/NFCConnection.swift @@ -66,8 +66,8 @@ public class NFCConnection { } let reader = try await CardReaderNFC(tag, CAN: CAN) switch detectedCardType { - case .latviaIdemia: return LatviaIdemia(reader: reader) - case .id1, .thales: return try await cardCommandsBySelectedAID(reader: reader, tag: tag) + case .idemiaLatvia: return IdemiaLatvia(reader: reader) + case .idemiaEstonia, .thalesEstonia: return try await cardCommandsBySelectedAID(reader: reader, tag: tag) } } @@ -84,16 +84,16 @@ public class NFCConnection { throw IdCardInternalError.connectionFailed } - // Try Idemia with explicit AID - if let cmd = Idemia(reader: reader, aid: aid) { + // Try IdemiaEstonia with explicit AID + if let cmd = IdemiaEstonia(reader: reader, aid: aid) { return cmd } // Try Thales with explicit AID if let cmd = Thales(reader: reader, aid: aid) { return cmd } - // Try Idemia selecting AID - if let cmd = await Idemia(reader: reader, selectAID: true) { + // Try IdemiaEstonia selecting AID + if let cmd = await IdemiaEstonia(reader: reader, selectAID: true) { return cmd } // Try Thales selecting AID From 798c70d5150a2a49a21ca7e76f59615dabdea1be Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Fri, 6 Nov 2026 19:35:17 +0200 Subject: [PATCH 3/7] Remove redundant AID selection before PUK verification --- Sources/nfclib/CardActions/Idemia.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Sources/nfclib/CardActions/Idemia.swift b/Sources/nfclib/CardActions/Idemia.swift index 7248a1b..cef1ce8 100644 --- a/Sources/nfclib/CardActions/Idemia.swift +++ b/Sources/nfclib/CardActions/Idemia.swift @@ -52,11 +52,6 @@ extension Idemia { guard type != .puk else { throw IdCardInternalError.notSupportedCodeType } - // PUK is associated with the MAIN AID context, so VERIFY PUK must run there. Make this - // self-contained instead of relying on the caller leaving MAIN active — otherwise calls - // composed after readCodeTryCounterRecord(.pin2)/calculateSignature would silently fail - // with a "wrong PUK" error. - _ = try await select(file: IdemiaAID.main) try await verifyCode(.puk, code: puk) if type == .pin2 { _ = try await select(file: IdemiaAID.qscd) From 9a69169a131759c50c31865a17b64c543eeb7ba8 Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Fri, 6 Nov 2026 19:58:55 +0200 Subject: [PATCH 4/7] Code review improvements --- Sources/nfclib/CardActions/CardType.swift | 4 +- Sources/nfclib/CardActions/Idemia.swift | 59 ++++++++++++------- .../nfclib/CardActions/IdemiaEstonia.swift | 22 +------ Sources/nfclib/CardActions/IdemiaLatvia.swift | 43 ++------------ Sources/nfclib/CardActions/Thales.swift | 8 --- 5 files changed, 47 insertions(+), 89 deletions(-) diff --git a/Sources/nfclib/CardActions/CardType.swift b/Sources/nfclib/CardActions/CardType.swift index 8f15ca9..95baeab 100644 --- a/Sources/nfclib/CardActions/CardType.swift +++ b/Sources/nfclib/CardActions/CardType.swift @@ -26,8 +26,8 @@ extension CardType { private static let knownHistoricalBytes: [(bytes: Bytes, cardType: CardType)] = [ (Bytes(hex: "00 12 23 3F 53 65 49 44 0F 90 00"), .idemiaEstonia), (Bytes(hex: "00 12 23 3F 54 65 49 44 32 0F 90 00"), .idemiaEstonia), - // Android lists only D8; this repo's Thales ATR carries B8 in the same position. Both - // are kept until a real tap settles it — matching neither would reject every Thales card. + // Two Thales variants differ only in the third byte. Both are kept until a real tap + // settles which one ships — matching neither would reject every Thales card. (Bytes(hex: "80 31 B8 53 65 49 44 64 B0 85 05 10 12 23 3F"), .thalesEstonia), (Bytes(hex: "80 31 D8 53 65 49 44 64 B0 85 05 10 12 23 3F"), .thalesEstonia), (Bytes(hex: "00 12 42 8F 53 65 49 44 0F 90 00"), .idemiaLatvia), diff --git a/Sources/nfclib/CardActions/Idemia.swift b/Sources/nfclib/CardActions/Idemia.swift index cef1ce8..0c59553 100644 --- a/Sources/nfclib/CardActions/Idemia.swift +++ b/Sources/nfclib/CardActions/Idemia.swift @@ -9,21 +9,50 @@ enum IdemiaAID { static let oberthur = Bytes(hex: "E8 28 BD 08 0F F2 50 4F 54 20 41 57 50") } +extension CodeType { + fileprivate var aid: Bytes { + switch self { + case .pin1: return IdemiaAID.main + case .pin2: return IdemiaAID.qscd + case .puk: return IdemiaAID.main + } + } + fileprivate var pinRef: UInt8 { + switch self { + case .pin1: return 0x01 + case .pin2: return 0x85 + case .puk: return 0x02 + } + } +} + protocol Idemia: CardCommandsInternal { var authKeyRef: UInt8 { get } var signKeyRef: UInt8 { get } var authAlgorithm: Bytes { get } var signAlgorithm: Bytes { get } var decryptAlgorithm: Bytes { get } + var authCertFile: Bytes { get } + var signCertFile: Bytes { get } } extension Idemia { + // MARK: - Certificates + + func readAuthenticationCertificate() async throws -> Data { + try await readCertificate(file: authCertFile) + } + + func readSignatureCertificate() async throws -> Data { + try await readCertificate(file: signCertFile) + } + // MARK: - PIN & PUK Management func readCodeTryCounterRecord(_ type: CodeType) async throws -> (retryCount: UInt8, pinActive: Bool) { - _ = try await select(file: aid(for: type)) - let ref = pinRef(for: type) & ~0x80 + _ = try await select(file: type.aid) + let ref = type.pinRef & ~0x80 let data = try await reader.sendAPDU(ins: 0xCB, p1Byte: 0x3F, p2Byte: 0xFF, data: [0x4D, 0x08, 0x70, 0x06, 0xBF, 0x81, ref, 0x02, 0xA0, 0x80], leByte: 0x00) if let info = TLV(from: data), info.tag == 0x70, @@ -40,12 +69,12 @@ extension Idemia { } func changeCode(_ type: CodeType, to code: SecureData, verifyCode: SecureData) async throws { - _ = try await select(file: aid(for: type)) - try await changeCode(pinRef(for: type), codeType: type, to: code, verifyCode: verifyCode) + _ = try await select(file: type.aid) + try await changeCode(type.pinRef, codeType: type, to: code, verifyCode: verifyCode) } func verifyCode(_ type: CodeType, code: SecureData) async throws { - try await verifyCode(pinRef(for: type), codeType: type, code: code) + try await verifyCode(type.pinRef, codeType: type, code: code) } func unblockCode(_ type: CodeType, puk: SecureData, newCode: SecureData) async throws { @@ -54,9 +83,9 @@ extension Idemia { } try await verifyCode(.puk, code: puk) if type == .pin2 { - _ = try await select(file: IdemiaAID.qscd) + _ = try await select(file: type.aid) } - try await unblockCode(pinRef(for: type), codeType: type, puk: nil, newCode: newCode) + try await unblockCode(type.pinRef, codeType: type, puk: nil, newCode: newCode) } // MARK: - Authentication & Signing @@ -85,19 +114,9 @@ extension Idemia { // MARK: - Helpers - private func aid(for type: CodeType) -> Bytes { - switch type { - case .pin1, .puk: return IdemiaAID.main - case .pin2: return IdemiaAID.qscd - } - } - - private func pinRef(for type: CodeType) -> UInt8 { - switch type { - case .pin1: return 0x01 - case .pin2: return 0x85 - case .puk: return 0x02 - } + private func readCertificate(file: Bytes) async throws -> Data { + _ = try await select(file: IdemiaAID.main) + return try await readFile(p1Byte: 0x09, file: file) } /** diff --git a/Sources/nfclib/CardActions/IdemiaEstonia.swift b/Sources/nfclib/CardActions/IdemiaEstonia.swift index 5521958..18f465c 100644 --- a/Sources/nfclib/CardActions/IdemiaEstonia.swift +++ b/Sources/nfclib/CardActions/IdemiaEstonia.swift @@ -3,9 +3,6 @@ import Foundation -private let ATR = Bytes(hex: "3B DB 96 00 80 B1 FE 45 1F 83 00 12 23 3F 53 65 49 44 0F 90 00 F1") -private let ATRv2 = Bytes(hex: "3B DC 96 00 80 B1 FE 45 1F 83 00 12 23 3F 54 65 49 44 32 0F 90 00 C3") - final class IdemiaEstonia: Idemia { let canChangePUK: Bool = true let reader: CardReader @@ -17,13 +14,8 @@ final class IdemiaEstonia: Idemia { let authAlgorithm: Bytes = [0xFF, 0x20, 0x08, 0x00] let signAlgorithm: Bytes = [0xFF, 0x15, 0x08, 0x00] let decryptAlgorithm: Bytes = [0xFF, 0x30, 0x04, 0x00] - - init?(reader: CardReader, atr: Bytes) { - guard atr == ATR || atr == ATRv2 else { - return nil - } - self.reader = reader - } + let authCertFile: Bytes = [0xAD, 0xF1, 0x34, 0x01] + let signCertFile: Bytes = [0xAD, 0xF2, 0x34, 0x1F] init?(reader: CardReader, aid: Bytes) { guard aid == IdemiaAID.main else { @@ -65,14 +57,4 @@ final class IdemiaEstonia: Idemia { } return personalData } - - func readAuthenticationCertificate() async throws -> Data { - _ = try await select(file: IdemiaAID.main) - return try await readFile(p1Byte: 0x09, file: [0xAD, 0xF1, 0x34, 0x01]) - } - - func readSignatureCertificate() async throws -> Data { - _ = try await select(file: IdemiaAID.main) - return try await readFile(p1Byte: 0x09, file: [0xAD, 0xF2, 0x34, 0x1F]) - } } diff --git a/Sources/nfclib/CardActions/IdemiaLatvia.swift b/Sources/nfclib/CardActions/IdemiaLatvia.swift index f75f6bd..c3d6777 100644 --- a/Sources/nfclib/CardActions/IdemiaLatvia.swift +++ b/Sources/nfclib/CardActions/IdemiaLatvia.swift @@ -7,15 +7,12 @@ import Foundation * Latvian eID card NFC token. * * Built on the same IDEMIA chip as the Estonian card, so PACE, secure messaging, and PIN - * operations come from ``Idemia``. Overrides personal-data and certificate reads, and uses - * different key references for authentication and signing. + * operations come from ``Idemia``. Overrides the personal-data read and uses different key + * references for authentication and signing. */ final class IdemiaLatvia: Idemia { private static let personalCodeDF: Bytes = [0x50, 0x00] private static let personalCodeEF: Bytes = [0x50, 0x01] - private static let authenticationCertificatePath: Bytes = [0xAD, 0xF1, 0x34, 0x01] - private static let signatureCertificatePath: Bytes = [0xAD, 0xF2, 0x34, 0x1F] - private static let maximumFileSize = 0x8000 let canChangePUK: Bool = true let reader: CardReader @@ -35,6 +32,8 @@ final class IdemiaLatvia: Idemia { let authAlgorithm: Bytes = [0x04] let signAlgorithm: Bytes = [0x54] let decryptAlgorithm: Bytes = [0x04] + let authCertFile: Bytes = [0xAD, 0xF1, 0x34, 0x01] + let signCertFile: Bytes = [0xAD, 0xF2, 0x34, 0x1F] init(reader: CardReader) { self.reader = reader @@ -76,14 +75,6 @@ final class IdemiaLatvia: Idemia { ) } - func readAuthenticationCertificate() async throws -> Data { - try await readCertificate(path: IdemiaLatvia.authenticationCertificatePath) - } - - func readSignatureCertificate() async throws -> Data { - try await readCertificate(path: IdemiaLatvia.signatureCertificatePath) - } - // MARK: - Reads private func readPersonalCode() async throws -> String { @@ -105,30 +96,4 @@ final class IdemiaLatvia: Idemia { } return trimmedCode } - - private func readCertificate(path: Bytes) async throws -> Data { - _ = try await select(file: IdemiaAID.main) - _ = try await select(p1Byte: 0x09, file: path) - - var data = Data() - while data.count < IdemiaLatvia.maximumFileSize { - let chunk: Data - do { - chunk = try await reader.sendAPDU( - ins: 0xB0, - p1Byte: UInt8(data.count >> 8), - p2Byte: UInt8(truncatingIfNeeded: data.count), - leByte: 0x00 - ) - } catch IdCardInternalError.swError(0x6B00) where !data.isEmpty { - return data - } - guard !chunk.isEmpty else { - throw IdCardInternalError.invalidResponse( - message: "Card returned no certificate data at offset \(data.count)") - } - data.append(chunk) - } - throw IdCardInternalError.invalidResponse(message: "Certificate file exceeds addressable size") - } } diff --git a/Sources/nfclib/CardActions/Thales.swift b/Sources/nfclib/CardActions/Thales.swift index 1d4dd1d..1871b3d 100644 --- a/Sources/nfclib/CardActions/Thales.swift +++ b/Sources/nfclib/CardActions/Thales.swift @@ -14,7 +14,6 @@ extension CodeType { } final class Thales: CardCommandsInternal { - static private let ATR = Bytes(hex: "3B FF 96 00 00 80 31 FE 43 80 31 B8 53 65 49 44 64 B0 85 05 10 12 23 3F 1D") static private let kAID = Bytes(hex: "A0 00 00 00 63 50 4B 43 53 2D 31 35") static private let kAIDGlobal = Bytes(hex: "A0 00 00 00 18 10 02 03 00 00 00 00 00 00 00 01") static private let AUTHKEY: UInt8 = 0x01 @@ -25,13 +24,6 @@ final class Thales: CardCommandsInternal { let fillChar: UInt8 = 0x00 let cardType: CardType = .thalesEstonia - init?(reader: any CardReader, atr: Bytes) throws { - guard atr == Thales.ATR else { - return nil - } - self.reader = reader - } - required init?(reader: CardReader, aid: Bytes) { guard aid == Thales.kAIDGlobal else { return nil From 199f2620af610444d52880c4c0576387c9f67bd4 Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Mon, 9 Nov 2026 13:19:33 +0200 Subject: [PATCH 5/7] Make certificate file identifiers async functions --- Sources/nfclib/CardActions/Idemia.swift | 8 ++-- .../nfclib/CardActions/IdemiaEstonia.swift | 8 +++- Sources/nfclib/CardActions/IdemiaLatvia.swift | 8 +++- Sources/nfclib/Operations/NFCConnection.swift | 38 +++++++++++++++++-- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/Sources/nfclib/CardActions/Idemia.swift b/Sources/nfclib/CardActions/Idemia.swift index 0c59553..4aec4d2 100644 --- a/Sources/nfclib/CardActions/Idemia.swift +++ b/Sources/nfclib/CardActions/Idemia.swift @@ -32,8 +32,8 @@ protocol Idemia: CardCommandsInternal { var authAlgorithm: Bytes { get } var signAlgorithm: Bytes { get } var decryptAlgorithm: Bytes { get } - var authCertFile: Bytes { get } - var signCertFile: Bytes { get } + func authCertFile() async throws -> Bytes + func signCertFile() async throws -> Bytes } extension Idemia { @@ -41,11 +41,11 @@ extension Idemia { // MARK: - Certificates func readAuthenticationCertificate() async throws -> Data { - try await readCertificate(file: authCertFile) + try await readCertificate(file: authCertFile()) } func readSignatureCertificate() async throws -> Data { - try await readCertificate(file: signCertFile) + try await readCertificate(file: signCertFile()) } // MARK: - PIN & PUK Management diff --git a/Sources/nfclib/CardActions/IdemiaEstonia.swift b/Sources/nfclib/CardActions/IdemiaEstonia.swift index 18f465c..4f6a372 100644 --- a/Sources/nfclib/CardActions/IdemiaEstonia.swift +++ b/Sources/nfclib/CardActions/IdemiaEstonia.swift @@ -14,8 +14,6 @@ final class IdemiaEstonia: Idemia { let authAlgorithm: Bytes = [0xFF, 0x20, 0x08, 0x00] let signAlgorithm: Bytes = [0xFF, 0x15, 0x08, 0x00] let decryptAlgorithm: Bytes = [0xFF, 0x30, 0x04, 0x00] - let authCertFile: Bytes = [0xAD, 0xF1, 0x34, 0x01] - let signCertFile: Bytes = [0xAD, 0xF2, 0x34, 0x1F] init?(reader: CardReader, aid: Bytes) { guard aid == IdemiaAID.main else { @@ -35,6 +33,12 @@ final class IdemiaEstonia: Idemia { } } + // MARK: - Certificates + + func authCertFile() async throws -> Bytes { [0xAD, 0xF1, 0x34, 0x01] } + + func signCertFile() async throws -> Bytes { [0xAD, 0xF2, 0x34, 0x1F] } + // MARK: - Public Data func readPublicData() async throws -> CardInfo { diff --git a/Sources/nfclib/CardActions/IdemiaLatvia.swift b/Sources/nfclib/CardActions/IdemiaLatvia.swift index c3d6777..ec0f5da 100644 --- a/Sources/nfclib/CardActions/IdemiaLatvia.swift +++ b/Sources/nfclib/CardActions/IdemiaLatvia.swift @@ -32,13 +32,17 @@ final class IdemiaLatvia: Idemia { let authAlgorithm: Bytes = [0x04] let signAlgorithm: Bytes = [0x54] let decryptAlgorithm: Bytes = [0x04] - let authCertFile: Bytes = [0xAD, 0xF1, 0x34, 0x01] - let signCertFile: Bytes = [0xAD, 0xF2, 0x34, 0x1F] init(reader: CardReader) { self.reader = reader } + // MARK: - Certificates + + func authCertFile() async throws -> Bytes { [0xAD, 0xF1, 0x34, 0x01] } + + func signCertFile() async throws -> Bytes { [0xAD, 0xF2, 0x34, 0x1F] } + // MARK: - Public Data /** diff --git a/Sources/nfclib/Operations/NFCConnection.swift b/Sources/nfclib/Operations/NFCConnection.swift index 4d87cbb..988b7a0 100644 --- a/Sources/nfclib/Operations/NFCConnection.swift +++ b/Sources/nfclib/Operations/NFCConnection.swift @@ -65,10 +65,7 @@ public class NFCConnection { throw IdCardInternalError.cardNotSupported } let reader = try await CardReaderNFC(tag, CAN: CAN) - switch detectedCardType { - case .idemiaLatvia: return IdemiaLatvia(reader: reader) - case .idemiaEstonia, .thalesEstonia: return try await cardCommandsBySelectedAID(reader: reader, tag: tag) - } + return try await cardCommands(for: detectedCardType, reader: reader, tag: tag) } let reader = try await CardReaderNFC(tag, CAN: CAN) @@ -79,6 +76,39 @@ public class NFCConnection { return commands } + private func cardCommands( + for cardType: CardType, + reader: CardReader, + tag: NFCISO7816Tag + ) async throws -> CardCommands { + switch cardType { + case .idemiaLatvia: + return IdemiaLatvia(reader: reader) + case .idemiaEstonia: + guard let aid = Bytes(hex: tag.initialSelectedAID) else { + throw IdCardInternalError.connectionFailed + } + if let cmd = IdemiaEstonia(reader: reader, aid: aid) { + return cmd + } + if let cmd = await IdemiaEstonia(reader: reader, selectAID: true) { + return cmd + } + case .thalesEstonia: + guard let aid = Bytes(hex: tag.initialSelectedAID) else { + throw IdCardInternalError.connectionFailed + } + if let cmd = Thales(reader: reader, aid: aid) { + return cmd + } + if let cmd = await Thales(reader: reader, selectAID: true) { + return cmd + } + } + + throw IdCardInternalError.cardNotSupported + } + private func cardCommandsBySelectedAID(reader: CardReader, tag: NFCISO7816Tag) async throws -> CardCommands { guard let aid = Bytes(hex: tag.initialSelectedAID) else { throw IdCardInternalError.connectionFailed From e0520e3e9383a3e7fffc332767c2dd97f2b03e8e Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Mon, 9 Nov 2026 15:02:02 +0200 Subject: [PATCH 6/7] Discover Latvian certificate and key references from PKCS#15 --- Sources/nfclib/CardActions/Idemia.swift | 21 +-- .../nfclib/CardActions/IdemiaEstonia.swift | 8 +- Sources/nfclib/CardActions/IdemiaLatvia.swift | 120 ++++++++++++++++-- 3 files changed, 126 insertions(+), 23 deletions(-) diff --git a/Sources/nfclib/CardActions/Idemia.swift b/Sources/nfclib/CardActions/Idemia.swift index 4aec4d2..b9cf699 100644 --- a/Sources/nfclib/CardActions/Idemia.swift +++ b/Sources/nfclib/CardActions/Idemia.swift @@ -27,13 +27,13 @@ extension CodeType { } protocol Idemia: CardCommandsInternal { - var authKeyRef: UInt8 { get } - var signKeyRef: UInt8 { get } + var authKeyRef: UInt8 { get async throws } + var signKeyRef: UInt8 { get async throws } var authAlgorithm: Bytes { get } var signAlgorithm: Bytes { get } var decryptAlgorithm: Bytes { get } - func authCertFile() async throws -> Bytes - func signCertFile() async throws -> Bytes + var authCertFile: Bytes { get async throws } + var signCertFile: Bytes { get async throws } } extension Idemia { @@ -41,11 +41,11 @@ extension Idemia { // MARK: - Certificates func readAuthenticationCertificate() async throws -> Data { - try await readCertificate(file: authCertFile()) + try await readCertificate(file: authCertFile) } func readSignatureCertificate() async throws -> Data { - try await readCertificate(file: signCertFile()) + try await readCertificate(file: signCertFile) } // MARK: - PIN & PUK Management @@ -92,23 +92,26 @@ extension Idemia { func authenticate(for hash: any DataType, withPin1 pin1: SecureData) async throws -> Data { _ = try await select(file: IdemiaAID.oberthur) + let keyRef = try await authKeyRef try await verifyCode(.pin1, code: pin1) - try await setSecEnv(mode: 0xA4, algo: authAlgorithm, keyRef: authKeyRef) + try await setSecEnv(mode: 0xA4, algo: authAlgorithm, keyRef: keyRef) return try await reader.sendAPDU(ins: 0x88, data: padWithZeroes(hash), leByte: 0x00) } func calculateSignature(for hash: any DataType, withPin2 pin2: SecureData) async throws -> Data { _ = try await select(file: IdemiaAID.qscd) + let keyRef = try await signKeyRef try await verifyCode(.pin2, code: pin2) - try await setSecEnv(mode: 0xB6, algo: signAlgorithm, keyRef: signKeyRef) + try await setSecEnv(mode: 0xB6, algo: signAlgorithm, keyRef: keyRef) return try await reader.sendAPDU( ins: 0x2A, p1Byte: 0x9E, p2Byte: 0x9A, data: padWithZeroes(hash), leByte: 0x00) } func decryptData(_ hash: any DataType, withPin1 pin1: SecureData) async throws -> Data { _ = try await select(file: IdemiaAID.oberthur) + let keyRef = try await authKeyRef try await verifyCode(.pin1, code: pin1) - try await setSecEnv(mode: 0xB8, algo: decryptAlgorithm, keyRef: authKeyRef) + try await setSecEnv(mode: 0xB8, algo: decryptAlgorithm, keyRef: keyRef) return try await reader.sendAPDU(ins: 0x2A, p1Byte: 0x80, p2Byte: 0x86, data: [0x00] + hash, leByte: 0x00) } diff --git a/Sources/nfclib/CardActions/IdemiaEstonia.swift b/Sources/nfclib/CardActions/IdemiaEstonia.swift index 4f6a372..18f465c 100644 --- a/Sources/nfclib/CardActions/IdemiaEstonia.swift +++ b/Sources/nfclib/CardActions/IdemiaEstonia.swift @@ -14,6 +14,8 @@ final class IdemiaEstonia: Idemia { let authAlgorithm: Bytes = [0xFF, 0x20, 0x08, 0x00] let signAlgorithm: Bytes = [0xFF, 0x15, 0x08, 0x00] let decryptAlgorithm: Bytes = [0xFF, 0x30, 0x04, 0x00] + let authCertFile: Bytes = [0xAD, 0xF1, 0x34, 0x01] + let signCertFile: Bytes = [0xAD, 0xF2, 0x34, 0x1F] init?(reader: CardReader, aid: Bytes) { guard aid == IdemiaAID.main else { @@ -33,12 +35,6 @@ final class IdemiaEstonia: Idemia { } } - // MARK: - Certificates - - func authCertFile() async throws -> Bytes { [0xAD, 0xF1, 0x34, 0x01] } - - func signCertFile() async throws -> Bytes { [0xAD, 0xF2, 0x34, 0x1F] } - // MARK: - Public Data func readPublicData() async throws -> CardInfo { diff --git a/Sources/nfclib/CardActions/IdemiaLatvia.swift b/Sources/nfclib/CardActions/IdemiaLatvia.swift index ec0f5da..cc811be 100644 --- a/Sources/nfclib/CardActions/IdemiaLatvia.swift +++ b/Sources/nfclib/CardActions/IdemiaLatvia.swift @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later import Foundation +import Synchronization /** * Latvian eID card NFC token. @@ -13,14 +14,51 @@ import Foundation final class IdemiaLatvia: Idemia { private static let personalCodeDF: Bytes = [0x50, 0x00] private static let personalCodeEF: Bytes = [0x50, 0x01] + private static let objectDirectoryEF: Bytes = [0x50, 0x31] + private static let certificateDirectoryTag: UInt32 = 0xA4 + private static let privateKeyDirectoryTag: UInt32 = 0xA0 + private static let privateECKeyTag: UInt32 = 0xA0 + + private enum CertificateUsage: Hashable { + case authentication, signing + + var aid: Bytes { + switch self { + case .authentication: IdemiaAID.oberthur + case .signing: IdemiaAID.qscd + } + } + + var df: Bytes { + switch self { + case .authentication: [0xAD, 0xF1] + case .signing: [0xAD, 0xF2] + } + } + + var defaultKeyReference: UInt8 { + switch self { + case .authentication: 0x81 + case .signing: 0x9F + } + } + } + + private struct KeyDescriptor { + let certificateFile: Bytes + let keyReference: UInt8 + } + + private let descriptors = Mutex<[CertificateUsage: KeyDescriptor]>([:]) let canChangePUK: Bool = true let reader: CardReader let fillChar: UInt8 = 0xFF let cardType: CardType = .idemiaLatvia - let authKeyRef: UInt8 = 0x82 - let signKeyRef: UInt8 = 0x9E + var authKeyRef: UInt8 { get async throws { try await descriptor(for: .authentication).keyReference } } + + var signKeyRef: UInt8 { get async throws { try await descriptor(for: .signing).keyReference } } /** * Algorithm references for the MSE algorithm-reference DO, sent as a single byte here where @@ -32,17 +70,14 @@ final class IdemiaLatvia: Idemia { let authAlgorithm: Bytes = [0x04] let signAlgorithm: Bytes = [0x54] let decryptAlgorithm: Bytes = [0x04] + var authCertFile: Bytes { get async throws { try await descriptor(for: .authentication).certificateFile } } + + var signCertFile: Bytes { get async throws { try await descriptor(for: .signing).certificateFile } } init(reader: CardReader) { self.reader = reader } - // MARK: - Certificates - - func authCertFile() async throws -> Bytes { [0xAD, 0xF1, 0x34, 0x01] } - - func signCertFile() async throws -> Bytes { [0xAD, 0xF2, 0x34, 0x1F] } - // MARK: - Public Data /** @@ -100,4 +135,73 @@ final class IdemiaLatvia: Idemia { } return trimmedCode } + + // MARK: - PKCS#15 descriptors + + private func descriptor(for usage: CertificateUsage) async throws -> KeyDescriptor { + if let cached = descriptors.withLock({ $0[usage] }) { + return cached + } + + _ = try await select(file: IdemiaAID.main) + _ = try await select(file: usage.aid) + let objects = try await readFile(p1Byte: 0x02, file: IdemiaLatvia.objectDirectoryEF) + + let certificateDirectory = try await readDirectory( + objects, tag: IdemiaLatvia.certificateDirectoryTag, file: "EF.CD") + let certificateFile = try descriptorValue( + certificateDirectory, path: [0x30, 0xA1, 0x30, 0x30, 0x04], file: "EF.CD") + guard certificateFile.count == 2 else { + throw IdCardInternalError.invalidResponse( + message: "EF.CD: certificate reference is \(certificateFile.count) bytes, expected 2") + } + + let privateKeyDirectory = try await readDirectory( + objects, tag: IdemiaLatvia.privateKeyDirectoryTag, file: "EF.PrKD") + guard let key = TLV.sequenceOfRecords(from: privateKeyDirectory)?.first else { + throw IdCardInternalError.invalidResponse(message: "EF.PrKD: no private key record") + } + guard key.tag == IdemiaLatvia.privateECKeyTag else { + throw IdCardInternalError.cardNotSupported + } + + let descriptor = KeyDescriptor( + certificateFile: usage.df + Bytes(certificateFile), + keyReference: keyReference(in: key.value) ?? usage.defaultKeyReference + ) + descriptors.withLock { $0[usage] = descriptor } + return descriptor + } + + private func readDirectory(_ objectDirectory: Data, tag: UInt32, file: String) async throws -> Data { + let reference = try descriptorValue(objectDirectory, path: [tag, 0x30, 0x04], file: "EF.OD") + guard reference.count == 2 else { + throw IdCardInternalError.invalidResponse( + message: "EF.OD: \(file) reference is \(reference.count) bytes, expected 2") + } + return try await readFile(p1Byte: 0x02, file: Bytes(reference)) + } + + private func keyReference(in privateKey: Data) -> UInt8? { + guard let attributes = TLV.sequenceOfRecords(from: privateKey)?.dropFirst().first, + let identifier = TLV.sequenceOfRecords(from: attributes.value)? + .first(where: { $0.tag == 0x02 }), + identifier.value.count == 2 else { + return nil + } + return identifier.value.last + } + + private func descriptorValue(_ data: Data, path: [UInt32], file: String) throws -> Data { + var value = data + for tag in path { + guard let records = TLV.sequenceOfRecords(from: value), + let record = records.first(where: { $0.tag == tag }) else { + throw IdCardInternalError.invalidResponse( + message: "\(file): missing PKCS#15 tag 0x\(String(tag, radix: 16, uppercase: true))") + } + value = record.value + } + return value + } } From 7a96a57b381c3ce226e2608c265df58f2dec29c3 Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Mon, 9 Nov 2026 16:21:36 +0200 Subject: [PATCH 7/7] Code review fixes --- Sources/nfclib/CardActions/Idemia.swift | 1 + .../nfclib/CardActions/IdemiaEstonia.swift | 2 +- Sources/nfclib/CardActions/IdemiaLatvia.swift | 23 +++++++++++---- Sources/nfclib/Operations/NFCConnection.swift | 29 +++++++++---------- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/Sources/nfclib/CardActions/Idemia.swift b/Sources/nfclib/CardActions/Idemia.swift index b9cf699..5dbaf25 100644 --- a/Sources/nfclib/CardActions/Idemia.swift +++ b/Sources/nfclib/CardActions/Idemia.swift @@ -37,6 +37,7 @@ protocol Idemia: CardCommandsInternal { } extension Idemia { + var personalDataDF: Bytes { [0x50, 0x00] } // MARK: - Certificates diff --git a/Sources/nfclib/CardActions/IdemiaEstonia.swift b/Sources/nfclib/CardActions/IdemiaEstonia.swift index 18f465c..44a402e 100644 --- a/Sources/nfclib/CardActions/IdemiaEstonia.swift +++ b/Sources/nfclib/CardActions/IdemiaEstonia.swift @@ -39,7 +39,7 @@ final class IdemiaEstonia: Idemia { func readPublicData() async throws -> CardInfo { _ = try await select(file: IdemiaAID.main) - _ = try await select(p1Byte: 0x01, file: [0x50, 0x00]) + _ = try await select(p1Byte: 0x01, file: personalDataDF) var personalData = CardInfo(cardType: cardType) for recordNr: UInt8 in 1...8 { let data = try await readFile(p1Byte: 0x02, file: [0x50, recordNr]) diff --git a/Sources/nfclib/CardActions/IdemiaLatvia.swift b/Sources/nfclib/CardActions/IdemiaLatvia.swift index cc811be..5e025a2 100644 --- a/Sources/nfclib/CardActions/IdemiaLatvia.swift +++ b/Sources/nfclib/CardActions/IdemiaLatvia.swift @@ -12,7 +12,6 @@ import Synchronization * references for authentication and signing. */ final class IdemiaLatvia: Idemia { - private static let personalCodeDF: Bytes = [0x50, 0x00] private static let personalCodeEF: Bytes = [0x50, 0x01] private static let objectDirectoryEF: Bytes = [0x50, 0x31] private static let certificateDirectoryTag: UInt32 = 0xA4 @@ -38,8 +37,8 @@ final class IdemiaLatvia: Idemia { var defaultKeyReference: UInt8 { switch self { - case .authentication: 0x81 - case .signing: 0x9F + case .authentication: 0x82 + case .signing: 0x9E } } } @@ -74,10 +73,24 @@ final class IdemiaLatvia: Idemia { var signCertFile: Bytes { get async throws { try await descriptor(for: .signing).certificateFile } } - init(reader: CardReader) { + init?(reader: CardReader, aid: Bytes) { + guard aid == IdemiaAID.main else { + return nil + } self.reader = reader } + init?(reader: CardReader, selectAID: Bool) async { + self.reader = reader + if (selectAID) { + do { + _ = try await select(file: IdemiaAID.main) + } catch { + return nil + } + } + } + // MARK: - Public Data /** @@ -119,7 +132,7 @@ final class IdemiaLatvia: Idemia { private func readPersonalCode() async throws -> String { // Read personal code from EF 0x5001 _ = try await select(file: IdemiaAID.main) - _ = try await select(p1Byte: 0x01, file: IdemiaLatvia.personalCodeDF) + _ = try await select(p1Byte: 0x01, file: personalDataDF) _ = try await select(p1Byte: 0x02, file: IdemiaLatvia.personalCodeEF) let record = try await reader.sendAPDU(ins: 0xB0, leByte: 0x00) diff --git a/Sources/nfclib/Operations/NFCConnection.swift b/Sources/nfclib/Operations/NFCConnection.swift index 988b7a0..5796c7d 100644 --- a/Sources/nfclib/Operations/NFCConnection.swift +++ b/Sources/nfclib/Operations/NFCConnection.swift @@ -59,17 +59,21 @@ public class NFCConnection { CAN: String, allowedCardTypes: Set = CardType.estonianCards ) async throws -> CardCommands { + guard let aid = Bytes(hex: tag.initialSelectedAID) else { + throw IdCardInternalError.connectionFailed + } + if let historicalBytes = tag.historicalBytes { guard let detectedCardType = CardType(historicalBytes: historicalBytes), allowedCardTypes.contains(detectedCardType) else { throw IdCardInternalError.cardNotSupported } let reader = try await CardReaderNFC(tag, CAN: CAN) - return try await cardCommands(for: detectedCardType, reader: reader, tag: tag) + return try await cardCommands(for: detectedCardType, reader: reader, aid: aid) } let reader = try await CardReaderNFC(tag, CAN: CAN) - let commands = try await cardCommandsBySelectedAID(reader: reader, tag: tag) + let commands = try await cardCommandsBySelectedAID(reader: reader, aid: aid) guard allowedCardTypes.contains(commands.cardType) else { throw IdCardInternalError.cardNotSupported } @@ -79,15 +83,17 @@ public class NFCConnection { private func cardCommands( for cardType: CardType, reader: CardReader, - tag: NFCISO7816Tag + aid: Bytes ) async throws -> CardCommands { switch cardType { case .idemiaLatvia: - return IdemiaLatvia(reader: reader) - case .idemiaEstonia: - guard let aid = Bytes(hex: tag.initialSelectedAID) else { - throw IdCardInternalError.connectionFailed + if let cmd = IdemiaLatvia(reader: reader, aid: aid) { + return cmd + } + if let cmd = await IdemiaLatvia(reader: reader, selectAID: true) { + return cmd } + case .idemiaEstonia: if let cmd = IdemiaEstonia(reader: reader, aid: aid) { return cmd } @@ -95,9 +101,6 @@ public class NFCConnection { return cmd } case .thalesEstonia: - guard let aid = Bytes(hex: tag.initialSelectedAID) else { - throw IdCardInternalError.connectionFailed - } if let cmd = Thales(reader: reader, aid: aid) { return cmd } @@ -109,11 +112,7 @@ public class NFCConnection { throw IdCardInternalError.cardNotSupported } - private func cardCommandsBySelectedAID(reader: CardReader, tag: NFCISO7816Tag) async throws -> CardCommands { - guard let aid = Bytes(hex: tag.initialSelectedAID) else { - throw IdCardInternalError.connectionFailed - } - + private func cardCommandsBySelectedAID(reader: CardReader, aid: Bytes) async throws -> CardCommands { // Try IdemiaEstonia with explicit AID if let cmd = IdemiaEstonia(reader: reader, aid: aid) { return cmd