最近糊了个 meow-counter(Footer 那个,魔改自 moco),但是统计面板不想公开,所以自然需要一个鉴权方式。我选择了 Passkey,也借此搞通了 Passkey 的验证流程。
为什么选择 Passkey?
我不喜欢输密码,基于公钥认证的系统让我感觉更安心。就像使用 SSH,我也是仅支持公钥验证,完全不需要密码,安全性更高且劝退暴力破解者。
对于普通用户,Passkey 解决了密码泄露、钓鱼攻击等问题,简化了身份验证流程(无需额外的 2FA)。
不足之处
Passkey 通常绑定到设备或其生态系统(如 iCloud、Google 账号)。目前,除了使用 KeePassXC 外,几乎没有导出和导入的手段。 更换设备或丢失绑定设备时,可能会面临身份验证困难。 未来或许会有更多密码管理器支持。 1
Passkey 必需依赖 Javascript,不能纯 HTML 了。 2
注册与登录流程
注册流程
登录流程
数据库
sign_count 用于防止 passkey 被克隆。比如 yubikey 每次签名都会计数,key 返回的计数如果小于记录的计数,则说明存在克隆的 key。
但是水果和一众密码管理器都没实现这个特征(返回 0),或许是同步计数器比较麻烦。3
容易被克隆的软件实现都没实现(密码管理器的同步功能做的事情确实是在克隆),难以克隆的硬件反而有
API 使用指南
环境要求
- 服务端需要支持 HTTPS,也可以是
localhost(WebAuthn 需要 Secure contexts)。 - 后端推荐使用现成的库,比如 Node.js 的 simplewebauthn。不然验证挺繁琐的。
下面都算伪代码,实际操作中 ArrayBuffer 编码为 Base64URL
用户注册
前端需要输入用户名,请求 /api/registerStart,获取
publicKeyCredentialCreationOptions。
const publicKeyCredentialCreationOptions = {
challenge: Uint8Array.from(
randomStringFromServer,
(c) => c.charCodeAt(0),
),
rp: {
name: "Meow Counter",
id: "example.com", // 浏览器会验证当前域名与 RP ID 是否匹配
},
user: {
id: Uint8Array.from(
"UZSL85T9AFC",
(c) => c.charCodeAt(0),
),
name: "lee@example.com",
displayName: "Lee",
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }], // 接受的算法
authenticatorSelection: {
authenticatorAttachment: "cross-platform", // 平台外部认证器
},
timeout: 60000,
attestation: "direct",
};
challenge:
challenge 应该是一次性且短期有效的,使用后作废
- challenge 在 KV 中存储,应使用 Expired key。
- 每次请求新的 challenge,旧 challenge 立即作废。
rp:
假设你的 RP ID 设置为 example.com,只有在 example.com 或其子域 login.example.com 上调用 Passkey,验证会成功。
authenticatorAttachment:
- platform:使用本地认证器(如 Windows Hello、Face ID)。
- cross-platform:使用外部认证器(如 YubiKey,密码管理器)。
前端创建凭据
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
});
console.log(credential);
PublicKeyCredential {
id: 'ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcHDTBsv4NSSX9...',
rawId: ArrayBuffer(59),
response: AuthenticatorAttestationResponse {
clientDataJSON: ArrayBuffer(121),
attestationObject: ArrayBuffer(306),
},
type: 'public-key'
}
post('/api/registerFinish', { credential });
服务端验证注册
服务端需要保存 challenge,以供验证使用。
const { verifyRegistrationResponse } = require("@simplewebauthn/server");
const verification = verifyRegistrationResponse({
response: clientResponse, // 前端返回的凭证数据
expectedChallenge: storedChallenge,
expectedOrigin: "https://example.com",
expectedRPID: "example.com",
});
if (verification.verified) {
saveToDatabase({
credentialID: verification.registrationInfo.credentialID,
publicKey: verification.registrationInfo.credentialPublicKey,
});
}
verifyRegistrationResponse 会解析 clientDataJSON,将 challenge
与之前保存的进行比较以验证是否对应于当前注册事件。然后从 attestationObject
中获取 credential.id 和 publicKey,并将它们存储到数据库中。
用户登录
服务端生成 challenge
const publicKeyCredentialRequestOptions = {
challenge: Uint8Array.from(
randomStringFromServer,
(c) => c.charCodeAt(0),
),
allowCredentials: [{
id: Uint8Array.from(
credentialId,
(c) => c.charCodeAt(0),
), // optional,注册时保存的 credentialID
type: "public-key",
transports: ["usb", "ble", "nfc"], // 支持的认证器传输方式
}],
timeout: 60000,
};
前端触发认证
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
});
console.log(assertion);
PublicKeyCredential {
id: 'ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcHDTBsv4NSSX9...',
rawId: ArrayBuffer(59),
response: AuthenticatorAssertionResponse {
authenticatorData: ArrayBuffer(191),
clientDataJSON: ArrayBuffer(118),
signature: ArrayBuffer(70),
userHandle: ArrayBuffer(10), // optional, 注册时提供的用户ID。用于与服务器上的用户关联起来。服务端未提供 allowCredentialDescriptorList 的时候,服务端可以通过 userHandle 确定用户。
},
type: 'public-key'
}
post('/api/loginFinish', { assertion });
服务端验证登录
const { verifyAuthenticationResponse } = require("@simplewebauthn/server");
const verification = verifyAuthenticationResponse({
response: clientResponse, // 前端返回的数据
expectedChallenge: storedChallenge,
expectedOrigin: "https://example.com",
expectedRPID: "example.com",
credential: storedCredential, // 注册时保存的 credentialId 和 publicKey
});
if (verification.verified) {
// 用户验证成功
grantAccessToUser();
}
verifyAuthenticationResponse 验证 signature 和 clientDataJSON 是否与存储的 publicKey 匹配,确认登录成功。
