注意: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());
}
行内注释(逐行解读) :
Uint32Array(1)
:准备一个 32 位的容器(足够多位保证高精度)。crypto.getRandomValues(arr)
:用安全随机填充容器。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>
,data
为ArrayBuffer
或 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)用于把低熵或共享材料转换为实际使用的密钥。
实践
(见后续子节中 generateKey
、deriveKey
、sign/verify
、encrypt/decrypt
的具体示例)
2.3 生成 CryptoKey
(参数对象与 keyUsages
)
你要的示例:RsaHashedKeyGenParams
、EcKeyGenParams
、HmacKeyGenParams
、AesKeyGenParams
,并给出两个 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
导出成某种格式(raw
、pkcs8
、spki
、jwk
)。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 forjwk
)。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)
:使用wrappingKey
对key
进行包装(加密),返回包装后的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
vsjwk
等)。 extractable
若为 false,wrapKey("raw",...)
会失败(你无法导出 raw)。