Android NFC Demo (2) - Card Emulator
Overview
我們想通過手機來模擬成智慧卡(Smart Card),很多情況下,都是通過裝置上的稱為 Secure Element (以下簡稱 SE)的安全晶片來模擬的,譬如很多運營商提供的 SIM 卡中也會內嵌有這樣的 SE 晶片(例如:中國移動和銀聯推的 NFC-SIM 卡-雲閃付)。SE 晶片一般在裝置出廠前就已經內嵌在板子上了,無法替換,並且 SE 上的系統主要負責處理安全支付方面的工作。
通過下面的使用 SE 來模擬 Card 的結構圖,可以看到,這裡的 transactions 都是由 SE 晶片直接和 NFC 讀卡器進行通訊和互動的,而不需要其他 Android 應用的參與,在 transaction 完成之後,Android 應用可以查詢狀態並通知使用者:

而我們這裡是想用另外一種不依賴於 SE 晶片的方案來模擬 Card,也就是基於主機的卡模擬(Host-based Card Emulator),在此方案下,互動的資料被直接路由到主機 CPU 上,而不是 SE 晶片上,這樣直接執行在主機 CPU 上的 Android 應用就可以與 NFC 讀卡器“直接”互動了,如下圖:

HCE 支援的卡和協議棧
NFC 標準提供了對許多不同協議的支援,以及許多可以被模擬的卡型別。
從 Android 4.4 開始就已經支援目前市場上的一些常見型別的卡,如非接觸式的支付卡,下圖為 Android HCE 支援的協議棧:

上圖列出了協議棧的分層,以及各層基於的規範。
目前市場上的許多NFC讀卡器也都支援這些協議,包括作為讀卡器的Android NFC裝置。這樣我們就可以基於 HCE 方案僅使用 Android 裝置來構建和部署端到端NFC解決方案。
另外,Android 4.4 主要支援模擬的是基於 NFC-Forum ISO-DEP 規範(ISO/IEC 14443-4)以及處理定義於 ISO/IEC 7816-4 規範中的 APDU(Application Protocol Data Units)協議資料。Android 目前要求僅在 Nfc-A (ISO/IEC 14443-3 Type A) 技術之上模擬 ISO-DEP,對於 Nfc-B (ISO/IEC 14443-3 Type B) 技術的支援是可選的。
HCE Service
在 Android 中 HCE 架構是基於 Service 元件的(也就是 HCE services),這樣可以在後臺執行,
在 ISO/IEC 7816-4 規範中定義了一種選擇應用的方式,即以 Application ID(AID)為中心,AID 由最多 16 個位元組組成的標識,如果要為現有的NFC讀卡器設施模擬卡片,那些 reader 尋找的AID通常都是 well-known 並且是公開註冊的,例如:支付領域的 Visa, MasterCard 的 AID。
如果你需要部署自己應用的新讀卡器設施,就需要註冊自己的 AID,註冊過程參考 ISO/IEC 7816-5 規範。
關於 AID 組
有時,HCE 服務可能需要註冊多個 AID 來實現某個應用程式,並且需要確保它是所有這些 AID 的預設處理程式(而不是這個 AID 組中的某些 AID 是另外的 HCE 服務來處理)。
一個組中的所有 AID 要麼都被路由到 HCE 服務,要麼所有 AID 都不會被路由到 HCE 服務。
每一個 AID 組都可以被關聯到某個分類,從 Android 4.4 開始支援兩個分類,分別為: CATEGORY_PAYMENT 和 CATEGORY_OTHER ,關於分類,還有些規則和限制:
- 在任何給定時間,系統中只能啟用 CATEGORY_PAYMENT 類別中的一個AID組。
- 對於僅在某個指定商家使用的卡,應該設定為 CATEGORY_OTHER 。此類別中的AID組可以始終處於活動狀態,並且必要時可以在 AID 選擇期間由 NFC 讀卡器給予優先順序。
實現 HCE 服務
為了能夠使用 HCE 模式來模擬 NFC 卡,需要實現一個 HostApduService 介面的 Service 元件來處理 NFC 互動 transactions。
public class MyHostApduService extends HostApduService { @Override public byte[] processCommandApdu(byte[] apdu, Bundle extras) { ... } @Override public void onDeactivated(int reason) { ... } }
在 HostApduService 中聲明瞭兩個需要過載和實現的抽象方法:
processCommandApdu()
NFC 讀卡器傳送 APDU 到服務時呼叫此方法。APDU 定義在 ISO/IEC 7816-4 規範中,APDU 是 NFC 讀卡器 和 HCE 服務之間傳遞資料的應用層協議單元,並且該協議是半雙工的,即 NFC 讀卡器在傳送一個 APDU 命令後,會同步等待 HCE 服務返回一個 APDU 的響應。
APDU
下圖為 APDU 協議的 command-response 結構,主要由 Header 和 Body 兩個部分組成,並且 Body 部分由 Lc, Data 和 Le 組成。

