PAYATHON 2026

两部手机能否分别作为NFC模拟卡和Reader POS?

支付老李

结论

可以。两部支持相应 NFC 功能的手机可以分别承担以下角色:

  • 手机 A:模拟一张基于 ISO-DEP(ISO/IEC 14443-4)的非接触卡;
  • 手机 B:进入 NFC Reader 模式,像 POS 一样发送 APDU 并接收响应。

在 Android 上,比较容易实现的方案是:

  • 手机 A 使用 HCE(Host Card Emulation)和 HostApduService;
  • 手机 B 使用 NfcAdapter.enableReaderMode() 发现模拟卡,再通过 IsoDep.transceive() 交换 APDU。

如果卡片逻辑必须运行在 Secure Element(SE)中,实现难度会高很多。普通第三方应用通常不能随意安装或管理 SE 内的 applet,也无法直接把一部手机变成可以受理银行卡的正式 EMV POS。支付场景还涉及 SE 访问权限、EMV 认证、收单机构、密钥管理和 PCI 等要求。

两种实现路线

使用 HCE 模拟卡

HCE 不会把卡片应用放进 Secure Element,而是由 Android 应用处理 APDU。它适合门禁原型、会员卡、票证、设备认证和自定义交易协议。

典型的通信过程是:

  1. Reader 手机轮询 NFC-A/NFC-B 设备。
  2. 模拟卡手机响应防冲突和协议激活。
  3. Reader 通过 ISO-DEP 发送 SELECT AID APDU。
  4. Android 根据 AID 启动对应的 HostApduService。
  5. 双方继续交换自定义 APDU。
  6. Reader 移开或链路中断后,会话结束。

对于两部手机之间的 NFC 交易开发和测试,这是最现实的方案。

使用 Secure Element 模拟卡

这种方案将 applet、密钥或敏感处理逻辑放在 eSE、UICC/SIM 或其他安全硬件中。理论上,另一部手机仍然可以把它当作非接触卡读取,但能否落地取决于以下条件:

  • 设备的 NFC 控制器是否支持相应的卡模拟路由;
  • 系统是否允许将指定 AID 路由到 SE;
  • 是否获得 SE issuer、运营商、设备厂商或 Trusted Service Manager 的授权;
  • applet 是否已安全安装并完成密钥注入;
  • Reader 是否支持该 applet 使用的协议。

因此,手机带有 Secure Element,并不代表普通应用可以自行部署虚拟卡。对一般开发者来说,HCE 通常更适合作为起点。

Android HCE 示例

下面的示例使用自定义 AID:F0010203040506。生产环境应自行规划 AID,以免与其他应用冲突。

声明卡模拟服务

在 AndroidManifest.xml 中注册 HostApduService:

<uses-permission android:name="android.permission.NFC" />
<uses-feature
    android:name="android.hardware.nfc.hce"
    android:required="true" />

<application>
    <service
        android:name=".DemoHostApduService"
        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>

创建 res/xml/apduservice.xml:

<?xml version="1.0" encoding="utf-8"?>
<host-apdu-service
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/app_name"
    android:requireDeviceUnlock="false">

    <aid-group
        android:category="other"
        android:description="@string/app_name">
        <aid-filter android:name="F0010203040506" />
    </aid-group>
</host-apdu-service>

处理 APDU

import android.nfc.cardemulation.HostApduService
import android.os.Bundle

class DemoHostApduService : HostApduService() {

    private val aid = hexToBytes("F0010203040506")
    private val success = hexToBytes("9000")
    private val notFound = hexToBytes("6A82")
    private val wrongInstruction = hexToBytes("6D00")

