如果你刷过不少加密算法相关的帖子八成见过类似的评论“反正都是调包谁还看原理”工程里我确实更愿意直接调现成库但这次要聊的是一个完全相反的体验把DES算法在Java里从零纯手工实现一遍不引入任何依赖所有置换表、S盒、Feistel结构、16轮子密钥调度全部自己写。标题里那句“无需导包简单易懂还很全”不是夸张我的实现里连java.util.Arrays都没有 import核心逻辑就是一堆数组、循环和位运算。DES 的全称是 Data Encryption Standard数据加密标准属于对称分组密码。它一次处理一个64比特的数据块密钥名义上也是64比特实际参与运算的只有56比特剩下8比特是奇偶校验位。整个算法固定跑16轮核心是一个叫作 Feistel 的结构。虽然现在生产环境早就是AES的天下但DES在密码学教学、面试手写算法、内部演示系统里依然是常青树因为它的模块足够清晰又不像AES那样把字节代换和列混合搅在一起特别适合拿来“练手”。这篇文章适合三类人正在学密码学、不想只背结论的学生需要在某个内部演示或作业里交付一份完整DES实现的开发者以及想练一练位运算和底层功底的Java工程师。我会把设计思路、完整代码、结果验证和我在实际操作里踩过的坑都摊开讲你按着顺序把代码块拼起来就能跑。1. 整体拆解DES 的设计骨架1.1 Feistel 结构一轮到底做了什么DES 的核心是 Feistel 结构。这个结构最大的好处是加密和解密使用完全相同的轮函数只是子密钥使用顺序相反也就是说你不需要为解密单独设计一套算法。先看一轮加密的流程。64比特的输入经过初始置换 IP 后分成左右两半各32比特记为 L 和 R。之后重复16轮每轮干的事就两行newL R newR L XOR F(R, K)这里 F 是轮函数K 是这一轮的子密钥。一轮结束后左右交换位置进入下一轮。你可以把它想象成两个人互相换手上的东西右手的东西原封不动交给左手左手的东西则跟“右手经过某种计算后”的结果做异或再交给右手。异或这个操作是可逆的而且只需要再异或一次同样的值就能恢复所以十六轮做完只要把子密钥倒着用一遍就能逐步还原出原来的 L 和 R。需要注意一个细节16轮做完之后输出的前32位是 R16后32位是 L16而不是 L16||R16。这看起来像“少交换了一次”但实际上是为了让加解密流程保持一致。如果你写代码时把左右顺序搞反加密结果就会完全错掉。整个流程用文字描述就是明文块 - IP置换 - 左右拆开 - Feistel循环16轮 - 最后拼接成 R16||L16 - FP逆置换 - 密文块。其中的 IP 和 FP 互为逆置换作用是把数据重新“洗牌”让算法在硬件实现上更顺也把相邻比特分散开。1.2 56位密钥如何变成16个48位子密钥DES 的密钥名义上是64比特也就是8个字节但每个字节的最后一位是奇偶校验位不参与运算。所以真正有用的只有56比特而这56比特还要进一步扩展成16个48位的子密钥。密钥调度的流程是把64位密钥做 PC-1 置换去掉8位校验位得到56位。把56位分成两半C 和 D各28位。每一轮分别把 C 和 D 循环左移移动位数由固定表决定。把左移后的 C、D 拼接成56位再做 PC-2 压缩置换得到48位的子密钥。循环左移的位数表是1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1。也就是说前两轮各移1位第3到第8轮各移2位第9轮移1位后面七轮各移2位。这个表没有特别神秘的来源就是标准里写死的你把表抄对就行。PC-1 和 PC-2 都是置换不是压缩后再加入什么新位只是从固定位置抽取比特。PC-1 从64位里挑56位PC-2 从56位里挑48位。两次置换下来每一轮子密钥使用的实际密钥比特都不太一样这保证了16轮之间不会出现重复模式。1.3 轮函数F扩展、异或、S盒、置换轮函数 F 是DES里最容易抄错的部分它由四个动作拼成E扩展置换把32位的 R 扩展成48位。方法是把32位分成8组每组4位每组复制首尾两个相邻位变成6位。所以8组就是48位。与子密钥异或把扩展后的48位跟这一轮的48位子密钥逐位异或。S盒替换48位分成8组每组6位分别送进8个不同的S盒。每个S盒输出4位总共得到32位。P置换把32位结果按 P 表重新排列。S盒是整个DES唯一的非线性部件。你可以把它理解成一张查表6位输入首尾两位拼成行号中间四位拼成列号查表得到0到15之间的一个数转成4位二进制输出。如果DES没有S盒整个算法就会退化成一堆线性变换的组合破解会变得极其容易。S盒表的具体数值是设计者精心挑选的用来抵抗差分分析和线性分析咱们做实现的时候不需要理解每个数的来历但抄表绝对不能抄错。2. Java 实现与核心代码解析2.1 类骨架与全部常数表实现DES不需要任何第三方库我把所有逻辑封在一个DES类里。类的结构分成四块常数表、位工具方法、核心算法、对外接口。下面这段是常数区我建议你直接复制到类的最开头。public class DES { // 初始置换 IP private static final int[] IP { 58,50,42,34,26,18,10,2, 60,52,44,36,28,20,12,4, 62,54,46,38,30,22,14,6, 64,56,48,40,32,24,16,8, 57,49,41,33,25,17,9,1, 59,51,43,35,27,19,11,3, 61,53,45,37,29,21,13,5, 63,55,47,39,31,23,15,7 }; // 逆置换 FPIP-1 private static final int[] FP { 40,8,48,16,56,24,64,32, 39,7,47,15,55,23,63,31, 38,6,46,14,54,22,62,30, 37,5,45,13,53,21,61,29, 36,4,44,12,52,20,60,28, 35,3,43,11,51,19,59,27, 34,2,42,10,50,18,58,26, 33,1,41,9,49,17,57,25 }; // E 扩展置换32 - 48 private static final int[] E { 32,1,2,3,4,5, 4,5,6,7,8,9, 8,9,10,11,12,13, 12,13,14,15,16,17, 16,17,18,19,20,21, 20,21,22,23,24,25, 24,25,26,27,28,29, 28,29,30,31,32,1 }; // P 置换32 - 32 private static final int[] P { 16,7,20,21,29,12,28,17, 1,15,23,26,5,18,31,10, 2,8,24,14,32,27,3,9, 19,13,30,6,22,11,4,25 }; // PC-164位密钥 - 56位 private static final int[] PC1 { 57,49,41,33,25,17,9, 1,58,50,42,34,26,18, 10,2,59,51,43,35,27, 19,11,3,60,52,44,36, 63,55,47,39,31,23,15, 7,62,54,46,38,30,22, 14,6,61,53,45,37,29, 21,13,5,28,20,12,4 }; // PC-256位 - 48位子密钥 private static final int[] PC2 { 14,17,11,24,1,5, 3,28,15,6,21,10, 23,19,12,4,26,8, 16,7,27,20,13,2, 41,52,31,37,47,55, 30,40,51,45,33,48, 44,49,39,56,34,53, 46,42,50,36,29,32 }; // 每轮循环左移位数 private static final int[] SHIFT { 1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1 }; // 8个S盒每个S盒4行16列 private static final int[][] SBOX { { // S1 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 15,1,8,14,6,11,3,4,9,7,2,13,12,0,5,10, 3,13,4,7,15,2,8,14,12,0,1,10,6,9,11,5, 0,14,7,11,10,4,13,1,5,8,12,6,9,3,2,15, 13,8,10,1,3,15,4,2,11,6,7,12,0,5,14,9 }, { // S3 10,0,9,14,6,3,15,5,1,13,12,7,11,4,2,8, 13,7,0,9,3,4,6,10,2,8,5,14,12,11,15,1, 13,6,4,9,8,15,3,0,11,1,2,12,5,10,14,7, 1,10,13,0,6,9,8,7,4,15,14,3,11,5,2,12 }, { // S4 7,13,14,3,0,6,9,10,1,2,8,5,11,12,4,15, 13,8,11,5,6,15,0,3,4,7,2,12,1,10,14,9, 10,6,9,0,12,11,7,13,15,1,3,14,5,2,8,4, 3,15,0,6,10,1,13,8,9,4,5,11,12,7,2,14 }, { // S5 2,12,4,1,7,10,11,6,8,5,3,15,13,0,14,9, 14,11,2,12,4,7,13,1,5,0,15,10,3,9,8,6, 4,2,1,11,10,13,7,8,15,9,12,5,6,3,0,14, 11,8,12,7,1,14,2,13,6,15,0,9,10,4,5,3 }, { // S6 12,1,10,15,9,2,6,8,0,13,3,4,14,7,5,11, 10,15,4,2,7,12,9,5,6,1,13,14,0,11,3,8, 9,14,15,5,2,8,12,3,7,0,4,10,1,13,11,6, 4,3,2,12,9,5,15,10,11,14,1,7,6,0,8,13 }, { // S7 4,11,2,14,15,0,8,13,3,12,9,7,5,10,6,1, 13,0,11,7,4,9,1,10,14,3,5,12,2,15,8,6, 1,4,11,13,12,3,7,14,10,15,6,8,0,5,9,2, 6,11,13,8,1,4,10,7,9,5,0,15,14,2,3,12 }, { // S8 13,2,8,4,6,15,11,1,10,9,3,14,5,0,12,7, 1,15,13,8,10,3,7,4,12,5,6,11,0,14,9,2, 7,11,4,1,9,12,14,2,0,6,10,13,15,3,5,8, 2,1,14,7,4,10,8,13,15,12,9,0,3,5,6,11 } }; // 辅助方法如下... }看到这些大表先别慌。以我手写过多遍的经验最容易被搞坏的恰恰就是这里。常见错误有三种表抄漏一行、表项写成0到63而不是1到64、S盒里某一行的16个数顺序抄错。置换表本身并不难理解表里每个数字都是从原始比特位置取第几个比特放到结果里第几个位置。因为标准里用1到64编号代码里取下标时一定要减1。2.2 位工具方法用 boolean[] 代替位运算实现DES最直观的方式是用boolean[]来存放比特位。每个下标存一个布尔值长度为64就代表64比特。虽然比位运算繁琐一点但排错极其方便打印中间结果特别清楚非常适合教学和展示。让我先介绍几个基础方法。private static boolean[] slice(boolean[] src, int from, int to) { boolean[] dst new boolean[to - from]; for (int i from; i to; i) { dst[i - from] src[i]; } return dst; } private static byte[] sliceBytes(byte[] src, int from, int to) { byte[] dst new byte[to - from]; for (int i from; i to; i) { dst[i - from] src[i]; } return dst; } private static boolean[] xor(boolean[] a, boolean[] b) { boolean[] dst new boolean[a.length]; for (int i 0; i a.length; i) { dst[i] a[i] ^ b[i]; } return dst; } private static boolean[] leftShift(boolean[] src, int n) { boolean[] dst new boolean[src.length]; for (int i 0; i src.length; i) { dst[i] src[(i n) % src.length]; } return dst; } private static boolean[] permute(boolean[] input, int[] table) { boolean[] output new boolean[table.length]; for (int i 0; i table.length; i) { output[i] input[table[i] - 1]; } return output; } private static boolean[] toBits(byte[] block) { boolean[] bits new boolean[64]; for (int i 0; i 8; i) { for (int j 0; j 8; j) { bits[i * 8 j] ((block[i] (7 - j)) 1) 1; } } return bits; } private static byte[] toBytes(boolean[] bits) { byte[] block new byte[8]; for (int i 0; i 8; i) { int v 0; for (int j 0; j 8; j) { v (v 1) | (bits[i * 8 j] ? 1 : 0); } block[i] (byte) v; } return block; } public static String toHex(byte[] data) { char[] hex 0123456789ABCDEF.toCharArray(); char[] out new char[data.length * 2]; for (int i 0; i data.length; i) { out[i * 2] hex[(data[i] 4) 0x0F]; out[i * 2 1] hex[data[i] 0x0F]; } return new String(out); } public static byte[] fromHex(String s) { byte[] data new byte[s.length() / 2]; for (int i 0; i data.length; i) { int high Character.digit(s.charAt(i * 2), 16); int low Character.digit(s.charAt(i * 2 1), 16); data[i] (byte) ((high 4) | low); } return data; }permute是DES的灵魂方法IP、FP、E、P、PC-1、PC-2 六张表全部由它统一处理。实现思路就是目标数组的第 i 位取源数组中table[i] - 1位。因为标准表的编号从1开始而Java数组下标从0开始所以这里必须减1。忘了减1轻则越界重则整个输出错位。toBits把8字节变成64个布尔值遵循“大端风格”每个字节的最高位对应bits[i*8]最低位对应bits[i*87]。如果你习惯小端后面所有置换表都要跟着改那会灾难性。所以在整个项目里统一使用“第一个比特是最高位”的约定。2.3 子密钥调度16个48位子密钥怎么生成有了工具方法生成子密钥就顺理成章了。注意我这里完全没用到System.arraycopy就是为了保持“零 import”的纯粹性。private static boolean[][] generateSubKeys(byte[] key) { boolean[] keyBits toBits(key); boolean[] key56 permute(keyBits, PC1); boolean[] c slice(key56, 0, 28); boolean[] d slice(key56, 28, 56); boolean[][] subKeys new boolean[16][48]; for (int i 0; i 16; i) { c leftShift(c, SHIFT[i]); d leftShift(d, SHIFT[i]); boolean[] cd new boolean[56]; for (int j 0; j 28; j) { cd[j] c[j]; cd[j 28] d[j]; } subKeys[i] permute(cd, PC2); } return subKeys; }这段代码最需要注意的是leftShift是循环左移不是普通左移。移出的高位比特要绕回到低位否则后面PC-2取位时会错位。我把 C 和 D 分别看作两个环每次移动 n 位就是把整段数据看成首尾相连的圆环整体转一格或多格。子密钥生成是整个流程里最容易“加密成功、解密失败”的地方。如果你在加密函数里用顺序子密钥解密函数里也用了顺序而不是逆序密文就会像被搅烂的纸团怎么都解不回来。后面我会专门讲这个坑。2.4 F函数S盒查表与置换F函数是DES的核心计算模块这块代码建议自己敲一遍private static boolean[] fFunction(boolean[] right32, boolean[] subKey48) { boolean[] expanded permute(right32, E); boolean[] xored xor(expanded, subKey48); boolean[] afterSBox new boolean[32]; for (int i 0; i 8; i) { boolean[] group slice(xored, i * 6, i * 6 6); int row (group[0] ? 1 : 0) * 2 (group[5] ? 1 : 0); int col (group[1] ? 1 : 0) * 8 (group[2] ? 1 : 0) * 4 (group[3] ? 1 : 0) * 2 (group[4] ? 1 : 0); int value SBOX[i][row * 16 col]; for (int j 0; j 4; j) { afterSBox[i * 4 j] ((value (3 - j)) 1) 1; } } return permute(afterSBox, P); }S盒查表有个经典坑行号是6位输入的首尾两位拼出来的中间4位才是列号。第一次写的时候十个人有八个会把行列搞反。我也翻过车输出全乱排查了半天才发现是 row 和 col 的计算写反了。另外注意value是一个0到15的整数转成4位布尔数组时我用(value (3 - j)) 1取了它的高到低位。如果你希望第1位对应最高位这个写法就是对的。这个细节和toBits的约定保持一致。2.5 单块加解密、PKCS5填充与对外接口现在把上面的模块拼成完整流程。先实现一个runBlock负责处理8字节的一个块加密和解密共用这一个方法只通过参数控制子密钥顺序private static byte[] runBlock(byte[] block, byte[] key, boolean encrypt) { if (block.length ! 8 || key.length ! 8) { throw new IllegalArgumentException(block 和 key 必须是8字节); } boolean[][] subKeys generateSubKeys(key); boolean[] bits toBits(block); boolean[] ip permute(bits, IP); boolean[] l slice(ip, 0, 32); boolean[] r slice(ip, 32, 64); for (int round 0; round 16; round) { int idx encrypt ? round : 15 - round; boolean[] newL r; boolean[] newR xor(l, fFunction(r, subKeys[idx])); l newL; r newR; } boolean[] preOutput new boolean[64]; for (int i 0; i 32; i) { preOutput[i] r[i]; preOutput[i 32] l[i]; } boolean[] outBits permute(preOutput, FP); return toBytes(outBits); } public static byte[] encryptBlock(byte[] block, byte[] key) { return runBlock(block, key, true); } public static byte[] decryptBlock(byte[] block, byte[] key) { return runBlock(block, key, false); }加密和解密的流程完全相同区别只有一个idx。加密时第 i 轮用subKeys[i]解密时第 i 轮用subKeys[15 - i]。这就是Feistel结构最优雅的地方加解密不用写两套逻辑反着用子密钥就行。下面处理多块数据和填充。DES处理的是分组数据但实际要加密的字节长度不一定正好是8的倍数所以需要填充。我用的是PKCS5填充需要补 n 个字节时每个填充字节的值都写成 n。public static byte[] encrypt(byte[] data, byte[] key) { int padLen 8 - (data.length % 8); byte[] padded new byte[data.length padLen]; for (int i 0; i data.length; i) { padded[i] data[i]; } for (int i data.length; i padded.length; i) { padded[i] (byte) padLen; } byte[] out new byte[padded.length]; for (int i 0; i padded.length; i 8) { byte[] block sliceBytes(padded, i, i 8); byte[] enc runBlock(block, key, true); for (int j 0; j 8; j) { out[i j] enc[j]; } } return out; } public static byte[] decrypt(byte[] cipherData, byte[] key) { if (cipherData.length % 8 ! 0) { throw new IllegalArgumentException(密文长度必须是8的倍数); } byte[] out new byte[cipherData.length]; for (int i 0; i cipherData.length; i 8) { byte[] block sliceBytes(cipherData, i, i 8); byte[] dec runBlock(block, key, false); for (int j 0; j 8; j) { out[i j] dec[j]; } } int padLen out[out.length - 1] 0xFF; if (padLen 1 || padLen 8) { throw new IllegalStateException(填充长度非法密钥或数据可能不对); } byte[] result new byte[out.length - padLen]; for (int i 0; i result.length; i) { result[i] out[i]; } return result; }填充有个容易忽略的边界即使原始数据长度正好是8的倍数也要多补一个完整的8字节块里面每个字节都是8。否则解密时无法区分“数据自然到这里结束”和“刚好还差一组”。亏我当年第一次写填充时没注意加密一段长度恰好是8的倍数的数据解密回来尾巴上多出一截“\u0008\u0008\u0008...”排查了很久。public static void main(String[] args) { byte[] key fromHex(133457799BBCDFF1); byte[] plain fromHex(0123456789ABCDEF); byte[] cipher encryptBlock(plain, key); System.out.println(加密结果: toHex(cipher)); byte[] recovered decryptBlock(cipher, key); System.out.println(解密结果: toHex(recovered)); // 带填充的自测任意长度数据 byte[] selfKey new byte[] {1,2,3,4,5,6,7,8}; byte[] selfData new byte[100]; for (int i 0; i selfData.length; i) { selfData[i] (byte) i; } byte[] enc encrypt(selfData, selfKey); byte[] dec decrypt(enc, selfKey); boolean ok true; for (int i 0; i selfData.length; i) { if (selfData[i] ! dec[i]) { ok false; break; } } System.out.println(多块自检通过: ok); } }把上面所有代码块按顺序放进同一个DES类里不需要任何 import直接java DES就能跑。main 方法里先测单块加解密再用100字节的随机数据测多块填充加解密两步都过了基本就说明实现没大问题。3. 验证结果与常见问题排查3.1 用标准测试向量验证密码学算法最怕“自以为写对了”。我的做法是先拿教科书标准测试向量跑一遍。用密钥133457799BBCDFF1加密明文0123456789ABCDEF标准结果应该是85E813540F0AB405。这也是许多教材里都会出现的经典向量很适合用来验收。如果你运行上面的 main应该可以得到加密结果: 85E813540F0AB405 解密结果: 0123456789ABCDEF 多块自检通过: true这里特别说明一下我调用的是encryptBlock不是带填充的encrypt。原因是标准测试向量针对的是“单个8字节块”而encrypt会额外补一个填充块输出就会变成16字节没法跟教科书里的值直接对上。如果加密结果不对先不要怀疑人生按优先级排查三件事S盒数据有没有抄错行号列号有没有反IP和FP表有没有抄成同一个方向。我第一次手写时就是S7中间某行的两个数字顺序抄错前几轮看着还像样后面完全乱套。3.2 高频问题最容易翻车的四个点我在实际调试里遇到的高频问题整理成一张速查表你可以直接拿去对照问题表现大概率原因怎么修加密结果和标准向量差很远置换表抄错或S盒行列反了逐表核对用打印把第一轮输出与参考实现对比加密正常解密出来是乱码解密没用逆序子密钥检查idx encrypt ? round : 15 - round是否写对加密结果只有前几位对后面全错某个S盒内部数据顺序错把8个S盒的每行数字打印出来逐行对照标准表解密时报“填充长度非法”密钥不对或数据根本不是DES密文先不对数据进行填充校验打印最后一个字节看一眼还有一个新手高频问题Java 的 byte 是有符号的十六进制FF会被当成-1。我在toBytes里把v强转成 byte转回来时用 0xFF或者处理符号扩展才不会出现负数捣乱。你要是用byte参与移位、异或记得时刻警惕符号位。3.3 调试技巧如何快速定位问题写密码学算法调试工具非常重要。我的经验是准备一个能打印boolean[]的辅助方法然后在runBlock里临时加几行日志打印每一轮开始时的L和R。不要一上来就对着64位密文发愁把问题定位到具体轮次以后范围会小很多。// 调试用把boolean数组打印成01字符串 private static String bitsToString(boolean[] bits) { StringBuilder sb new StringBuilder(); for (boolean bit : bits) { sb.append(bit ? 1 : 0); } return sb.toString(); }另一个很实用的自验思路单独验证置换表。IP 和 FP 互为逆置换所以任意64位数据先做 IP 再做 FP应该原样返回。如果这个都不成立说明两张表里至少有一张抄错了。E 和 P 没有这么直观的逆关系但你可以通过标准测试向量间接验证。第三个小技巧是分模块单测。把 S 盒查表抽出来输入已知的6位值比如101011这种手算出预期的4位结果再和代码输出比对。这样能把“F函数”和“主流程”隔离排查。不要等整个算法跑通才看结果那样一旦出错排查范围大得吓人。4. 实操心得与扩展方向整份代码写下来我最深的感受是手写一遍DES后再去看任何对称加密算法都多了一层“肌肉记忆”。你不再只是知道“AES比DES安全”这个结论而是能一眼看出分组密码里常见的套路扩散靠置换混淆靠代换轮密钥靠调度。Feistel结构为什么能保证加解密一致S盒为什么必须是唯一的非线性部件这些问题在写代码的过程中都会自动得到答案。后面如果要继续扩展这条路是现成的先做3DES也就是用两个或三个不同的DES密钥连续加解密C E_K1(D_K2(E_K3(P)))再加CBC模式让每个明文块先与上一块密文异或再加密第一块用初始化向量IV再进一步还可以尝试把同样的Feistel思想扩展到自定义的迷你分组密码上用来理解AES里的字节代换和列混合到底在解决什么问题。当然工程里真正要加密数据的时候请务必使用成熟的加密库。手写实现的最大价值是学习生产环境里自己造密码轮子是大忌。但如果你遇到一个不依赖任何第三方库的演示项目或者需要在一场面试里把DES的每一张置换表都讲清楚这份DES.java应该能成为一份靠谱的原型。
阅读完成 · 觉得有帮助?