从零实现DES算法:Java手写对称加密核心原理与工程实践
1. 项目概述:从“黑盒”到“白盒”的DES之旅
在信息安全领域,对称加密算法扮演着数据保密传输的基石角色。其中,DES(Data Encryption Standard)算法,尽管因其56位的密钥长度在现代算力面前已显脆弱,但它作为密码学发展史上的里程碑,其精巧的Feistel网络结构和完整的加解密流程,依然是理解现代加密技术(如AES)不可或缺的入门课。很多开发者,尤其是Java方向的求职者,在面试中常被问及“DES的原理是什么?如何用Java实现?”这类问题。仅仅调用 javax.crypto.Cipher 完成加密,就像使用一个黑盒,知其然而不知其所以然。今天,我们就抛开现成的API,从零开始,用Java语言“白盒”实现一遍DES算法,并探讨其在实际场景中的应用与局限。这不仅是一次深入的密码学实践,更是对算法思维和工程实现能力的一次绝佳锻炼。无论你是正在准备技术面试,还是希望夯实对对称加密的理解,这篇文章都将带你走完从原理到代码,从实现到应用的完整路径。
2. DES算法核心原理深度拆解
2.1 算法背景与基本结构
DES是一种分组密码算法,它将固定长度的明文(64位)在固定长度密钥(64位,其中8位为奇偶校验位,实际有效56位)的控制下,经过一系列复杂的变换,产生64位的密文。其核心设计思想是 混淆(Confusion) 和 扩散(Diffusion) ,分别通过S盒(Substitution-box)和P盒(Permutation-box)来实现,目的是让密文与明文、密钥之间的关系变得极其复杂。
DES的整体结构属于 Feistel网络 。这种结构有一个非常优雅的特性:其加密和解密过程可以使用相同的算法流程,仅需在子密钥的使用顺序上做出调整。这极大地简化了硬件和软件的实现。一轮典型的Feistel结构将输入数据分成左右两半(L和R),经过轮函数F处理后,输出为新的左右两半(R, L⊕F(R, K)),其中K为该轮的轮密钥,⊕表示异或操作。
注意:理解Feistel结构是理解DES乃至许多其他对称加密算法的关键。它的对称性之美在于,解密过程仅仅是加密过程的逆序执行,无需设计一个完全不同的逆算法。
2.2 核心流程与关键变换
DES的加密过程主要包含三个核心阶段:初始置换(IP)、16轮迭代的Feistel结构、以及末置换(IP⁻¹)。而其中最复杂、最核心的部分在于每一轮中 轮函数F 的计算以及 子密钥的生成 。
1. 初始置换与末置换(IP/IP⁻¹) 这是一个固定的、公开的比特位重排操作。它没有密码学上的意义,其主要目的据信是为了方便上世纪70年代硬件芯片的加载。在软件实现中,我们可以通过查表法高效完成。例如,IP置换表的第一位是原明文的第58位,第二位是原明文的第50位,依此类推。末置换IP⁻¹是IP的逆过程,将经过16轮加密后的数据还原为最终的输出顺序。
2. 轮函数F(R, K)详解 这是DES算法的灵魂所在。对于每一轮,它接收32位的右半部分R和48位的子密钥K,输出一个32位的结果。其计算包含以下四步:
- 扩展置换(E-box) :将32位的R扩展为48位。这不是简单的填充,而是通过重复某些比特位来实现的(例如,原第32位同时成为新输出的第1位和第48位)。扩展的目的主要是为了与48位的子密钥进行异或,同时提供更长的结果以进行后续的S盒替换,增加扩散效果。
- 与子密钥异或 :将扩展后的48位数据与当前轮的48位子密钥进行按位异或(XOR)操作。
- S盒替换(核心的非线性变换) :这是DES算法中唯一的非线性步骤,是提供“混淆”的关键。将上一步得到的48位数据分成8组,每组6位,分别送入8个不同的S盒(S1-S8)中。每个S盒是一个4行16列的查找表,它将6位输入映射为4位输出。具体规则是:6位输入的首尾两位组成行号(0-3),中间四位组成列号(0-15),然后查找表中对应位置的4位数值作为输出。8个S盒共输出32位。S盒的设计是DES安全性的核心机密,其内部映射关系是经过精心设计以抵抗密码分析。
- P盒置换 :将S盒输出的32位数据通过一个固定的P置换表进行重新排列,以提供“扩散”效果,使得S盒输出的每一位影响下一轮多个S盒的输入。
3. 子密钥生成算法 DES使用64位密钥(实际有效56位)生成16个48位的子密钥。过程如下:
- 置换选择1(PC-1) :首先,忽略64位密钥中的8个奇偶校验位(第8, 16, 24, 32, 40, 48, 56, 64位),并对剩余的56位进行置换,输出分成两个28位的半密钥C0和D0。
- 循环左移 :对于每一轮i(i从1到16),C(i-1)和D(i-1)分别进行循环左移。左移的位数根据轮数而定:第1, 2, 9, 16轮左移1位,其余轮左移2位。得到Ci和Di。
- 置换选择2(PC-2) :将Ci和Di合并成的56位数据,通过PC-2置换表,压缩并置换输出48位的子密钥Ki。
实操心得:在软件实现中,子密钥生成完全可以预先计算好并存储在一个数组中,这样在加密或解密时直接按顺序(或逆序)取用即可,避免在每轮中重复计算,提升性能。
3. Java实现DES的核心编码实践
理解了原理,我们开始动手编码。我们将创建几个核心类来模块化地实现DES。
3.1 数据结构与常量定义
首先,我们将所有置换表、S盒等常量定义为静态数组。为了高效地进行位操作,我们选择使用 long 类型(64位)来表示数据和密钥,因为Java的位操作对 long 类型支持很好。
public class DESConstants {
// 初始置换IP表 (64位 -> 64位)
public static final int[] IP = {58, 50, 42, 34, 26, 18, 10, 2,
60, 52, 44, 36, 28, 20, 12, 4,
... // 省略完整表格
57, 49, 41, 33, 25, 17, 9, 1};
// 逆初始置换IP^-1表
public static final int[] IP_INV = {40, 8, 48, 16, 56, 24, 64, 32,
39, 7, 47, 15, 55, 23, 63, 31,
...};
// 扩展置换E表 (32位 -> 48位)
public static final int[] E = {32, 1, 2, 3, 4, 5,
4, 5, 6, 7, 8, 9,
...};
// P盒置换表 (32位 -> 32位)
public static final int[] P = {16, 7, 20, 21, 29, 12, 28, 17,
1, 15, 23, 26, 5, 18, 31, 10,
...};
// 8个S盒,每个S盒是4x16的二维数组
public static final int[][][] S_BOX = {
{ // 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 到 S8 的定义省略
};
// 密钥生成相关置换表 PC-1, PC-2, 循环左移位数表
public static final int[] PC1 = {57, 49, 41, 33, 25, 17, 9,
1, 58, 50, 42, 34, 26, 18,
...};
public static final int[] PC2 = {14, 17, 11, 24, 1, 5, 3, 28,
15, 6, 21, 10, 23, 19, 12, 4,
...};
public static final int[] SHIFT_BITS = {1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1};
}
3.2 核心工具方法:位操作与置换
我们需要编写一些通用的位操作方法,因为DES算法本质上是比特位的游戏。
public class BitUtils {
/**
* 对输入数据`input`(long类型,仅低`dataWidth`位有效)进行置换操作。
* @param input 输入数据
* @param table 置换表,表中的数字表示从input中取第几位(从1开始计数,从左到右为高位到低位)
* @param dataWidth 输入数据的有效位宽
* @return 置换后的结果(long类型,高位补0)
*/
public static long permute(long input, int[] table, int dataWidth) {
long result = 0L;
// 遍历置换表的每一位
for (int i = 0; i < table.length; i++) {
// 计算原数据位的位置(从最高位开始为1)
int srcPos = dataWidth - table[i];
// 取出原数据对应位的值(0或1)
long bit = (input >> srcPos) & 1L;
// 将取出的位放到结果对应的位置
int destPos = table.length - 1 - i;
result |= (bit << destPos);
}
return result;
}
/**
* 对28位的数据进行循环左移
* @param data 28位数据(long类型,仅低28位有效)
* @param shift 左移位数(1或2)
* @return 循环左移后的28位数据
*/
public static long circularLeftShift28(long data, int shift) {
// 确保只取低28位
long mask = (1L << 28) - 1;
data &= mask;
// 循环左移
return ((data << shift) | (data >>> (28 - shift))) & mask;
}
}
注意事项:在实现
permute方法时,要特别注意比特位的序。DES标准文档中通常以1开始计数,且将最高位(MSB)视为第1位。而在Java的long类型中,我们通常将最低位(LSB)视为第0位。上面的实现通过dataWidth - table[i]进行了转换,这是正确实现的关键。务必用已知的测试向量验证你的置换函数。
3.3 子密钥生成器的实现
子密钥生成是一个独立且可复用的模块。
public class DESKeyScheduler {
private long[] subKeys; // 存储16轮48位的子密钥
public DESKeyScheduler(long originalKey) {
subKeys = new long[16];
generateSubKeys(originalKey);
}
private void generateSubKeys(long key) {
// 1. 经过PC-1置换,得到56位有效密钥,并分成C0, D0 (各28位)
long permutedKey = BitUtils.permute(key, DESConstants.PC1, 64);
long c = (permutedKey >>> 28) & 0x0FFFFFFFL; // 取高28位
long d = permutedKey & 0x0FFFFFFFL; // 取低28位
// 2. 生成16轮子密钥
for (int i = 0; i < 16; i++) {
// 循环左移
c = BitUtils.circularLeftShift28(c, DESConstants.SHIFT_BITS[i]);
d = BitUtils.circularLeftShift28(d, DESConstants.SHIFT_BITS[i]);
// 合并C和D,经过PC-2置换生成48位子密钥
long combined = (c << 28) | d;
subKeys[i] = BitUtils.permute(combined, DESConstants.PC2, 56);
}
}
// 获取加密用的子密钥(正序)
public long getEncryptKey(int round) {
return subKeys[round];
}
// 获取解密用的子密钥(逆序)
public long getDecryptKey(int round) {
return subKeys[15 - round];
}
}
3.4 轮函数F的实现
这是DES算法最核心的单轮变换。
public class DESRoundFunction {
public static long f(long r, long subKey) {
// 1. 扩展置换 E: 32位 -> 48位
long expanded = BitUtils.permute(r, DESConstants.E, 32);
// 2. 与子密钥异或
long xored = expanded ^ subKey; // subKey是48位
// 3. S盒替换: 48位 -> 32位
long substituted = sBoxSubstitution(xored);
// 4. P盒置换
return BitUtils.permute(substituted, DESConstants.P, 32);
}
private static long sBoxSubstitution(long input48) {
long output32 = 0L;
// 将48位输入分成8组,每组6位
for (int i = 0; i < 8; i++) {
// 取出当前6位 (每次右移(7-i)*6位,然后取低6位)
int shift = (7 - i) * 6;
long sixBits = (input48 >>> shift) & 0x3FL; // 0x3F = 0b111111
// 计算S盒的行号和列号
int row = (int) (((sixBits >> 5) & 0x01) << 1) | (int) (sixBits & 0x01); // 首尾两位
int col = (int) ((sixBits >> 1) & 0x0F); // 中间四位
// 查表得到4位输出
int fourBits = DESConstants.S_BOX[i][row][col];
// 将4位输出拼接到最终结果中
output32 |= ((long) fourBits << ((7 - i) * 4));
}
return output32;
}
}
3.5 完整的DES加解密引擎
最后,我们将所有模块组装起来,形成完整的加解密流程。
public class DESEngine {
private DESKeyScheduler keyScheduler;
public DESEngine(long key) {
this.keyScheduler = new DESKeyScheduler(key);
}
public long encrypt(long plaintext) {
// 1. 初始置换IP
long data = BitUtils.permute(plaintext, DESConstants.IP, 64);
// 2. 拆分成左右各32位
long left = (data >>> 32) & 0xFFFFFFFFL;
long right = data & 0xFFFFFFFFL;
// 3. 16轮Feistel迭代
for (int round = 0; round < 16; round++) {
long temp = right;
// 右半部分进入轮函数F,结果与左半部分异或,成为新的右半部分
right = left ^ DESRoundFunction.f(right, keyScheduler.getEncryptKey(round));
// 旧的右半部分成为新的左半部分
left = temp;
}
// 4. 最后一轮后不交换(16轮迭代后已经交换了16次,所以最后需要再交换回来?)
// 注意:标准的Feistel在最后一轮后通常不交换,但DES标准在16轮后**没有**最终的左右交换。
// 上面循环结束后,left = R16, right = L16 ⊕ F(R16, K16)。而我们需要将 R16 和 L16 ⊕ F(R16, K16) 合并。
// 实际上,为了与大多数实现兼容,我们直接合并 left 和 right。
long combined = (right << 32) | (left & 0xFFFFFFFFL);
// 5. 末置换IP^-1
return BitUtils.permute(combined, DESConstants.IP_INV, 64);
}
public long decrypt(long ciphertext) {
// 解密过程与加密完全相同,只是子密钥使用顺序相反
long data = BitUtils.permute(ciphertext, DESConstants.IP, 64);
long left = (data >>> 32) & 0xFFFFFFFFL;
long right = data & 0xFFFFFFFFL;
for (int round = 0; round < 16; round++) {
long temp = right;
// 注意:这里使用 getDecryptKey,即逆序使用子密钥
right = left ^ DESRoundFunction.f(right, keyScheduler.getDecryptKey(round));
left = temp;
}
long combined = (right << 32) | (left & 0xFFFFFFFFL);
return BitUtils.permute(combined, DESConstants.IP_INV, 64);
}
}
实操心得:关于最后一轮是否交换左右部分,是DES实现中一个经典的“坑”。根据最原始的DES标准文献(如FIPS PUB 46),在16轮迭代后, 不进行 左右交换,直接将
R16和L16(这里的L16和R16是迭代变量)进行合并,然后进行末置换。很多教科书和网络代码为了对称性,会画一个第16轮后交换的图,但在实际代码中,如果循环内每轮都进行了left, right = right, left ^ F(right, key)这样的交换,那么循环结束后,left和right已经处于“交换后”的状态,直接合并即可。我们的代码逻辑遵循了这一标准。务必使用官方测试向量验证这一点。
4. 应用示例与完整测试
有了核心引擎,我们可以将其封装成更易用的形式,处理字符串和字节数组。
4.1 封装工具类与工作模式
实际应用中,我们很少直接加密一个64位的数据块。我们需要处理任意长度的数据,并选择合适的工作模式,如ECB(电子密码本)或CBC(密码分组链接)。这里以ECB模式为例,展示如何加密一个字符串。
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public class DESUtil {
private static final HexFormat hexFormatter = HexFormat.of();
/**
* 使用ECB模式加密字符串(无填充,数据长度必须是8字节的倍数)
* @param plaintext 明文
* @param keyStr 8字节密钥字符串
* @return 十六进制格式的密文
*/
public static String encryptECB(String plaintext, String keyStr) throws Exception {
if (keyStr.length() != 8) {
throw new IllegalArgumentException("DES密钥必须为8个字符(64位,含校验位)");
}
byte[] keyBytes = keyStr.getBytes(StandardCharsets.US_ASCII);
long key = bytesToLong(keyBytes);
byte[] inputBytes = plaintext.getBytes(StandardCharsets.UTF_8);
if (inputBytes.length % 8 != 0) {
throw new IllegalArgumentException("ECB模式无填充时,明文长度必须是8字节的倍数");
}
DESEngine engine = new DESEngine(key);
StringBuilder ciphertextHex = new StringBuilder();
for (int i = 0; i < inputBytes.length; i += 8) {
long block = bytesToLong(inputBytes, i);
long encryptedBlock = engine.encrypt(block);
ciphertextHex.append(longToHex(encryptedBlock));
}
return ciphertextHex.toString();
}
public static String decryptECB(String ciphertextHex, String keyStr) throws Exception {
// ... 解密过程类似,反向操作
// 将十六进制字符串按16字符(64位)分块,每块转换回long,解密,再转回字节
}
// 辅助方法:将字节数组(从offset开始8字节)转换为long
private static long bytesToLong(byte[] bytes, int offset) {
long result = 0;
for (int i = 0; i < 8; i++) {
result = (result << 8) | (bytes[offset + i] & 0xFFL);
}
return result;
}
private static long bytesToLong(byte[] bytes) { return bytesToLong(bytes, 0); }
// 辅助方法:将long转换为固定长度的十六进制字符串
private static String longToHex(long l) {
// 保证输出16个十六进制字符(64位)
String hex = Long.toHexString(l).toUpperCase();
return "0".repeat(16 - hex.length()) + hex;
}
}
4.2 使用官方测试向量验证
为了确保我们的实现完全正确,必须使用已知的测试向量进行验证。NIST(美国国家标准与技术研究院)等机构提供了标准测试数据。
public class DESTest {
public static void main(String[] args) throws Exception {
// 经典测试向量 (来自 NIST 或其他标准文档示例)
// 明文: 0x0123456789ABCDEF
// 密钥: 0x133457799BBCDFF1 (注意:这是带奇偶校验的64位密钥,实际有效56位)
// 期望密文: 0x85E813540F0AB405
long plaintext = 0x0123456789ABCDEFL;
long key = 0x133457799BBCDFF1L;
long expectedCiphertext = 0x85E813540F0AB405L;
DESEngine engine = new DESEngine(key);
long actualCiphertext = engine.encrypt(plaintext);
System.out.println("明文: 0x" + Long.toHexString(plaintext).toUpperCase());
System.out.println("密钥: 0x" + Long.toHexString(key).toUpperCase());
System.out.println("期望密文: 0x" + Long.toHexString(expectedCiphertext).toUpperCase());
System.out.println("实际密文: 0x" + Long.toHexString(actualCiphertext).toUpperCase());
System.out.println("加密测试: " + (actualCiphertext == expectedCiphertext ? "通过" : "失败"));
// 解密测试
long decrypted = engine.decrypt(actualCiphertext);
System.out.println("解密结果: 0x" + Long.toHexString(decrypted).toUpperCase());
System.out.println("解密测试: " + (decrypted == plaintext ? "通过" : "失败"));
// 测试字符串加密(ECB模式)
System.out.println("\n--- ECB模式字符串测试 ---");
String testText = "HelloDES"; // 正好8字节
String testKey = "8byteKey"; // 8字节密钥
String cipher = DESUtil.encryptECB(testText, testKey);
System.out.println("明文: " + testText);
System.out.println("密文(Hex): " + cipher);
String decryptedText = DESUtil.decryptECB(cipher, testKey);
System.out.println("解密后: " + decryptedText);
System.out.println("字符串测试: " + (testText.equals(decryptedText) ? "通过" : "失败"));
}
}
运行这个测试,如果所有断言都通过,那么恭喜你,你已经成功实现了一个完全符合标准的DES算法。
5. 常见问题、安全考量与实战建议
5.1 实现过程中的典型陷阱
- 比特序问题 :这是最大的坑。DES标准文档、教科书、网络文章对位的编号顺序(是从左到右1-64,还是从右到左0-63)描述不一。我们的实现统一采用“从最高位(MSB)开始为第1位”的标准,并在
permute函数中通过dataWidth - table[i]进行转换。任何置换表(IP, E, P, PC-1, PC-2)都必须严格按照此约定实现。 - 数据类型与符号位 :Java的
long类型是有符号的,在进行右移(>>)时,高位会补符号位。在DES的位操作中,我们必须使用无符号右移>>>,或者确保数据在高位没有干扰位(通过& mask进行掩码操作)。 - S盒查找 :S盒的行列索引计算容易出错。记住规则:6位输入
b1b2b3b4b5b6,行号=(b1 << 1) | b6,列号=b2b3b4b5。务必用测试向量验证S盒输出。 - 最后一轮交换 :如前所述,严格按照标准实现,用测试向量验证最终结果。
5.2 DES的安全性讨论与现代应用
为什么DES不再安全? 核心原因是密钥长度太短(56位)。随着计算能力的飞速提升,暴力破解56位密钥在当今已完全可行(可在数小时或数天内完成)。此外,DES的64位分组大小在面对大规模数据时,可能受到分组密码工作模式相关攻击的影响。
3DES(Triple DES) 为了增强安全性,出现了3DES。它使用两个或三个不同的DES密钥(K1, K2, K3),对数据进行三次DES加密。常见模式有:
- EDE模式(Encrypt-Decrypt-Encrypt) :
Ciphertext = E_K3(D_K2(E_K1(Plaintext)))。如果K1=K2=K3,则退化为单DES。 - 3DES将有效密钥长度提升到112位或168位,安全性大大增强,但速度是单DES的三分之一。它曾作为AES的过渡算法被广泛使用。
在现代Java开发中该如何选择?
- 绝对不要在新项目中使用单DES 进行任何需要安全保护的数据加密。
- 遗留系统 :如果必须与老旧系统交互,可能仍需支持DES或3DES。
- 现代标准 :对于新系统,应使用 AES(Advanced Encryption Standard) 。AES密钥长度可选128、192、256位,安全性高,且现代处理器通常提供硬件加速(如AES-NI指令集),性能极佳。
- Java Cryptography Architecture (JCA) :对于生产环境,强烈建议使用Java标准库
javax.crypto.Cipher,并指定AES/GCM/PKCS5Padding这样的强算法和模式。我们的手写实现主要用于教育和理解原理。
5.3 从DES实现中学到的编程与密码学思维
- 模块化设计 :将算法清晰地拆分为常量定义、位工具、密钥调度、轮函数、主引擎等模块,使得代码结构清晰,易于测试和调试。
- 位操作的精妙 :密码学算法大量依赖位操作。通过这次实践,你能深入理解掩码(
&)、移位(<<,>>>)、异或(^)等操作在底层数据变换中的威力。 - 测试驱动开发(TDD) :密码学实现必须绝对正确。使用官方标准测试向量进行验证是必不可少的步骤,这体现了TDD的思想——先有明确的预期结果,再编写实现代码。
- 理解“为什么” :通过亲手实现,你不再把加密看作黑盒。你明白了S盒提供非线性、P盒提供扩散、Feistel结构实现加解密对称。这种理解对于学习更复杂的密码学协议(如SSL/TLS)至关重要。
5.4 性能优化与扩展思考
我们当前的实现是教育优先的清晰版本。在实际追求性能的场景下,可以考虑:
- 查表法优化 :将轮函数F中的扩展置换E、S盒替换、P盒置换等多个步骤预先计算并合并成一张大的查找表(如将32位输入直接映射到32位输出),但这会消耗大量内存(2^32 * 4字节 ≈ 16GB),不现实。更实际的是将S盒输出和P盒置换合并成8个小的查找表(每个表6位输入 -> 32位输出的一部分)。
- 并行处理 :在ECB模式下,各个数据块的加密是独立的,可以并行计算。但在CBC等模式下,由于链式依赖,无法并行。
- 使用现成库 :重申,生产环境请使用
Cipher.getInstance("AES")。JVM会利用本地硬件加速,性能远超任何纯Java的手动优化实现。
最后,虽然DES本身已过时,但这次从零实现的旅程,其价值远超学会一个具体的算法。它训练了你将复杂规范转化为可靠代码的能力,加深了对计算机底层数据表示和操作的理解,并为你打开了密码学这座宏伟殿堂的大门。当你下次看到 Cipher 类时,你看到的将不再是一个魔法黑箱,而是一系列精妙绝伦的比特之舞。
所有评论(0)