阐述 `WebAuthn` (`FIDO2`) 在浏览器端实现无密码认证的流程,包括 `Attestation` 和 `Assertion`。

各位靓仔靓女,晚上好!我是你们的老朋友,今天我们来聊聊WebAuthn,这玩意儿听起来高大上,其实就是让你的网站告别密码,拥抱未来。准备好了吗? Let’s dive in!

WebAuthn: 密码已死,我来接班!

WebAuthn(Web Authentication API)是W3C的一个标准,它与FIDO2联盟的CTAP(Client to Authenticator Protocol)协议一起,组成了一套完整的无密码认证解决方案。简单来说,它让你的浏览器和你的身份验证器(比如指纹识别器、安全密钥)直接对话,不再需要用户输入密码。

核心概念:Attestation 和 Assertion

WebAuthn的核心流程可以分为两部分:

  1. Attestation (注册/认证器证明): 告诉网站“嘿,我是一个靠谱的认证器,我生成的密钥你可以信任!”
  2. Assertion (认证/密钥断言): 证明“嘿,我是这个用户,我拥有这个密钥,让我登录吧!”

这两个过程分别发生在用户注册和登录的时候。

第一幕:Attestation – 认证器自我介绍

当用户第一次在你的网站上注册时,Attestation流程就开始了。这个流程就像一个认证器向你的网站出示它的“身份证”,证明它是经过认证的,并且生成的密钥是安全的。

  1. 网站发起注册请求 (Registration Request):

    网站首先会向浏览器发起一个注册请求,这个请求包含了各种参数,比如网站的域名(rpId),用户的ID(user.id),以及认证器的偏好设置(比如是否需要用户验证,支持哪些算法等)。

    async function registerUser() {
      const options = {
        publicKey: {
          rp: {
            name: 'My Awesome Website',
            id: window.location.hostname
          },
          user: {
            id: new Uint8Array(16), // 用户的唯一ID,通常是随机生成的
            name: '[email protected]',
            displayName: 'John Doe'
          },
          challenge: new Uint8Array(32), // 服务器生成的随机挑战值
          pubKeyCredParams: [
            { type: 'public-key', alg: -7 },  // ES256
            { type: 'public-key', alg: -257 } // RS256
          ],
          attestation: 'direct', //  'none', 'indirect', or 'direct'
          authenticatorSelection: {
            residentKey: 'discouraged',
            userVerification: 'preferred', // 'required', 'preferred', or 'discouraged'
            requireResidentKey: false
          },
          timeout: 60000, // 超时时间 (毫秒)
          extensions: {
            // 可选的扩展
          }
        }
      };
    
      // 将 ArrayBufferView 转换为 Base64URL 字符串
      function arrayBufferToBase64URLString(arrayBuffer) {
          return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)))
              .replace(/+/g, '-')
              .replace(///g, '_')
              .replace(/=+$/, '');
      }
    
      // 将 options 中的 ArrayBufferView 转换为 Base64URL 字符串
      const transformedOptions = {
          publicKey: {
              ...options.publicKey,
              challenge: arrayBufferToBase64URLString(options.publicKey.challenge),
              user: {
                  ...options.publicKey.user,
                  id: arrayBufferToBase64URLString(options.publicKey.user.id),
              }
          }
      };
    
      // 将 transformedOptions 发送给后端,后端返回服务器生成的 challenge 和 userId
      const response = await fetch('/register/start', {
          method: 'POST',
          headers: {
              'Content-Type': 'application/json'
          },
          body: JSON.stringify(transformedOptions)
      });
    
      const registrationOptions = await response.json();
    
      // 将 Base64URL 字符串转换回 ArrayBufferView
      function base64URLStringToArrayBuffer(base64URLString) {
          const padding = '='.repeat((4 - base64URLString.length % 4) % 4);
          const base64 = (base64URLString + padding)
              .replace(/-/g, '+')
              .replace(/_/g, '/');
    
          const rawData = atob(base64);
          const outputArray = new Uint8Array(rawData.length);
    
          for (let i = 0; i < rawData.length; ++i) {
              outputArray[i] = rawData.charCodeAt(i);
          }
          return outputArray;
      }
    
      const finalOptions = {
        publicKey: {
          ...registrationOptions.publicKey,
          challenge: base64URLStringToArrayBuffer(registrationOptions.publicKey.challenge),
          user: {
            ...registrationOptions.publicKey.user,
            id: base64URLStringToArrayBuffer(registrationOptions.publicKey.user.id)
          },
          pubKeyCredParams: registrationOptions.publicKey.pubKeyCredParams.map(param => ({
              ...param,
              alg: parseInt(param.alg) // 将 alg 转换为数字
          })),
          attestation: options.publicKey.attestation,
          timeout: 60000
        }
      }
    
      // 调用 WebAuthn API
      let credential;
      try{
          credential = await navigator.credentials.create(finalOptions);
      } catch(error){
          console.error("注册失败:", error);
          return;
      }
    
      // 处理注册结果
      const credentialResponse = {
          id: credential.id,
          rawId: arrayBufferToBase64URLString(credential.rawId),
          type: credential.type,
          response: {
              attestationObject: arrayBufferToBase64URLString(credential.response.attestationObject),
              clientDataJSON: arrayBufferToBase64URLString(credential.response.clientDataJSON),
          },
          clientExtensionResults: credential.getClientExtensionResults()
      };
      // 发送注册结果到服务器
      const registrationResultResponse = await fetch('/register/finish', {
          method: 'POST',
          headers: {
              'Content-Type': 'application/json'
          },
          body: JSON.stringify(credentialResponse)
      });
    
      const registrationResult = await registrationResultResponse.json();
      if (registrationResult.status === 'ok') {
          alert('注册成功!');
      } else {
          alert('注册失败: ' + registrationResult.message);
      }
    }
  2. 浏览器向认证器发起注册请求:

    浏览器接收到网站的注册请求后,会调用WebAuthn API,向认证器发起注册请求。 这个过程可能涉及到用户交互,比如要求用户触摸安全密钥,或者扫描指纹。

  3. 认证器生成密钥对:

    认证器接收到注册请求后,会生成一个非对称密钥对:一个私钥(只有认证器知道),一个公钥。

  4. 认证器生成 Attestation Statement:

    认证器会生成一个Attestation Statement,这个Statement包含了以下信息:

    • 生成的公钥
    • 认证器的信息(比如厂商、型号等)
    • 一个签名,用于证明这个Statement是由认证器生成的

    这个Attestation Statement就像认证器的“身份证”,证明它生成的密钥是可信的。

  5. 浏览器将注册结果返回给网站:

    浏览器将认证器返回的注册结果(包括公钥、Attestation Statement等)发送给网站。

  6. 网站验证 Attestation Statement:

    网站接收到注册结果后,会验证Attestation Statement,确认认证器是可信的,并且生成的密钥是安全的。 这一步通常涉及到与认证器的可信根证书进行比对。

  7. 网站保存用户信息和公钥:

    如果Attestation Statement验证通过,网站会将用户的ID和公钥保存到数据库中。 这样,以后用户就可以使用这个公钥来进行登录了。

