分析 WebAuthn (FIDO2) 在浏览器端实现无密码认证的流程,包括 Attestation (注册) 和 Assertion (认证) 的密码学细节。

各位观众老爷们,大家好!我是你们的老朋友,江湖人称“代码搬运工”的程序猿大侠。今天,咱们不聊风花雪月,来点硬核的,聊聊 WebAuthn (FIDO2) 这个“无密码认证”的当红炸子鸡,看看它在浏览器端是如何玩转密码学的,让密码这玩意儿彻底退休。

咱们今天的讲座分为两大部分:

  1. Attestation (注册): “我是谁?我从哪里来?我要到哪里去?” —— 设备的身份证明。
  2. Assertion (认证): “芝麻开门!” —— 验证你的身份,安全登录。

第一部分:Attestation (注册) – 设备的“户口本”

想象一下,你要在一个新的国家定居,首先要做的就是办个户口本,证明你的身份和合法性。WebAuthn 的 Attestation 过程就类似,它让你的设备(比如你的指纹识别器、你的安全密钥)向网站证明自己是一个“合格公民”,并且拥有一个独一无二的身份。

1.1 Attestation 的基本流程

  1. 网站发起注册请求 (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);
      }
    }
  2. 浏览器/认证器处理请求: 浏览器拿到请求后,会调用你的认证器(比如指纹识别器)。认证器会生成一个密钥对(公钥和私钥),并且将公钥和一些设备信息打包成一个叫做 attestationObject 的数据结构。

  3. 生成 Attestation Statement: attestationObject 里面包含了关于设备的信息,以及一个叫做 Attestation Statement 的东西。Attestation Statement 就像是设备的“出生证明”,它由认证器的制造商(或者可信的第三方)签名,证明这个设备是合法的。

  4. 浏览器将数据返回给网站: 浏览器会将 attestationObjectclientDataJSON (包含关于注册操作的信息) 返回给网站。

  5. 网站验证 Attestation Statement: 网站拿到 attestationObject 后,会验证 Attestation Statement 的签名,确认这个设备的身份是真实的。 这步是至关重要的,防止伪造设备。

  6. 网站保存公钥: 如果 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:authenticatorDataclientDataHash 的签名。 签名算法由 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 的基本流程

  1. 网站发起认证请求 (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);
      }
    }
  2. 浏览器/认证器处理请求: 浏览器会找到与网站关联的密钥对,并要求用户进行验证(比如指纹验证)。

  3. 生成 Signature: 认证器会使用私钥对一些数据进行签名,生成一个 Signature。 这些数据包括 authenticatorDataclientDataHash

  4. 浏览器将数据返回给网站: 浏览器会将 authenticatorData, clientDataJSON, signatureuserHandle 返回给网站。

  5. 网站验证 Signature: 网站会使用之前保存的公钥来验证 Signature,确认这个用户的身份是真实的。 同时,还会检查 authenticatorData 中的 Sign Count,防止重放攻击。

2.2 Assertion 的密码学细节

在 Assertion 过程中,最关键的就是 Signature 的生成和验证。

  • authenticatorData: 和注册时一样,包含了关于认证器的信息,以及 Sign Count。
  • clientDataHash: clientDataJSON 的 SHA-256 哈希值。
  • signature:authenticatorDataclientDataHash 的签名。

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 的原理和实现,为构建更安全、更便捷的网络世界贡献一份力量!

感谢大家的收听! 下次有机会再和大家分享更多有趣的技术知识! 各位,下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注