4. 使用AES-CBC进行对称加密并处理填充

使用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 加密,再解密并验证还原结果。

AES-CBC 加密与 PKCS#7 填充处理流程 明文 Plaintext "Hello AES-CBC!" 14 字节 PKCS#7 填充 padLen = 16 - (14 % 16) = 2 追加 0x02 0x02 填充后数据 16 字节(1 块) AES-128-CBC Key: 16B IV: 16B 密文 Ciphertext 16 字节(1 块) AES-128-CBC 解密 相同 Key + IV 去除 PKCS#7 填充 读取末字节 0x02,移除 2 字节 还原明文 "Hello AES-CBC!" 关键验证点 填充长度计算:padLen = blockSize - (dataLen % blockSize),当 dataLen % 16 == 0 时 padLen = 16 去除填充时校验:末字节值必须在 1~16 之间,且末尾 padLen 个字节值均等于 padLen IV 必须随机生成且与密文一同传输,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 加密不同明文。