【Node.js】crypto 与 WebCrypto:从 Legacy 到密码学

4180 字
21 分钟
【Node.js】crypto 与 WebCrypto:从 Legacy 到密码学

一、引言:密码学是 Web 安全的“基建”#

在现代 Web 开发中,密码学 API 不是“可选功能”,而是“安全底线”。它解决的是三个核心问题:

  • 防篡改:确保请求或文件未被恶意修改;
  • 防泄露:确保用户敏感数据(如密码)不会明文暴露;
  • 防伪造:确保数据来源真实(如数字签名)。

我们用三个真实业务场景感受密码学的重要性:

场景 1:电商接口的“防篡改签名”#

你开发了一个电商 API,用于处理用户下单请求。若黑客拦截请求并修改amount(金额)从 100 元改为 1 元,会直接导致损失。此时需要用HMAC(哈希消息认证码)对请求参数签名——客户端用密钥生成签名,服务器验证签名,不一致则拒绝请求。

场景 2:用户密码的“安全存储”#

用户的登录密码不能明文存储——若数据库泄露,明文密码会被直接盗用。此时需要用慢哈希算法(如 PBKDF2)将密码派生为密钥,存储哈希值而非明文。

场景 3:文件的“完整性校验”#

你发布了一款安装包,若黑客篡改安装包植入木马,用户下载后会遭受攻击。此时需要用哈希算法(如 SHA-256)计算文件哈希,用户下载后对比哈希值,一致则说明文件完整。

在 Node.js 中,实现这些需求有两个核心工具:

  • Legacy crypto 模块:Node.js 早期的“裸 OpenSSL 封装”,功能全但不跨平台;
  • WebCrypto API:WinterCG 标准的“跨 runtime 密码学接口”,API 现代且兼容浏览器/边缘计算。

二、Node.js crypto:OpenSSL 的“直接翻译”#

crypto 模块是 Node.js 对 OpenSSL(全球最流行的开源密码学库)的逐函数封装。OpenSSL 提供了哈希、加密、签名等核心功能,crypto 只是将这些功能“暴露为 JavaScript 函数”。

2.1 核心特性:与 OpenSSL 一一对应#

crypto 的 API 几乎是 OpenSSL 函数的“直译”,以下是核心映射关系:

Node.js crypto API对应 OpenSSL 函数系列功能说明
createHash().update().digest()EVP_DigestInit_ex/Update/Final_ex哈希计算(如 SHA-256)
createHmac().update().digest()HMAC_Init_ex/Update/FinalHMAC 计算(如 HMAC-SHA256)
createCipheriv().update().final()EVP_CipherInit_ex/Update/Final_ex加密(如 AES-GCM)
createDecipheriv().update().final()EVP_CipherInit_ex/Update/Final_ex解密(如 AES-GCM)
createSign().update().sign()EVP_SignInit_ex/Update/Final_ex数字签名(如 RSA-SHA256)
createVerify().update().verify()EVP_VerifyInit_ex/Update/Final_ex签名验证(如 RSA-SHA256)

2.2 实际项目示例:计算文件的 SHA256 哈希(流实现)#

最常见的场景是验证文件完整性。以下是用 crypto 结合流读取的高效实现(避免同步阻塞):

const crypto = require('crypto')
const fs = require('fs')
const { pipeline } = require('stream/promises')
/**
* 计算文件的 SHA256 哈希(流实现,非阻塞)
* @param {string} filePath - 文件路径
* @returns {Promise<string>} 哈希结果(十六进制字符串)
*/
async function computeFileHash(filePath) {
const hash = crypto.createHash('sha256') // 初始化哈希算法
const readStream = fs.createReadStream(filePath) // 创建文件读取流
try {
await pipeline(readStream, hash) // 将流管道到哈希对象
return hash.digest('hex') // 生成十六进制哈希
} catch (err) {
throw new Error(`计算哈希失败:${err.message}`)
}
}
// 使用示例:计算安装包的哈希
computeFileHash('./app-installer.exe').then((hash) => {
console.log('文件哈希:', hash)
// 对比官方哈希:if (hash === '官方发布的SHA256') { /* 文件完整 */ }
})

关键优势

  • 用流读取文件,避免同步读取大文件阻塞主事件循环;
  • pipeline 方法自动处理流的错误和关闭,代码更健壮。

2.3 历史痛点:OpenSSL 的“双刃剑”#

crypto 依赖 OpenSSL 的强大,但也继承了它的三大缺陷

痛点 1:同步 API 阻塞主循环#

