Web Crypto API 的正确使用:避免常见的加密误用漏洞。

各位观众老爷们,晚上好! 咱们今天来聊聊 Web Crypto API 这玩意儿。别看它名字挺唬人,好像很高深莫测的样子,其实用好了能帮你加固网站安全,用不好嘛…嘿嘿,那就等着黑客蜀黍上门拜访吧。

咱们今天的主题是:Web Crypto API 的正确使用:避免常见的加密误用漏洞。

我会尽量用大白话,配合代码示例,让大家都能听明白,并且能避免一些常见的坑。准备好了吗?咱们这就开始!

第一部分:Web Crypto API 是个啥?

简单来说,Web Crypto API 就是浏览器提供的一套用于执行加密操作的 JavaScript API。它允许你在客户端执行诸如生成密钥、加密数据、签名数据等操作,而无需依赖服务器。

想象一下,你和妹子用微信聊天,内容需要加密才能保证不被别人偷窥,那么 Web Crypto API 就相当于微信自带的加密引擎,帮你把聊天内容变成只有你和妹子才能看懂的火星文。

第二部分:Web Crypto API 的基本概念

在使用 Web Crypto API 之前,我们需要了解几个核心概念:

  • 算法 (Algorithm): 加密/解密、签名/验证的具体方法,比如 AES、RSA、SHA-256 等。 不同的算法适用于不同的场景。
  • 密钥 (Key): 用于加密/解密或签名/验证的秘密信息。 密钥的安全性至关重要。
  • 密文 (Ciphertext): 经过加密后的数据,是人类无法直接阅读的内容。
  • 明文 (Plaintext): 原始的、未加密的数据,是人类可以阅读的内容。
  • 向量 (IV) / Salt: 用于增加加密随机性的数据,防止相同的明文生成相同的密文。
  • 哈希 (Hash): 将任意长度的数据转换为固定长度的唯一值。 用于数据完整性校验。

咱们用一个表格来总结一下:

概念 解释 举例
算法 用于加密/解密、签名/验证的具体方法。 AES-CBC, RSA-PKCS1.5, SHA-256
密钥 用于加密/解密或签名/验证的秘密信息。 一串随机的二进制数据
密文 经过加密后的数据。 AWESOMESAUCE 加密后变成 j#$@%^&*()
明文 原始的、未加密的数据。 AWESOMESAUCE
向量/Salt 用于增加加密随机性的数据。 一串随机的二进制数据
哈希 将任意长度的数据转换为固定长度的唯一值。 AWESOMESAUCE 哈希后变成 e5b70398397f4474724e22e6089607d3

第三部分:Web Crypto API 的基本用法

Web Crypto API 的入口点是 window.crypto.subtle 对象。 通过它,我们可以访问各种加密操作。

1. 生成密钥 (Key Generation)

首先,我们需要生成密钥。不同的算法需要不同类型的密钥。

  • 对称密钥 (Symmetric Key): 加密和解密使用相同的密钥,例如 AES。
  • 非对称密钥 (Asymmetric Key): 加密和解密使用不同的密钥,例如 RSA。 一个是公钥 (Public Key),用于加密或验证签名;另一个是私钥 (Private Key),用于解密或生成签名。

代码示例 (AES 密钥生成):

async function generateAESKey() {
  try {
    const key = await window.crypto.subtle.generateKey(
      {
        name: "AES-CBC",
        length: 256, // 密钥长度,可以是 128, 192, 或 256
      },
      true, // 是否可以导出密钥
      ["encrypt", "decrypt"] // 密钥用途,可以是 encrypt, decrypt, sign, verify, wrapKey, unwrapKey
    );
    return key;
  } catch (error) {
    console.error("密钥生成失败:", error);
    return null;
  }
}

generateAESKey().then(key => {
  if (key) {
    console.log("AES 密钥生成成功:", key);
  }
});

代码解释:

  • window.crypto.subtle.generateKey(): 用于生成密钥的函数。
  • { name: "AES-CBC", length: 256 }: 指定算法和密钥长度。 这里我们使用 AES-CBC 算法,密钥长度为 256 位。
  • true: 指定密钥是否可以导出。如果设置为 true,你可以使用 exportKey() 函数将密钥导出为 JSON Web Key (JWK) 格式。 注意:除非绝对必要,否则不要导出密钥! 导出密钥会增加密钥泄露的风险。
  • ["encrypt", "decrypt"]: 指定密钥的用途。 这里我们指定密钥用于加密和解密。

代码示例 (RSA 密钥生成):

