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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<CardType>) 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<CardType>) 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<CardType>) 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<CardType>) 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<CardType>) 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<CardType>) 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<CardType>) 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.
Expand Down
9 changes: 9 additions & 0 deletions Sources/nfclib/CardActions/CardCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
7 changes: 6 additions & 1 deletion Sources/nfclib/CardActions/CardCommandsInternal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
77 changes: 46 additions & 31 deletions Sources/nfclib/CardActions/CardInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand All @@ -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
Expand All @@ -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 {
Expand Down
45 changes: 45 additions & 0 deletions Sources/nfclib/CardActions/CardType.swift
Original file line number Diff line number Diff line change
@@ -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<CardType> = [.idemiaEstonia, .thalesEstonia]

static let allCards: Set<CardType> = 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
}
}
7 changes: 5 additions & 2 deletions Sources/nfclib/CardActions/IDCardError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -59,7 +61,6 @@ public enum IdCardInternalError: Error {
.notSupportedCodeType,
.authenticationFailed,
.invalidTag,
.cardNotSupported,
.nfcNotSupported,
.connectionFailed,
.multipleTagsDetected,
Expand All @@ -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):
Expand Down
Loading
Loading