1. 建立正确的密码学模型
1.1 crypto 是什么
node:crypto 是 Node.js 官方核心模块对 OpenSSL 等底层密码学能力的封装。JavaScript 代码负责选择算法、准备密钥与数据;真正的大量字节运算通常由 Node.js 背后的原生代码完成。
TypeScript 业务代码
↓ 调用 node:crypto
Node.js 原生绑定
↓
OpenSSL / 操作系统能力
↓
CPU 完成密码学计算
它主要解决四个安全目标:
- 机密性:没有密钥的人看不懂数据,例如 AES 加密。
- 完整性:发现数据是否被修改,例如哈希、HMAC、认证标签。
- 真实性:判断数据是否由掌握密钥的一方产生,例如 HMAC 或数字签名。
- 不可预测性:生成攻击者难以猜中的值,例如会话 Token、密钥、盐。
crypto 只是基础工具,不会自动替业务做出正确选择。用 SHA-256 保存密码、重复使用 AES-GCM 的 IV,虽然代码可以正常运行,安全设计却是错误的。
1.2 数据最终都是字节
密码学算法处理的是字节,不理解“中文”“JSON”或“用户对象”。Node.js 通常用 Buffer 表示字节:
const bytes = Buffer.from("你好", "utf8");
console.log(bytes); // UTF-8 字节
console.log(bytes.length); // 6
算法的输入输出关系可以理解为:
字符串/对象
↓ UTF-8 编码或 JSON.stringify
Buffer 字节
↓ crypto 算法
Buffer 字节
↓ hex / base64 / base64url 编码
便于存储或传输的字符串
hex、Base64 和 Base64URL 都只是字节的文本表示方式,不是加密。知道编码规则的人不需要密钥就能还原:
const encoded = Buffer.from("secret", "utf8").toString("base64url");
const decoded = Buffer.from(encoded, "base64url").toString("utf8");
console.log(encoded);
console.log(decoded); // secret
常用表示方式:
| 编码 | 特点 | 常见场景 |
|---|---|---|
hex |
一个字节变成两个字符,直观但较长 | 摘要、日志、调试 |
base64 |
更紧凑,可能包含 +、/、= |
配置、普通文本传输 |
base64url |
适合 URL,不使用 + 和 / |
Token、JWT |
1.3 哈希、密码哈希、加密、HMAC 与签名
这些技术都可能输出“看不懂的字符串”,但用途完全不同。
普通哈希
任意数据 --SHA-256--> 固定长度摘要
- 不需要密钥。
- 不可逆,不用于恢复原文。
- 同样输入产生同样摘要。
- 适合文件完整性、内容指纹、高熵 Token 的数据库摘要。
- 因为速度太快,不适合直接哈希人类密码。
密码哈希
密码 + 随机盐 + 成本参数 --scrypt--> 密码哈希
- 不可逆。
- 故意消耗时间和内存,降低攻击者批量猜密码的速度。
- 登录时不是“解密密码”,而是重新计算并比较。
对称加密
明文 + Key + IV -> 密文
密文 + 同一个 Key + IV -> 明文
- 可逆。
- 适合必须在以后取回原文的数据。
- 现代业务场景应优先使用 AES-GCM 这类认证加密,同时保护机密性和完整性。
HMAC
消息 + 共享 Secret -> MAC
- 不隐藏消息。
- 持有相同 Secret 的一方可以生成和验证 MAC。
- 用于证明内容未改且来自共享密钥持有者。
- JWT 的 HS256 底层就是 HMAC-SHA-256。
非对称签名
消息 + 私钥 -> 签名
消息 + 签名 + 公钥 -> 真或假
- 私钥负责签名,必须保密。
- 公钥负责验证,可以分发给其他服务。
- 签名不加密消息。
1.4 Key、Salt、IV 和 Auth Tag 不要混用
| 名称 | 是否保密 | 主要作用 | 能否重复使用 |
|---|---|---|---|
| Key / Secret | 必须保密 | 控制加解密、HMAC 或签名能力 | 可按策略使用和轮换 |
| Salt | 不需要 | 让相同密码产生不同哈希 | 每个密码都应随机生成 |
| IV / Nonce | 通常不需要 | 让同一 Key 下的加密具有唯一性 | AES-GCM 同一 Key 下绝不能重复 |
| Auth Tag | 不需要 | 验证密文和附加数据未被篡改 | 与本次密文一起保存 |
一个常见误解是“不是秘密就不重要”。Salt、IV、Auth Tag 通常可以与结果一起保存,但丢失或错误使用仍会让验证失败,甚至破坏安全性。
1.5 随机数不是随便一个随机函数
Math.random() 主要用于游戏、抽样和界面效果,不承诺密码学不可预测性。攻击者可能根据内部状态推测结果,因此不能用它生成:
- 登录会话 Token。
- 找回密码链接。
- API Key。
- 加密密钥或 IV。
- 验证码的安全核心值。
安全相关随机值应来自 node:crypto:
import { randomBytes, randomInt, randomUUID } from "node:crypto";
1.6 crypto 不能解决所有安全问题
即使算法选择正确,下面的问题仍需业务系统处理:
- 密钥不能提交到 Git,也不能写进日志。
- 传输仍需 HTTPS;应用层加密不能代替 TLS。
- 登录接口需要限流,否则攻击者仍能不断猜密码。
- 解密后的数据仍要做权限检查。
- JWT 验签成功不代表用户当前仍有权限。
- 数据库泄露、日志泄露、依赖漏洞和服务器入侵需要分别防护。
密码学更像门锁:门锁很重要,但它不会替你决定谁可以拿钥匙,也不会阻止别人从没关好的窗户进入。