diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4b8bf91
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,27 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+
+ - name: Run unit tests
+ run: mvn -B test
diff --git a/.gitignore b/.gitignore
index 384350d..5f1e2a7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-# Java 教学演示项目(经典 src/ 布局,无 Maven / Gradle)
+# Java 教学演示项目(经典 src/ 布局 + 根目录 Maven 测试)
# 编译与打包产物(仓库内不再保留第三方 JAR)
*.class
@@ -9,6 +9,8 @@ encryption/bin/
bin/
out/
build/
+
+# Maven 构建输出(mvn test / mvn package)
target/
# IDE
diff --git a/README.md b/README.md
index 9d2632c..0e76cf7 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
一套面向学习的 Java 加解密示例:用 JDK 自带的 `javax.crypto` / `java.security` / `java.util.Base64` API,演示 Base64、对称密码(DES、3DES、AES)、口令派生密钥(PBKDF2)、密钥交换(经典 DH、椭圆曲线 ECDH)、非对称密码(RSA 加密 / PSS 签名,以及 ECDSA)、SHA-256 摘要和 HMAC-SHA-256 的基本调用方式。
-源码在 `encryption/src/` 下,配套若干带 `main` 的演示类。仓库是经典 `src/` 包目录布局,**没有 Maven / Gradle**。
+源码在 `encryption/src/` 下,配套若干带 `main` 的演示类。仓库仍是经典 `src/` 包目录布局(没有把教学代码搬进 `src/main/java`)。根目录有一份 `pom.xml`,用来编译这些类并跑 JUnit 5;也可以继续只用 `javac` / `java`,不必装 Maven。
> **请先读这一句:** 这是教学 / 演示代码,**不是**生产级密码学库,也不是 FIPS 认证组件。不要把它拷进线上系统当安全模块。
@@ -16,7 +16,7 @@
**这不是什么**
-- 不是经过安全评审的加密 SDK:没有版本化 API、没有 JUnit、没有密钥存储 / 证书 / 协议设计
+- 不是经过安全评审的加密 SDK:没有版本化 API、没有密钥存储 / 证书 / 协议设计。仓库里的 JUnit 只覆盖教学 round-trip / 篡改失败,不是安全评审。
- 不处理侧信道、随机数审计、密钥轮换、多接收方等问题
- Base64 **只是编码**,不是加密;SHA-256 **只是哈希**,也不是加密
@@ -40,7 +40,7 @@
- `testBase64` / `testDES` / `testDESede` / `testAES` / `testPBKDF2` / `testRSA` / `testRSASign` / `testDH` / `testECDH` / `testSHA256` / `testHMAC`
-它们都是带 `main` 的普通 Java 类,**不是** JUnit。
+它们都是带 `main` 的普通 Java 类,**不是** JUnit。自动化测试在 `src/test/java/`,用 `mvn test` 跑(见下面「自动化测试」)。
## 线格式(密文怎么拼)
@@ -82,6 +82,9 @@
.
├── README.md
├── .gitignore
+├── pom.xml # Maven:编译 encryption/src + 跑 JUnit 5
+├── .github/workflows/ci.yml # push / PR 到 master 时 mvn test
+├── src/test/java/elven/encryption/ # JUnit 5(不是 main 演示)
└── encryption/
└── src/
└── elven/
@@ -91,9 +94,9 @@
│ ├── DESede.java
│ ├── AESUtil.java
│ ├── PBKDF2Util.java
-│ ├── DHUtil.java
-│ ├── ECDHUtil.java
-│ ├── RSAUtil.java
+ │ ├── DHUtil.java
+ │ ├── ECDHUtil.java
+ │ ├── RSAUtil.java
│ ├── SHA256Util.java
│ ├── HMACUtil.java
│ └── BytesToHex.java
@@ -111,7 +114,8 @@
└── testHMAC.java
```
-- 目录名像 Eclipse 工程,但仓库里 **没有** `.project` / `.classpath` / `pom.xml` / `build.gradle`。
+- 目录名像 Eclipse 工程。根目录 `pom.xml` 把 `encryption/src` 配成 Maven 主源码根(所以包名仍是 `elven.encryption` / `elven.test`),JUnit 放在标准的 `src/test/java`。没有把教学文件搬进 `src/main/java`。
+- 没有 `.project` / `.classpath` / `build.gradle`。`mvn` 产出在 `target/`,已写入 `.gitignore`。
- 曾经捆绑的 `encryption/sun.misc.BASE64Decoder.jar`(包名其实是 `Decoder`)**已删除**,Base64 改走 JDK 标准库。
- `DESede` 的类名没有 `Util` 后缀;`RSAUtil.getpublicKey` 的方法名仍是小写 `p`,调用时请按源码原样写。
@@ -119,9 +123,10 @@
### 环境
-- 需要 JDK 8+(`javac` + `java`)。`RSASSA-PSS` 签名演示需要 **JDK 11+**。文档中的命令在 **OpenJDK 21** 上核对过。
-- **没有** Maven / Gradle。把 `encryption/src` 当源码根目录即可。
-- 不再需要任何第三方 JAR。
+- **跑 `elven.test` 演示(`javac` / `java`)**:JDK 11+(`RSASSA-PSS` 需要 11)。文档中的命令在 **OpenJDK 21** 上核对过。
+- **跑 `mvn test` / GitHub Actions**:需要 **JDK 17+**(`pom.xml` 的 `maven.compiler.release` 是 17)。CI 使用 **JDK 21**。
+- 把 `encryption/src` 当源码根目录即可。教学代码没有搬到 `src/main/java`。
+- 编译工具类本身仍是纯 JDK,不需要 BouncyCastle。JUnit 5 只作为 Maven **test** 依赖出现,不会打进演示 classpath。
Windows 下把 classpath 分隔符 `:` 换成 `;`。
@@ -148,12 +153,29 @@ java -cp encryption/bin elven.test.testAES
成功时大致会看到:打印密钥 → 打印密文(十六进制)→ 再打印解密后的原文。`testRSA` 现在直接打印解密字符串(不再把明文打成 hex)。`testRSASign` 会打印 PSS 签名,并演示改一个字节后面验签失败;同一对密钥仍可做 OAEP 加解密。`testHMAC` 同样会翻转一个字节,展示 `verify` 从 `true` 变成 `false`。`testDH` 会先确认双方派生密钥相同,再用 AES-GCM 加解密那句示例明文。DH 第一次生成 2048 bit 参数可能要几秒。`testECDH` 同样确认双方 AES 密钥相同并 round-trip,再演示「拿错对方公钥会得到不同秘密、AES-GCM 解密失败」,以及一对很小的 ECDSA 验签。ECDH 比 DH 快得多,因为不用生成 2048 bit 素数。`testPBKDF2` 会打印盐、派生耗时、正确口令 round-trip,以及错误口令 / 错误盐时 AES-GCM 解密失败。
+### 用 Maven 跑 JUnit(自动化测试)
+
+`elven.test.test*` 是给人看输出的 `main` 演示;`src/test/java` 里的 `*Test` 才是断言。两者都覆盖同一套工具类,**改算法教学默认值之前请两边都看一眼**。
+
+在仓库根目录(有 `pom.xml` 的地方):
+
+```bash
+mvn test
+```
+
+CI(`.github/workflows/ci.yml`)在 push / PR 到 `master` 时用 JDK 21 执行同样的 `mvn -B test`。
+
+常见结果:Base64 / SHA-256 / HMAC / AES-GCM / PBKDF2 / RSA / ECDH 都应很快结束;`DHUtilTest` 第一次生成 2048 bit DH 参数可能要几秒,这是预期行为,不是卡住。
+
+不需要把 `elven.test` 演示改成 JUnit,也不要用 `mvn test` 去「运行」那些 `main`。演示仍然用上一节的 `java -cp encryption/bin elven.test.testAES`。
+
### 用 IDE
-1. 用「从现有源码创建项目」或新建空 Java 项目。
-2. 把 `encryption/src` 标成 Source Root,这样 `elven.encryption` / `elven.test` 才会被识别成包名。
+1. 最省事:用 IDE **打开根目录的 Maven 项目**(识别 `pom.xml`)。主源码根已指向 `encryption/src`,测试根是 `src/test/java`。
+2. 也可以「从现有源码创建项目」:把 `encryption/src` 标成 Source Root,这样 `elven.encryption` / `elven.test` 才会被识别成包名。
3. **不必**再添加 Base64 JAR。
-4. 打开某个 `test*.java`,运行它的 `main`。
+4. 打开某个 `encryption/src/elven/test/test*.java`,运行它的 `main`。
+5. 若用 Maven / IDE 的 JUnit 运行器:打开 `src/test/java/elven/encryption/*Test.java`,不要和上面的 `main` 演示搞混。
## 最小用法示例
@@ -391,12 +413,12 @@ boolean ok = ECDHUtil.verify(data.getBytes(StandardCharsets.UTF_8), signature, p
欢迎来。请尽量保持「小而清晰的教学示例」:
-- 修文档、核对某个 JDK 上能否跑通,都很有价值。
-- 大规模框架化、引入完整密码学库,通常超出本仓库定位。
+- 修文档、核对某个 JDK 上能否跑通、补 JUnit 断言,都很有价值。
+- 大规模框架化、引入 BouncyCastle 等完整密码学库,通常超出本仓库定位。
- 没有贡献者协议,普通 GitHub Pull Request 即可。
## English summary
-Educational Java samples for Base64, DES, 3DES, AES, PBKDF2, classic Diffie–Hellman, ECDH (secp256r1), RSA (OAEP encrypt + PSS sign), ECDSA, SHA-256, and HMAC-SHA-256 using only the JDK. Classic `src/` layout, no Maven/Gradle. **Not a production crypto library and not FIPS certified.** Hashing is not encryption; HMAC authenticates but does not conceal; RSA/ECDSA signatures are not encryption.
+Educational Java samples for Base64, DES, 3DES, AES, PBKDF2, classic Diffie–Hellman, ECDH (secp256r1), RSA (OAEP encrypt + PSS sign), ECDSA, SHA-256, and HMAC-SHA-256 using only the JDK. Teaching sources stay in classic `encryption/src/` (`elven.encryption` / `elven.test` mains). A root `pom.xml` compiles that tree and runs JUnit 5 from `src/test/java` (`mvn test`; CI uses JDK 21). Maven requires **JDK 17+**; `javac` demos need **JDK 11+**. **Not a production crypto library and not FIPS certified.** Hashing is not encryption; HMAC authenticates but does not conceal; RSA/ECDSA signatures are not encryption.
Current teaching defaults: `java.util.Base64`; AES-128-GCM with a 12-byte IV prepended; PBKDF2-HMAC-SHA256 with a 16-byte salt, 100_000 iterations, and a 128-bit AES key (demo speed, not OWASP production guidance); RSA-2048 OAEP(SHA-256) for encrypt/decrypt and `RSASSA-PSS` with SHA-256/MGF1-SHA256 parameters for sign/verify; HMAC-SHA-256 with `MessageDigest.isEqual`; DH-2048 kept as a classic contrast; ECDH on `secp256r1` (P-256) as the modern key-agreement path. Both DH and ECDH hash the raw `generateSecret()` bytes with SHA-256 and truncate to an AES-128 key (a teaching KDF, not HKDF; the old `generateSecret("DES")` / `generateSecret("AES")` paths are avoided). ECDHUtil also has a tiny `SHA256withECDSA` bonus on the same keys. DES remains as an explicit insecure ECB demo; 3DES remains as legacy CBC with an 8-byte IV prepended. The bundled Base64 JAR has been removed. Licensed under the MIT License; see the `LICENSE` file.
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..dbed94f
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,48 @@
+
+
+ 4.0.0
+
+ elven
+ encryption
+ 1.0.0-SNAPSHOT
+ encryption
+ Educational JDK-only Java crypto demos (Base64, AES, RSA, DH, ECDH, …)
+
+
+ UTF-8
+ UTF-8
+
+ 17
+ 5.11.4
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.jupiter.version}
+ test
+
+
+
+
+
+ encryption/src
+
+ src/test/java
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.2
+
+
+
+
diff --git a/src/test/java/elven/encryption/AESUtilTest.java b/src/test/java/elven/encryption/AESUtilTest.java
new file mode 100644
index 0000000..2d6f3fa
--- /dev/null
+++ b/src/test/java/elven/encryption/AESUtilTest.java
@@ -0,0 +1,61 @@
+package elven.encryption;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.Arrays;
+
+import javax.crypto.AEADBadTagException;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * AES-GCM:能 round-trip;改密文或 tag 会认证失败(AEADBadTagException)。
+ */
+class AESUtilTest {
+
+ @Test
+ void encryptDecryptRoundTrip() throws Exception {
+ byte[] key = AESUtil.initKey();
+ assertEquals(AESUtil.KEY_SIZE_BITS / 8, key.length);
+
+ byte[] packed = AESUtil.encryptAES(TestData.BYTES, key);
+ byte[] plain = AESUtil.decryptAES(packed, key);
+ assertArrayEquals(TestData.BYTES, plain);
+ }
+
+ @Test
+ void packedCiphertextStartsWithRandomIv() throws Exception {
+ byte[] key = AESUtil.initKey();
+ byte[] first = AESUtil.encryptAES(TestData.BYTES, key);
+ byte[] second = AESUtil.encryptAES(TestData.BYTES, key);
+ // 每次加密换新 IV,所以整段 packed 密文通常不同;两边都能解开
+ assertFalse(Arrays.equals(first, second));
+ assertArrayEquals(TestData.BYTES, AESUtil.decryptAES(first, key));
+ assertArrayEquals(TestData.BYTES, AESUtil.decryptAES(second, key));
+ }
+
+ @Test
+ void tamperedCiphertextFailsAuthentication() throws Exception {
+ byte[] key = AESUtil.initKey();
+ byte[] packed = AESUtil.encryptAES(TestData.BYTES, key);
+ byte[] tampered = TestData.flip(packed, packed.length - 1); // 翻 tag 的最后一个字节
+ assertThrows(AEADBadTagException.class, () -> AESUtil.decryptAES(tampered, key));
+ }
+
+ @Test
+ void wrongKeyFailsAuthentication() throws Exception {
+ byte[] key = AESUtil.initKey();
+ byte[] otherKey = AESUtil.initKey();
+ byte[] packed = AESUtil.encryptAES(TestData.BYTES, key);
+ assertThrows(AEADBadTagException.class, () -> AESUtil.decryptAES(packed, otherKey));
+ }
+
+ @Test
+ void shortCiphertextRejected() {
+ byte[] key = new byte[AESUtil.KEY_SIZE_BITS / 8];
+ assertThrows(IllegalArgumentException.class, () -> AESUtil.decryptAES(new byte[8], key));
+ }
+}
diff --git a/src/test/java/elven/encryption/Base64UtilTest.java b/src/test/java/elven/encryption/Base64UtilTest.java
new file mode 100644
index 0000000..8fff10c
--- /dev/null
+++ b/src/test/java/elven/encryption/Base64UtilTest.java
@@ -0,0 +1,36 @@
+package elven.encryption;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Base64 是编码,不是加密:任何人都能解码。这里只核对 round-trip 和非法输入。
+ */
+class Base64UtilTest {
+
+ @Test
+ void roundTripUtf8Text() {
+ String encoded = Base64Util.base64Encrypt(TestData.BYTES);
+ assertEquals(TestData.TEXT, Base64Util.base64Decrypt(encoded));
+ }
+
+ @Test
+ void knownVectorHello() {
+ // "hello" 的标准 Base64,方便对照教材 / 在线计算器
+ assertEquals("aGVsbG8=", Base64Util.base64Encrypt("hello".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+ assertEquals("hello", Base64Util.base64Decrypt("aGVsbG8="));
+ }
+
+ @Test
+ void nullInputRejected() {
+ assertThrows(IllegalArgumentException.class, () -> Base64Util.base64Encrypt(null));
+ assertThrows(IllegalArgumentException.class, () -> Base64Util.base64Decrypt(null));
+ }
+
+ @Test
+ void invalidBase64Rejected() {
+ assertThrows(IllegalArgumentException.class, () -> Base64Util.base64Decrypt("@@@not-base64@@@"));
+ }
+}
diff --git a/src/test/java/elven/encryption/DESUtilTest.java b/src/test/java/elven/encryption/DESUtilTest.java
new file mode 100644
index 0000000..32a6d39
--- /dev/null
+++ b/src/test/java/elven/encryption/DESUtilTest.java
@@ -0,0 +1,18 @@
+package elven.encryption;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * DES 仅教材对照:56 bit + ECB,不安全。这里只做一次 round-trip。
+ */
+class DESUtilTest {
+
+ @Test
+ void encryptDecryptRoundTrip() throws Exception {
+ byte[] key = DESUtil.initKey();
+ byte[] cipher = DESUtil.encryptDES(TestData.BYTES, key);
+ assertArrayEquals(TestData.BYTES, DESUtil.decryptDES(cipher, key));
+ }
+}
diff --git a/src/test/java/elven/encryption/DESedeTest.java b/src/test/java/elven/encryption/DESedeTest.java
new file mode 100644
index 0000000..603d648
--- /dev/null
+++ b/src/test/java/elven/encryption/DESedeTest.java
@@ -0,0 +1,18 @@
+package elven.encryption;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * 3DES 是遗留算法:CBC + 前置 IV。只做一次 round-trip,新系统请看 AES。
+ */
+class DESedeTest {
+
+ @Test
+ void encryptDecryptRoundTrip() throws Exception {
+ byte[] key = DESede.initKey();
+ byte[] packed = DESede.encrypt3DES(TestData.BYTES, key);
+ assertArrayEquals(TestData.BYTES, DESede.decrypt3DES(packed, key));
+ }
+}
diff --git a/src/test/java/elven/encryption/DHUtilTest.java b/src/test/java/elven/encryption/DHUtilTest.java
new file mode 100644
index 0000000..0e265e2
--- /dev/null
+++ b/src/test/java/elven/encryption/DHUtilTest.java
@@ -0,0 +1,34 @@
+package elven.encryption;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * 经典 DH:乙方必须复用甲方 p、g。双方应算出同一把 AES-128 密钥。
+ * 第一次生成 2048 bit 参数可能要几秒,这是预期行为。
+ */
+class DHUtilTest {
+
+ @Test
+ void bothSidesDeriveSameSecretAndAesRoundTrip() throws Exception {
+ Map alice = DHUtil.initKey();
+ byte[] alicePub = DHUtil.getPublicKey(alice);
+ byte[] alicePri = DHUtil.getPrivateKey(alice);
+
+ Map bob = DHUtil.initKey(alicePub);
+ byte[] bobPub = DHUtil.getPublicKey(bob);
+ byte[] bobPri = DHUtil.getPrivateKey(bob);
+
+ byte[] secretAlice = DHUtil.getSecretKey(bobPub, alicePri);
+ byte[] secretBob = DHUtil.getSecretKey(alicePub, bobPri);
+ assertEquals(DHUtil.AES_KEY_LENGTH_BYTES, secretAlice.length);
+ assertArrayEquals(secretAlice, secretBob);
+
+ byte[] packed = AESUtil.encryptAES(TestData.BYTES, secretAlice);
+ assertArrayEquals(TestData.BYTES, AESUtil.decryptAES(packed, secretBob));
+ }
+}
diff --git a/src/test/java/elven/encryption/ECDHUtilTest.java b/src/test/java/elven/encryption/ECDHUtilTest.java
new file mode 100644
index 0000000..4dc170d
--- /dev/null
+++ b/src/test/java/elven/encryption/ECDHUtilTest.java
@@ -0,0 +1,46 @@
+package elven.encryption;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * ECDH on secp256r1:双方独立 initKey(),应得到同一把 AES 密钥;ECDSA 可对照验签。
+ */
+class ECDHUtilTest {
+
+ @Test
+ void bothSidesDeriveSameSecretAndAesRoundTrip() throws Exception {
+ Map alice = ECDHUtil.initKey();
+ byte[] alicePub = ECDHUtil.getPublicKey(alice);
+ byte[] alicePri = ECDHUtil.getPrivateKey(alice);
+
+ Map bob = ECDHUtil.initKey();
+ byte[] bobPub = ECDHUtil.getPublicKey(bob);
+ byte[] bobPri = ECDHUtil.getPrivateKey(bob);
+
+ byte[] secretAlice = ECDHUtil.getSecretKey(bobPub, alicePri);
+ byte[] secretBob = ECDHUtil.getSecretKey(alicePub, bobPri);
+ assertEquals(ECDHUtil.AES_KEY_LENGTH_BYTES, secretAlice.length);
+ assertArrayEquals(secretAlice, secretBob);
+
+ byte[] packed = AESUtil.encryptAES(TestData.BYTES, secretAlice);
+ assertArrayEquals(TestData.BYTES, AESUtil.decryptAES(packed, secretBob));
+ }
+
+ @Test
+ void ecdsaVerifyTrueAndFalse() throws Exception {
+ Map keys = ECDHUtil.initKey();
+ byte[] publicKey = ECDHUtil.getPublicKey(keys);
+ byte[] privateKey = ECDHUtil.getPrivateKey(keys);
+
+ byte[] signature = ECDHUtil.sign(TestData.BYTES, privateKey);
+ assertTrue(ECDHUtil.verify(TestData.BYTES, signature, publicKey));
+ assertFalse(ECDHUtil.verify(TestData.flip(TestData.BYTES, 0), signature, publicKey));
+ }
+}
diff --git a/src/test/java/elven/encryption/HMACUtilTest.java b/src/test/java/elven/encryption/HMACUtilTest.java
new file mode 100644
index 0000000..6b8ede5
--- /dev/null
+++ b/src/test/java/elven/encryption/HMACUtilTest.java
@@ -0,0 +1,41 @@
+package elven.encryption;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * HMAC 能发现篡改,但不加密。verify 用 MessageDigest.isEqual 比较。
+ */
+class HMACUtilTest {
+
+ @Test
+ void verifyAcceptsUntamperedMac() throws Exception {
+ byte[] key = HMACUtil.initKey();
+ byte[] mac = HMACUtil.hmac(TestData.BYTES, key);
+ assertEquals(HMACUtil.MAC_LENGTH_BYTES, mac.length);
+ assertTrue(HMACUtil.verify(TestData.BYTES, key, mac));
+ }
+
+ @Test
+ void verifyRejectsTamperedData() throws Exception {
+ byte[] key = HMACUtil.initKey();
+ byte[] mac = HMACUtil.hmac(TestData.BYTES, key);
+ assertFalse(HMACUtil.verify(TestData.flip(TestData.BYTES, 0), key, mac));
+ }
+
+ @Test
+ void verifyRejectsTamperedMac() throws Exception {
+ byte[] key = HMACUtil.initKey();
+ byte[] mac = HMACUtil.hmac(TestData.BYTES, key);
+ assertFalse(HMACUtil.verify(TestData.BYTES, key, TestData.flip(mac, 0)));
+ }
+
+ @Test
+ void wrongKeyLengthRejected() {
+ assertThrows(IllegalArgumentException.class, () -> HMACUtil.hmac(TestData.BYTES, new byte[8]));
+ }
+}
diff --git a/src/test/java/elven/encryption/PBKDF2UtilTest.java b/src/test/java/elven/encryption/PBKDF2UtilTest.java
new file mode 100644
index 0000000..ff152c7
--- /dev/null
+++ b/src/test/java/elven/encryption/PBKDF2UtilTest.java
@@ -0,0 +1,54 @@
+package elven.encryption;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.Arrays;
+
+import javax.crypto.AEADBadTagException;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * 同一口令 + 同一盐 → 同一把 AES 密钥;口令错了,AES-GCM 解不开。
+ */
+class PBKDF2UtilTest {
+
+ private static final String PASSWORD = "correct horse battery staple";
+
+ @Test
+ void samePasswordAndSaltDeriveSameKey() throws Exception {
+ byte[] salt = PBKDF2Util.generateSalt();
+ assertEquals(PBKDF2Util.SALT_LENGTH_BYTES, salt.length);
+
+ byte[] first = PBKDF2Util.deriveKey(PASSWORD, salt);
+ byte[] second = PBKDF2Util.deriveKey(PASSWORD, salt);
+ assertEquals(PBKDF2Util.DEFAULT_KEY_LENGTH_BITS / 8, first.length);
+ assertArrayEquals(first, second);
+ }
+
+ @Test
+ void differentSaltProducesDifferentKey() throws Exception {
+ byte[] key1 = PBKDF2Util.deriveKey(PASSWORD, PBKDF2Util.generateSalt());
+ byte[] key2 = PBKDF2Util.deriveKey(PASSWORD, PBKDF2Util.generateSalt());
+ assertFalse(Arrays.equals(key1, key2));
+ }
+
+ @Test
+ void wrongPasswordCannotDecryptAes() throws Exception {
+ byte[] salt = PBKDF2Util.generateSalt();
+ byte[] key = PBKDF2Util.deriveKey(PASSWORD, salt);
+ byte[] packed = AESUtil.encryptAES(TestData.BYTES, key);
+
+ byte[] wrongKey = PBKDF2Util.deriveKey("wrong password", salt);
+ assertThrows(AEADBadTagException.class, () -> AESUtil.decryptAES(packed, wrongKey));
+ }
+
+ @Test
+ void emptyPasswordRejected() {
+ byte[] salt = PBKDF2Util.generateSalt();
+ assertThrows(IllegalArgumentException.class, () -> PBKDF2Util.deriveKey("", salt));
+ }
+}
diff --git a/src/test/java/elven/encryption/RSAUtilTest.java b/src/test/java/elven/encryption/RSAUtilTest.java
new file mode 100644
index 0000000..acbd23f
--- /dev/null
+++ b/src/test/java/elven/encryption/RSAUtilTest.java
@@ -0,0 +1,61 @@
+package elven.encryption;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * RSA-OAEP 加解密 + RSASSA-PSS 签名。生成 2048 bit 密钥较慢,整类共用一对。
+ */
+class RSAUtilTest {
+
+ private static RSAPublicKey publicKey;
+ private static RSAPrivateKey privateKey;
+
+ @BeforeAll
+ static void generateKeyPair() throws Exception {
+ Map keyMap = RSAUtil.initKey();
+ publicKey = RSAUtil.getpublicKey(keyMap);
+ privateKey = RSAUtil.getPrivateKey(keyMap);
+ }
+
+ @Test
+ void encryptDecryptRoundTrip() throws Exception {
+ byte[] cipher = RSAUtil.encrypt(TestData.BYTES, publicKey);
+ byte[] plain = RSAUtil.decrypt(cipher, privateKey);
+ assertArrayEquals(TestData.BYTES, plain);
+ }
+
+ @Test
+ void tamperedCiphertextFailsDecrypt() throws Exception {
+ byte[] cipher = RSAUtil.encrypt(TestData.BYTES, publicKey);
+ byte[] tampered = TestData.flip(cipher, 0);
+ assertThrows(Exception.class, () -> RSAUtil.decrypt(tampered, privateKey));
+ }
+
+ @Test
+ void signVerifyAcceptsUntamperedData() throws Exception {
+ byte[] signature = RSAUtil.sign(TestData.BYTES, privateKey);
+ assertTrue(RSAUtil.verify(TestData.BYTES, signature, publicKey));
+ }
+
+ @Test
+ void verifyRejectsTamperedData() throws Exception {
+ byte[] signature = RSAUtil.sign(TestData.BYTES, privateKey);
+ assertFalse(RSAUtil.verify(TestData.flip(TestData.BYTES, 0), signature, publicKey));
+ }
+
+ @Test
+ void verifyRejectsTamperedSignature() throws Exception {
+ byte[] signature = RSAUtil.sign(TestData.BYTES, privateKey);
+ assertFalse(RSAUtil.verify(TestData.BYTES, TestData.flip(signature, 0), publicKey));
+ }
+}
diff --git a/src/test/java/elven/encryption/SHA256UtilTest.java b/src/test/java/elven/encryption/SHA256UtilTest.java
new file mode 100644
index 0000000..f0ce608
--- /dev/null
+++ b/src/test/java/elven/encryption/SHA256UtilTest.java
@@ -0,0 +1,49 @@
+package elven.encryption;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * SHA-256 是确定性哈希:同样输入永远同样输出,但不能还原原文。
+ */
+class SHA256UtilTest {
+
+ @Test
+ void digestIsDeterministicAnd32Bytes() throws Exception {
+ byte[] first = SHA256Util.hash(TestData.BYTES);
+ byte[] second = SHA256Util.hash(TestData.BYTES);
+ assertEquals(SHA256Util.DIGEST_LENGTH_BYTES, first.length);
+ assertArrayEquals(first, second);
+ }
+
+ @Test
+ void hashHexIsLowercaseAndMatchesBytes() throws Exception {
+ byte[] digest = SHA256Util.hash(TestData.BYTES);
+ String hex = SHA256Util.hashHex(TestData.BYTES);
+ assertEquals(64, hex.length());
+ assertEquals(hex, hex.toLowerCase());
+ assertEquals(BytesToHex.fromBytesToHex(digest), hex);
+ }
+
+ @Test
+ void knownVectorEmptyString() throws Exception {
+ // FIPS 180-2:空输入的 SHA-256
+ assertEquals(
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ SHA256Util.hashHex(new byte[0]));
+ }
+
+ @Test
+ void differentInputDifferentHash() throws Exception {
+ assertNotEquals(SHA256Util.hashHex(TestData.BYTES), SHA256Util.hashHex(TestData.flip(TestData.BYTES, 0)));
+ }
+
+ @Test
+ void nullInputRejected() {
+ assertThrows(IllegalArgumentException.class, () -> SHA256Util.hash(null));
+ }
+}
diff --git a/src/test/java/elven/encryption/TestData.java b/src/test/java/elven/encryption/TestData.java
new file mode 100644
index 0000000..3157a93
--- /dev/null
+++ b/src/test/java/elven/encryption/TestData.java
@@ -0,0 +1,23 @@
+package elven.encryption;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * JUnit 测试共用的示例明文。和 {@code elven.test} 演示里的句子保持一致,方便对照。
+ */
+final class TestData {
+
+ static final String TEXT = "hi, welcome to my git area!";
+
+ static final byte[] BYTES = TEXT.getBytes(StandardCharsets.UTF_8);
+
+ /** 翻转指定下标的一个比特,用来构造「被篡改」的副本。 */
+ static byte[] flip(byte[] src, int index) {
+ byte[] copy = src.clone();
+ copy[index] ^= 0x01;
+ return copy;
+ }
+
+ private TestData() {
+ }
+}