Attestation 的代码示例 (后端验证):

以下是一个简单的Node.js后端验证Attestation的示例代码 (简化版,实际应用中需要更严谨的验证):

const crypto = require('crypto');
const cbor = require('cbor');
const cose = require('cose-js');

async function verifyAttestation(attestationObject, clientDataJSON) {
    const attestation = cbor.decodeAllSync(Buffer.from(attestationObject, 'base64'))[0];
    const clientData = JSON.parse(Buffer.from(clientDataJSON, 'base64').toString());

    // 验证 clientData 的 hash
    const clientDataHash = crypto.createHash('sha256').update(Buffer.from(clientDataJSON, 'base64')).digest();
    if (!Buffer.from(attestation.authData.slice(37)).equals(clientDataHash)) {
        throw new Error('clientData hash verification failed');
    }

    // 验证 rpIdHash
    const rpIdHash = crypto.createHash('sha256').update(Buffer.from('your_website_rp_id')).digest();
    if (!Buffer.from(attestation.authData.slice(4, 36)).equals(rpIdHash)) {
        throw new Error('rpIdHash verification failed');
    }

    // 验证 flags
    const flags = attestation.authData[0];
    const userPresent = flags & 0x01;
    const userVerified = flags & 0x04;
    const attestedCredentialDataIncluded = flags & 0x40;

    if (!userPresent) {
        throw new Error('User presence flag not set');
    }

    // 验证 attestation statement
    const fmt = attestation.fmt;
    const attStmt = attestation.attStmt;

    if (fmt === 'packed') {
        // 验证 packed attestation
        const alg = attStmt.alg;
        const sig = attStmt.sig;
        const x5c = attStmt.x5c;
        const pubArea = attestation.authData.slice(0, attestation.authData.length); // Simplified

        // 这只是一个占位符,实际验证需要使用证书链进行验证
        // 并且需要针对不同的 attestation format 进行不同的处理
        if (!x5c || x5c.length === 0) {
            throw new Error('x5c is missing');
        }
        return true;
    } else if (fmt === 'none') {
        // 无 attestation
        return true;
    } else {
        throw new Error('Unsupported attestation format: ' + fmt);
    }
}

第二幕:Assertion – 我真的是我!