createHash().digest() 等同步 API 会阻塞主事件循环——若计算 1GB 文件的哈希,服务器将在数秒内无法响应其他请求:

// 反面示例:同步读取大文件,阻塞主循环
const hash = crypto.createHash('sha256')
const fileData = fs.readFileSync('./large-file.bin') // 同步读取 1GB 文件(阻塞)
hash.update(fileData)
const result = hash.digest('hex') // 继续阻塞

痛点 2:算法“过多过杂”#

crypto.getHashes() 返回 50+ 种算法(如 MD5、SHA-1),但很多算法已不安全

  • MD5:碰撞攻击成本极低(可在数秒内生成两个不同文件但 MD5 相同);
  • SHA-1:美国国家标准与技术研究院(NIST)已在 2011 年禁止使用。

新手容易误用弱算法,比如用 MD5 存储密码,导致安全漏洞。

痛点 3:跨平台兼容差#

crypto 是 Node.js 特有 API——无法在浏览器、Cloudflare Workers 中使用。若你开发跨端应用(如 Node.js 后端 + 浏览器前端),需要写两份代码:

  • 后端用 crypto.createHash()
  • 前端用 crypto.subtle.digest()(WebCrypto)。

三、WebCrypto:Winter 标准的“跨 runtime 密码学”#

为了解决 crypto 的跨平台问题,WinterCG(Web-interoperable Runtime Community Group)推出了 WebCrypto API——一个统一所有 JavaScript runtime 的密码学标准。

3.1 核心设计原则#

WebCrypto 的设计遵循三个“安全优先”原则:

  1. 异步优先:所有操作返回 Promise,不会阻塞主事件循环;
  2. 安全算法子集:仅支持常用、安全的算法(如 SHA-256、AES-GCM、ChaCha20-Poly1305);
  3. 跨平台兼容:在 Node.js、浏览器、Cloudflare Workers、Deno 中行为一致。

3.2 核心 API 介绍#

WebCrypto 的入口是 globalThis.crypto,核心功能封装在 crypto.subtle(SubtleCrypto 接口)中:

API 名称功能说明示例用法
crypto.getRandomValues生成安全随机数(用于密钥、盐值)crypto.getRandomValues(new Uint8Array(16))
subtle.digest计算哈希(如 SHA-256)subtle.digest('SHA-256', data)
subtle.encrypt加密数据(如 AES-GCM)subtle.encrypt(algorithm, key, data)
subtle.decrypt解密数据(如 AES-GCM)subtle.decrypt(algorithm, key, data)
subtle.generateKey生成加密密钥(如 AES-256-GCM)subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt'])
subtle.sign数字签名(如 RSASSA-PKCS1-v1_5)subtle.sign(algorithm, key, data)
subtle.verify验证签名(如 RSASSA-PKCS1-v1_5)subtle.verify(algorithm, key, signature, data)

3.3 实际项目示例:Vue3 上传头像生成哈希#

在 Vue3 项目中,用户上传头像后,需要生成唯一哈希标识(避免重复存储)。以下是用 WebCrypto 实现的完整示例:

<template>
<div class="avatar-upload">
<input
type="file"
accept="image/*"
@change="handleUpload" />
<div
v-if="avatarHash"
class="hash-result">
头像哈希:<code>{{ avatarHash }}</code>
</div>
<img
v-if="avatarUrl"
:src="avatarUrl"
class="avatar-preview"
alt="预览" />
</div>
</template>
<script setup>
import { ref } from 'vue'
const avatarHash = ref('')
const avatarUrl = ref('')
/**
* 处理头像上传:生成哈希 + 预览图片
*/
async function handleUpload(event) {
const file = event.target.files[0]
if (!file) return
// 1. 生成图片预览 URL
avatarUrl.value = URL.createObjectURL(file)
// 2. 用 WebCrypto 计算 SHA-256 哈希
const encoder = new TextEncoder()
const fileBuffer = await file.arrayBuffer() // 将文件转为 ArrayBuffer
const hashBuffer = await crypto.subtle.digest('SHA-256', fileBuffer) // 异步计算哈希
// 3. 将 ArrayBuffer 转为十六进制字符串
const hashArray = Array.from(new Uint8Array(hashBuffer))
avatarHash.value = hashArray
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
</script>

核心优势

  • 异步无阻塞:计算哈希不会卡住 Vue 的渲染;
  • 跨平台兼容:代码可直接在浏览器、Cloudflare Workers 中运行;
  • 安全算法:仅用 SHA-256(避免弱算法)。

3.4 深入:WebCrypto 的“异步魔法”#

WebCrypto 的异步操作依赖 libuv 线程池(Node.js 的异步 I/O 核心)。以 subtle.digest 为例,完整流程如下:

  1. 任务提交:调用 subtle.digest('SHA-256', data) 时,Node.js 将任务放入 libuv 线程池
  2. 线程执行:线程池中的线程调用 OpenSSL 的 EVP_DigestInit_exEVP_DigestUpdateEVP_DigestFinal_ex,计算哈希;
  3. 结果通知:任务完成后,线程通过 uv_async_t(异步通知机制)告知主事件循环;
  4. Promise resolve:主循环收到通知,resolve Promise,返回哈希结果。

关键细节:libuv 线程池配置#

  • 默认线程数:4 条(可通过 UV_THREADPOOL_SIZE 环境变量调整,如 UV_THREADPOOL_SIZE=8 node app.js);
  • 线程复用:线程池中的线程是“长驻”的,完成任务后会等待新任务,避免频繁创建销毁线程。

四、crypto 与 WebCrypto:对比与最佳实践#

我们用实际场景对比两者的选择:

4.1 核心差异表#

特性Node.js cryptoWebCrypto(Winter 标准)
底层依赖OpenSSLOpenSSL(Node.js 实现)
API 风格同步/异步混合全异步(Promise)
算法支持OpenSSL 全集(含弱算法)标准子集(仅安全算法)
跨平台兼容性仅 Node.js支持 Node.js、浏览器、Cloudflare
数据格式返回 Buffer(Node.js 特有)返回 ArrayBuffer(标准格式)
性能同步快但阻塞异步不阻塞,适合大文件

4.2 最佳实践:“该用哪个?”#

根据场景选择 API,优先 WebCrypto

场景推荐 API原因
新项目开发WebCrypto跨平台兼容,异步无阻塞
需要特殊算法(如 SM3)cryptoWebCrypto 不支持非标准算法
处理大文件(>1GB)WebCrypto异步不阻塞主循环
生成随机数WebCrypto跨平台兼容(或 crypto.randomBytes
跨端应用(浏览器+Node)WebCrypto一套代码,多端运行

五、常见问题解答(Q&A)#

Q1:WebCrypto 在 Node.js 中如何启用?#

  • Node.js v18+:默认启用 globalThis.crypto
  • Node.js v16:需要加 --experimental-web-crypto flag(如 node --experimental-web-crypto app.js);
  • Node.js v14 及以下:不支持 WebCrypto。

Q2:如何转换 Buffer 和 ArrayBuffer?#

  • Buffer → ArrayBufferconst arrayBuffer = buffer.buffer;(Buffer 是 ArrayBuffer 的“视图”);
  • ArrayBuffer → Bufferconst buffer = Buffer.from(arrayBuffer);

Q3:WebCrypto 的密钥可以导出吗?#

可以。WebCrypto 支持将密钥导出为标准格式,用于跨平台传输或存储:

  1. JWK(JSON Web Key):适合跨平台传输(如浏览器 → 服务器),是 JSON 格式的密钥表示;
  2. PKCS#8:用于存储私钥(需加密);
  3. SPKI:用于存储公钥(可明文存储)。

示例:导出 AES 密钥为 JWK:

async function exportKeyToJWK(key) {
return crypto.subtle.exportKey('jwk', key)
}
// 使用示例:生成 AES 密钥并导出
const aesKey = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // 是否可提取(exportable)
['encrypt', 'decrypt']
)
const jwk = await exportKeyToJWK(aesKey)
console.log('JWK 密钥:', jwk)

六、更多实战:从“理论”到“生产”的密码学应用#

6.1 场景 1:电商接口的 HMAC 签名(crypto 实现)#

在电商系统中,接口签名是防止请求篡改的关键。以下是用 Node.js crypto 实现的请求签名与验证完整流程:

客户端:生成请求签名#

const crypto = require('crypto')
/**
* 生成 API 请求签名(HMAC-SHA256)
* @param {Object} params - 请求参数(如 orderId、amount)
* @param {string} secretKey - 客户端与服务器共享的密钥
* @returns {string} 签名结果(十六进制字符串)
*/
function generateApiSignature(params, secretKey) {
// 1. 参数按字母升序排序(避免参数顺序导致签名不一致)
const sortedParams = Object.keys(params)
.sort()
.reduce((acc, key) => {
acc[key] = params[key]
return acc
}, {})
// 2. 将参数拼接为 URL 格式字符串(如 "amount=100&orderId=123456&timestamp=1698543210")
const paramString = new URLSearchParams(sortedParams).toString()
// 3. 生成 HMAC-SHA256 签名
const hmac = crypto.createHmac('sha256', secretKey)
hmac.update(paramString) // 传入拼接后的参数字符串
return hmac.digest('hex') // 输出十六进制签名
}
// 使用示例:生成订单请求的签名
const params = {
orderId: '123456',
amount: 100,
timestamp: Date.now(), // 加入时间戳防止重放攻击
}
const secretKey = 'my-server-secret-key' // 客户端与服务器共享的密钥
const signature = generateApiSignature(params, secretKey)
// 发送请求:将 params 和 signature 一起传给服务器
const request = { params, signature }

服务器:验证请求签名#

/**
* 验证 API 请求签名(防止篡改)
* @param {Object} params - 请求参数
* @param {string} signature - 客户端传入的签名
* @param {string} secretKey - 共享密钥
* @returns {boolean} 签名是否有效
*/
function verifyApiSignature(params, signature, secretKey) {
// 1. 用同样的方法生成服务器端签名
const generatedSignature = generateApiSignature(params, secretKey)
// 2. 使用 timingSafeEqual 比较签名(避免计时攻击)
return crypto.timingSafeEqual(
Buffer.from(signature), // 客户端传入的签名
Buffer.from(generatedSignature) // 服务器生成的签名
)
}
// 使用示例:验证请求签名
const isSignatureValid = verifyApiSignature(
request.params,
request.signature,
secretKey
)
if (!isSignatureValid) {
throw new Error('签名无效,请求可能被篡改')
}

6.2 场景 2:WebCrypto 实现文件分块哈希(大文件优化)#

对于 GB 级大文件,直接读取整个文件到内存会导致内存溢出。WebCrypto 的异步特性结合文件分块读取,可以高效计算哈希。以下是浏览器中的实现(Node.js 中可类似用 fs.createReadStream):

实现思路#

  1. 分块读取:用 FileReader.readAsArrayBuffer 分块读取文件(每次 64KB);
  2. 异步处理:每块读取完成后,用 crypto.subtle.digest 计算哈希?不对——哈希是单向且不可累加的,因此需合并所有块的 ArrayBuffer 后再计算哈希。但大文件合并会内存溢出,因此需用 Stream API 实现流式哈希(WebCrypto 目前不支持原生流式哈希,需用 ReadableStream 模拟)。

代码示例(浏览器)#

/**
* 分块计算文件哈希(WebCrypto + Stream API)
* @param {File} file - 浏览器中的文件对象
* @param {string} algorithm - 哈希算法(默认 'SHA-256')
* @param {number} chunkSize - 分块大小(默认 64KB)
* @returns {Promise<string>} 哈希结果(十六进制)
*/
async function computeFileHash(
file,
algorithm = 'SHA-256',
chunkSize = 64 * 1024
) {
const reader = file.stream().getReader()
const chunks = []
// 1. 分块读取文件
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value) // 收集每块的 ArrayBuffer
}
// 2. 合并所有块的 ArrayBuffer(大文件需注意内存限制)
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.byteLength, 0)
const combinedBuffer = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
combinedBuffer.set(new Uint8Array(chunk), offset)
offset += chunk.byteLength
}
// 3. 用 WebCrypto 计算哈希
const hashBuffer = await crypto.subtle.digest(
algorithm,
combinedBuffer.buffer
)
// 4. 转换为十六进制字符串
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
// 使用示例:浏览器中计算大文件哈希
const fileInput = document.getElementById('file-input')
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0]
if (!file) return
const hash = await computeFileHash(file)
console.log('文件哈希:', hash)
})

