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 之所以能终结这个模型,靠的是两点:

  1. 私钥不可导出。认证器里存的是密钥对,签名在认证器内部完成,网络那一端永远只看到签名。
  2. 凭据绑定到 origin。签名覆盖的 clientDataJSON 中写着浏览器实际观察到的 origin(https://corp.com),RP 服务端严格校验。代理站点拿到的是一个签了 evil-corp.com 的签名,一验就废。

第 2 点是全部安全性的支点,也是工程上最容易做错的地方。

二、FIDO2 的三层:别把 WebAuthn 和 CTAP2 混为一谈

层规范谁实现职责
RP Web APIW3C 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 的抗钓鱼就白做了——整套系统的安全性回落到最弱的那一环。

正确组合是:

  1. OAuth 2.1:PKCE 对所有客户端强制,废除 implicit 与 ROPC,refresh token 轮换 + 重放检测。
  2. 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、无 usernamelessresidentKey: "required"
直接把裸 `r\\s` 丢给 crypto 库签名校验莫名失败转 DER,注意前导零与符号位
接受 RS1 / 无算法白名单降级到 SHA-1只允许 ES256 / EdDSA
保留密码 + 短信作为"备份登录"抗钓鱼形同虚设做 acid test:移除所有可钓鱼回退路径
账户恢复仍走邮箱 OTP攻击者改走恢复流程恢复路径必须同等抗钓鱼(多凭据、人工审核、延迟生效)
多域用父域当 rpId 绕过凭据作用域被放大用 Related Origin Requests

最后一条值得单独强调:"acid test"——把系统里所有 passkey 之外的登录路径全部关掉,然后问一句"用户还能登录吗"。如果答案是"还能,用短信验证码",那你上线的是一套昂贵的装饰品。账户恢复是真正的最弱环节,绝大多数真实攻击已经从"骗登录"转向"骗恢复"。

八、结论

Passkey 的价值不在密码学 novelty,而在它把凭据作用域这个原本靠人类判断("这个网站看起来是真的吗")的问题,交给了浏览器与认证器做密码学校验。工程上的全部难度集中在三处:

  1. 服务端校验的严密性——origin 全等、rpIdHash 自算、challenge 一次性、算法白名单,任何一处放松都会把安全性打回密码时代。
  2. 同步密钥带来的语义变化——BE/BS 标志改变了 signCount、设备绑定、凭据生命周期的所有既有假设,照搬 U2F 时代的校验逻辑一定会出线上事故。
  3. 会话层的配套——没有 DPoP 这类发送方约束令牌,passkey 只保护了链条的第一环。

小步快跑的落地顺序建议:先做"passkey 作为第二因素"(风险最低、可回滚)→ 再做 passwordless 主登录 → 最后关闭可钓鱼回退。每一步都用真实设备的同步/换机/跨设备场景跑一遍回归,否则你会在生产环境第一次遇到"用户换了手机就登不进去"的问题。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部