    override fun processCommandApdu(
        commandApdu: ByteArray,
        extras: Bundle?
    ): ByteArray {
        if (isSelectAid(commandApdu, aid)) {
            val payload = "CARD_READY".toByteArray(Charsets.UTF_8)
            return payload + success
        }

        // 示例自定义命令:80 10 00 00 00
        val demoCommand = hexToBytes("8010000000")
        if (commandApdu.contentEquals(demoCommand)) {
            val payload = "TRANSACTION_OK".toByteArray(Charsets.UTF_8)
            return payload + success
        }

        return wrongInstruction
    }

    override fun onDeactivated(reason: Int) {
        // NFC 链路断开或其他服务取得路由时,清理当前会话状态
    }

    private fun isSelectAid(command: ByteArray, expectedAid: ByteArray): Boolean {
        if (command.size < 5) return false
        if (
            command[0] != 0x00.toByte() ||
            command[1] != 0xA4.toByte() ||
            command[2] != 0x04.toByte() ||
            command[3] != 0x00.toByte()
        ) {
            return false
        }

        val length = command[4].toInt() and 0xFF
        if (command.size < 5 + length || length != expectedAid.size) return false

        return command.copyOfRange(5, 5 + length).contentEquals(expectedAid)
    }

    private fun hexToBytes(value: String): ByteArray =
        value.chunked(2)
            .map { it.toInt(16).toByte() }
            .toByteArray()
}

APDU 响应末尾的 90 00 是状态字,表示命令成功。6A 82 通常表示未找到目标,6D 00 表示不支持该指令。自定义协议可以沿用这些常见状态字,但仍需明确规定每条命令的格式、长度和错误处理方式。

Reader 手机示例

Reader 端启用 Reader 模式,并且只轮询需要的技术类型:

import android.app.Activity
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.os.Bundle

class ReaderActivity : Activity(), NfcAdapter.ReaderCallback {

    private lateinit var nfcAdapter: NfcAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        nfcAdapter = NfcAdapter.getDefaultAdapter(this)
    }

    override fun onResume() {
        super.onResume()

        val flags =
            NfcAdapter.FLAG_READER_NFC_A or
            NfcAdapter.FLAG_READER_NFC_B or
            NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK

        nfcAdapter.enableReaderMode(this, this, flags, null)
    }

    override fun onPause() {
        nfcAdapter.disableReaderMode(this)
        super.onPause()
    }

    override fun onTagDiscovered(tag: Tag) {
        val isoDep = IsoDep.get(tag) ?: return

        try {
            isoDep.connect()
            isoDep.timeout = 3000

            val selectResponse = isoDep.transceive(
                buildSelectAid("F0010203040506")
            )

            if (!hasSuccessStatus(selectResponse)) {
                return
            }

            val transactionResponse = isoDep.transceive(
                hexToBytes("8010000000")
            )

            val payload = transactionResponse
                .copyOfRange(0, transactionResponse.size - 2)
                .toString(Charsets.UTF_8)

            runOnUiThread {
                // 更新界面,例如显示 payload
            }
        } catch (e: Exception) {
            // 处理 TagLostException、IOException 等异常
        } finally {
            try {
                isoDep.close()
            } catch (_: Exception) {
            }
        }
    }

    private fun buildSelectAid(aidHex: String): ByteArray {
        val aid = hexToBytes(aidHex)
        return byteArrayOf(
            0x00,
            0xA4.toByte(),
            0x04,
            0x00,
            aid.size.toByte()
        ) + aid + byteArrayOf(0x00)
    }

    private fun hasSuccessStatus(response: ByteArray): Boolean =
        response.size >= 2 &&
            response[response.size - 2] == 0x90.toByte() &&
            response[response.size - 1] == 0x00.toByte()

    private fun hexToBytes(value: String): ByteArray =
        value.chunked(2)
            .map { it.toInt(16).toByte() }
            .toByteArray()
}

onTagDiscovered() 通常不在主线程中执行,因此可以直接在其中进行 NFC I/O。更新界面时则应切回主线程。实际代码还需要检查响应长度,避免在响应不足两个字节时截取状态字。