Node.js 中的类似实现(用 fs.createReadStream#

const fs = require('fs/promises')
const { Readable } = require('stream')
async function computeFileHashNode(
filePath,
algorithm = 'SHA-256',
chunkSize = 64 * 1024
) {
const stream = fs.createReadStream(filePath, { highWaterMark: chunkSize })
const chunks = []
// 分块读取文件
for await (const chunk of stream) {
chunks.push(chunk)
}
// 合并块并计算哈希
const combinedBuffer = Buffer.concat(chunks)
const hashBuffer = await crypto.subtle.digest(
algorithm,
combinedBuffer.buffer
)
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}

七、常见问题解答(续)#

Q4:WebCrypto 的 AES-GCM 为什么需要 IV?#

A:AES-GCM 是带认证的流密码模式,IV(初始化向量)的核心作用是确保相同明文生成不同密文——若使用相同 IV 和密钥加密相同明文,会得到相同密文,攻击者可通过比对密文破解内容。

IV 的三个关键要求

  1. 随机生成:不能重复(即使密钥相同);
  2. 长度为 12 字节:WebCrypto 推荐,GCM 模式对 12 字节 IV 的处理性能最优;
  3. 不需要保密:可随密文一起传输(如放在密文前 12 字节)。

Q5:如何选择加密算法?#

A:优先选择带认证的加密算法(Authenticated Encryption,AE),这类算法同时提供机密性(加密数据)和完整性(验证数据未被篡改)。以下是常用算法对比:

算法特点适用场景
AES-GCM性能高、支持流加密、带完整性验证加密用户数据、文件
ChaCha20-Poly1305适合低功耗设备(如手机)、抗侧信道攻击移动端加密、IoT 设备
RSA-OAEP非对称加密、适合小数据(如加密密钥)密钥封装、数字签名

Q6:WebCrypto 的密钥可以存储吗?#

A:可以。WebCrypto 支持将密钥导出为标准格式,用于跨平台传输或存储:

格式说明适用场景
JWKJSON 格式,跨平台兼容浏览器 → 服务器传输密钥
PKCS#8私钥存储格式(需加密)服务器存储私钥
SPKI公钥存储格式(可明文)公开公钥(如 HTTPS 证书)

示例:导出 AES 密钥为 JWK 并存储#

/**
* 保存密钥到 LocalStorage(浏览器)
* @param {CryptoKey} key - WebCrypto 密钥
* @param {string} keyName - 存储键名
*/
async function saveKeyToLocalStorage(key, keyName = 'aes-key') {
const jwk = await crypto.subtle.exportKey('jwk', key)
localStorage.setItem(keyName, JSON.stringify(jwk))
}
/**
* 从 LocalStorage 加载密钥(浏览器)
* @param {string} keyName - 存储键名
* @returns {Promise<CryptoKey>} WebCrypto 密钥
*/
async function loadKeyFromLocalStorage(keyName = 'aes-key') {
const jwkStr = localStorage.getItem(keyName)
if (!jwkStr) throw new Error('密钥未找到')
const jwk = JSON.parse(jwkStr)
return crypto.subtle.importKey(
'jwk',
jwk,
{ name: 'AES-GCM', length: 256 },
true, // 是否允许提取密钥
['encrypt', 'decrypt'] // 密钥用途
)
}
// 使用示例:生成并存储密钥
const aesKey = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
)
await saveKeyToLocalStorage(aesKey)
// 加载密钥
const loadedKey = await loadKeyFromLocalStorage()
console.log('加载的密钥:', loadedKey)

