From da1c0549a48aa9cd7946f18258b7a19e46c6eb1b Mon Sep 17 00:00:00 2001 From: Lakr233 Date: Sat, 17 Oct 2026 12:24:55 +0000 Subject: [PATCH 1/3] feat: NTAG 424 DNA plain file operations (#14) Add Cards/NTAG424 with NTAG 424 DNA native commands in CommMode.Plain: - NTAG424Commands.getFileSettings(fileID:) sends GetFileSettings (F5h) and parses the response strictly into NTAG424FileSettings: file type, file option / communication mode, access-right nibbles, 24-bit file size and, when SDM is enabled, SDMOptions, SDMAccessRights and every offset the options and access rights make present. Truncated responses, trailing bytes (such as a response MAC), non-StandardData files and a reserved SDMMetaRead value throw invalidResponse. - NTAG424Commands.writeDataPlain(fileID:offset:data:chunkSize:) sends the native WriteData 8Dh (not the DESFire 3Dh) with 3-byte little-endian offset and length, split into short-APDU chunks. - Facades CoreExtendedNFC.getNTAG424FileSettings(fileID:transport:) and writeNTAG424DataPlain(fileID:offset:data:chunkSize:transport:). SDMAccessRights is decoded as SDMMetaRead bits 15-12, SDMFileRead 11-8, RFU 7-4, SDMCtrRet 3-0, checked against the NXP AN12196 GetFileSettings (Tables 7, 10/11, 18) and plain WriteData (Table 24) vectors in Vectors/NTAG424VectorTests.swift. ChangeFileSettings / SDM provisioning, CommMode.MAC / Full and EV2 command counters are out of scope. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 + README.md | 4 + .../Cards/NTAG424/NTAG424Commands.swift | 142 ++++++ .../Cards/NTAG424/NTAG424FileSettings.swift | 272 +++++++++++ .../CoreExtendedNFC+NTAG424.swift | 33 ++ .../Cards/NTAG424Tests.swift | 433 ++++++++++++++++++ .../Vectors/NTAG424VectorTests.swift | 109 +++++ docs/api/card-commands.html | 8 + docs/api/core.html | 4 + docs/architecture.html | 2 +- docs/index.html | 5 +- docs/research/references.html | 3 +- 12 files changed, 1020 insertions(+), 4 deletions(-) create mode 100644 Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424Commands.swift create mode 100644 Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424FileSettings.swift create mode 100644 Sources/CoreExtendedNFC/CoreExtendedNFC+NTAG424.swift create mode 100644 Tests/CoreExtendedNFCTests/Cards/NTAG424Tests.swift create mode 100644 Tests/CoreExtendedNFCTests/Vectors/NTAG424VectorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a8c96c..3acb3e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to CoreExtendedNFC are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- NTAG 424 DNA module (`Cards/NTAG424/`, issue #14) for plain native file operations over the ISO 7816 wrapped transport: + - `NTAG424Commands.getFileSettings(fileID:)` (`F5h`) and `CoreExtendedNFC.getNTAG424FileSettings(fileID:transport:)` return the new `NTAG424FileSettings`. Its parser is strict: it decodes FileType, FileOption (communication mode, SDM bit 6), the Read / Write / ReadWrite / Change access-right nibbles and the 24-bit file size, and, when SDM is enabled, SDMOptions, SDMAccessRights and the offsets that SDMOptions and SDMAccessRights make present (UID, SDMReadCtr, PICCData, SDMMACInput, SDMENC offset and length, SDMMAC, SDMReadCtrLimit). Truncated responses, trailing bytes (such as a response MAC), a file type other than StandardData and a reserved SDMMetaRead value throw `invalidResponse`. + - `NTAG424Commands.writeDataPlain(fileID:offset:data:chunkSize:)` and `CoreExtendedNFC.writeNTAG424DataPlain(fileID:offset:data:chunkSize:transport:)` send the NTAG 424 native WriteData `8Dh` (not the DESFire `3Dh`) in CommMode.Plain, with 3-byte little-endian offset and length. Data is split into commands of at most `chunkSize` bytes (default 128, at most 248 so each command stays a short APDU). Arguments are checked before anything is sent (`invalidParameter`), and the first non-`9100` status stops the write. + - CommMode.MAC / Full, ChangeFileSettings / SDM provisioning and EV2 command counters are not implemented. + ## [2.0.0] - 2026-10-16 2.0 is a major release because it breaks source and behaviour compatibility with 1.1.2. The Swift tools version (6.2), the iOS 15 deployment target and the async/await API style are unchanged. See the [migration guide](docs/guide/migrating-to-2.html) for before/after code. diff --git a/README.md b/README.md index 513d098..45fe697 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ This library does. | MIFARE Ultralight / EV1 / C | yes | yes | yes | yes | | NTAG 210 / 212 / 213 / 215 / 216 | yes | yes | yes | yes | | MIFARE DESFire EV1–EV3 | yes | yes | no | yes | +| NTAG 424 DNA | yes¹ | yes | plain | no | | FeliCa (Type 3 NDEF) | yes | yes | yes | partial | | ISO 15693 (ICODE, ST25) | yes | yes | yes | partial | | Type 4 NDEF (ISO 7816) | yes | yes | yes | partial | @@ -60,6 +61,8 @@ This library does. | My Number card (Japan) | yes | yes | no | token | | MIFARE Classic 1K/4K | yes | no | no | no | +> ¹ NTAG 424 DNA is identified as DESFire / Type 4; `NTAG424Commands` reads its file settings (including SDM) and writes CommMode.Plain files with WriteData `8Dh`. +> > DESFire is read-only: there is no write API, and the dump reads only free-access files, keyed by AID + file ID (Transaction MAC files are listed but not read). FeliCa dumps cover the Type 3 NDEF area plus up to 4 blocks per plain service; Type 4 dumps cover the CC and NDEF files. ISO 15693 blocks above 255 use the extended read command; tags that reject it are dumped up to block 255 with a `Blocks Read` fact. Ultralight / NTAG dumps stop at the first read-protected page (AUTH0) and keep what was readable. MIFARE Classic is ID-only because iOS does not support Crypto1 authentication. Real hardware coverage is tracked in [Tested Cards](docs/research/tested-cards.html). @@ -221,6 +224,7 @@ Sources/CoreExtendedNFC/ ├── Cards/ │ ├── MiFareUltralight/ READ, WRITE, FAST_READ, PWD_AUTH, Ultralight C auth, memory maps │ ├── NTAG/ READ_SIG, READ_CNT, variant detection +│ ├── NTAG424/ NTAG 424 DNA file settings (SDM), plain WriteData │ ├── DESFire/ Native commands, AF chaining, app/file ops, auth, dump │ ├── FeliCa/ Service probing, Type 3 NDEF, frame assembly │ ├── ISO15693/ System info, block R/W, lock status, security diff --git a/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424Commands.swift b/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424Commands.swift new file mode 100644 index 0000000..83b752e --- /dev/null +++ b/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424Commands.swift @@ -0,0 +1,142 @@ +import Foundation + +/// NTAG 424 DNA native file commands in CommMode.Plain (NXP NT4H2421Gx datasheet). +/// +/// Commands use the ISO/IEC 7816-4 wrapping shared with DESFire, +/// `[0x90, CMD, 0x00, 0x00, LEN, DATA..., 0x00]`, and the NDEF application +/// (`D2760000850101`) must already be selected, as it is when iOS connects with +/// that AID first in `com.apple.developer.nfc.readersession.iso7816.select-identifiers` +/// or after a Type 4 NDEF read. +/// +/// Only plain, unauthenticated framing is implemented: no CommMode.MAC / Full, +/// no EV2 command counter and no response MAC verification. Inside an +/// authenticated session the card appends a MAC to MAC-protected responses, which +/// ``NTAG424FileSettings`` rejects as trailing bytes. +/// +/// Subclasses must preserve thread safety: any added mutable state must be +/// lock-protected or immutable. +open class NTAG424Commands: @unchecked Sendable { + public let transport: any NFCTagTransport + + public init(transport: any NFCTagTransport) { + self.transport = transport + } + + // MARK: - Command Codes + + /// GetFileSettings. + public static let GET_FILE_SETTINGS: UInt8 = 0xF5 + /// WriteData (NTAG 424 DNA native `8Dh`, not the DESFire `3Dh`). + public static let WRITE_DATA: UInt8 = 0x8D + + // MARK: - Limits + + /// Largest file number: FileNo bits 4-0. + public static let maxFileID: UInt8 = 0x1F + /// Largest 24-bit offset or length value. + public static let maxUInt24: UInt32 = 0x00FF_FFFF + /// WriteData header: FileNo (1) ‖ Offset (3) ‖ Length (3). + public static let writeDataHeaderLength = 7 + /// Largest data chunk per WriteData that keeps the command a short APDU + /// (Lc ≤ 255 including the 7-byte header). + public static let maxWriteChunkSize = 255 - writeDataHeaderLength + /// Default data bytes per WriteData command. + public static let defaultWriteChunkSize = 128 + + // MARK: - Commands + + /// GetFileSettings (`F5h`) for `fileID`, parsed strictly. + open func getFileSettings(fileID: UInt8) async throws -> NTAG424FileSettings { + try Self.validateFileID(fileID) + let data = try await DESFireCommands(transport: transport) + .sendCommand(Self.GET_FILE_SETTINGS, data: Data([fileID])) + return try NTAG424FileSettings(data: data) + } + + /// WriteData (`8Dh`) in CommMode.Plain. + /// + /// `data` is split into WriteData commands of at most `chunkSize` bytes, each + /// with its own 3-byte little-endian offset and length, sent in order. Every + /// command must be answered `91 00`; a failure stops the write and earlier + /// chunks stay written. Arguments are checked before anything is sent: + /// `fileID` must be `0x00...0x1F`, `data` must not be empty, `offset + data.count` + /// must not exceed 2^24, and `chunkSize` must be `1...maxWriteChunkSize`. + open func writeDataPlain( + fileID: UInt8, + offset: UInt32, + data: Data, + chunkSize: Int = NTAG424Commands.defaultWriteChunkSize + ) async throws { + let frames = try Self.writeDataPlainPayloads( + fileID: fileID, + offset: offset, + data: data, + chunkSize: chunkSize + ) + let desfire = DESFireCommands(transport: transport) + for payload in frames { + try Task.checkCancellation() + let response = try await transport.sendAPDU( + CommandAPDU.desfireWrap(command: Self.WRITE_DATA, data: payload) + ) + try desfire.validateDESFireStatus(response, expected: .operationOK) + } + } + + // MARK: - Payload Construction + + /// The WriteData command data for each chunk: + /// `FileNo ‖ Offset (3, LE) ‖ Length (3, LE) ‖ Data`. + open class func writeDataPlainPayloads( + fileID: UInt8, + offset: UInt32, + data: Data, + chunkSize: Int = NTAG424Commands.defaultWriteChunkSize + ) throws -> [Data] { + try validateFileID(fileID) + guard !data.isEmpty else { + throw NFCError.invalidParameter("NTAG 424 WriteData needs at least one byte") + } + guard (1 ... maxWriteChunkSize).contains(chunkSize) else { + throw NFCError.invalidParameter( + "NTAG 424 WriteData chunk size must be 1...\(maxWriteChunkSize)" + ) + } + guard offset <= maxUInt24 else { + throw NFCError.invalidParameter("NTAG 424 offset must fit in 3 bytes (0x000000...0xFFFFFF)") + } + guard UInt64(offset) + UInt64(data.count) <= UInt64(maxUInt24) + 1 else { + throw NFCError.invalidParameter("NTAG 424 offset + length must not exceed 0x1000000") + } + + let bytes = [UInt8](data) + var payloads: [Data] = [] + var position = 0 + while position < bytes.count { + let end = min(position + chunkSize, bytes.count) + var payload = Data([fileID]) + payload.append(uint24LE(offset + UInt32(position))) + payload.append(uint24LE(UInt32(end - position))) + payload.append(contentsOf: bytes[position ..< end]) + payloads.append(payload) + position = end + } + return payloads + } + + /// Encode the low 24 bits of `value` little-endian. + public static func uint24LE(_ value: UInt32) -> Data { + Data([ + UInt8(truncatingIfNeeded: value), + UInt8(truncatingIfNeeded: value >> 8), + UInt8(truncatingIfNeeded: value >> 16), + ]) + } + + /// Throw `invalidParameter` for a file number above ``maxFileID``. + public static func validateFileID(_ fileID: UInt8) throws { + guard fileID <= maxFileID else { + throw NFCError.invalidParameter("NTAG 424 file number must be 0x00...0x1F") + } + } +} diff --git a/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424FileSettings.swift b/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424FileSettings.swift new file mode 100644 index 0000000..dfda554 --- /dev/null +++ b/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424FileSettings.swift @@ -0,0 +1,272 @@ +import Foundation + +/// File settings of an NTAG 424 DNA file, parsed from a plain GetFileSettings (`F5h`) +/// response (NXP NT4H2421Gx datasheet, GetFileSettings). +/// +/// Layout: +/// +/// ``` +/// FileType (1) ‖ FileOption (1) ‖ AccessRights (2) ‖ FileSize (3, LE) +/// [ SDMOptions (1) ‖ SDMAccessRights (2) +/// ‖ UIDOffset (3) if SDMOptions.uid and SDMMetaRead == Eh +/// ‖ SDMReadCtrOffset (3) if SDMOptions.readCounter and SDMMetaRead == Eh +/// ‖ PICCDataOffset (3) if SDMMetaRead is a key (0h...4h) +/// ‖ SDMMACInputOffset (3) if SDMFileRead != Fh +/// ‖ SDMENCOffset (3) if SDMOptions.encryptedFileData and SDMFileRead != Fh +/// ‖ SDMENCLength (3) if SDMOptions.encryptedFileData and SDMFileRead != Fh +/// ‖ SDMMACOffset (3) if SDMFileRead != Fh +/// ‖ SDMReadCtrLimit (3) if SDMOptions.readCounterLimit ] only when FileOption bit 6 is set +/// ``` +/// +/// The parser is strict: a truncated response, trailing bytes (for example the +/// 8-byte response MAC of an authenticated session, which this parser does not +/// verify), a file type other than StandardData, or an SDMMetaRead value that is +/// neither a key number, `Eh` nor `Fh` throws ``NFCError/invalidResponse(_:)``. +public struct NTAG424FileSettings: Sendable, Equatable { + /// The only file type NTAG 424 DNA supports: StandardData. + public static let standardDataFileType: UInt8 = 0x00 + + /// FileOption bit 6: Secure Dynamic Messaging (SDM) and mirroring enabled. + public static let sdmEnabledBit: UInt8 = 0x40 + + /// Communication mode, FileOption bits 1-0. + public enum CommunicationMode: Sendable, Equatable { + /// `00b` (and `10b`): plain. + case plain + /// `01b`: MACed. + case mac + /// `11b`: fully enciphered. + case full + + /// Decode FileOption bits 1-0. + public init(fileOption: UInt8) { + switch fileOption & 0x03 { + case 0x01: self = .mac + case 0x03: self = .full + default: self = .plain + } + } + } + + /// The meaning of one 4-bit access condition nibble. + public enum AccessCondition: Sendable, Equatable { + /// `0h...4h`: granted after authenticating with this application key. + case key(UInt8) + /// `Eh`: free access. + case free + /// `Fh`: no access. + case never + /// `5h...Dh`: reserved for future use. + case reserved(UInt8) + + public init(nibble: UInt8) { + let value = nibble & 0x0F + switch value { + case 0x00 ... 0x04: self = .key(value) + case 0x0E: self = .free + case 0x0F: self = .never + default: self = .reserved(value) + } + } + } + + /// File access rights, sent least significant byte first. + /// Bits 15-12 Read, 11-8 Write, 7-4 ReadWrite, 3-0 Change. + public struct AccessRights: Sendable, Equatable { + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } + + /// Read access nibble (bits 15-12). + public var read: UInt8 { UInt8(truncatingIfNeeded: rawValue >> 12) & 0x0F } + /// Write access nibble (bits 11-8). + public var write: UInt8 { UInt8(truncatingIfNeeded: rawValue >> 8) & 0x0F } + /// ReadWrite access nibble (bits 7-4). + public var readWrite: UInt8 { UInt8(truncatingIfNeeded: rawValue >> 4) & 0x0F } + /// Change (ChangeFileSettings) access nibble (bits 3-0). + public var change: UInt8 { UInt8(truncatingIfNeeded: rawValue) & 0x0F } + + public var readCondition: AccessCondition { AccessCondition(nibble: read) } + public var writeCondition: AccessCondition { AccessCondition(nibble: write) } + public var readWriteCondition: AccessCondition { AccessCondition(nibble: readWrite) } + public var changeCondition: AccessCondition { AccessCondition(nibble: change) } + } + + /// SDMOptions bits. + public struct SDMOptions: OptionSet, Sendable, Equatable { + public let rawValue: UInt8 + + public init(rawValue: UInt8) { + self.rawValue = rawValue + } + + /// Bit 7: UID mirroring. + public static let uid = SDMOptions(rawValue: 0x80) + /// Bit 6: SDMReadCtr mirroring. + public static let readCounter = SDMOptions(rawValue: 0x40) + /// Bit 5: SDMReadCtrLimit enabled. + public static let readCounterLimit = SDMOptions(rawValue: 0x20) + /// Bit 4: SDMENCFileData (encrypted file data mirroring). + public static let encryptedFileData = SDMOptions(rawValue: 0x10) + /// Bit 0: ASCII encoding of mirrored data. + public static let asciiEncoding = SDMOptions(rawValue: 0x01) + } + + /// SDM access rights, sent least significant byte first. + /// Bits 15-12 SDMMetaRead, 11-8 SDMFileRead, 7-4 RFU, 3-0 SDMCtrRet + /// (AN12196 decodes the bytes `F1 21` as RFU Fh, SDMCtrRet 1h, SDMMetaRead 2h, + /// SDMFileRead 1h). + public struct SDMAccessRights: Sendable, Equatable { + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } + + /// SDMMetaRead (bits 15-12): `Eh` plain PICC data mirroring, `0h...4h` + /// encrypted PICC data with that key, `Fh` no PICC data mirroring. + public var metaRead: UInt8 { UInt8(truncatingIfNeeded: rawValue >> 12) & 0x0F } + /// SDMFileRead (bits 11-8): `0h...4h` SDM MAC (and encryption) key, `Fh` no SUN MAC. + public var fileRead: UInt8 { UInt8(truncatingIfNeeded: rawValue >> 8) & 0x0F } + /// Reserved nibble (bits 7-4). + public var reserved: UInt8 { UInt8(truncatingIfNeeded: rawValue >> 4) & 0x0F } + /// SDMCtrRet (bits 3-0): access condition for GetFileCounters. + public var counterRetrieval: UInt8 { UInt8(truncatingIfNeeded: rawValue) & 0x0F } + } + + /// The SDM extension present when FileOption bit 6 is set. + /// Offsets and lengths are 24-bit values; absent fields are `nil`. + public struct SDMSettings: Sendable, Equatable { + public let options: SDMOptions + public let accessRights: SDMAccessRights + public let uidOffset: UInt32? + public let readCounterOffset: UInt32? + public let piccDataOffset: UInt32? + public let macInputOffset: UInt32? + public let encryptedDataOffset: UInt32? + public let encryptedDataLength: UInt32? + public let macOffset: UInt32? + public let readCounterLimit: UInt32? + } + + /// FileType byte (always ``standardDataFileType``). + public let fileType: UInt8 + /// Raw FileOption byte. + public let fileOption: UInt8 + /// Communication mode from FileOption bits 1-0. + public let communicationMode: CommunicationMode + public let accessRights: AccessRights + /// File size in bytes (24-bit). + public let fileSize: UInt32 + /// SDM settings, or `nil` when SDM is disabled. + public let sdm: SDMSettings? + + /// `true` when FileOption bit 6 (SDM and mirroring) is set. + public var isSDMEnabled: Bool { + sdm != nil + } + + /// Parse a plain GetFileSettings response payload (status word removed). + public init(data: Data) throws { + var reader = NTAG424ByteReader(bytes: [UInt8](data), source: data) + + let fileType = try reader.uint8() + guard fileType == Self.standardDataFileType else { + throw NFCError.invalidResponse(data) + } + self.fileType = fileType + fileOption = try reader.uint8() + communicationMode = CommunicationMode(fileOption: fileOption) + accessRights = try AccessRights(rawValue: reader.uint16LE()) + fileSize = try reader.uint24LE() + + if fileOption & Self.sdmEnabledBit != 0 { + let options = try SDMOptions(rawValue: reader.uint8()) + let sdmRights = try SDMAccessRights(rawValue: reader.uint16LE()) + + let metaRead = sdmRights.metaRead + let plainMirroring: Bool + let encryptedPICCData: Bool + switch metaRead { + case 0x0E: + plainMirroring = true + encryptedPICCData = false + case 0x00 ... 0x04: + plainMirroring = false + encryptedPICCData = true + case 0x0F: + plainMirroring = false + encryptedPICCData = false + default: + // RFU SDMMetaRead value: the layout of the trailing fields is undefined. + throw NFCError.invalidResponse(data) + } + let fileReadEnabled = sdmRights.fileRead != 0x0F + let encryptedFileData = options.contains(.encryptedFileData) && fileReadEnabled + + let uidOffset = try (options.contains(.uid) && plainMirroring) ? reader.uint24LE() : nil + let readCounterOffset = try (options.contains(.readCounter) && plainMirroring) ? reader.uint24LE() : nil + let piccDataOffset = try encryptedPICCData ? reader.uint24LE() : nil + let macInputOffset = try fileReadEnabled ? reader.uint24LE() : nil + let encOffset = try encryptedFileData ? reader.uint24LE() : nil + let encLength = try encryptedFileData ? reader.uint24LE() : nil + let macOffset = try fileReadEnabled ? reader.uint24LE() : nil + let readCounterLimit = try options.contains(.readCounterLimit) ? reader.uint24LE() : nil + + sdm = SDMSettings( + options: options, + accessRights: sdmRights, + uidOffset: uidOffset, + readCounterOffset: readCounterOffset, + piccDataOffset: piccDataOffset, + macInputOffset: macInputOffset, + encryptedDataOffset: encOffset, + encryptedDataLength: encLength, + macOffset: macOffset, + readCounterLimit: readCounterLimit + ) + } else { + sdm = nil + } + + guard reader.isAtEnd else { + throw NFCError.invalidResponse(data) + } + } +} + +/// Bounds-checked little-endian reader; every overrun throws `invalidResponse`. +private struct NTAG424ByteReader { + let bytes: [UInt8] + let source: Data + var index = 0 + + var isAtEnd: Bool { + index == bytes.count + } + + mutating func take(_ count: Int) throws -> ArraySlice { + guard count <= bytes.count - index else { + throw NFCError.invalidResponse(source) + } + defer { index += count } + return bytes[index ..< index + count] + } + + mutating func uint8() throws -> UInt8 { + let byte = try take(1) + return byte[byte.startIndex] + } + + mutating func uint16LE() throws -> UInt16 { + let b = try Array(take(2)) + return UInt16(b[0]) | UInt16(b[1]) << 8 + } + + mutating func uint24LE() throws -> UInt32 { + let b = try Array(take(3)) + return UInt32(b[0]) | UInt32(b[1]) << 8 | UInt32(b[2]) << 16 + } +} diff --git a/Sources/CoreExtendedNFC/CoreExtendedNFC+NTAG424.swift b/Sources/CoreExtendedNFC/CoreExtendedNFC+NTAG424.swift new file mode 100644 index 0000000..6171458 --- /dev/null +++ b/Sources/CoreExtendedNFC/CoreExtendedNFC+NTAG424.swift @@ -0,0 +1,33 @@ +import Foundation + +// MARK: - NTAG 424 DNA Operations + +public extension CoreExtendedNFC { + /// GetFileSettings (`F5h`): file type, communication mode, access rights, size + /// and, when enabled, the Secure Dynamic Messaging settings of an NTAG 424 DNA file. + /// + /// Plain (unauthenticated) only; the NDEF application must be selected. + static func getNTAG424FileSettings( + fileID: UInt8, + transport: any NFCTagTransport + ) async throws -> NTAG424FileSettings { + try await NTAG424Commands(transport: transport) + .getFileSettings(fileID: fileID) + } + + /// WriteData (`8Dh`) in CommMode.Plain to an NTAG 424 DNA file, split into + /// commands of at most `chunkSize` data bytes. + /// + /// Only works for files whose communication mode is plain and whose write + /// access is free (or granted without secure messaging). + static func writeNTAG424DataPlain( + fileID: UInt8, + offset: UInt32 = 0, + data: Data, + chunkSize: Int = NTAG424Commands.defaultWriteChunkSize, + transport: any NFCTagTransport + ) async throws { + try await NTAG424Commands(transport: transport) + .writeDataPlain(fileID: fileID, offset: offset, data: data, chunkSize: chunkSize) + } +} diff --git a/Tests/CoreExtendedNFCTests/Cards/NTAG424Tests.swift b/Tests/CoreExtendedNFCTests/Cards/NTAG424Tests.swift new file mode 100644 index 0000000..5fa0ede --- /dev/null +++ b/Tests/CoreExtendedNFCTests/Cards/NTAG424Tests.swift @@ -0,0 +1,433 @@ +// NTAG 424 DNA plain file-operation and GetFileSettings parser tests. +// +// Layouts follow the NXP NT4H2421Gx (NTAG 424 DNA) datasheet: GetFileSettings +// (F5h) and WriteData (8Dh) in the ISO/IEC 7816-4 wrapped native form. +// https://www.nxp.com/docs/en/data-sheet/NT4H2421Gx.pdf +// +// The byte strings below are constructed for these tests from that layout; they +// are not NXP-published test vectors. The three SDM-disabled responses use the +// factory file settings the datasheet describes for the CC (file 01h), +// NDEF (file 02h) and proprietary (file 03h) files. +// The NXP AN12196 published vectors are in Vectors/NTAG424VectorTests.swift. +@testable import CoreExtendedNFC +import Foundation +import Testing + +struct NTAG424FileSettingsTests { + // MARK: SDM disabled + + @Test + func `NDEF file factory settings parse as plain free access`() throws { + let settings = try NTAG424FileSettings(data: Data([0x00, 0x00, 0xE0, 0xEE, 0x00, 0x01, 0x00])) + + #expect(settings.fileType == 0x00) + #expect(settings.communicationMode == .plain) + #expect(settings.accessRights.rawValue == 0xEEE0) + #expect(settings.accessRights.read == 0x0E) + #expect(settings.accessRights.write == 0x0E) + #expect(settings.accessRights.readWrite == 0x0E) + #expect(settings.accessRights.change == 0x00) + #expect(settings.accessRights.readCondition == .free) + #expect(settings.accessRights.changeCondition == .key(0)) + #expect(settings.fileSize == 256) + #expect(settings.isSDMEnabled == false) + #expect(settings.sdm == nil) + } + + @Test + func `CC file factory settings parse`() throws { + let settings = try NTAG424FileSettings(data: Data([0x00, 0x00, 0x00, 0xE0, 0x20, 0x00, 0x00])) + + #expect(settings.accessRights.read == 0x0E) + #expect(settings.accessRights.write == 0x00) + #expect(settings.accessRights.readWrite == 0x00) + #expect(settings.accessRights.change == 0x00) + #expect(settings.fileSize == 32) + } + + @Test + func `Proprietary file factory settings parse as full mode`() throws { + let settings = try NTAG424FileSettings(data: Data([0x00, 0x03, 0x30, 0x23, 0x80, 0x00, 0x00])) + + #expect(settings.communicationMode == .full) + #expect(settings.accessRights.readCondition == .key(2)) + #expect(settings.accessRights.writeCondition == .key(3)) + #expect(settings.accessRights.readWriteCondition == .key(3)) + #expect(settings.accessRights.changeCondition == .key(0)) + #expect(settings.fileSize == 128) + } + + @Test + func `File size decodes 24-bit little-endian`() throws { + let settings = try NTAG424FileSettings(data: Data([0x00, 0x01, 0xEE, 0xEE, 0x56, 0x34, 0x12])) + + #expect(settings.communicationMode == .mac) + #expect(settings.fileSize == 0x123456) + } + + @Test + func `Communication mode decodes FileOption bits 1-0`() { + #expect(NTAG424FileSettings.CommunicationMode(fileOption: 0x00) == .plain) + #expect(NTAG424FileSettings.CommunicationMode(fileOption: 0x01) == .mac) + #expect(NTAG424FileSettings.CommunicationMode(fileOption: 0x02) == .plain) + #expect(NTAG424FileSettings.CommunicationMode(fileOption: 0x43) == .full) + } + + @Test + func `Access condition nibbles decode`() { + #expect(NTAG424FileSettings.AccessCondition(nibble: 0x04) == .key(4)) + #expect(NTAG424FileSettings.AccessCondition(nibble: 0x05) == .reserved(5)) + #expect(NTAG424FileSettings.AccessCondition(nibble: 0x0E) == .free) + #expect(NTAG424FileSettings.AccessCondition(nibble: 0x0F) == .never) + } + + // MARK: SDM enabled + + @Test + func `Plain UID and counter mirroring with SUN MAC`() throws { + // SDMOptions C1h (UID, ReadCtr, ASCII); SDMAccessRights E1FEh: + // MetaRead Eh (plain), FileRead 1h, RFU Fh, CtrRet Eh. + let data = Data([ + 0x00, 0x40, 0xE0, 0xEE, 0x00, 0x01, 0x00, + 0xC1, 0xFE, 0xE1, + 0x20, 0x00, 0x00, // UIDOffset + 0x33, 0x00, 0x00, // SDMReadCtrOffset + 0x20, 0x00, 0x00, // SDMMACInputOffset + 0x3A, 0x00, 0x00, // SDMMACOffset + ]) + let settings = try NTAG424FileSettings(data: data) + let sdm = try #require(settings.sdm) + + #expect(settings.isSDMEnabled) + #expect(settings.communicationMode == .plain) + #expect(sdm.options == [.uid, .readCounter, .asciiEncoding]) + #expect(sdm.accessRights.rawValue == 0xE1FE) + #expect(sdm.accessRights.reserved == 0x0F) + #expect(sdm.accessRights.metaRead == 0x0E) + #expect(sdm.accessRights.fileRead == 0x01) + #expect(sdm.accessRights.counterRetrieval == 0x0E) + #expect(sdm.uidOffset == 0x20) + #expect(sdm.readCounterOffset == 0x33) + #expect(sdm.piccDataOffset == nil) + #expect(sdm.macInputOffset == 0x20) + #expect(sdm.encryptedDataOffset == nil) + #expect(sdm.encryptedDataLength == nil) + #expect(sdm.macOffset == 0x3A) + #expect(sdm.readCounterLimit == nil) + } + + @Test + func `Encrypted PICC data, encrypted file data and counter limit`() throws { + // SDMOptions F1h (UID, ReadCtr, ReadCtrLimit, ENCFileData, ASCII); + // SDMAccessRights 21FFh: MetaRead 2h (encrypted), FileRead 1h, RFU Fh, CtrRet Fh. + let data = Data([ + 0x00, 0x40, 0xE0, 0xEE, 0x00, 0x01, 0x00, + 0xF1, 0xFF, 0x21, + 0x20, 0x00, 0x00, // PICCDataOffset + 0x43, 0x00, 0x00, // SDMMACInputOffset + 0x43, 0x00, 0x00, // SDMENCOffset + 0x20, 0x00, 0x00, // SDMENCLength + 0x68, 0x00, 0x00, // SDMMACOffset + 0x10, 0x27, 0x00, // SDMReadCtrLimit + ]) + let sdm = try #require(try NTAG424FileSettings(data: data).sdm) + + #expect(sdm.accessRights.metaRead == 0x02) + #expect(sdm.uidOffset == nil) + #expect(sdm.readCounterOffset == nil) + #expect(sdm.piccDataOffset == 0x20) + #expect(sdm.macInputOffset == 0x43) + #expect(sdm.encryptedDataOffset == 0x43) + #expect(sdm.encryptedDataLength == 0x20) + #expect(sdm.macOffset == 0x68) + #expect(sdm.readCounterLimit == 10000) + } + + @Test + func `SDM without mirroring or MAC carries no offsets`() throws { + // SDMAccessRights FFFFh: MetaRead Fh, FileRead Fh. The ENCFileData bit + // has no fields without an SDM file read key. + let data = Data([0x00, 0x40, 0xE0, 0xEE, 0x00, 0x01, 0x00, 0xD1, 0xFF, 0xFF]) + let sdm = try #require(try NTAG424FileSettings(data: data).sdm) + + #expect(sdm.uidOffset == nil) + #expect(sdm.readCounterOffset == nil) + #expect(sdm.piccDataOffset == nil) + #expect(sdm.macInputOffset == nil) + #expect(sdm.encryptedDataOffset == nil) + #expect(sdm.macOffset == nil) + } + + @Test + func `Read counter limit alone is parsed`() throws { + let data = Data([0x00, 0x40, 0xE0, 0xEE, 0x00, 0x01, 0x00, 0x20, 0xFF, 0xFF, 0x01, 0x02, 0x03]) + let sdm = try #require(try NTAG424FileSettings(data: data).sdm) + + #expect(sdm.readCounterLimit == 0x030201) + } + + // MARK: Malformed + + @Test + func `Every truncation of a valid response is rejected`() { + let full = Data([ + 0x00, 0x40, 0xE0, 0xEE, 0x00, 0x01, 0x00, + 0xF1, 0xFF, 0x21, + 0x20, 0x00, 0x00, 0x43, 0x00, 0x00, 0x43, 0x00, 0x00, + 0x20, 0x00, 0x00, 0x68, 0x00, 0x00, 0x10, 0x27, 0x00, + ]) + for length in 0 ..< full.count { + let truncated = Data(full.prefix(length)) + #expect(throws: NFCError.invalidResponse(truncated)) { + try NTAG424FileSettings(data: truncated) + } + } + } + + @Test + func `Trailing bytes are rejected`() { + // SDM disabled plus an 8-byte response MAC. + let withMAC = Data([0x00, 0x00, 0xE0, 0xEE, 0x00, 0x01, 0x00] + [UInt8](repeating: 0xAA, count: 8)) + #expect(throws: NFCError.invalidResponse(withMAC)) { + try NTAG424FileSettings(data: withMAC) + } + + // SDM enabled without MAC fields, plus one stray byte. + let stray = Data([0x00, 0x40, 0xE0, 0xEE, 0x00, 0x01, 0x00, 0x01, 0xFF, 0xFF, 0x00]) + #expect(throws: NFCError.invalidResponse(stray)) { + try NTAG424FileSettings(data: stray) + } + } + + @Test + func `Non-StandardData file type is rejected`() { + let data = Data([0x01, 0x00, 0xE0, 0xEE, 0x00, 0x01, 0x00]) + #expect(throws: NFCError.invalidResponse(data)) { + try NTAG424FileSettings(data: data) + } + } + + @Test + func `Reserved SDMMetaRead value is rejected`() { + // SDMAccessRights 71FFh: MetaRead 7h is RFU. + let data = Data([0x00, 0x40, 0xE0, 0xEE, 0x00, 0x01, 0x00, 0x01, 0xFF, 0x71, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + #expect(throws: NFCError.invalidResponse(data)) { + try NTAG424FileSettings(data: data) + } + } +} + +struct NTAG424CommandsTests { + // MARK: GetFileSettings + + @Test + func `GetFileSettings sends the wrapped F5 command`() async throws { + let mock = MockTransport() + mock.apduResponses = [ + ResponseAPDU(data: Data([0x00, 0x00, 0xE0, 0xEE, 0x00, 0x01, 0x00]), sw1: 0x91, sw2: 0x00), + ] + + let settings = try await NTAG424Commands(transport: mock).getFileSettings(fileID: 0x02) + + #expect(mock.sentAPDUs.count == 1) + #expect(mock.sentAPDUs[0].bytes == Data([0x90, 0xF5, 0x00, 0x00, 0x01, 0x02, 0x00])) + #expect(settings.fileSize == 256) + } + + @Test + func `GetFileSettings maps native error status`() async { + let mock = MockTransport() + mock.apduResponses = [ResponseAPDU(data: Data(), sw1: 0x91, sw2: 0xF0)] + + await #expect(throws: NFCError.desfireError(.fileNotFound)) { + _ = try await NTAG424Commands(transport: mock).getFileSettings(fileID: 0x05) + } + } + + @Test + func `GetFileSettings reports ISO status words as-is`() async { + let mock = MockTransport() + mock.apduResponses = [ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82)] + + await #expect(throws: NFCError.unexpectedStatusWord(0x6A, 0x82)) { + _ = try await NTAG424Commands(transport: mock).getFileSettings(fileID: 0x02) + } + } + + @Test + func `GetFileSettings rejects a malformed payload`() async { + let mock = MockTransport() + let short = Data([0x00, 0x00, 0xE0, 0xEE, 0x00, 0x01]) + mock.apduResponses = [ResponseAPDU(data: short, sw1: 0x91, sw2: 0x00)] + + await #expect(throws: NFCError.invalidResponse(short)) { + _ = try await NTAG424Commands(transport: mock).getFileSettings(fileID: 0x02) + } + } + + @Test + func `GetFileSettings rejects file numbers above 1F before sending`() async { + let mock = MockTransport() + + await expectInvalidParameter(containing: "file number") { + _ = try await NTAG424Commands(transport: mock).getFileSettings(fileID: 0x20) + } + #expect(mock.sentAPDUs.isEmpty) + } + + // MARK: WriteData + + @Test + func `WriteData sends 8D with 24-bit little-endian offset and length`() async throws { + let mock = MockTransport() + mock.apduResponses = [ResponseAPDU(data: Data(), sw1: 0x91, sw2: 0x00)] + + try await NTAG424Commands(transport: mock) + .writeDataPlain(fileID: 0x02, offset: 0x123456, data: Data([0xAA, 0xBB, 0xCC])) + + #expect(mock.sentAPDUs.count == 1) + #expect(mock.sentAPDUs[0].ins == 0x8D) + #expect(mock.sentAPDUs[0].bytes == Data([ + 0x90, 0x8D, 0x00, 0x00, 0x0A, + 0x02, 0x56, 0x34, 0x12, 0x03, 0x00, 0x00, 0xAA, 0xBB, 0xCC, + 0x00, + ])) + } + + @Test + func `WriteData splits data into chunks with advancing offsets`() async throws { + let mock = MockTransport() + mock.apduResponses = Array(repeating: ResponseAPDU(data: Data(), sw1: 0x91, sw2: 0x00), count: 3) + let data = Data((0 ..< 300).map { UInt8(truncatingIfNeeded: $0) }) + + try await NTAG424Commands(transport: mock).writeDataPlain(fileID: 0x02, offset: 10, data: data) + + #expect(mock.sentAPDUs.count == 3) + let payloads = mock.sentAPDUs.map { $0.data ?? Data() } + #expect(payloads[0].prefix(7) == Data([0x02, 0x0A, 0x00, 0x00, 0x80, 0x00, 0x00])) + #expect(payloads[1].prefix(7) == Data([0x02, 0x8A, 0x00, 0x00, 0x80, 0x00, 0x00])) + #expect(payloads[2].prefix(7) == Data([0x02, 0x0A, 0x01, 0x00, 0x2C, 0x00, 0x00])) + #expect(payloads.map(\.count) == [135, 135, 51]) + #expect(Data(payloads.flatMap { $0.dropFirst(7) }) == data) + #expect(mock.sentAPDUs.allSatisfy { $0.cla == 0x90 && $0.ins == 0x8D && !$0.isExtendedLength }) + } + + @Test + func `WriteData honours a custom chunk size up to a short APDU`() throws { + let data = Data(repeating: 0x5A, count: 500) + let payloads = try NTAG424Commands.writeDataPlainPayloads( + fileID: 0x03, + offset: 0, + data: data, + chunkSize: NTAG424Commands.maxWriteChunkSize + ) + + #expect(NTAG424Commands.maxWriteChunkSize == 248) + #expect(payloads.map(\.count) == [255, 255, 11]) + #expect(payloads[2].prefix(7) == Data([0x03, 0xF0, 0x01, 0x00, 0x04, 0x00, 0x00])) + } + + @Test + func `WriteData accepts the last addressable byte`() throws { + let payloads = try NTAG424Commands.writeDataPlainPayloads(fileID: 0x02, offset: 0xFF_FFFF, data: Data([0x01])) + + #expect(payloads == [Data([0x02, 0xFF, 0xFF, 0xFF, 0x01, 0x00, 0x00, 0x01])]) + } + + @Test + func `WriteData stops at the first error status`() async { + let mock = MockTransport() + mock.apduResponses = [ + ResponseAPDU(data: Data(), sw1: 0x91, sw2: 0x00), + ResponseAPDU(data: Data(), sw1: 0x91, sw2: 0x9D), + ResponseAPDU(data: Data(), sw1: 0x91, sw2: 0x00), + ] + + await #expect(throws: NFCError.desfireError(.permissionDenied)) { + try await NTAG424Commands(transport: mock) + .writeDataPlain(fileID: 0x02, offset: 0, data: Data(repeating: 0x00, count: 200)) + } + #expect(mock.sentAPDUs.count == 2) + } + + @Test + func `WriteData does not chain on an Additional Frame answer`() async { + let mock = MockTransport() + mock.apduResponses = [ResponseAPDU(data: Data(), sw1: 0x91, sw2: 0xAF)] + + await #expect(throws: NFCError.desfireError(.additionalFrame)) { + try await NTAG424Commands(transport: mock) + .writeDataPlain(fileID: 0x02, offset: 0, data: Data([0x01])) + } + #expect(mock.sentAPDUs.count == 1) + } + + @Test + func `WriteData reports ISO status words as-is`() async { + let mock = MockTransport() + mock.apduResponses = [ResponseAPDU(data: Data(), sw1: 0x6E, sw2: 0x00)] + + await #expect(throws: NFCError.unexpectedStatusWord(0x6E, 0x00)) { + try await NTAG424Commands(transport: mock) + .writeDataPlain(fileID: 0x02, offset: 0, data: Data([0x01])) + } + } + + @Test + func `WriteData validates arguments before sending`() async { + let mock = MockTransport() + let commands = NTAG424Commands(transport: mock) + + await expectInvalidParameter(containing: "file number") { + try await commands.writeDataPlain(fileID: 0x20, offset: 0, data: Data([0x01])) + } + await expectInvalidParameter(containing: "at least one byte") { + try await commands.writeDataPlain(fileID: 0x02, offset: 0, data: Data()) + } + await expectInvalidParameter(containing: "offset must fit") { + try await commands.writeDataPlain(fileID: 0x02, offset: 0x0100_0000, data: Data([0x01])) + } + await expectInvalidParameter(containing: "offset + length") { + try await commands.writeDataPlain(fileID: 0x02, offset: 0xFF_FFFF, data: Data([0x01, 0x02])) + } + await expectInvalidParameter(containing: "chunk size") { + try await commands.writeDataPlain(fileID: 0x02, offset: 0, data: Data([0x01]), chunkSize: 0) + } + await expectInvalidParameter(containing: "chunk size") { + try await commands.writeDataPlain(fileID: 0x02, offset: 0, data: Data([0x01]), chunkSize: 249) + } + #expect(mock.sentAPDUs.isEmpty) + } + + // MARK: Facades + + @Test + func `Facades forward to NTAG424Commands`() async throws { + let mock = MockTransport() + mock.apduResponses = [ + ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0xE0, 0x20, 0x00, 0x00]), sw1: 0x91, sw2: 0x00), + ResponseAPDU(data: Data(), sw1: 0x91, sw2: 0x00), + ] + + let settings = try await CoreExtendedNFC.getNTAG424FileSettings(fileID: 0x01, transport: mock) + try await CoreExtendedNFC.writeNTAG424DataPlain(fileID: 0x02, data: Data([0xD1]), transport: mock) + + #expect(settings.fileSize == 32) + #expect(mock.sentAPDUs.map(\.ins) == [0xF5, 0x8D]) + #expect(mock.sentAPDUs[1].data == Data([0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0xD1])) + } + + private func expectInvalidParameter( + containing fragment: String, + while operation: () async throws -> Void + ) async { + do { + try await operation() + Issue.record("Expected invalidParameter containing \(fragment)") + } catch let NFCError.invalidParameter(message) { + #expect(message.contains(fragment)) + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} diff --git a/Tests/CoreExtendedNFCTests/Vectors/NTAG424VectorTests.swift b/Tests/CoreExtendedNFCTests/Vectors/NTAG424VectorTests.swift new file mode 100644 index 0000000..05f823a --- /dev/null +++ b/Tests/CoreExtendedNFCTests/Vectors/NTAG424VectorTests.swift @@ -0,0 +1,109 @@ +// Published NTAG 424 DNA vectors for GetFileSettings and plain WriteData, run as executable tests. +// +// ## Sources +// - NXP AN12196 Rev. 2.0 (4 March 2025), "NTAG 424 DNA and NTAG 424 DNA TagTamper +// features and hints": https://www.nxp.com/docs/en/application-note/AN12196.pdf +// - Table 7: CommMode.MAC GetFileSettings of file 02h. Step 13 gives the plain +// response data `0040EEEE000100D1FE001F00004400004400002000006A0000` (the leading +// `00` is the response code; the 8-byte MACt of step 12 is not part of it). +// - Tables 10 and 11: GetFileSettings C-APDU `90F50000010200` and a response the +// note itself labels illustrative ("does not reflect default ... configuration"). +// - Table 18: ChangeFileSettings CmdData `4000E0C1F121200000430000430000`, whose +// SDMAccessRights `F121h` the note decodes as RFU Fh, SDMCtrRet 1h, SDMMetaRead 2h, +// SDMFileRead 1h. +// - Table 24: plain WriteData of the CC file, +// C-APDU `908D000019010E0000120000FF0506E1050080828300000000000000000000`. +@testable import CoreExtendedNFC +import Foundation +import Testing + +struct NTAG424VectorTests { + private static func hex(_ string: String) -> Data { + Data(hexString: string)! + } + + @Test + func `AN12196 Table 7 GetFileSettings response with encrypted PICC and file data`() throws { + let settings = try NTAG424FileSettings( + data: Self.hex("0040EEEE000100D1FE001F00004400004400002000006A0000") + ) + let sdm = try #require(settings.sdm) + + #expect(settings.fileType == 0x00) + #expect(settings.communicationMode == .plain) + #expect(settings.accessRights.rawValue == 0xEEEE) + #expect(settings.fileSize == 256) + // D1h: UID, SDMReadCtr, SDMENCFileData, ASCII. + #expect(sdm.options == [.uid, .readCounter, .encryptedFileData, .asciiEncoding]) + // FE00h, least significant byte first: SDMMetaRead 0h, SDMFileRead 0h, + // RFU Fh, SDMCtrRet Eh. + #expect(sdm.accessRights.rawValue == 0x00FE) + #expect(sdm.accessRights.metaRead == 0x00) + #expect(sdm.accessRights.fileRead == 0x00) + #expect(sdm.accessRights.reserved == 0x0F) + #expect(sdm.accessRights.counterRetrieval == 0x0E) + #expect(sdm.uidOffset == nil) + #expect(sdm.readCounterOffset == nil) + #expect(sdm.piccDataOffset == 0x1F) + #expect(sdm.macInputOffset == 0x44) + #expect(sdm.encryptedDataOffset == 0x44) + #expect(sdm.encryptedDataLength == 0x20) + #expect(sdm.macOffset == 0x6A) + #expect(sdm.readCounterLimit == nil) + } + + @Test + func `AN12196 Table 18 SDM settings read back through GetFileSettings`() throws { + // FileType 00h ‖ size 256 around the Table 18 ChangeFileSettings CmdData. + let settings = try NTAG424FileSettings( + data: Self.hex("00" + "4000E0" + "000100" + "C1F121200000430000430000") + ) + let sdm = try #require(settings.sdm) + + #expect(settings.accessRights.readCondition == .free) + #expect(settings.accessRights.writeCondition == .key(0)) + #expect(settings.accessRights.readWriteCondition == .key(0)) + #expect(settings.accessRights.changeCondition == .key(0)) + #expect(sdm.options == [.uid, .readCounter, .asciiEncoding]) + #expect(sdm.accessRights.reserved == 0x0F) + #expect(sdm.accessRights.counterRetrieval == 0x01) + #expect(sdm.accessRights.metaRead == 0x02) + #expect(sdm.accessRights.fileRead == 0x01) + #expect(sdm.uidOffset == nil) + #expect(sdm.readCounterOffset == nil) + #expect(sdm.piccDataOffset == 0x20) + #expect(sdm.macInputOffset == 0x43) + #expect(sdm.macOffset == 0x43) + } + + @Test + func `AN12196 Table 10 illustrative response is rejected as truncated`() async { + // Table 11 decodes SDMAccessRights F121h as SDMMetaRead 2h and SDMFileRead 1h, + // which makes PICCDataOffset, SDMMACInputOffset and SDMMACOffset present, but the + // published response carries only two 3-byte offsets. + let mock = MockTransport() + let payload = Self.hex("004300E0000100C1F121200000430000") + mock.apduResponses = [ResponseAPDU(data: payload, sw1: 0x91, sw2: 0x00)] + + await #expect(throws: NFCError.invalidResponse(payload)) { + _ = try await NTAG424Commands(transport: mock).getFileSettings(fileID: 0x02) + } + #expect(mock.sentAPDUs.map(\.bytes) == [Self.hex("90F50000010200")]) + } + + @Test + func `AN12196 Table 24 plain WriteData of the CC file`() async throws { + let mock = MockTransport() + mock.apduResponses = [ResponseAPDU(data: Data(), sw1: 0x91, sw2: 0x00)] + + try await NTAG424Commands(transport: mock).writeDataPlain( + fileID: 0x01, + offset: 0x0E, + data: Self.hex("FF0506E10500808283000000000000000000") + ) + + #expect(mock.sentAPDUs.map(\.bytes) == [ + Self.hex("908D000019010E0000120000FF0506E1050080828300000000000000000000"), + ]) + } +} diff --git a/docs/api/card-commands.html b/docs/api/card-commands.html index 625b146..a2af904 100644 --- a/docs/api/card-commands.html +++ b/docs/api/card-commands.html @@ -11,6 +11,11 @@ let version = try await desfire.getVersion() let aids = try await desfire.getApplicationIDs() +let ntag424 = NTAG424Commands(transport: transport) +let settings = try await ntag424.getFileSettings(fileID: 0x02) +if let sdm = settings.sdm { print(sdm.macOffset as Any) } +try await ntag424.writeDataPlain(fileID: 0x02, offset: 0, data: ndefFile) + let select = CommandAPDU.select(aid: passportAID) let response = try await iso7816Transport.sendAPDU(select) if response.isApplicationNotFound { /* the card does not carry this AID */ } @@ -24,6 +29,9 @@

ISO 15693 security

DESFire

getVersion() maps the hardware major version to EV1 (0x01), EV2 (0x12, 0x22, and 0x42 on DESFire/EMV combo chips) or EV3 (0x33, and 0x43 on hardware type 0x81). Other major versions, such as DESFire Light (0x30) and DUOX (0xA0), are reported as generic .mifareDesfire; read hardwareType and hardwareMajorVersion to tell them apart. File settings include Transaction MAC files (type 0x05). The library has no DESFire write API. The automatic dump reads every free-access file and keys each one by AID (3 bytes) ‖ file ID (1 byte), so equal file IDs in different applications stay distinct. Applications and files that need authentication are skipped and counted. Transaction MAC files are counted but not read.

Authentication covers AuthenticateISO (0x1A, 2K3DES; a key with equal halves is treated as single DES) and AuthenticateEV2First (0x71, AES-128). EV2First Part 1 sends KeyNo ‖ LenCap ‖ PCDcap2, with LenCap taken from pcdCapabilities (0 to 6 bytes, empty by default). After AuthenticateISO the card appends an 8-byte CMAC to plain responses; the authenticated read helpers strip it but do not verify it. Not implemented: AuthenticateAES (0xAA), 3K3DES keys (AuthenticateISO takes 16-byte keys only), AuthenticateEV2NonFirst, EV1 and EV2 CMAC secure messaging for commands (MACed and enciphered communication), the EV2 command counter, and Transaction MAC (TMAC) computation. Authenticated reads therefore work only on files whose communication mode is plain.

+

NTAG 424 DNA

+

NTAG424Commands sends NTAG 424 DNA native commands in the ISO 7816 wrapped form shared with DESFire (90 CMD 00 00 Lc data 00), in CommMode.Plain only, to the NDEF application D2760000850101, which must already be selected (iOS selects it when it is the first AID in select-identifiers, and a Type 4 NDEF read selects it too). getFileSettings(fileID:) (F5h) returns NTAG424FileSettings: fileType, fileOption, communicationMode (.plain, .mac, .full), accessRights (read, write, readWrite, change nibbles, each also as an AccessCondition: .key(n), .free, .never) and the 24-bit fileSize. When FileOption bit 6 is set, sdm holds SDMOptions, SDMAccessRights (metaRead bits 15-12, fileRead bits 11-8, counterRetrieval bits 3-0, sent least significant byte first, as in the AN12196 examples) and the offsets the datasheet makes present: uidOffset and readCounterOffset for plain mirroring (SDMMetaRead Eh), piccDataOffset for encrypted PICC data (SDMMetaRead 0h-4h), macInputOffset and macOffset when SDMFileRead is not Fh, encryptedDataOffset / encryptedDataLength with SDMENCFileData, and readCounterLimit with SDMReadCtrLimit. The parser throws invalidResponse for truncated responses, trailing bytes (including the response MAC an authenticated session adds), a file type other than StandardData, and a reserved SDMMetaRead value.

+

writeDataPlain(fileID:offset:data:chunkSize:) uses the NTAG 424 WriteData command 8Dh, not the DESFire 3Dh. Each command carries FileNo ‖ Offset (3, LE) ‖ Length (3, LE) ‖ data; data longer than chunkSize (default 128 bytes, at most 248 so the command stays a short APDU) is sent as consecutive WriteData commands at advancing offsets. Each must be answered 9100; the first other status stops the write (desfireError for 91xx, unexpectedStatusWord otherwise) and chunks already acknowledged stay written. A file number above 1Fh, empty data, an offset or end beyond 24 bits, or a chunk size outside 1...248 throws invalidParameter before anything is sent. MAC / Full writes, ChangeFileSettings (SDM provisioning) and EV2 command counters are not implemented.

APDUs

CommandAPDU encodes short APDUs, and switches to extended length automatically when the command data exceeds 255 bytes or more than 256 response bytes are expected (Nc up to 65535, Ne up to 65536). APDUExchange.transmit(_:maxGetResponseFrames:retryOnWrongLength:using:) resolves 6Cxx (re-issue with the corrected Le) and 61xx (GET RESPONSE chaining). It throws NFCError.responseChainTooLong(maxFrames:) after defaultMaxGetResponseFrames (256) follow-ups.

Type 4

diff --git a/docs/api/core.html b/docs/api/core.html index 92ecac6..9d58542 100644 --- a/docs/api/core.html +++ b/docs/api/core.html @@ -64,6 +64,10 @@ static func getDESFireFileIDs(transport: any NFCTagTransport) async throws -> [UInt8] static func getDESFireFileSettings(_ fileID: UInt8, transport: any NFCTagTransport) async throws -> DESFireFileSettings +// NTAG 424 DNA (plain, unauthenticated; NDEF application selected) +static func getNTAG424FileSettings(fileID: UInt8, transport: any NFCTagTransport) async throws -> NTAG424FileSettings +static func writeNTAG424DataPlain(fileID: UInt8, offset: UInt32 = 0, data: Data, chunkSize: Int = NTAG424Commands.defaultWriteChunkSize, transport: any NFCTagTransport) async throws + // ISO 15693 (block ranges are checked against 0...255) static func readISO15693Blocks(range: ClosedRange<Int>, transport: any NFCTagTransport) async throws -> [Data] static func getISO15693BlockSecurityStatus(range: ClosedRange<Int>, transport: any NFCTagTransport) async throws -> [Bool] diff --git a/docs/architecture.html b/docs/architecture.html index 0606123..538dc94 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -6,7 +6,7 @@ ├── Transport/ CoreNFC tag wrappers, NFCSessionManager, CoreNFC error mapping ├── Protocol/ ISO 14443, card ID + refinement, APDU + exchange, ASN.1, NDEF, errors ├── Cards/ Family-specific command, parser, reader, and dump logic -│ ├── MiFareUltralight/, NTAG/, DESFire/, FeliCa/, ISO15693/, Type4/ +│ ├── MiFareUltralight/, NTAG/, NTAG424/, DESFire/, FeliCa/, ISO15693/, Type4/ │ ├── IndividualNumber/ Japanese My Number card │ ├── Transit/ Japan IC, Octopus, Singapore CEPAS, KS X 6924, T-Union │ └── Passport/ PACE, BAC, Secure Messaging, AA, PA, data-group parsers (CA detection only) diff --git a/docs/index.html b/docs/index.html index 6ddc17f..54a4934 100644 --- a/docs/index.html +++ b/docs/index.html @@ -27,7 +27,7 @@

CoreExtendedNFC

Capabilities

  • Card identification from ATQA, SAK, ATS, FeliCa system code, ISO 15693 metadata, and ISO 7816 application hints, refined by safe probes (Ultralight C, NTAG210/212, DESFire EV1-EV3, FeliCa Lite / Lite-S).
  • -
  • Memory operations for Ultralight, NTAG, DESFire (read-only), FeliCa Type 3, ISO 15693, and Type 4 NDEF tags.
  • +
  • Memory operations for Ultralight, NTAG, DESFire (read-only), FeliCa Type 3, ISO 15693, and Type 4 NDEF tags, plus NTAG 424 DNA file settings (including SDM) and plain WriteData.
  • Passport and eMRTD reading with PACE (ECDH Generic Mapping on NIST P-256/384/521 and Brainpool P-224 to P-512 curves) or BAC, Secure Messaging, data-group parsing, Passive Authentication against caller-supplied CSCA certificates, and Active Authentication.
  • Transit-card readers for Japan IC, Hong Kong Octopus, Singapore CEPAS, China T-Union, and Korea KS X 6924 paths.
  • Japan My Number card token reads, protected individual-number reads, and PIN-attempt lookup.
  • @@ -70,6 +70,7 @@

    Supported Cards

    MIFARE Ultralight / EV1 / Cyesyesyesyes NTAG 210 / 212 / 213 / 215 / 216yesyesyesyes MIFARE DESFire EV1-EV3yesyesnofree-access files +NTAG 424 DNAas DESFire / Type 4file settings, NDEFplain filesno FeliCa Type 3 NDEFyesyesyespartial ISO 15693 / Type 5yesyesyesextended blocks when supported Type 4 NDEF / ISO 7816yesyesyespartial @@ -78,7 +79,7 @@

    Supported Cards

    MIFARE Classic 1K / 4Kyesnonono -

    MIFARE Classic is identification-only because iOS does not expose Crypto1 authentication (iOS cannot send the encrypted parity bits it needs). DESFire has no write API. ISO 15693 tags with more than 256 blocks are read with extended commands; tags that reject them are dumped up to block 255 and marked partially readable.

    +

    MIFARE Classic is identification-only because iOS does not expose Crypto1 authentication (iOS cannot send the encrypted parity bits it needs). DESFire has no write API; NTAG 424 DNA has a plain (CommMode.Plain) WriteData only. ISO 15693 tags with more than 256 blocks are read with extended commands; tags that reject them are dumped up to block 255 and marked partially readable.

    Next Steps

      diff --git a/docs/research/references.html b/docs/research/references.html index df990e2..8338707 100644 --- a/docs/research/references.html +++ b/docs/research/references.html @@ -87,7 +87,8 @@

      NXP datasheets and application notes

      NTAG210/212Memory layout and configuration pages for NTAG210 and NTAG212.Link only · © NXP, all rights reserved NT3H1101/1201, NTAG I²C (product page)NTAG I²C identification from the GET_VERSION product subtype.Link only · © NXP. Discontinued; nxp.com no longer serves the datasheet PDF. NT3H2111/2211, NTAG I²C plusNTAG I²C plus identification and memory sizes.Link only · © NXP, all rights reserved -AN12196, NTAG 424 DNA features and hintsAuthenticateEV2First flow and session-key derivation vectors (SV1/SV2).Link only · © NXP, all rights reserved +NT4H2421Gx, NTAG 424 DNANative GetFileSettings (F5h) layout including the SDM fields, WriteData (8Dh), and access-right nibbles.Link only · © NXP, all rights reserved +AN12196, NTAG 424 DNA features and hintsAuthenticateEV2First flow and session-key derivation vectors (SV1/SV2); NTAG 424 DNA GetFileSettings responses (Tables 7, 10, 11, 18) and the plain WriteData of the CC file (Table 24), including the SDMAccessRights nibble order.Link only · © NXP, all rights reserved AN12343, MIFARE DESFire Light features and hintsEV2 authentication parts 1 and 2, PCD capabilities, and EV2 secure messaging.Link only · © NXP, all rights reserved AN10922, Symmetric key diversificationsBackground on DESFire key diversification. The library does not implement diversification.Link only · © NXP, all rights reserved From 3fd83419344eeb9624ebde0c751c858144304119 Mon Sep 17 00:00:00 2001 From: Lakr233 Date: Sat, 17 Oct 2026 13:46:30 +0000 Subject: [PATCH 2/3] fix(ntag424): single-frame WriteData default, TagTamper status offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings checked against NT4H2421Gx / NT4H2421Tx Rev. 3.0: - Default WriteData chunk is now 111 bytes so each command fits one 128-byte ISO/IEC 14443-4 frame (ATS T0 77h, CID supported). The datasheet only guarantees tearing protection within one frame (§10.8.2, §8.5); the old 128-byte default produced 141-byte APDUs that the reader chains. The 248-byte maximum (Table 81) is unchanged. - GetFileSettings parses TTStatusOffset, present on NTAG 424 DNA TT when SDMOptions bit 3 is set, between PICCDataOffset and SDMMACInputOffset (NT4H2421Tx Table 73). Such responses used to be rejected. - SDMAccessRights gains counterRetrievalCondition; doc comments note that SDMReadCtrOffset FFFFFFh means no counter mirroring and that SDMFileRead Eh is RFU but still makes the MAC offsets present. - The writeNTAG424DataPlain doc now states the real precondition: without authentication the Write or ReadWrite access condition must be Eh; the file's CommMode only applies inside an authenticated session. Co-Authored-By: Claude Opus 5.5 --- .../Cards/NTAG424/NTAG424Commands.swift | 14 +++++++-- .../Cards/NTAG424/NTAG424FileSettings.swift | 16 +++++++++- .../CoreExtendedNFC+NTAG424.swift | 5 ++-- .../Cards/NTAG424Tests.swift | 27 ++++++++++++++--- .../Vectors/NTAG424VectorTests.swift | 30 +++++++++++++++++++ 5 files changed, 82 insertions(+), 10 deletions(-) diff --git a/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424Commands.swift b/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424Commands.swift index 83b752e..efb8993 100644 --- a/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424Commands.swift +++ b/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424Commands.swift @@ -40,8 +40,14 @@ open class NTAG424Commands: @unchecked Sendable { /// Largest data chunk per WriteData that keeps the command a short APDU /// (Lc ≤ 255 including the 7-byte header). public static let maxWriteChunkSize = 255 - writeDataHeaderLength - /// Default data bytes per WriteData command. - public static let defaultWriteChunkSize = 128 + /// Default data bytes per WriteData command: the largest chunk whose command + /// fits one ISO/IEC 14443-4 frame. The PICC's FSC is 128 bytes (ATS T0 `77h`) and + /// it supports CID, so a frame carries PCB (1) ‖ CID (1) ‖ APDU ‖ CRC (2), and the + /// APDU adds CLA, INS, P1, P2, Lc, Le and the 7-byte header: 128 - 4 - 6 - 7 = 111. + /// The datasheet only guarantees tearing protection for data sent within one frame; + /// larger chunks are chained by the reader and an interrupted chain can leave the + /// chunk partially written. + public static let defaultWriteChunkSize = 111 // MARK: - Commands @@ -56,7 +62,9 @@ open class NTAG424Commands: @unchecked Sendable { /// WriteData (`8Dh`) in CommMode.Plain. /// /// `data` is split into WriteData commands of at most `chunkSize` bytes, each - /// with its own 3-byte little-endian offset and length, sent in order. Every + /// with its own 3-byte little-endian offset and length, sent in order. The + /// default keeps every command in one tearing-protected frame (see + /// ``defaultWriteChunkSize``). Every /// command must be answered `91 00`; a failure stops the write and earlier /// chunks stay written. Arguments are checked before anything is sent: /// `fileID` must be `0x00...0x1F`, `data` must not be empty, `offset + data.count` diff --git a/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424FileSettings.swift b/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424FileSettings.swift index dfda554..0b168d6 100644 --- a/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424FileSettings.swift +++ b/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424FileSettings.swift @@ -1,7 +1,8 @@ import Foundation /// File settings of an NTAG 424 DNA file, parsed from a plain GetFileSettings (`F5h`) -/// response (NXP NT4H2421Gx datasheet, GetFileSettings). +/// response (NXP NT4H2421Gx datasheet, GetFileSettings; NT4H2421Tx for the +/// TagTamper variant). /// /// Layout: /// @@ -11,6 +12,7 @@ import Foundation /// ‖ UIDOffset (3) if SDMOptions.uid and SDMMetaRead == Eh /// ‖ SDMReadCtrOffset (3) if SDMOptions.readCounter and SDMMetaRead == Eh /// ‖ PICCDataOffset (3) if SDMMetaRead is a key (0h...4h) +/// ‖ TTStatusOffset (3) if SDMOptions.tagTamperStatus (NTAG 424 DNA TT) /// ‖ SDMMACInputOffset (3) if SDMFileRead != Fh /// ‖ SDMENCOffset (3) if SDMOptions.encryptedFileData and SDMFileRead != Fh /// ‖ SDMENCLength (3) if SDMOptions.encryptedFileData and SDMFileRead != Fh @@ -110,6 +112,9 @@ public struct NTAG424FileSettings: Sendable, Equatable { public static let readCounterLimit = SDMOptions(rawValue: 0x20) /// Bit 4: SDMENCFileData (encrypted file data mirroring). public static let encryptedFileData = SDMOptions(rawValue: 0x10) + /// Bit 3: TagTamper status mirroring. NTAG 424 DNA TT (NT4H2421Tx) only; RFU + /// on NTAG 424 DNA. + public static let tagTamperStatus = SDMOptions(rawValue: 0x08) /// Bit 0: ASCII encoding of mirrored data. public static let asciiEncoding = SDMOptions(rawValue: 0x01) } @@ -129,11 +134,15 @@ public struct NTAG424FileSettings: Sendable, Equatable { /// encrypted PICC data with that key, `Fh` no PICC data mirroring. public var metaRead: UInt8 { UInt8(truncatingIfNeeded: rawValue >> 12) & 0x0F } /// SDMFileRead (bits 11-8): `0h...4h` SDM MAC (and encryption) key, `Fh` no SUN MAC. + /// `Eh` is RFU; any value other than `Fh` makes the MAC offsets present. public var fileRead: UInt8 { UInt8(truncatingIfNeeded: rawValue >> 8) & 0x0F } /// Reserved nibble (bits 7-4). public var reserved: UInt8 { UInt8(truncatingIfNeeded: rawValue >> 4) & 0x0F } /// SDMCtrRet (bits 3-0): access condition for GetFileCounters. public var counterRetrieval: UInt8 { UInt8(truncatingIfNeeded: rawValue) & 0x0F } + + /// SDMCtrRet as an access condition (same coding as the file access rights). + public var counterRetrievalCondition: AccessCondition { AccessCondition(nibble: counterRetrieval) } } /// The SDM extension present when FileOption bit 6 is set. @@ -142,8 +151,11 @@ public struct NTAG424FileSettings: Sendable, Equatable { public let options: SDMOptions public let accessRights: SDMAccessRights public let uidOffset: UInt32? + /// SDMReadCtrOffset; `FFFFFFh` means the counter is not mirrored. public let readCounterOffset: UInt32? public let piccDataOffset: UInt32? + /// TTStatusOffset, NTAG 424 DNA TT only. + public let tagTamperStatusOffset: UInt32? public let macInputOffset: UInt32? public let encryptedDataOffset: UInt32? public let encryptedDataLength: UInt32? @@ -209,6 +221,7 @@ public struct NTAG424FileSettings: Sendable, Equatable { let uidOffset = try (options.contains(.uid) && plainMirroring) ? reader.uint24LE() : nil let readCounterOffset = try (options.contains(.readCounter) && plainMirroring) ? reader.uint24LE() : nil let piccDataOffset = try encryptedPICCData ? reader.uint24LE() : nil + let tagTamperStatusOffset = try options.contains(.tagTamperStatus) ? reader.uint24LE() : nil let macInputOffset = try fileReadEnabled ? reader.uint24LE() : nil let encOffset = try encryptedFileData ? reader.uint24LE() : nil let encLength = try encryptedFileData ? reader.uint24LE() : nil @@ -221,6 +234,7 @@ public struct NTAG424FileSettings: Sendable, Equatable { uidOffset: uidOffset, readCounterOffset: readCounterOffset, piccDataOffset: piccDataOffset, + tagTamperStatusOffset: tagTamperStatusOffset, macInputOffset: macInputOffset, encryptedDataOffset: encOffset, encryptedDataLength: encLength, diff --git a/Sources/CoreExtendedNFC/CoreExtendedNFC+NTAG424.swift b/Sources/CoreExtendedNFC/CoreExtendedNFC+NTAG424.swift index 6171458..1bbed8b 100644 --- a/Sources/CoreExtendedNFC/CoreExtendedNFC+NTAG424.swift +++ b/Sources/CoreExtendedNFC/CoreExtendedNFC+NTAG424.swift @@ -18,8 +18,9 @@ public extension CoreExtendedNFC { /// WriteData (`8Dh`) in CommMode.Plain to an NTAG 424 DNA file, split into /// commands of at most `chunkSize` data bytes. /// - /// Only works for files whose communication mode is plain and whose write - /// access is free (or granted without secure messaging). + /// Sent without authentication, so the file's Write or ReadWrite access condition + /// must be free (`Eh`); the file's communication mode only applies inside an + /// authenticated session, which this library does not keep for NTAG 424. static func writeNTAG424DataPlain( fileID: UInt8, offset: UInt32 = 0, diff --git a/Tests/CoreExtendedNFCTests/Cards/NTAG424Tests.swift b/Tests/CoreExtendedNFCTests/Cards/NTAG424Tests.swift index 5fa0ede..f5503e0 100644 --- a/Tests/CoreExtendedNFCTests/Cards/NTAG424Tests.swift +++ b/Tests/CoreExtendedNFCTests/Cards/NTAG424Tests.swift @@ -106,6 +106,7 @@ struct NTAG424FileSettingsTests { #expect(sdm.accessRights.metaRead == 0x0E) #expect(sdm.accessRights.fileRead == 0x01) #expect(sdm.accessRights.counterRetrieval == 0x0E) + #expect(sdm.accessRights.counterRetrievalCondition == .free) #expect(sdm.uidOffset == 0x20) #expect(sdm.readCounterOffset == 0x33) #expect(sdm.piccDataOffset == nil) @@ -304,10 +305,10 @@ struct NTAG424CommandsTests { #expect(mock.sentAPDUs.count == 3) let payloads = mock.sentAPDUs.map { $0.data ?? Data() } - #expect(payloads[0].prefix(7) == Data([0x02, 0x0A, 0x00, 0x00, 0x80, 0x00, 0x00])) - #expect(payloads[1].prefix(7) == Data([0x02, 0x8A, 0x00, 0x00, 0x80, 0x00, 0x00])) - #expect(payloads[2].prefix(7) == Data([0x02, 0x0A, 0x01, 0x00, 0x2C, 0x00, 0x00])) - #expect(payloads.map(\.count) == [135, 135, 51]) + #expect(payloads[0].prefix(7) == Data([0x02, 0x0A, 0x00, 0x00, 0x6F, 0x00, 0x00])) + #expect(payloads[1].prefix(7) == Data([0x02, 0x79, 0x00, 0x00, 0x6F, 0x00, 0x00])) + #expect(payloads[2].prefix(7) == Data([0x02, 0xE8, 0x00, 0x00, 0x4E, 0x00, 0x00])) + #expect(payloads.map(\.count) == [118, 118, 85]) #expect(Data(payloads.flatMap { $0.dropFirst(7) }) == data) #expect(mock.sentAPDUs.allSatisfy { $0.cla == 0x90 && $0.ins == 0x8D && !$0.isExtendedLength }) } @@ -327,6 +328,24 @@ struct NTAG424CommandsTests { #expect(payloads[2].prefix(7) == Data([0x03, 0xF0, 0x01, 0x00, 0x04, 0x00, 0x00])) } + @Test + func `Default WriteData chunks each fit one ISO 14443-4 frame`() throws { + // NT4H2421Gx Table 4: FSC is 128 bytes; TC(1) 02h: CID supported. Section 10.8.2: + // only data sent within one frame is tearing protected. A frame carries + // PCB (1) ‖ CID (1) ‖ INF ‖ CRC (2). + let payloads = try NTAG424Commands.writeDataPlainPayloads( + fileID: 0x02, + offset: 0, + data: Data(repeating: 0x5A, count: 256) + ) + + for payload in payloads { + let apdu = CommandAPDU.desfireWrap(command: NTAG424Commands.WRITE_DATA, data: payload) + #expect(1 + 1 + apdu.bytes.count + 2 <= 128) + } + #expect(payloads.count == 3) + } + @Test func `WriteData accepts the last addressable byte`() throws { let payloads = try NTAG424Commands.writeDataPlainPayloads(fileID: 0x02, offset: 0xFF_FFFF, data: Data([0x01])) diff --git a/Tests/CoreExtendedNFCTests/Vectors/NTAG424VectorTests.swift b/Tests/CoreExtendedNFCTests/Vectors/NTAG424VectorTests.swift index 05f823a..ab20615 100644 --- a/Tests/CoreExtendedNFCTests/Vectors/NTAG424VectorTests.swift +++ b/Tests/CoreExtendedNFCTests/Vectors/NTAG424VectorTests.swift @@ -91,6 +91,36 @@ struct NTAG424VectorTests { #expect(mock.sentAPDUs.map(\.bytes) == [Self.hex("90F50000010200")]) } + @Test + func `NTAG 424 DNA TT TagTamper status offset sits before the MAC offsets`() throws { + // NT4H2421Tx Rev. 3.0 Table 73: TTStatusOffset is present if SDMOptions bit 3 + // is set, between PICCDataOffset and SDMMACInputOffset. SDMOptions 89h (UID, + // TT status, ASCII); SDMAccessRights E1FFh: MetaRead Eh, FileRead 1h. + let settings = try NTAG424FileSettings( + data: Self.hex("004000E0000100" + "89FFE1" + "200000" + "2E0000" + "340000" + "450000") + ) + let sdm = try #require(settings.sdm) + + #expect(sdm.options == [.uid, .tagTamperStatus, .asciiEncoding]) + #expect(sdm.uidOffset == 0x20) + #expect(sdm.piccDataOffset == nil) + #expect(sdm.tagTamperStatusOffset == 0x2E) + #expect(sdm.macInputOffset == 0x34) + #expect(sdm.macOffset == 0x45) + } + + @Test + func `NTAG 424 DNA TT status offset after encrypted PICC data`() throws { + // SDMOptions 08h (TT status); SDMAccessRights 2FFFh: MetaRead 2h, FileRead Fh. + let sdm = try #require( + try NTAG424FileSettings(data: Self.hex("004000E0000100" + "08FF2F" + "200000" + "400000")).sdm + ) + + #expect(sdm.piccDataOffset == 0x20) + #expect(sdm.tagTamperStatusOffset == 0x40) + #expect(sdm.macInputOffset == nil) + } + @Test func `AN12196 Table 24 plain WriteData of the CC file`() async throws { let mock = MockTransport() From 7269cf220def6517c4957d5a9c5cbc9e1dbbba02 Mon Sep 17 00:00:00 2001 From: Lakr233 Date: Sat, 17 Oct 2026 13:46:30 +0000 Subject: [PATCH 3/3] fix(dump): dump NTAG 424 DNA reported as DESFire as a Type 4 tag NTAG 424 DNA shares DESFire's ATQA 0344h / SAK 20h, so iOS can report it as a DESFire tag. It has no GetApplicationIDs (NT4H2421Gx Table 22) and answers 91 1C (Table 23), which made dumpCard fail. When that happens and GetVersion reports hardware type 04h (NTAG, Table 54) the tag is now dumped through the Type 4 path; any other chip still reports the error. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- .../Cards/DESFire/DESFireApplication.swift | 3 + .../CoreExtendedNFC+Dump.swift | 15 ++++- .../Cards/DESFireDumpTests.swift | 59 +++++++++++++++++++ docs/api/card-commands.html | 5 +- 5 files changed, 81 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3acb3e2..a67ee6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Added - NTAG 424 DNA module (`Cards/NTAG424/`, issue #14) for plain native file operations over the ISO 7816 wrapped transport: - - `NTAG424Commands.getFileSettings(fileID:)` (`F5h`) and `CoreExtendedNFC.getNTAG424FileSettings(fileID:transport:)` return the new `NTAG424FileSettings`. Its parser is strict: it decodes FileType, FileOption (communication mode, SDM bit 6), the Read / Write / ReadWrite / Change access-right nibbles and the 24-bit file size, and, when SDM is enabled, SDMOptions, SDMAccessRights and the offsets that SDMOptions and SDMAccessRights make present (UID, SDMReadCtr, PICCData, SDMMACInput, SDMENC offset and length, SDMMAC, SDMReadCtrLimit). Truncated responses, trailing bytes (such as a response MAC), a file type other than StandardData and a reserved SDMMetaRead value throw `invalidResponse`. - - `NTAG424Commands.writeDataPlain(fileID:offset:data:chunkSize:)` and `CoreExtendedNFC.writeNTAG424DataPlain(fileID:offset:data:chunkSize:transport:)` send the NTAG 424 native WriteData `8Dh` (not the DESFire `3Dh`) in CommMode.Plain, with 3-byte little-endian offset and length. Data is split into commands of at most `chunkSize` bytes (default 128, at most 248 so each command stays a short APDU). Arguments are checked before anything is sent (`invalidParameter`), and the first non-`9100` status stops the write. + - `NTAG424Commands.getFileSettings(fileID:)` (`F5h`) and `CoreExtendedNFC.getNTAG424FileSettings(fileID:transport:)` return the new `NTAG424FileSettings`. Its parser is strict: it decodes FileType, FileOption (communication mode, SDM bit 6), the Read / Write / ReadWrite / Change access-right nibbles and the 24-bit file size, and, when SDM is enabled, SDMOptions, SDMAccessRights and the offsets that SDMOptions and SDMAccessRights make present (UID, SDMReadCtr, PICCData, TTStatus on NTAG 424 DNA TT, SDMMACInput, SDMENC offset and length, SDMMAC, SDMReadCtrLimit). Truncated responses, trailing bytes (such as a response MAC), a file type other than StandardData and a reserved SDMMetaRead value throw `invalidResponse`. + - `NTAG424Commands.writeDataPlain(fileID:offset:data:chunkSize:)` and `CoreExtendedNFC.writeNTAG424DataPlain(fileID:offset:data:chunkSize:transport:)` send the NTAG 424 native WriteData `8Dh` (not the DESFire `3Dh`) in CommMode.Plain, with 3-byte little-endian offset and length. Data is split into commands of at most `chunkSize` bytes (default 111, so each command fits one tearing-protected 128-byte ISO 14443-4 frame; at most 248, the datasheet limit per WriteData). Arguments are checked before anything is sent (`invalidParameter`), and the first non-`9100` status stops the write. + - `dumpCard` on an NTAG 424 DNA that iOS reports as DESFire no longer fails with `desfireError(.illegalCommand)`: when GetApplicationIDs answers `91 1C` and GetVersion reports hardware type `04h` (`DESFireVersionInfo.ntagHardwareType`), the tag is dumped as a Type 4 tag (CC and NDEF file). - CommMode.MAC / Full, ChangeFileSettings / SDM provisioning and EV2 command counters are not implemented. ## [2.0.0] - 2026-10-16 diff --git a/Sources/CoreExtendedNFC/Cards/DESFire/DESFireApplication.swift b/Sources/CoreExtendedNFC/Cards/DESFire/DESFireApplication.swift index 114e11b..0d6f912 100644 --- a/Sources/CoreExtendedNFC/Cards/DESFire/DESFireApplication.swift +++ b/Sources/CoreExtendedNFC/Cards/DESFire/DESFireApplication.swift @@ -2,6 +2,9 @@ import Foundation /// DESFire version info (from GET_VERSION, 3 frames: HW + SW + UID). public struct DESFireVersionInfo: Sendable { + /// Hardware type of NTAG parts such as NTAG 424 DNA (NT4H2421Gx Table 54). + public static let ntagHardwareType: UInt8 = 0x04 + public let hardwareVendorID: UInt8 public let hardwareType: UInt8 public let hardwareSubType: UInt8 diff --git a/Sources/CoreExtendedNFC/CoreExtendedNFC+Dump.swift b/Sources/CoreExtendedNFC/CoreExtendedNFC+Dump.swift index 7be25a3..9859acd 100644 --- a/Sources/CoreExtendedNFC/CoreExtendedNFC+Dump.swift +++ b/Sources/CoreExtendedNFC/CoreExtendedNFC+Dump.swift @@ -47,12 +47,23 @@ public extension CoreExtendedNFC { } /// Dump a DESFire card: list apps, list files, read all accessible data. + /// + /// NTAG 424 DNA shares DESFire's ATQA / SAK but answers GetApplicationIDs with + /// `91 1C` (NT4H2421Gx Tables 22 and 23). When that happens and GetVersion reports + /// hardware type `04h` (NTAG, Table 54), the tag is dumped as the Type 4 tag it is. static func dumpDESFire( info: CardInfo, transport: any NFCTagTransport ) async throws -> MemoryDump { - try await DESFireDump(commands: DESFireCommands(transport: transport)) - .dumpAll(cardInfo: info) + let commands = DESFireCommands(transport: transport) + do { + return try await DESFireDump(commands: commands).dumpAll(cardInfo: info) + } catch NFCError.desfireError(.illegalCommand) { + guard try await commands.getVersion().hardwareType == DESFireVersionInfo.ntagHardwareType else { + throw NFCError.desfireError(.illegalCommand) + } + return try await dumpType4(info: info, transport: transport) + } } static func dumpType4( diff --git a/Tests/CoreExtendedNFCTests/Cards/DESFireDumpTests.swift b/Tests/CoreExtendedNFCTests/Cards/DESFireDumpTests.swift index 17f4c65..4e890b3 100644 --- a/Tests/CoreExtendedNFCTests/Cards/DESFireDumpTests.swift +++ b/Tests/CoreExtendedNFCTests/Cards/DESFireDumpTests.swift @@ -165,4 +165,63 @@ struct DESFireDumpTests { // Only GetApplicationIDs went out before the per-application check. #expect(mock.sentAPDUs.count == 1) } + + @Test + func `NTAG 424 DNA reported as DESFire is dumped as a Type 4 tag`() async throws { + // NTAG 424 DNA shares DESFire's ATQA 0344h / SAK 20h (NT4H2421Gx §6) but has no + // GetApplicationIDs: Table 22 omits 6Ah and Table 23 answers an unknown command + // with 91 1C. GetVersion frames are the AN12196 Table 12 transcript; hardware + // type 04h is "HW type for NTAG" (NT4H2421Gx Table 54). + let mock = MockTransport() + let ndefMessage = Data([0xD1, 0x01, 0x05, 0x54, 0x02, 0x65, 0x6E, 0x48, 0x69]) + let cc = Data([ + 0x00, 0x17, 0x20, 0x01, 0x00, 0x00, 0xFF, + 0x04, 0x06, 0xE1, 0x04, 0x01, 0x00, 0x00, 0x00, + 0x05, 0x06, 0xE1, 0x05, 0x00, 0x80, 0x82, 0x83, + ]) + let isoOK = ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00) + mock.apduResponses = [ + ResponseAPDU(data: Data(), sw1: 0x91, sw2: 0x1C), // GetApplicationIDs + ResponseAPDU(data: Data([0x04, 0x04, 0x08, 0x30, 0x00, 0x11, 0x05]), sw1: 0x91, sw2: 0xAF), + ResponseAPDU(data: Data([0x04, 0x04, 0x02, 0x01, 0x01, 0x11, 0x05]), sw1: 0x91, sw2: 0xAF), + Self.ok([0x04, 0x96, 0x8C, 0xAA, 0x5C, 0x5E, 0x80, 0xCD, 0x65, 0x93, 0x5D, 0x40, 0x21, 0x18]), + isoOK, // SELECT NDEF application + isoOK, // SELECT CC + ResponseAPDU(data: cc, sw1: 0x90, sw2: 0x00), + isoOK, + isoOK, + ResponseAPDU(data: cc, sw1: 0x90, sw2: 0x00), + isoOK, // SELECT NDEF file + ResponseAPDU(data: Data([0x00, UInt8(ndefMessage.count)]), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: ndefMessage, sw1: 0x90, sw2: 0x00), + ] + + let dump = try await CoreExtendedNFC.dumpCard( + info: CardInfo(type: .mifareDesfire, uid: mock.identifier), + transport: mock + ) + + #expect(Array(mock.sentAPDUs.prefix(4).map(\.ins)) == [0x6A, 0x60, 0xAF, 0xAF]) + #expect(dump.ndefMessage == ndefMessage) + #expect(dump.files.map(\.identifier) == [Type4Constants.ccFileID, Data([0xE1, 0x04])]) + } + + @Test + func `DESFire illegal command is still reported when the chip is not an NTAG`() async { + let mock = MockTransport() + mock.apduResponses = [ + ResponseAPDU(data: Data(), sw1: 0x91, sw2: 0x1C), + ResponseAPDU(data: Data([0x04, 0x01, 0x01, 0x01, 0x00, 0x18, 0x05]), sw1: 0x91, sw2: 0xAF), + ResponseAPDU(data: Data([0x04, 0x01, 0x01, 0x01, 0x04, 0x18, 0x05]), sw1: 0x91, sw2: 0xAF), + Self.ok(Array(repeating: 0x11, count: 14)), + ] + + await #expect(throws: NFCError.desfireError(.illegalCommand)) { + _ = try await CoreExtendedNFC.dumpCard( + info: CardInfo(type: .mifareDesfireEV1, uid: mock.identifier), + transport: mock + ) + } + #expect(mock.sentAPDUs.count == 4) + } } diff --git a/docs/api/card-commands.html b/docs/api/card-commands.html index a2af904..9beead4 100644 --- a/docs/api/card-commands.html +++ b/docs/api/card-commands.html @@ -30,8 +30,9 @@

      DESFire

      getVersion() maps the hardware major version to EV1 (0x01), EV2 (0x12, 0x22, and 0x42 on DESFire/EMV combo chips) or EV3 (0x33, and 0x43 on hardware type 0x81). Other major versions, such as DESFire Light (0x30) and DUOX (0xA0), are reported as generic .mifareDesfire; read hardwareType and hardwareMajorVersion to tell them apart. File settings include Transaction MAC files (type 0x05). The library has no DESFire write API. The automatic dump reads every free-access file and keys each one by AID (3 bytes) ‖ file ID (1 byte), so equal file IDs in different applications stay distinct. Applications and files that need authentication are skipped and counted. Transaction MAC files are counted but not read.

      Authentication covers AuthenticateISO (0x1A, 2K3DES; a key with equal halves is treated as single DES) and AuthenticateEV2First (0x71, AES-128). EV2First Part 1 sends KeyNo ‖ LenCap ‖ PCDcap2, with LenCap taken from pcdCapabilities (0 to 6 bytes, empty by default). After AuthenticateISO the card appends an 8-byte CMAC to plain responses; the authenticated read helpers strip it but do not verify it. Not implemented: AuthenticateAES (0xAA), 3K3DES keys (AuthenticateISO takes 16-byte keys only), AuthenticateEV2NonFirst, EV1 and EV2 CMAC secure messaging for commands (MACed and enciphered communication), the EV2 command counter, and Transaction MAC (TMAC) computation. Authenticated reads therefore work only on files whose communication mode is plain.

      NTAG 424 DNA

      -

      NTAG424Commands sends NTAG 424 DNA native commands in the ISO 7816 wrapped form shared with DESFire (90 CMD 00 00 Lc data 00), in CommMode.Plain only, to the NDEF application D2760000850101, which must already be selected (iOS selects it when it is the first AID in select-identifiers, and a Type 4 NDEF read selects it too). getFileSettings(fileID:) (F5h) returns NTAG424FileSettings: fileType, fileOption, communicationMode (.plain, .mac, .full), accessRights (read, write, readWrite, change nibbles, each also as an AccessCondition: .key(n), .free, .never) and the 24-bit fileSize. When FileOption bit 6 is set, sdm holds SDMOptions, SDMAccessRights (metaRead bits 15-12, fileRead bits 11-8, counterRetrieval bits 3-0, sent least significant byte first, as in the AN12196 examples) and the offsets the datasheet makes present: uidOffset and readCounterOffset for plain mirroring (SDMMetaRead Eh), piccDataOffset for encrypted PICC data (SDMMetaRead 0h-4h), macInputOffset and macOffset when SDMFileRead is not Fh, encryptedDataOffset / encryptedDataLength with SDMENCFileData, and readCounterLimit with SDMReadCtrLimit. The parser throws invalidResponse for truncated responses, trailing bytes (including the response MAC an authenticated session adds), a file type other than StandardData, and a reserved SDMMetaRead value.

      -

      writeDataPlain(fileID:offset:data:chunkSize:) uses the NTAG 424 WriteData command 8Dh, not the DESFire 3Dh. Each command carries FileNo ‖ Offset (3, LE) ‖ Length (3, LE) ‖ data; data longer than chunkSize (default 128 bytes, at most 248 so the command stays a short APDU) is sent as consecutive WriteData commands at advancing offsets. Each must be answered 9100; the first other status stops the write (desfireError for 91xx, unexpectedStatusWord otherwise) and chunks already acknowledged stay written. A file number above 1Fh, empty data, an offset or end beyond 24 bits, or a chunk size outside 1...248 throws invalidParameter before anything is sent. MAC / Full writes, ChangeFileSettings (SDM provisioning) and EV2 command counters are not implemented.

      +

      NTAG424Commands sends NTAG 424 DNA native commands in the ISO 7816 wrapped form shared with DESFire (90 CMD 00 00 Lc data 00), in CommMode.Plain only, to the NDEF application D2760000850101, which must already be selected (iOS selects it when it is the first AID in select-identifiers, and a Type 4 NDEF read selects it too). getFileSettings(fileID:) (F5h) returns NTAG424FileSettings: fileType, fileOption, communicationMode (.plain, .mac, .full), accessRights (read, write, readWrite, change nibbles, each also as an AccessCondition: .key(n), .free, .never) and the 24-bit fileSize. When FileOption bit 6 is set, sdm holds SDMOptions, SDMAccessRights (metaRead bits 15-12, fileRead bits 11-8, counterRetrieval bits 3-0, sent least significant byte first, as in the AN12196 examples) and the offsets the datasheet makes present: uidOffset and readCounterOffset for plain mirroring (SDMMetaRead Eh), piccDataOffset for encrypted PICC data (SDMMetaRead 0h-4h), tagTamperStatusOffset when SDMOptions bit 3 is set (NTAG 424 DNA TT only), macInputOffset and macOffset when SDMFileRead is not Fh, encryptedDataOffset / encryptedDataLength with SDMENCFileData, and readCounterLimit with SDMReadCtrLimit. The parser throws invalidResponse for truncated responses, trailing bytes (including the response MAC an authenticated session adds), a file type other than StandardData, and a reserved SDMMetaRead value.

      +

      writeDataPlain(fileID:offset:data:chunkSize:) uses the NTAG 424 WriteData command 8Dh, not the DESFire 3Dh. Each command carries FileNo ‖ Offset (3, LE) ‖ Length (3, LE) ‖ data; data longer than chunkSize (default 111 bytes, so each command fits one 128-byte ISO/IEC 14443-4 frame, the only unit the datasheet makes tearing-protected; at most 248, the datasheet limit per WriteData, in which case the reader chains frames and an interrupted chain can leave that chunk partially written) is sent as consecutive WriteData commands at advancing offsets. Each must be answered 9100; the first other status stops the write (desfireError for 91xx, unexpectedStatusWord otherwise) and chunks already acknowledged stay written. A file number above 1Fh, empty data, an offset or end beyond 24 bits, or a chunk size outside 1...248 throws invalidParameter before anything is sent. MAC / Full writes, ChangeFileSettings (SDM provisioning) and EV2 command counters are not implemented.

      +

      NTAG 424 DNA has DESFire's ATQA and SAK, so iOS can report it as a DESFire tag. Its GetApplicationIDs answers 91 1C; dumpCard then checks GetVersion, and when the hardware type is 04h (DESFireVersionInfo.ntagHardwareType) it dumps the tag as a Type 4 tag (CC and NDEF file) instead of failing.

      APDUs

      CommandAPDU encodes short APDUs, and switches to extended length automatically when the command data exceeds 255 bytes or more than 256 response bytes are expected (Nc up to 65535, Ne up to 65536). APDUExchange.transmit(_:maxGetResponseFrames:retryOnWrongLength:using:) resolves 6Cxx (re-issue with the corrected Le) and 61xx (GET RESPONSE chaining). It throws NFCError.responseChainTooLong(maxFrames:) after defaultMaxGetResponseFrames (256) follow-ups.

      Type 4