Passkeys (2022年9月22日)
这是一份针对Web开发者的、带有主观色彩的Passkeys“快速入门”指南。希望它能广泛适用,但一种方案无法满足所有身份验证需求,本指南忽略了所有可选内容。因此,请将其视为一个工作示例,而非绝对真理。
它不使用任何WebAuthn库,仅假设您可以访问验证签名的函数。这可能不是最优方案——也许找一个好库是更好的主意——但Passkeys并不复杂,让人们了解其原理是合理的。
这篇文章可能需要随时间更新,因此不太适合放在博客中,未来我可能会移动它。但目前它就在这里。
开发Passkeys的平台包括:
- iOS 16或macOS 13上的Safari。
- Windows 22H2上的Chrome Canary(设置
chrome://flags#webauthn-conditional-ui
)。 - macOS上的Chrome Canary(设置
chrome://flags#webauthn-conditional-ui
)。
数据库更改
每个用户都需要一个Passkey用户ID。用户ID用于标识账户,但不应包含任何个人身份信息(PII)。您的系统中可能已有用户ID,但应专门为Passkeys创建一个,以便更容易保持其无PII。在用户表中创建一个新列,并为其填充大型随机值。(以下使用SQLite语法,您需要针对其他数据库进行调整。)
|
|
一个用户只能有一个密码,但可以有多个Passkeys。因此为它们创建一个表:
|
|
安全上下文
WebAuthn中的所有内容都必须在安全上下文中工作,因此如果您没有使用HTTPS,请先修复这个问题。
注册现有用户
当用户使用密码登录时,您可能希望提示他们在本地设备上创建Passkey,以便下次更轻松地登录。
首先,检查他们的设备是否有本地验证器,并且浏览器将支持Passkeys:
|
|
(这里的代码片段使用TypeScript。如果您需要,可以轻松将其转换为纯JavaScript。您可能会注意到几个地方TypeScript的DOM类型被覆盖,因为lib.dom.d.ts尚未跟上。希望这些情况会随时间消失。)
如果用户接受,请要求浏览器创建本地凭据:
|
|
RP ID
有两个级别的控制机制防止Passkeys在错误的网站上使用。您需要提前了解这一点,以防止后期陷入困境。
“RP”代表“依赖方”。您(网站)在身份验证术语中是“依赖方”。RP ID是一个域名,每个Passkey在创建时都有一个固定的RP ID。每个Passkey操作都会断言一个RP ID,如果Passkey的RP ID不匹配,则该操作中它不存在。
这防止了一个站点使用另一个站点的Passkeys。具有foo.com
RP ID的Passkey不能在bar.com
上使用,因为bar.com
无法断言foo.com
的RP ID。站点可以使用通过从左丢弃零个或多个标签直到遇到eTLD形成的任何RP ID。因此,假设您是https://www.foo.co.uk
:您可以断言www.foo.co.uk
(丢弃零个标签)、foo.co.uk
(丢弃一个标签),但不能断言co.uk
,因为那会遇到eTLD。如果您在请求中未设置RP ID,则默认为站点的完整域名。
我们的www.foo.co.uk
示例可能很高兴地使用默认RP ID创建Passkeys,但后来决定将所有登录活动移动到隔离源https://accounts.foo.co.uk
。但所有Passkeys都无法从该源使用!如果最初创建它们时使用foo.co.uk
的RP ID,就可以允许这样做。
但您可能希望小心总是设置最通用的RP ID,因为那样usercontent.foo.co.uk
也可以访问和覆盖它们。这引出了第二个控制机制。
正如您稍后将看到的,当使用Passkey登录时,浏览器会在签名数据中包含发出请求的来源。因此accounts.foo.co.uk
将能够看到请求是由usercontent.foo.co.uk
触发的并拒绝它,即使Passkey的RP ID允许usercontent.foo.co.uk
使用它。但该机制无法对usercontent.foo.co.uk
能够覆盖它们做任何事情。
因此,要么选择一个RP ID并将其放入上面的“SEE BELOW”占位符中,要么根本不包含rp.id
字段并使用默认值。
记录Passkey
当navigator.credentials.create
的Promise成功解析时,您有一个新创建的Passkey!现在您必须确保服务器记录它。
Promise将产生一个PublicKeyCredential
对象,其response
字段是一个AuthenticatorAttestationResponse
。
首先,对浏览器中的一些数据进行健全性检查。由于在我们使用的配置中这些数据未签名,因此在客户端进行此检查是可以的。
|
|
在response
上调用getAuthenticatorData()
和getPublicKey()
,并将这些ArrayBuffer
发送到服务器。
在服务器端,我们希望为这个用户向passkeys
表中插入一行。验证器数据是一种相当简单的二进制格式。偏移量32包含标志字节。健全性检查位7是否设置,然后提取:
- 位4作为
backed_up
的值。(即(authData[32] >> 4) & 1
。) - 偏移量53处的大端uint16作为凭据ID的长度。
- 从偏移量55开始的那么多字节作为
id
的值。
来自getPublicKey()
的ArrayBuffer
是public_key_spki
的值。这应该是插入行所需的所有值。
处理注册异常
create()
的Promise也可能导致异常。InvalidStateError
是特殊的,意味着本地设备已存在Passkey。这不是错误,并且不会向用户显示任何错误。他们会看到类似于注册Passkey的UI,但服务器不需要更新任何内容。
NotAllowedError
意味着用户取消了操作。其他异常意味着发生了更意外的事情。
要测试异常是否是这些值之一,请执行类似以下操作:
|
|
(但显然不要在实际代码中只是将它们记录到控制台。)
使用自动完成登录
在您站点的某个地方,有用户名和密码输入。在用户名输入元素上,向autocomplete
属性添加webauthn
。因此如果您有:
|
|
…那么将其更改为…
|
|
Passkeys的自动完成与密码不同。对于后者,当用户从弹出窗口中选择用户名和密码时,输入字段会为他们填充。然后他们可以单击按钮提交表单并登录。对于Passkeys,不会填充任何字段,而是解析一个待处理的Promise。然后站点有责任导航/更新页面,以便用户登录。
该待处理Promise必须由站点在用户聚焦用户名字段并触发自动完成之前设置。(仅添加webauthn
标签如果没有待处理Promise供浏览器解析,则不会执行任何操作。)要创建它,在页面加载时运行一个函数:
- 进行功能检测,如果支持,
- 启动“条件”WebAuthn请求以产生Promise,如果用户选择凭据,该Promise将被解析。
以下是进行功能检测的方法:
|
|
然后,启动条件请求:
|
|
挑战
挑战是由服务器生成的大型随机值,在使用Passkey时进行签名。因为它们是大型随机值,服务器知道签名必须是在生成挑战之后生成的。这阻止了签名被捕获并多次使用的“重放”攻击。
挑战有点像CSRF令牌:它们应该是大型(16或32字节)、加密随机值,并存储在会话对象中。它们应该只使用一次:当收到登录尝试时,挑战应失效。未来的登录尝试必须使用新的挑战。
上面的代码片段有一个值CHALLENGE_SEE_BELOW
,假定它是登录的base64编码挑战。登录页面可能通过XHR获取挑战,或者挑战可能注入到页面模板中。无论哪种方式,它必须在服务器生成!
处理登录
如果用户选择Passkey,则handleSignIn
将被调用,并带有一个PublicKeyCredential
对象,其response
字段是一个AuthenticatorAssertionResponse
。
将ArrayBuffer
rawId
、response.clientDataJSON
、response.authenticatorData
和response.signature
发送到服务器。
在服务器端,首先查找Passkey:
|
|
并使用rawId
的值进行匹配。id
列是主键,因此可以有零个或一个匹配行。如果有零行,则用户正在使用服务器不知道的Passkey登录——也许他们删除了它。这是一个错误,拒绝登录。
否则,服务器现在知道声称的用户名和公钥。要验证签名,您需要构造签名数据并解析公钥。数据库中的public_key_spki
值以SubjectPublicKeyInfo格式存储,大多数语言都有某种方式摄入它们。以下是一些示例:
- Java:
java.security.spec.X509EncodedKeySpec
- .NET:
System.Security.Cryptography.ECDsa.ImportSubjectPublicKeyInfo
- Go:
crypto/x509.ParsePKIXPublicKey
您的语言的加密库应提供一个函数,该函数接受签名和一些签名数据,并告诉您该签名对于给定的公钥是否有效。对于签名,传入客户端发送的签名ArrayBuffer
的值。对于签名数据,计算clientDataJSON
的SHA-256哈希,并将其附加到authenticatorData
的内容。如果签名无效,拒绝登录。
但还有一堆事情需要检查!将clientDataJSON
解析为UTF-8 JSON并检查:
type
成员是“webauthn.get”。challenge
成员等于服务器为此登录给出的挑战的base64url编码。origin
成员等于您站点的登录来源(例如,像“https://www.example.com”这样的字符串)。crossOrigin
成员(如果存在)为false。
还有更多!获取authenticatorData
并检查:
- 前32字节等于您使用的RP ID的SHA-256哈希。
- 偏移量32处的字节的位零为一。即
(authData[32] & 1) == 1
。这是用户存在位,指示用户批准了签名。
如果所有这些检查都通过,则登录其Passkey的用户。即设置cookie并响应运行的JavaScript,以便它可以更新页面。
如果存储的backed_up
值不等于(authData[32] >> 4) & 1
,则在数据库中更新它。
移除密码
一旦用户使用Passkeys登录,太好了!但如果他们是从密码升级的,那么该密码仍然挂在账户上,没有任何用处,却创造风险。询问用户关于移除密码是好的。
如果账户有备份的Passkey,这样做是合理的。即如果
|
|
有结果。站点可以考虑在用户使用Passkey登录并注册了备份Passkey时,提示用户移除账户上的密码。
注册新的仅Passkey用户
对于新用户的注册,如果功能检测(来自注册用户部分)满意,考虑使他们仅使用Passkey。
当注册用户时,如果Passkey将是他们唯一的登录方法,您真的希望Passkey最终“在他们的口袋中”,即在他们的手机上。否则,他们可能在注册时使用的计算机上有一个Passkey,但如果它没有同步到他们的手机,那就不太方便。目前,恐怕没有很好的答案!希望几个月后,使用authenticatorSelection.authenticatorAttachment
设置为cross-platform
调用navigator.credentials.create()
会做正确的事情。但在iOS 16上,它将排除平台验证器。
因此,目前,在所有平台上都这样做,除了iOS/iPadOS,其中authenticatorAttachment
应继续为platform
。
(当答案更简单时,我会尝试更新这一部分!)
设置
如果您在任何站点上使用过安全密钥,您会注意到它们倾向于在账户设置中列出注册的安全密钥,让用户命名每个密钥,显示最后使用时间,并允许单独移除它们。如果您喜欢,也可以对Passkeys这样做,但这相当复杂。相反,我认为您可以只有两个按钮:
首先,一个添加Passkey的按钮,使用上面的createOptions
对象,但删除authenticatorAttachment
以允许注册其他设备。
其次,一个“重置Passkeys”按钮(像“重置密码”按钮)。它将提示新的Passkey注册,删除所有其他Passkeys,并使用户的所有其他活动会话失效。
测试向量
连接到您语言的加密库是其中较棘手的部分之一。为了帮助,这里有一些测试向量,以Python 3代码的形式提供,检查断言签名,为您提供一个检查的基础真相。
|
|