taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit 763554efd0542006e5a5c999eff88385995da421
parent 0776b681276e4040aa1c586e9b23c5529cc857ac
Author: Marc Stibane <marc@taler.net>
Date:   Fri, 30 May 2025 20:38:14 +0200

HCE

Diffstat:
MGNU_Taler.entitlements | 6++++++
ATalerWallet1/Helper/Data+fromUInt8Array.swift | 25+++++++++++++++++++++++++
ATalerWallet1/Helper/TagEmulation.swift | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATalerWallet1/Helper/apdu.swift | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTaler_Nightly.entitlements | 6++++++
5 files changed, 489 insertions(+), 0 deletions(-)

diff --git a/GNU_Taler.entitlements b/GNU_Taler.entitlements @@ -4,6 +4,12 @@ <dict> <key>com.apple.developer.default-data-protection</key> <string>NSFileProtectionComplete</string> + <key>com.apple.developer.nfc.hce</key> + <true/> + <key>com.apple.developer.nfc.hce.iso7816.select-identifier-prefixes</key> + <array> + <string>D2760000850101</string> + </array> <key>com.apple.developer.nfc.readersession.formats</key> <array> <string>TAG</string> diff --git a/TalerWallet1/Helper/Data+fromUInt8Array.swift b/TalerWallet1/Helper/Data+fromUInt8Array.swift @@ -0,0 +1,25 @@ +/* + * This file is part of GNU Taler, ©2022-24 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ +import Foundation + +extension Data { + init(fromUInt8Array inValues: [UInt8]) { + let values = inValues + self.init(bytes: values, count: values.count) + } + + struct HexEncodingOptions: OptionSet { + let rawValue: Int + static let upperCase = HexEncodingOptions(rawValue: 1 << 0) + } + + func hexEncodedString(options: HexEncodingOptions = []) -> String { + let format = options.contains(.upperCase) ? "%02hhX" : "%02hhx" + return self.map { String(format: format, $0) }.joined() + } +} diff --git a/TalerWallet1/Helper/TagEmulation.swift b/TalerWallet1/Helper/TagEmulation.swift @@ -0,0 +1,154 @@ +// +// TagEmulation.swift +// HCEtest +// +// Created by Marc Stibane on 2024-09-01. +// + +import CoreNFC +import os.log + +@available(iOS 18.3, *) +@MainActor +class TagEmulation: ObservableObject { + + @Published var canUseHCE: Bool = false + + nonisolated let logger = Logger(subsystem: "net.taler.gnu", category: "TagEmulation") + private var cardSession: CardSession? = nil + private var presentmentIntent: NFCPresentmentIntentAssertion? = nil + + init() { + if NFCReaderSession.readingAvailable { + if CardSession.isSupported { + Task { + if await CardSession.isEligible { + logger.info("CardSession is eligible") + canUseHCE = true + } else { logger.error("❗️CardSession is not eligible"); canUseHCE = false } + } + } else { logger.error("❗️CardSession is not supported"); canUseHCE = false } + } else { logger.error("❗️NFCReaderSession is not available"); canUseHCE = false } + } + + func emulateTag(_ emulatedData: String) { + Task() { + let result = await startEmulation(emulatedData) + if result { + logger.info("Emulation was successful") + } else { + logger.warning("❗️Emulation was not successful") + } + } + } + + private func startEmulation(_ emulatedData: String) async -> Bool { + // Hold a presentment intent assertion reference to prevent the + // default contactless app from launching. In a real app, monitor + // presentmentIntent.isValid to ensure the assertion remains active. + var result = false + + do { + if presentmentIntent == nil { + presentmentIntent = try await NFCPresentmentIntentAssertion.acquire() + logger.info("NFCPresentmentIntentAssertion acquired") + } else { + logger.info("NFCPresentmentIntentAssertion exists") + } + if cardSession == nil { + cardSession = try await CardSession() + logger.info("CardSession launched") + } else { + logger.info("❗️Yikes! CardSession exists") + } + if let cardSession { + logger.info("cardSession.startEmulation (without waiting for reader)") + try await cardSession.startEmulation() + + logger.info("starting eventStream") + try await eventStream(cardSession, data: emulatedData) + logger.info("eventStream finished") + result = true + } + } catch let error { + // Handle failure to acquire NFC presentment intent assertion or card session. + logger.error("❗️NFCPresentmentIntentAssertion not possible: \(error.localizedDescription)") + presentmentIntent = nil /// Release presentment intent assertion. + return false + } + return result + } + + func killEmulation() { + Task() { + if let session = cardSession { + if await session.isEmulationInProgress { + logger.info("cardSession.invalidate") + } + await session.invalidate() + cardSession = nil + } + logger.info("NFCPresentmentIntentAssertion released") + presentmentIntent = nil /// Release presentment intent assertion + } + } + + private func eventStream(_ mySession: CardSession, data emulatedData: String) async throws { + let apdu = APDU(emulatedData) + // Iterate over events as the card session produces them. + for try await event in mySession.eventStream { +// if presentmentIntent?.isValid ?? false { + switch event { + case .sessionStarted: + let message = "sessionStarted" + mySession.alertMessage = message + logger.info("\(message)") + break + + case .readerDetected: + /// Start card emulation on first detection of an external reader. + logger.info("readerDetected") +// logger.info("cardSession.startEmulation") +// try await mySession.startEmulation() + + case .readerDeselected: + /// Stop emulation on first notification of RF link loss. + logger.info("❗️readerDeselected. cardSession.stopEmulation") + await mySession.stopEmulation(status: .success) + return + + case .received(let cardAPDU): + do { + /// Call handler to process received input and produce a response. + let responseAPDU = apdu.processAPDU(cardAPDU.payload) + + logger.info("sending back data: \(responseAPDU.hexEncodedString())") + try await cardAPDU.respond(response: responseAPDU) + } catch { + /// Handle the error from respond(response:). If the error is + /// CardSession.Error.transmissionError, then retry by calling + /// CardSession.APDU.respond(response:) again. + logger.error("❗️Error while responding: \(error)") + } + + case .sessionInvalidated(reason: _): + mySession.alertMessage = "Ending communication with card reader." + logger.info("❗️cardSession invalidation") + /// Handle the reason for session invalidation. + await mySession.stopEmulation(status: .failure) + return + + default: + let message = "Unknown event from card reader." + mySession.alertMessage = message + logger.info("❗️\(message)") + } +// } else { +// logger.error("❗️presentmentIntent is not valid") +// await mySession.stopEmulation(status: .failure) +// presentmentIntent = nil /// Release presentment intent assertion. +// // TODO: stop eventStream? +// } + } // cardSession.eventStream + } +} diff --git a/TalerWallet1/Helper/apdu.swift b/TalerWallet1/Helper/apdu.swift @@ -0,0 +1,298 @@ +// +// apdu.swift +// HCEtest +// +// Created by Marc Stibane on 2024-07-27. +// + +import Foundation +import os.log + +@MainActor +class APDU { + let talerUri: String + nonisolated let logger = Logger(subsystem: "net.taler.gnu", category: "APDU") + private var readCapabilityContainerCheck = false + + init(_ talerUri: String) { + self.talerUri = talerUri + } + + private var APDU_SELECT: Data { + Data(fromUInt8Array: [ + 0x00, // CLA - Class - Class of instruction + 0xA4, // INS - Instruction - Instruction code + 0x04, // P1 - Parameter 1 - Instruction parameter 1 + 0x00, // P2 - Parameter 2 - Instruction parameter 2 + 0x07, // Lc field - Number of bytes present in the data field of the command + 0xD2, // NDEF Tag Application name + 0x76, + 0x00, + 0x00, + 0x85, + 0x01, + 0x01, + 0x00, // Le field - Maximum number of bytes expected in the data field of the response to the command + ]) + } // 00a4 0400 07 d2760000850101 00 + + private var CAPABILITY_CONTAINER_OK: Data { + Data(fromUInt8Array: [ + 0x00, // CLA - Class - Class of instruction + 0xA4, // INS - Instruction - Instruction code + 0x00, // P1 - Parameter 1 - Instruction parameter 1 + 0x0C, // P2 - Parameter 2 - Instruction parameter 2 + 0x02, // Lc field - Number of bytes present in the data field of the command + 0xE1, // file identifier of the CC file + 0x03, + ]) + } // 00a4 000c 02 e103 + + private var READ_CAPABILITY_CONTAINER: Data { + Data(fromUInt8Array: [ + 0x00, // CLA - Class - Class of instruction + 0xB0, // INS - Instruction - Instruction code + 0x00, // P1 - Parameter 1 - Instruction parameter 1 + 0x00, // P2 - Parameter 2 - Instruction parameter 2 + 0x0F, // Lc field - Number of bytes present in the data field of the command + ]) + } // 00b0 0000 0f + +// private var READ_CAPABILITY_CONTAINER_RESPONSE: Data { +// Data(fromUInt8Array: [ +// 0x00, 0x11, // CCLEN length of the CC file +// 0x20, // Mapping Version 2.0 +// 0xFF, 0xFF, // MLe maximum +// 0xFF, 0xFF, // MLc maximum +// 0x04, // T field of the NDEF File Control TLV +// 0x06, // L field of the NDEF File Control TLV +// 0xE1, 0x04, // File Identifier of NDEF file +// 0xFF, 0xFE, // Maximum NDEF file size of 65534 bytes +// 0x00, // Read access without any security +// 0xFF, // Write access without any security +// 0x90, 0x00, // A_OKAY +// ]) +// } // 0011 20ffffffff 0406 e104 ffff 00ff 9000 + + private var READ_CAPABILITY_CONTAINER_RESPONSE: Data { + Data(fromUInt8Array: [ + 0x00, 0x0f, // CCLEN length of the CC file + 0x20, // Mapping Version 2.0 + 0x00, 0x3B, // MLe maximum + 0x00, 0x34, // MLc maximum + 0x04, // T field of the NDEF File Control TLV + 0x06, // L field of the NDEF File Control TLV + 0xE1, 0x04, // File Identifier of NDEF file + 0x00, 0xFF, // Maximum NDEF file size of 65534 bytes + 0x00, // Read access without any security + 0xFF, // Write access without any security + 0x90, 0x00, // A_OKAY + ]) + } //000f 20 003b 0034 04 06 e104 00ff 00ff9000 + + + private var NDEF_SELECT_OK: Data { + Data(fromUInt8Array: [ + 0x00, // CLA - Class - Class of instruction + 0xA4, // Instruction byte (INS) for Select command + 0x00, // Parameter byte (P1), select by identifier + 0x0C, // Parameter byte (P2), select by identifier + 0x02, // Lc field - Number of bytes present in the data field of the command + 0xE1, + 0x04, // file identifier of the NDEF file retrieved from the CC file + ]) + } // 00a4 000c 02 e104 + + private var NDEF_READ_BINARY_NLEN: Data { + Data(fromUInt8Array: [ + 0x00, // Class byte (CLA) + 0xB0, // Instruction byte (INS) for ReadBinary command + 0x00, + 0x00, // Parameter byte (P1, P2), offset inside the CC file + 0x02, // Le field + ]) + } // 00b0 0000 02 + + private var NDEF_READ_BINARY: Data { + Data(fromUInt8Array: [ + 0x00, // Class byte (CLA) + 0xB0, // Instruction byte (INS) for ReadBinary command + ]) + } // 00b0 +// private let NDEF_READ_BINARY_DATA = Data(fromUInt8Array: NDEF_READ_BINARY) + + private var NDEF_RECORD_HEADER_SHORT: Data { + Data(fromUInt8Array: [ + 0xD1, // 1101 + 0001 = MessageBegin, MessageEnd, ShortRecord + Format "Well-Known" (but no ID Length) + 0x01, // TypeLength + // 0x00, // Payload Length 1 byte only since ShortRecord is true + // no ID Length byte since the IDlength flag is false + // 0x55, // Payload Type = “U” for URI, 1 byte as specified in the TypeLength field + // since we have no ID length, there is no Payload ID + ]) + } // d101 + + private var NDEF_RECORD_HEADER_LONG: Data { + Data(fromUInt8Array: [ + 0xD1, // 1100 + 0001 = MessageBegin, MessageEnd + Format "Well-Known" (but no ID Length and no ShortRecord) + 0x01, // TypeLength + // 0x0000, 0x0000, // Payload Length 4 bytes since ShortRecord is false + // no ID Length byte since the IDlength flag is false + // 0x55, // Payload Type = “U” for URI, 1 byte as specified in the TypeLength field + // since we have no ID length, there is no Payload ID + ]) + } // c101 + + private var A_OKAY_ARRAY: [UInt8] { + [ + 0x90, // SW1 Status byte 1 - Command processing status + 0x00, // SW2 Status byte 2 - Command processing qualifier + ] + } // 9000 = OK + private var A_OKAY: Data { + Data(fromUInt8Array: A_OKAY_ARRAY) + } + + private var A_ERROR: Data { + Data(fromUInt8Array: [ + 0x6A, // SW1 Status byte 1 - Command processing status + 0x82, // SW2 Status byte 2 - Command processing qualifier + ]) + } // 6A82 = File not found + + private var APPLICATION_ERROR: Data { + Data(fromUInt8Array: [ + 0x6A, // SW1 Status byte 1 - Command processing status + 0x88, // SW2 Status byte 2 - Command processing qualifier + ]) + } // 6A88 = no application ID + + private var GET_VERSION: Data { + Data(fromUInt8Array: [ + 0x90, // CLA - Class - Class of instruction + 0x60, // INS - Instruction - Instruction code + 0x00, // P1 - Parameter 1 - Instruction parameter 1 + 0x00, // P2 - Parameter 2 - Instruction parameter 2 + 0x00, // Lc field - Number of bytes present in the data field of the command + ]) + } // 9060 0000 00 + + private var GET_VERSION_RESPONSE: Data { + Data(fromUInt8Array: [ + 0x00, // fixed header + 0x04, // vendor ID (04 = NXP Semiconductors) + 0x04, // product type (04 = NTAG) + 0x02, // product subtype (02 = NTAG215) + 0x01, // major product version + 0x00, // minor product version + 0x11, // storage size + 0x03, // protocol type: ISO/IEC 14443-3 compliant + 0x90, 0x00, // A_OKAY + ]) + } // + + private var MORE_INFO: Data { + Data(fromUInt8Array: [ + 0x90, // CLA - Class - Class of instruction + 0xAF, // INS - Instruction - Instruction code + 0x00, // P1 - Parameter 1 - Instruction parameter 1 + 0x00, // P2 - Parameter 2 - Instruction parameter 2 + 0x00, // Lc field - Number of bytes present in the data field of the command + ]) + } // 90af 0000 00 + + /// process the received data and return a response as Data. + func processAPDU(_ commandApdu: Data) -> Data { + /// The following flow is based on Appendix E "Example of Mapping Version 2.0 Command Flow" + /// in the NFC Forum specification + logger.info("incoming commandApdu: \(commandApdu.hexEncodedString())") + + /// First command: NDEF Tag Application select + if APDU_SELECT == commandApdu { + logger.info("APDU_SELECT triggered. Our Response: A_OKAY") + return A_OKAY + } // 00a4040007d276000085010100 + + /// Second command: Capability Container select + if CAPABILITY_CONTAINER_OK == commandApdu { + logger.info("CAPABILITY_CONTAINER_OK triggered. Our Response: A_OKAY") + return A_OKAY + } // 00a4000c02e103 + + /// Third command: ReadBinary data from CC file + if READ_CAPABILITY_CONTAINER == commandApdu && !readCapabilityContainerCheck { + logger.info("READ_CAPABILITY_CONTAINER triggered. Our Response: READ_CAPABILITY_CONTAINER_RESPONSE") + readCapabilityContainerCheck = true + return READ_CAPABILITY_CONTAINER_RESPONSE + } // 00b000000f + + /// Fourth command: NDEF Select command + if NDEF_SELECT_OK == commandApdu { + logger.info("NDEF_SELECT_OK triggered. Our Response: A_OKAY") + return A_OKAY + } // 00a4000c02e104 + + let uriCount = talerUri.count + let ndefLen = UInt16(uriCount + 5) // NDEF_RECORD_HEADER is 4 bytes + /// Fifth Command: Read the Length of the NDEF File + if NDEF_READ_BINARY_NLEN == commandApdu { + logger.info("NDEF_READ_BINARY_NLEN triggered. Our Response: length + A_OKAY") + readCapabilityContainerCheck = false + + let ndefLenHi = UInt8(ndefLen >> 8) + let ndefLenLo = UInt8(ndefLen & 0xff) + let ndefLenLoData = withUnsafeBytes(of: ndefLenLo) { Data($0) } + var lenData = withUnsafeBytes(of: ndefLenHi) { Data($0) } + lenData.append(ndefLenLoData) + lenData.append(A_OKAY_ARRAY, count: 2) + return lenData + } // 00b0000002 + + /// Sixth Command: Read the NDEF File + if NDEF_READ_BINARY == commandApdu[0..<2] { + let count = commandApdu.count + if count > 4 { + var arrayApdu = Array<UInt8>(repeating: 0, count: count) + _ = arrayApdu.withUnsafeMutableBytes { commandApdu.copyBytes(to: $0) } +// if arrayApdu[..<2] == NDEF_READ_BINARY[..<2] { +// if count == 5 { + let length = arrayApdu[4] + let offset = UInt16(arrayApdu[3]) + (UInt16(arrayApdu[2]) << 8) + logger.info("NDEF_READ_BINARY triggered. Our Response: data + A_OKAY") +// if offset == 2 && length == ndefLen { +// var data = NDEF_RECORD_HEADER_SHORT + var data = NDEF_RECORD_HEADER_LONG + let uriCount8 = UInt8((uriCount + 1) & 0xff) + let lenData = withUnsafeBytes(of: uriCount8) { Data($0) } + data.append(lenData) + data.append("U".data(using: .utf8)!) + let zero: UInt8 = 0 + let zeroData = withUnsafeBytes(of: zero) { Data($0) } + data.append(zeroData) + data.append(talerUri.data(using: .utf8)!) + data.append(A_OKAY) + + readCapabilityContainerCheck = false + return data +// } + } + } + + // Reject GET_VERSION and MORE_INFO + if GET_VERSION == commandApdu { + logger.info("❗️GET_VERSION triggered. Our Response: GET_VERSION_RESPONSE❗️") + return GET_VERSION_RESPONSE + } + if MORE_INFO == commandApdu { + logger.info("❗️MORE_INFO triggered. Our Response: A_OKAY❗️") + return A_OKAY + } + + // + // We're doing something outside our scope + // + logger.error("❗️processAPDU() | not yet implemented!") + return A_ERROR + } // processAPDU() + +} diff --git a/Taler_Nightly.entitlements b/Taler_Nightly.entitlements @@ -4,6 +4,12 @@ <dict> <key>com.apple.developer.default-data-protection</key> <string>NSFileProtectionComplete</string> + <key>com.apple.developer.nfc.hce</key> + <true/> + <key>com.apple.developer.nfc.hce.iso7816.select-identifier-prefixes</key> + <array> + <string>D2760000850101</string> + </array> <key>com.apple.developer.nfc.readersession.formats</key> <array> <string>TAG</string>