async function generateRSAKey() {
  try {
    const keyPair = await window.crypto.subtle.generateKey(
      {
        name: "RSA-OAEP",
        modulusLength: 2048, // 密钥长度,至少 2048 位
        publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 公钥指数,通常是 65537
        hash: "SHA-256", // 哈希算法
      },
      true, // 是否可以导出密钥
      ["encrypt", "decrypt"] // 密钥用途
    );
    return keyPair;
  } catch (error) {
    console.error("密钥生成失败:", error);
    return null;
  }
}

generateRSAKey().then(keyPair => {
  if (keyPair) {
    console.log("RSA 密钥生成成功:", keyPair);
  }
});

代码解释:

  • { name: "RSA-OAEP", ... }: 指定 RSA-OAEP 算法,并设置密钥长度、公钥指数和哈希算法。
  • modulusLength: RSA 密钥长度,通常是 2048 位或更高。 密钥长度越长,安全性越高,但计算速度也越慢。
  • publicExponent: 公钥指数,通常是 65537 (0x010001)。
  • hash: 哈希算法,用于 OAEP 填充方案。

2. 加密 (Encryption)

有了密钥,我们就可以对数据进行加密了。

代码示例 (AES 加密):

async function encryptAES(key, plaintext) {
  try {
    const iv = window.crypto.getRandomValues(new Uint8Array(16)); // 生成随机向量 (IV)

    const ciphertext = await window.crypto.subtle.encrypt(
      {
        name: "AES-CBC",
        iv: iv, // 使用向量
      },
      key,
      new TextEncoder().encode(plaintext) // 将明文转换为 Uint8Array
    );

    // 将 IV 和密文合并,方便存储和传输
    const combined = new Uint8Array(iv.length + ciphertext.byteLength);
    combined.set(iv, 0);
    combined.set(new Uint8Array(ciphertext), iv.length);

    return combined;
  } catch (error) {
    console.error("加密失败:", error);
    return null;
  }
}

// 使用示例
generateAESKey().then(key => {
  if (key) {
    const plaintext = "AWESOMESAUCE";
    encryptAES(key, plaintext).then(ciphertext => {
      if (ciphertext) {
        console.log("加密后的数据:", ciphertext);
      }
    });
  }
});

代码解释:

  • window.crypto.getRandomValues(new Uint8Array(16)): 生成 16 字节的随机向量 (IV)。 IV 必须是随机的,且每次加密都应该生成新的 IV。
  • { name: "AES-CBC", iv: iv }: 指定算法和向量。
  • new TextEncoder().encode(plaintext): 将明文转换为 Uint8Array 格式。 Web Crypto API 只能处理 ArrayBufferUint8Array 格式的数据。
  • combined.set(iv, 0); combined.set(new Uint8Array(ciphertext), iv.length);: 将 IV 和密文合并。 这是因为解密时需要用到 IV。

代码示例 (RSA 加密):

async function encryptRSA(publicKey, plaintext) {
  try {
    const ciphertext = await window.crypto.subtle.encrypt(
      {
        name: "RSA-OAEP",
      },
      publicKey,
      new TextEncoder().encode(plaintext)
    );
    return ciphertext;
  } catch (error) {
    console.error("加密失败:", error);
    return null;
  }
}

// 使用示例
generateRSAKey().then(keyPair => {
  if (keyPair) {
    const publicKey = keyPair.publicKey;
    const plaintext = "AWESOMESAUCE";
    encryptRSA(publicKey, plaintext).then(ciphertext => {
      if (ciphertext) {
        console.log("加密后的数据:", ciphertext);
      }
    });
  }
});

代码解释:

  • RSA 加密使用公钥进行加密。

3. 解密 (Decryption)

解密是加密的逆过程。

代码示例 (AES 解密):

async function decryptAES(key, combined) {
  try {
    const iv = combined.slice(0, 16); // 从合并后的数据中提取 IV
    const ciphertext = combined.slice(16); // 从合并后的数据中提取密文

    const plaintext = await window.crypto.subtle.decrypt(
      {
        name: "AES-CBC",
        iv: iv,
      },
      key,
      ciphertext
    );

    return new TextDecoder().decode(plaintext); // 将解密后的数据转换为字符串
  } catch (error) {
    console.error("解密失败:", error);
    return null;
  }
}

// 使用示例
generateAESKey().then(key => {
  if (key) {
    const plaintext = "AWESOMESAUCE";
    encryptAES(key, plaintext).then(ciphertext => {
      if (ciphertext) {
        decryptAES(key, ciphertext).then(decryptedText => {
          console.log("解密后的数据:", decryptedText); // 输出 "AWESOMESAUCE"
        });
      }
    });
  }
});

