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..dcd7efc 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 = .thalesEstonia ) { 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 .idemiaLatvia: return PersonalCodeParser.latvianDateOfBirth(personalCode) + case .idemiaEstonia, .thalesEstonia: 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..95baeab --- /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 idemiaEstonia + case thalesEstonia + case idemiaLatvia +} + +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 = [.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"), .idemiaEstonia), + (Bytes(hex: "00 12 23 3F 54 65 49 44 32 0F 90 00"), .idemiaEstonia), + // 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), + (Bytes(hex: "00 12 42 8F 54 65 49 44 32 0F 90 00"), .idemiaLatvia) + ] + + 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..5dbaf25 100644 --- a/Sources/nfclib/CardActions/Idemia.swift +++ b/Sources/nfclib/CardActions/Idemia.swift @@ -3,20 +3,18 @@ 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 +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") +} extension CodeType { fileprivate var aid: Bytes { switch self { - case .pin1: return kAID - case .pin2: return kAIDQSCD - case .puk: return kAID + case .pin1: return IdemiaAID.main + case .pin2: return IdemiaAID.qscd + case .puk: return IdemiaAID.main } } fileprivate var pinRef: UInt8 { @@ -28,67 +26,27 @@ extension CodeType { } } -final class Idemia: CardCommandsInternal { - let canChangePUK: Bool = true - let reader: CardReader - let fillChar: UInt8 = 0xFF - - required init?(reader: CardReader, atr: Bytes) { - guard atr == ATR || atr == ATRv2 else { - return nil - } - self.reader = reader - } +protocol Idemia: CardCommandsInternal { + var authKeyRef: UInt8 { get async throws } + var signKeyRef: UInt8 { get async throws } + var authAlgorithm: Bytes { get } + var signAlgorithm: Bytes { get } + var decryptAlgorithm: Bytes { get } + var authCertFile: Bytes { get async throws } + var signCertFile: Bytes { get async throws } +} - required init?(reader: CardReader, aid: Bytes) { - guard aid == kAID else { - return nil - } - self.reader = reader - } +extension Idemia { + var personalDataDF: Bytes { [0x50, 0x00] } - required init?(reader: CardReader, selectAID: Bool) async { - self.reader = reader - if (selectAID) { - do { - _ = try await select(file: kAID) - } catch { - return nil - } - } - } - - // MARK: - Public Data - - func readPublicData() async throws -> CardInfo { - _ = try await select(file: kAID) - _ = try await select(p1Byte: 0x01, file: [0x50, 0x00]) - var personalData = CardInfo() - 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 - } + // MARK: - Certificates func readAuthenticationCertificate() async throws -> Data { - _ = try await select(file: kAID) - return try await readFile(p1Byte: 0x09, file: [0xAD, 0xF1, 0x34, 0x01]) + try await readCertificate(file: authCertFile) } func readSignatureCertificate() async throws -> Data { - _ = try await select(file: kAID) - return try await readFile(p1Byte: 0x09, file: [0xAD, 0xF2, 0x34, 0x1F]) + try await readCertificate(file: signCertFile) } // MARK: - PIN & PUK Management @@ -107,7 +65,8 @@ final class Idemia: CardCommandsInternal { return (counter, true) } } - throw IdCardInternalError.invalidResponse(message: "Missing PIN counter record") + throw IdCardInternalError.invalidResponse( + message: "Missing PIN counter record for \(cardType.rawValue)") } func changeCode(_ type: CodeType, to code: SecureData, verifyCode: SecureData) async throws { @@ -133,27 +92,43 @@ final class Idemia: CardCommandsInternal { // MARK: - Authentication & Signing func authenticate(for hash: any DataType, withPin1 pin1: SecureData) async throws -> Data { - _ = try await select(file: kAIDOberthur) + _ = try await select(file: IdemiaAID.oberthur) + let keyRef = try await authKeyRef 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) + 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: kAIDQSCD) + _ = try await select(file: IdemiaAID.qscd) + let keyRef = try await signKeyRef 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) + 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: kAIDOberthur) + _ = try await select(file: IdemiaAID.oberthur) + let keyRef = try await authKeyRef try await verifyCode(.pin1, code: pin1) - try await setSecEnv(mode: 0xB8, algo: [0xFF, 0x30, 0x04, 0x00], keyRef: AUTHKEY) + 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) } + + // MARK: - Helpers + + private func readCertificate(file: Bytes) async throws -> Data { + _ = try await select(file: IdemiaAID.main) + return try await readFile(p1Byte: 0x09, file: file) + } + + /** + * @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/IdemiaEstonia.swift b/Sources/nfclib/CardActions/IdemiaEstonia.swift new file mode 100644 index 0000000..44a402e --- /dev/null +++ b/Sources/nfclib/CardActions/IdemiaEstonia.swift @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Foundation + +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] + let authCertFile: Bytes = [0xAD, 0xF1, 0x34, 0x01] + let signCertFile: Bytes = [0xAD, 0xF2, 0x34, 0x1F] + + 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: personalDataDF) + 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 + } +} diff --git a/Sources/nfclib/CardActions/IdemiaLatvia.swift b/Sources/nfclib/CardActions/IdemiaLatvia.swift new file mode 100644 index 0000000..5e025a2 --- /dev/null +++ b/Sources/nfclib/CardActions/IdemiaLatvia.swift @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Foundation +import Synchronization + +/** + * 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 the personal-data read and uses different key + * references for authentication and signing. + */ +final class IdemiaLatvia: Idemia { + 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: 0x82 + case .signing: 0x9E + } + } + } + + 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 + + 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 + * 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 + */ + 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, 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 + + /** + * 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 + ) + } + + // 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: personalDataDF) + _ = 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 + // 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 + } + + // 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 + } +} diff --git a/Sources/nfclib/CardActions/Thales.swift b/Sources/nfclib/CardActions/Thales.swift index 0e01e39..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 @@ -23,13 +22,7 @@ final class Thales: CardCommandsInternal { let canChangePUK: Bool = false let reader: CardReader let fillChar: UInt8 = 0x00 - - init?(reader: any CardReader, atr: Bytes) throws { - guard atr == Thales.ATR else { - return nil - } - self.reader = reader - } + let cardType: CardType = .thalesEstonia required init?(reader: CardReader, aid: Bytes) { guard aid == Thales.kAIDGlobal else { @@ -54,7 +47,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..5796c7d 100644 --- a/Sources/nfclib/Operations/NFCConnection.swift +++ b/Sources/nfclib/Operations/NFCConnection.swift @@ -41,24 +41,88 @@ 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 - let reader = try await CardReaderNFC(tag, CAN: CAN) - guard let aid = Bytes(hex: initialSelectedAID) else { + public func getCardCommands( + _: NFCTagReaderSession, + tag: NFCISO7816Tag, + CAN: String, + allowedCardTypes: Set = CardType.estonianCards + ) async throws -> CardCommands { + guard let aid = Bytes(hex: tag.initialSelectedAID) else { throw IdCardInternalError.connectionFailed } - // Try Idemia with explicit AID - if let cmd = Idemia(reader: reader, aid: aid) { + 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, aid: aid) + } + + let reader = try await CardReaderNFC(tag, CAN: CAN) + let commands = try await cardCommandsBySelectedAID(reader: reader, aid: aid) + guard allowedCardTypes.contains(commands.cardType) else { + throw IdCardInternalError.cardNotSupported + } + return commands + } + + private func cardCommands( + for cardType: CardType, + reader: CardReader, + aid: Bytes + ) async throws -> CardCommands { + switch cardType { + case .idemiaLatvia: + 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 + } + if let cmd = await IdemiaEstonia(reader: reader, selectAID: true) { + return cmd + } + case .thalesEstonia: + 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, aid: Bytes) async throws -> CardCommands { + // 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 @@ -69,4 +133,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