-
CLA : Class byte, 表示命令的類別,佔 1 個位元組,8 個 bit 位的使用有一定的規範,如:Bit 8 用於區分產業還是專有分類(詳細定義參考 ISO/IEC 7816-4 5.1.1 );
-
INS : Instruction byte,表示處理的指令,佔 1 個位元組,不同指令表示不同的意義,如下圖定義:

- P1 & P2 : Parameter bytes,可用於指令的引數;
- Lc : 表示傳送的 command data 欄位的長度;
- Le :表示期待的 response data 欄位的長度;
- Command Data Field :表示實際的指令資料;
- Response Data Field :響應資料;
- SW1 & SW2 :Status bytes,根據規範,狀態碼均為 6xxx 和 9xxx,並且 60xx 也是無效的,其他的詳細規範參考 ISO/IEC 7816-3 ,下圖是規範定義狀態碼值結構圖:

互動處理
如前所述,Android 是通過 AID 來決定 讀卡器 需要互動的 HCE 服務。通常情況下, NFC 讀卡器傳送的第一條 APDU 就是 “選擇 AID",該條 APDU 中就包含了 讀卡器 希望與之互動的 AID,Android 從該 APDU 中解析出 AID 並轉發至相應的 HCE 服務。
應用層可以在 processCommandApdu 方法中返回響應 APDU,要注意的是,該方法會在主執行緒中被呼叫,因此不能被阻塞,可以在其他執行緒中做些複雜計算,並通過 sendResponseApdu() 方法來返回響應。
onDeactivated()
在此過程中,Android 會持續向你的 HCE 服務轉發新的 APDU,直到:
- NFC 讀卡器傳送了另外一個 “SELECT AID" APDU,並且解析到了其他的 HCE 服務;
- NFC 讀卡器和裝置之間的鏈路斷開;
上面兩種情形下 onDeactivated() 方法都會被呼叫,傳入的引數 reason 就是指明是什麼原因引起的。
示例
下面我們分別來寫兩個程式,並且分別安裝在兩臺不同的 Android 手機上:
- CardEmulatorSample : 用於模擬卡,主要實現 HCE 服務;
- CardReaderSample : 作為 NFC 讀卡器來讀取 CardEmulatorSample 中模擬的卡資訊;
CardEmulatorSample
實現卡模擬服務
如下 HostCardEmulatorService 派生於 HostApduService 並重載實現了 processCommandApdu 和 onDeactivated 方法。另外,我們根據規範自定義了一些常量,如:成功狀態,失敗狀態,AID 等等。
public class HostCardEmulatorService extends HostApduService { public static final String TAG = HostCardEmulatorService.class.getName(); // self-defined APDU public static final String STATUS_SUCCESS = "9000"; public static final String STATUS_FAILED = "6F00"; public static final String CLA_NOT_SUPPORTED = "6E00"; public static final String INS_NOT_SUPPORTED = "6D00"; public static final String AID = "A0000002471001"; public static final String SELECT_INS = "A4"; public static final String DEFAULT_CLA = "00"; public static final int MIN_APDU_LENGTH = 12; @Override public byte[] processCommandApdu(byte[] commandApdu, Bundle extras) { Toast.makeText(this, "processCommandApdu", Toast.LENGTH_LONG).show(); if (commandApdu == null) { return hexStringToByteArray(STATUS_SUCCESS); } final String hexCommandApdu = encodeHexString(commandApdu); if (hexCommandApdu.length() < MIN_APDU_LENGTH) { return hexStringToByteArray(STATUS_FAILED); } if (!hexCommandApdu.substring(0, 2).equals(DEFAULT_CLA)) { return hexStringToByteArray(CLA_NOT_SUPPORTED); } if (!hexCommandApdu.substring(2, 4).equals(SELECT_INS)) { return hexStringToByteArray(INS_NOT_SUPPORTED); } if (hexCommandApdu.substring(10, 24).equals(AID)){ return hexStringToByteArray(STATUS_SUCCESS); } else { return hexStringToByteArray(STATUS_FAILED); } } @Override public void onDeactivated(int reason) { Toast.makeText(this, "Deactivated - reason:" + reason, Toast.LENGTH_LONG).show(); } }
另外,這裡涉及到一些十六進位制和字串的轉換,都是用的如下三個方法:
public static String byteToHex(byte num, boolean upper) { char[] hexDigits = new char[2]; if (upper) { hexDigits[0] = Character.toUpperCase(Character.forDigit((num >> 4) & 0xF, 16)); hexDigits[1] = Character.toUpperCase(Character.forDigit((num & 0xF), 16)); } else { hexDigits[0] = Character.forDigit((num >> 4) & 0xF, 16); hexDigits[1] = Character.forDigit((num & 0xF), 16); } return new String(hexDigits); } public static String encodeHexString(byte[] byteArray, boolean upper) { StringBuilder hexStringBuffer = new StringBuilder(); for (byte aByteArray : byteArray) { hexStringBuffer.append(byteToHex(aByteArray, upper)); } return hexStringBuffer.toString(); } public static byte[] hexStringToByteArray(String s) { int len = s.length(); byte[] data = new byte[len / 2]; for (int i = 0; i < len; i += 2) { data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i+1), 16)); } return data; }
之後,我們還需要在 AndroidManifest.xml 中新增如下 service 配置項及相應的 Intent-Filter:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="io.github.stevenocean.cardemulatorsample"> <uses-permission android:name="android.permission.NFC" /> <uses-feature android:name="android.hardware.nfc.hce" android:required="true" /> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <service android:name=".HostCardEmulatorService" android:exported="true" android:permission="android.permission.BIND_NFC_SERVICE"> <intent-filter> <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" /> </intent-filter> <meta-data android:name="android.nfc.cardemulation.host_apdu_service" android:resource="@xml/apduservice" /> </service> </application> </manifest>
- android:name :啟動的 HostApduService 實現類,這裡為 HostCardEmulatorService;
- android:exported :這裡設定為 true,可以被其他應用訪問;
- android:permission :需要有繫結 NFC 服務的許可權;
- intent-filter :當系統檢測到有外部 NFC 讀卡器嘗試讀取卡資訊的時候,就會觸發一個 HOST_APDU_SERVICE 的 action,因此我們的服務需要註冊該 action;
- meta-data :為了讓系統能夠選擇正確 AID 的服務來呼叫,需要配置 AID 的資訊,這裡指向 apduservice.xml 檔案;
<?xml version="1.0" encoding="utf-8"?> <host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android" android:description="@string/servicedesc" android:requireDeviceUnlock="false"> <aid-group android:description="@string/aiddescription" android:category="other"> <aid-filter android:name="A0000002471001"/> </aid-group> </host-apdu-service>
這裡配置了 AID 組分類(為 CATEGORY_OTHER),以及組中的 AID(為 A0000002471001)。
實現 NFC 讀卡器
我們這裡的 NFC 讀卡器示例還是基於前一篇文章中的示例進行擴充套件,首先在 MainActivity 的 中新增對 IsoDep 的處理,其中 onTagDiscovered(Tag tag) 方法為 NfcAdapter.ReaderCallback 回撥,如下:
@Override protected void onResume() { super.onResume(); mNfcAdapter.enableReaderMode(this, this, NfcAdapter.FLAG_READER_NFC_A | NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, null); } @Override protected void onPause() { super.onPause(); mNfcAdapter.disableReaderMode(this); } @Override public void onTagDiscovered(Tag tag) { // Card response for IsoDep final StringBuilder cardResp = new StringBuilder("Card response: \n"); // read card data of CardEmulator IsoDep isoDep = IsoDep.get(tag); try { isoDep.connect(); byte [] resp = isoDep.transceive(hexStringToByteArray(DEFAULT_CLA + SELECT_INS + "0400" + LC + AID)); String respStatus = encodeHexString(resp, true); if (respStatus.equals(STATUS_SUCCESS)) { cardResp.append("Success response"); } else { cardResp.append("Failed response, code:").append(respStatus); } runOnUiThread(new Runnable() { @Override public void run() { mNfcInfoText.setText(cardResp.toString()); } }); } catch (IOException e) { e.printStackTrace(); } }
測試
分別在兩臺 Android 手機上安裝並開啟 CardEmulatorSample 和 CardReaderSample 程式,之後將兩臺手機靠近,可以在 CardReaderSample 中看到如下資訊:
