前端安全必修课:用 Web Cryptography API 玩转加密、签名、派生与密钥管理

注意:Web Crypto 只能在安全上下文(HTTPS 或 localhost)中使用;某些细节(如签名的编码形式、浏览器行为)在不同浏览器间可能略有不同,后面会在"潜在问题"中详细说明。


1 生成随机数(Math.random 的缺点 + crypto.getRandomValues 生成浮点数示例)

概念

  • Math.random() :伪随机,非加密安全(预测性可能高),生成的随机位数有限(实现相关)。
  • crypto.getRandomValues() :来源于操作系统的加密安全随机数,适合生成密钥、IV、nonce、令牌等。

原理

  • Math.random() 由 JS 引擎提供,旨在统计随机性(非密码学用途)。攻击者在一些情况下可预测其序列。
  • crypto.getRandomValues() 调用底层 CSPRNG(如 /dev/urandom 或操作系统安全 API),不可预测,适用于加密场景。

对比

  • 安全性:crypto.getRandomValues >> Math.random
  • 用途:会话ID、密钥、IV、nonce 使用 crypto.getRandomValues;非安全目的(简单动画)可用 Math.random

实践(生成浮点随机数的示例)

下面示例演示如何用 crypto.getRandomValues 生成 [0, 1) 的浮点数,并说明为什么要用 Uint32 并除以 2**32(避免偏差)。

javascript 复制代码
// 工具:ArrayBuffer -> 十六进制字符串
function bufToHex(buffer) {
  const bytes = new Uint8Array(buffer);
  return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}

// 使用 crypto.getRandomValues 生成安全的 [0,1) 浮点数
function secureRandomFloat() {
  // 1) 创建一个 32 位无符号整型数组(4 字节)
  const arr = new Uint32Array(1);
  // 2) 用操作系统 CSPRNG 填充
  crypto.getRandomValues(arr);
  // 3) 将 32 位整数除以 2^32 得到 [0,1) 的浮点数
  //    注意使用 2**32 而不是 2**32 - 1 可以保证均匀分布到 [0,1)
  return arr[0] / 2 ** 32;
}

// 示例:生成 5 个随机浮点数并打印
for (let i = 0; i < 5; i++) {
  console.log(secureRandomFloat());
}

行内注释(逐行解读)

  1. Uint32Array(1):准备一个 32 位的容器(足够多位保证高精度)。
  2. crypto.getRandomValues(arr):用安全随机填充容器。
  3. arr[0] / 2**32:把整数映射到 [0,1),避免对 Math.random() 的依赖与预测性问题。

拓展

  • 如果需要随机布尔、字节数组、范围内整数,使用 getRandomValues + 取模/区间重采样(要采用拒绝采样以避免偏差)。
  • 若需要密码学随机位流用于密钥,直接使用 getRandomValues 获取足够字节。

潜在问题

  • 不要用 Math.random() 生成密钥或 IV;会带来可预测性风险。
  • 在低内存嵌入环境或旧浏览器可能不支持 crypto.getRandomValues(请降级处理或不给出安全保证)。

2 SubtleCrypto 简介与各功能(总览)

概念

  • window.crypto.subtle 是浏览器提供的高层加密 API(称为 SubtleCrypto),提供摘要、生成/导入/导出密钥、签名/验证、加密/解密、派生密钥、包装/解包密钥等功能。

原理

  • 它暴露在 安全上下文 中,底层委托给操作系统或浏览器实现,返回 Promise、处理 ArrayBuffer

对比

  • 与使用 JS 实现(如 SJCL):性能更好、安全性更好(硬件加速/系统 CSPRNG)。
  • 与 Node.js crypto:接口不同,但能完成相同任务;在浏览器中使用 SubtleCrypto

实践

下面各小节给出细化内容与完整示例。


2.1 生成密码学摘要(SHA-1, SHA-2 系列)

目标 :展示 SHA-256 生成消息摘要,并把 ArrayBuffer 转成 hex/string;再给出 SHA-512 验证摘要示例。

概念

  • 摘要(hash)将任意长度输入压缩为固定长度(例如 SHA-256 -> 256 bit)。
  • 常用于消息完整性、签名前的摘要、密钥派生中。

原理

  • crypto.subtle.digest('SHA-256', data) 返回 Promise<ArrayBuffer>dataArrayBuffer 或 TypedArray。

实践(示例代码)

javascript 复制代码
// 工具函数:字符串 -> ArrayBuffer
function str2ab(str) {
  return new TextEncoder().encode(str).buffer;
}

// 工具函数:ArrayBuffer -> hex 字符串
function ab2hex(buf) {
  const view = new Uint8Array(buf);
  
  
  console.log("5".padStart(3, "0")); // "005"
  console.log("5".padEnd(3, "0"));   // "500"

  return Array.from(view).map(b => b.toString(16).padStart(2, '0')).join('');
}

// 计算 SHA-256 摘要并返回 hex
async function sha256Hex(message) {
  // 1) 将字符串转为 ArrayBuffer
  const data = str2ab(message); 
  // 2) 调用 SubtleCrypto.digest(返回 ArrayBuffer)
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  // 3) 转成可读 hex
  return ab2hex(hashBuffer);
}

// 示例:计算并打印
(async () => {
  const msg = "hello, world";
  const digestHex = await sha256Hex(msg);
  console.log("SHA-256 (hex):", digestHex);
})();

逐行注释要点

  • TextEncoder().encode 把 UTF-8 字符串编码为 Uint8Array(兼容所有文本)。
  • crypto.subtle.digest('SHA-256', data) 返回 ArrayBuffer,之后转换为 hex 以便显示/比较。

SHA-512 验证摘要示例

ini 复制代码
async function verifySha512(message, expectedHex) {
  const data = str2ab(message);
  const hashBuf = await crypto.subtle.digest('SHA-512', data);
  const actualHex = ab2hex(hashBuf);
  return actualHex === expectedHex; // 返回 boolean,指示是否匹配
}

说明 :比较摘要时注意使用恒时比较(在某些高安全性场景要避免时间侧信道),不过在浏览器 JS 层面直接 === 比较通常足够用于非高强度攻击模型。

拓展

  • SHA-1 已被逐步淘汰,避免用于安全场景。优先选择 SHA-256 或更强的 SHA-512。
  • 对超大文件进行分块哈希时需使用流式方法(浏览器端需分段读取并自行组合,SubtleCrypto 不直接提供流哈希)。

潜在问题

  • 不同浏览器对 digest 支持的算法可能有差异(一般支持 SHA-1/SHA-256/SHA-384/SHA-512)。
  • 输入编码问题(确保统一使用 UTF-8)。

2.2 CryptoKey 与算法(支持算法与使用场景说明)

概念

  • CryptoKey 是 SubtleCrypto 表示的密钥对象(不能直接读取原始密钥,除非 extractable: true 并导出)。
  • 支持多种算法:RSA 系列、ECC(ECDSA/ECDH)、AES(CTR/CBC/GCM/KW)、HMAC、KDF(HKDF, PBKDF2)等。

主要算法与使用场景(简要说明)

  • RSASSA-PKCS1-v1_5 :传统 RSA 签名,兼容性好,但 RSA-PSS 更安全。
    用途:兼容性强的签名场景。
  • RSA-PSS :基于概率化填充的 RSA 签名,推荐用于新系统。
    用途:数字签名(更强抗攻击性)。
  • RSA-OAEP :RSA 加密/解密(用于小数据或用于包装对称密钥)。
    用途:密钥封装、加密短文。
  • ECDSA (如 P-256):椭圆曲线签名,比 RSA 更短的密钥长度同等安全。
    用途:签名(移动/浏览器友好)。
  • ECDH :椭圆曲线密钥交换,派生共享密钥。
    用途:双方建立共享对称密钥(密钥协商)。
  • AES-GCM :带认证的对称加密(AEAD),推荐用于对称加密。
    用途:消息机密性与完整性。
  • AES-CBC :传统模式,无内置认证(需额外 MAC)。
    用途:兼容旧系统,尽量配合 MAC 或使用 GCM。
  • AES-CTR :计数器模式(流式),不提供认证。
    用途:需要流式加密场景(需另行认证)。
  • AES-KW :用于密钥包装(Key Wrap),设计用于密钥封装。
    用途:包装/解包密钥(配合 wrapKey/unwrapKey)。
  • HMAC :基于散列的消息认证码,常用于认证和完整性校验。
    用途:签名/消息认证(对称)。
  • HKDF :基于 HMAC 的密钥派生函数。
    用途:从高熵输入导出多个密钥材料。
  • PBKDF2 :基于密码的密钥派生(带迭代,防暴力)。
    用途:从用户密码派生密钥(注意迭代次数和盐)。

