1. 项目概述为什么今天还要手搓DES如果你是一名Java开发者无论是准备面试还是处理一些遗留系统的兼容性需求或者单纯想深入理解对称加密的“古典”魅力DESData Encryption Standard算法都是一个绕不开的话题。尽管在今天的标准看来DES的56位密钥长度已不再安全早已被AESAdvanced Encryption Standard所取代但它的设计思想、Feistel网络结构以及对分组加密的经典实现依然是密码学入门的绝佳教材。更重要的是很多老系统、老协议、甚至是一些硬件设备中DES或其变体如3DES依然在服役。理解它的Java实现不仅能帮你搞定“请手写一段DES加密”这类经典的面试八股文更能让你在面对“为何此处用3DES”、“这个加密强度够吗”等实际问题时心里有底知其然更知其所以然。简单来说这个项目就是不依赖Javax.crypto等现成库从零开始用纯Java代码实现DES算法的核心加密和解密流程。我们会深入其心脏——Feistel网络一步步拆解IP置换、子密钥生成、16轮迭代、最终置换等所有环节。这不仅仅是“写代码”更是一次对经典密码学设计的深度游。我会分享在实现过程中遇到的坑比如字节操作、位运算的细节以及如何验证自己实现的正确性。无论你是想夯实基础的新手还是希望深入原理的进阶者这篇长文都能给你带来实实在在的收获。2. DES算法核心原理与设计思路拆解在动手写代码之前我们必须先搞清楚DES到底在干什么。把它想象成一个结构精密的机械密码箱我们的任务就是按照图纸用Java语言把这个密码箱的每一个齿轮和杠杆造出来。2.1 算法总体框架Feistel网络的魔力DES是一种分组密码一次处理64位8字节的明文数据块输出64位的密文。它的核心结构是Feistel网络。这种结构有一个天才的特性加密和解密过程可以使用完全相同的算法仅仅在子密钥的使用顺序上相反。这极大地简化了硬件和软件的实现。Feistel网络将64位的输入块分成左右两半各32位记为L0和R0。然后进行多轮DES是16轮迭代。每一轮的操作可以概括为L[i] R[i-1] R[i] L[i-1] XOR F(R[i-1], K[i])其中F是轮函数K[i]是第i轮的子密钥。XOR异或操作是整个对称加密的灵魂因为它具有可逆性A XOR B XOR B A。经过16轮迭代后得到L16和R16但最终输出前需要将R16和L16拼接起来注意这里不交换是R16L16再进行一次最终的置换IP^-1就得到了64位密文。解密时过程完全一样只是子密钥K[i]的使用顺序倒过来即第一轮用K16第二轮用K15...第十六轮用K1。这就是Feistel结构的精妙之处。2.2 核心组件拆解轮函数F与子密钥生成整个DES的实现难点和代码量主要集中在这两个部分。1. 轮函数 F(R, K)这是每一轮加密的核心。它接受32位的右半部分R和48位的子密钥K输出一个32位的结果。其内部步骤如下扩展置换E-box将32位的R扩展为48位。这不是简单填充而是通过重复某些位来实现的。目的是让数据与48位的子密钥进行异或同时让输出的一位能影响下一轮的两个S盒称为雪崩效应。与子密钥异或将扩展后的48位结果与48位的子密钥K进行按位异或。S盒替换S-box Substitution这是DES唯一非线性的部分也是安全性的关键。将异或后的48位数据分成8组每组6位送入8个不同的S盒Substitution-box。每个S盒是一个4行16列的查找表它根据6位输入首位和末位组成行号中间4位组成列号输出一个4位的结果。8个S盒总共输出32位。S盒的设计是保密的其目的是最大限度地混淆数据。P盒置换P-box Permutation将S盒输出的32位结果进行一次固定的位置置换打乱顺序。目的是让S盒的输出位在下一轮中能更广泛地扩散。2. 子密钥生成从用户输入的64位密钥实际有效56位每字节第8位是奇偶校验位生成16个48位的子密钥。密钥置换选择1PC-1去掉64位密钥中的8个校验位并对剩下的56位进行置换分成两个28位的半密钥C0和D0。循环左移每一轮C和D分别进行循环左移左移的位数根据轮数而定第1、2、9、16轮左移1位其余轮左移2位。得到Ci和Di。密钥置换选择2PC-2将Ci和Di合并成的56位数据通过PC-2置换压缩并置换输出48位的子密钥Ki。注意所有提到的置换IP, E, P, PC-1, PC-2以及S盒的内容都是由DES标准严格定义的常数表。我们的代码需要将这些表完整、正确地定义出来。这是最繁琐但必须精确无误的一步。3. Java实现核心细节与数据表示用Java实现DES最大的挑战在于如何优雅地处理位(bit)级别的操作。Java最小的操作单元是字节(byte)而DES充斥着48位、32位的数据块和置换操作。这里有几个关键决策点。3.1 数据表示用int还是long用数组还是位运算对于64位数据块最直观的是使用long类型。long正好是64位似乎天作之合。但实际操作起来很别扭因为DES的置换操作是按位移动位置用long进行位提取和设置非常麻烦需要大量的移位和掩码操作代码可读性极差。更实用的方法是使用int[]或byte[]。byte[8]数组最贴近数据本身8字节。但在进行位置换时需要跨字节操作计算复杂。int[2]数组一个int32位两个int正好64位。可以将高32位放在data[0]低32位放在data[1]。这样在Feistel网络中处理左半部分L和右半部分R时直接用一个int变量即可非常方便。进行64位置换时则需要处理两个int之间的位搬运。我的选择是混合策略。对于输入输出、密钥使用byte[8]数组因为这是与外部如文件、网络交互的自然格式。对于内部计算如Feistel迭代中的L, R轮函数中的中间结果全部转换为int32位或long64位进行位运算。我们会编写辅助方法在byte[]和long之间进行转换并将所有置换操作抽象成对long或int的位操作函数。这样做的好处是内部核心逻辑清晰使用位运算而对外接口友好使用字节数组。我们需要实现几个核心工具方法/** * 将8字节数组转换为64位long整数 */ private static long byteArrayToLong(byte[] data, int offset) { long result 0; for (int i 0; i 8; i) { result | ((long) (data[offset i] 0xFF)) ((7 - i) * 8); } return result; } /** * 将64位long整数转换为8字节数组 */ private static byte[] longToByteArray(long data) { byte[] result new byte[8]; for (int i 0; i 8; i) { result[i] (byte) ((data ((7 - i) * 8)) 0xFF); } return result; } /** * 核心根据置换表对输入数据进行位置换 * param data 输入数据long或int * param table 置换表表中的数字表示“将原数据的第几位放到新数据的当前位” * param inputWidth 输入数据的位数如6432 * return 置换后的数据 */ private static long permute(long data, int[] table, int inputWidth) { long result 0; for (int i 0; i table.length; i) { // 计算原数据位的位置从1开始计数 int originalPos table[i]; // 获取原数据位的值1或0 long bit (data (inputWidth - originalPos)) 1L; // 设置到结果对应的位上 result | (bit (table.length - 1 - i)); } return result; }permute方法是整个实现的基石IP、IP^-1、E、P、PC-1、PC-2等所有置换操作都将调用它。3.2 常量定义枯燥但必须精确这是最需要耐心和细心的一步。我们需要将DES标准中所有固定的置换表和S盒定义成静态数组。这里以初始置换IP和第一个S盒为例// 初始置换IP表 (64位 - 64位) private static final int[] IP_TABLE { 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, // ... 省略后续56行 15, 7, 62, 54, 46, 38, 30, 22 }; // 扩展置换E表 (32位 - 48位) private static final int[] E_TABLE { 32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, // ... 省略后续 28, 29, 30, 31, 32, 1 }; // S盒1 (6位输入 - 4位输出) private static final int[][] S1_BOX { {14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7}, {0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8}, {4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0}, {15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13} }; // ... 还需要定义S2到S8P盒逆初始置换IP^-1PC-1PC-2等实操心得在网上查找这些表时务必核对多个来源确保准确无误。一个数字错误就会导致整个加密解密失败。建议从权威的密码学教材或标准文档如FIPS PUB 46-3中获取。将这些表单独放在一个最终类DesConstants中是个好主意保持主类的整洁。4. 完整实现步骤与核心代码解析有了理论基础和数据结构设计我们现在可以分模块构建整个DES引擎了。我将按照实际编码的顺序讲解关键部分的实现。4.1 第一步子密钥生成器这是独立的模块它的任务是根据初始密钥生成16轮所需的48位子密钥。public class DesKeyGenerator { private long[] subKeys; // 存储16个48位子密钥用long的低48位存储 public DesKeyGenerator(byte[] key) { if (key.length ! 8) { throw new IllegalArgumentException(DES密钥必须为8字节(64位)); } subKeys new long[16]; generateSubKeys(key); } private void generateSubKeys(byte[] key) { // 1. 将8字节密钥转为64位long long keyBits byteArrayToLong(key, 0); // 2. 执行PC-1置换64位 - 56位 long permutedKey permute(keyBits, PC1_TABLE, 64); // 3. 分割成28位的C0和D0 int c (int) ((permutedKey 28) 0x0FFFFFFF); // 取高28位 int d (int) (permutedKey 0x0FFFFFFF); // 取低28位 // 4. 16轮迭代生成子密钥 for (int i 0; i 16; i) { // 4.1 循环左移根据轮数决定左移位数 int shift KEY_SHIFTS[i]; // KEY_SHIFTS {1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1} c leftRotate28(c, shift); d leftRotate28(d, shift); // 4.2 合并C和D56位 long combined ((long) c 28) | (d 0x0FFFFFFFL); // 4.3 执行PC-2置换56位 - 48位得到子密钥Ki subKeys[i] permute(combined, PC2_TABLE, 56); } } // 28位循环左移辅助方法 private int leftRotate28(int value, int shift) { return ((value shift) 0x0FFFFFFF) | (value (28 - shift)); } public long getSubKey(int round) { // round: 0~15 return subKeys[round]; } // 用于解密获取逆序的子密钥 public long getSubKeyForDecrypt(int round) { // round: 0~15 return subKeys[15 - round]; } }4.2 第二步轮函数F的实现这是算法的“心脏”代码需要精确实现扩展、异或、S盒替换、P盒置换四步。private static int fFunction(int r, long subKey) { // r: 32位 subKey: 48位 // 1. 扩展置换 E: 32位 - 48位 long expanded permute(r 0xFFFFFFFFL, E_TABLE, 32); // 2. 与子密钥异或 long xored expanded ^ subKey; // 结果仍是48位 // 3. S盒替换 (48位 - 32位) int sBoxOutput 0; for (int i 0; i 8; i) { // 取出6位 (从最高位开始取) int sixBits (int) ((xored (42 - i * 6)) 0x3F); // 计算行号和列号 int row ((sixBits 0x20) 4) | (sixBits 0x01); // 首位和末位 int col (sixBits 1) 0x0F; // 中间4位 // 查询对应的S盒 int sBoxValue 0; switch (i) { case 0: sBoxValue S1_BOX[row][col]; break; case 1: sBoxValue S2_BOX[row][col]; break; // ... 省略 case 2 到 case 6 case 7: sBoxValue S8_BOX[row][col]; break; } // 将4位输出拼接到结果中 sBoxOutput 4; sBoxOutput | (sBoxValue 0x0F); } // 4. P盒置换 return (int) permute(sBoxOutput 0xFFFFFFFFL, P_TABLE, 32); }4.3 第三步整合加密与解密流程现在我们可以将密钥生成、Feistel网络、初始和最终置换组合起来形成完整的加密方法。public class DesCipher { private DesKeyGenerator keyGenerator; public DesCipher(byte[] key) { this.keyGenerator new DesKeyGenerator(key); } public byte[] encrypt(byte[] data) { if (data.length ! 8) { throw new IllegalArgumentException(DES加密数据块必须为8字节); } // 1. 初始置换IP long block byteArrayToLong(data, 0); long permutedBlock permute(block, IP_TABLE, 64); // 2. 分割成L0和R0 int l (int) (permutedBlock 32); int r (int) (permutedBlock 0xFFFFFFFFL); // 3. 16轮Feistel迭代 for (int i 0; i 16; i) { long subKey keyGenerator.getSubKey(i); // 获取加密用的子密钥 int previousL l; l r; // R[i] L[i-1] XOR F(R[i-1], K[i]) r previousL ^ fFunction(r, subKey); } // 4. 最终合并 (注意最后一轮后不交换直接 R16L16) long combined ((long) r 32) | (l 0xFFFFFFFFL); // 5. 逆初始置换 IP^-1 long cipherBlock permute(combined, IP_INV_TABLE, 64); return longToByteArray(cipherBlock); } public byte[] decrypt(byte[] cipherData) { // 解密过程与加密几乎完全相同仅子密钥使用顺序相反 if (cipherData.length ! 8) { throw new IllegalArgumentException(DES解密数据块必须为8字节); } long block byteArrayToLong(cipherData, 0); long permutedBlock permute(block, IP_TABLE, 64); int l (int) (permutedBlock 32); int r (int) (permutedBlock 0xFFFFFFFFL); for (int i 0; i 16; i) { long subKey keyGenerator.getSubKeyForDecrypt(i); // 关键使用逆序子密钥 int previousL l; l r; r previousL ^ fFunction(r, subKey); } long combined ((long) r 32) | (l 0xFFFFFFFFL); long plainBlock permute(combined, IP_INV_TABLE, 64); return longToByteArray(plainBlock); } }4.4 第四步处理任意长度数据与工作模式上面的encrypt和decrypt方法只处理一个8字节的数据块。现实中我们需要加密任意长度的数据。这就需要引入工作模式Mode of Operation。最简单也是最经典但已不推荐用于多个块的模式是ECBElectronic Codebook模式。public byte[] encryptECB(byte[] data) { // 1. 数据填充确保长度是8字节的倍数使用PKCS#5/PKCS#7填充 int padding 8 - (data.length % 8); byte[] paddedData new byte[data.length padding]; System.arraycopy(data, 0, paddedData, 0, data.length); for (int i data.length; i paddedData.length; i) { paddedData[i] (byte) padding; } // 2. 分块加密 byte[] result new byte[paddedData.length]; for (int i 0; i paddedData.length; i 8) { byte[] block new byte[8]; System.arraycopy(paddedData, i, block, 0, 8); byte[] encryptedBlock encrypt(block); System.arraycopy(encryptedBlock, 0, result, i, 8); } return result; } public byte[] decryptECB(byte[] cipherData) { if (cipherData.length % 8 ! 0) { throw new IllegalArgumentException(密文长度必须是8字节的倍数); } byte[] decryptedData new byte[cipherData.length]; for (int i 0; i cipherData.length; i 8) { byte[] block new byte[8]; System.arraycopy(cipherData, i, block, 0, 8); byte[] decryptedBlock decrypt(block); System.arraycopy(decryptedBlock, 0, decryptedData, i, 8); } // 3. 去除填充 int padding decryptedData[decryptedData.length - 1] 0xFF; if (padding 1 || padding 8) { throw new RuntimeException(无效的填充数据); } // 简单验证填充 for (int i decryptedData.length - padding; i decryptedData.length; i) { if ((decryptedData[i] 0xFF) ! padding) { throw new RuntimeException(填充验证失败); } } byte[] finalData new byte[decryptedData.length - padding]; System.arraycopy(decryptedData, 0, finalData, 0, finalData.length); return finalData; }重要提示ECB模式对于重复的明文块会产生重复的密文块安全性很差不应用于实际加密系统。实际应用中应使用CBC密码分组链接、CFB等更安全的模式这些模式需要引入初始化向量IV实现逻辑会更复杂一些。这里实现ECB主要是为了演示和验证核心算法的正确性。5. 验证、调试与常见问题实录自己实现了一个加密算法最紧要的事情就是验证它是否正确。这里分享我踩过的坑和验证方法。5.1 如何验证实现的正确性1. 使用标准测试向量Test Vectors这是最权威的方法。NIST或其他标准机构会提供已知的明文、密钥和密文组合。你可以用你的程序加密明文看结果是否与标准密文完全一致通常以十六进制表示。例如找一个简单的测试向量密钥: 0x133457799BBCDFF1 明文: 0x0123456789ABCDEF 密文: 0x85E813540F0AB405你需要将十六进制字符串转换成byte[]然后调用你的encrypt方法再将输出的byte[]转回十六进制进行比对。2. 与JDK内置实现交叉验证Java标准库Javax.crypto.Cipher提供了DES实现。你可以用它作为“标准答案”来验证你的核心算法。import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; public boolean validateWithJCE(byte[] key, byte[] data) throws Exception { // 使用JCE加密 Cipher cipher Cipher.getInstance(DES/ECB/NoPadding); // 注意模式和无填充 SecretKeySpec keySpec new SecretKeySpec(key, DES); cipher.init(Cipher.ENCRYPT_MODE, keySpec); byte[] jceCipher cipher.doFinal(data); // 使用自己的实现加密 DesCipher myCipher new DesCipher(key); byte[] myCipher myCipher.encrypt(data); // 使用单块加密方法 return Arrays.equals(jceCipher, myCipher); }注意必须使用NoPadding模式并且数据长度必须是8字节这样才能进行单块对比排除填充和工作模式带来的干扰。3. 加密-解密循环验证这是最基本的自检。用任意数据和密钥加密然后立即解密看是否能还原出原始数据。byte[] original HelloDES.getBytes(StandardCharsets.UTF_8); // 正好8字节 byte[] encrypted desCipher.encrypt(original); byte[] decrypted desCipher.decrypt(encrypted); boolean success Arrays.equals(original, decrypted); // 应该为true5.2 常见问题与调试技巧在实现过程中我遇到了以下几个典型问题问题1加密解密结果不对但又不是完全乱码。可能原因置换表IP, E, P, PC-1, PC-2或S盒的数据有误。这是最常见的问题。一个数字写错就会导致位的位置不对。排查方法逐轮调试在Feistel循环中打印每一轮迭代后的L和R的十六进制值。与已知的中间结果有些教材或测试套件会提供进行比对定位在哪一轮开始出现偏差。单元测试每个组件单独测试permute函数、fFunction函数、子密钥生成函数。用一个小输入和已知的正确输出进行验证。重点检查S盒S盒是二维数组极易在录入时行、列索引搞反。确认你的行号计算((sixBits 0x20) 4) | (sixBits 0x01)是否正确。问题2加密后的数据只有前几个字节正确后面不对。可能原因在处理多字节数据块如ECB模式时数组拷贝或循环边界错误。或者在byteArrayToLong和longToByteArray方法中字节序Big-Endian vs Little-Endian处理有误。我上面的实现采用的是大端序Big-Endian即数组第一个字节是最高有效字节。排查方法加密一个全零的8字节数组单步跟踪查看每一步long类型的中间值是否符合预期。问题3解密时抛出“无效填充”异常。可能原因加密和解密使用的密钥不一致。密文在传输或处理过程中被篡改。更可能你的ECB模式实现中加密时填充了数据但解密后去除填充的逻辑有bug。比如没有正确读取最后一个字节的填充值或者验证填充字节的逻辑过于严格。排查方法先对不填充的8字节数据块进行加密解密测试如果通过问题就出在填充逻辑上。仔细检查decryptECB方法中计算padding值和验证填充字节的循环。问题4性能极差。可能原因在轮函数或置换中如果对byte[]进行逐位的操作会产生大量临时对象和位运算开销。优化建议就像我们之前的设计内部核心计算全部使用long和int进行位运算避免在循环中频繁创建byte[]。将所有置换表声明为static final。这些优化对于学习目的不是必须的但能让你理解性能关键点。终极调试技巧实现一个“软”DES即每一步操作都输出详细的日志如“第1轮R0x12345678子密钥K10x...扩展后0x...异或后0x...S盒输出0x...P置换后0x...”。将这些日志与标准实现如一个可靠的在线DES计算器的每一步输出进行逐行对比。这是最笨但最有效的方法。6. 从DES到3DES与AES思路延伸实现完基本的DES你对Feistel网络和分组加密就有了直观感受。但这只是起点。基于此我们可以很容易地理解它的衍生算法。1. 3DESTriple DES顾名思义就是对数据用DES处理三次。目的是为了增加密钥长度抵御暴力破解。有两种常见模式EEE3使用三个不同的密钥K1, K2, K3。Cipher Encrypt(K3, Encrypt(K2, Encrypt(K1, Plaintext)))。EDE3加密-解密-加密。Cipher Encrypt(K3, Decrypt(K2, Encrypt(K1, Plaintext)))。当K1K2K3时就退化成了普通的DES提供了向后兼容性。 用我们刚写好的DesCipher类很容易就能组合出3DESpublic byte[] encrypt3DES_EDE3(byte[] data, byte[] key1, byte[] key2, byte[] key3) { DesCipher cipher1 new DesCipher(key1); DesCipher cipher2 new DesCipher(key2); DesCipher cipher3 new DesCipher(key3); byte[] step1 cipher1.encrypt(data); byte[] step2 cipher2.decrypt(step1); // 注意这里是解密 return cipher3.encrypt(step2); }2. AESAdvanced Encryption Standard这是DES的取代者。理解DES后再看AES你会发现它们有根本不同结构AES不是Feistel网络而是SPN结构Substitution-Permutation Network。每一轮都处理整个数据块。状态AES将数据块组织成4x4的字节矩阵State。轮函数包含四个步骤字节替换SubBytes类似S盒但更统一、行移位ShiftRows、列混合MixColumns提供强扩散、轮密钥加AddRoundKey即异或。密钥扩展比DES的子密钥生成更复杂。 虽然AES更复杂但核心思想——通过多轮的替换和置换来达到混淆和扩散——是一脉相承的。如果你能啃下DES那么理解AES的字节替换、行移位和列混合操作就不会有本质上的困难。手动实现AES的代码量大约是DES的2-3倍主要是列混合运算涉及有限域GF(2^8)上的乘法需要预先计算好查找表如对数表、反对数表来优化性能。这可以作为你下一个挑战项目。最后我必须强调本文实现的DES以及任何自行实现的密码学算法都只适用于学习和研究目的。在实际的生产环境中必须使用经过严格审计、广泛测试的成熟密码学库如Java的Javax.crypto包、Bouncy Castle等。自行实现的算法在时间攻击、侧信道攻击等方面几乎必然存在漏洞。但通过这个从零实现的过程你获得的对对称加密算法本质的理解是任何现成库的API文档都无法给予的。下次当你配置SSL/TLS看到“加密套件”里那些DES-CBC3-SHA之类的名词时你就能会心一笑知道它背后究竟在发生什么了。