ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

纯前端网页直连读卡器:使用WebHID与APDU读写DESFire卡完整指南

纯前端网页直连读卡器:使用WebHID与APDU读写DESFire卡完整指南

很多第一次接触 DESFire 的开发者,听到“网页读写智能卡”第一反应都是:不可能吧?浏览器权限限制那么多,连读卡器都未必认得到,还想改密钥?但我在实际项目里把这条路走通了:用纯 JavaScript 写了一整套前端逻辑,通过 WebHID 直连 CCID 读卡器,再配合 Web Crypto 与一些底层算法补全,实现了对 Desfire EV1、EV2、EV3 系列卡片的读取、写入、修改 DES/3DES/AES 密钥,整套代码不需要后端参与,静态页面就能跑。这篇文章就把这套方案的选型思路、卡片底层安全模型、前端密码学实现、APDU 交互流程,以及我在真实调试中踩过的一堆坑,完整写出来。

如果你正在做一卡通自助发卡、门禁卡巡检、会员卡离线段发、或者票务系统的卡片密钥维护,这篇文章应该能帮你省好几个星期的摸底时间。

1. 项目需求与整体技术选型

1.1 为什么非要网页操作 DESFire 卡

我这次的需求背景很典型:客户有一批 DESFire EV1/EV2 卡需要做批量初始化,包括把默认密钥改成项目密钥、建立应用目录、写入基础数据文件。但客户现场不是所有电脑都能装客户端软件,IT 策略又限制得很死,偏偏业务上要求即便在临时电脑上也能完成发卡操作。

这时候你会想到几个痛点,传统客户端一装就是一套运行时环境,PC/SC 驱动、卡片固件库、加密算法库全都得跟着分发,版本一不一致就出幺蛾子。而网页方案的好处非常直接:页面即程序、URL 即安装入口,浏览器拉起一次 USB 设备授权后就能干活,驱动层面只要系统认得读卡器即可,真正做到了“临时电脑也能用”。

DESFire 系列不是普通 NFC 标签,它内部有完整的文件系统与权限体系,安全上走的是双向认证与加密通道。这些恰恰不适合用现成的navigator.nfc去读,因为浏览器原生的 Web NFC 把标签抽象成了 NDEF 消息,根本让你碰不到底层 APDU 指令。要真正读写 DESFire,你得自己掌控 APDU 层的收发,所以整个项目从一开始就把技术栈定在了“前端直连读卡器”这条路上。

1.2 浏览器访问读卡器的四条路线对比

为了实现“网页直接操作读卡器”,我一共考虑过四种路线,下面用表格把方案与限制摆出来:

方案可访问性对 DESFire 支持落地成本我的结论
Web NFC API仅安卓 Chrome 部分版本只能读 NDEF 消息,接触不到 APDU 与 DESFire 目录低基本不可用
本地服务 + WebSocket全平台,需安装本地程序完整,本地程序做 PC/SC 桥接中可用但没解决分发痛点
浏览器扩展 + Native Messaging桌面浏览器可装扩展完整,扩展走原生消息中高分发链条太长,维护痛苦
WebHID/WebUSB 直连读卡器桌面 Chrome/Edge 可用完整,页面直接收发 APDU中最终采用

我最终选了 WebHID 直连。现在主流桌面 Chrome、Edge 都支持 WebHID,读卡器被识别为 HID 设备后,页面能在用户授权下直接发送 report、接收 input report,这刚好能满足 CCID 读卡器的传输需求。相比 WebUSB 的 bulk 传输,WebHID 对设备类型更宽容,很多读卡器在系统里本身就是以 HID 接口暴露的,踩坑面更小。

1.3 技术栈全景图