当用户尝试登录时,Assertion流程就开始了。这个流程就像用户使用私钥对一个挑战值进行签名,证明他们拥有与之前注册的公钥对应的私钥。

  1. 网站发起登录请求 (Authentication Request):

    网站首先会向浏览器发起一个登录请求,这个请求包含了各种参数,比如网站的域名(rpId),以及一个服务器生成的随机挑战值(challenge)。

    async function loginUser() {
      const options = {
        publicKey: {
          challenge: new Uint8Array(32), // 服务器生成的随机挑战值
          rpId: window.location.hostname,
          allowCredentials: [], // 允许的凭证,通常是之前注册的 credential 的 ID
          userVerification: 'preferred', // 'required', 'preferred', or 'discouraged'
          timeout: 60000, // 超时时间 (毫秒)
        }
      };
    
      // 将 ArrayBufferView 转换为 Base64URL 字符串
      function arrayBufferToBase64URLString(arrayBuffer) {
          return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)))
              .replace(/+/g, '-')
              .replace(///g, '_')
              .replace(/=+$/, '');
      }
    
      // 将 options 中的 ArrayBufferView 转换为 Base64URL 字符串
      const transformedOptions = {
          publicKey: {
              ...options.publicKey,
              challenge: arrayBufferToBase64URLString(options.publicKey.challenge),
          }
      };
    
      const response = await fetch('/login/start', {
          method: 'POST',
          headers: {
              'Content-Type': 'application/json'
          },
          body: JSON.stringify(transformedOptions)
      });
    
      const authenticationOptions = await response.json();
    
      // 将 Base64URL 字符串转换回 ArrayBufferView
      function base64URLStringToArrayBuffer(base64URLString) {
          const padding = '='.repeat((4 - base64URLString.length % 4) % 4);
          const base64 = (base64URLString + padding)
              .replace(/-/g, '+')
              .replace(/_/g, '/');
    
          const rawData = atob(base64);
          const outputArray = new Uint8Array(rawData.length);
    
          for (let i = 0; i < rawData.length; ++i) {
              outputArray[i] = rawData.charCodeAt(i);
          }
          return outputArray;
      }
    
      const finalOptions = {
        publicKey: {
          ...authenticationOptions.publicKey,
          challenge: base64URLStringToArrayBuffer(authenticationOptions.publicKey.challenge),
          allowCredentials: authenticationOptions.publicKey.allowCredentials.map(cred => ({
            ...cred,
            id: base64URLStringToArrayBuffer(cred.id)
          })),
          timeout: 60000
        }
      };
    
      // 调用 WebAuthn API
      let credential;
      try {
          credential = await navigator.credentials.get(finalOptions);
      } catch (error) {
          console.error("登录失败:", error);
          return;
      }
    
      // 处理登录结果
      const credentialResponse = {
          id: credential.id,
          rawId: arrayBufferToBase64URLString(credential.rawId),
          type: credential.type,
          response: {
              authenticatorData: arrayBufferToBase64URLString(credential.response.authenticatorData),
              clientDataJSON: arrayBufferToBase64URLString(credential.response.clientDataJSON),
              signature: arrayBufferToBase64URLString(credential.response.signature),
              userHandle: credential.response.userHandle ? arrayBufferToBase64URLString(credential.response.userHandle) : null,
          },
          clientExtensionResults: credential.getClientExtensionResults()
      };
    
      // 发送登录结果到服务器
      const authenticationResultResponse = await fetch('/login/finish', {
          method: 'POST',
          headers: {
              'Content-Type': 'application/json'
          },
          body: JSON.stringify(credentialResponse)
      });
    
      const authenticationResult = await authenticationResultResponse.json();
      if (authenticationResult.status === 'ok') {
          alert('登录成功!');
      } else {
          alert('登录失败: ' + authenticationResult.message);
      }
    }
  2. 浏览器向认证器发起登录请求:

    浏览器接收到网站的登录请求后,会调用WebAuthn API,向认证器发起登录请求。 这个过程同样可能涉及到用户交互。

  3. 认证器使用私钥对挑战值进行签名:

    认证器接收到登录请求后,会使用与之前注册的公钥对应的私钥,对网站提供的挑战值进行签名。

  4. 浏览器将登录结果返回给网站:

    浏览器将认证器返回的签名和其他相关信息(比如Authenticator Data)发送给网站。

  5. 网站验证签名:

    网站接收到登录结果后,会使用之前保存的公钥,验证认证器返回的签名。

  6. 网站验证 Authenticator Data:

    网站还需要验证Authenticator Data,确保用户存在,并且已经通过验证(比如指纹识别)。

  7. 网站允许用户登录:

    如果签名和Authenticator Data验证通过,网站就认为用户是可信的,允许用户登录。

Assertion 的代码示例 (后端验证):

