加密与数据保护
加密是保护敏感数据的最后一道防线。本章将深入讲解如何在 Web 应用中正确实施加密措施,涵盖密码存储、数据加密、密钥管理和传输安全等核心主题。
核心概念:哈希与加密的区别
理解哈希和加密的区别是正确实施安全措施的基础,这也是开发者最容易混淆的概念之一。
哈希(Hashing)
哈希是一种单向函数:将任意长度的输入转换为固定长度的输出,且无法从输出反推输入。这个特性使哈希成为密码存储的理想选择——即使数据库泄露,攻击者也无法直接获取用户密码。
// 哈希是单向的
const hash = sha256("password123"); // 输出固定的哈希值
// 无法从哈希值反推出 "password123"
特点:
- 单向不可逆
- 相同输入始终产生相同输出
- 不同输入产生不同输出(理想情况下)
- 输出长度固定
适用场景:
- 密码存储
- 数据完整性验证
- 数字签名
加密(Encryption)
加密是一种双向函数:使用密钥将明文转换为密文,也可以使用密钥将密文还原为明文。当需要获取原始数据时(如用户的身份证号、银行卡号),必须使用加密而非哈希。
// 加密是双向的
const encrypted = aesEncrypt("敏感数据", secretKey); // 加密
const decrypted = aesDecrypt(encrypted, secretKey); // 解密得到原始数据
特点:
- 双向可逆(需要密钥)
- 加密和解密使用相同密钥(对称加密)或不同密钥(非对称加密)
- 输出长度与输入相关
适用场景:
- 敏感数据存储(身份证号、银行卡号等需要显示的信息)
- 数据传输保护
- 数字信封
选择原则
| 数据类型 | 存储方式 | 原因 |
|---|---|---|
| 用户密码 | 哈希 | 验证时不需要原始密码 |
| 身份证号 | 加密 | 业务需要显示完整或部分信息 |
| 银行卡号 | 加密 | 业务需要显示后四位 |
| 安全问题答案 | 哈希 | 验证时不需要原始答案 |
| OAuth Token | 加密或签名 | 需要验证或使用原始值 |
永远不要加密密码!密码应该只使用专用的哈希算法存储。加密的密码可以被解密,一旦密钥泄露,所有密码都将暴露。
密码存储:安全的核心
密码是用户身份认证的基石,密码存储的安全直接关系到整个系统的安全。OWASP 明确指出:密码必须使用专用的哈希算法存储,而不是加密。
为什么不能用 MD5 或 SHA-256 存储密码?
MD5、SHA-1、SHA-256 等哈希算法设计目标是"快速计算",这与密码存储的需求恰恰相反。攻击者可以使用 GPU 每秒计算数十亿次 SHA-256,在极短时间内破解大量密码。
// 危险:使用快速哈希存储密码
const hashedPassword = sha256(password); // 攻击者每秒可破解数十亿次
// 即使加盐也不够安全
const hashedPassword = sha256(password + salt); // 仍然太快
专用密码哈希算法(如 Argon2id、bcrypt、scrypt)故意设计得很"慢",通过消耗大量 CPU 和内存资源,显著增加破解成本。
算法选择与配置
根据 OWASP 2024 年的最新建议,密码哈希算法应按以下优先级选择:
1. Argon2id(首选)
Argon2 是 2015 年密码哈希竞赛的获胜者,Argon2id 是推荐的变体,同时抵抗 GPU 攻击和侧信道攻击。
推荐配置:
| 内存 (m) | 迭代次数 (t) | 并行度 (p) | 说明 |
|---|---|---|---|
| 46 MiB | 1 | 1 | 内存充足时推荐 |
| 19 MiB | 2 | 1 | 平衡配置(推荐) |
| 12 MiB | 3 | 1 | 内存受限时 |
| 9 MiB | 4 | 1 | 内存受限时 |
| 7 MiB | 5 | 1 | 内存受限时 |
// Node.js - 使用 argon2 库
const argon2 = require('argon2');
// 哈希密码
async function hashPassword(password) {
try {
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2, // 迭代次数
parallelism: 1, // 并行度
hashLength: 32, // 哈希输出长度
saltLength: 16 // 盐值长度
});
return hash;
} catch (err) {
console.error('哈希失败:', err);
throw err;
}
}
// 验证密码
async function verifyPassword(hash, password) {
try {
return await argon2.verify(hash, password);
} catch (err) {
return false;
}
}
// 使用示例
const hash = await hashPassword('user-password');
const isValid = await verifyPassword(hash, 'user-password'); // true
# Python - 使用 argon2-cffi 库
import argon2
# 创建哈希器
hasher = argon2.PasswordHasher(
time_cost=2, # 迭代次数
memory_cost=19456, # 19 MiB
parallelism=1, # 并行度
hash_len=32, # 哈希长度
salt_len=16 # 盐值长度
)
# 哈希密码
hashed = hasher.hash("user-password")
# 验证密码
try:
hasher.verify(hashed, "user-password")
print("密码正确")
except argon2.exceptions.VerifyMismatchError:
print("密码错误")
except argon2.exceptions.VerificationError:
print("验证失败")
// Java - 使用 argon2-jvm 库
import de.mkammerer.argon2.Argon2;
import de.mkammerer.argon2.Argon2Factory;
public class Argon2PasswordHasher {
private static final int ITERATIONS = 2;
private static final int MEMORY = 19456; // 19 MiB
private static final int PARALLELISM = 1;
public String hash(String password) {
Argon2 argon2 = Argon2Factory.create();
try {
return argon2.hash(ITERATIONS, MEMORY, PARALLELISM, password.toCharArray());
} finally {
argon2.wipeArray(password.toCharArray());
}
}
public boolean verify(String hash, String password) {
Argon2 argon2 = Argon2Factory.create();
try {
return argon2.verify(hash, password.toCharArray());
} finally {
argon2.wipeArray(password.toCharArray());
}
}
}
// Go - 使用 argon2 包
import (
"crypto/argon2"
"crypto/rand"
"encoding/base64"
"fmt"
)
type Argon2Params struct {
memory uint32
iterations uint32
parallelism uint8
saltLength uint32
keyLength uint32
}
func HashPassword(password string, p Argon2Params) (string, error) {
// 生成随机盐值
salt := make([]byte, p.saltLength)
if _, err := rand.Read(salt); err != nil {
return "", err
}
// 生成哈希
hash := argon2.IDKey(
[]byte(password),
salt,
p.iterations,
p.memory,
p.parallelism,
p.keyLength,
)
// 编码为字符串
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
p.memory, p.iterations, p.parallelism, b64Salt, b64Hash), nil
}
// 使用示例
params := Argon2Params{
memory: 19456,
iterations: 2,
parallelism: 1,
saltLength: 16,
keyLength: 32,
}
hash, _ := HashPassword("user-password", params)
2. scrypt(备选)
当 Argon2id 不可用时,scrypt 是第二选择。它同样使用内存困难的设计,能有效抵抗 GPU 和 ASIC 攻击。
推荐配置:
| N (CPU/内存参数) | r (块大小) | p (并行度) | 内存消耗 |
|---|---|---|---|
| 2^17 (131072) | 8 | 1 | 128 MiB |
| 2^16 (65536) | 8 | 2 | 64 MiB |
| 2^15 (32768) | 8 | 3 | 32 MiB |
| 2^14 (16384) | 8 | 5 | 16 MiB |
# Python - scrypt 示例
import hashlib
import os
import base64
def hash_password_scrypt(password: str) -> str:
# 生成盐值
salt = os.urandom(16)
# 计算 scrypt 哈希
# N=2^17, r=8, p=1
hash_bytes = hashlib.scrypt(
password.encode('utf-8'),
salt=salt,
n=2**17,
r=8,
p=1,
dklen=32
)
# 编码存储
return f"$scrypt${base64.b64encode(salt).decode()}${base64.b64encode(hash_bytes).decode()}"
def verify_password_scrypt(stored_hash: str, password: str) -> bool:
parts = stored_hash.split('$')
salt = base64.b64decode(parts[2])
stored = base64.b64decode(parts[3])
computed = hashlib.scrypt(
password.encode('utf-8'),
salt=salt,
n=2**17,
r=8,
p=1,
dklen=32
)
return computed == stored
3. bcrypt(遗留系统)
bcrypt 是广泛使用的密码哈希算法,适合遗留系统或需要广泛兼容性的场景。注意 bcrypt 有 72 字节的输入限制。
推荐配置:
- 工作因子:至少 10(越高越安全,但越慢)
- 避免预哈希:直接使用 bcrypt,除非密码超过 72 字节
// Node.js - bcrypt 示例
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // 工作因子
// 哈希密码
async function hashPassword(password) {
// 注意:bcrypt 有 72 字节限制
if (Buffer.byteLength(password, 'utf8') > 72) {
// 密码过长,需要预哈希
const crypto = require('crypto');
password = crypto.createHash('sha384')
.update(password)
.digest('base64');
}
return await bcrypt.hash(password, SALT_ROUNDS);
}
// 验证密码
async function verifyPassword(password, hash) {
// 同样处理超长密码
if (Buffer.byteLength(password, 'utf8') > 72) {
const crypto = require('crypto');
password = crypto.createHash('sha384')
.update(password)
.digest('base64');
}
return await bcrypt.compare(password, hash);
}
# Python - bcrypt 示例
import bcrypt
def hash_password(password: str) -> str:
# 处理超长密码
if len(password.encode('utf-8')) > 72:
import hashlib
password = hashlib.sha384(password.encode()).hexdigest()
# 工作因子 12
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def verify_password(password: str, hashed: str) -> bool:
if len(password.encode('utf-8')) > 72:
import hashlib
password = hashlib.sha384(password.encode()).hexdigest()
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
// Java - BCrypt 示例
import org.mindrot.jbcrypt.BCrypt;
public class BcryptPasswordHasher {
// 工作因子 12
private static final int WORK_FACTOR = 12;
public String hashPassword(String password) {
return BCrypt.hashpw(password, BCrypt.gensalt(WORK_FACTOR));
}
public boolean verifyPassword(String password, String hash) {
return BCrypt.checkpw(password, hash);
}
}
4. PBKDF2(FIPS 合规)
当需要 FIPS-140 合规时,PBKDF2 是唯一选择。它使用 HMAC 作为底层哈希函数。
推荐配置:
| 哈希函数 | 迭代次数 |
|---|---|
| HMAC-SHA-1 | 1,300,000 |
| HMAC-SHA-256 | 600,000 |
| HMAC-SHA-512 | 210,000 |
# Python - PBKDF2 示例
import hashlib
import os
import base64
def hash_password_pbkdf2(password: str) -> str:
salt = os.urandom(16)
# 使用 HMAC-SHA-256,迭代 600000 次
hash_bytes = hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'),
salt,
600000, # 迭代次数
dklen=32
)
return f"$pbkdf2-sha256${base64.b64encode(salt).decode()}${base64.b64encode(hash_bytes).decode()}"
def verify_password_pbkdf2(stored_hash: str, password: str) -> bool:
parts = stored_hash.split('$')
salt = base64.b64decode(parts[2])
stored = base64.b64decode(parts[3])
computed = hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'),
salt,
600000,
dklen=32
)
# 使用恒定时间比较,防止计时攻击
return secrets.compare_digest(computed, stored)
算法对比总结
| 算法 | 优先级 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Argon2id | 首选 | 抗 GPU/ASIC/侧信道攻击,可配置内存 | 库支持相对较新 | 新项目首选 |
| scrypt | 备选 | 内存困难,抗 GPU/ASIC | 配置相对复杂 | Argon2id 不可用时 |
| bcrypt | 遗留 | 广泛支持,简单易用 | 72 字节限制,无内存困难 | 遗留系统、简单场景 |
| PBKDF2 | 合规 | FIPS 合规,广泛支持 | 无内存困难,易受 GPU 攻击 | 需要 FIPS 合规时 |
工作因子的选择
工作因子决定了哈希计算的复杂度。选择原则是:在可接受的响应时间内尽可能高。一般建议单个密码验证时间控制在 0.5 秒以内。
// 测试不同工作因子的耗时
async function benchmarkBcrypt() {
const password = "test-password";
for (let rounds = 10; rounds <= 15; rounds++) {
const start = Date.now();
await bcrypt.hash(password, rounds);
const duration = Date.now() - start;
console.log(`Rounds ${rounds}: ${duration}ms`);
}
}
// 示例输出:
// Rounds 10: 100ms
// Rounds 11: 200ms
// Rounds 12: 400ms
// Rounds 13: 800ms
// Rounds 14: 1600ms
// Rounds 15: 3200ms
盐值与胡椒
盐值(Salt):每个用户唯一的随机值,与密码一起哈希。盐值的作用是防止彩虹表攻击和批量破解。
// 现代库自动处理盐值,无需手动管理
const hash = await argon2.hash(password); // 盐值已包含在 hash 中
胡椒(Pepper):所有用户共享的秘密值,存储在应用层而非数据库。胡椒提供了额外的防御层:即使数据库泄露,攻击者仍无法离线破解密码。
// 胡椒的使用
const pepper = process.env.PASSWORD_PEPPER; // 从安全配置中获取
async function hashWithPepper(password) {
// 先与胡椒组合,再哈希
const pepperedPassword = password + pepper;
return await argon2.hash(pepperedPassword);
}
async function verifyWithPepper(password, hash) {
const pepperedPassword = password + pepper;
return await argon2.verify(hash, pepperedPassword);
}
- 胡椒泄露后,需要强制所有用户重置密码
- 胡椒应存储在密钥管理系统或 HSM 中,而非代码或配置文件
- 胡椒不能替代盐值,两者应配合使用
数据加密:保护敏感信息
当业务需要存储和读取敏感数据时(如身份证号、银行卡号),必须使用加密而非哈希。
对称加密:AES-GCM
AES(Advanced Encryption Standard)是最广泛使用的对称加密算法。强烈推荐使用 GCM(Galois/Counter Mode)模式,因为它同时提供加密和完整性验证。
为什么选择 GCM 模式?
| 模式 | 加密 | 完整性验证 | 推荐度 |
|---|---|---|---|
| GCM | ✓ | ✓ | 首选 |
| CCM | ✓ | ✓ | 备选 |
| CBC | ✓ | ✗ | 需配合 HMAC |
| CTR | ✓ | ✗ | 需配合 HMAC |
| ECB | ✓ | ✗ | 禁止使用 |
完整实现示例
// Node.js - AES-GCM 加密
const crypto = require('crypto');
class AESGCMEncryptor {
constructor(key) {
// 密钥应为 256 位(32 字节)
if (Buffer.byteLength(key) !== 32) {
throw new Error('密钥必须是 32 字节(256 位)');
}
this.key = key;
}
/**
* 加密数据
* @param {string|Buffer} plaintext - 明文
* @returns {string} - Base64 编码的密文(包含 IV 和认证标签)
*/
encrypt(plaintext) {
// 生成随机 IV(初始化向量)
// GCM 推荐 12 字节的 IV
const iv = crypto.randomBytes(12);
// 创建加密器
const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv);
// 加密
let encrypted = cipher.update(plaintext, 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
// 获取认证标签
const authTag = cipher.getAuthTag();
// 组合:IV + 认证标签 + 密文
// 这样存储可以方便解密时提取各部分
const result = Buffer.concat([iv, authTag, encrypted]);
return result.toString('base64');
}
/**
* 解密数据
* @param {string} encryptedData - Base64 编码的密文
* @returns {string} - 明文
*/
decrypt(encryptedData) {
const data = Buffer.from(encryptedData, 'base64');
// 提取各部分
const iv = data.subarray(0, 12);
const authTag = data.subarray(12, 28);
const encrypted = data.subarray(28);
// 创建解密器
const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, iv);
decipher.setAuthTag(authTag);
// 解密
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}
}
// 使用示例
const key = crypto.randomBytes(32); // 生产环境应从密钥管理系统获取
const encryptor = new AESGCMEncryptor(key);
const encrypted = encryptor.encrypt('敏感数据:身份证号 123456789012345678');
console.log('加密后:', encrypted);
const decrypted = encryptor.decrypt(encrypted);
console.log('解密后:', decrypted);
# Python - AES-GCM 加密
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
import base64
class AESGCMEncryptor:
def __init__(self, key: bytes):
if len(key) != 32:
raise ValueError('密钥必须是 32 字节(256 位)')
self.aesgcm = AESGCM(key)
def encrypt(self, plaintext: str) -> str:
# 生成 12 字节的 nonce(相当于 IV)
nonce = os.urandom(12)
# 加密
ciphertext = self.aesgcm.encrypt(
nonce,
plaintext.encode('utf-8'),
None # 关联数据(可选)
)
# 组合:nonce + ciphertext(ciphertext 包含认证标签)
result = nonce + ciphertext
return base64.b64encode(result).decode('utf-8')
def decrypt(self, encrypted_data: str) -> str:
data = base64.b64decode(encrypted_data)
# 提取 nonce 和密文
nonce = data[:12]
ciphertext = data[12:]
# 解密(会自动验证认证标签)
plaintext = self.aesgcm.decrypt(nonce, ciphertext, None)
return plaintext.decode('utf-8')
# 使用示例
key = os.urandom(32)
encryptor = AESGCMEncryptor(key)
encrypted = encryptor.encrypt('敏感数据:身份证号 123456789012345678')
print(f'加密后: {encrypted}')
decrypted = encryptor.decrypt(encrypted)
print(f'解密后: {decrypted}')
// Java - AES-GCM 加密
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
public class AESGCMEncryptor {
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128; // 位
private static final String ALGORITHM = "AES/GCM/NoPadding";
private final byte[] key;
public AESGCMEncryptor(byte[] key) {
if (key.length != 32) {
throw new IllegalArgumentException("密钥必须是 32 字节(256 位)");
}
this.key = key;
}
public String encrypt(String plaintext) throws Exception {
// 生成随机 IV
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
// 创建加密器
Cipher cipher = Cipher.getInstance(ALGORITHM);
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
// 加密
byte[] ciphertext = cipher.doFinal(plaintext.getBytes("UTF-8"));
// 组合 IV 和密文
byte[] result = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, result, 0, iv.length);
System.arraycopy(ciphertext, 0, result, iv.length, ciphertext.length);
return Base64.getEncoder().encodeToString(result);
}
public String decrypt(String encryptedData) throws Exception {
byte[] data = Base64.getDecoder().decode(encryptedData);
// 提取 IV 和密文
byte[] iv = new byte[GCM_IV_LENGTH];
byte[] ciphertext = new byte[data.length - GCM_IV_LENGTH];
System.arraycopy(data, 0, iv, 0, GCM_IV_LENGTH);
System.arraycopy(data, GCM_IV_LENGTH, ciphertext, 0, ciphertext.length);
// 创建解密器
Cipher cipher = Cipher.getInstance(ALGORITHM);
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
// 解密
byte[] plaintext = cipher.doFinal(ciphertext);
return new String(plaintext, "UTF-8");
}
}
// Go - AES-GCM 加密
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
type AESGCMEncryptor struct {
key []byte
}
func NewAESGCMEncryptor(key []byte) (*AESGCMEncryptor, error) {
if len(key) != 32 {
return nil, errors.New("密钥必须是 32 字节(256 位)")
}
return &AESGCMEncryptor{key: key}, nil
}
func (e *AESGCMEncryptor) Encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// 生成随机 nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// 加密(结果包含 nonce)
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (e *AESGCMEncryptor) Decrypt(encryptedData string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encryptedData)
if err != nil {
return "", err
}
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("密文太短")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
非对称加密:RSA 与椭圆曲线
非对称加密使用公钥加密、私钥解密,适用于密钥交换和数字签名场景。
RSA 加密
// Java - RSA-OAEP 加密
import javax.crypto.Cipher;
import java.security.*;
import java.security.spec.*;
import java.util.Base64;
public class RSAEncryptor {
private static final String ALGORITHM = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
// 生成密钥对
public static KeyPair generateKeyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
}
// 公钥加密
public static String encrypt(String plaintext, PublicKey publicKey) throws Exception {
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] encrypted = cipher.doFinal(plaintext.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(encrypted);
}
// 私钥解密
public static String decrypt(String encryptedData, PrivateKey privateKey) throws Exception {
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedData));
return new String(decrypted, "UTF-8");
}
}
椭圆曲线加密(推荐)
椭圆曲线加密(ECC)提供与 RSA 相当的安全性,但使用更短的密钥,计算效率更高。
# Python - 椭圆曲线加密示例
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
# 生成椭圆曲线密钥对(使用 Curve25519)
private_key = ec.generate_private_key(ec.SECP384R1())
public_key = private_key.public_key()
# 使用 ECDH 进行密钥交换
def derive_shared_key(private_key, peer_public_key):
# 派生共享密钥
shared_key = private_key.exchange(ec.ECDH(), peer_public_key)
# 使用 HKDF 派生对称密钥
derived_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b'handshake data',
).derive(shared_key)
return derived_key
Web Cryptography API:浏览器端加密
现代浏览器提供了 Web Cryptography API(Web Crypto API),允许在浏览器中进行安全的加密操作。这是实现端到端加密(E2EE)的关键技术。
为什么需要在浏览器端加密?
传统的加密模式是:客户端明文传输 → 服务端加密存储。这种模式存在问题:
- 服务端可以访问所有明文数据
- 传输过程中的中间人可能窃取数据
- 服务端被入侵时,所有数据暴露
端到端加密可以解决这些问题:数据在客户端加密,服务端只存储密文。
基本使用
// 浏览器端 AES-GCM 加密
class BrowserCrypto {
/**
* 生成新的 AES 密钥
*/
static async generateKey() {
return await crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256
},
true, // 是否可导出
['encrypt', 'decrypt']
);
}
/**
* 从密码派生密钥
*/
static async deriveKeyFromPassword(password, salt) {
// 将密码转换为密钥材料
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
// 派生 AES 密钥
return await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
/**
* 加密数据
*/
static async encrypt(key, plaintext) {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
encoder.encode(plaintext)
);
// 返回 IV + 密文
return {
iv: Array.from(iv),
data: Array.from(new Uint8Array(ciphertext))
};
}
/**
* 解密数据
*/
static async decrypt(key, encrypted) {
const decoder = new TextDecoder();
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: new Uint8Array(encrypted.iv)
},
key,
new Uint8Array(encrypted.data)
);
return decoder.decode(plaintext);
}
/**
* 导出密钥为 Base64
*/
static async exportKey(key) {
const exported = await crypto.subtle.exportKey('raw', key);
return btoa(String.fromCharCode(...new Uint8Array(exported)));
}
/**
* 从 Base64 导入密钥
*/
static async importKey(base64Key) {
const keyData = Uint8Array.from(atob(base64Key), c => c.charCodeAt(0));
return await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'AES-GCM' },
true,
['encrypt', 'decrypt']
);
}
}
// 使用示例
async function example() {
// 方式一:生成随机密钥
const key = await BrowserCrypto.generateKey();
// 方式二:从密码派生密钥
const salt = crypto.getRandomValues(new Uint8Array(16));
const keyFromPassword = await BrowserCrypto.deriveKeyFromPassword(
'user-password',
salt
);
// 加密
const encrypted = await BrowserCrypto.encrypt(key, '敏感数据');
console.log('加密后:', encrypted);
// 解密
const decrypted = await BrowserCrypto.decrypt(key, encrypted);
console.log('解密后:', decrypted);
// 导出密钥用于存储
const exportedKey = await BrowserCrypto.exportKey(key);
console.log('导出的密钥:', exportedKey);
}
端到端加密聊天示例
// 简化的端到端加密聊天客户端
class E2EEChat {
constructor() {
this.keyPair = null;
this.peerPublicKeys = new Map(); // userId -> publicKey
}
// 初始化:生成密钥对
async init() {
this.keyPair = await crypto.subtle.generateKey(
{
name: 'ECDH',
namedCurve: 'P-256'
},
true,
['deriveKey']
);
return await this.exportPublicKey();
}
// 导出公钥
async exportPublicKey() {
const exported = await crypto.subtle.exportKey('spki', this.keyPair.publicKey);
return btoa(String.fromCharCode(...new Uint8Array(exported)));
}
// 接收对方的公钥
async receivePeerPublicKey(userId, publicKeyBase64) {
const keyData = Uint8Array.from(atob(publicKeyBase64), c => c.charCodeAt(0));
const publicKey = await crypto.subtle.importKey(
'spki',
keyData,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
this.peerPublicKeys.set(userId, publicKey);
}
// 加密消息给特定用户
async encryptForUser(userId, message) {
const peerPublicKey = this.peerPublicKeys.get(userId);
if (!peerPublicKey) throw new Error('未知用户');
// 使用 ECDH 派生共享密钥
const sharedKey = await crypto.subtle.deriveKey(
{ name: 'ECDH', public: peerPublicKey },
this.keyPair.privateKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
// 加密消息
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoder = new TextEncoder();
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
sharedKey,
encoder.encode(message)
);
return {
iv: Array.from(iv),
data: Array.from(new Uint8Array(ciphertext))
};
}
// 解密来自特定用户的消息
async decryptFromUser(userId, encrypted) {
const peerPublicKey = this.peerPublicKeys.get(userId);
if (!peerPublicKey) throw new Error('未知用户');
// 使用 ECDH 派生共享密钥
const sharedKey = await crypto.subtle.deriveKey(
{ name: 'ECDH', public: peerPublicKey },
this.keyPair.privateKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
// 解密消息
const decoder = new TextDecoder();
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(encrypted.iv) },
sharedKey,
new Uint8Array(encrypted.data)
);
return decoder.decode(plaintext);
}
}
Web Crypto API 的安全限制
| 限制 | 说明 |
|---|---|
| 仅 HTTPS | API 只能在安全上下文(HTTPS 或 localhost)中使用 |
| 不可预测的随机数 | 所有随机数都使用 CSPRNG |
| 密钥隔离 | 密钥对象不可直接访问,只能通过 API 操作 |
| 算法限制 | 只支持经过审查的安全算法 |
Web Crypto API 适合需要在前端处理敏感数据的场景,如端到端加密聊天、密码管理器、本地加密存储等。但对于关键安全功能,服务端验证仍然是必要的。
ChaCha20-Poly1305:移动端友好的替代方案
ChaCha20-Poly1305 是另一种优秀的 AEAD(认证加密)算法,由 Daniel J. Bernstein 设计。在没有 AES 硬件加速的设备上(如大多数移动设备),ChaCha20-Poly1305 的性能明显优于 AES-GCM。
为什么需要 ChaCha20-Poly1305?
AES-GCM 的安全性依赖于硬件加速。在没有 AES-NI 指令集的 CPU 上,AES-GCM 的性能较差,且容易受到时序攻击。ChaCha20-Poly1305 则是纯软件实现,在所有平台上都能保持一致的高性能和安全性。
// Node.js - ChaCha20-Poly1305 加密
const crypto = require('crypto');
class ChaCha20Poly1305Encryptor {
constructor(key) {
// 密钥应为 256 位(32 字节)
if (Buffer.byteLength(key) !== 32) {
throw new Error('密钥必须是 32 字节(256 位)');
}
this.key = key;
}
encrypt(plaintext) {
// ChaCha20-Poly1305 使用 12 字节的 nonce
const nonce = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('chacha20-poly1305', this.key, nonce);
let encrypted = cipher.update(plaintext, 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
const authTag = cipher.getAuthTag();
// 组合:nonce + authTag + ciphertext
const result = Buffer.concat([nonce, authTag, encrypted]);
return result.toString('base64');
}
decrypt(encryptedData) {
const data = Buffer.from(encryptedData, 'base64');
const nonce = data.subarray(0, 12);
const authTag = data.subarray(12, 28);
const encrypted = data.subarray(28);
const decipher = crypto.createDecipheriv('chacha20-poly1305', this.key, nonce);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}
}
# Python - ChaCha20-Poly1305 加密
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
import os
import base64
class ChaCha20Poly1305Encryptor:
def __init__(self, key: bytes):
if len(key) != 32:
raise ValueError('密钥必须是 32 字节(256 位)')
self.chacha = ChaCha20Poly1305(key)
def encrypt(self, plaintext: str) -> str:
nonce = os.urandom(12)
ciphertext = self.chacha.encrypt(nonce, plaintext.encode('utf-8'), None)
return base64.b64encode(nonce + ciphertext).decode('utf-8')
def decrypt(self, encrypted_data: str) -> str:
data = base64.b64decode(encrypted_data)
nonce = data[:12]
ciphertext = data[12:]
plaintext = self.chacha.decrypt(nonce, ciphertext, None)
return plaintext.decode('utf-8')
AES-GCM 与 ChaCha20-Poly1305 对比
| 特性 | AES-256-GCM | ChaCha20-Poly1305 |
|---|---|---|
| 密钥长度 | 256 位 | 256 位 |
| Nonce 长度 | 12 字节(推荐) | 12 字节 |
| 认证标签 | 可变(通常 128 位) | 固定 128 位 |
| 硬件加速依赖 | 需要 AES-NI | 无需硬件加速 |
| 移动设备性能 | 较慢(无 AES-NI 时) | 优秀 |
| Nonce 重用风险 | 严重(密钥泄露) | 严重(密钥泄露) |
| 标准化 | NIST SP 800-38D | RFC 8439 |
选择建议:
- 服务器端(有 AES-NI):AES-256-GCM
- 移动端或嵌入式设备:ChaCha20-Poly1305
- 需要跨平台一致性:ChaCha20-Poly1305
加密模式选择指南
| 场景 | 推荐方案 | 说明 |
|---|---|---|
| 敏感数据存储 | AES-256-GCM | 使用认证加密模式 |
| 移动端加密 | ChaCha20-Poly1305 | 无需硬件加速 |
| 大文件加密 | AES-256-GCM + 分块 | 每个分块独立加密 |
| 密钥交换 | ECDH(Curve25519) | 比 RSA 更高效 |
| 数字签名 | Ed25519 或 RSA-PSS | Ed25519 更高效 |
| 密码存储 | Argon2id | 使用专用哈希算法 |
密钥管理
密钥是加密系统的核心。密钥管理不当会导致整个加密体系形同虚设。
密钥生成
密钥必须使用加密安全的随机数生成器生成。
// Node.js - 安全密钥生成
const crypto = require('crypto');
// 生成 AES-256 密钥
const aesKey = crypto.randomBytes(32);
// 生成 RSA 密钥对
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
// 生成 EC 密钥对
const ecKeyPair = crypto.generateKeyPairSync('ec', {
namedCurve: 'P-256',
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
# Python - 安全密钥生成
import os
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from cryptography.hazmat.primitives import serialization
# 生成 AES-256 密钥
aes_key = os.urandom(32)
# 生成 RSA 密钥对
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
# 生成 EC 密钥对(推荐)
ec_private_key = ec.generate_private_key(ec.SECP256R1())
ec_public_key = ec_private_key.public_key()
密钥存储
原则:密钥永远不应该硬编码在代码中,也不应该与加密数据存储在同一位置。
密钥存储层次
- 硬件安全模块(HSM):最高安全级别,密钥无法导出
- 密钥管理服务(KMS):云服务提供的密钥管理(如 AWS KMS、Azure Key Vault)
- 密钥管理系统:如 HashiCorp Vault
- 文件系统:使用文件权限保护(最低安全级别)
// 使用 AWS KMS 示例
import { KMSClient, EncryptCommand, DecryptCommand } from '@aws-sdk/client-kms';
const kmsClient = new KMSClient({ region: 'us-east-1' });
const keyId = 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012';
async function encryptWithKMS(plaintext) {
const command = new EncryptCommand({
KeyId: keyId,
Plaintext: Buffer.from(plaintext)
});
const response = await kmsClient.send(command);
return Buffer.from(response.CiphertextBlob).toString('base64');
}
async function decryptWithKMS(ciphertext) {
const command = new DecryptCommand({
CiphertextBlob: Buffer.from(ciphertext, 'base64')
});
const response = await kmsClient.send(command);
return Buffer.from(response.Plaintext).toString('utf-8');
}
密钥轮换
密钥应该定期轮换,以降低密钥泄露的风险。
// 密钥版本管理示例
class KeyManager {
constructor() {
this.keys = new Map(); // keyId -> key
this.currentKeyId = null;
}
// 添加新密钥
addKey(keyId, key) {
this.keys.set(keyId, {
key,
createdAt: new Date(),
status: 'active'
});
}
// 设置当前密钥
setCurrentKey(keyId) {
if (!this.keys.has(keyId)) {
throw new Error('Key not found');
}
this.currentKeyId = keyId;
}
// 获取当前加密密钥
getCurrentKey() {
return {
keyId: this.currentKeyId,
key: this.keys.get(this.currentKeyId).key
};
}
// 获取指定密钥(用于解密旧数据)
getKey(keyId) {
return this.keys.get(keyId)?.key;
}
// 标记密钥为过期
deprecateKey(keyId) {
const keyInfo = this.keys.get(keyId);
if (keyInfo) {
keyInfo.status = 'deprecated';
}
}
}
// 使用示例
const keyManager = new KeyManager();
keyManager.addKey('v1', crypto.randomBytes(32));
keyManager.addKey('v2', crypto.randomBytes(32));
keyManager.setCurrentKey('v2');
// 加密时使用当前密钥,并记录 keyId
function encrypt(plaintext) {
const { keyId, key } = keyManager.getCurrentKey();
const encrypted = encryptWithKey(plaintext, key);
return { keyId, encrypted };
}
// 解密时根据 keyId 获取对应密钥
function decrypt(keyId, encrypted) {
const key = keyManager.getKey(keyId);
return decryptWithKey(encrypted, key);
}
信封加密
信封加密是一种常用的密钥管理模式,使用数据加密密钥(DEK)加密数据,使用密钥加密密钥(KEK)加密 DEK。
┌─────────────────────────────────────────────────────────────┐
│ 信封加密流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 生成随机 DEK(数据加密密钥) │
│ ↓ │
│ 2. 用 DEK 加密数据 │
│ ↓ │
│ 3. 用 KEK(密钥加密密钥)加密 DEK │
│ ↓ │
│ 4. 存储:加密数据 + 加密的 DEK │
│ │
│ 解密流程: │
│ 1. 用 KEK 解密 DEK │
│ 2. 用 DEK 解密数据 │
│ │
└─────────────────────────────────────────────────────────────┘
// 信封加密实现
class EnvelopeEncryption {
constructor(kek) {
this.kek = kek; // 密钥加密密钥
}
encrypt(plaintext) {
// 1. 生成随机 DEK
const dek = crypto.randomBytes(32);
// 2. 用 DEK 加密数据
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
// 3. 用 KEK 加密 DEK
const dekIv = crypto.randomBytes(12);
const dekCipher = crypto.createCipheriv('aes-256-gcm', this.kek, dekIv);
const encryptedDek = Buffer.concat([dekCipher.update(dek), dekCipher.final()]);
const dekAuthTag = dekCipher.getAuthTag();
return {
encryptedData: Buffer.concat([iv, authTag, encrypted]).toString('base64'),
encryptedDek: Buffer.concat([dekIv, dekAuthTag, encryptedDek]).toString('base64')
};
}
decrypt(encryptedData, encryptedDek) {
// 1. 解密 DEK
const dekData = Buffer.from(encryptedDek, 'base64');
const dekIv = dekData.subarray(0, 12);
const dekAuthTag = dekData.subarray(12, 28);
const dekCiphertext = dekData.subarray(28);
const dekDecipher = crypto.createDecipheriv('aes-256-gcm', this.kek, dekIv);
dekDecipher.setAuthTag(dekAuthTag);
const dek = Buffer.concat([dekDecipher.update(dekCiphertext), dekDecipher.final()]);
// 2. 解密数据
const data = Buffer.from(encryptedData, 'base64');
const iv = data.subarray(0, 12);
const authTag = data.subarray(12, 28);
const ciphertext = data.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', dek, iv);
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return plaintext.toString('utf8');
}
}
密钥派生函数(KDF)
密钥派生函数是将一个密钥(或密码)转换为另一个密钥的函数。KDF 在密码学中扮演重要角色:从用户密码派生加密密钥、从共享密钥派生会话密钥、密钥分层管理等。
HKDF:基于 HMAC 的密钥派生
HKDF(HMAC-based Key Derivation Function)由 RFC 5869 定义,是一种高效且安全的密钥派生函数。它分为两个阶段:提取(Extract)和扩展(Expand)。
HKDF 工作原理
输入密钥材料 (IKM)
↓
[Extract] ← Salt(可选)
↓
伪随机密钥 (PRK)
↓
[Expand] ← Info(上下文信息)
↓
输出密钥材料 (OKM)
- Extract 阶段:将可能不均匀分布的输入密钥材料"压缩"为固定长度的伪随机密钥
- Expand 阶段:将伪随机密钥扩展为所需长度的输出密钥
使用场景
| 场景 | 是否需要 Extract | 说明 |
|---|---|---|
| ECDH 共享密钥派生 | 是 | 共享密钥分布不均匀 |
| 随机密钥派生子密钥 | 否 | 直接使用 Expand |
| 密码派生加密密钥 | 不推荐 | 应使用 Argon2id |
实现示例
// Node.js - HKDF 密钥派生
const crypto = require('crypto');
class HKDFKeyDeriver {
/**
* 使用 HKDF 从输入密钥材料派生密钥
* @param {Buffer} ikm - 输入密钥材料
* @param {Buffer} salt - 盐值(可选,但推荐使用)
* @param {string} info - 上下文信息
* @param {number} length - 派生密钥长度
* @returns {Buffer} - 派生密钥
*/
static derive(ikm, salt, info, length) {
// 使用 SHA-256 作为底层哈希函数
return crypto.hkdfSync('sha256', ikm, salt, info, length);
}
/**
* 从 ECDH 共享密钥派生 AES 密钥
*/
static deriveFromECDH(sharedSecret, context = 'aes-encryption') {
const salt = crypto.randomBytes(32); // 每次派生使用新盐值
const key = this.derive(sharedSecret, salt, context, 32);
return { key, salt };
}
}
// 使用示例:从 ECDH 共享密钥派生加密密钥
const sharedSecret = Buffer.from('...'); // ECDH 交换得到的共享密钥
const { key, salt } = HKDFKeyDeriver.deriveFromECDH(sharedSecret, 'file-encryption');
# Python - HKDF 密钥派生
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
import os
class HKDFKeyDeriver:
@staticmethod
def derive(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=length,
salt=salt,
info=info,
)
return hkdf.derive(ikm)
@staticmethod
def derive_from_ecdh(shared_secret: bytes, context: str = 'aes-encryption') -> tuple:
salt = os.urandom(32)
key = HKDFKeyDeriver.derive(
shared_secret,
salt,
context.encode('utf-8'),
32
)
return key, salt
// Java - HKDF 密钥派生
import javax.crypto.spec.SecretKeySpec;
import javax.crypto.spec.PBEKeySpec;
import java.security.spec.KeySpec;
import javax.crypto.SecretKeyFactory;
public class HKDFKeyDeriver {
// Java 标准库没有直接提供 HKDF,这里展示使用第三方库的方式
// 推荐使用 Google Tink 或 Bouncy Castle
public static byte[] derive(byte[] ikm, byte[] salt, byte[] info, int length) {
// 使用 Bouncy Castle 的实现
// 或者手动实现 Extract-Expand
return manualHKDF(ikm, salt, info, length);
}
private static byte[] manualHKDF(byte[] ikm, byte[] salt, byte[] info, int length) {
// Extract: PRK = HMAC-Hash(salt, IKM)
// 注意:如果 salt 为空,使用全零字符串
// Expand: OKM = HMAC-Hash(PRK, info | 0x01) | HMAC-Hash(PRK, OKM[-N] | info | 0x02) | ...
// 简化实现,实际应用中建议使用成熟库
// ...
return new byte[length]; // 简化
}
}
HKDF vs PBKDF2:关键区别
这两个 KDF 经常被混淆,但它们的用途截然不同:
| 特性 | HKDF | PBKDF2 |
|---|---|---|
| 设计目的 | 密钥派生 | 密码哈希 |
| 输入类型 | 高熵密钥材料 | 低熵密码 |
| 计算速度 | 快 | 慢(故意设计) |
| 是否可迭代 | 单轮 | 多轮(数万到数百万轮) |
| 内存消耗 | 低 | 低 |
| 推荐场景 | ECDH 派生密钥、TLS 密钥派生 | 密码存储(仅 FIPS 合规时) |
核心原则:
- 从密钥派生密钥 → 使用 HKDF
- 从密码派生密钥或存储密码 → 使用 Argon2id(首选)或 PBKDF2(FIPS 合规时)
子密钥派生最佳实践
在复杂系统中,通常需要从主密钥派生多个子密钥,每个子密钥用于不同用途:
// 子密钥派生示例
const crypto = require('crypto');
class KeyHierarchy {
constructor(masterKey) {
this.masterKey = masterKey;
}
// 派生特定用途的子密钥
deriveKey(purpose, keyId = 'default') {
const info = `${purpose}:${keyId}`;
return crypto.hkdfSync('sha256', this.masterKey, null, info, 32);
}
// 获取不同用途的密钥
getEncryptionKey() {
return this.deriveKey('encryption');
}
getMACKey() {
return this.deriveKey('mac');
}
getDatabaseKey(tableName) {
return this.deriveKey('database', tableName);
}
}
// 使用示例
const masterKey = crypto.randomBytes(32);
const keyHierarchy = new KeyHierarchy(masterKey);
const encryptionKey = keyHierarchy.getEncryptionKey();
const macKey = keyHierarchy.getMACKey();
const userTableKey = keyHierarchy.getDatabaseKey('users');
不同用途的密钥应该相互独立。即使一个子密钥泄露,也不应该影响其他子密钥的安全。HKDF 的 info 参数确保了派生密钥的独立性。
安全随机数生成
随机数在安全系统中扮演重要角色:生成密钥、IV、盐值、会话 ID、CSRF Token 等都需要加密安全的随机数。
为什么不能用普通随机数?
普通的伪随机数生成器(PRNG)是可预测的,攻击者可以通过观察足够多的输出,预测下一个随机数。
// ❌ 危险:使用不安全的随机数
Math.random(); // 可预测,不安全
Date.now(); // 可预测,不安全
// ✅ 安全:使用加密安全的随机数
crypto.randomBytes(16); // 安全
crypto.randomUUID(); // 安全
各语言安全随机数生成
| 语言 | 不安全函数 | 安全函数 |
|---|---|---|
| JavaScript | Math.random() | crypto.randomBytes(), crypto.randomUUID() |
| Python | random.random() | secrets.token_bytes(), secrets.token_hex() |
| Java | Math.random(), java.util.Random | java.security.SecureRandom |
| Go | math/rand | crypto/rand |
| PHP | rand(), mt_rand() | random_bytes(), openssl_random_pseudo_bytes() |
| C# | System.Random | RandomNumberGenerator |
# Python - 安全随机数生成
import secrets
# 生成安全的随机字节
random_bytes = secrets.token_bytes(16)
# 生成安全的随机十六进制字符串
random_hex = secrets.token_hex(16)
# 生成安全的 URL 安全字符串
random_url = secrets.token_urlsafe(16)
# 生成安全的整数
random_int = secrets.randbelow(1000000)
# 安全的比较(防止计时攻击)
secrets.compare_digest('expected', 'actual')
// Java - 安全随机数生成
import java.security.SecureRandom;
SecureRandom secureRandom = new SecureRandom();
// 生成随机字节
byte[] randomBytes = new byte[16];
secureRandom.nextBytes(randomBytes);
// 生成随机整数
int randomInt = secureRandom.nextInt();
// 生成随机长整数
long randomLong = secureRandom.nextLong();
UUID 的安全性
UUID/GUID 的随机性取决于版本:
- UUID v1:基于时间戳和 MAC 地址,不随机
- UUID v4:随机生成,安全性取决于实现
// Node.js - 使用安全的 UUID
const crypto = require('crypto');
// 安全:crypto.randomUUID() 使用加密安全的随机数
const safeUuid = crypto.randomUUID();
// 安全:uuid 库的 v4 也可以
const { v4: uuidv4 } = require('uuid');
const uuid = uuidv4();
传输层安全(TLS)
TLS(Transport Layer Security)保护数据在网络传输过程中的安全。TLS 1.3 是当前最新、最安全的版本。
TLS 配置最佳实践
Nginx 配置
# nginx.conf - TLS 1.3 最佳实践配置
# 只启用 TLS 1.2 和 1.3
ssl_protocols TLSv1.2 TLSv1.3;
# 安全的加密套件
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305';
# 优先使用服务器端加密套件
ssl_prefer_server_ciphers on;
# 启用 OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
# HSTS - 强制 HTTPS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# 其他安全头
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
# 证书配置
ssl_certificate /path/to/certificate.crt;
ssl_certificate_key /path/to/private.key;
# 会话缓存
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off; # 禁用会话票据,提高前向保密性
# DH 参数(使用 ECDHE 时可以省略)
# ssl_dhparam /path/to/dhparam.pem;
Apache 配置
# Apache TLS 配置
<VirtualHost *:443>
SSLEngine on
# TLS 协议
SSLProtocol -all +TLSv1.2 +TLSv1.3
# 加密套件
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
SSLHonorCipherOrder on
# 证书
SSLCertificateFile /path/to/certificate.crt
SSLCertificateKeyFile /path/to/private.key
# HSTS
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
</VirtualHost>
强制 HTTPS
// Spring Boot - 强制 HTTPS
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.requiresChannel(channel -> channel
.anyRequest().requiresSecure()
);
return http.build();
}
}
// Express - 强制 HTTPS
app.use((req, res, next) => {
if (!req.secure && req.get('x-forwarded-proto') !== 'https') {
return res.redirect(`https://${req.get('host')}${req.url}`);
}
next();
});
证书透明度(Certificate Transparency)
证书透明度(Certificate Transparency,简称 CT)是一个开源框架,用于监控和审计 TLS 证书的颁发。它可以防止证书颁发机构(CA)错误或恶意颁发证书。
为什么需要证书透明度?
在 CT 出现之前,CA 可以在证书所有者不知情的情况下为其颁发证书。攻击者可能通过以下方式获取伪造证书:
- CA 被入侵:攻击者入侵 CA 系统后可以任意颁发证书
- CA 操作失误:CA 未能正确验证申请者身份
- 恶意 CA:某些 CA 可能故意颁发欺诈证书
这些情况都可能导致攻击者获得合法域名证书,实施中间人攻击。
CT 的工作机制
┌─────────────┐ 1. 申请证书 ┌─────────────┐
│ 网站运营者 │ ──────────────────→ │ 证书颁发机构 │
└─────────────┘ └─────────────┘
│
2. 提交预证书
↓
┌─────────────┐
│ CT 日志 │
│ (公开可查) │
└─────────────┘
│
3. 返回 SCT (签名证书时间戳)
↓
┌─────────────┐ 4. 验证 SCT ┌─────────────┐
│ 浏览器客户端 │ ←────────────────── │ 网站服务器 │
└─────────────┘ 5. 展示证书 └─────────────┘
核心概念:
- CT 日志(CT Log):公开的、只能追加的证书记录,任何人都可以查询
- SCT(Signed Certificate Timestamp):CT 日志对预证书的签名时间戳,证明证书已被记录
- 监控(Monitoring):定期检查 CT 日志,发现可疑证书
浏览器要求
主流浏览器对 SCT 有严格要求。Chrome 要求所有证书必须包含有效的 SCT,否则会显示警告:
| 证书有效期 | SCT 要求 |
|---|---|
| ≤ 180 天 | 至少 1 个 SCT |
| > 180 天 | 至少 2 个 SCT |
如何确保 CT 合规
现代 CA 在颁发证书时会自动将证书提交到 CT 日志,SCT 通常嵌入在证书中或通过 OCSP 装订提供。
# Nginx 启用 OCSP Stapling(有助于 SCT 传递)
ssl_stapling on;
ssl_stapling_verify on;
ssl_stapling_responder http://ocsp.example-ca.com;
监控 CT 日志
作为网站运营者,应该监控 CT 日志以及时发现异常证书:
# 使用 crt.sh 监控证书
# 也可以使用专业服务如 Facebook CT Monitoring、Google CT Dashboard
import requests
def check_ct_logs(domain):
"""检查 CT 日志中是否有可疑证书"""
url = f"https://crt.sh/?q={domain}&output=json"
response = requests.get(url)
if response.status_code == 200:
certificates = response.json()
for cert in certificates:
print(f"发现证书: {cert['name']}")
print(f" 颁发者: {cert['issuer_name']}")
print(f" 颁发时间: {cert['entry_timestamp']}")
# 检查是否为已知的颁发者
# 检查是否有异常证书
CT 的实际价值
2015 年至 2025 年间,CT 帮助发现了多起证书颁发安全事件:
- 2015 年:Google 发现 MCS Holdings 为 google.com 颁发了未经授权的证书
- 2017 年:Google 发现 Symantec 颁发了大量有问题的证书
- 2024 年:发现 Fina CA 为 Cloudflare 的 IP 地址颁发了未授权证书
这些事件都通过 CT 日志被发现,并最终导致相关 CA 被浏览器移除信任。
数据脱敏
数据脱敏是在显示或日志中隐藏敏感信息的技术,是防止数据泄露的重要手段。
常用脱敏规则
// JavaScript - 数据脱敏工具类
class DataMasker {
// 手机号脱敏:138****5678
static maskPhone(phone) {
if (!phone || phone.length < 11) return phone;
return phone.substring(0, 3) + '****' + phone.substring(7);
}
// 身份证脱敏:310***********1234
static maskIdCard(idCard) {
if (!idCard || idCard.length < 18) return idCard;
return idCard.substring(0, 3) + '***********' + idCard.substring(14);
}
// 银行卡脱敏:6222 **** **** 1234
static maskBankCard(card) {
if (!card || card.length < 16) return card;
return card.substring(0, 4) + ' **** **** ' + card.substring(12);
}
// 邮箱脱敏:a***@example.com
static maskEmail(email) {
if (!email || !email.includes('@')) return email;
const [local, domain] = email.split('@');
if (local.length <= 2) {
return '*'.repeat(local.length) + '@' + domain;
}
return local[0] + '***' + local[local.length - 1] + '@' + domain;
}
// 姓名脱敏:张**
static maskName(name) {
if (!name) return name;
return name[0] + '*'.repeat(name.length - 1);
}
// 通用脱敏:保留前 n 位和后 m 位
static mask(data, keepFront = 2, keepBack = 2, maskChar = '*') {
if (!data) return data;
const len = data.length;
if (len <= keepFront + keepBack) {
return maskChar.repeat(len);
}
const front = data.substring(0, keepFront);
const back = data.substring(len - keepBack);
const middle = maskChar.repeat(len - keepFront - keepBack);
return front + middle + back;
}
}
// 使用示例
console.log(DataMasker.maskPhone('13812345678')); // 138****5678
console.log(DataMasker.maskIdCard('310101199001011234')); // 310***********1234
console.log(DataMasker.maskBankCard('6222021234567890')); // 6222 **** **** 7890
console.log(DataMasker.maskEmail('[email protected]')); // t***[email protected]
# Python - 数据脱敏工具类
import re
class DataMasker:
@staticmethod
def mask_phone(phone: str) -> str:
if not phone or len(phone) < 11:
return phone
return phone[:3] + '****' + phone[7:]
@staticmethod
def mask_id_card(id_card: str) -> str:
if not id_card or len(id_card) < 18:
return id_card
return id_card[:3] + '***********' + id_card[14:]
@staticmethod
def mask_bank_card(card: str) -> str:
if not card or len(card) < 16:
return card
return card[:4] + ' **** **** ' + card[12:]
@staticmethod
def mask_email(email: str) -> str:
if not email or '@' not in email:
return email
local, domain = email.split('@')
if len(local) <= 2:
return '*' * len(local) + '@' + domain
return local[0] + '***' + local[-1] + '@' + domain
@staticmethod
def mask(data: str, keep_front: int = 2, keep_back: int = 2, mask_char: str = '*') -> str:
if not data:
return data
length = len(data)
if length <= keep_front + keep_back:
return mask_char * length
front = data[:keep_front]
back = data[-keep_back:]
middle = mask_char * (length - keep_front - keep_back)
return front + middle + back
数据库层面的脱敏
-- MySQL - 使用视图实现数据脱敏
CREATE VIEW users_masked AS
SELECT
id,
username,
CONCAT(LEFT(phone, 3), '****', RIGHT(phone, 4)) AS phone,
CONCAT(LEFT(email, 1), '***', SUBSTRING(email, LOCATE('@', email))) AS email,
CONCAT(LEFT(id_card, 3), '***********', RIGHT(id_card, 4)) AS id_card,
role,
created_at
FROM users;
-- PostgreSQL - 使用函数实现动态脱敏
CREATE OR REPLACE FUNCTION mask_phone(phone TEXT)
RETURNS TEXT AS $$
BEGIN
RETURN LEFT(phone, 3) || '****' || RIGHT(phone, 4);
END;
$$ LANGUAGE plpgsql;
常见错误与陷阱
错误 1:使用弱加密算法
// ❌ 危险:使用已破解的算法
const cipher = crypto.createCipher('des', key); // DES 已被破解
// ❌ 危险:使用不安全的模式
const cipher = crypto.createCipheriv('aes-128-ecb', key, null); // ECB 模式不安全
// ✅ 正确:使用安全的算法和模式
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
错误 2:密钥硬编码
// ❌ 危险:密钥硬编码在代码中
const ENCRYPTION_KEY = 'my-secret-key-12345';
// ✅ 正确:从安全配置中获取
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY;
// 或从密钥管理服务获取
const ENCRYPTION_KEY = await kms.getKey('encryption-key');
错误 3:IV/Nonce 重用
// ❌ 危险:重用 IV
const iv = Buffer.from('0000000000000000'); // 固定 IV
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
// ✅ 正确:每次加密使用新的随机 IV
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
错误 4:不验证完整性
// ❌ 危险:使用不提供完整性验证的模式
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
// 解密时不验证数据是否被篡改
// ✅ 正确:使用认证加密模式
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
// 解密时会自动验证认证标签
错误 5:不安全的随机数
// ❌ 危险:使用不安全的随机数生成 IV
const iv = Buffer.from(Math.random().toString());
// ✅ 正确:使用加密安全的随机数
const iv = crypto.randomBytes(12);
安全检查清单
密码存储
- 使用 Argon2id、scrypt 或 bcrypt 存储密码
- 工作因子足够高(验证时间约 0.5 秒)
- 考虑使用胡椒提供额外保护
- 不存储明文密码
数据加密
- 使用 AES-256-GCM 进行对称加密
- 每次加密使用新的随机 IV
- 使用椭圆曲线加密进行密钥交换
- 敏感数据字段级加密
密钥管理
- 密钥不硬编码在代码中
- 使用密钥管理服务存储密钥
- 实施密钥轮换策略
- 密钥与加密数据分离存储
传输安全
- 强制使用 HTTPS
- 启用 TLS 1.2 或更高版本
- 配置 HSTS
- 使用安全的加密套件
其他
- 使用加密安全的随机数生成器
- 敏感数据展示前脱敏
- 日志中不记录敏感信息
- 定期审计加密实现