密码登录烦不烦,密钥(Passkeys)到底怎么玩?

4,158字
18–26 分钟
in

每次登录网站都得输一长串密码,记不住还得点“忘记密码”,这套流程早就该翻篇了。最近圈子里总在聊一种叫“密钥(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",校验 originchallenge。然后用 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() 检测能不能实现无感登录(即输入框自动提示)。检测不通过就降级回传统密码或短信验证码。

折腾小结

把代码跑通后,会发现登录流程变得贼丝滑——点一下、按个指纹、直接进主页,再也不用跟密码管理器斗智斗勇。虽然跨平台同步还没完全打通,但用第三方密码管理器或蓝牙桥接都能绕过去。以后操作系统原生支持到位了,这套东西大概率会像指纹解锁一样稀松平常。现在上手折腾,等于提前占了个坑。