4. 使用AES-CBC进行对称加密并处理填充
AES-CBC 的填充问题与手动 PKCS#7 处理
AES-CBC 是分组加密模式,分组长度固定为 128 位(16 字节)。它要求输入必须是 16 字节的整数倍,因此当明文长度不是 16 的倍数时,必须进行填充。PKCS#7 是最常用的填充方案:填充字节的值等于填充长度,且总是填充——即使明文恰好是 16 的倍数,也要追加一整块 16 个值为 0x10 的字节,否则解密后无法区分末尾数据是真实明文还是填充。
下面用 Node.js 的 crypto 模块演示完整流程:手动实现 PKCS#7 填充与去除,使用 AES-128-CBC 加密,再解密并验证还原结果。
完整实现代码
const crypto = require('crypto');
// ---------- PKCS#7 填充 ----------
function pkcs7Pad(data, blockSize = 16) {
const padLen = blockSize - (data.length % blockSize);
const pad = Buffer.alloc(padLen, padLen);
return Buffer.concat([data, pad]);
}
// ---------- PKCS#7 去填充 ----------
function pkcs7Unpad(data, blockSize = 16) {
if (data.length === 0 || data.length % blockSize !== 0) {
throw new Error('无效的填充数据长度');
}
const padLen = data[data.length - 1];
if (padLen < 1 || padLen > blockSize) {
throw new Error('无效的填充长度: ' + padLen);
}
for (let i = data.length - padLen; i < data.length; i++) {
if (data[i] !== padLen) {
throw new Error('填充字节不一致');
}
}
return data.subarray(0, data.length - padLen);
}
// ---------- AES-128-CBC 加密 ----------
function aesCbcEncrypt(plaintext, key, iv) {
const padded = pkcs7Pad(plaintext);
const cipher = crypto.createCipheriv('aes-128-cbc', key, iv);
return Buffer.concat([cipher.update(padded), cipher.final()]);
}
// ---------- AES-128-CBC 解密 ----------
function aesCbcDecrypt(ciphertext, key, iv) {
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
const padded = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return pkcs7Unpad(padded);
}
// ---------- 演示与验证 ----------
const key = crypto.randomBytes(16); // 128 位密钥
const iv = crypto.randomBytes(16); // 128 位随机 IV
const plaintext = Buffer.from('Hello AES-CBC!', 'utf8');
console.log('明文长度:', plaintext.length, '字节');
console.log('明文:', plaintext.toString());
const ciphertext = aesCbcEncrypt(plaintext, key, iv);
console.log('密文长度:', ciphertext.length, '字节');
console.log('密文(hex):', ciphertext.toString('hex'));
const decrypted = aesCbcDecrypt(ciphertext, key, iv);
console.log('解密结果:', decrypted.toString());
// 验证
const ok = decrypted.equals(plaintext);
console.log('解密还原验证:', ok ? '通过 ✓' : '失败 ✗');
// 边界测试:明文恰好 16 字节
const exact16 = Buffer.from('0123456789ABCDEF', 'utf8');
const ct2 = aesCbcEncrypt(exact16, key, iv);
console.log('\n16 字节明文加密后长度:', ct2.length, '(应为 32,多出一整块填充)');
const pt2 = aesCbcDecrypt(ct2, key, iv);
console.log('边界还原验证:', pt2.equals(exact16) ? '通过 ✓' : '失败 ✗');
运行结果与判读
执行上述代码,预期输出如下(密文为随机值,每次运行不同):
明文长度: 14 字节
明文: Hello AES-CBC!
密文长度: 16 字节
密文(hex): 3a7f...(16 字节随机值)
解密结果: Hello AES-CBC!
解密还原验证: 通过 ✓
16 字节明文加密后长度: 32 (应为 32,多出一整块填充)
边界还原验证: 通过 ✓
判读要点:
- 14 字节明文加密后密文为 16 字节,说明填充了 2 字节(0x02 0x02)。
- 16 字节明文加密后密文为 32 字节,说明即使刚好对齐也追加了完整一块 16 字节的 0x10 填充。
- 解密后
decrypted.equals(plaintext)为true,证明填充与去填充逻辑正确。
安全提醒:CBC 模式只提供机密性,不提供完整性。攻击者可以翻转密文块中的比特,导致解密后对应明文块出现可预测的变化(比特翻转攻击)。在生产环境中,应优先使用 AES-GCM 等带认证的加密模式,或在 CBC 密文上额外附加 HMAC 进行完整性校验(Encrypt-then-MAC)。
实践建议:IV 必须使用密码学安全的随机数生成器产生,且每次加密都应重新生成。IV 不需要保密,但必须与密文一起存储或传输,解密时使用相同的 IV。切勿硬编码 IV 或重复使用同一 IV 加密不同明文。