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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,16 @@ 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 |
| eMRTD / ePassport | yes | yes | no | no |
| 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).
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
150 changes: 150 additions & 0 deletions Sources/CoreExtendedNFC/Cards/NTAG424/NTAG424Commands.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Loading
Loading