我上个月在一个副业项目里上线了"用通行密钥登录"(Sign in with a passkey)。指纹识别弹出来,凭证创建成功,登录跑通。我挺得意的——直到我搭档试了一次。
她先输入了邮箱地址,就像她登录其他所有网站那样,然后才等到指纹提示。那一刻我知道,我做的不是通行密钥。
![]()
那是"多绕几步的安全密钥"。通行密钥的全部卖点,是浏览器已经知道谁在尝试登录——没有用户名字段,不用打字,点一下就行。而我的实现仍然需要先知道你是谁。更糟的是,我写的代码看起来和所有人都在转发的 WebAuthn 示例一模一样。
那段"能跑"的代码
我当时的代码大致是这样,只保留关键部分:
调用 navigator.credentials.create(),传入 publicKey 对象,里面包含 challenge(来自服务端的随机挑战值)、rp(依赖方名称和域名)、user(用户 ID 字节、邮箱、显示名)、pubKeyCredParams(算法参数,这里用的是 -7,即 ES256)。
这是正确的 WebAuthn。它生成一对真实的公钥/私钥,私钥存在认证器上,公钥回传给你存到服务器。之后用它登录也没问题——前提是你已经知道是哪个用户在登录,并且能把当初为他存下的那个 credential.id 发回去。
那个"前提"就是全部的 bug。这段代码里没有任何东西告诉认证器:把这个凭证存到一个浏览器能自己找到的地方。于是到了登录环节,你只能先问用户名,查出该用户存下的凭证 ID,然后才带着它去调用 navigator.credentials.get()。
这正是我搭出来的流程——技术上算 WebAuthn,功能上是一个更慢的双因素认证提示。
可发现与不可发现,教程跳过的那一步
一个 WebAuthn 凭证可以是两种东西之一,而规范给它们起的名字,严重低估了它们在实践中的差别:
- 不可发现("服务端"凭证):凭证注册了,但认证器上没有任何东西以浏览器可枚举的方式存下来。要登录,你的服务器必须已经知道用户是谁(通过用户名、cookie 或别的什么),并在 get() 调用里回传该用户特定的凭证 ID。
- 可发现(人们说"通行密钥"时指的就是它):凭证本身被保存在设备上——平台钥匙串或安全密钥自己的存储里——带着足够的元数据,浏览器可以在没有任何前置输入的情况下,把它显示在一个选择器里。什么都不用输,直接得到一串"以某某身份登录",点一个,完成。
看出修复点了吗?就是你手上那个 options 对象里的一个字段。
在 create() 的 publicKey 里加上 authenticatorSelection,其中 residentKey 设为 "required"——这就是全部的修复。同时把 userVerification 设为 "preferred",让它去询问用户验证。
为什么示例代码会误导人
那些被到处链接的 WebAuthn 示例,往往只演示"创建凭证"和"用凭证登录"两个动作,而这两个动作在服务端已经知道用户身份的前提下都能跑通。于是你照着抄,测试也过,演示也顺,唯独漏掉了通行密钥之所以是通行密钥的那一环。
结果就是一个看起来像通行密钥、用起来像旧式登录的东西。用户不会告诉你哪里不对,他们只会像往常一样先打邮箱——因为那是他们熟悉的方式,而你的页面也默许了这种方式。
真正让通行密钥区别于安全密钥的,不是指纹识别本身,而是凭证有没有被存到浏览器能自己找到的地方。这一个字段决定了你交付的是"无密码登录",还是"带额外步骤的密码登录"。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.