DES Algorithm Core Implementation

JavaScript 实现源码深度解析

00

整体架构概览

DES(Data Encryption Standard)是一种对称分组密码,将64位明文块通过16轮Feistel网络变换为64位密文块。 由于JavaScript原生位运算仅支持32位有符号整数,本实现采用位数组(bit array)方案, 每个元素为0或1,可精确控制64位甚至更多位的运算。

算法流程图

64位明文 IP初始置换 L₀(32位) || R₀(32位)
16轮Feistel网络
轮i: Lᵢ₊₁ = Rᵢ | Rᵢ₊₁ = Lᵢ ⊕ F(Rᵢ, Kᵢ)
R₁₆ || L₁₆(左右交换) FP最终置换 64位密文
01

字节与位转换

为什么需要位数组?

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为例)

jb >> j二进制& 1结果
790>>70000000000 (MSB)
690>>60000000111
590>>50000001000
490>>40000010111
...............
090>>00101101000 (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;
};
02

位运算基础工具

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];
// 展开运算符合并数组
03

密钥生成 (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盒映射(核心)

这是DES安全性的灵魂:唯一的非线性组件
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个独立元素,才能统一处理。

05

单块处理 (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网络的精妙之处。

06

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₁₆,必须手动交换。

// 必须是R在前,L在后
let preOutput = concat(R, L);