整体架构概览
DES(Data Encryption Standard)是一种对称分组密码,将64位明文块通过16轮Feistel网络变换为64位密文块。 由于JavaScript原生位运算仅支持32位有符号整数,本实现采用位数组(bit array)方案, 每个元素为0或1,可精确控制64位甚至更多位的运算。
算法流程图
字节与位转换
为什么需要位数组?
JavaScript的Number类型使用IEEE 754双精度浮点数,位运算<<、>>、&等
仅对32位有符号整数有效。DES需要处理64位数据,因此必须将数据拆分为位数组(每个元素0或1),
通过数组操作模拟位运算。
bytesToBits:字节 → 位数组(MSB优先)
const bytesToBits = (bytes) => {
let bits = [];
for (let i = 0; i < bytes.length; i++) {
let b = bytes[i]; // 当前字节,0-255
// 从最高位(bit7)到最低位(bit0)依次提取
for (let j = 7; j >= 0; j--) {
bits.push((b >> j) & 1); // 右移后取最低位
}
}
return bits;
};
// 示例: [0x5A] → [0,1,0,1,1,0,1,0]
// 0x5A = 0b01011010,MSB优先输出
位提取原理(以0x5A为例)
| j | b >> j | 二进制 | & 1 | 结果 |
|---|---|---|---|---|
| 7 | 90>>7 | 00000000 | 0 | 0 (MSB) |
| 6 | 90>>6 | 00000001 | 1 | 1 |
| 5 | 90>>5 | 00000010 | 0 | 0 |
| 4 | 90>>4 | 00000101 | 1 | 1 |
| ... | ... | ... | ... | ... |
| 0 | 90>>0 | 01011010 | 0 | 0 (LSB) |
bitsToBytes:位数组 → 字节(逆操作)
const bitsToBytes = (bits) => {
let bytes = new Uint8Array(bits.length / 8);
for(let i=0; i<bytes.length; i++) {
let val = 0;
// 每8位合并为1字节,左移累加
for(let j=0; j<8; j++) val = (val << 1) | bits[i*8 + j];
bytes[i] = val;
}
return bytes;
};
位运算基础工具
permute(bits, table)
根据置换表重新排列位数组
const permute = (bits, table) =>
table.map(idx => bits[idx - 1]);
// DES表通常1-based,需减1转0-based
xor(a, b)
两个等长位数组按位异或
const xor = (a, b) =>
a.map((val, i) => val ^ b[i]);
// ^ 是JS的按位异或运算符
split(bits)
从中间拆分位数组(用于L/R、C/D)
const split = (bits) => [
bits.slice(0, bits.length / 2),
bits.slice(bits.length / 2)
];
concat(a, b)
合并两个位数组
const concat = (a, b) => [...a, ...b];
// 展开运算符合并数组
密钥生成 (Key Schedule)
从64位主密钥生成16个48位子密钥,流程为:PC-1置换(64→56位)→ 分裂为C/D → 16轮循环左移 → PC-2置换(56→48位)
function generateSubkeys(keyBits) {
let keys = [];
// 1. PC-1置换:去掉8个校验位,剩余56位并打乱顺序
let pc1 = permute(keyBits, PC1);
let [C, D] = split(pc1); // 各28位
for (let i = 0; i < 16; i++) {
let shift = SHIFTS[i]; // 本轮左移位数
// 2. 循环左移:将前shift位移到末尾
C = concat(C.slice(shift), C.slice(0, shift));
D = concat(D.slice(shift), D.slice(0, shift));
// 3. PC-2置换:从56位中选48位,生成子密钥
keys.push(permute(concat(C, D), PC2));
}
return keys;
}
SHIFTS数组规律
轮次: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
移位: 1 1 2 2 2 2 2 2 1 2 2 2 2 2 2 1
累计28位后回到初始状态(完整周期)
F函数与S盒映射(核心)
function feistelF(rightBits, subkey, recordDetails = false) {
// 1. E扩展:32位 → 48位(部分位重复)
let expanded = permute(rightBits, E);
// 2. 密钥加:与子密钥异或
let xored = xor(expanded, subkey);
let sboxOutput = [];
for (let i = 0; i < 8; i++) {
// 取6位输入
let block = xored.slice(i * 6, i * 6 + 6);
/* 💡 【核心】S盒坐标计算
* 首尾2位组成行号 (0-3) */
let row = (block[0] << 1) | block[5];
/* 中间4位组成列号 (0-15) */
let col = (block[1] << 3) | (block[2] << 2) | (block[3] << 1) | block[4];
// 查表得4位输出(非线性!)
let val = SBOX[i][row * 16 + col];
// 展开为4个独立位
for (let b = 3; b >= 0; b--)
sboxOutput.push((val >> b) & 1);
}
// 3. P置换:32位扩散打乱
return permute(sboxOutput, P);
}
S盒结构
- 8个S盒,每个4×16=64个值
- 输入6位,输出4位(压缩)
- 行号:首尾位 (b₀b₅)
- 列号:中间4位 (b₁b₂b₃b₄)
- 手工设计,抗差分/线性攻击
为什么需要位展开?
S盒输出是0-15的整数,但后续P置换需要位数组格式(按索引取位)。 必须将4位整数展开为4个独立元素,才能统一处理。
单块处理 (processBlock)
function processBlock(blockBits, subkeys, isDecrypt, recordHistory) {
// 初始置换
let bits = permute(blockBits, IP);
let [L, R] = split(bits); // L0, R0各32位
for (let i = 0; i < 16; i++) {
// 解密时子密钥倒序使用
let keyIdx = isDecrypt ? 15 - i : i;
let nextL = R; // Lᵢ₊₁ = Rᵢ
let fRes = feistelF(R, subkeys[keyIdx]);
let nextR = xor(L, fRes.result); // Rᵢ₊₁ = Lᵢ ⊕ F(Rᵢ, K)
L = nextL;
R = nextR;
}
/* ⚠️ 关键:最后必须 R 在前,L 在后!
* Feistel设计最后一轮不交换,需手动交换 */
let preOutput = concat(R, L);
return { outBits: permute(preOutput, FP), history: localHistory };
}
Feistel网络的可逆性
加密:使用K₁→K₁₆。解密:使用K₁₆→K₁(倒序)。同一套代码,只需改变子密钥顺序即可解密, 这是Feistel网络的精妙之处。
CBC模式与加解密
加密流程
encrypt: function(textBytes, keyBytes, mode, ivBytes) {
// 1. 密钥初始化
let keyBits = bytesToBits(keyBytes.slice(0, 8));
let subkeys = generateSubkeys(keyBits);
// 2. PKCS#7填充(填充值为缺少的字节数)
let padLen = 8 - (textBytes.length % 8);
if (padLen === 0) padLen = 8;
let padded = new Uint8Array(textBytes.length + padLen);
padded.set(textBytes);
padded.fill(padLen, textBytes.length); // 填充padLen个字节,值均为padLen
// 3. CBC初始化
let prevBlockBits = ivBytes ? bytesToBits(ivBytes.slice(0, 8)) : Array(64).fill(0);
// 4. 分块处理
for (let i = 0; i < padded.length; i += 8) {
let blockBits = bytesToBits(padded.slice(i, i + 8));
// CBC:明文块先与前序密文(或IV)异或
if (mode === 'CBC') {
blockBits = xor(blockBits, prevBlockBits);
}
let res = processBlock(blockBits, subkeys, false, isFirstBlock);
prevBlockBits = res.outBits; // 保存用于下一块
outBytes.set(bitsToBytes(res.outBits), i);
}
}
解密流程(关键修复)
decrypt: function(cipherBytes, keyBytes, mode, ivBytes) {
// ...密钥初始化相同...
/* ✅ 修复:保存的是前一块密文,不是解密结果! */
let prevCipherBlock = ivBytes ? bytesToBits(ivBytes.slice(0, 8)) : Array(64).fill(0);
for (let i = 0; i < cipherBytes.length; i += 8) {
let cipherBlockBits = bytesToBits(cipherBytes.slice(i, i + 8));
// 先DES解密(子密钥倒序)
let res = processBlock(cipherBlockBits, subkeys, true, false);
let decBits = res.outBits;
if (mode === 'CBC') {
/* ✅ 解密结果再与IV/前块密文异或得明文 */
decBits = xor(decBits, prevCipherBlock);
}
/* ✅ 关键:保存当前密文块(不是decBits!) */
prevCipherBlock = cipherBlockBits;
outBytes.set(bitsToBytes(decBits), i);
}
// PKCS#7填充验证与移除
let padLen = outBytes[outBytes.length - 1];
// 验证所有填充字节等于padLen...
return outBytes.slice(0, outBytes.length - padLen);
}
Bug修复记录
修复1:CBC解密IV处理
问题:解密时错误地保存了解密结果而非原始密文块,导致下一块解密时使用了错误的XOR值。
prevBlock = decBits;
// 保存了解密后的明文
prevCipherBlock = cipherBlockBits;
// 保存原始密文
修复2:PKCS#7填充验证
问题:早期实现仅截断填充,未验证填充合法性,存在Padding Oracle攻击风险。
if (padLen < 1 || padLen > 8) throw Error("无效填充");
for (let i = outBytes.length - padLen; i < outBytes.length; i++) {
if (outBytes[i] !== padLen) throw Error("填充验证失败");
}
修复3:processBlock最终交换
问题:16轮后变量L=R₁₅, R=R₁₆,但DES输出应为R₁₆||L₁₆,必须手动交换。
let preOutput = concat(R, L);