很多第一次接触 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。不同算法对应不同密钥长度DES8 字节密钥早期 EV1 常用现在基本不建议用于新项目。3DES16 字节或 24 字节密钥16 字节时本质上是 EDE 三段结构中的两个独立 8 字节密钥第三个 8 字节复用第一个安全性明显高于 DES。AES-12816 字节密钥EV1 后期和 EV2/EV3 时代的默认能力目前最推荐。密钥跟文件访问权限是绑定在一起的。每个文件在创建时可以指定读操作需要哪个密钥、写操作需要哪个密钥以及读写的通信模式。通信模式还分为“明文传输”“带 MAC 校验”“全量加密”三档。这意味着就算拿到了文件数据如果通道是加密的抓取射频数据也还原不出明文。这也是 DESFire 相对 MIFARE Classic 最大的优势——早先 MIFARE Classic 的 Crypto-1 流密码早被破解了而 DES/3DES/AES 这套体系至今仍然被认为是安全的。2.3 双向认证与安全消息机制DESFire 的认证可以理解为两个人在黑屋子里互相确认身份。EV1 传统认证大致是这样的流程页面先向卡片发一个 GET CHALLENGE卡片返回一个 8 字节随机数。页面自己也生成一组随机数和卡片的随机数组合拼装用密钥做加密运算后发回。卡片解密验证里面的随机数是不是自己刚发的同时把自己的随机数回传。页面再解密验证如果两边都通过认证即完成双方同时算出一个会话密钥。认证完成后后续命令都会用这个会话密钥做加密或 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/3DESWeb Crypto 不支持这两种老算法而很多存量 EV1 卡片的密钥仍然是 3DES。CMACWeb 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-CMACCMAC 的实现在这个项目里绕不开。用 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 扩展生成长度符合要求的派生密钥。这里我强烈建议把“派生规则”做成可配置项不要硬编码因为我碰到过两批卡片明明都是 EV2KDF 的字节序却是反的。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 构造和发送 APDUDESFire 的很多原生命令在 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 认证为例说整条流程因为这种卡存量最大。具体步骤是进入应用前先选 AID。如果目标是改 PICC 主密钥连应用都不需要选。发送 GET CHALLENGE拿到卡片返回的 8 字节随机数。这个随机数在后面的加密运算里是核心因子。页面生成 8 字节随机数作为应答挑战的一部分。用指定密钥把“卡片随机数 页面随机数”按 3DES-CBC零 IV加密然后把结果作为认证命令的数据发出去。卡片回传一段加密结果里面包含转换后的页面随机数和卡片随机数。做解密和比对一致则认证通过同时得到会话密钥。这个流程里最容易翻车的就是第 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 日志面板”把每次收发的原始十六进制数据打出来再对照文档逐字节核对。这个日志面板在初期开发阶段几乎是决定成败的调试工具等系统稳定了再考虑去掉或者降级为隐藏模式。有了这套网页读写能力后续无论是卡片巡检、自助发卡还是密钥轮换你都会发现道路突然宽了很多。