这个项目的最终技术栈不复杂,但是每一层都有讲究:

  • 前端框架:原生 JavaScript ES2020+,没有引入重型框架,因为核心逻辑在 APDU 和加密运算上,框架帮不上什么忙。
  • 设备通信:WebHID,负责发现读卡器、授权、收发数据包。
  • 加密能力:Web Crypto API 提供 AES-128-CBC、SHA、随机数生成;由于 Web Crypto 不支持 DES/3DES,也不支持 CMAC,所以补了一个纯 JS 版本的 DES/3DES 算法,再基于 AES-CBC 自己实现了 AES-CMAC。
  • 卡片协议:ISO 7816-4 的 APDU 封装,配合 DESFire 原生命令集。
  • 后端:没有业务后端,纯静态单页应用,密钥由页面运行时注入或由服务端接口按需下发。

整个链路可以概括成:网页 → WebHID → 读卡器 → ISO 14443A 射频 → DESFire 卡。中间的加密、命令拼接、响应解析全部在浏览器里完成。

2. DESFire 卡片结构与安全模型

2.1 三层存储结构不是闹着玩的

DESFire 不是一块简单的存储区,它带了完整的卡片操作系统。结构上分为三个层级:PICC 层、Application 层、File 层。

PICC 层是整个卡片的根部,里面保存着卡片序列号(UID)、PICC 主密钥、已创建的应用列表。卡片出厂后,PICC 主密钥一般是厂商预置的默认值,拿到手第一件事就是把它换掉,否则任何人只要用默认密钥就能进入系统。

Application 层是卡片里的“应用”,每个应用由一个 3 字节的 AID 标识。你可以把 AID 理解成门牌号,DESFire EV1 最多能创建多少个应用我记得软件文档里写的是 28 个左右,具体数值不同批次略有差异,但设计上足够应付一卡通场景了。每个应用内部还能继续有不同的文件,文件通过 1 字节 File ID 区分,单应用内文件数量上限一般是 32 个。

File 层是真正存数据的地方,DESFire 支持四种文件类型:

  • 标准数据文件:普通的读写存储区,适合存取身份信息、余额、卡号。
  • 备份数据文件:带备份机制的普通文件,边写边校验,断电时能恢复,适合关键资料。
  • 值文件:专门做电子钱包用的,支持加减值和限额操作,内部用冗余存储防止篡改。
  • 记录文件:线性记录或循环记录,适合保存消费流水、入场记录。

理解了这三层,你再看后面 APDU 流程就清楚了。所有操作都按“先选择应用、再选择文件、最后读写”的顺序走,少一步都会报错。

2.2 密钥体系与 DES、3DES、AES 之间的关系

很多做应用层开发的人,一上来就被“密钥算法”搞晕。其实 DESFire 的密钥体系很简单:每个应用里面可以配置 0 到 13 号共 14 个密钥,每个密钥本身是一串字节,算法可以选择 DES、3DES 或 AES。不同算法对应不同密钥长度:

  • DES:8 字节密钥,早期 EV1 常用,现在基本不建议用于新项目。
  • 3DES:16 字节或 24 字节密钥,16 字节时本质上是 EDE 三段结构中的两个独立 8 字节密钥,第三个 8 字节复用第一个,安全性明显高于 DES。
  • AES-128:16 字节密钥,EV1 后期和 EV2/EV3 时代的默认能力,目前最推荐。

密钥跟文件访问权限是绑定在一起的。每个文件在创建时可以指定读操作需要哪个密钥、写操作需要哪个密钥,以及读写的通信模式。通信模式还分为“明文传输”“带 MAC 校验”“全量加密”三档。这意味着就算拿到了文件数据,如果通道是加密的,抓取射频数据也还原不出明文。这也是 DESFire 相对 MIFARE Classic 最大的优势——早先 MIFARE Classic 的 Crypto-1 流密码早被破解了,而 DES/3DES/AES 这套体系至今仍然被认为是安全的。

2.3 双向认证与安全消息机制

DESFire 的认证可以理解为两个人在黑屋子里互相确认身份。EV1 传统认证大致是这样的流程:

  1. 页面先向卡片发一个 GET CHALLENGE,卡片返回一个 8 字节随机数。
  2. 页面自己也生成一组随机数,和卡片的随机数组合拼装,用密钥做加密运算后发回。
  3. 卡片解密,验证里面的随机数是不是自己刚发的,同时把自己的随机数回传。
  4. 页面再解密验证,如果两边都通过,认证即完成,双方同时算出一个会话密钥。

