每次登录网站都得输一长串密码,记不住还得点“忘记密码”,这套流程早就该翻篇了。最近圈子里总在聊一种叫“密钥(Passkeys)”的东西,说它能彻底干掉密码登录的破事。这玩意儿到底靠不靠谱?怎么上手?下面把这段时间折腾出来的经验掰扯清楚。
啥是密钥?
密钥(Passkeys)本质上是一种基于公钥密码学的登录凭证,可以理解为“不用记、不怕偷、还能跨设备同步”的超级密码。它背后站着的其实是WebAuthn协议——那个让浏览器和硬件安全设备聊天的标准。但老版WebAuthn有个硬伤:生成的密钥对只锁死在一台设备上,手机丢了就凉凉。密钥(Passkeys)给补上了云同步功能,手机、电脑、平板上的凭证能自动串起来。
具体点说:每个密钥都是一对“双胞胎”——公钥扔给网站服务器随便看,私钥死死锁在设备的安全芯片里或者云端(比如苹果的iCloud钥匙串、谷歌的密码管理器)。登录时,网站甩过来一个随机数,设备用私钥给它签个名,网站用公钥一验,完事。全程不传输私钥,钓鱼网站也骗不走。
这里有几个关键概念得心里有数:
| 术语 | 简单解释 |
|---|---|
| 依赖方 | 要登录的那个网站服务器 |
| 认证器 | 生成和存密钥的软硬件 |
| CTAP | 浏览器和认证器聊天的协议 |
| ES256 | 一种椭圆曲线签名算法 |
| 常驻密钥 | 存在认证器里的可同步凭证 |
实操走起?
下面把注册和登录两个阶段的操作流程拆开细说。整个流程全靠浏览器提供的两个API:navigator.credentials.create 用来注册,navigator.credentials.get 用来登录。后端得配合生成随机挑战值(challenge)并验证签名。
注册凭证
注册阶段也叫证明阶段,相当于给网站绑定一把新钥匙。前端代码大致长这样:
// 从后端捞一个随机挑战值(base64url编码)
const challengeFromServer = "s8f7s9f7s9f7s9f7s9f7s9f...";
// 配置生成参数
const createOptions = {
challenge: base64urlToBuffer(challengeFromServer),
rp: {
id: "example.com", // 网站域名
name: "示例站点"
},
user: {
id: new TextEncoder().encode("user-123456"),
name: "cool_dude",
displayName: "酷哥"
},
pubKeyCredParams: [
{ type: "public-key", alg: -7 }, // 优先用ES256
{ type: "public-key", alg: -257 } // 备选RS256
],
authenticatorSelection: {
residentKey: "required", // 必须生成常驻密钥(即passkey)
userVerification: "preferred" // 尽量用指纹或PIN
},
attestation: "none", // 不搞复杂认证
timeout: 60000
};
const credential = await navigator.credentials.create({
publicKey: createOptions
});敲黑板:residentKey 必须设为 "required",否则生成的是老式单设备凭证,没法云同步。challenge 不能写死,每次注册都得让后端新生成一个随机数,防止重放攻击。
拿到 credential 后,里面藏着公钥、密钥ID、客户端数据等。需要把这些玩意儿打包发给后端:
const pubKeyCred = credential;
const keyId = pubKeyCred.id;
const clientDataJSON = pubKeyCred.response.clientDataJSON;
const attestationObject = pubKeyCred.response.attestationObject;
// 发到后端保存
fetch('/api/register', {
method: 'POST',
body: JSON.stringify({
keyId: keyId,
clientDataJSON: bufferToBase64url(clientDataJSON),
attestationObject: bufferToBase64url(attestationObject)
})
});后端收到后要干几件细活:解码 clientDataJSON,校验 type 是不是 "webauthn.create",校验 origin 是否匹配自己的域名,校验 challenge 跟自己之前发的是否一致。全都过线了,才把公钥和 keyId 存进数据库。
登录验证
登录阶段也叫断言阶段,就是用之前存的私钥给新挑战签名。前端调用 navigator.credentials.get:
// 后端再给一个新鲜挑战
const loginChallenge = "9d8f7a9d8f7a9d8f7a...";
const getOptions = {
challenge: base64urlToBuffer(loginChallenge),
rpId: "example.com", // 只认这个域名的凭证
timeout: 60000,
allowCredentials: [] // 留空数组,让浏览器自动弹出可用的passkey列表
};
const assertion = await navigator.credentials.get({
publicKey: getOptions,
mediation: "optional"
});这里有个讨巧的点:allowCredentials 传空数组,系统会自动把当前域名下存过的所有passkey列出来让选。如果知道具体用哪个 keyId,也可以塞进去过滤。
拿到的 assertion 对象里,response 字段包含了签名(signature)、认证器数据(authenticatorData)、客户端数据(clientDataJSON)等。统统扔给后端去验:
const { signature, authenticatorData, clientDataJSON } = assertion.response;
fetch('/api/login', {
method: 'POST',
body: JSON.stringify({
keyId: assertion.id,
signature: bufferToBase64url(signature),
authenticatorData: bufferToBase64url(authenticatorData),
clientDataJSON: bufferToBase64url(clientDataJSON)
})
});后端拿到后先解码 clientDataJSON,再次校验 type 为 "webauthn.get",校验 origin 和 challenge。然后用 authenticatorData 前32字节跟 origin 的SHA256哈希比对,确保请求没被劫持。最后拿数据库里存的那个公钥去验签名——签名算法必须跟注册时约定的(比如ES256)一致,验过了就放行登录。
另一种路子:手机当桥
碰到电脑系统不支持passkey同步(比如老版Windows或Linux)咋整?可以用手机蓝牙临时搭个桥。操作流程是这样的:电脑上打开登录页,点“使用其他设备”之类的按钮,选“手机”。手机会弹出一个二维码或者蓝牙配对请求。手机扫描后,用手机上的认证器(比如指纹)签个名,然后通过蓝牙把签名传回电脑,电脑再转发给网站服务器。整个过程手机上的私钥从没离开过手机,只是借了电脑的“嘴”跟服务器说话。
这个方案不需要手机和电脑登录同一个账号,甚至手机没联网都行(蓝牙直连)。但注意蓝牙配对时得确认设备名,别连到隔壁老王手机上。
坑点在哪?
先说跨平台同步这事儿。苹果和谷歌的passkey云端各自为政——iCloud钥匙串里的密钥,安卓手机死活读不到。解决办法要么只用一家生态,要么靠密码管理器(比如1Password、Bitwarden)这种第三方来当中间人。目前靠谱的密码管理器已经开始支持导入导出passkey,换平台时可以先导出再导入,就是步骤多了点。
另一个容易翻车的地方是 residentKey 参数。不少人抄代码时漏掉它或者写了 "discouraged",结果生成的是单设备凭证——换台电脑登录就找不到密钥了。所以注册时务必把 residentKey 设成 "required"。如果已经生成错了,只能删掉账号重新注册。
challenge 的有效期也值得唠叨。后端生成的挑战值最好带上时间戳和签名,有效期控制在2分钟内。太长了容易被人抓到包后慢慢重放,太短了用户扫个脸的时间都不够。业界惯用做法是把 challenge 存在session里或者生成JWT,验证通过后立即失效。
signCount 这个字段经常被忽略。在 authenticatorData 的第33到37字节,对于单设备凭证,它每次登录都会递增,后端应该记下上次的值,如果新值不大于旧值,说明密钥可能被克隆了。对于多设备passkey,这个值永远是0,不用校验。写后端逻辑时得根据凭证类型区分处理。
最后说浏览器兼容性。截止目前,Chrome和Edge在Windows上只支持单设备passkey(云同步还在画饼),Safari在Mac和iPhone上支持全功能。开发时可以用 PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() 检测设备是否支持平台认证器,用 PublicKeyCredential.isConditionalMediationAvailable() 检测能不能实现无感登录(即输入框自动提示)。检测不通过就降级回传统密码或短信验证码。
折腾小结
把代码跑通后,会发现登录流程变得贼丝滑——点一下、按个指纹、直接进主页,再也不用跟密码管理器斗智斗勇。虽然跨平台同步还没完全打通,但用第三方密码管理器或蓝牙桥接都能绕过去。以后操作系统原生支持到位了,这套东西大概率会像指纹解锁一样稀松平常。现在上手折腾,等于提前占了个坑。
