1. 引言:软件令牌为什么挡不住钓鱼
过去十年,双因素认证的标配是"密码 + 软件令牌(TOTP)"。这套组合在企业内网和 SaaS 登录里跑了很久,但它在攻击面上有三个结构性弱点:
第一,共享密钥模型。TOTP 的密钥同时存在于认证服务器和用户的手机 App 中。一旦手机被植入恶意软件,或者用户被诱骗截图给攻击者,这个因子就失去了"持有"语义。
第二,可被中转的会话。软件令牌只解决了"你是谁",没有解决"你正在登录的是不是真的那个站点"。攻击者搭建一个中间人代理,把用户的登录请求原样转发给真正的服务器,软件令牌照样通过校验,因为服务器看到的是一次合法的时间窗签名。
第三,无密码诉求与国密合规的错位。FIDO2/WebAuthn 已经把"无密码 + 抗钓鱼"写进了标准,但它的默认算法是 ECDSA(P-256) / Ed25519 和 SHA-256,与国内等保、密评要求的 SM2/SM3/SM4 体系并不天然兼容。政务、能源、轨交、制造这些强监管行业,既想要 WebAuthn 的用户体验,又必须满足国密算法合规。
把硬件Key当作"认证器"而不是"存储令牌",是弥合这条裂缝的关键。硬件Key的私钥在芯片内生成、在芯片内参与运算、永远不离开安全边界,天然契合 WebAuthn 的"设备绑定 + 持有因子 + 用户验证"三要素。问题留给工程侧:怎样让一颗国密芯片,对浏览器和 IdP 表现出一个标准 WebAuthn authenticator 的行为?
2. 原理:WebAuthn 的信任链与国密适配层
2.1 挑战-响应为什么抗钓鱼
WebAuthn 的核心是一个非对称密码学挑战-响应协议。理解它只需要抓住四个角色:
| 角色 | 职责 | 关键产物 |
|---|---|---|
| Relying Party (RP) | 业务系统 / 身份提供方 | challenge(一次性随机数)、credentialId |
| WebAuthn Client | 浏览器中的凭据接口 | navigator.credentials调用 |
| Authenticator | 硬件Key | 签名、非导出私钥 |
| User | 持Key的人 | 持有因子 + 验证因子 |
注册和登录两条主流程:
# 注册流程(简化序列) RP ──生成 challenge──> Browser ──publicKeyCredentialCreationOptions──> Authenticator Authenticator ──在芯片内生成密钥对──> 私钥留存 / 公钥回传 Authenticator ──对 (challenge + rpId + 用户句柄) 签名──> AttestationObject Browser ──credential(rawId + response)──> RP RP ──验签 + 存公钥 + 绑 credentialId──> 完成注册 # 断言(登录)流程 RP ──生成 challenge + 允许Credentials──> Browser Browser ──publicKeyCredentialRequestOptions──> Authenticator Authenticator ──读取私钥──> 对 (challenge + rpIdHash + 当前计数) 签名 Browser ──assertion(authenticatorData + signature)──> RP RP ──验签 + 校验 rpIdHash + 校验 signCount 单调──> 放行抗钓鱼的根源在rpIdHash和clientDataHash:签名覆盖的是RP 的域名哈希 + 浏览器提交的 clientData,中间人即使把 challenge 转发出去,也无法篡改rpIdHash,因为 authenticator 在签名前会重新计算它看到的 RP 标识。签名结果对"是哪个站点发起的"强绑定,攻击者拿到的断言无法重放到另一个域名。
2.2 国密芯片如何充当 WebAuthn authenticator
标准 WebAuthn authenticator 抽象出两个能力:密钥生成和受限签名。国密硬件Key需要做一层"适配层"把国密算法桥接到这个抽象上:
/* 适配层伪代码:把 WebAuthn makeCredential 映射到国密 Key */intukey_make_credential(constuint8_t*challenge,constuint8_t*rp_id_hash,uint8_t*credential_id,/* 输出:Key 句柄 */uint8_t*pub_sm2_x,/* 输出:SM2 公钥 X */uint8_t*pub_sm2_y)/* 输出:SM2 公钥 Y */{/* 1. 在芯片内生成 SM2 密钥对,私钥不离开安全区 */ukey_gen_sm2_inside_secure_zone();/* 2. 把 rpIdHash + challenge 作为待签原文的一部分 */uint8_tto_sign[64];memcpy(to_sign,rp_id_hash,32);memcpy(to_sign+32,challenge,32);/* 3. 调用芯片内部签名指令,SM2 签名 */returnukey_sm2_sign_inside(to_sign,64,sig_out);}适配层要做三件关键的事:
- 算法协商:在
attestationObject的alg字段声明国密算法标识(如-50对应 SM2),并把aaguid指向国密Key的型号。 - 私钥不可导出:所有 SM2 签名都在芯片内完成,适配层只回传签名值和公钥,从接口层面杜绝私钥落盘。
- 计数器防克隆:
authenticatorData里的signCount必须在芯片内单调递增并持久化,用于在 RP 侧检测 Key 被克隆后的重放。
2.3 为什么私钥不出硬件是底线
WebAuthn 的威胁模型假设 authenticator 可能被物理拿走,但只要私钥无法读出,攻击者拿到的只是一块"需要原厂才能用的石头"。国密硬件Key的 SM2 私钥在 32 位 RISC 安全芯片内生成,支持 SM1/SM2/SM3/SM4 以及 RSA/AES/ECC/SHA 算法族,其安全边界由芯片的硬件熔丝和访问权限控制保证。任何上层应用只能通过C动态库或 RESTful 接口发起"签名"指令,永远拿不到私钥明文——这是它区别于软件令牌的本质。
2.4 CTAP 协议栈与国密Key的指令映射
WebAuthn 只是用户侧接口,真正跑在浏览器与硬件之间的协议叫 CTAP(Client to Authenticator Protocol)。理解它,才能把国密Key正确接进链路。CTAP 分两层:
| 协议层 | 适用设备 | 能力范围 | 与本方案的关联 |
|---|---|---|---|
| CTAP1 / U2F | 旧版单因子 Key | 仅注册/认证断言 | 兼容存量设备 |
| CTAP2 | 现代 authenticator | makeCredential / getAssertion / clientPin / 凭据管理 | 无密码主通道 |
对国密Key来说,真正要实现的只有两个核心命令:authenticatorMakeCredential和authenticatorGetAssertion。这两个命令在适配层被翻译成对芯片的C库调用。注意 CTAP2 还要求支持getInfo来声明本设备支持的算法列表,这里必须把 SM2 加进去,否则浏览器在能力协商阶段就会把 Key 过滤掉:
/* getInfo 返回的能力描述(节选) */staticconstint32_talg_list[]={-50,/* SM2 */-7,/* ECDSA(P-256),兼容国际站点 */-8,/* EdDSA,可选 */};staticconstuint8_taaguid[16]={/* 国密Key 型号标识 */};/* 在 webauthn 能力声明里同时暴露 SM2 与 ECDSA, 让 RP 按自身合规要求选择 alg,互不干扰 */多算法并存的好处是:同一个 Key,对接国内政务 SSO 时走 SM2,对接支持 FIDO 的国际 SaaS 时走 ECDSA,硬件不变、策略可变。
2.5 凭据存储:credentialId 与芯片内密钥的映射
WebAuthn 注册后会回传一个credentialId,RP 侧只存它和公钥。真正的私钥保存在哪里?在国密Key里,credentialId本质上是一个指向芯片内密钥槽的句柄。一个 128 KB 的芯片通常能容纳数十到上百把密钥,靠credentialId索引:
credentialId ──(适配层解析)──> 芯片内 Slot 编号 ──> SM2 私钥 │ │ │ └─ 签名运算全部在此完成 └─ RP 侧仅作索引,无密钥材料这意味着:即便 RP 数据库被拖库,攻击者拿到的也只是credentialId和公钥,无法反推出任何私钥——这是"公钥基础设施 + 硬件隔离"组合提供的天然抗拖库属性。
2.6 证明语句(Attestation)的国密表达
标准 WebAuthn 的证明语句有packed、tpm、android-key等格式,它们各自描述"这把公钥是由哪种安全硬件背书的"。对国密Key,最贴合的是packed格式的一个国密变体:用 SM2 证书链替代原有的 X.509 ECDSA 证书,把attStmt写成{ alg: -50, sig: <SM2签名>, x5c: <SM2证书链> }。RP 在注册时用内置的根 CA 验证这条链,就能确认"这把 SM2 公钥确实出自合规国密芯片",而不仅仅是任意一个能算 SM2 的软件。这一步对密评尤其重要:它把"算法的国密化"进一步升级为"根信任的国密化"。
attStmt (国密 packed 变体) ├─ alg : -50 # SM2 ├─ sig : SM2( authData || clientDataHash ) └─ x5c : [ 设备证书(SM2), 中间CA(SM2), 根CA(SM2) ] RP 校验:用根CA 逐级验证书 -> 用设备证书验 sig -> 确认 authData 可信3. 架构:从浏览器到国密Key的链路
典型的部署形态是在 WebAuthn Client 与物理 Key 之间插入一个本地代理 + 浏览器扩展的桥接组件。浏览器不能直接碰 USB HID,所以需要一个本地常驻进程把CTAP风格的指令翻译成对国密Key的C库调用。
| 层级 | 组件 | 输入 | 输出 | 算法 |
|---|---|---|---|---|
| L1 浏览器 | WebAuthn API | create()/get() | 标准断言对象 | - |
| L2 本地代理 | CTAP 桥接 | authenticatorMakeCredential | C库调用 | - |
| L3 适配层 | 国密适配器 | challenge + rpIdHash | SM2 签名值 | SM2/SM3 |
| L4 硬件Key | 安全芯片 | 签名指令 | 签名 + 计数 | SM1/SM2/SM3/SM4 |
# 一次登录在链路上的数据走向 Browser.get() └─> NativeBridge (localhost socket) └─> ukey_adapter.c:assertion_prepare(challenge, rp_id) └─> ukey_sm2_sign_inside( hash(SM3, rp_id_hash||challenge||counter) ) └─> 返回 (authenticatorData, signature) └─> 组装 WebAuthn assertion 回传 Browser Browser ──POST /verifyAssertion──> RP 验签这个架构的好处是RP 和浏览器完全不需要改动——它们看见的仍然是一个标准 WebAuthn authenticator。所有国密逻辑被封装在 L2/L3/L4,对上层透明。
4. 落地实践
4.1 Key 注册为 WebAuthn authenticator
注册阶段要把国密Key的 SM2 公钥登记到 RP 的凭据库,并把credentialId与用户账户绑定。下面是一段 RP 侧(基于 Node 生态的fido2风格库)的处理骨架:
// RP 侧:接收国密Key的注册响应app.post('/webauthn/register',async(req,res)=>{const{rawId,response}=req.body;constexpectedChallenge=req.session.challenge;// 1. 解析 attestationObject,确认 alg === -50 (SM2)constatt=parseAttestation(response.attestationObject);if(att.alg!==-50)returnres.status(400).send('unsupported alg');// 2. rpIdHash 必须匹配本域constrpHash=sm3(Buffer.from(req.hostname));if(!timingSafeEqual(att.rpIdHash,rpHash))returnres.status(400).send('rpId mismatch');// 3. 验签(SM2 验签用公钥)constok=sm2Verify(att.pubKey,att.signedData,att.signature);if(!ok)returnres.status(400).send('bad attestation');// 4. 落库:credentialId -> userIdawaitCredential.put(rawId,{userId:req.session.uid,publicKey:att.pubKey,alg:-50,signCount:att.signCount,aaguid:att.aaguid,// 指向国密Key型号});res.json({registered:true});});注意第 2 步的rpIdHash校验:这是由SM3(域名)得到的 32 字节哈希,确保 Key 是在"这个站点"下注册的,杜绝跨站重放。
4.2 以安当UKey为例:把国密Key接入现有 IdP
以安当UKey为例,其对外暴露的接入面包含两类:一是面向快速集成的RESTful接口(约 2300 个端点覆盖初始化、密钥管理、签名、会话加密等),二是面向高性能场景的C动态库,可直接在业务进程内调用,省去一次网络往返。在把它登记为 WebAuthn authenticator 时,适配层通常走C库路径:
/* 登录断言:安当UKey 适配示例 */intukey_webauthn_assert(constchar*credential_id,constuint8_t*challenge,constuint8_t*rp_id_hash,uint32_tstored_sign_count,uint8_t*out_auth_data,uint8_t*out_signature){uint32_tcur_count;ukey_read_sign_count(credential_id,&cur_count);if(cur_count<=stored_sign_count)/* 计数器不增 => 疑似克隆 */return-1;uint8_tcdh[32];sm3_ctx_tc;sm3_init(&c);sm3_update(&c,rp_id_hash,32);sm3_update(&c,challenge,32);sm3_final(&c,cdh);/* 硬件内 SM2 签名,私钥不可导出 */if(ukey_sm2_sign_inside(credential_id,cdh,32,out_signature)!=0)return-2;build_authenticator_data(out_auth_data,rp_id_hash,cur_count);return0;}这段代码里有两个工程细节值得强调:其一是signCount的单调递增校验,它在 RP 侧同样要再校验一遍,形成双重防御;其二是rp_id_hash在签名原文里参与哈希,这是抗钓鱼的锚点。
4.3 以安当UKey为例:与现有 IdP 的协同与回退
在政务 SSO、电网调度这类既有身份体系里,很难一步把密码登录全部下线。以安当UKey为例,常见的渐进式策略是"WebAuthn 为主、密码 + OTP 为辅"的双通道:
| 认证方式 | 触发条件 | 信任等级 | 备注 |
|---|---|---|---|
| 国密Key + PIN | 正常办公、内网终端 | 高(持有+知识因子) | 主通道 |
| 国密Key 签名验签 | 调度指令、审批动作 | 高(操作级不可否认) | 关键操作强制 |
| 密码 + 动态口令 | Key 遗失 / 芯片损坏 | 中 | 回退通道,需二次复核 |
| 远程接入 + Key | 外场、轨交、指挥车 | 高 | 远程访问场景同样走硬件签名 |
回退不是简单的"降级",而是要在审计日志里标记该次会话的信任等级,并对回退通道附加限制(例如回退会话只允许只读、关键写操作仍要求 Key 在线)。这种"策略分級"比"全有或全无"更容易在强监管行业落地。
4.4 关键操作强制签名验签
对于电网调度指令签名、军用指挥车的指令下发这类"一旦伪造后果严重"的操作,应当把 WebAuthn 断言之外的业务级签名再做一次。此时国密Key不只是登录凭据,更是操作不可否认的印章:
操作员发起"断开 220kV 甲线"指令 └─> 终端组装指令明文 M └─> 安当UKey 对 SM3(M) 做 SM2 签名 => Sig └─> 报文 = M || Sig || certId 上送调度中心 调度中心 ──用 Key 预置证书验签──> 验过则执行,并留痕这里用到了 Key 的CA 证书能力:第四种认证方案KeyID → CA证书允许把芯片内的 SM2 密钥对与一个由内部 CA 签发的证书绑定,使签名结果可被独立的证书链验证,满足密评里对"身份真实性 + 数据完整性 + 不可否认性"的三项要求。
4.5 会话加密:用 SM4 把断言之外的通道也锁住
"无密码登录"解决的是身份鉴别,但登录之后的业务报文仍处于明文风险中。国密Key的"会话加密"方向正好补上这一环:登录成功后,用芯片内的 SM2 做一次密钥协商,派生出 SM4 会话密钥,后续报文用 SM4 对称加密传输,兼顾安全与性能。
/* 会话密钥协商:ECDH 风格,但用 SM2 曲线 */intukey_session_key(uint8_t*peer_pub_x,uint8_t*peer_pub_y,uint8_t*out_sm4_key/* 32字节 SM4 密钥 */){/* 1. 芯片内取本端 SM2 私钥(不可导出) *//* 2. 与对端公钥做 SM2 密钥协商 => 共享秘密 Z */uint8_tZ[32];ukey_sm2_derive_inside(peer_pub_x,peer_pub_y,Z);/* 3. SM3(Z) 作为 SM4 密钥,或直接用 Z 做密钥包装 */sm3(Z,32,out_sm4_key);return0;}这种"认证用非对称、传输用对称"的分层,在电网调度、SCADA、轨交外场这类既有实时性要求又有保密性要求的场合尤其合适:握手阶段一次 SM2,之后海量遥测报文全走 SM4,延迟可控。
4.6 软件授权保护:把 Key 当成"许可锁"
除了人登录,国密Key还能给软件本身上锁。制造车间的 CAD 授权、ERP 防盗版就是典型场景:软件启动时向 Key 发起一次KeyID → 签名验签,只有持有合法 Key 的终端才能进入主功能;Key 遗失则授权随硬件一起收回,比"序列号 + 在线激活"更难被批量破解。实现上同样依赖私钥不出硬件:破解者即便反编译了软件,也拿不到可伪造的签名密钥。
CAD 软件启动 └─> 读取 Key 的 KeyID,构造随机 challenge └─> 安当UKey 对 challenge 做 SM2 签名 └─> 软件用 Key 预置公钥验签 ├─ 通过 => 进入完整功能 └─ 失败 => 仅试用模式 / 拒绝启动5. 关键参数、性能与合规
5.1 硬件与算法参数
| 参数 | 规格 | 说明 |
|---|---|---|
| 主控 | 32 位 RISC 安全芯片 | 专用安全核,非通用 MCU |
| 片上存储 | 128 KB | 存放密钥、证书、计数、固件 |
| 国密算法 | SM1 / SM2 / SM3 / SM4 | 对称/非对称/杂凑/分组 |
| 国际算法 | RSA / AES / ECC / SHA | 兼容存量系统 |
| 私钥保护 | 不可导出 | 签名只在芯片内 |
| 接口 | RESTful(~2300) + C 动态库 | 灵活与高性能两条路径 |
| 适配 | 信创环境 | 国产 OS / 数据库 / 中间件 |
5.2 性能关注点
无密码认证的体验瓶颈通常在"用户插Key到断言返回"的时延。几个可优化点:
- 优先走 C 动态库而非 RESTful。本地进程内调用省去一次 HTTP 往返,断言时延通常从十几毫秒降到个位数毫秒。
- 会话加密复用。登录完成后,可用 Key 协商的会话密钥(SM4)保护后续通道,避免每次操作都做完整 WebAuthn 断言。
- 计数器读取走缓存。
signCount的写入必须落芯片,但读取可缓存到本地代理,减少不必要的芯片交互。
5.3 合规映射
| 合规诉求 | 国密Key 提供的对应能力 |
|---|---|
| 等保 / 密评 身份鉴别 | 硬件持有因子 + PIN 用户验证 |
| 传输加密 | SM4 会话加密、SM2 密钥协商 |
| 数据完整性 | SM3 摘要 + SM2 签名 |
| 不可否认 | CA 证书绑定的 SM2 签名 |
| 信创适配 | 国产生态下的驱动与库支持 |
| 固件来源可信 | 固件签名校验,防篡改刷写 |
6. 常见坑与排查
坑 1:rpId 与域名不一致导致验签失败。
WebAuthn 的rpId默认取当前域名,rpIdHash是SM3(域名)。如果 RP 入口经过反向代理,对外域名和内网host不同,注册和断言就会对不上。排查时打印两端的rpIdHash十六进制比对即可。
坑 2:signCount 不涨被误判为克隆。
部分芯片在多应用共享时计数回写有竞态。现象是第二次登录就被 RP 拒绝。排查方法:在适配层打印每次断言前后的cur_count,确认差值,并确认写回路径有原子性保护。
坑 3:浏览器拿不到 authenticator。
浏览器通过 USB HID / 蓝牙与 Key 通信,本地代理没起来或权限不足时,navigator.credentials.get()直接 reject。先用系统工具确认 Key 已被识别、对应设备节点存在,再检查本地代理是否在监听。
坑 4:混合算法验签失败。
RP 用 ECDSA(P-256) 的验签函数去验国密Key的 SM2 签名,必然失败。务必在attestationObject.alg上做分支:-50走 SM2 验签,-7走 ECDSA 验签,不要假设算法。
坑 5:回退通道被长期占用。
回退(密码+OTP)本应是临时通道,但若不做策略限制,会被常态化使用,等于架空硬件Key。建议在策略里给回退会话打标,并限制其写权限与有效期,定期审计回退次数异常。
坑 6:固件被非法刷写。
如果芯片未启用固件签名校验,攻击者可刷入恶意固件读取密钥。必须开启固件签名校验,只允许由持有合法签名密钥的镜像写入,从硬件启动链上堵住这条路。
坑 7:多 RP 共用一把 Key 时的凭据混淆。
一把国密Key可能被同时登记给政务 SSO 和内部 ERP。如果适配层在makeCredential时没有把rpIdHash作为密钥槽命名空间的一部分,就会出现 A 站点的断言被 B 站点接受。正确做法是把Slot = HASH(rpId) || KeyID,让每把 Key 在每个 RP 下拥有独立密钥槽,互不串台。
坑 8:PIN 验证与用户 Presence 的语义不清。
WebAuthn 区分userPresent(按了一下 Key)和userVerified(通过了 PIN/生物)。无密码主通道应当要求userVerified=true,否则只证明了"有人碰了 Key",没证明"是本人"。在适配层要正确填充authenticatorData里的 UV 标志位,并在 RP 侧强制校验该位,避免把"持有"误当成"验证"。
坑 9:远程接入时的本地代理可用性。
在轨交外场、制造车间、指挥车这类通过远程接入办公的终端上,本地代理常因没有常驻权限而未能自启,导致浏览器找不到 authenticator。排查时应把代理注册为系统服务并设置开机自启,并在断言超时时优先检查本地代理存活状态,而不是怀疑 Key 本身。
7. 无密码与国密协同的演进判断
把视角拉远一点,FIDO2/WebAuthn 和国内国密体系并不是互斥关系,而是"标准外壳 + 国密内核"的叠加。浏览器和 IdP 的世界已经围绕 WebAuthn 建立了完整的生态,我们没必要重写一套;真正需要工程投入的是最底层的authenticator 适配层——让国密芯片用 SM2/SM3 完成挑战-响应,对外暴露标准接口。
这条路径的收益是双向的:一方面,无密码认证把"密码 + 软件令牌"的钓鱼面整体收敛;另一方面,国密合规要求由硬件Key在芯片内闭环满足,不依赖任何软件侧密钥托管。二者在工程上汇于一点:私钥只在芯片里,签名只在芯片里,这是所有安全属性的根。
方案参考
把国密硬件Key改造成 WebAuthn 认证器,落地时可以遵循一套通用方法论,而不必绑定某一款产品:
- 接口对齐:确认 Key 是否提供
C动态库与RESTful两类接口,前者用于低时延断言,后者用于运维与初始化。 - 算法声明:在
attestationObject中正确声明国密算法标识,并在 RP 验签侧实现对应的 SM2/SM3 分支,避免与国际算法混用。 - 私钥边界:所有签名必须在芯片内完成,任何上层接口都只回传签名值与公钥,从设计上保证私钥不可导出。
- 抗钓鱼锚点:签名原文必须覆盖
rpIdHash与challenge,并在 RP 侧二次校验rpIdHash,这是无密码抗钓鱼的硬约束。 - 计数器与克隆检测:
signCount在芯片内单调持久化,RP 侧再校验一次,形成双重防御。 - 回退策略:保留"密码 + 动态口令"回退通道,但对其做信任等级标记、权限收敛与审计,避免回退通道被常态化。
- 合规闭环:用 SM2 签名 + CA 证书满足身份真实、数据完整、操作不可否认;用 SM4 会话加密满足传输机密性;在信创环境下完成驱动与库的适配。
- 固件可信:开启固件签名校验,防止恶意镜像刷写导致密钥泄露。
安当UKey 的定位,是这套方法论里一个"国密安全芯片硬件级认证与加解密"的可用载体:它在 32 位 RISC 芯片上以 128 KB 资源实现了 SM1/SM2/SM3/SM4 及 RSA/AES/ECC/SHA 的全算法支撑,通过"Web 双因素、C-S 认证、软件授权保护、会话加密、OS 双因素"五个方向覆盖从登录到业务操作的认证面,并以"KeyID→UserName、KeyID→签名验签、CA 证书"等认证方案对接既有 IdP。对于需要在电网调度指令签名、政务 SSO、轨交外场、制造车间 SCADA、CAD 授权、ERP 防盗版、远程接入指挥车等场合同时满足无密码体验与国密合规的团队,它可以作为认证器适配层的一个参考实现来评估和集成。