代码解释:

  • combined.slice(0, 16); combined.slice(16);: 从合并后的数据中提取 IV 和密文。
  • new TextDecoder().decode(plaintext): 将解密后的 ArrayBuffer 转换为字符串。

代码示例 (RSA 解密):

async function decryptRSA(privateKey, ciphertext) {
  try {
    const plaintext = await window.crypto.subtle.decrypt(
      {
        name: "RSA-OAEP",
      },
      privateKey,
      ciphertext
    );
    return new TextDecoder().decode(plaintext);
  } catch (error) {
    console.error("解密失败:", error);
    return null;
  }
}

// 使用示例
generateRSAKey().then(keyPair => {
  if (keyPair) {
    const privateKey = keyPair.privateKey;
    const publicKey = keyPair.publicKey;
    const plaintext = "AWESOMESAUCE";
    encryptRSA(publicKey, plaintext).then(ciphertext => {
      if (ciphertext) {
        decryptRSA(privateKey, ciphertext).then(decryptedText => {
          console.log("解密后的数据:", decryptedText); // 输出 "AWESOMESAUCE"
        });
      }
    });
  }
});

代码解释:

  • RSA 解密使用私钥进行解密。

4. 签名 (Signing) 和 验证 (Verification)

数字签名用于验证数据的完整性和来源。

代码示例 (HMAC 签名和验证):

async function signHMAC(key, data) {
  try {
    const signature = await window.crypto.subtle.sign(
      {
        name: "HMAC",
        hash: "SHA-256", // 指定哈希算法
      },
      key,
      new TextEncoder().encode(data)
    );
    return signature;
  } catch (error) {
    console.error("签名失败:", error);
    return null;
  }
}

async function verifyHMAC(key, signature, data) {
  try {
    const isValid = await window.crypto.subtle.verify(
      {
        name: "HMAC",
        hash: "SHA-256",
      },
      key,
      signature,
      new TextEncoder().encode(data)
    );
    return isValid;
  } catch (error) {
    console.error("验证失败:", error);
    return false;
  }
}

// 使用示例
async function main() {
  const key = await window.crypto.subtle.generateKey(
    {
      name: "HMAC",
      hash: "SHA-256",
    },
    true,
    ["sign", "verify"]
  );

  const data = "This is the data to be signed.";
  const signature = await signHMAC(key, data);

  if (signature) {
    const isValid = await verifyHMAC(key, signature, data);
    console.log("签名是否有效:", isValid); // 输出 true

    // 篡改数据
    const tamperedData = "This is the tampered data.";
    const isStillValid = await verifyHMAC(key, signature, tamperedData);
    console.log("篡改后的签名是否有效:", isStillValid); // 输出 false
  }
}

main();

代码解释:

  • HMAC 算法使用密钥和哈希函数来生成签名。
  • 签名验证需要使用相同的密钥、哈希函数和数据。
  • 如果数据被篡改,签名验证将会失败。

第四部分:常见的加密误用漏洞及防范措施

好了,基本用法咱们都过了一遍。接下来,咱们来聊聊一些常见的坑,以及如何避免掉进去。

1. 使用不安全的算法或模式

  • 问题: 使用已经过时或存在安全漏洞的算法或模式,例如 DES, RC4, MD5, SHA-1, CBC 模式不使用 authenticated encryption (AEAD)。
  • 防范措施:
    • 坚持使用最新、最安全的算法和模式。 推荐使用 AES-GCM (Authenticated Encryption with Associated Data), ChaCha20-Poly1305, RSA-OAEP, ECDSA, Ed25519 等。
    • 避免使用 CBC 模式,除非你非常清楚自己在做什么,并且使用了 authenticated encryption (AEAD)。 CBC 模式容易受到 padding oracle 攻击。
    • 定期更新你的加密库,以修复已知的安全漏洞。 虽然 Web Crypto API 是浏览器提供的,但是浏览器也会定期更新,所以要关注浏览器的更新说明。

2. 密钥管理不当

  • 问题: 将密钥存储在客户端,或者使用弱密钥、硬编码密钥。
  • 防范措施:
    • 永远不要将密钥存储在客户端! 客户端是不安全的,任何存储在客户端的数据都可能被窃取。
    • 使用服务器端密钥管理系统来存储和管理密钥。
    • 使用强密钥。 密钥长度越长,安全性越高。 对于 AES,推荐使用 256 位密钥。 对于 RSA,推荐使用 2048 位或更高位密钥。
    • 使用密码学安全的随机数生成器来生成密钥。 Web Crypto API 的 window.crypto.getRandomValues() 函数就是一个密码学安全的随机数生成器。
    • 定期更换密钥。

