半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

iOS

如何用 CoreNFC 讀取 SUICA 資訊(FeliCa)

如何用 CoreNFC 讀取 SUICA 資訊(FeliCa)

前言

iOS 11.0 開始可以透過 CoreNFC 來讀寫 NFC Tag,不過還無法讀取 IC 卡片之類的資訊,到了 iOS 13 後才開放 IC 卡讀取。

剛好一直以來對 NFC 蠻有興趣的,而且也想要自己讀取 Suica (日本交通 IC 卡)的資訊,以後就可以直接用手機看餘額了(我知道已經有 app 做到這件事了)不過在網路上關於 Suica 讀取的中文資源比較少,所以花了幾天研究了一下 FeliCa 文件,並且用 CoreNFC 實作。

這篇文章會先從 NFC 協定開始講起,再談到日本交通 IC 卡中廣泛使用的 FeliCa,最後是 swift 中的實作。不過我的 swift 也才剛起步,可能會有寫得不是很好的地方。

什麼是 NFC?

NFC(Near Field Communication)是個近距離溝通的協定,同時也是 RFID 無線通信技術。

這個協定主要規範了:

  • 溝通協定:發送器與接收器間如何溝通
  • 資料交換:兩者如何交換資料

本篇內容會限縮在 FeliCa 的資料讀取過程。

NFC 解決的問題

在無線通信上,我們可以利用藍牙、wifi 等等來做溝通,但最大的問題在於安全性以及配對。

在藍牙上可能需要先藍芽配對後才能彼此溝通,設定上比較繁瑣一些,如果讀取機可以在 20 公尺遠的地方就能夠偵測卡片資訊,或者是直接要求付款,在安全性上就有很大的問題。

因此在 NFC 當中,偵測距離通常都在幾公分之內,除了確保安全性之外,也可以減少雜訊干擾。

FeliCa

Felica 是由 Sony 在 2001 年開發的非接觸式 IC 卡技術。跟 NFC Type-A 與 Type-B 比起來更加快速,原因可能是日本可怕的通勤人潮吧。

順帶一提,台灣的悠遊卡是由飛利浦公司設計的 Mifare 製造的 IC 卡,同時 Mifare 也是目前全球廣泛使用的非接觸式 IC 卡片,目前似乎只有日本廣泛使用 FeliCa。

FeliCa 的速度真的很快,在日本如果有搭乘大眾運輸工具的話應該會發現進入月台完全不用停下來。目前廣泛使用的交通 IC 卡像是 Suica、ICOCA、はやかけん 等等都是採用 FeliCa 技術,除了交通之外,在便利超商付款上也常常利用交通 IC 卡來付款。

資料安全性

在 NFC 當中有些資料可以讀取、有些資料不行,有些卡片需要正確的金鑰解密後才能夠讀寫資料。

雖然說 Android 跟 iOS 現在都已經可以讀取 NFC 卡片了,但如果要篡改餘額之類的資料,必須要有正確的金鑰才行。

FeliCa 架構

在 FeliCa 的架構中,主要可以分為兩大類:私有領域(プライベート領域)跟共通領域。

共通領域存放一些可供讀取的資訊,而私有領域則存放一些像是個人資料或控制餘額等的訊息,必須要透過加解密認證才能夠操作。

而在共通領域又可以分為幾個部分:

  • System:表示卡片的單位。每張卡片都會有個 system code(2 byte),根據日本 JIS 6319-4 規定,這個值會在 FE00 ~ AA00 之間。像 Suica 之類的卡片就是 0003(這個值很重要,等下會出現)
  • Area:包含了像是儲存空間、每個 service 存放的 block 數等等的資訊。
  • Service:將資料以 blocks 的方式儲存讓外部訪問。每個 service 中會有 service code(2 bytes),例如出入場紀錄的 service code 就是 090f。service 又分為 random service、cyclic service、pass service
  • Block:資料儲存的地方,每個 block 都是 16bytes,根據 service 不同需要的 blocks 數也會不一樣。

單純讀取資訊的話,比較會碰到的是 service 與 block 這兩部分。

Service 種類

前面有提到 service 又分為 random、cyclic、pass,主要是以 data access 的方式區分。

  • Random Service:可以自由讀寫,由製造商決定的資料
  • Cyclic Service:記錄像是歷史資料的地方。
  • Pass Service:管理像是餘額、加扣款的地方

指令 Command

在 FeliCa 當中有很多種指令,可以在 FeliCa 的文件當中找到,要讀取 FeliCa 資料的話需要幾個指令:

  • polling
  • request service
  • Read without encryption

每個指令都有一個對應的 request pocket,規範了 request 當中應該要有哪些內容,這部分在 CoreNFC 中已經提供好對應的函數可供使用。

與 FeliCa 溝通的流程

  1. 透過 polling command 來捕捉卡片。
  2. 使用 Request Service 指令,並且傳入 service code list,卡片會確認 service code 是否正確或可以讀取。如果 service 不存在或是錯誤,會回傳 0xFFFF
  3. 使用 read without encryption 指令,並傳入 service code,會將對應的資料以(blocks) 回傳。(service 最多 16 個)

    1. 這個指令會回傳 status1 跟 status2,如果都是 0 表示沒問題。
    2. 如果不是 0 的話代表有錯誤,錯誤的代碼狀態蠻多的就不一一列出。

iOS 實作

關於如何讀取 NFC Tag,可以參考 apple developer 上的範例,這邊主要會專注在如何讀取 FeliCaTag

要實作 NFC,首先必須要有一台實體的 iPhone,在模擬器上並沒有辦法讀取 NFC。

能不能讀取 NFC,可以透過 NFCTagReaderSession.isReadingAvailable 來判斷。

首先參考 CoreNFC 的文件,加入對應的 info.plist 還有 entitlement。