认证完成后,后续命令都会用这个会话密钥做加密或 MAC 运算,这就是所谓的安全消息机制。你不想让第三方在射频链路上监听或篡改命令,就必须走这套机制。

到了 EV2/EV3,认证流程变了,引入了AUTHENTICATE_EV2_FIRST一类的两条腿认证方式,并且强制使用 CMAC 做消息完整性保护。EV2 之后的认证命令一般以带 4 字节 CMAC 的报文出现,所以不光是加密算法的问题,你还得在代码里正确实现 CMAC 子密钥生成和报文拼接,否则认证流程怎么走都走不通。

3. 浏览器侧的密码学实现

3.1 Web Crypto API 能做什么,不能做什么

浏览器里的 Web Crypto API 能提供 AES-CBC、AES-CTR、AES-GCM、SHA-1、SHA-256、HKDF 等能力,也能生成随机数。对于 DESFire EV1 到 EV3 来说,AES-128-CBC 是够用的,因为 EV1 后期和 EV2/EV3 都支持 AES-128。

但有几个关键能力 Web Crypto 原生没有:

  • DES/3DES:Web Crypto 不支持这两种老算法,而很多存量 EV1 卡片的密钥仍然是 3DES。
  • CMAC:Web Crypto 没有 CMAC 原语。CMAC 不是简单地把最后一次加密结果截断,它需要在加密前处理子密钥和 Padding。
  • 部分 KDF 运算:DESFire EV2 的密钥多样化算法虽然底层是 AES,但因子拼接、派生步骤都需要自己写。

所以这套项目里真正的开发量不在页面 UI,而是把浏览器缺失的算法块用纯 JS 补上,并且保证和卡片侧完全对齐。

3.2 补全 DES/3DES 的纯 JS 实现

用 JavaScript 实现 DES 是个体力活,但也不算太夸张。核心是 Feistel 网络、S 盒、置换表这些标准结构。3DES 更简单一些,就是在 DES 外面套一个 EDE 结构:加密-解密-加密。

对于 16 字节的 3DES 密钥,加密一段数据实际上是:

第一步:用 K1 做 DES 加密 第二步:用 K2 做 DES 解密 第三步:再用 K1 做 DES 加密

如果拿到的是 24 字节密钥,第三段就换成独立的 K3。这个结构在 DESFire 的旧版认证和文件加密里非常常见。我建议你不要在项目里手搓整个 DES 实现,而是找社区里已经成熟的小型 DES/3DES 纯 JS 实现,以独立文件引进来,然后用标准测试向量做回归验证。关键不是你写了多少行算法代码,而是算法实现跟卡片侧完全一致。

3.3 基于 AES-CBC 自己实现 AES-CMAC

CMAC 的实现在这个项目里绕不开。用 Web Crypto 做 AES-CMAC 的思路是这样:

async function aesCmac(aesKey, message) { // 1. 导入 AES-CBC 密钥 const cryptoKey = await crypto.subtle.importKey( "raw", aesKey, { name: "AES-CBC" }, false, ["encrypt"] ); // 2. 计算子密钥 K1 / K2 const block = new Uint8Array(16); const L = await aesCbcEncryptZeroIv(cryptoKey, block); const Rb = 0x87; const K1 = clmulShift(L, Rb); const K2 = clmulShift(K1, Rb); // 3. 消息分块与 Padding const blocks = split16Bytes(message); let last = blocks.pop(); if (last.length === 16) { // 不补位,异或 K1 } else { // 补 0x80 后,异或 K2 } // 4. 逐块 CBC 加密,取最后一块的 16 字节作为 CMAC return lastEncryptedBlock; }

这个逻辑看起来不复杂,但有几个容易出错的地方:

  • L 的计算必须用全零明文加密,不能自己拍脑袋随便填。
  • 子密钥生成时的左移和异或0x87不能省,否则长消息算出来的 MAC 就错。
  • DESFire 的 CMAC 通常不是完整 16 字节,而是截取前 4 字节或前 8 字节做校验,截取长度要跟命令对齐。

3.4 会话密钥推导与密钥多样化

认证过程中,会话密钥的推导通常就是拿固定密钥加密一组随机数构造成分。EV1 传统认证里常见的是把卡片随机数、页面随机数顺序拼接后用 3DES-CBC 或 AES-CBC 加密,输出结果再分成几段异或得到 8 字节或 16 字节会话密钥。不同厂商页面上写的公式可能不一样,但核心思路是一致的:用密钥做 CBC 加密,把加密结果的指定片段做异或。

EV2/EV3 的密钥多样化则更规范,项目如果碰到“一卡一密”,一般规则是:卡片序列号 + AID 等因子,先经过配置好的 KDF 函数计算出应用密钥。前端要做的是拿到主密钥后,把因子按规定的字节序叠起来,用 AES-CBC 或 CMAC 扩展,生成长度符合要求的派生密钥。这里我强烈建议把“派生规则”做成可配置项,不要硬编码,因为我碰到过两批卡片,明明都是 EV2,KDF 的字节序却是反的。

4. 网页读写 DESFire 卡的核心流程

4.1 用 WebHID 连接 CCID 读卡器

首先得让网页拿到读卡器设备。WebHID 的授权弹窗需要用户手势触发,不能页面加载后默默请求。我封装了一个非常基础的连接函数:

async function connectReader() { const devices = await navigator.hid.requestDevice({ filters: [{ usagePage: 0xFF00 }], // CCID 常用 usage page,实际可按设备调整 }); if (!devices.length) { throw new Error("没有选择读卡器设备"); } const device = devices[0]; await device.open(); device.addEventListener("inputreport", (event) => { const data = new Uint8Array(event.data.buffer); onCardResponse(data); }); return device; }

实际项目里usagePage并不总是 0xFF00,有些读卡器用的是别的值。最简单的办法是把filters留空,让用户在系统弹出来的列表里手动选择,虽然体验差一点,但兼容性最好。连接成功后,后续读取卡片状态、收发 APDU 都走这个设备对象。

4.2 构造和发送 APDU

DESFire 的很多原生命令在 ISO 7816-4 的包装下,通常以CLA = 0x90出现,INS 是具体的命令码,P1/P2 多为 0。我封装了一个非常朴素的小函数:

function buildApdu(ins, p1 = 0x00, p2 = 0x00, data = new Uint8Array()) { const apdu = new Uint8Array(4 + data.length + 2); apdu[0] = 0x90; // CLA apdu[1] = ins; apdu[2] = p1; apdu[3] = p2; apdu.set(data, 4); // 最后两位是 Le,这里简化成 0x00 表示自动分配 apdu[4 + data.length] = 0x00; apdu[5 + data.length] = 0x00; return apdu; }

注意,实际发送到 CCID 读卡器时还要按具体设备固件补上 CCID 报文头,包括消息类型、数据长度、槽号、序号。但这部分属于设备驱动层,我在项目里单独拆了一个ccidTransport.js,统一处理 report 分包和重组。

4.3 完整认证流程的实战拆解

下面我按 EV1 的 3DES 认证为例说整条流程,因为这种卡存量最大。具体步骤是:

  1. 进入应用前先选 AID。如果目标是改 PICC 主密钥,连应用都不需要选。
  2. 发送 GET CHALLENGE,拿到卡片返回的 8 字节随机数。这个随机数在后面的加密运算里是核心因子。
  3. 页面生成 8 字节随机数,作为应答挑战的一部分。
  4. 用指定密钥把“卡片随机数 + 页面随机数”按 3DES-CBC(零 IV)加密,然后把结果作为认证命令的数据发出去。
  5. 卡片回传一段加密结果,里面包含转换后的页面随机数和卡片随机数。
  6. 做解密和比对,一致则认证通过,同时得到会话密钥。

这个流程里最容易翻车的就是第 4 步的字节拼接顺序。有些文档里写“RndA || RndB”,有些写“RndB || RndA”,谁在前谁在后直接决定认证失败。所以我在工程里专门加了一个认证字节序的配置常量,按卡批次分开维护。

EV2/EV3 的认证流程比 EV1 复杂,命令也变了,整体上多了 CMAC 校验项,响应里还会带更多的安全状态位。但前端处理节奏是类似的:发挑战、收挑战、算 MAC、回数据、验结果。把 EV1 吃透了,EV2/EV3 只是认证数据字段再打磨一下的事。

4.4 读文件、写文件、修改密钥的实现逻辑

认证完成后,文件操作就顺理成章了。先SELECT APPLICATION到目标应用,再SELECT FILE选定文件,然后发读文件命令。如果是明文模式,响应直接就是数据;如果是加密模式,返回数据要先解密再按文件结构解析。

写文件比读文件多一道保险,尤其当文件开启了读/写需加密模式时,页面必须把待写内容加密成密文,再附带 MAC,卡片收到后才解密落盘。我在前端实现的写流程包含下面几步:

  • 准备文件内容的二进制数组,按文件大小和偏移量分包。
  • 从会话密钥中提取加密密钥与 MAC 密钥。
  • 使用 AES-CBC 或 3DES-CBC 加密内容,注意补位规则。
  • 计算 CMAC 并截取规定长度的校验码。
  • 构造 WRITE DATA 命令,发送,解析状态字。

修改密钥是另一个重点。CHANGE KEY命令执行时,新密钥通常不是直接明文发过去的,而是要先派生出一个“加密后的新密钥”再传输。即便是在已经建立安全通道的情况下,也要注意加密方式以及新密钥版本号。密钥版本号在 DESFire 里是一个字节,用来标识当前使用的是哪一代密钥,很多做应用的人忽略它,结果换完密钥后卡死活不认。

我把读文件、写文件、改密钥这三个操作分别封装成三个独立模块:fileReader.js、fileWriter.js、keyChanger.js。每个模块只负责一个业务动作,把 APDU 构造和响应解析都收敛在内部,页面 UI 只需要调用对应函数。这样维护起来非常舒服,后续如果要支持更多卡片命令,也只是再往这个架构里加模块。

5. 常见问题与避坑要点

5.1 网页层的兼容性与权限问题

WebHID 是桌面端浏览器的能力,Chrome 和 Edge 都能用,但 iOS 的 Safari 不支持。如果业务里有 iPhone 用户,那就得退回本地服务方案,或者把卡片操作限定在安卓/iOS 的 WebView 加原生桥接上。

另一个非常常见的问题是读卡器在 WebHID 里看不到。原因多半是系统层面已经有一个 PC/SC 服务把设备独占锁定了,浏览器枚举不到已经“被占用”的设备。排查办法很粗暴:先关掉本机所有读卡器管理软件、杀毒软件的移动设备监控模块,再重新插拔读卡器。如果还不行,就换一台干净的电脑确认是不是读卡器固件本身不支持 HID 枚举。

我在项目里还遇到过一个更冷门的情况:读卡器有两个接口,一个走 HID,一个走串口,WebHID 能枚举到但一open()就报错,因为设备已经在串口模式下被系统虚拟成 COM 口了。这时需要在设备厂商工具里把工作模式切到 HID 或 CCID。

5.2 认证失败和写卡失败排查手册

下面是实际调试中整理出来的高频问题:

现象可能原因排查建议
GET VERSION 都不回数据卡片未进入射频场,或 APDU 封装错误换电脑自带读卡工具确认卡片是否物理正常
选应用后一直报 0x9E(未找到)AID 长度或字节序错误确认 AID 是否 3 字节,高位在前还是低位在前
认证第一次就失败密钥算法选错,比如实际是 AES 你却用 3DES 套路先尝试用默认密钥做 AES 认证,确认卡版本
认证第二步失败随机数拼接顺序错,或加密 IV 不是零对照文档检查 RndA/RndB 次序,并确认 CBC 使用零 IV
能认证但读文件失败文件权限配置的密钥号不对,或当前未选文件执行 SELECT FILE 后再读,检查对应文件权限
写文件时校验 MAC 失败CMAC 截断长度不符,或子密钥没对齐用已知数据算一遍 CMAC,对比卡片返回状态
改密钥后旧密码失效新密钥长度或算法与命令配置不一致改密钥前后都读取一次密钥版本号做确认

我最想强调的是:拿到新卡后不要直接拿默认密钥去试高级功能,先用GET VERSION把卡片的型号、硬件版本、软件版本、制造批次都读出来存档。不同批次的 EV1/EV2 卡,默认密钥值和默认通信加密选项可能是不同的,这个信息能避免后续排查走偏。

5.3 前端密钥安全管理

浏览器里的 JavaScript 天然面临 XSS 风险,如果页面被注入恶意脚本,内存里的密钥等于裸奔。所以生产环境里我不会把真正的项目密钥硬编码进静态 JS 文件,而是采用运行时下发的方式:

  • 高权限操作用户临时输入,或由后端接口返回一次性的密钥材料。
  • 后端返回加密后的密钥,前端用短时 PIN 解密到内存,用完即清。
  • 尽量使用不可导出的 CryptoKey 对象,把密钥材料限制在 Web Crypto 内部,不暴露给业务变量。
  • 所有操作日志只记录卡号、操作类型、执行结果,不记录密钥。

这样做不是为了防绝对意义上的黑客,至少能防住大多数脚本注入和日志泄露场景。做一卡通系统要记住:卡片密钥一旦泄露,整个系统的信任基础就没了,这是安全红线。

5.4 性能与交互细节

页面侧处理 APDU 的响应速度比原生 PC/SC 要慢一点点,主要开销在 Web Crypto 的异步边界和 HID 轮询上。实际体验下来,一次认证加一次文件读取,稳定在 200 毫秒以内,完全够用。但如果要做批量巡检很多张卡,每张卡都重新连接和认证,这时候需要做两个优化:

  • 对同一个读卡器设备长连接复用,不要每次都重新请求设备授权,因为 WebHID 的授权弹窗只能由用户手势触发,代码里没法静默重连。
  • 把加密上下文缓存到内存里,认证之后不销毁 CryptoKey 对象,一张卡多次读写时复用同一个会话密钥,能省掉重复认证的时间。

交互上我建议页面显示当前读卡器连接状态、卡片是否存在、是否已认证、当前选中应用和文件。有了这些状态提示,操作人员就不会在一个错误的上下文里反复尝试,排查成本大幅下降。

6. 这个项目的可扩展方向

做完这套网页读写能力后,我发现它不止能解决当前需求,还能继续延伸。比如把页面封装成 PWA,放在内网服务器上,所有电脑打开浏览器就能使用,不再需要任何人安装桌面环境,这就是自助设备跨终端化的重要底座。

另一个方向是把密钥管理单独拆成服务,页面只做“请求密钥服务签署的凭证”,卡片操作的过程审计全部在后端完成。这样前端彻底变成壳,敏感材料始终不落地。即使某台电脑被入侵,攻击者拿到的也只是单张卡的操作凭证,而不是整个系统的根密钥。

还可以把 DESFire 的读写能力继续扩展到更多卡片家族,我知道不少团队在做“统一读卡中间层”,网页今后会成为其中一个轻客户端。这种模式在巡检、售后、设备校准等场景里很有价值——只要设备有浏览器,就能完成复杂的卡片运维工作。

最后再分享一个小心得:这个项目里最让我意外的不是加密算法本身,而是 APDU 交互的时序细节。很多网上资料只讲“发什么命令、收什么响应”,但很少讲真机调试时该看哪些关键状态位。建议你在开发时给自己做一个串口级的“APDU 日志面板”,把每次收发的原始十六进制数据打出来,再对照文档逐字节核对。这个日志面板在初期开发阶段几乎是决定成败的调试工具,等系统稳定了再考虑去掉或者降级为隐藏模式。有了这套网页读写能力,后续无论是卡片巡检、自助发卡还是密钥轮换,你都会发现道路突然宽了很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进