3. 向量 (IV) / Salt 使用不当

  • 问题: 使用固定 IV, 重复使用 IV, 或者没有使用足够随机的 Salt。
  • 防范措施:
    • 对于 CBC 模式,必须使用随机的、唯一的 IV。 不要使用固定 IV 或重复使用 IV。
    • 对于密码哈希,必须使用随机的 Salt。 Salt 用于增加哈希的复杂度,防止彩虹表攻击。
    • IV 和 Salt 应该足够长。 对于 AES-CBC,IV 应该是 16 字节。 Salt 至少应该是 16 字节。

4. 错误处理不当

  • 问题: 忽略加密操作中的错误,或者没有正确处理错误。
  • 防范措施:
    • 使用 try...catch 语句来捕获加密操作中的错误。
    • 在发生错误时,记录错误信息,并采取适当的措施,例如重新生成密钥或停止加密操作。
    • 不要向用户显示详细的错误信息,以防止攻击者利用这些信息来攻击你的系统。

5. 混淆加密和哈希

  • 问题: 错误地使用哈希来代替加密。
  • 防范措施:
    • 加密用于保护数据的机密性,而哈希用于验证数据的完整性。
    • 不要使用哈希来存储密码。 应该使用加盐哈希函数,例如 bcrypt, scrypt, Argon2。
    • 不要使用哈希来代替加密。 哈希是单向的,无法将哈希值还原为原始数据。

6. 缺乏安全审计

  • 问题: 没有定期进行安全审计,以发现和修复加密相关的安全漏洞。
  • 防范措施:
    • 定期进行安全审计,以发现和修复加密相关的安全漏洞。
    • 使用静态代码分析工具来检查代码中是否存在加密相关的安全漏洞。
    • 进行渗透测试,以模拟攻击者攻击你的系统,并发现安全漏洞。

7. 跨域问题

  • 问题: 在不同的域名之间共享密钥或密文,导致跨域安全问题。
  • 防范措施:
    • 使用 CORS (Cross-Origin Resource Sharing) 来控制跨域访问。
    • 避免在不同的域名之间共享密钥或密文。
    • 使用安全的跨域通信机制,例如 postMessage。

第五部分:实战演练:安全存储密码

咱们来一个实际的例子,看看如何使用 Web Crypto API 来安全地存储密码。

错误示范:

// 绝对不要这样做!
function storePassword(password) {
  localStorage.setItem("password", password); // 明文存储密码,太可怕了!
}

正确示范 (使用 bcrypt 加盐哈希):

由于 Web Crypto API 本身并没有提供 bcrypt 算法,我们需要借助第三方库来实现。这里我们使用 bcryptjs 库。

// 首先,引入 bcryptjs 库
// 可以通过 npm 安装: npm install bcryptjs

import bcrypt from 'bcryptjs';

async function hashPassword(password) {
  const saltRounds = 10; // Salt 的轮数,越大越安全,但计算时间也越长
  const hash = await bcrypt.hash(password, saltRounds);
  return hash;
}

async function verifyPassword(password, hash) {
  const match = await bcrypt.compare(password, hash);
  return match;
}

// 使用示例
async function main() {
  const password = "AWESOMESAUCE";
  const hashedPassword = await hashPassword(password);
  console.log("哈希后的密码:", hashedPassword);

  const isValid = await verifyPassword(password, hashedPassword);
  console.log("密码是否匹配:", isValid); // 输出 true

  const invalidPassword = "WRONGPASSWORD";
  const isStillValid = await verifyPassword(invalidPassword, hashedPassword);
  console.log("错误的密码是否匹配:", isStillValid); // 输出 false
}

main();

代码解释:

  • bcrypt.hash(password, saltRounds): 使用 bcrypt 算法对密码进行加盐哈希。
  • bcrypt.compare(password, hash): 验证密码是否与哈希值匹配。

注意: bcryptjs 库是纯 JavaScript 实现的,性能可能不如原生实现的 bcrypt 库。 如果对性能有要求,可以考虑使用其他 bcrypt 库,例如 bcrypt (需要 native bindings)。

第六部分:总结

Web Crypto API 是一把双刃剑,用好了可以保护你的数据安全,用不好就会留下安全漏洞。 记住以下几点:

  • 选择安全的算法和模式。
  • 妥善管理密钥。
  • 使用随机的 IV 和 Salt。
  • 正确处理错误。
  • 不要混淆加密和哈希。
  • 定期进行安全审计。

希望今天的讲座对大家有所帮助。 记住,安全无小事,时刻保持警惕,才能构建更安全的 Web 应用。

溜了溜了,下次再见!

发表回复

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