1. Capabilities 加入 Near Field Communication Tag Reading

ios-capability

2. 在 Info.plist 中加入 ISO18092 system codes for NFC Tag Reader Session

plist

  • 對應到剛剛的 system code,如果要讀取 Suica 之類的卡片就是 0003
  • 加入 Privacy - NFC Scan Usage Description

CoreNFC 實作

NFCTagReaderSession 當中,polling、requestService、readWithoutEncryption 都已經有對應的函數包好了,不需要辛辛苦苦塞一堆數字跟理解 command。

不過大致上的流程還是要理解一下:

  1. 建立 NFCTagReadersession 並且設定 pollingOption 跟 delegate
  2. 呼叫 session.begin()
  3. 在讀取到卡片後 session 會呼叫對應的 delegate 方法
  4. func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) 函數中實作讀取邏輯。

    1. 在這裡頭呼叫 requestService
    2. 確認 service 回應後,呼叫 readWithoutEncryption

NFCTagReaderSession

要讓 class 可以具有讀取 NFC 的功能,首先要實作 NFCTagReaderSessionDelegate

public protocol NFCTagReaderSessionDelegate : NSObjectProtocol {
    // 當 session 可以開始讀取 NFC Tag 時呼叫
    func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession)

    // 當呼叫 session invalidate 時呼叫
    func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error)

  	// 當 session 偵測到 tag 時呼叫
    func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag])
}

在 apple developer 上可以找到目前有的 tag type,主流的 MiFare 或是 FeliCa 都有。

Block List

block list 是指要讀取 service 中 block 的哪些部分,在 readWithoutEncryption 的時候需要 blockList 當作參數,關於 blockList 中每個 bit 的定義,可以參考文件上的說明,在 CoreNFC 中你可以這樣子寫:

let blockList = (0..<UInt8(10)).map { Data([0x80, $0]) }

這邊的 10 代表回傳 10 個 block。

FeliCa Reader

要讀取 FeliCa 的資料,首先要先知道對應的 service code 是多少,乘車歷史的 service code 或是卡片餘額的 service code。在這個網站(日文)中可以找到:

System Code 是 0003 的情況下:

  • service code: 008B (1 block) 紀錄卡片類別跟餘額
  • service code: 090F(20 block)乘車歷史紀錄(20 筆)
  • service code: 108F(3 block)中途換車的歷史紀錄(3 筆)

其中對我來說乘車歷史紀錄跟餘額應該是最感興趣的資料,來看看這個 block 的內容記錄了哪些資訊:

一個 block 有 16 byte,每個 byte 都中都存放著對應的資料,以下根據網站中的文件列出幾個,數字代表第幾個 byte,括號代表這個資料的長度佔用幾個 byte。

  • 0 (1 byte):機器類別
  • 1 (1 byte):使用類別(精算、新規、自動加值等)
  • 2(1 byte):付款方式(信用卡、mobile 等)
  • 3(1 byte):出入場類別(入場、出場等)
  • 4 ~ 5 (2 byte):年月日(年 7 bit、月 4 bit、日 5 bit),年是用 2000 年當作基準,所以 19 代表 2019 年。
  • 6 ~ 9 (4 byte):出入場的車站代號、販賣機資訊
  • A ~ B (2 byte):餘額(Little Endian)
  • C:不明
  • F:地方代號(關東私鐵、中部私鐵、沖繩私鐵等)

知道這些訊息之後,我們就可以在 for data in dataList 讀取資料了!例如出入場的時間可以:

let year = data[4] >> 1 // 不知道為什麼要右移一個才是正確的
let month = UInt(bytes: data[4...5]) >> 5 & 0b1111 // 取得 month 的 bit
let date = data[5] & 0b11111 // 取得 date 的 bit

let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.date(from: "20\(year)-\(month)-\(date)")!

Demo

github 上可以看到原始碼。

後記

雖然成功讀取到了資訊,但有些數字跟我想像中的不太一樣,像是車站代碼上網找了好久還是找不到,不知道解析出來的車站代碼是否正確。

另外雖然說歷史紀錄可以拿到 20 筆,但有些卡片讀取上會因此失敗,比較安全的範圍是 10。(還在測試中)

不過看了一下裡頭的紀錄(我的卡片是定期票),好像不是每筆出入站都會被記錄。如果想要用卡片來搜集自己的進出站資料的話恐怕沒那麼好用(例如視覺化等)。

關於 FeliCa 讀取,在 iOS 上已經有日本開發者寫出一套還蠻好用的 library TRETJapanNFCReader,如果想要直接使用的話可以參考看看,除了 SUICA 之外他還支援了蠻多類型的 IC 卡,甚至連駕照都可以讀取。

當初看到 CoreNFC 這個 SDK 就想要來試試看讀取 FeliCa 資訊了,不過網路上沒有太多相關中文資源,日文的實作倒是蠻多的,只是看到 service code,blockList 什麼的還是不知所以然,所以花了一天的時間看完了 FeliCa 的文件並且試著實作出來。

另外在 swift 的型別轉換上蠻麻煩,像是把 Data 轉換成 UInt 等等,從網路上找來了這段 code,但看不太懂他在幹嘛 XD,只知道我可以用 UInt(bytes: Data) 的方式作轉換。

import Foundation

extension FixedWidthInteger {
    init(bytes: UInt8...) {
        self.init(bytes: bytes)
    }

    init<T: DataProtocol>(bytes: T) {
        let count = bytes.count - 1
        self = bytes.enumerated().reduce(into: 0) { (result, item) in
            result += Self(item.element) << (8 * (count - item.offset))
        }
    }
}

學習 iOS 開發跟 swift 的時間還不長,因此可能不知道一些慣用手法或開發方式,寫得有點亂七八糟的。