八、总结:密码学 API 的“未来式”#

从 Node.js crypto 到 WebCrypto,密码学 API 的演进方向是**“标准化、跨平台、安全优先”**:

1. WebCrypto 成为主流#

随着 WinterCG 的普及,WebCrypto 已支持 Node.js、浏览器、Cloudflare Workers 等几乎所有现代 runtime。新项目应优先选择 WebCrypto——它的 API 跨平台兼容,异步不阻塞,且仅支持安全算法。

2. 安全算法“去弱留强”#

MD5、SHA-1 等弱算法将逐渐被淘汰,SHA-256、AES-GCM 等安全算法成为标配。浏览器和 runtime 会逐步禁用弱算法(如 Chrome 已禁止在 HTTPS 中使用 SHA-1 证书)。

3. 异步与性能的平衡#

WebCrypto 的异步设计解决了同步 API 的阻塞问题,结合 libuv 线程池的优化,即使处理大文件也能保持主事件循环的响应性。

给开发者的最后建议

  • 永远不要自己发明加密算法:使用经过验证的标准算法(如 AES-GCM、SHA-256);
  • 重视密钥管理:密钥的安全存储(如加密存储私钥)比加密算法本身更重要;
  • 避免弱算法:即使需求是“兼容旧系统”,也应尽量用安全算法替代(如用 SHA-256 替代 MD5)。

密码学是 Web 安全的基石,选择正确的 API 和算法,才能真正保护用户数据的安全。愿你在开发中,既能“实现功能”,又能“守住安全”。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

【Node.js】crypto 与 WebCrypto:从 Legacy 到密码学
https://blog.fridolph.top/posts/2023-11-24__crpto/
作者
Fridolph
发布于
2023-11-24
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录