最近,体验了 GitHub 的 Passkeys 登录,体验十分流畅自然,于是,老板下了个命令,希望公司自己的业务系统,也能支持 Passkeys 登录。于是,我对 Passkeys 进行了研究和学习。
一、什么是 Passkeys?
Passkeys 读音 /ˈpasˌkēs/
,基于 FIDO 标准,通行钥匙(Passkeys)是一种替代密码的解决方案,它为用户在其设备上访问网站和应用程序提供了更快、更便捷且更安全的登录方式。与密码不同的是,通行钥匙始终具有很强的强度并且能抵抗网络钓鱼攻击。
通行钥匙简化了应用程序和网站的账户注册过程,使用起来简单便捷,可在用户的大多数设备上使用,甚至可以在物理接近的其他设备上使用。
上图是一个使用 Passkey 登录的体验,弹出了系统的登录提示框,点击继续,就会自动从 KeyChain 中调用凭据,直接登录成功,过程中,完全不需要输入密码。
如何在一个已经有账号的网站上,绑定自己的 Passkey,第一步,点击“通行秘钥”。
注册 Passkey 绑定的第二步,弹出提示,询问用户是否同意使用 Passkey,这时候需要用户验证指纹,这是系统自带验证器要求的验证。
这是注册 Passkey 绑定的第三步,用户同意后,会在目标网站,登记一个可用的 Passkey。
Passkeys 有什么优势?
Passkeys 的提出,主要是针对使用密码的一些缺点,所以,我们先看看密码有什么缺点。第一,密码需要记忆,这无疑是对用户最大的一个心智负担,为了保证密码的强度,密码必须足够长,足够无序,这加重了每个人的负担。如果我们不按照要求去设置密码,则密码强度变低,安全得不到保证。第二,输入麻烦,拥有一定强度的密码,不但记忆困难,输入也是很困难的,因为字符无序。另外,一旦忘记密码,恢复起来,流程无比繁琐。第三,密码很容易泄露和被钓鱼攻击。为了得到用户的密码,黑客往往无所不用其极,而很多用户往往会在很多网站使用相同的密码,一旦其中一个网站被攻破,等于用户的密码就已经泄露了,所有使用相同密码的网站都变得畅通无阻。
而 Passkeys 的提出,正是规避了这些缺点。因为 Passkeys 是使用公钥-私钥对的非对称加密的原理,进行身份验证的。不存在每次需要用户主动记住一个密码串的形式。而这种验证形式,也是所有后台开发或者 Linux 运维人员最为熟悉的服务器验证的方式。
因为 Passkeys 的公私钥对的形式,就注定了其秘钥非常长,很难猜测,而公私钥对的验证形式也决定了,秘钥几乎不在网络上传输,尤其是私钥,几乎完全不通过公有网络传输,从而也很难被截获。因为这种秘钥的强度问题,注定了其无法被人为记忆,所以就只能使用密码管理器,而这都是由操作系统提供的基础设施实现,安全性方面比自己开发要有保证得多。
有些用户有把自己的密码记录在小本本上的习惯,大家可以理解 Passkeys 就是发明了一种操作系统使用小本本管理秘钥的一套标准和规范。以及如何跟网站,App 交互的行为规范。
快速理解 Passkeys 的流程
我先假设你是一个程序员,了解 Linux 服务器的远程管理和运维。那么你理解 Passkeys 就会变得无比简单。回忆一下我们拿到一台 Linux 服务器的时候,是如何进行登录和管理的。
- 首先,我们需要使用初始密码登录到服务器上;
- 然后,我们需要在客户端生成公私钥对;
- 将公钥拷贝到服务器的 .ssh/ 目录下,插入到 authorized_keys 文件中,注册自己的公钥;
- 在 ssh 的配置中,设定优先使用 public key 方式进行身份验证;
从此以后,我们登录自己的 Linux 服务器,再也不用输入密码了。
类比这个过程,你就理解了 Passkeys 的注册绑定流程和验证流程。
注册流程
一般来说,我们既有的业务系统,App 后台等,都有自己的用户鉴权体系。也就是传统的密码验证或者 OTP 验证。验证后,用户获得登录态。在此基础上,我们来注册 Passkeys,其过程,也是操作系统的密码管理器,生成公私钥对,然后将私钥保存好,将公钥发送给服务器(术语 relying server),然后绑定成功。
登录流程
使用 Passkeys 登录的时候,我们先解锁密码管理器,然后调取 App 或者网站的私钥,然后加密服务器规定的随机串,将密文发送给服务器后,服务器用事先保存好的公钥,验证随机串的密文是否正确,即完成了用户身份的验证。
从以上两个流程的描述中,我们不难发现,这和服务器的公私钥验证登录流程一模一样。区别就是,私钥在这个流程里是用密码管理器管理的。在服务器场景下,我们一般保存在自己的家目录 .ssh 子目录中。程序员一般都偷懒,不用密码保护私钥的调用。但是在 Passkeys 的流程里,这些都是必须的,因为密码管理器是必须用本地密码保护的,在现代设备上,还可以用生物特征来保护,比如指纹,比如面容等。
Passkey 和 Face ID,Touch ID 验证的关系
网上有不少文章会提到,Passkey 的登录体验,只要验证一下指纹,或者验证一下面容,就可以进行网站和 App 登录。我相信,以前,大家在使用很多 App 的时候,配置界面都有一个选项,启用 Touch ID 或者启用 Face ID,早就用过了,那么这个跟 Passkey 有什么关系呢?是不是一种重复呢?
其实,上文中已经部分有所提及了。
在使用 Passkey 登录的时候,本质上跟 Face ID 和 Touch ID 是没有什么必要关系的。Passkey 的本质是一种公私钥对。而 Face ID 和 Touch ID 这类生物验证手段,只是在操作系统上保护验证器的验证方式而已。显然,除了 Face ID 和 Touch ID 也可以用本地密码来保护验证器,也就是常说的 Pin。这并没什么不同。程序员请再次联想一下服务器登录时候的私钥 id_rsa 文件。其实就是使用 id_rsa 文件的时候,需要二次校验密码。防止被系统上其他登录的用户冒用(操作系统都是多用户系统)。
那么,很多 Face ID 和 Touch ID 登录的 App,又是什么原理呢?
首先,生物验证为了保护用户的隐私,用户的生物信息是被保存在芯片里的,数字化的特征,在芯片内部可以作为一种秘钥来使用。每次,校验用户身份的时候,都是将用户的生物信息生成的新的数字特征和芯片里保存好的特征进行比对。比对成功视为验证通过。
这里区别的是跟服务器的交互行为。Face ID 登录特性被开启后,验证时,我们会将芯片验证通过后产生的令牌发往服务器,服务器通过事先配置好的证书,解密令牌,对令牌内容进行校验,里面包含的ID,时间戳等信息。验证无误,服务器给 App 颁发访问 token。
从这些过程里,我们可以看到,Face ID 和 Touch ID 高度依赖芯片,用户的信息也是保存在芯片里的。所以,对于每一台设备来说,都要重新设置一个 ID,只能跟着设备跑。比如手机一个指纹,电脑也是一个指纹,不可能你在手机上设置好指纹,到了电脑上,就不用再次设置了,这不可能。
但是 Passkey 本质上是公私钥对,只要手机上设置好了 Passkey,那么私钥通过 iCloud 同步到电脑后,电脑不必再次设置。也不会出现,一个网络服务,在 App 上我设置了 Touch ID,但是登录网页版的时候,还是必须用密码登录这种情况。
尤其是,第三方的验证器提供此类服务后,Passkey 完全是跨平台的。比如,你在 Mac 上的 1Password 里创建了 Passkey,在 Mac 上用指纹保护 1Password,你的体验是,在 Mac 上,你用指纹,调取 Passkey 的私钥,登录 A 网站,到了 Windows 上,你用 Windows Hello 人脸验证,再次登录 A 网站。不知道这么说大家是否理解了呢?
简单说,Face ID、Touch ID 是针对 设备+人 的验证方式,二者缺一不可。而 Passkey 是针对能出具 私钥 的人 的验证方式,只强调你可以出具 私钥。安全性上,是前者更高的,方便性则是后者远胜前者。Face ID 和 Touch ID 是不可能借给别人使用的。而 Passkey 是允许把私钥 分享给别人的。
二、如何实现 Passkeys
首先就是服务器,必须支持 WebAuthn 的标准,只有支持这个标准,才能实现用户公钥的获取和保存流程,以及后续的验证流程。
这里,一般来说,我们不需要自己开发,使用现成的类库即可。在开始实现之前,先来了解一些基本的概念,我画了一幅图来说明:
在整个 Passkey 的验证过程里,有几个重要的实体,在流程里都有自己的术语名字,大家可能需要有基本的了解,才能看懂各种文档和 API 的设计。
- Relying Party,这个本质上就是只,需要验证功能的服务器。例如,你要登录一个网站,这个网站需要验证你的身份,它就是一个 Relying Party。它在你电脑或者手机设备上的表现形态,可能是一个 App,或者你用浏览器打开了一个网站的网页。如果是网页,它通过浏览器提供的 API 来跟验证器沟通,如果是 App,则通过原生的一些 API 来跟验证器沟通。
- Authenticator,验证器,这个其实就理解成密码,秘钥管理器即可。在 Windows 系统,原生提供的是 Windows Hello,而 Mac 和 iOS 则提供了 iCloud KeyChain。也有第三方的 App,比如 1Password 也可以。还有一些是硬件产品,比如优盾,或者 USB Key等等。
- WebAuthn,这本质是一个规范的名字,它从原理上约定了,浏览器也好,App 也好,怎么从验证器获取秘钥,怎么跟服务器进行验证,这个操作的流程,通信的协议等,都有约定。
如果我们去实现一个 Passkey 登录的特性,本质上就是学会使用 WebAuthn 的规范,并分别在客户端和服务器实现即可。注意,这里肯定是需要服务器做出配合的。
客户端
这里的客户端,是泛指,可以是浏览器里的一个网页,也可以是一个 App,都可以称为是客户端。这里就借浏览器说明一下,其实原理是一样的,而客户端往往是有封装好的类库的,不过过程是一样的。
先注意一个前提条件,WebAuthn 要求 Secure Context,安全上下文,也即,必须是在 HTTPS 环境下,才能使用,如果本地调试的时候,随便起个域名配个 host 恐怕不能使用。必须是 https 的域名或者 http://localhsot 这两者,后者就是给本地调试留的口子。
浏览器里有一个 API 是 navigator.credentials
,通过这个访问验证器里的秘钥。
是否支持
使用之前要判断当前环境是否支持:
if (!navigator.credentials) {
// 浏览器不支持Passkey
}
注册流程
这里的注册,不是说用户在一个网站注册账号,而是说,用户把自己的公钥,登记到服务器端的过程。当然,用户可以在新注册账号的时候完成这个动作,不过一般来说,网站都已经按照传统方法设计了用户的注册交互流程,现在最常见的有几种,设定用户名和密码,或者,使用 OAuth 直接登录,或者使用手机号的 OTP 直接登录,并设定用户名等等。就不再引入 Passkey 的流程来增加用户的心智负担了。
所以,更常见的情况是,用户已经在一个网站有自己的账号和登录方式了。现在新增一种登录方式,就是使用 Passkey 来登录。一旦登记完毕,以后就再也不用密码了。显然 Passkey 也可以作为第二因子使用,就是另一个话题了。
第一步,客户端请求服务器,要求创建 Passkey,这里要注意,真正的秘钥对是 Authenticator 创建的,并不是服务器,这个流程只是用于服务器登记的过程。这是服务器需要实现的第一个 API,比如,可以叫做 /passkey/chanllenge
,返回的数据结构如下:
{
"challenge": "gVQ2n5FCAcksuEefCEgQRKJB_xfMF4rJMinTXSP72E8",
"rp": {
"name": "Passkey Example",
"id": "example.com"
},
"user": {
"id": "GOVsRuhMQWNoScmh_cK02QyQwTolHSUSlX5ciH242Y4",
"name": "Michael",
"displayName": "Michael"
},
"pubKeyCredParams": [
{
"alg": -7,
"type": "public-key"
}
],
"timeout": 60000,
"attestation": "none",
"excludeCredentials": [
],
"authenticatorSelection": {
"authenticatorAttachment": "platform",
"requireResidentKey": true,
"residentKey": "required"
},
"extensions": {
"credProps": true
}
}
服务器的第一个 API 返回,除了最重要的值 challenge
(防重放攻击),就是支持的加密算法,用户信息,网站信息等元数据了。
第二步,客户端调用 create()
从验证器创建公私钥对。
let credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
});
获得了秘钥后,需要组织好包,发送给服务器,以下是示例代码:
// 请求参数:
const options = await get_json('/passkey/challenge');
console.log(options);
// 用Base64 URLSafe解码:
options.challenge = base64_urlsafe_decode(options.challenge);
options.user.id = base64_urlsafe_decode(options.user.id);
// 创建Credential,其中Private Key存储在操作系统的密钥管理器中,JavaScript不能获取Private Key:
const cred = await navigator.credentials.create({
publicKey: options
});
console.log(cred);
// 用Base64 URLSafe编码:
const credential = {
id: cred.id,
rawId: base64_urlsafe_encode(cred.rawId),
type: cred.type,
response: {
clientDataJSON: base64_urlsafe_encode(cred.response.clientDataJSON),
attestationObject: base64_urlsafe_encode(cred.response.attestationObject),
transports: cred.response.getTransports ? cred.response.getTransports() : []
}
};
if (cred.authenticatorAttachment) {
credential.authenticatorAttachment = cred.authenticatorAttachment;
}
console.log(credential);
// 发送Credential至服务器:
let createResult = await post_json('/passkey/register', credential);
上面的代码,我们看到了第二个服务器需要实现的 API,就取名叫做 /passkey/register
,这个 API 收到的包体中, clientDataJSON
字段中包含 challenge
,而 attestationObject
里面则包含要注册的 Public Key。具体我不想赘述,参考文献中的廖雪峰:Passkey 开发指南即可。
验证流程
验证流程,实际上和注册过程差不多,首先也是获得一个 challenge
这个作用不再赘述,可以假设这个 API 叫做 /passkey/login-challenge
,得到的返回值。
然后我们需要调用浏览器的 API,来获得一个凭据:
let credential = await navigator.credentials.get({
publicKey: publicKeyCredentialGetOptions
});
然后,我们把这个凭据发往服务器进行登录验证:
// 请求参数:
const options = await get_json('/passkey/login-challenge');
console.log(options);
// 用Base64 URLSafe解码:
options.challenge = base64_urlsafe_decode(options.challenge);
// 创建签名:
const cred = await navigator.credentials.get({
publicKey: options
});
console.log(cred);
// 用Base64 URLSafe编码:
const credential = {
id: cred.id,
rawId: base64_urlsafe_encode(cred.rawId),
type: cred.type,
response: {
clientDataJSON: base64_urlsafe_encode(cred.response.clientDataJSON),
authenticatorData: base64_urlsafe_encode(cred.response.authenticatorData),
userHandle: base64_urlsafe_encode(cred.response.userHandle),
signature: base64_urlsafe_encode(cred.response.signature)
}
};
console.log(credential);
// 发送Credential至服务器:
let createResult = await post_json('/passkey/signin', credential);
登录的时候,服务器需要实现一个 API,比如取名叫 /passkey/signin
,然后将请求验证器得到的 credential,调用服务器的 /signin
API ,就可以完成验证。在这个验证过程中,主要是验证 signature
字段是否是用私钥加密的,也就是公私钥对验证的核心原理。
其中具体字段的含义,和验证的重要步骤,请参考廖雪峰:Passkey 开发指南。
另外,在 Web Authentication API 一文中,有更详细的解释,毕竟这就是规范原文。可惜又臭又长,并不是很好读。如果遇到了什么疑惑,还是应该查询该文。
三、总结
虽然 Passkey,以及背后的规范 WebAuthn,都很复杂,但是理解其基本原理,以及实现一个使用 Passkey 登录的功能,并不复杂,只是客户端和服务器端,都要按照规范,去实现一些 API,即可以完成。工作量不大,却能极大的优化用户的登录体验。只是,在已经有账号的网站上,再次绑定 Passkey 这个流程,并不符合直觉,而在用户不参与下,主动完成 Passkey 的绑定也完全不现实。最多只能做到,主动弹出,邀请用户绑定 Passkey,并要求用户主动配合流程,但是这个本身就是一种骚扰。是不是要用这种手段,请自行权衡。
参考文献
- Web Authentication API Web 认证 API(WebAuthn)是凭证管理 API 的扩展,它通过公钥加密技术实现了强大的身份验证功能,使得无密码认证和不依赖短信的安全多因素认证(MFA)成为可能。
- WebAuthn.io WebAuthn 规范的演示。这里还提供了 TS/Python/Ruby/Java/Go/Swift 等语言的类库列表,用于实现 WebAuthn。
- 廖雪峰:一文搞懂通行秘钥 关于 Passkeys 的基础简介,以及对整个流程的一些截图演示,并且其博客上自己就带有了通行秘钥的登录功能,大家可以体验一下。
- 廖雪峰:Passkeys 开发指南 介绍浏览器的相关 API 的使用范例,以及服务器的协议报文,以及他们的含义。
- [Web Authentication: An API for accessing Public Key Credentials Level 2] 该规范定义了一个 API,该 API 允许 Web 应用创建和使用强大、经过认证、有范围限制的基于公钥的凭证,目的是为了强力认证用户。基本就是定义了浏览器侧的 API 实现和使用范例。
- Passkey vs. WebAuthn: What’s the Difference? 这篇文章辨析了概念 Passkey 和 WebAuthn 的区别。前者是一种登录凭证或者登录方法。而后者则是一种规范,Web 应用如何访问公钥和进行验证的规范。