From e56e3c8216bf76d1e1ca9d5f00df1edbf8dd4c47 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 09:59:17 +0000 Subject: [PATCH] Add Maven + JUnit 5 tests and GitHub Actions CI. Keep teaching sources under encryption/src and point Maven sourceDirectory there. Add JUnit 5 coverage for the main utils, a JDK 21 mvn test workflow, and README updates. Co-authored-by: Elven_xu <799835984@qq.com> --- .github/workflows/ci.yml | 27 ++++++++ .gitignore | 4 +- README.md | 54 +++++++++++----- pom.xml | 48 +++++++++++++++ .../java/elven/encryption/AESUtilTest.java | 61 +++++++++++++++++++ .../java/elven/encryption/Base64UtilTest.java | 36 +++++++++++ .../java/elven/encryption/DESUtilTest.java | 18 ++++++ .../java/elven/encryption/DESedeTest.java | 18 ++++++ .../java/elven/encryption/DHUtilTest.java | 34 +++++++++++ .../java/elven/encryption/ECDHUtilTest.java | 46 ++++++++++++++ .../java/elven/encryption/HMACUtilTest.java | 41 +++++++++++++ .../java/elven/encryption/PBKDF2UtilTest.java | 54 ++++++++++++++++ .../java/elven/encryption/RSAUtilTest.java | 61 +++++++++++++++++++ .../java/elven/encryption/SHA256UtilTest.java | 49 +++++++++++++++ src/test/java/elven/encryption/TestData.java | 23 +++++++ 15 files changed, 557 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pom.xml create mode 100644 src/test/java/elven/encryption/AESUtilTest.java create mode 100644 src/test/java/elven/encryption/Base64UtilTest.java create mode 100644 src/test/java/elven/encryption/DESUtilTest.java create mode 100644 src/test/java/elven/encryption/DESedeTest.java create mode 100644 src/test/java/elven/encryption/DHUtilTest.java create mode 100644 src/test/java/elven/encryption/ECDHUtilTest.java create mode 100644 src/test/java/elven/encryption/HMACUtilTest.java create mode 100644 src/test/java/elven/encryption/PBKDF2UtilTest.java create mode 100644 src/test/java/elven/encryption/RSAUtilTest.java create mode 100644 src/test/java/elven/encryption/SHA256UtilTest.java create mode 100644 src/test/java/elven/encryption/TestData.java 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() { + } +}