各位观众老爷们,大家好!我是你们的老朋友,江湖人称“代码搬运工”的程序猿大侠。今天,咱们不聊风花雪月,来点硬核的,聊聊 WebAuthn (FIDO2) 这个“无密码认证”的当红炸子鸡,看看它在浏览器端是如何玩转密码学的,让密码这玩意儿彻底退休。
咱们今天的讲座分为两大部分:
- Attestation (注册): “我是谁?我从哪里来?我要到哪里去?” —— 设备的身份证明。
- Assertion (认证): “芝麻开门!” —— 验证你的身份,安全登录。
第一部分:Attestation (注册) – 设备的“户口本”
想象一下,你要在一个新的国家定居,首先要做的就是办个户口本,证明你的身份和合法性。WebAuthn 的 Attestation 过程就类似,它让你的设备(比如你的指纹识别器、你的安全密钥)向网站证明自己是一个“合格公民”,并且拥有一个独一无二的身份。
1.1 Attestation 的基本流程
-
网站发起注册请求 (createCredential): 网站告诉浏览器:“嘿,我想让你给用户注册一个无密码的身份。” 这通过
navigator.credentials.create()
方法实现。async function register() { const challenge = generateChallenge(); // 服务器生成的随机数 const rpId = window.location.hostname; // 网站域名 const rpName = "My Awesome Website"; // 网站名称 const userName = "johndoe"; // 用户名 const userId = generateUserId(); // 用户ID const createCredentialOptions = { publicKey: { challenge: base64ToArrayBuffer(challenge), rp: { id: rpId, name: rpName }, user: { id: base64ToArrayBuffer(userId), name: userName, displayName: userName }, pubKeyCredParams: [ { type: "public-key", alg: -7 // ES256 (ECDSA with SHA-256) }, { type: "public-key", alg: -257 // RS256 (RSASSA-PKCS1-v1_5 with SHA-256) } ], attestation: "direct", // 关键!请求直接的 Attestation 证书 timeout: 60000, // 60秒超时 authenticatorSelection: { requireResidentKey: false, // 不要常驻密钥 userVerification: "required", // 需要用户验证 (比如指纹) authenticatorAttachment: "platform" // 平台认证器 (比如指纹识别器) } } }; try { const credential = await navigator.credentials.create(createCredentialOptions); // 将 credential 传给服务器进行验证 console.log("Credential created:", credential); sendCredentialToServer(credential); } catch (error) { console.error("Registration failed:", error); } } // 辅助函数:生成随机数,用户ID,base64转换 function generateChallenge() { // ... 生成随机数的代码 return btoa(String.fromCharCode.apply(null, new Uint8Array(32))); // 示例:返回一个base64编码的32字节随机数 } function generateUserId() { // ... 生成用户ID的代码 return btoa(String.fromCharCode.apply(null, new Uint8Array(16))); // 示例:返回一个base64编码的16字节随机数 } function base64ToArrayBuffer(base64) { const binary_string = window.atob(base64); const len = binary_string.length; const bytes = new Uint8Array( len ); for (let i = 0; i < len; i++){ bytes[i] = binary_string.charCodeAt(i); } return bytes.buffer; } async function sendCredentialToServer(credential) { // ... 将 credential 发送到服务器的代码 // 服务器需要验证 attestation 对象和 clientDataJSON const attestationObject = credential.response.attestationObject; const clientDataJSON = credential.response.clientDataJSON; const rawId = credential.rawId; const id = credential.id; const type = credential.type; const attestationObjectBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(attestationObject))); const clientDataJSONBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(clientDataJSON))); const rawIdBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(rawId))); const registrationData = { id: id, rawId: rawIdBase64, type: type, attestationObject: attestationObjectBase64, clientDataJSON: clientDataJSONBase64 }; try { const response = await fetch('/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(registrationData) }); const data = await response.json(); console.log('Server response:', data); } catch (error) { console.error('Error sending credential to server:', error); } }
-
浏览器/认证器处理请求: 浏览器拿到请求后,会调用你的认证器(比如指纹识别器)。认证器会生成一个密钥对(公钥和私钥),并且将公钥和一些设备信息打包成一个叫做
attestationObject
的数据结构。 -
生成 Attestation Statement:
attestationObject
里面包含了关于设备的信息,以及一个叫做 Attestation Statement 的东西。Attestation Statement 就像是设备的“出生证明”,它由认证器的制造商(或者可信的第三方)签名,证明这个设备是合法的。 -
浏览器将数据返回给网站: 浏览器会将
attestationObject
和clientDataJSON
(包含关于注册操作的信息) 返回给网站。 -
网站验证 Attestation Statement: 网站拿到
attestationObject
后,会验证 Attestation Statement 的签名,确认这个设备的身份是真实的。 这步是至关重要的,防止伪造设备。 -
网站保存公钥: 如果 Attestation Statement 验证通过,网站就会保存这个设备的公钥,将来用于认证。
1.2 Attestation 对象的密码学细节
attestationObject
是一个 CBOR 编码的数据结构,它包含以下关键信息:
字段名 | 类型 | 描述 |
---|---|---|
fmt | string | Attestation Statement 的格式 (比如 "fido-u2f", "packed", "tpm")。不同的格式意味着不同的签名算法和数据结构。 |
attStmt | object | Attestation Statement,包含签名和其他相关数据。 |
authData | bytes | Authenticator Data,包含关于认证器的信息,例如 AAGUID (认证器 GUID),Flags (用户存在性、用户验证等),以及公钥。 |
1.2.1 Authenticator Data (authData)
authData
包含以下信息:
- RP ID Hash: 网站域名 (RP ID) 的 SHA-256 哈希值。
- Flags: 一个字节,包含了关于认证器状态的标志位:
- UP (User Present): 用户是否在场 (比如触摸了指纹识别器)。
- UV (User Verified): 用户是否通过了验证 (比如指纹验证成功)。
- AT (Attested Credential Data): 是否包含 Attested Credential Data。
- ED (Extension Data): 是否包含扩展数据。
- Sign Count: 一个 4 字节的计数器,每次认证都会增加。用于防止重放攻击。
- Attested Credential Data (如果 AT 标志位为真):
- AAGUID (Authenticator Attestation GUID): 认证器的 GUID,用于标识认证器的型号。
- Credential ID: 网站分配给这个密钥对的 ID。
- Credential Public Key: 这个密钥对的公钥,用于后续的认证。
1.2.2 Attestation Statement (attStmt)
attStmt
的内容取决于 fmt
字段指定的格式。 我们以 packed
格式为例,它是最常见的格式之一。
packed
格式的 attStmt
包含以下字段:
- sig: 对
authenticatorData
和clientDataHash
的签名。 签名算法由alg
字段指定。 - alg: 签名算法,例如
-7
(ES256) 或-257
(RS256)。 - x5c (可选): X.509 证书链,用于验证签名。 如果存在,则需要验证证书链的有效性,并确认证书链的根证书是可信的。
1.3 Attestation 验证的步骤 (服务器端代码示例,Node.js):
const cbor = require('cbor');
const crypto = require('crypto');
const base64url = require('base64url');
const jsrsasign = require('jsrsasign'); // 引入jsrsasign库
async function verifyAttestation(attestationObjectBase64, clientDataJSONBase64, rpId) {
const attestationObject = cbor.decodeAllSync(Buffer.from(attestationObjectBase64, 'base64'))[0];
const clientDataJSON = JSON.parse(Buffer.from(clientDataJSONBase64, 'base64').toString('utf8'));
// 1. 验证 clientDataJSON 的 hash 必须与 clientDataJSON 的 SHA-256 哈希值相等
const clientDataHash = crypto.createHash('sha256').update(Buffer.from(clientDataJSONBase64, 'base64')).digest();
if (clientDataJSON.hash !== base64url.encode(clientDataHash)) {
throw new Error('clientDataJSON hash mismatch');
}
// 2. 验证 clientDataJSON 的 type 必须是 "webauthn.create"
if (clientDataJSON.type !== 'webauthn.create') {
throw new Error('clientDataJSON type is not "webauthn.create"');
}
// 3. 验证 clientDataJSON 的 challenge 必须与服务器生成的 challenge 相等
// 假设你已经将 challenge 存储在 session 中
// if (clientDataJSON.challenge !== req.session.challenge) {
// throw new Error('clientDataJSON challenge mismatch');
// }
// 4. 验证 clientDataJSON 的 origin 必须与网站的 origin 相等
if (clientDataJSON.origin !== `https://${rpId}`) { // 或者 http:// 如果你的网站使用 http
throw new Error('clientDataJSON origin mismatch');
}
const fmt = attestationObject.fmt;
const attStmt = attestationObject.attStmt;
const authData = attestationObject.authData;
// 5. 解析 authenticatorData
const parsedAuthData = parseAuthenticatorData(authData);
// 6. 根据 attestation format 进行验证
switch (fmt) {
case 'packed':
await verifyPackedAttestation(attStmt, authData, clientDataHash, parsedAuthData);
break;
// 其他 attestation format 的处理
default:
throw new Error(`Unsupported attestation format: ${fmt}`);
}
// 7. 如果验证通过,提取公钥
const publicKey = parsedAuthData.credentialPublicKey;
return publicKey;
}
function parseAuthenticatorData(authData) {
let offset = 0;
const rpIdHash = authData.slice(offset, offset + 32);
offset += 32;
const flagsBuf = authData.slice(offset, offset + 1);
offset += 1;
const flags = flagsBuf[0];
const signCountBuf = authData.slice(offset, offset + 4);
offset += 4;
const signCount = signCountBuf.readUInt32BE(0);
let attestedCredentialData = null;
if (flags & 0x40) { // AT flag is set
attestedCredentialData = {};
attestedCredentialData.aaguid = authData.slice(offset, offset + 16);
offset += 16;
const credentialIdLengthBuf = authData.slice(offset, offset + 2);
offset += 2;
const credentialIdLength = credentialIdLengthBuf.readUInt16BE(0);
attestedCredentialData.credentialId = authData.slice(offset, offset + credentialIdLength);
offset += credentialIdLength;
attestedCredentialData.credentialPublicKey = cbor.decodeAllSync(authData.slice(offset))[0];
offset += authData.slice(offset).length; // Consume remaining data
}
return {
rpIdHash: rpIdHash,
flags: flags,
signCount: signCount,
attestedCredentialData: attestedCredentialData,
credentialPublicKey: attestedCredentialData ? attestedCredentialData.credentialPublicKey : null
};
}
async function verifyPackedAttestation(attStmt, authData, clientDataHash, parsedAuthData) {
const sig = attStmt.sig;
const alg = attStmt.alg;
const x5c = attStmt.x5c;
const signData = Buffer.concat([authData, clientDataHash]);
if (x5c && x5c.length > 0) {
// 验证证书链
const certificate = jsrsasign. X509();
certificate.readCertPEM(Buffer.from(x5c[0]).toString()); // 假设x5c[0]是PEM格式的证书
// TODO: 验证证书链的有效性,包括过期时间,CA签名等
const publicKey = certificate.getPublicKey();
const isValid = publicKey.verify(signData, Buffer.from(sig), 'SHA256withRSA'); // 或者其他hash算法
if (!isValid) {
throw new Error('Signature verification failed');
}
} else {
// 如果没有证书链,则需要信任认证器制造商的公钥
throw new Error('No certificate chain provided, cannot verify signature');
}
}
// 示例用法
// verifyAttestation(attestationObjectBase64, clientDataJSONBase64, 'example.com')
// .then(publicKey => {
// console.log('Attestation verification successful, public key:', publicKey);
// })
// .catch(err => {
// console.error('Attestation verification failed:', err);
// });
第二部分:Assertion (认证) – “我是我,如假包换!”
注册成功后,你的设备就有了“户口本”,可以证明自己的身份了。 Assertion 过程就是用这个“户口本”去验证你的身份,让你安全登录。
2.1 Assertion 的基本流程
-
网站发起认证请求 (getCredential): 网站告诉浏览器:“嘿,我想让你验证一下这个用户。” 这通过
navigator.credentials.get()
方法实现。async function authenticate() { const challenge = generateChallenge(); // 服务器生成的随机数 const rpId = window.location.hostname; // 网站域名 const userId = getUserId(); // 获取用户ID const getCredentialOptions = { publicKey: { challenge: base64ToArrayBuffer(challenge), rpId: rpId, userVerification: "required", // 需要用户验证 (比如指纹) }, mediation: "conditional" //允许浏览器使用条件中间人 (比如自动填充) }; try { const credential = await navigator.credentials.get(getCredentialOptions); // 将 credential 传给服务器进行验证 console.log("Credential retrieved:", credential); sendAssertionToServer(credential); } catch (error) { console.error("Authentication failed:", error); } } function getUserId() { // ... 获取用户ID的代码 return btoa(String.fromCharCode.apply(null, new Uint8Array(16))); // 示例:返回一个base64编码的16字节随机数 } async function sendAssertionToServer(credential) { const authenticatorData = credential.response.authenticatorData; const clientDataJSON = credential.response.clientDataJSON; const signature = credential.response.signature; const userHandle = credential.response.userHandle; const id = credential.id; const type = credential.type; const authenticatorDataBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(authenticatorData))); const clientDataJSONBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(clientDataJSON))); const signatureBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(signature))); const userHandleBase64 = userHandle ? btoa(String.fromCharCode.apply(null, new Uint8Array(userHandle)) : null; const assertionData = { id: id, type: type, authenticatorData: authenticatorDataBase64, clientDataJSON: clientDataJSONBase64, signature: signatureBase64, userHandle: userHandleBase64 }; try { const response = await fetch('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(assertionData) }); const data = await response.json(); console.log('Server response:', data); } catch (error) { console.error('Error sending assertion to server:', error); } }
-
浏览器/认证器处理请求: 浏览器会找到与网站关联的密钥对,并要求用户进行验证(比如指纹验证)。
-
生成 Signature: 认证器会使用私钥对一些数据进行签名,生成一个 Signature。 这些数据包括
authenticatorData
和clientDataHash
。 -
浏览器将数据返回给网站: 浏览器会将
authenticatorData
,clientDataJSON
,signature
和userHandle
返回给网站。 -
网站验证 Signature: 网站会使用之前保存的公钥来验证 Signature,确认这个用户的身份是真实的。 同时,还会检查
authenticatorData
中的 Sign Count,防止重放攻击。
2.2 Assertion 的密码学细节
在 Assertion 过程中,最关键的就是 Signature 的生成和验证。
authenticatorData
: 和注册时一样,包含了关于认证器的信息,以及 Sign Count。clientDataHash
:clientDataJSON
的 SHA-256 哈希值。signature
: 对authenticatorData
和clientDataHash
的签名。
2.3 Assertion 验证的步骤 (服务器端代码示例,Node.js):
const cbor = require('cbor');
const crypto = require('crypto');
const base64url = require('base64url');
async function verifyAssertion(assertionDataBase64, clientDataJSONBase64, publicKey, signCount, rpId) {
const assertionData = assertionDataBase64;
const clientDataJSON = JSON.parse(Buffer.from(clientDataJSONBase64, 'base64').toString('utf8'));
// 1. 验证 clientDataJSON 的 hash 必须与 clientDataJSON 的 SHA-256 哈希值相等
const clientDataHash = crypto.createHash('sha256').update(Buffer.from(clientDataJSONBase64, 'base64')).digest();
if (clientDataJSON.hash !== base64url.encode(clientDataHash)) {
throw new Error('clientDataJSON hash mismatch');
}
// 2. 验证 clientDataJSON 的 type 必须是 "webauthn.get"
if (clientDataJSON.type !== 'webauthn.get') {
throw new Error('clientDataJSON type is not "webauthn.get"');
}
// 3. 验证 clientDataJSON 的 challenge 必须与服务器生成的 challenge 相等
// 假设你已经将 challenge 存储在 session 中
// if (clientDataJSON.challenge !== req.session.challenge) {
// throw new Error('clientDataJSON challenge mismatch');
// }
// 4. 验证 clientDataJSON 的 origin 必须与网站的 origin 相等
if (clientDataJSON.origin !== `https://${rpId}`) { // 或者 http:// 如果你的网站使用 http
throw new Error('clientDataJSON origin mismatch');
}
const authenticatorData = Buffer.from(assertionData.authenticatorData, 'base64');
const signature = Buffer.from(assertionData.signature, 'base64');
const signData = Buffer.concat([authenticatorData, clientDataHash]);
// 5. 验证签名
const isValid = crypto.verify(null, signData, publicKey, signature); // 使用之前保存的公钥
if (!isValid) {
throw new Error('Signature verification failed');
}
// 6. 解析 authenticatorData
const parsedAuthData = parseAuthenticatorData(authenticatorData);
// 7. 验证 UP (User Present) 标志位是否为真
if (!(parsedAuthData.flags & 0x01)) {
throw new Error('User Present flag not set');
}
// 8. 验证 UV (User Verified) 标志位是否为真 (如果网站要求用户验证)
// if (!(parsedAuthData.flags & 0x04)) {
// throw new Error('User Verified flag not set');
// }
// 9. 验证 Sign Count 是否大于之前的值,防止重放攻击
if (parsedAuthData.signCount <= signCount) {
throw new Error('Sign Count is not greater than previous value');
}
// 如果验证通过,更新 Sign Count
return parsedAuthData.signCount;
}
// 复用之前定义的 parseAuthenticatorData 函数
// 示例用法
// verifyAssertion(assertionData, clientDataJSONBase64, publicKey, lastSignCount, 'example.com')
// .then(newSignCount => {
// console.log('Assertion verification successful, new sign count:', newSignCount);
// // 更新数据库中的 signCount
// })
// .catch(err => {
// console.error('Assertion verification failed:', err);
// });
总结
WebAuthn 的核心思想就是利用公钥密码学,将密码的风险转移到硬件设备上。 通过 Attestation 过程,网站可以验证设备的身份,确认它是可信的。 通过 Assertion 过程,用户可以使用私钥进行签名,证明自己的身份,而无需输入密码。
一些需要注意的点:
- 安全性: WebAuthn 的安全性依赖于认证器的安全性。 如果认证器被破解,用户的身份就会被盗用。
- 隐私: Attestation 过程可能会泄露一些设备信息,需要注意隐私保护。
- 兼容性: WebAuthn 的兼容性取决于浏览器和认证器的支持。
- 错误处理: 在实际开发中,需要处理各种错误情况,例如用户取消操作,认证器不可用等。
代码之外的思考
WebAuthn 是一项革命性的技术,它不仅可以提高安全性,还可以改善用户体验。 但是,要真正实现无密码认证,还需要解决很多问题,例如如何处理设备丢失,如何保护用户隐私,如何提高兼容性等等。 希望今天的讲座能帮助大家更好地理解 WebAuthn 的原理和实现,为构建更安全、更便捷的网络世界贡献一份力量!
感谢大家的收听! 下次有机会再和大家分享更多有趣的技术知识! 各位,下课!