diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a8c96c..a67ee6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ 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, 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 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/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/Cards/NTAG424/NTAG424Commands.swift b/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424Commands.swift new file mode 100644 index 0000000..efb8993 --- /dev/null +++ b/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424Commands.swift @@ -0,0 +1,150 @@ +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: 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 + + /// 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. 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` + /// 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..0b168d6 --- /dev/null +++ b/Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424FileSettings.swift @@ -0,0 +1,286 @@ +import Foundation + +/// File settings of an NTAG 424 DNA file, parsed from a plain GetFileSettings (`F5h`) +/// response (NXP NT4H2421Gx datasheet, GetFileSettings; NT4H2421Tx for the +/// TagTamper variant). +/// +/// 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) +/// ‖ 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 +/// ‖ 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 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) + } + + /// 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. + /// `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. + /// 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? + /// 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? + 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 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 + 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, + tagTamperStatusOffset: tagTamperStatusOffset, + 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+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/Sources/CoreExtendedNFC/CoreExtendedNFC+NTAG424.swift b/Sources/CoreExtendedNFC/CoreExtendedNFC+NTAG424.swift new file mode 100644 index 0000000..1bbed8b --- /dev/null +++ b/Sources/CoreExtendedNFC/CoreExtendedNFC+NTAG424.swift @@ -0,0 +1,34 @@ +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. + /// + /// 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, + 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/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/Tests/CoreExtendedNFCTests/Cards/NTAG424Tests.swift b/Tests/CoreExtendedNFCTests/Cards/NTAG424Tests.swift new file mode 100644 index 0000000..f5503e0 --- /dev/null +++ b/Tests/CoreExtendedNFCTests/Cards/NTAG424Tests.swift @@ -0,0 +1,452 @@ +// 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.accessRights.counterRetrievalCondition == .free) + #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, 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 }) + } + + @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 `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])) + + #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..ab20615 --- /dev/null +++ b/Tests/CoreExtendedNFCTests/Vectors/NTAG424VectorTests.swift @@ -0,0 +1,139 @@ +// 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 `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() + 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..9beead4 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,10 @@

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), 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

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