以下是一个简单的Node.js后端验证Assertion的示例代码 (简化版,实际应用中需要更严谨的验证):

const crypto = require('crypto');
const cbor = require('cbor');

async function verifyAssertion(authenticatorData, clientDataJSON, signature, publicKey) {
    const clientData = JSON.parse(Buffer.from(clientDataJSON, 'base64').toString());
    const authenticatorDataBuffer = Buffer.from(authenticatorData, 'base64');
    const signatureBuffer = Buffer.from(signature, 'base64');

    // 验证 clientData 的 hash
    const clientDataHash = crypto.createHash('sha256').update(Buffer.from(clientDataJSON, 'base64')).digest();
    if (!Buffer.from(authenticatorDataBuffer.slice(37)).equals(clientDataHash)) {
        throw new Error('clientData hash verification failed');
    }

    // 验证 rpIdHash
    const rpIdHash = crypto.createHash('sha256').update(Buffer.from('your_website_rp_id')).digest();
    if (!Buffer.from(authenticatorDataBuffer.slice(4, 36)).equals(rpIdHash)) {
        throw new Error('rpIdHash verification failed');
    }

    // 验证 flags
    const flags = authenticatorDataBuffer[0];
    const userPresent = flags & 0x01;
    const userVerified = flags & 0x04;

    if (!userPresent) {
        throw new Error('User presence flag not set');
    }

    // 创建验证数据
    const verificationData = Buffer.concat([
        authenticatorDataBuffer,
        crypto.createHash('sha256').update(Buffer.from(clientDataJSON, 'base64')).digest()
    ]);

    // 验证签名
    const verifier = crypto.createVerify('sha256');
    verifier.update(verificationData);
    const isVerified = verifier.verify(publicKey, signatureBuffer);

    if (!isVerified) {
        throw new Error('Signature verification failed');
    }

    return true;
}

WebAuthn 的优势:

  • 安全性: WebAuthn使用硬件级别的安全机制,比如安全密钥、指纹识别器等,比传统的密码认证更加安全。私钥永远不会离开认证设备。
  • 易用性: 用户只需要触摸安全密钥或者扫描指纹,就可以完成认证,无需记忆和输入密码。
  • 防钓鱼: WebAuthn认证过程基于域名绑定,可以有效防止钓鱼攻击。
  • 跨平台: WebAuthn是W3C标准,得到了主流浏览器和操作系统的支持。

WebAuthn 的挑战:

  • 认证器丢失: 如果用户丢失了认证器,就需要进行恢复流程。
  • 兼容性: 虽然WebAuthn得到了广泛支持,但仍然有一些老旧的浏览器和设备不支持。
  • 复杂性: WebAuthn的实现细节比较复杂,需要一定的技术积累。

总结:

WebAuthn是一项非常有前景的无密码认证技术,它可以提高网站的安全性,改善用户体验。 虽然WebAuthn的实现细节比较复杂,但是随着WebAuthn的普及,相信会有越来越多的开发者掌握这项技术。

表格总结:

流程 步骤 描述
Attestation 1. 网站发起注册请求 网站向浏览器发送注册请求,包含网站域名、用户ID、认证器偏好设置等信息。
2. 浏览器向认证器发起注册请求 浏览器调用WebAuthn API,向认证器发起注册请求。
3. 认证器生成密钥对 认证器生成一个非对称密钥对:私钥(认证器持有)和公钥。
4. 认证器生成 Attestation Statement 认证器生成一个包含公钥、认证器信息和签名的 Attestation Statement,证明密钥的可信度。
5. 浏览器将注册结果返回给网站 浏览器将公钥和 Attestation Statement 发送给网站。
6. 网站验证 Attestation Statement 网站验证 Attestation Statement,确认认证器可信,密钥安全。
7. 网站保存用户信息和公钥 网站将用户信息和公钥保存到数据库。
Assertion 1. 网站发起登录请求 网站向浏览器发送登录请求,包含网站域名和服务器生成的随机挑战值。
2. 浏览器向认证器发起登录请求 浏览器调用WebAuthn API,向认证器发起登录请求。
3. 认证器使用私钥对挑战值进行签名 认证器使用与注册时公钥对应的私钥,对挑战值进行签名。
4. 浏览器将登录结果返回给网站 浏览器将签名和Authenticator Data发送给网站。
5. 网站验证签名 网站使用注册时保存的公钥验证签名。
6. 网站验证 Authenticator Data 网站验证 Authenticator Data,确认用户存在且已通过验证。
7. 网站允许用户登录 如果签名和Authenticator Data验证通过,允许用户登录。

彩蛋:

最后,记住一点:安全无小事,代码需谨慎。 祝大家早日告别密码,拥抱WebAuthn的美好未来!下课!

发表回复

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