Passkey 与 WebAuthn 深度工程实战:从 CTAP2 认证器模型、抗钓鱼密钥绑定到 DPoP 令牌约束的生产级全解
执行摘要:Passkey 的安全收益并不来自"密钥更强",而来自凭据被密码学绑定到 origin。密码、TOTP、短信验证码都处在同一个失败域——它们都是可转交的共享秘密,攻击者只要做一次实时中继代理就能拿到;而 passkey 的签名私钥从不离开认证器,且签名的clientDataJSON里包含浏览器给出的真实 origin,中继代理无法改写。本文拆解 FIDO2 的分层(WebAuthn / CTAP2 / 认证器模型)、注册与认证仪式的服务端逐步校验、authData位标志的真实语义、同步密钥与 hybrid 跨设备传输为何依然抗钓鱼、以及最容易被忽略的那一半问题——passkey 只解决"你是谁",不解决"会话怎么绑定",必须配合 OAuth 2.1 + DPoP 才能形成完整闭环。
一、先搞清楚对手是谁:实时中继,不是离线爆破
绝大多数"多因素"方案的威胁模型是错的。它们假设攻击者拿到的是静态凭据(密码哈希、备份码),于是不断叠加第二因素:短信、TOTP、推送确认。
但真实的高频攻击是 Adversary-in-the-Middle(AiTM)反向代理:攻击者注册一个和真实站点几乎一模一样的域名,用 evil-corp.com 冒充 corp.com,把用户浏览器的每一个请求实时转发给真站。用户输入密码、输入 TOTP、点击推送确认——全部逐字转发,会话 cookie 原样落到攻击者手里。
这里的关键洞察是:TOTP 和短信码不是"知识证明",而是"可转交的令牌"。用户把 6 位数字交给了骗子,就像把家门钥匙递给了骗子。
Passkey 之所以能终结这个模型,靠的是两点:
- 私钥不可导出。认证器里存的是密钥对,签名在认证器内部完成,网络那一端永远只看到签名。
- 凭据绑定到 origin。签名覆盖的
clientDataJSON中写着浏览器实际观察到的 origin(https://corp.com),RP 服务端严格校验。代理站点拿到的是一个签了evil-corp.com的签名,一验就废。
第 2 点是全部安全性的支点,也是工程上最容易做错的地方。
二、FIDO2 的三层:别把 WebAuthn 和 CTAP2 混为一谈
| 层 | 规范 | 谁实现 | 职责 |
|---|---|---|---|
| RP Web API | W3C WebAuthn | 浏览器 | 暴露 navigator.credentials.create()/get(),构造 options、校验 origin、做用户手势与权限检查 |
| 传输/协议 | FIDO CTAP2 | 认证器 | 定义 authenticatorMakeCredential / GetAssertion,PIN/UV 校验,CBOR 编解码 |
| 认证器 | 平台/漫游 | OS、安全芯片、密码管理器 | 生成并保护私钥,执行用户验证(生物识别/PIN) |
工程含义很直接:服务端的信任根是认证器产生的 authData 与签名,不是浏览器。浏览器只是搬运工,而且必须被当作不可信的搬运工——所有关键字段(challenge、origin、rpIdHash)服务端都要重新算一遍。
三、注册仪式:服务端到底要校验什么
注册返回的 attestationObject 是 CBOR 结构 {fmt, authData, attStmt},authData 的字节布局必须逐段解析:
| rpIdHash(32) | flags(1) | signCount(4) | attestedCredentialData | extensions |
| aaguid(16) | credIdLen(2) | credId | COSE公钥 |
flags 位是重灾区:0x01 UP(用户在场)、0x02 UV(已验证身份)、0x08 BE(可备份)、0x10 BS(已备份)、0x40 AT(含凭据数据)、0x80 ED(含扩展)。
import base64, hashlib, json, cbor2
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
def b64u(d): return base64.urlsafe_b64decode(d + "=" * (-len(d) % 4))
def verify_registration(cred: dict, session: dict, rp_id: str, origin: str):
cdata = json.loads(b64u(cred["response"]["clientDataJSON"]))
att = cbor2.loads(b64u(cred["response"]["attestationObject"]))
ad = att["authData"]
# 1) 类型与 challenge:challenge 必须一次性绑定会话,且有 60s TTL
assert cdata["type"] == "webauthn.create"
assert cdata["challenge"] == session["challenge"]
# 2) origin 必须是浏览器给出的字符串,做全等比较,禁止后缀匹配
assert cdata["origin"] == origin
# 3) rpIdHash:自己算,不要信客户端传的 rpId
assert ad[:32] == hashlib.sha256(rp_id.encode()).digest()
flags = ad[32]
assert flags & 0x41 == 0x41 # UP + AT 必须置位
assert flags & 0x02 # passkey 场景要求 UV
# 4) 解析 attestedCredentialData
p, cred_id_len = 53, int.from_bytes(ad[53:55], "big")
cred_id = ad[55:55 + cred_id_len]
cose = cbor2.loads(ad[55 + cred_id_len:])
# 5) 算法白名单:只接受 ES256(-7) / EdDSA(-8),拒绝 RS1(-65535)
assert cose[3] in (-7, -8)
# 6) credentialId 去重:excludeCredentials 只是提示,服务端必须再查一次
assert not credential_exists(cred_id)
# 7) attStmt:按 fmt 校验;对普通消费者场景,fmt=none 完全可接受
verify_attestation(att["fmt"], att["attStmt"], ad, cose)
return {"credential_id": cred_id, "public_key": cose,
"sign_count": int.from_bytes(ad[33:37], "big"),
"backup_eligible": bool(flags & 0x08),
"backed_up": bool(flags & 0x10)}
三点实战判断:
- 不要强索 attestation。Chrome 默认返回
fmt: none(匿名 attestation)。只有企业场景需要attestation: "enterprise"+ AAGUID 白名单来强制"只能用公司发的 YubiKey"。对 C 端产品索取 direct attestation 会显著降低注册转化率,而且拿到的信息也没什么鉴别力。 - 必须要求可发现凭据(
authenticatorSelection.residentKey: "required")。否则认证时必须先输用户名才能拿到allowCredentials,你就失去了 usernameless 登录和 autofill 能力——而那正是 passkey 体验的全部价值。 - 存储要留全。除了公钥,还要存
transports、BE/BS、aaguid、用户句柄。这些信息后续用于凭据管理 UI("这个密钥在你的 iCloud 钥匙串里"/"这是硬件密钥")和风险策略。
四、认证仪式:签名格式与 signCount 的真相
def verify_assertion(assertion: dict, session: dict, rp_id: str, origin: str, cred):
cdata = json.loads(b64u(assertion["response"]["clientDataJSON"]))
ad = b64u(assertion["response"]["authenticatorData"])
sig = b64u(assertion["response"]["signature"])
assert cdata["type"] == "webauthn.get"
assert cdata["challenge"] == session["challenge"]
assert cdata["origin"] == origin
assert ad[:32] == hashlib.sha256(rp_id.encode()).digest()
flags = ad[32]
assert flags & 0x01 # UP
assert not (flags & 0x40) # 断言阶段不应有 AT 位
if cred.policy_requires_uv: assert flags & 0x02
# 签名原文 = authData || SHA256(clientDataJSON)
digest = hashlib.sha256(b64u(assertion["response"]["clientDataJSON"])).digest()
raw_sig = raw_to_der(sig) # 关键:WebAuthn 给的是 r||s 裸 64 字节
verify_es256(cred.public_key, raw_sig, ad + digest)
# signCount 只能当信号,不能当门禁(见下文)
new_count = int.from_bytes(ad[33:37], "big")
if new_count and new_count <= cred.sign_count:
raise SuspiciousCredential("计数器回退")
return new_count
两个几乎所有人第一次都会踩的坑:
其一,签名格式。 ES256 在 WebAuthn 里是 64 字节裸 r||s,不是 ASN.1 DER。Python cryptography、Java Signature、OpenSSL 高层接口大多只吃 DER,直接喂原始字节会报"无效签名"。要么自己拼 DER(0x30 | len | 0x02 | rlen | r | 0x02 | slen | s,注意补齐前导零与正整数符号位),要么用专门处理 WebAuthn 的库。
其二,signCount 在同步密钥时代已经失效。 计数器本意是硬件密钥独有、单调递增,用来检测克隆。但 iCloud 钥匙串 / Google 密码管理器同步出来的 passkey 在多台设备上共享同一私钥,每台设备各有自己的计数(很多实现干脆永远返回 0)。如果你对 BE/BS 置位的凭据执行"计数器必须递增"硬校验,会在用户换设备的瞬间把人锁在门外。正确做法:只对设备绑定凭据(BS=0)做递增校验,对已备份凭据只做"显著回退"告警。
五、同步密钥与 hybrid:跨设备为什么仍然抗钓鱼
Passkey 的最大体验突破是凭据可以跨设备流转。这里的机制值得拆开看,因为它常被误认为"抗钓鱼性被削弱了"。
- 同步(backup):同一生态内的多台设备共享同一密钥对。
BE=1, BS=1告诉你这是一个同步凭据。安全性从"密钥绑定设备"退化为"密钥绑定生态账号"——这确实引入了新的假设(云侧账号安全、端到端加密的钥匙串),但没有削弱抗钓鱼性:签名仍然绑定 origin。 - hybrid 传输(跨设备临时使用):手机作为认证器、桌面浏览器作为客户端时,走 BLE 广播 + 屏幕二维码。二维码内容是
FIDO:/<base64url>,里面包含一个 128 比特的qrSecret;手机扫码后通过 BLE 与浏览器完成密钥协商,后续 CABLE 隧道用它加密。更重要的是邻近性校验(proximity check):认证器只有在 BLE 范围内收到浏览器的信号才允许签发,远程攻击者即使骗用户扫了码,也因为不在蓝牙范围内而拿不到可用的签名。这条把"扫码"从可远程化的攻击面,重新压回"物理邻近"的攻击面。
六、Passkey 只解决身份,不解决会话:OAuth 2.1 + DPoP
这是工程上最常被漏掉的一课。WebAuthn 的产出是一次登录事件的证明,它不产生、也不保护后续会话。如果你用 passkey 登录后仍然下发一个裸 bearer token,那么攻击者只要偷到这个 token,passkey 的抗钓鱼就白做了——整套系统的安全性回落到最弱的那一环。
正确组合是:
- OAuth 2.1:PKCE 对所有客户端强制,废除 implicit 与 ROPC,refresh token 轮换 + 重放检测。
- DPoP(RFC 9449):把访问令牌绑定到 RP 持有的非对称密钥。每次请求都随附一个
dpop+jwt,其中jwk是公钥、htm/htu绑定方法与 URI、ath是访问令牌的哈希。服务端校验ath与 token 匹配、htu与请求路径匹配、并比对首次发行时登记的jkt(JWK 指纹)。
// 客户端:为每次请求生成 DPoP 证明
async function signRequest(url, method, accessToken, dpopKey) {
const ath = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken)));
const jwt = await signJWT({
alg: 'ES256', typ: 'dpop+jwt', jwk: toPublicJWK(dpopKey)
}, { jti: crypto.randomUUID(), htm: method, htu: stripQuery(url), ath, iat: now() }, dpopKey);
return { 'Authorization': `DPoP ${accessToken}`, 'DPoP': jwt };
}
这样一来,令牌被盗也不可用(攻击者没有 DPoP 私钥),令牌被中继也不可用(htu 不匹配)。passkey 管"进门",DPoP 管"进门之后每一步",两者缺一,抗钓鱼链条就是断的。
还有一个部署细节:当登录域与业务域不同(如 login.corp.com 与 app.corp.com)时,需要用 Related Origin Requests——在登录域的 /.well-known/webauthn 上声明业务域列表,且业务域也要反过来声明。别用"把 rpId 设成父域"这种土办法绕过,那会一次性放大凭据的作用域。
七、生产陷阱清单
| 陷阱 | 后果 | 正确做法 | ||
|---|---|---|---|---|
| origin 用 host 后缀匹配 | 子域/协议降级被放过 | 全等比较完整 origin 字符串 | ||
| rpIdHash 信任客户端传参 | 凭据跨站重放 | 服务端用配置中的 rpId 自行 SHA-256 | ||
| challenge 复用/不设 TTL | 重放攻击 | 32 字节 CSPRNG,单用途,60s 过期,绑定会话 | ||
| 对同步凭据强校验 signCount | 用户换机后被锁 | 仅设备绑定凭据做递增校验 | ||
| 不要求 residentKey | 无 autofill、无 usernameless | residentKey: "required" | ||
| 直接把裸 `r\ | \ | s` 丢给 crypto 库 | 签名校验莫名失败 | 转 DER,注意前导零与符号位 |
| 接受 RS1 / 无算法白名单 | 降级到 SHA-1 | 只允许 ES256 / EdDSA | ||
| 保留密码 + 短信作为"备份登录" | 抗钓鱼形同虚设 | 做 acid test:移除所有可钓鱼回退路径 | ||
| 账户恢复仍走邮箱 OTP | 攻击者改走恢复流程 | 恢复路径必须同等抗钓鱼(多凭据、人工审核、延迟生效) | ||
| 多域用父域当 rpId 绕过 | 凭据作用域被放大 | 用 Related Origin Requests |
最后一条值得单独强调:"acid test"——把系统里所有 passkey 之外的登录路径全部关掉,然后问一句"用户还能登录吗"。如果答案是"还能,用短信验证码",那你上线的是一套昂贵的装饰品。账户恢复是真正的最弱环节,绝大多数真实攻击已经从"骗登录"转向"骗恢复"。
八、结论
Passkey 的价值不在密码学 novelty,而在它把凭据作用域这个原本靠人类判断("这个网站看起来是真的吗")的问题,交给了浏览器与认证器做密码学校验。工程上的全部难度集中在三处:
- 服务端校验的严密性——origin 全等、rpIdHash 自算、challenge 一次性、算法白名单,任何一处放松都会把安全性打回密码时代。
- 同步密钥带来的语义变化——
BE/BS标志改变了signCount、设备绑定、凭据生命周期的所有既有假设,照搬 U2F 时代的校验逻辑一定会出线上事故。 - 会话层的配套——没有 DPoP 这类发送方约束令牌,passkey 只保护了链条的第一环。
小步快跑的落地顺序建议:先做"passkey 作为第二因素"(风险最低、可回滚)→ 再做 passwordless 主登录 → 最后关闭可钓鱼回退。每一步都用真实设备的同步/换机/跨设备场景跑一遍回归,否则你会在生产环境第一次遇到"用户换了手机就登不进去"的问题。

发表评论 取消回复