1. 项目概述与核心价值最近在整理一些老项目的代码发现很多同学对DESData Encryption Standard算法的理解还停留在“调用javax.crypto.Cipher”的层面一旦面试官问起“能不能手写一个DES”或者遇到一些需要深度定制加密流程的场景就有点抓瞎了。确实现在AES是主流但DES作为现代密码学的里程碑其精巧的Feistel网络结构和位操作逻辑是理解分组密码设计思想的绝佳范本。今天我就带大家从零开始用纯Java实现一个完整的DES算法不依赖任何第三方加密库甚至连BigInteger都尽量少用核心就是最基础的位运算和数组操作。这个过程不仅能帮你彻底吃透DES的16轮加密、密钥调度、S盒置换这些核心机制更能极大提升你对二进制数据、位掩码、循环移位这些底层操作的理解和掌控力这对优化性能、调试加密相关bug甚至自己设计一些简单的混淆算法都大有裨益。2. DES算法原理深度拆解2.1 DES算法的基本框架与Feistel结构DES是一种对称密钥分组密码算法它将64位的明文块加密成64位的密文块使用的密钥长度是56位外加8位奇偶校验位通常我们说的64位密钥包含了这8位。其核心设计是Feistel网络这是一种特别巧妙的结构它保证了加密和解密过程可以使用几乎相同的逻辑只是子密钥的使用顺序相反。Feistel结构将64位的输入块分成左右两半各32位我们称为L0和R0。然后进行多轮DES是16轮迭代。每一轮的操作可以概括为一个公式Li Ri-1Ri Li-1 XOR F(Ri-1, Ki)。这里的F就是轮函数Ki是第i轮的子密钥。你可以把它想象成在搅拌一杯分层的鸡尾酒每一轮都把右边那半和密钥混合后的结果再和左边那半搅拌异或一下然后左右交换位置。经过16轮这样的搅拌原始的两部分数据就充分混合了。解密时只需要把子密钥Ki的使用顺序倒过来即第一轮用K16第二轮用K15...套用完全相同的结构就能还原这是Feistel网络最优雅的地方。2.2 核心组件轮函数F与S盒的奥秘轮函数F是DES安全性的心脏它接收32位的右半部分R和48位的子密钥Ki输出一个32位的结果。这个过程分为四步扩展置换E-box将32位的R扩展为48位。这不是简单填充而是通过重复R中的某些位来实现的。具体规则是一个固定的扩展表比如原R的第32位在新生成的48位中会同时出现在第1位和第47位。这样做的目的一是为了与48位的子密钥匹配以便进行异或二是为了在后续的S盒置换中让输出位能更广泛地依赖于输入位增强扩散效果。与子密钥异或将扩展后的48位数据与48位的子密钥Ki进行按位异或XOR操作。S盒置换Substitution这是DES算法中唯一的非线性变换也是其保密性的关键。上一步得到的48位结果被分成8组每组6位分别送入8个不同的S盒S1到S8。每个S盒是一个4行16列的查找表它将6位输入映射为4位输出。映射规则是6位输入的头尾两位组成一个2位数决定行号0-3中间4位组成一个4位数决定列号0-15。根据行列坐标在S盒表中找到对应的4位值作为输出。8个S盒总共输出32位。S盒的设计非常精妙它提供了至关重要的混淆特性使得密文与密钥、明文之间的关系变得极其复杂。P盒置换Permutation将S盒输出的32位结果按照一个固定的P盒表进行位置重排置换。这一步提供了扩散使得S盒输出的每一位都能快速影响到下一轮多个位的状态。2.3 密钥调度算法从主密钥到16轮子密钥DES的有效密钥是56位。密钥调度算法负责将这56位密钥素材生成16个48位的子密钥K1到K16。过程如下初始密钥置换PC-1输入的64位密钥含8位校验位经过PC-1置换去掉校验位并重排得到56位的密钥。分割与循环左移将56位密钥分成左右两部分C0和D0各28位。在每一轮i根据轮数对Ci-1和Di-1进行循环左移左移的位数由轮数决定第1、2、9、16轮左移1位其他轮左移2位。得到Ci和Di。压缩置换PC-2将合并后的CiDi56位经过PC-2置换压缩并重排最终得到该轮所需的48位子密钥Ki。注意正是由于每轮循环左移的位数不同才导致了16个子密钥各不相同。解密时只需要将子密钥数组逆序使用即可因为密钥生成过程是确定的反向使用子密钥等价于在Feistel网络中反向进行轮迭代。3. 纯Java实现的核心设计与编码3.1 数据结构与常量定义我们不依赖BigInteger的位操作而是用int数组或long类型来模拟位。考虑到DES处理的是64位数据用Java的long类型64位来存储中间结果是最直观高效的。但对于置换、S盒查找等需要按位操作的过程我们将其转换为boolean[]数组或通过掩码和移位来操作long会更清晰。首先定义所有必要的置换表和S盒。这些表都是公开的、固定的。例如初始置换IP表// 初始置换IP表 (64位 - 64位) private static final int[] IP { 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, // ... 省略后续56个元素 };表中的数字58表示输出块的第1位取自输入块的第58位。其他所有置换表如E扩展表、P盒表、PC-1、PC-2以及8个S盒都以类似方式定义。3.2 核心工具方法位操作与置换我们需要实现几个核心的位操作工具方法这是纯手工实现的基石。按位置取位getBit和设置位setBit/** * 从一个long值中取出指定位置的位1-based。 * param data 64位数据 * param pos 位置1到64 * return 该位的布尔值true为1false为0 */ private static boolean getBit(long data, int pos) { // 将目标位移到最低位然后与1进行与操作 return ((data (64 - pos)) 1L) 1L; } /** * 设置一个long值中指定位置的位1-based。 * param data 原始数据 * param pos 位置1到64 * param bit 要设置的值true为1false为0 * return 设置后的新long值 */ private static long setBit(long data, int pos, boolean bit) { long mask 1L (64 - pos); // 创建一个只在目标位置为1的掩码 if (bit) { return data | mask; // 如果位为1则进行或操作 } else { return data ~mask; // 如果位为0则先取反掩码再与操作 } }这里的关键是理解无符号右移和基于1的索引。DES标准文档中的位序是从左到右为1到64而Java的long的二进制表示最左边是最高位第63位索引。我们的getBit方法通过(64 - pos)的移位来对齐这个逻辑。通用置换方法permute/** * 根据指定的置换表对输入数据进行置换。 * param input 输入数据用long表示 * param table 置换表 * param inputLen 输入数据的有效位数如64、56、48、32 * return 置换后的数据long高位可能为0 */ private static long permute(long input, int[] table, int inputLen) { long output 0L; for (int i 0; i table.length; i) { int srcPos table[i]; // 表中指定的源位置1-based // 只从输入的有效位中取位 if (srcPos inputLen) { boolean bit getBit(input, srcPos); output setBit(output, i 1, bit); // 目标位置是i11-based } // 如果srcPos inputLen在DES的一些表中如PC-2可能出现表示忽略某些位 } return output; }这个方法非常通用IP、IP-1、E、P、PC-1、PC-2置换都可以用它来完成只需传入对应的表和输入长度。3.3 密钥调度算法的实现密钥调度是状态化的我们需要为每个DES实例生成并保存16个子密钥。public class DES { private long[] subKeys new long[16]; // 存储16轮子密钥每个密钥48位有效存储在long的低48位 /** * 根据64位密钥含校验位生成16轮子密钥。 * param key 64位主密钥 */ public void generateSubKeys(long key) { // 1. 初始置换PC-1 (64位 - 56位) long permutedKey permute(key, PC1_TABLE, 64); // 将56位分成左右两部分C0和D0 (各28位) long c (permutedKey 28) 0x0FFFFFFFL; // 右移28位取高28位 long d permutedKey 0x0FFFFFFFL; // 取低28位 // 2. 生成16轮子密钥 for (int i 0; i 16; i) { // 2.1 循环左移根据轮数决定左移位数 int shift KEY_SHIFTS[i]; // KEY_SHIFTS数组定义了每轮左移位数 c circularLeftShift28(c, shift); d circularLeftShift28(d, shift); // 2.2 合并C和D (56位) long combined (c 28) | d; // 2.3 压缩置换PC-2 (56位 - 48位)生成子密钥Ki subKeys[i] permute(combined, PC2_TABLE, 56); } } /** * 对28位数据进行循环左移。 */ private static long circularLeftShift28(long data, int shift) { // 确保数据只有低28位有效 data 0x0FFFFFFFL; // 循环左移shift位 return ((data shift) | (data (28 - shift))) 0x0FFFFFFFL; } }这里KEY_SHIFTS数组是{1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1}。注意circularLeftShift28方法中的掩码操作 0x0FFFFFFFL至关重要它保证了运算结果始终被限制在28位内防止左移后高位溢出干扰结果。3.4 轮函数F的实现这是DES算法中最核心的单轮变换。/** * DES轮函数F。 * param r 32位的右半部分数据 * param subKey 48位的本轮子密钥 * return 32位的输出 */ private static long fFunction(long r, long subKey) { // 1. 扩展置换E (32位 - 48位) long expanded permute(r, E_TABLE, 32); // 2. 与子密钥异或 long xored expanded ^ subKey; // 注意subKey只有低48位有效expanded也是48位 // 3. S盒置换 (48位 - 32位) long substituted sBoxSubstitution(xored); // 4. P盒置换 (32位 - 32位) return permute(substituted, P_TABLE, 32); } /** * S盒置换将48位输入转换为32位输出。 */ private static long sBoxSubstitution(long data48) { long output32 0L; // 将48位数据分成8组每组6位 for (int i 0; i 8; i) { // 取出当前6位 (从最高位开始取) // 先将数据左移使得当前组位于最高6位然后右移58位将其移到最低6位并掩码。 int shift 42 - i * 6; // 第一组(i0)需要左移42位第二组左移36位... long sixBits (data48 shift) 58; // 先左移再无符号右移得到低6位 // 计算行号和列号 int row (int)(((sixBits 4) 0x02) | (sixBits 0x01)); // 取第1位和第6位组合 int col (int)((sixBits 1) 0x0F); // 取中间4位 // 查S盒表 int sBoxValue S_BOXES[i][row][col]; // S_BOXES是一个三维数组 // 将4位输出拼接到最终结果上 output32 4; // 为新的4位输出腾出位置 output32 | (sBoxValue 0x0FL); } return output32; }S盒置换的实现需要仔细处理位提取和拼接。上述方法通过移位和掩码操作清晰地完成了6位到4位的映射。三维数组S_BOXES[i][row][col]预先存储了8个S盒的所有值。3.5 完整的加密与解密流程整合以上所有组件实现加密主流程。/** * DES加密单块数据64位。 * param plaintextBlock 64位明文块 * param encryptMode true为加密false为解密 * return 64位密文块或解密后的明文块 */ public long processBlock(long block, boolean encryptMode) { // 1. 初始置换IP long permuted permute(block, IP_TABLE, 64); // 分割成左右两部分L0和R0 (各32位) long left (permuted 32) 0xFFFFFFFFL; long right permuted 0xFFFFFFFFL; // 2. 16轮Feistel迭代 for (int round 0; round 16; round) { long temp right; // 决定使用第几轮子密钥加密正向用解密反向用 long subKey encryptMode ? subKeys[round] : subKeys[15 - round]; // 计算轮函数F(R, K) long fResult fFunction(right, subKey); // R_{i1} L_i XOR F(R_i, K_i) right left ^ fResult; // 注意这里异或的结果是32位 // L_{i1} R_i left temp; } // 3. 最后一轮结束后交换左右Feistel网络最后一步并合并 long combined (right 32) | (left 0xFFFFFFFFL); // 4. 逆初始置换IP-1 return permute(combined, IP_INV_TABLE, 64); }解密时只需将encryptMode设为false方法内部会自动反向使用子密钥数组。这正是Feistel结构对称性的完美体现。3.6 工作模式与数据填充上述processBlock只处理一个64位块。实际中数据长度不一定是64位的倍数这就需要工作模式和填充。最简单的模式是ECB电子密码本模式但ECB模式相同明文块会产生相同密文块不安全。我们可以实现更安全的CBC密码分组链接模式。public class DES_CBC { private DES desEngine; private long iv; // 初始化向量 public byte[] encrypt(byte[] data) { // 1. 数据填充PKCS#5/PKCS#7 byte[] paddedData padData(data); int blockCount paddedData.length / 8; byte[] ciphertext new byte[paddedData.length]; long previousBlock iv; // 2. CBC模式加密 for (int i 0; i blockCount; i) { long plainBlock bytesToLong(paddedData, i * 8); // CBC核心明文块先与前一个密文块或IV异或 long blockToEncrypt plainBlock ^ previousBlock; long encryptedBlock desEngine.processBlock(blockToEncrypt, true); // 保存当前密文块作为下一轮的“前一个密文块” previousBlock encryptedBlock; longToBytes(encryptedBlock, ciphertext, i * 8); } return ciphertext; } public byte[] decrypt(byte[] ciphertext) { // 类似加密过程但顺序相反 // 解密时先解密再与前一个密文块异或得到明文 // ... } private byte[] padData(byte[] data) { // PKCS#7填充计算需要填充的字节数1到8 int padLen 8 - (data.length % 8); byte[] padded new byte[data.length padLen]; System.arraycopy(data, 0, padded, 0, data.length); // 每个填充字节的值等于填充长度 Arrays.fill(padded, data.length, padded.length, (byte) padLen); return padded; } }bytesToLong和longToBytes是辅助方法用于处理byte[]和long之间的转换需要注意字节序我们通常使用大端序。4. 常见问题、调试技巧与性能考量4.1 典型问题与排查清单在手工实现DES的过程中几乎一定会遇到以下问题。这里提供一个排查清单问题现象可能原因排查步骤与解决方案加密结果与标准库如javax.crypto或已知测试向量不一致。1. 置换表数据错误或索引理解错误。2. S盒查找的行列计算错误。3. 位序MSB/LSB处理错误。4. 子密钥生成错误左移位错误或PC-2置换错误。5. 工作模式或填充方式不一致。1.逐轮调试不要一次性跑完16轮。实现一个单轮测试用已知的中间值很多密码学教材或标准文档提供进行比对。重点检查第一轮输入IP置换后、第一轮F函数输出、第一轮结束后的左右两部分。解密无法还原原始明文。1. 解密时子密钥顺序错误。2. Feistel网络最后一轮结束后忘记交换左右部分。3. CBC模式中IV处理错误或加解密逻辑不对应。1.验证对称性用同一个密钥加密一个随机块然后立即解密看是否能还原。这是最基本的测试。2.检查子密钥数组打印出16轮子密钥与可靠实现对比。确保加密和解密使用的子密钥顺序正好相反。3.检查CBC逻辑加密时是明文 XOR 前密文然后加密解密时是先解密得到中间值然后中间值 XOR 前密文得到明文。这两个前密文是同一个东西。处理多字节数据如字符串时结果乱码。1. 字符编码问题如UTF-8到字节的转换。2.byte[]与long转换时的字节序问题。3. 填充与去填充逻辑错误。1.统一编码明确指定字符编码如Hello.getBytes(StandardCharsets.UTF_8)。2.统一字节序在bytesToLong中确保byte[0]对应long的最高位字节大端序。这是最常见的坑。3.验证填充加密前打印填充后的字节数组解密后验证并去除填充字节。性能极差。大量使用了boolean[]数组和逐位操作而不是基于long的掩码和移位。优化策略核心的置换、S盒操作应尽量使用预计算的掩码或查找表。例如可以预先计算好每个S盒的64个输入6位到4位输出的完整映射表一个int[64]数组这样S盒置换就变成一次数组查找而不是每次计算行列。4.2 调试心得与单元测试手工实现密码算法单元测试是救命稻草。一定要找一套权威的测试向量Test Vectors。NIST美国国家标准与技术研究院或一些经典密码学教材的附录里都有。我的测试方法是分模块测试Test public void testKeySchedule() { DES des new DES(); long key 0x133457799BBCDFF1L; // 一个经典测试密钥 des.generateSubKeys(key); // 断言第一轮和最后一轮的子密钥是否符合已知值 assertEquals(0x1B02EFFC7072L, des.getSubKey(0)); // K1 assertEquals(0xCB3D8B0E17F5L, des.getSubKey(15)); // K16 }完整流程测试Test public void testFullEncryptionDecryption() { DES des new DES(); long key 0x133457799BBCDFF1L; long plaintext 0x0123456789ABCDEFL; des.generateSubKeys(key); long ciphertext des.processBlock(plaintext, true); // 已知密文结果 assertEquals(0x85E813540F0AB405L, ciphertext); // 解密验证 long decrypted des.processBlock(ciphertext, false); assertEquals(plaintext, decrypted); }使用日志辅助在关键步骤如每轮Feistel迭代的输入输出添加条件日志对比自己的计算结果和标准中间值。这是定位错误最有效的方法。4.3 性能优化与生产环境考量我们这个纯手工实现的教育意义大于实用意义。在生产环境中有几点必须注意不要用于真实加密DES本身已被证明不安全密钥太短56位密钥可在一天内被暴力破解。这个实现旨在学习原理切勿用于保护真实敏感数据。性能瓶颈我们基于long和位操作的实现比纯boolean[]快很多但仍远低于JVM内置的本地实现或硬件加速AES-NI。对于DESJava标准库javax.crypto.Cipher的性能是碾压级的。侧信道攻击我们的实现没有考虑任何抵御侧信道攻击如计时攻击、功耗分析的措施。一个简单的if语句分支或基于数据值的数组查找时间差异都可能泄露密钥信息。安全的密码实现需要恒定时间算法。如果需要使用DES/3DES请务必使用经过严格审计的库如Java标准库并选择3DESTriple DES或直接使用AES。在代码中应该这样调用Cipher cipher Cipher.getInstance(DESede/CBC/PKCS5Padding); // 3DES // 或 Cipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding);5. 从DES到理解现代密码学通过这个纯手工实现DES的项目我们不仅仅是写了一个加密函数。更重要的是我们亲身体验了Feistel网络的设计美感理解了混淆与扩散这两个密码学核心原则是如何通过S盒和P盒实现的也深刻认识了密钥调度算法的重要性。当你再去学习AESSPN结构时你会自然地去对比AES没有Feistel结构它的轮函数包括字节代换SubBytes、行移位ShiftRows、列混合MixColumns和轮密钥加AddRoundKey扩散和混淆的职责被分配给了不同的步骤。这个项目的最大收获是建立了一种“比特级”的思维方式。在调试一个加密结果不对时你学会了自己写个小程序把两个long值按位打印出来对比在理解S盒时你不再把它看成一个黑盒而是去思考那6位输入是如何被拆分成行和列的。这种对数据最底层的操控能力是很多高级抽象框架无法给予的。下次当你再遇到一个复杂的位运算问题或者需要优化一段处理二进制协议的性能时这次手搓DES的经历很可能会给你带来意想不到的灵感。