这些模式算不算“组合使用”

从应用效果来看,这确实是组合使用:一台设备负责卡模拟,另一台使用 Reader/Writer 能力。

不过,对单个 NFC 控制器而言,它们通常是不同的工作角色。设备会根据当前配置和射频场景进入 Reader、Card Emulation 或 P2P 等状态,应用不能随意把多个角色叠加到同一条连接中。这个方案的重点是让两部手机分别承担一个角色。

P2P 也不是实现该方案的必要条件。P2P 用于对等设备通信,HCE 与 Reader 则采用智能卡式的命令和响应模型。不要在同一个会话中混用 P2P 消息与 ISO-DEP APDU。

能否模拟银行卡并让另一部手机充当 POS

技术演示与正式支付是两回事。

HCE 可以模拟某种 APDU 应用,但仅仅返回类似银行卡的 APDU,并不能构成真实的 EMV 支付。正式支付通常还需要:

  • 支付网络认可的支付应用和凭证;
  • 动态密码或交易密文计算;
  • Token、密钥及生命周期管理;
  • 终端内核、非接触 Level 1/Level 2 等相关认证;
  • 商户、收单和后台授权链路;
  • 设备完整性、风控及合规控制。

同样,在普通 Android 手机上运行 IsoDep Reader 示例,也不代表这部手机已经成为合规 POS。如果只是验证两台设备之间的 NFC 通信,应使用自定义 AID 和自定义 APDU,不要直接仿造真实的银行卡交易流程。

实现时需要注意的问题

设备兼容性

两台手机都必须具备 NFC。模拟卡一侧还要支持 HCE,Reader 一侧则要支持 Reader 模式和 IsoDep。厂商系统、系统策略和硬件天线设计也可能影响实际表现。

屏幕与锁屏状态

HCE 服务能否在锁屏或灭屏状态下工作,取决于 Android 版本、设备厂商策略、服务配置和应用类别。不能假设所有手机在屏幕关闭后仍然可以模拟卡。涉及敏感操作时,建议主动要求用户解锁设备或完成身份认证。

APDU 大小和超时

不要假设所有设备都支持相同的最大 APDU 长度。Reader 端可以结合 IsoDep.getMaxTransceiveLength() 设计分包协议。卡端需要异步处理时,可以先返回 null,完成后再调用 sendResponseApdu()。整个处理过程仍应尽量简短,以免 Reader 超时或用户提前移开手机。

不要依赖 NFC UID 做身份认证

手机在卡模拟模式下暴露的低层标识不一定稳定,也不应作为可信身份使用。需要认证时,应在 APDU 协议中加入随机挑战、会话标识、签名或 MAC,并采取措施防止重放。

AID 冲突和默认路由

如果多个应用声明了相同的 AID,系统可能要求用户选择默认应用,也可能按照系统路由规则处理。应做好明确的 AID 规划,并测试同时安装多个 NFC 应用时的行为。

手机摆放位置

手机的 NFC 天线通常位于背部上方、摄像头附近或机身中部。两部手机背靠背时,需要缓慢调整位置。天线没有对准导致的断连,经常会被误认为协议或代码出了问题。

推荐的开发顺序

先使用 HCE 和简单的 SELECT AID 验证链路,再增加一条自定义 APDU。确认两部手机可以稳定交换数据后,再逐步加入:

  1. 请求和响应的数据格式;
  2. 序列号或会话 ID;
  3. 随机挑战及双向认证;
  4. 重放保护;
  5. 超时、重试和断连恢复;
  6. 后台服务器校验;
  7. 必要时再评估 Secure Element 接入。

只有在业务明确要求密钥不能由 Android 应用进程访问时,才需要进一步向设备厂商、运营商或 SE 服务方确认 applet 部署和 AID 路由能力。对于一般的两机 NFC 通信原型,HCE 加 Reader 模式已经足够。

备注:内容仅供参考。