この記事はレコチョク Advent Calendar 2025 の1日目の記事となります。
はじめに
こんにちは、永田です。 株式会社レコチョクでiOSアプリ開発をしています。
今年はthe cabsが再結成したり凛として時雨が武道館公演を開催したりなど、個人的には激アツの1年でした。 両バンド共、今後も末長く活動してほしいです。
さて、レコチョクは今年PlayPASS PAKというサービスをリリースしました。 NFCタグが内蔵されたグッズをスマートフォンにかざすことで、音楽、映像、フォトなどの多様なデジタルコンテンツを楽しめるサービスです。
このサービスではNTAG215というNFCタグを採用しています。 このタグからデータを読み出す方法を調査していたところ、ちょっと厄介な問題に遭遇しました。 今回はこの問題の詳細と、それをどう乗り越えたのかをご紹介します。
この記事のゴール
この記事を読むことで、次のことが理解できるようになります。
- パスワード保護されたNFCタグ(NTAG215)からデータを読み出す方法
- Core NFCの高レベルAPI(
readNDEF())で対応できない場合、低レベルAPI(MIFAREコマンド)を使うこと - NFCタグのデータ構造とMIFAREコマンドの基礎知識
NTAG215以外のタグを扱いたい場合でも、参考にできるような洞察を得られることを目指しています。
背景: 通常のNFC読み取りとパスワード保護による問題
通常、Core NFCを使ってNFCタグからデータを読み取る場合、NFCNDEFReaderSessionを使って次のような流れで実装します。
import CoreNFC
// 通常のNFCタグ読み取り(パスワードなしの場合)
let session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: false)
session.begin()
// NFCNDEFReaderSessionDelegateメソッド内でタグ検出後
func readerSession(
_ session: NFCNDEFReaderSession,
didDetect tags: [NFCNDEFTag]
) {
guard let tag = tags.first else {
return
}
Task {
do {
// タグに接続
try await session.connect(to: tag)
// NDEFメッセージを読み取る
let message = try await tag.readNDEF()
// messageを使って処理
session.invalidate()
} catch {
session.invalidate(errorMessage: "読み取りに失敗しました")
}
}
}
しかし、PlayPASS PAKで扱うタグにはデータを保護するためのパスワードが設定されています。 そのため、上記のコードだとパスワード認証ができていないため、読み取りに失敗してしまいます。
そこで、まずはパスワード認証の方法を調査することにしました。
基礎知識
パスワード認証を実装する前に、NDEFとMIFAREについて簡単に説明しておきます。
NDEF(NFC Data Exchange Format)
NDEF(NFC Data Exchange Format) は、NFCタグとリーダ/ライタの間で情報をやり取りするための標準化されたデータ形式です。
NDEFのデータ構造は次のようになっています。
- NDEF Record: テキスト、URLなどのデータを記録する最小単位
- NDEF Message: 1〜n個のRecordを集約するコンテナ
- 1つのNFCタグ内に0〜n個のNDEF Messageを保持可能
Core NFCでは、NDEFのデータ構造に対応したクラスが用意されています。
NFCNDEFMessage: NDEF MessageNFCNDEFPayload: NDEF Record
通常、readNDEF()メソッドを使うことで簡単にNDEF Messageを読み取ることができます。
MIFARE
MIFARE®(以下、MIFAREと表記)は、NFC Type-Aを拡張した上位規格で、NXP Semiconductorsが開発したものです。今回使用するNTAG215は、このMIFARE規格に準拠しています。
MIFAREでは、タグとの通信にMIFAREコマンドを使用します。MIFAREコマンドは「1バイトのコマンドコード + コマンドデータ」という形式で構成されています。
例えば、パスワード認証なら0x1B + パスワード4バイト、ページ読み込みなら0x30 + ページ番号1バイトといった形です。
詳細はNTAG215のデータシートを参照してください。
Core NFCでは、NFCMiFareTagというプロトコルを通じて、MIFAREコマンドを直接送信できます。
このコマンドを使うことで、パスワード認証やメモリの読み書きなど、低レベルの操作が可能になります。
パスワード認証の実装
それでは、MIFAREコマンドを使ってパスワード認証を実装していきましょう。
名前空間の定義
まず、NFC関連のコードを整理するための名前空間を定義します。
import Foundation
import CoreNFC
enum NFC {
enum MiFare {
}
}
この名前空間を使えば、NFC関連の型をNFC.MiFare.Passwordのような階層構造で管理できます。
MIFAREコマンドの定義
次に、MIFAREコマンドを送信するためのプロトコルを定義します。
extension NFC.MiFare {
protocol Requestable {
var commandCode: UInt8 { get }
var commandData: Data { get }
}
}
extension NFC.MiFare.Requestable {
var commandPacket: Data {
.init([commandCode]) + commandData
}
}
このプロトコルに準拠した構造体を作成することで、各種MIFAREコマンドを実装していきます。
パスワード認証コマンド
パスワード認証のコマンドを実装します。NTAG215では、コマンドコード0x1B(PWD_AUTH)にパスワード4バイトを続けて送信することで認証します。
extension NFC.MiFare {
struct AuthenticationRequest: Requestable {
var commandCode: UInt8 {
0x1B
}
var commandData: Data {
password.data
}
let password: Password
}
enum Password {
case pin(String)
case binary(Data)
var data: Data {
switch self {
case let .pin(pin):
.init(pin.utf8)
case let .binary(data):
data
}
}
}
}
extension NFC.MiFare.Requestable where Self == NFC.MiFare.AuthenticationRequest {
static func authenticate(password: NFC.MiFare.Password) -> Self {
.init(password: password)
}
}
NFCMiFareTagのextension
Requestableプロトコルを使い、MIFAREコマンドを簡単に送信できるようextensionを実装します。
extension NFCMiFareTag {
@discardableResult
func send(request: any NFC.MiFare.Requestable) async throws -> Data {
try await sendMiFareCommand(commandPacket: request.commandPacket)
}
}
このextensionと先ほど定義したRequestableのextensionを組み合わせることで、型安全にMIFAREコマンドを送信できます。
たとえば、tag.send(request: .authenticate(password: password))のように呼び出せます。
認証に失敗した場合、sendMiFareCommandメソッドでErrorがthrowされます。
認証の実行
実際に認証するコードは次のようになります。NFCTagReaderSessionDelegateのtagReaderSession(_:didDetect:)メソッド内で実装します。
func tagReaderSession(
_ session: NFCTagReaderSession,
didDetect tags: [NFCTag]
) {
Task {
do {
guard
let tag = tags.first,
case let .miFare(miFareTag) = tag
else {
throw NSError(
domain: "NFCError",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "MIFAREタグではありません"]
)
}
try await session.connect(to: tag)
// パスワード認証(ダミーのパスワード)
try await miFareTag.send(request: .authenticate(password: .binary(.init([0x00, 0x00, 0x00, 0x00]))))
// 認証成功後の処理...
} catch {
session.invalidate(errorMessage: "エラーが発生しました")
}
}
}
これで、パスワード認証の実装が完了しました。次は、認証後にreadNDEF()メソッドでデータを読み取ってみましょう。
readNDEF()での読み取り試行
パスワード認証が成功したので、通常通りreadNDEF()メソッドでNDEF Messageを読み取れるはずです。
次のようなコードで試してみます。
func tagReaderSession(
_ session: NFCTagReaderSession,
didDetect tags: [NFCTag]
) {
Task {
do {
guard
let tag = tags.first,
case let .miFare(miFareTag) = tag
else {
throw NSError(
domain: "NFCError",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "MIFAREタグではありません"]
)
}
try await session.connect(to: tag)
// パスワード認証(ダミーのパスワード)
try await miFareTag.send(request: .authenticate(password: .binary(.init([0x00, 0x00, 0x00, 0x00]))))
// 認証成功後、readNDEF()を呼び出す
let message = try await tag.readNDEF()
} catch {
session.invalidate(errorMessage: "エラーが発生しました")
}
}
}
問題の発覚
しかし、実際に実行してみると、readNDEF()が失敗してエラーがthrowされてしまいました。
パスワード認証自体は成功しているのに、なぜreadNDEF()が使えないのでしょうか?
Core NFCの公式ドキュメントを確認しても、パスワード保護されたタグに関する制限は明記されていません。
また、調べてもほぼ前例が見つからず、途方に暮れました。
実際に試した結果からの推測ですが、Core NFCの高レベルAPI(readNDEF())はパスワード保護されたNFCタグの読み取りに対応していないようです。
そこで、低レベルAPI(MIFAREコマンド)を使ってUser Memoryを直接読み取り、自分でバイナリデータをパースしてNDEF Messageを生成する方針に切り替えました。
NTAG215の技術仕様
User Memoryを直接読み取るには、NTAG215のデータ構造を理解しておく必要があります。 ここからは、NTAG215の技術仕様について深掘りしていきます。
NTAG215とは
NTAG215は、NXP Semiconductors製のNFCタグです。NFC Type-Aベースで、次のような特徴があります。
- 容量: 540バイト(4バイト/ページ × 135ページ)
- パスワード保護: 4バイトのバイナリでパスワード保護が可能
- 用途: 任天堂のamiiboなどにも使用されている
メモリ構造
NTAG215のメモリは、ページアドレスごとに次のように構成されています。
| ページアドレス | 内容 |
|---|---|
| 0x00-0x02 | Manufacturer data and static lock bytes |
| 0x03 | Capability Container (CC) |
| 0x04-0x81 | User Memory (504バイト) ← データ保存領域 |
| 0x82 | Dynamic lock bytes |
| 0x83-0x86 | Configuration pages |
この中で、User Memory領域(0x04〜0x81ページ) がデータを保存する場所です。パスワードは0x85ページに4バイトで格納されています。
User MemoryのTLV形式
User Memory領域には、TLV(Tag-Length-Value)形式 でデータが並んでいます。
TLV形式とは、次の3つの要素でデータを表現する形式です。
- T (Tag): データ種別(1バイト)
- L (Length): Valueのバイト長(1バイト)
- V (Value): データ本体(Lengthバイト分)
TLVの種別
TLVには、次のような種別があります。
| Tag | 名前 | 説明 |
|---|---|---|
0x00 |
NULL TLV | 空のTLV |
0x01 |
Lock control TLV | ロック制御情報 |
0x02 |
Memory control TLV | メモリ制御情報 |
0x03 |
NDEF message TLV | 目的のデータ |
0xFD |
Proprietary TLV | 独自データ |
0xFE |
Terminator TLV | 終端(これ以降TLVなし) |
私たちが読み取りたいNDEF Messageは、Tag = 0x03のTLV に格納されています。
User Memoryの直接読み取り実装
主要なMIFAREコマンド
User Memoryを読み取るために、次のMIFAREコマンドを使用します。
| コマンド | コード | 説明 |
|---|---|---|
| READ | 0x30 |
指定ページから4ページ分読み出し |
| FAST_READ | 0x3A |
指定区間のページを読み出し |
パスワード認証で使用したPWD_AUTH(0x1B)と同様に、これらのコマンドもRequestableプロトコルに準拠した構造体として実装します。
なお、厳密にはタグの種類を確認するためにGET_VERSION(0x60)コマンドでNTAG215かどうかを判定すべきですが、今回の実装では省略しています。
extension NFC.MiFare {
struct ReadRequest: Requestable {
var commandCode: UInt8 {
0x30
}
var commandData: Data {
.init([pageAddress])
}
let pageAddress: UInt8
}
struct FastReadRequest: Requestable {
var commandCode: UInt8 {
0x3a
}
var commandData: Data {
.init([startPageAddress, endPageAddress])
}
let startPageAddress: UInt8
let endPageAddress: UInt8
}
}
extension NFC.MiFare.Requestable where Self == NFC.MiFare.ReadRequest {
static func read(pageAddress: UInt8) -> Self {
.init(pageAddress: pageAddress)
}
}
extension NFC.MiFare.Requestable where Self == NFC.MiFare.FastReadRequest {
static func fastRead(
startPageAddress: UInt8,
endPageAddress: UInt8
) -> Self {
.init(
startPageAddress: startPageAddress,
endPageAddress: endPageAddress
)
}
}
ReadRequestは指定したページアドレスから4ページ分を読み出すコマンドです。
FastReadRequestは開始ページと終了ページを指定して、その範囲のページをすべて読み出すコマンドです。
User Memoryの読み取り
User Memory領域を読み取る関数を実装します。 NTAG215のUser Memory領域は0x04〜0x81ページに配置されています。
次の関数を定義します。
func readUserMemory(from tag: NFCMiFareTag) async throws -> Data {
let startPageAddress: UInt8 = 0x04
let endPageAddress: UInt8 = 0x81
let pagesPerRequest = 4
var userMemory = Data()
for pageAddress in stride(
from: startPageAddress,
through: endPageAddress,
by: pagesPerRequest
) {
// 末尾はFAST_READで読み取る
let request: any NFC.MiFare.Requestable = if pageAddress + UInt8(pagesPerRequest) [NFC.TLV] {
var tlvList = [NFC.TLV]()
var copy = userMemory
while !copy.isEmpty {
guard let tag = NFC.TLV.Tag(rawValue: copy.removeFirst()) else {
break
}
// null TLVとTerminator TLVはLength/Valueを持たない
let length = switch tag {
case .null, .terminator:
0
default:
Int(copy.removeFirst())
}
let value = copy.prefix(length)
copy.removeFirst(length)
tlvList.append(.init(tag: tag, length: UInt8(length), value: value))
if case .terminator = tag {
break
}
}
return tlvList
}
このパーサーは、バイナリデータを先頭から順に読み込み、TLVの配列を生成します。
Terminator TLV(0xFE)が出現した時点でパースを終了します。
NDEF Messageの生成
TLVリストからNDEF Message TLV(Tag = 0x03)を抽出し、NFCNDEFMessageオブジェクトを生成します。
次の関数で、User Memoryの読み取りとパースを統合します。
func readMessages(from tag: NFCMiFareTag) async throws -> [NFCNDEFMessage] {
let userMemory = try await readUserMemory(from: tag)
let tlvList = parseUserMemory(userMemory)
return tlvList
.filter {
$0.tag == .ndefMessage
}
.compactMap {
NFCNDEFMessage(data: $0.value)
}
}
ここでのポイントは、NDEF Message TLVのValueをそのままNFCNDEFMessage(data:)に渡すことです。
Core NFCが内部でNDEFのバイナリデータをパースしてくれるため、TLVのValue部分だけを渡せば良いのです。
最終的な実装
最後に、これまでのステップを統合して、実際のタグ読み取り処理を実装します。NFCTagReaderSessionを使ってタグを検出し、処理を進めます。
final class MyNFCReader: NSObject, NFCTagReaderSessionDelegate {
func startReading() {
let session = NFCTagReaderSession(
pollingOption: .iso14443,
delegate: self
)
session?.begin()
}
func tagReaderSession(
_ session: NFCTagReaderSession,
didDetect tags: [NFCTag]
) {
Task {
do {
guard
let tag = tags.first,
case let .miFare(miFareTag) = tag
else {
throw NSError(
domain: "NFCError",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "MIFAREタグではありません"]
)
}
try await session.connect(to: tag)
// パスワード認証(ダミーのパスワード)
try await miFareTag.send(request: .authenticate(password: .binary(.init([0x00, 0x00, 0x00, 0x00]))))
// NDEF Messageを読み取る
let messages = try await readMessages(from: miFareTag)
// messagesを使った処理...
session.invalidate()
} catch {
session.invalidate(errorMessage: "エラーが発生しました")
}
}
}
}
この実装により、パスワード保護されたNTAG215からNDEF Messageを読み取ることができるようになりました。
実装のポイント
今回の実装で重要だったポイントをまとめます。
- MIFAREコマンドを直接使う: Core NFCの低レベルAPI(
sendMiFareCommand)を活用 - TLV形式の理解: User Memoryのデータ構造を正しく理解することが鍵
- バイナリデータのパース: TLVをパースし、NDEF Message TLVのValueを抽出
- Core NFCとの連携: パース後は
NFCNDEFMessage(data:)に渡すだけ
振り返り・まとめ
学んだこと
今回の実装を通じて、次のことを学びました。
Core NFCのAPIの使い分け
- Core NFCは非常に便利なフレームワークで、高レベルAPI(
readNDEF())を使えば簡単にNDEFデータを読み取れる - しかし、パスワード保護されたタグなど特殊なケースでは、低レベルAPI(MIFAREコマンド)を使う必要がある
- 低レベルAPIを使えば、より細かい制御が可能になり、入り組んだ要件にも対応できる
データ構造を理解することの重要性
- TLV形式、User Memory、ページアドレスなど、NFCタグのデータ構造を理解することが重要
- 仕様書を読むことで、低レベルAPIを使った柔軟な制御が可能になる
- データ構造の理解は、NTAG215以外のNFCタグにも応用できる
MIFAREコマンドの活用
- MIFAREコマンドを直接送信することで、より細かい制御が可能になる
- NTAG215以外のNFCタグにも応用できる可能性がある
- 仕様書を読み解く力が、低レベルAPIを活用する鍵となる
注意点・今後の課題
実装にあたって、次の点に注意が必要です。
パスワードの難読化
- 今回の実装例では、パスワードをコード内にハードコーディングしているが、実際のアプリではセキュリティ上の問題がある
- パスワードの難読化や、サーバから取得するなどの対策が必要である
他のNFCタグへの対応
- NTAG215以外のタグ(NTAG213、NTAG216など)にも応用できるが、タグごとにメモリ構造は異なるためページアドレス等の調整が必須
- MIFAREに準拠していないタグについては本記事で紹介した手法では対応できない
終わりに
仕様書を読み解くのは大変でしたが、NFCタグのデータ構造やMIFAREコマンドについて深く理解できたのは新鮮で楽しかったです。 Core NFCの低レベルな実装については実例が少ないため、この記事が誰かの参考になれば幸いです。
最後に、レコチョクでは技術的なチャレンジを一緒に推進していく仲間を募集しています。 気になる方は是非採用ページをご覧ください。
明日の レコチョク Advent Calendar 2025 は2日目「Jetpack Compose Runtime APIを使って宣言的に画像を処理する」です。お楽しみに!
参考文献
- Core NFC | Apple Developer Documentation
- NTAG213/215/216 NFC Forum Type 2 Tag compliant IC with 144/504/888 bytes user memory
- NFC Data Exchange Format (NDEF) Technical Specification
- AN3408 Application note Using LRIxx, LRISxx, M24LRxx-R and M24LRxxE-R products as NFC vicinity tags
- 【NFC】NDEFについて理解する #NFC – Qiita
永田駿平
iOSアプリを作っています
音楽とガジェットが好きです