对比总结

  • 非对称(RSA/ECC)适合:身份、签名、密钥交换。优点:公钥可公开;缺点:性能相对对称差。
  • 对称(AES/HMAC)适合:高效加密与认证。优点:速度快;缺点:需安全分发密钥。
  • KDF(HKDF/PBKDF2)用于把低熵或共享材料转换为实际使用的密钥。

实践

(见后续子节中 generateKeyderiveKeysign/verifyencrypt/decrypt 的具体示例)


2.3 生成 CryptoKey(参数对象与 keyUsages

你要的示例:RsaHashedKeyGenParamsEcKeyGenParamsHmacKeyGenParamsAesKeyGenParams,并给出两个 generateKey 示例(AES-CTR 128-bit、ECDSA P-256)。

概念

  • generateKey 参数会依算法不同而变化,通常包含算法名、曲线名或长度、hash 算法等。
  • keyUsages 指明密钥可用于哪些操作(例如 encrypt, decrypt, sign, verify, deriveKey, wrapKey, unwrapKey, deriveBits)。

常用参数对象举例

javascript 复制代码
// RSA 生成参数 (RsaHashedKeyGenParams)
{
  name: "RSA-OAEP" | "RSASSA-PKCS1-v1_5" | "RSA-PSS",
  modulusLength: 2048 | 3072 | 4096,                // 公钥模数长度(位)
  publicExponent: new Uint8Array([0x01,0x00,0x01]),// 常用 65537
  hash: { name: "SHA-256" | "SHA-384" | "SHA-512" } // 散列算法
}

// ECC 生成参数 (EcKeyGenParams)
{
  name: "ECDSA" | "ECDH",
  namedCurve: "P-256" | "P-384" | "P-521"
}

// HMAC 生成参数 (HmacKeyGenParams)
{
  name: "HMAC",
  hash: { name: "SHA-256" } // HMAC 使用的 hash
  // length 可选(位),通常由 hash 长度决定
}

// AES 生成参数 (AesKeyGenParams)
{
  name: "AES-GCM" | "AES-CBC" | "AES-CTR" | "AES-KW",
  length: 128 | 192 | 256  // 密钥长度(位)
}

keyUsages 常见集合说明

  • 对称加密(AES-GCM/CBC/CTR):["encrypt","decrypt","wrapKey","unwrapKey"]
  • AES-KW 包装密钥:["wrapKey","unwrapKey"]
  • RSA-OAEP(加密/解密):["encrypt","decrypt"]
  • RSA-PSS / RSASSA-PKCS1-v1_5:["sign","verify"]
  • ECDSA:["sign","verify"]
  • ECDH:["deriveKey","deriveBits"]
  • HMAC:["sign","verify"](注意:HMAC 用 sign/verify 语义,但在浏览器层是对称)

实践:两个示例(逐行注释)

示例 A --- 生成 AES-CTR 128-bit 密钥
csharp 复制代码
// 生成 AES-CTR 128 位对称密钥
async function genAesCtr128Key() {
  // 1) 调用 generateKey:算法名为 AES-CTR,密钥长度 128(位)
  const key = await crypto.subtle.generateKey(
    { name: "AES-CTR", length: 128 }, // AesKeyGenParams
    true,                             // extractable:是否允许导出原始密钥(示例设 true)
    ["encrypt", "decrypt"]            // keyUsages:允许加解密
  );
  return key; // 返回 CryptoKey
}

/* 逐行注释:
 - generateKey 的第一个参数是算法与参数对象(这里是 AES-CTR, length)
 - 第二个参数表示是否可导出密钥(extractable),调试/示例用 true;生产中对私钥/对称密钥通常设 false
 - 第三个参数是 keyUsages(密钥用途)
*/
示例 B --- 生成 ECDSA (P-256) 密钥对(用于签名/验证)
csharp 复制代码
async function genEcdsaP256KeyPair() {
  // 1) 生成密钥对,算法为 ECDSA,曲线 P-256
  const keyPair = await crypto.subtle.generateKey(
    {
      name: "ECDSA",
      namedCurve: "P-256" // 曲线:P-256 (aka secp256r1)
    },
    true,                 // extractable:允许导出(示例)
    ["sign", "verify"]    // 私钥可用于 sign,公钥可用于 verify
  );
  return keyPair; // { publicKey, privateKey }
}

/* 注释:
 - ECDSA 生成返回的是密钥对对象(publicKey 与 privateKey)
 - namedCurve 可选 P-256/P-384/P-521;选择与服务端/标准一致的曲线
*/

拓展

  • 生产环境中 extractable 一般设为 false,以防密钥被导出、泄漏。
  • RSA 的 modulusLength 最小建议为 2048;ECC 默认曲线 P-256 在多数用例已足够。

潜在问题

  • keyUsages 必须与算法操作匹配(浏览器会校验),否则操作会失败。
  • 有些算法/参数在不同浏览器或平台上支持不完全(例如某些曲线或长度)。

2.4 导出与导入密钥(exportKey / importKey 格式说明与示例)

概念

  • exportKey(format, key):将 CryptoKey 导出成某种格式(rawpkcs8spkijwk)。
  • importKey(format, keyData, algorithm, extractable, keyUsages):从外部数据导入为 CryptoKey

常见格式

  • raw :对称密钥的原始字节(ArrayBuffer)。
  • spki:公钥(SubjectPublicKeyInfo) ------ 用于导出公钥(RSA/ECC)。
  • pkcs8:私钥(PKCS#8) ------ 用于导出私钥(RSA/ECC)。
  • jwk:JSON Web Key(可读的 JSON 格式,可包含元数据)。

实践(导出与导入示例)

导出对称 AES 密钥为 raw
javascript 复制代码
async function exportAesKeyRaw(aesKey) {
  // 1) 导出 raw 格式(ArrayBuffer)
  const raw = await crypto.subtle.exportKey("raw", aesKey);
  // 2) 可转成 Base64 或 hex 便于传输/存储
  return raw; // ArrayBuffer
}
导入对称 AES 密钥(从 raw)
javascript 复制代码
async function importAesKeyRaw(rawBuffer, algorithmName = "AES-GCM") {
  // 1) 从 ArrayBuffer 导入为 CryptoKey
  const key = await crypto.subtle.importKey(
    "raw",                        // format
    rawBuffer,                    // keyData
    { name: algorithmName },      // algorithm
    true,                         // extractable
    ["encrypt", "decrypt"]        // usages
  );
  return key;
}
导出与导入非对称密钥(pkcs8 / spki)示例(RSA/ECDSA)
csharp 复制代码
// 导出私钥为 pkcs8(ArrayBuffer)
const pkcs8 = await crypto.subtle.exportKey("pkcs8", privateKey);

// 导出公钥为 spki(ArrayBuffer)
const spki = await crypto.subtle.exportKey("spki", publicKey);

// 导入公钥(spki -> CryptoKey)
const importedPub = await crypto.subtle.importKey(
  "spki",
  spkiBuffer,
  { name: "ECDSA", namedCurve: "P-256" }, // 与原始生成时的一致
  true,
  ["verify"]
);

// 导入私钥(pkcs8 -> CryptoKey)
const importedPriv = await crypto.subtle.importKey(
  "pkcs8",
  pkcs8Buffer,
  { name: "ECDSA", namedCurve: "P-256" },
  true,
  ["sign"]
);

参数详解(importKey

  • format"raw"|"pkcs8"|"spki"|"jwk"|"pkcs1"(注意支持因浏览器而异)。
  • keyData:要导入的数据(ArrayBuffer 或 JSON for jwk)。
  • algorithm:与密钥用途匹配的算法描述(例如 {name:"RSA-OAEP", hash:{name:"SHA-256"}})。
  • extractable:是否允许之后导出此密钥。
  • keyUsages:导入后密钥允许的用途(必须与算法匹配)。

拓展

  • 使用 jwk 可以方便地与服务器或其它语言互通(例如后端返回 JWK)。
  • pkcs8/spki 是标准二进制格式,适合在 TLS/PKI 场景交换。

潜在问题

  • 导入/导出时算法参数必须完全匹配(例如 ECDSA 的曲线)。
  • 一些格式(如 pkcs1)在某些实现不被支持。
  • 将私钥设置 extractable: true 会增加泄露风险。

2.5 使用主密钥派生密钥(deriveKey / deriveBits

包含:ECDH 双方派生示例(确认相同位),以及 PBKDF2 从密码派生 AES-GCM 密钥示例。

概念

  • deriveBits:根据私钥与对方公钥或 KDF 输出原始位串。
  • deriveKey:直接派生成一个 CryptoKey(例如从 ECDH 得到 AES 密钥)。
  • 用途:在双方各自持有私钥、对方公钥时得到共享对称密钥;或从密码/主密钥派生多个子密钥。

示例 A:ECDH 双方派生同样的对称位(并验证)

php 复制代码
// 工具:ArrayBuffer -> Base64(便于打印比较)
function ab2base64(buf) {
  const bytes = new Uint8Array(buf);
  let binary = '';
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}

async function ecdhDeriveExample() {
  // 1) 双方各自产生 ECDH 密钥对(P-256)
  const a = await crypto.subtle.generateKey(
    { name: "ECDH", namedCurve: "P-256" },
    true,
    ["deriveKey","deriveBits"]
  );
  const b = await crypto.subtle.generateKey(
    { name: "ECDH", namedCurve: "P-256" },
    true,
    ["deriveKey","deriveBits"]
  );

  // 2) 彼此导出公钥(spki)
  const aPubSpki = await crypto.subtle.exportKey("spki", a.publicKey);
  const bPubSpki = await crypto.subtle.exportKey("spki", b.publicKey);

  // 3) 导入对方公钥(模拟网络传输后导入)
  const aPubImported = await crypto.subtle.importKey("spki", aPubSpki, { name: "ECDH", namedCurve: "P-256" }, false, []);
  const bPubImported = await crypto.subtle.importKey("spki", bPubSpki, { name: "ECDH", namedCurve: "P-256" }, false, []);

  // 4) 双方使用 deriveBits 直接派生 256 位共享秘密(ArrayBuffer)
  const aSharedBits = await crypto.subtle.deriveBits({ name: "ECDH", public: bPubImported }, a.privateKey, 256);
  const bSharedBits = await crypto.subtle.deriveBits({ name: "ECDH", public: aPubImported }, b.privateKey, 256);

  // 5) 将派生位转为 Base64 并比较
  console.log("A shared (base64):", ab2base64(aSharedBits));
  console.log("B shared (base64):", ab2base64(bSharedBits));
  // 它们应当完全相同
  return {
    aSharedBits,
    bSharedBits
  };
}

/* 注释:
 - generateKey(..., ["deriveKey","deriveBits"]):确保密钥允许派生操作
 - exportKey("spki")/importKey("spki",...):把公钥通过网络传输/导入
 - deriveBits(..., 256):产生 256 位共享位(ArrayBuffer)
 - 两边得到的 aSharedBits 与 bSharedBits 应一致(证明密钥协商成功)
*/
如果想直接 deriveKey 得到 AES-GCM CryptoKey
csharp 复制代码
// 双方直接 deriveKey 得到 AES-GCM key(示例)
async function ecdhDeriveKeyExample() {
  // a 和 b 生成并导入公钥部分见上文...
  // 假设 a.privateKey, b.publicKey 已可用
  // a 端 deriveKey -> AES-GCM
  const derivedKeyA = await crypto.subtle.deriveKey(
    { name: "ECDH", public: b.publicKey },
    a.privateKey,
    { name: "AES-GCM", length: 256 }, // 想要得到的目标 key 类型
    true,                             // extractable?
    ["encrypt", "decrypt"]
  );
  // b 端同理 ...
}

示例 B:PBKDF2 --- 从密码导出 AES-GCM 密钥(带 salt 与 iterations)

php 复制代码
async function pbkdf2ToAesGcmKey(password, saltBase64, iterations = 100000) {
  // 1) 将密码编码为 ArrayBuffer,并导入为 PBKDF2 "原始主密钥"
  const enc = new TextEncoder();
  const passKey = await crypto.subtle.importKey(
    "raw",
    enc.encode(password),
    { name: "PBKDF2" },
    false,
    ["deriveKey"]
  );

  // 2) 准备 salt(从 base64 转 ArrayBuffer;示例中假设传入 base64)
  const saltBytes = Uint8Array.from(atob(saltBase64), c => c.charCodeAt(0));

  // 3) 使用 PBKDF2 派生 AES-GCM 密钥
  const aesKey = await crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: saltBytes,
      iterations: iterations,
      hash: "SHA-256"
    },
    passKey,
    { name: "AES-GCM", length: 256 }, // 目标密钥类型
    true, // extractable(示例)
    ["encrypt", "decrypt"]
  );

  return aesKey;
}

/* 注释:
 - PBKDF2 的迭代次数应足够高(例如 100k 或更高,视平台能接受的性能决定)
 - salt 应随机生成并与派生密钥一并保存(明文),以防彩虹表攻击
*/

拓展

  • ECDH 结合 HKDF 可更安全地从原始共享位导出多个密钥:deriveBits -> HKDF -> 多用途密钥
  • PBKDF2 在移动端/浏览器上迭代次数受性能限制,可考虑 Argon2(但 WebCrypto 默认不支持)。

潜在问题

  • 派生出的位/密钥是否相同与曲线、导入公钥的格式密切相关;务必一致(曲线/编码)。
  • PBKDF2 的 iterations 需权衡安全与性能,选择合理值并在技术文档中说明。

2.6 使用非对称密钥签名和验证(ECDSA 示例)

概念

  • 使用私钥签名消息(subtle.sign),使用公钥验证签名(subtle.verify)。
  • ECDSA 在现代场景下常用于短签名与低带宽环境。

原理

  • 先对消息做一致编码(例如 UTF-8),通常在签名前做 hash(SubtleCrypto 在 sign 时自动对数据进行处理,algorithm 指定 hash)。
  • 返回的签名格式通常为 ASN.1/DER(不同浏览器实现需小心兼容性)。

实践(示例代码:生成 ECDSA 密钥、签名、验证)

php 复制代码
async function ecdsaSignVerifyExample() {
  const enc = new TextEncoder();
  const message = enc.encode("message to sign");

  // 1) 生成 ECDSA 密钥对(P-256)
  const keyPair = await crypto.subtle.generateKey(
    { name: "ECDSA", namedCurve: "P-256" },
    true,
    ["sign", "verify"]
  );

  // 2) 使用私钥签名(hash: SHA-256)
  //    注意:ECDSA 的 algorithm 对象需包含 hash
  const signature = await crypto.subtle.sign(
    { name: "ECDSA", hash: { name: "SHA-256" } },
    keyPair.privateKey,
    message
  );

  // 3) 使用公钥验证签名(返回 boolean)
  const isValid = await crypto.subtle.verify(
    { name: "ECDSA", hash: { name: "SHA-256" } },
    keyPair.publicKey,
    signature,
    message
  );

  console.log("signature (hex):", ab2hex(signature));
  console.log("signature valid:", isValid);
}

/* 注释:
 - sign/verify 的第一个参数包含算法和 hash(ECDSA 需要指定 hash)
 - 返回的 signature 是 ArrayBuffer(在许多实现中为 ASN.1 DER 格式)
 - verify 返回 boolean
*/

拓展

  • 若你的后端/其它库期望签名的 r|s 原始拼接格式(而非 DER),你需要在两端做对应转换(ASN.1 <-> raw r|s)。
  • 对于 RSA,请考虑使用 RSA-PSS 而不是 RSASSA-PKCS1-v1_5(更安全)。

潜在问题

  • 签名编码格式(DER vs raw)跨平台互通需要处理。
  • 签名前后数据的编码必须完全一致(UTF-8、排序、规范化)。

2.7 对称密钥加密与解密(AES-CBC 256-bit 示例)

概念

  • AES-CBC:分组密码的 CBC 模式,需使用 IV,并且本身不提供消息认证(所以通常与 MAC 一起使用或优先选择 AES-GCM)。

原理

  • 加密:ciphertext = AES-CBC(key, iv, plaintext);解密使用相同的 IV 与 key。
  • 注意:CBC 需要填充,Web Crypto 实现通常会处理填充;但为了消息完整性仍建议使用 AEAD(如 AES-GCM)或在 CBC 外层使用 HMAC。

实践(示例:256-bit AES-CBC 加解密,带注释)

javascript 复制代码
async function aesCbcEncryptDecryptExample() {
  const enc = new TextEncoder();
  const dec = new TextDecoder();

  // 1) 生成 AES-CBC 256 位密钥
  const key = await crypto.subtle.generateKey(
    { name: "AES-CBC", length: 256 },
    true, // 可导出以便演示;生产中通常 false
    ["encrypt", "decrypt"]
  );

  // 2) 明文
  const plaintext = enc.encode("这是一条需要加密的消息");

  // 3) 生成 16 字节的 IV(AES block size = 16 字节)
  const iv = crypto.getRandomValues(new Uint8Array(16));

  // 4) 加密
  const ciphertextBuffer = await crypto.subtle.encrypt(
    { name: "AES-CBC", iv: iv },
    key,
    plaintext
  );

  // 5) 解密
  const decryptedBuffer = await crypto.subtle.decrypt(
    { name: "AES-CBC", iv: iv },
    key,
    ciphertextBuffer
  );

  console.log("IV (hex):", ab2hex(iv.buffer));
  console.log("Ciphertext (hex):", ab2hex(ciphertextBuffer));
  console.log("Decrypted text:", dec.decode(decryptedBuffer));
}

逐行注释重点

  • IV 必须随机且对每次加密唯一(对同一 key 重复 IV 会导致安全问题)。
  • AES-CBC 不提供认证;如果内容重要,需另加 HMAC 或使用 AES-GCM。

拓展

  • 推荐使用 AES-GCM (内置认证),接口类似 crypto.subtle.encrypt({ name:'AES-GCM', iv, additionalData }, key, data)
  • 若必须使用 CBC,请配合 HMAC(Encrypt-then-MAC)。

潜在问题

  • IV 重用会导致明文泄露风险。
  • 某些浏览器的 AES-CBC 实现对填充表现不同,务必测试跨平台互通性。
  • CBC 模式对随机访问/流式场景不友好。

2.8 包装和解包密钥(wrapKey / unwrapKey 示例:生成 AES-GCM 主密钥,使用 AES-KW 包装)

概念

  • wrapKey(format, key, wrappingKey, wrapAlgorithm):使用 wrappingKeykey 进行包装(加密),返回包装后的 ArrayBuffer
  • unwrapKey(format, wrappedKey, wrappingKey, wrapAlgorithm, unwrappedKeyAlgorithm, extractable, keyUsages):解包并导入为 CryptoKey

原理

  • 通常使用专门的包装算法(如 AES-KW)或任何对称加密(例如 AES-GCM)来包装要传输的密钥。
  • 包装是密钥分发常见操作:用一个长期保管的 wrappingKey 来安全地传输临时密钥。

实践(详细示例:生成 AES-GCM 内容密钥,用 AES-KW 包装,解包并验证)

javascript 复制代码
async function wrapUnwrapExample() {
  const enc = new TextEncoder();

  // 1) 生成要被包装的对称密钥:AES-GCM (用于实际数据加解密)
  const contentKey = await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    true, // 可导出以便比较(演示)
    ["encrypt", "decrypt"]
  );

  // 2) 生成用于包装的密钥:AES-KW(Key Wrap)
  const wrappingKey = await crypto.subtle.generateKey(
    { name: "AES-KW", length: 256 },
    true,
    ["wrapKey", "unwrapKey"]
  );

  // 3) 使用 wrapKey 包装 contentKey(以 raw 格式包装)
  const wrapped = await crypto.subtle.wrapKey(
    "raw",          // 导出 contentKey 的格式(raw 表示原始字节)
    contentKey,     // 要被包装的密钥
    wrappingKey,    // 用于包装的密钥
    { name: "AES-KW"} // 包装算法
  );

  console.log("Wrapped key (hex):", ab2hex(wrapped));

  // 4) 使用 unwrapKey 解包得到 CryptoKey(可以设置用途等)
  const unwrappedKey = await crypto.subtle.unwrapKey(
    "raw",             // wrappedKey 的导出格式(与 wrap 时一致)
    wrapped,           // 包装后的数据(ArrayBuffer)
    wrappingKey,       // 解包所用 key
    { name: "AES-KW" },// wrapAlgorithm
    { name: "AES-GCM", length: 256 }, // 想要得到的密钥算法信息(目标 key 的算法)
    true,              // extractable
    ["encrypt", "decrypt"] // usages
  );

  // 5) (可选)导出原始与解包后的 key 比较以确认一致
  const rawOriginal = await crypto.subtle.exportKey("raw", contentKey);
  const rawUnwrapped = await crypto.subtle.exportKey("raw", unwrappedKey);

  console.log("Original key (hex):", ab2hex(rawOriginal));
  console.log("Unwrapped key (hex):", ab2hex(rawUnwrapped));
  // 两者应当相同
  return {
    wrapped,
    rawOriginal,
    rawUnwrapped
  };
}

/* 注释:
 - wrapKey("raw", contentKey, wrappingKey, {name:"AES-KW"}):把 contentKey 的 raw bytes 用 wrappingKey 按 AES-KW 算法包装
 - unwrapKey(..., {name:"AES-GCM", length:256}, ...):解包并以 AES-GCM 类型导入为 CryptoKey
 - 导出 raw 并比较可以验证包装/解包过程是否保持密钥一致
*/

拓展

  • 除了 AES-KW,也可以使用 AES-GCM 对 key bytes 进行加密来包装(然后用 unwrap 后 importKey)。
  • Key wrapping 常用于云密钥管理(KMS)或多方密钥分发。

潜在问题

  • 包装算法和导出/导入格式必须完全匹配(raw vs jwk 等)。
  • extractable 若为 false,wrapKey("raw",...) 会失败(你无法导出 raw)。
相关推荐
@大迁世界6 分钟前
这次 CSS 更新彻底改变了我的 CSS 开发方式。
前端·css
IT_陈寒30 分钟前
Python 3.12 新特性实战:5个让你的代码效率提升50%的技巧!🔥
前端·人工智能·后端
Apifox32 分钟前
Apifox 8 月更新|新增测试用例、支持自定义请求示例代码、提升导入/导出 OpenAPI/Swagger 数据的兼容性
前端·后端·测试
coding随想40 分钟前
最后的挽留:深入浅出HTML5 beforeunload事件
前端
亚里士多德芙1 小时前
记录:离线包实现桥接
前端
去伪存真1 小时前
用的好好的vue.config.js代理,突然报308, 怎么回事?🤔
前端
搞个锤子哟1 小时前
el-select使用filter-method实现自定义过滤
前端
flyliu1 小时前
什么是单点登录,如何实现
前端
码力无边_OEC1 小时前
第四章:幕后英雄 —— Background Scripts (Service Worker)
前端
阿黎啊啊啊1 小时前
避免 node_modules 修改被覆盖:用 patch-package 轻松搞定
前端