Files
encryptx/REVIEW.md
T
2026-09-30 15:49:12 +08:00

500 lines
29 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# encryptx 代码评审报告
> ## 修复状态:全部 P0 / P1 / P2 问题已处理完毕
>
> 本报告记录的是**修复前**的状态,保留作为审计依据。修复后的验证结果:
>
> | 检查项 | 修复前 | 修复后 |
> | --- | --- | --- |
> | `go build ./...` | 通过 | 通过 |
> | `go vet ./...` | 通过 | 通过 |
> | `gofmt -l .` | 3 个文件未格式化 | 干净 |
> | `go test ./...` | **失败**(2 个包 panic) | **27 个包全部 ok** |
> | `govulncheck ./...` | `GO-2026-5932`(无修复版本) | **0 个可达漏洞** |
> | 测试包数为 0 的包 | 4 个 | 0 个 |
> | 算法种类 | 25 个 `EncryptType` | 50 个 `EncryptType` + 6 个 `SignType` |
>
> ### 逐条对应
>
> | 编号 | 问题 | 处理方式 |
> | --- | --- | --- |
> | P0-1 | `BLOWFISH_*` 枚举空实现 | 新建 `blowfishx` 包,补齐 ECB/CBC/CFB/OFB/CTR 与密钥生成 |
> | P0-2 | `GenerateKey` 输出无法用作 key | 密钥契约统一为 `[]byte`,全库不再有字符串密钥 |
> | P0-3 | ECB 解密 panic(可远程 DoS) | 抽取 `cipherx`,所有模式先校验长度再切片,并新增回归测试 |
> | P0-4 | PBKDF2 盐值被丢弃 | `DeriveKey` 同时返回密钥与盐值;`Encrypt` 路径要求显式给盐 |
> | P0-5 | `rsax` 以 panic 代替 error | 全量重写:0 处 panic、OAEP、PSS、长明文分块、受检类型断言 |
> | P0-6 | `go test ./...` 失败 | 全绿 |
> | P1-1 | 无完整性保护 + padding oracle | 新增 ChaCha20-Poly1305 / XChaCha20;填充错误统一为单一 `ErrInvalidPadding`;不推荐模式全部加安全警告 |
> | P1-2 | `utilsx` SHA-1 口令哈希 | 删除 `HashPassword`/`VerifyPassword`/`CalculateHash`,改用 `bcryptx` 与新建的 `argon2x` |
> | P1-3 | 废弃的 openpgp | `gpgx` 迁移到 `github.com/ProtonMail/go-crypto`,govulncheck 归零 |
> | P1-4 | 弱算法无风险标注 | 新增 `SecurityLevel()` / `IsDeprecated()` / `IsAEAD()` / `Note()` 元数据,覆盖全部算法 |
> | P1-5 | 缺现代密码学原语 | 新增 ChaCha20、Ed25519、ECDSA、RSA-OAEP、HKDF、scrypt、Argon2id、常量时间比较、SHA-224/384、SHA3-224、BLAKE2b/s、HMAC-SHA1/384、hexx |
> | P1-6 | GCM 密文含换行、靠吞错误工作 | 输出改为干净的 `密文:nonce`,无空白字符,并加回归测试 |
> | P2-1 | 三个对称包 95% 重复 | 抽取 `cipherx.Suite`:777 行重复代码压缩为各包约 60 行包装 |
> | P2-2 | 两套行为不同的 CBC 实现 | 删除 `AESCBCEncrypt` / `AESCBCDecrypter` |
> | P2-3 | OFB 未暴露 | 已暴露到门面(`AES_OFB` 等),并补齐 CTR |
> | P2-4 | `map[string]interface{}` 静默降级 | `Options` 严格校验:未知键、类型不符一律报错 |
> | P2-5 | `EncryptType` 是裸 int | 显式赋值(原有 0–24 不变)、`String()` / `Valid()` / 元数据 |
> | P2-6 | 错误无法判别 | 全库 `%w` 包装,导出哨兵错误(`ErrInvalidKeySize`、`ErrAuthenticationFailed` 等) |
> | P2-7 | PKCS7 校验过宽 | `PKCS7Unpad` 接收 `blockSize` 并校验 `padding <= blockSize` |
> | 测试 | 覆盖率为 0 的包有 4 个、断言缺失 | 27 个包全部有测试,覆盖率 65%–100%,含 RFC 向量与错误路径 |
> | 工程化 | 无 README/LICENSE/.gitignore/CI | 全部补齐;`go.mod` 升级到 go 1.26 + x/crypto v0.57.0 |
>
> ### 仍待决策的一项
>
> **国密算法(SM2/SM3/SM4)未实现**:Go 标准库与 `x/crypto` 均不提供,需要引入第三方依赖(如 `github.com/tjfoc/gmsm`)。是否引入属于依赖策略决策,未擅自添加。若模块路径 `code.yun.ink` 意味着有国密合规要求,请告知,我可以按现有包的风格补齐。
>
> 其他明确未纳入的算法:BLAKE3、AES-GCM-SIV、Twofish、CAST5。它们都不属于「现代默认选项」,且同样需要额外依赖。
---
## 二次复查(修复完成后再做的一轮对抗性检查)
第一轮修复后,用「不看注释、只看行为」的方式重新检查了一遍:静态通读 + 行为探针实测 + `staticcheck` 全量静态分析 + Go 原生 fuzz。**又发现并修复了 9 个问题。**
### 二次复查发现的问题
| # | 问题 | 严重性 | 处理 |
| --- | --- | --- | --- |
| 1 | `bcryptx.VerifyPassword` **静默截断**超长口令:实测一个 100 字节口令通过了「前 72 字节相同」的哈希校验,而 `HashPassword` 却拒绝——两条路径不对称,等于凭空扩大了可接受的口令集合 | **高(认证缺陷)** | 校验路径同样拒绝 >72 字节并返回新增的 `ErrPasswordTooLong`;补回归测试 |
| 2 | `rsax` 在**解析与使用**既有密钥时也强制 2048 位下限:持有旧 1024 位密钥的调用方连解析都失败,**无法解密自己的历史数据**——把安全策略变成了功能性故障 | **高(可用性/回归)** | 区分「生成」与「使用」:生成仍要求 ≥2048;解析/加解密/签名允许 ≥`MinUsableKeyBits`(1024);新增 `IsWeakPrivateKey`/`IsWeakPublicKey` 供告警 |
| 3 | `chachax` **没有导出任何哨兵错误**,调用方无法用 `errors.Is` 区分「被篡改」与「参数错误」,只能解析错误文本 | 中 | 新增 `ErrInvalidKeySize`/`ErrInvalidNonceSize`/`ErrCiphertextTooShort`/`ErrAuthenticationFailed` 并全面接线 |
| 4 | 门面错误分类不自洽:畸形密文(长度不足、非分组对齐、非法十六进制)**不能**通过 `errors.Is(err, ErrInvalidInput)` 命中,调用方没有统一的「输入不合法」判定标准 | 中 | 新增 `classifyDecryptError`;同时**刻意**让认证失败保持独立,避免「被篡改」被淹没为普通格式错误 |
| 5 | 给不使用密钥的算法(`BASE64`、哈希、口令哈希)传密钥会被**静默忽略**——调用方以为数据受了密钥保护,实际完全没有 | 中 | 新增 `ErrUnexpectedKey` 并报错 |
| 6 | `GenerateKey(ARGON2ID)` 报错,而 `GenerateKey(PBKDF2)` 返回盐值——同样是需要盐的 KDF,行为不一致 | 低 | ARGON2ID 现在也返回盐值;`bcrypt`(盐内嵌在结果中)仍拒绝 |
| 7 | `Encrypt(HKDF)` 强制要求 `salt`,而 `DeriveKey(HKDF)` 允许缺省(HKDF 空盐在标准中等价于全零盐)——同一算法两条入口行为矛盾 | 低 | 两条路径统一允许空盐 |
| 8 | `bcryptx.ErrPasswordMismatch` 是**从未被返回的死导出 API**;`hmacx.errNilHashFunc`、`hmacx_test.mustHex`、`sha3x_test.hashLike` 是死代码 | 低 | 全部删除 |
| 9 | 包级可变全局 `readFileFn`/`writeFileFn`(注释称「供测试替换」但无任何测试使用)——加密库里的无必要可变状态 | 低 | 改为直接调用 `os.ReadFile`/`os.WriteFile` |
此外还发现并修正了一处真实隐患:`aesx`/`desx`/`tdesx`/`blowfishx` 的 `GenerateKeyHex` 系列函数文档没有说明**返回值必须先解码才能当密钥使用**——这正是第一轮 P0-2 缺陷的诱因。已在四个包的文档中写明。
### 二次复查新增的验证手段
- **`staticcheck` 全量静态分析**:首次运行报出 29 条告警,逐条处理后现为 **0 告警**,已加入 CI。
- 其中数条是真实死代码(上表 #8)。
- 其余是「有意保留遗留算法」导致的 `SA1019`:Blowfish、RIPEMD-160、CFB/OFB、RSA PKCS#1 v1.5。**调用点已逐一用 `//lint:ignore` 标注保留理由**,而非全局关闭该检查。
- `ecdsax` 的 `SA1019` 用包级 `staticcheck.conf` 豁免,理由写在该文件里:标准库的 `ECDH()`/`VerifyASN1` 在坐标缺失时会**空指针 panic**(已用探针实测),因此必须读取(只读、不修改)`D`/`X`/`Y` 做判空。删掉这些判空会重新引入 panic——这一点是实测确认的,不是推断。
- **Go 原生 fuzz**:新增 4 个 fuzz 目标,聚焦解密路径。实跑结果:
| 目标 | 执行次数 | 结果 |
| --- | --- | --- |
| `FuzzDecryptNeverPanics` | 1,009,552 | PASS |
| `FuzzDecryptWithIVNeverPanics` | 4,317,330 | PASS |
| `FuzzSymmetricRoundTrip` | 89,506 | PASS |
| `FuzzAEADTamperDetection` | 4,621,708 | PASS |
| **合计** | **约 1004 万次** | **0 崩溃、0 断言失败** |
这些目标在普通 `go test` 下以种子语料运行,等同常规回归测试;CI 中额外跑 30 秒冒烟。
### 二次复查后的最终状态
| 检查项 | 结果 |
| --- | --- |
| `go build ./...` | 通过 |
| `go vet ./...` | 干净 |
| `staticcheck ./...` | **0 告警** |
| `gofmt -l .` | 干净 |
| `go test ./...` | **27 个包全部 ok** |
| `go test -fuzz`(4 目标,各 20s) | 约 1004 万次执行,全部通过 |
| `govulncheck ./...` | **0 个可达漏洞** |
第二轮仍未纳入的算法与第一轮相同(国密 SM2/SM3/SM4、BLAKE3、AES-GCM-SIV、Twofish、CAST5),均需引入额外依赖,属待决策事项。
---
评审对象:`code.yun.ink/pkg/encryptx`(29 个受版本控制的文件,commit `034315d`)
评审方式:静态通读 + 编译/`go vet`/`go test -cover` 实测 + 独立 PoC 复现 + `govulncheck`
---
## 一、总体结论
**当前实现既不完善也不严谨,且存在多处会让调用方直接踩坑甚至崩溃的缺陷。不建议在未修复前用于生产。**
具体判断:
| 维度 | 结论 |
| --- | --- |
| 可编译性 | ✅ `go build ./...` 通过,`go vet ./...` 干净 |
| 可测试性 | ❌ **`go test ./...` 失败**:2 个包因 panic 直接失败 |
| 功能完整性 | ❌ 枚举声明了 Blowfish 但无实现;`GenerateKey` 的返回值无法使用;RSA 全链路不可用 |
| 安全性 | ❌ 无完整性保护的块模式当一等公民、SHA1 口令哈希、废弃的 openpgp、无常量时间比较、依赖有未修复漏洞 |
| 简洁性 | ❌ 三个对称算法包约 95% 代码重复(777 行可压缩到约 150 行);同一功能有两套不同实现的 API |
| 文档/工程化 | ❌ 无 README、LICENSE、.gitignore、CI;`go 1.19` 已 EOL |
### 严重性汇总
| 级别 | 数量 | 说明 |
| --- | --- | --- |
| **P0 功能性缺陷 / 崩溃** | 6 | 含 1 个可被远程触发的 panic(DoS) |
| **P1 安全设计缺陷** | 6 | 含 1 个已知未修复的依赖漏洞 |
| **P2 简洁性 / 可维护性** | 7 | |
| **算法覆盖缺口** | 约 20 项 | 无签名、无 AEAD 之外的现代算法、无 HKDF/Argon2 |
---
## 二、P0:功能性缺陷与崩溃
### P0-1 `BLOWFISH_*` 枚举已声明,但没有实现
`encryptx.go:50-52` 声明了 `BLOWFISH_ECB/CBC/CFB`,`encryptx.go:86-88` 注册了名称,`encryptx.go:318-331` 甚至实现了密钥生成,**但 `Encrypt`(:119-207)和 `Decrypt`(:216-274)的 switch 里根本没有对应 case**。
实测:
```
Encrypt(BLOWFISH_ECB) = "", err=不支持的加密类型: 17
Decrypt(BLOWFISH_ECB) = "", err=不支持的加密类型: 17
GenerateKey(BLOWFISH_ECB) = "b6ce0b2bb163993d4a4d91f8e0a3804c", err=<nil> <-- 只有它能用
```
用户能生成密钥、能在 `GetAllEncryptTypes()` 里看到它、能在 UI 上选中它,但一调用就报错。
### P0-2 `GenerateKey` 的输出**不能**作为加密函数的 key 使用(最严重的易用性缺陷)
`GenerateKey` 把随机字节 `hex.EncodeToString` 后返回(`encryptx.go:293` DES、`:301` 3DES、`:316` AES、`:331` Blowfish),而所有对称加密函数把入参当**原始字节**直接用:`aes.NewCipher([]byte(key))`(`aesx.go:18`)。
于是"生成密钥 → 加密"这条最基本的路径必然失败:
```
AES_CBC key=ee85f726...89a5f94f (len=64) -> Encrypt err=crypto/aes: invalid key size 64
DES_CBC key=4986a395d640e510 (len=16) -> Encrypt err=crypto/des: invalid key size 16
TDES_CBC key=484ef477...dcc66b9 (len=48) -> Encrypt err=crypto/des: invalid key size 48
```
AES-256 的 32 字节密钥变成 64 字符 → 64 字节 → 非法。**DES/3DES/AES 三条路径全部不可用**,只有 HMAC(不限长度)恰好能忍。
值得注意的是 `encryptx_test.go:207-240` 的 `TestGenerateKey` **只断言字符串长度,从未把生成的密钥拿去加密**,所以这个 bug 一直没被发现。
### P0-3 ECB 解密对畸形密文 panic(可远程触发的 DoS)
`aesx.go:50-53`:
```go
decrypted := make([]byte, len(encrypted))
for i := 0; i < len(encrypted); i += aes.BlockSize {
block.Decrypt(decrypted[i:i+aes.BlockSize], encrypted[i:i+aes.BlockSize])
}
```
**没有校验 `len(encrypted) % BlockSize == 0`**(CBC 路径在 `aesx.go:110` 有校验,ECB 没有)。同样问题存在于 `desx.go:50-53` 和 `tdesx.go:50-53`。
实测(20 字节非整块密文):
```
[PANIC] aesx.DecryptECBHex(20 字节非整块) -> runtime error: slice bounds out of range [:32] with capacity 20
```
密文通常来自网络或数据库等不可信来源,`DecryptECBHex` 又是导出函数,这是一个**攻击者用一串精心构造的十六进制就能让服务进程崩溃**的漏洞。
### P0-4 PBKDF2 自动生成的盐被丢弃,派生结果无法复现
`encryptx.go:179-199`:未提供 `salt` 时生成随机盐,但**只返回派生出的 key,盐值被丢弃**。
实测:同一密码两次调用得到不同结果,且调用方无从复原。
```
同一密码两次派生: k1==k2 ? false (盐值未返回给调用方)
```
密码派生最核心的语义就是"同样的密码+盐 → 同样的密钥"。现在这个函数只能用来"考一次试",无法用于真正的密钥派生与验证。
### P0-5 RSA 全链路不可用:以 panic 代替 error
`rsax/rsax.go` 是包内质量最差的一块:
| 行号 | 问题 |
| --- | --- |
| `:18` | `GenerateRSAKey(bits)` 无返回值、不返回路径,调用方无法知道密钥生成在哪 |
| `:23,32,46,52,68,81,88,101,113` | **9 处 `panic(err)`**,库代码绝不应 panic |
| `:30,50` | 文件名硬编码 `private.pem`/`public.pem`,写当前工作目录;并发调用互相覆盖,且私钥文件权限未收紧 |
| `:74,107` | `file.Read(buf)` 忽略返回的错误和 n |
| `:76,109` | `pem.Decode` 返回的 `block` 未判空,非法 PEM 文件会 nil 解引用 |
| `:84` | `publicKeyInterface.(*rsa.PublicKey)` 非受检类型断言,传入 ECDSA 公钥即 panic |
| `:86` | 仅支持 PKCS#1 v1.5 加密,未提供 OAEP(现代首选) |
| `:116` | `rsa.DecryptPKCS1v15` 的 error 被 `_` 吞掉,解密失败会静默返回空/垃圾 |
| `:18` | 未拒绝 1024 位等弱密钥长度 |
`encryptx.go:339-340` 的 `GenerateKey(RSA)` 返回的是字符串 `"RSA密钥已生成并保存到文件"`,而不是任何密钥材料——通过统一门面根本无法获得密钥,且实测 1024 位未被拒绝。
**项目自己的测试就因此崩溃**:
```
--- FAIL: TestRsa
panic: open public.pem: The system cannot find the file specified.
rsax/rsax.go:68
--- FAIL: TestEncryptAsymmetricAlgorithms
panic: open public.pem: The system cannot find the file specified.
encryptx.go:168
```
`encryptx_test.go:140` 的注释写着"不应该崩溃",结果它崩溃了。
### P0-6 `go test ./...` 整体失败
```
FAIL code.yun.ink/pkg/encryptx 1.084s
FAIL code.yun.ink/pkg/encryptx/rsax 0.904s
```
一个连自己的测试都跑不过的加密库,无法建立任何信任基础。
---
## 三、P1:安全设计缺陷
### P1-1 大量"只加密不认证"的模式,且填充错误可区分(padding oracle)
库把 `ECB`/`CBC`/`CFB`/`OFB` 作为一等公民暴露,但**没有任何完整性保护,也没有提供 encrypt-then-MAC 的封装**:
- CBC/ECB 密文可被篡改,解密端不会发现;
- `PKCS7UnPadding`(`aesx.go:352-371`)对"填充长度非法""填充字节不一致""密文为空"返回**不同的错误信息**,配合 CBC 模式即构成经典的 **padding oracle**,攻击者可逐字节还原明文(BEAST/POODLE 类攻击)。
现代 Go 加密代码的默认答案应是 **AES-GCM 或 ChaCha20-Poly1305**,其他模式若要保留必须显式标注为"仅用于兼容旧系统、不具备完整性"。
### P1-2 `utilsx` 的口令哈希是不安全的 SHA1 + 非常量时间比较
`utilsx.go:174-184`:
```go
// HashPassword 哈希密码(简单示例,实际应用中应使用更安全的算法如bcrypt)
func HashPassword(password, salt string) string {
h := sha1.New()
io.WriteString(h, password+salt)
return hex.EncodeToString(h.Sum(nil))
}
func VerifyPassword(password, salt, hash string) bool {
return HashPassword(password, salt) == hash
}
```
三个问题:
1. **SHA1 单次迭代**——GPU 每秒可尝试数十亿次,任何弱口令瞬间爆破。注释自己都承认"应使用 bcrypt",但代码没改;
2. **`==` 字符串比较非常量时间**——可用于时序侧信道;
3. **同一个包里已经有正确实现 `bcryptx`**,却没有被使用,也没有接入顶层门面。
另外 `GeneratePassword`(`utilsx.go:136-172`)注释声称"确保密码包含所有选择的字符类型",但实现只是从合并字符集中纯随机取字符,**不保证**每类字符都出现,注释与行为不符。
### P1-3 `gpgx` 依赖已废弃且被判定"设计上不安全"的 openpgp
`gpgx.go:7` 导入 `golang.org/x/crypto/openpgp`。`govulncheck` 实测结论:
```
Vulnerability #1: GO-2026-5932
The golang.org/x/crypto/openpgp package is unmaintained, unsafe by design,
and has known security issues
Module: golang.org/x/crypto
Found in: golang.org/x/crypto@v0.15.0
Fixed in: N/A <-- 没有修复版本
Your code is affected by 1 vulnerability from 1 module.
```
该漏洞**无修复版本**,唯一出路是迁移到 `github.com/ProtonMail/go-crypto/openpgp` 或删除该包。
此外 `gpgx.Demo()`(`gpgx.go:10`)根本不是可用 API:文件名硬编码(`signer-pubkey.asc` 等)、错误用 `fmt.Println` 打到 stdout 而不是返回、`os.Open` 的三个 reader 从未 `Close()`。它没有接入顶层门面,也没有任何测试(覆盖率 0%)。
### P1-4 弱算法未做任何风险标注
`MD5`、`SHA1`、`DES`、`3DES`、`ECB` 全部作为正常选项暴露,`GetAllEncryptTypes()` 会把它们平等地返回给调用方(很可能直接渲染成前端的下拉框),没有任何"已不推荐/不安全"的标记或 API 层面的区分。`MD5`/`DES` 甚至被放在枚举首位(`encryptx.go:31,40`)。
### P1-5 缺少现代密码学基础设施(详见第四节)
最突出的是**完全没有签名/验签能力**(无 RSA-PSS、无 ECDSA、无 Ed25519),以及**没有任何常量时间比较函数**(`hmac.Equal`/`subtle.ConstantTimeCompare` 都没有封装)。
### P1-6 AES-GCM 的密文格式脆弱且不可移植
`aesx.go:207-208` 用 `fmt.Sprintf("%x\n", ...)` 输出,**带尾随换行符**;`encryptx.go:160` 再用 `ciphertext + ":" + nonce` 拼接,得到:
```
"3e40b62ea1668b57cff415b5c691066d8ffeaf2f4dc1a61b95c4dee0\n:849734e5172e94786ea108b3\n"
```
往返之所以"能work",是因为 `aesx.go:217-218` 的 `hex.DecodeString` **忽略了 error**,恰好返回了解码成功的前缀:
```go
cipherbyte, _ := hex.DecodeString(ciphertext) // 忽略 error
nonce, _ := hex.DecodeString(noncetext) // 忽略 error
```
这是靠"错误被吞掉"维持的脆弱平衡,任何一次重构(比如把 `_` 改成正常的错误处理)都会立刻打断兼容性。同时 GCM 也不支持 AAD,无法绑定协议头等上下文。
---
## 四、加密方法覆盖缺口
回答"加密的方法是否都齐全了":**核心常用算法覆盖了,但缺的都是现代默认选项。**
### 已有的(✓)
哈希 `MD5` `SHA1` `SHA256` `SHA512` `SHA3-256/384/512` `SHAKE128/256`;
对称 `DES` `3DES` `AES`(ECB/CBC/CFB/OFB) `AES-GCM`;
非对称 `RSA`(仅加密,PKCS#1 v1.5);
MAC `HMAC-SHA256/512`;KDF `PBKDF2-SHA256` `bcrypt`;编码 `Base64`(4 种变体);随机数 `随机串/字节/Token/密码/UUID/APIKey`。
### 缺失的(✗)
| 分类 | 缺失 | 重要度 |
| --- | --- | --- |
| **AEAD** | ChaCha20-Poly1305、XChaCha20-Poly1305 | **高**(无 AES 硬件加速场景的默认选择) |
| **签名/验签** | RSA-PSS、RSA PKCS#1v1.5 Sign、ECDSA、**Ed25519** | **高**(整个库没有任何签名能力) |
| **非对称加密** | RSA-OAEP、ECDH/X25519 | **高**(OAEP 是现代 RSA 加密默认) |
| **KDF** | HKDF、scrypt、Argon2id | **高**(HKDF 是密钥派生的现代标准;口令存储应选 Argon2id/scrypt) |
| **常量时间比较** | `SecureCompare` / HMAC 校验 | **高**(安全 API 的必要件) |
| **口令哈希** | 将 bcrypt 接入顶层门面、Argon2id | 中(bcryptx 已存在但不可达) |
| 哈希 | SHA-224、SHA-384、SHA3-224、BLAKE2b/BLAKE2s、RIPEMD-160 | 中 |
| MAC | HMAC-SHA1、HMAC-SHA384、CMAC、Poly1305 | 中 |
| 对称 | Blowfish(枚举已声明未实现)、Twofish、AES-CTR、SM4 | 中 |
| 非对称 | SM2、国密 SM3 | 视需求(模块路径为 `code.yun.ink`,若有国密合规要求则重要) |
| 编码 | Hex 编解码、Base32、URL 转义 | 低 |
| 其他 | 流式/大文件加解密接口(`md5x` 有 `Md5Stream`,其余算法无) | 中 |
**结论**:覆盖面"看起来"不少,但**缺的都是 2024 年之后的默认推荐**——现代 Go 加密代码的典型组合 `AES-GCM / ChaCha20-Poly1305 + HKDF + Ed25519`,这里只有 `AES-GCM`。
---
## 五、P2:简洁性与可维护性
### P2-1 三个对称算法包 95% 代码重复
`aesx.go`(371) + `desx.go`(203) + `tdesx.go`(203) = **777 行**,其中 `EncryptECBHex`/`DecryptECBHex`/`EncryptCBCHex`/`DecryptCBCHex`/`EncryptCFBHex`/`DecryptCFBHex`/`PKCS7Padding`/`PKCS7UnPadding` 是逐行复制,唯一差别是 `aes.NewCipher` vs `des.NewCipher` vs `des.NewTripleDESCipher`、`aes.BlockSize`(16) vs `des.BlockSize`(8)。
`PKCS7Padding`/`PKCS7UnPadding` 被**复制了三遍**(`aesx.go:345,352`;`desx.go:177,184`;`tdesx.go:177,184`)——P0-3 的 panic 之所以要修三处,根因就在这里。
抽象成 `cipherx` 通用层后,三个包可以缩到各约 20 行、总计约 150 行。
### P2-2 同一功能存在两套行为不同的实现(陷阱)
`aesx.go` 里 CBC 有两套:
| | 输出格式 | 解密去填充 | hex 解码错误 |
| --- | --- | --- | --- |
| `EncryptCBCHex`/`DecryptCBCHex`(:64,:93) | 大写 hex,无换行 | ✅ 去除 | ✅ 处理 |
| `AESCBCEncrypt`/`AESCBCDecrypter`(:130,:158) | **小写 hex + 尾随 `\n`** | ❌ **不去除** | ❌ 忽略 |
实测第二套:
```
AESCBCEncrypt 返回含换行符: "c49a4807...932dca\n"
AESCBCDecrypter 返回: "hello\v\v\v\v\v\v\v\v\v\v\v" err=<nil> <-- 填充未去除
```
调用方拿到带 11 个 `\v` 的"明文",且不报错。`aesx_test.go:10-29` 的 `TestGCM` 甚至就是拿这套 API 把 `fmt.Println` 的结果又喂回去,**没有任何断言**,所以从未暴露。
这类"看起来能用、实际有毒"的重复 API 比缺 API 更危险,应直接删除。
### P2-3 OFB 实现未暴露到顶层
`aesx.EncryptOFBHex`/`DecryptOFBHex`(`aesx.go:293,318`)已实现且有测试,但 `EncryptType` 枚举里没有 `AES_OFB`,顶层门面调不到。要么暴露,要么删除。
### P2-4 顶层门面靠 `map[string]interface{}` 传参,无编译期检查
`Encrypt`/`Decrypt`/`GenerateKey`(`encryptx.go:118,215,280`)用 `options map[string]interface{}` + 字符串 key 取参:
```go
iterations, ok := options["iterations"].(int)
if !ok { iterations = 10000 } // 传 int64 / float64 会被静默忽略,回退默认值
```
后果:拼错 key 名、传错类型都**静默降级为默认值**(比如 `iterations` 传成了 `"10000"` 字符串,PBKDF2 就用 10000 而不是用户以为的值;传 1 也一样接受)。加密参数静默失效是安全敏感的。
更好的形态是每个算法一个强类型 Options 结构体 + 构造函数,或 `WithIterations(n)` 之类的函数式选项。
### P2-5 `EncryptType` 是裸 `int`
- 没有 `String()` 方法(需外部函数 `GetEncryptTypeName`);
- 没有 `Valid()` 校验;
- **iota 位置敏感**:`encryptx.go:29-66` 里任何中间插入都会让已有配置/数据库里的数值整体错位;
- 错误信息用 `%d` 输出数字而非名称(`:206,:273,:369`),排障困难。
### P2-6 错误信息未统一包装
`encryptx.go` 里错误信息是中文(`"哈希算法不可逆"`),子包 `aesx`/`desx` 里是混合中英文(`"hex解码失败: %v"` + `"ciphertext too short"`)。同时用 `fmt.Errorf` 而非 `%w` 包装,调用方无法 `errors.Is`/`errors.As` 判断错误类型——无法区分"密钥错误"和"密文损坏"。
### P2-7 `PKCS7UnPadding` 校验过宽
`aesx.go:359` 只检查 `unpadding > length`,没检查 `unpadding > blockSize`。实测一个 32 字节全 `0x20` 的块在 `blockSize=16` 下被当作合法填充接受,返回空串:
```
32 字节 0x20 块 -> 去填充="" err=<nil> (blockSize=16, 却接受了 padding=32)
```
(顺带:该函数签名不接收 `blockSize`,本身就无从校验。)
---
## 六、测试与工程化问题
### 测试覆盖
```
gpgx 0.0% <- 无测试文件
md5x 0.0% <- 无测试文件
pbkdf2x 0.0% <- 无测试文件
sha256x 0.0% <- 无测试文件
rsax FAIL <- panic
encryptx FAIL <- panic
```
覆盖率数字(aesx 85.5%、desx 85.3%)具有误导性,因为:
- `aesx_test.go:10-29`、`rsax_test.go:10-19` 等**只 `fmt.Println`,不写任何断言**,跑通不等于正确;
- 完全没有错误路径/畸形输入测试——所以 P0-3 的 panic 从未被触发;
- `TestGenerateKey` 只查长度不用密钥——所以 P0-2 从未被发现;
- 测试普遍硬编码弱密钥(`"example key 1234"`、`"test_key"`、`"test_key_1234567"`)。
### 工程化
- **无 README**、无 API 文档、无使用示例;
- **无 LICENSE**;
- **无 .gitignore**(本次评审产生的 `.gocache`/`.gomodcache` 立刻就显示为未跟踪目录);
- **无 CI**(有 CI 的话 P0-6 不可能存在);
- `go.mod:3` 声明 `go 1.19`——**已 EOL**,且 `crypto/ecdh` 等需要 1.20+;
- `go.mod:5` 锁定 `golang.org/x/crypto v0.15.0`——早已过时,`govulncheck` 报告:"**22 vulnerabilities in modules you require**"(当前未被调用,但升级是零成本的收益);
- commit 信息缺失上下文(`"提交"`、`"aes"`、`"添加更多的方法"`)。
---
## 七、建议的修复路线
### 第一批(必须,阻塞发布)
1. **修 P0-3 panic**:在 `aesx`/`desx`/`tdesx` 的 ECB 解密入口加 `len(ct)%BlockSize != 0` 校验。(三处;抽象后可只修一处)
2. **修 P0-2 密钥编码**:让 `GenerateKey` 与 `Encrypt`/`Decrypt` 对 key 的编码约定一致。推荐 **key 统一为原始字节/标准 Base64**,并在每个入口做长度校验;或保留 hex 但在加密入口显式 `hex.DecodeString`。**这是破坏性变更,需同步 README 与调用方。**
3. **修 P0-5 RSA**:全部 `panic` 改为返回 `error`;`pem.Decode` 判空;受检类型断言;`os.ReadFile` 替代手写读;`GenerateRSAKey` 返回路径或 `*rsa.PrivateKey`;拒绝 `<2048` 位;补 `rsa.DecryptPKCS1v15` 的错误处理。
4. **修 P0-1**:实现 Blowfish(`golang.org/x/crypto/blowfish`)或从枚举/名称表/`GenerateKey` 中移除。
5. **修 P0-4 PBKDF2**:返回值改为 `(key, salt, error)`,或强制调用方传入 salt 并在文档中明确。
6. **让 `go test ./...` 通过**,并修 `encryptx_test.go:133-144`(它自己就 panic)。
### 第二批(安全)
7. 删除或标注不安全模式;新增 **ChaCha20-Poly1305**,把 AEAD 作为推荐路径。
8. **新增签名能力**:Ed25519(首选)+ RSA-PSS。
9. 新增 **`SecureCompare`(`hmac.Equal` / `subtle.ConstantTimeCompare`)**,并在 `hmacx`/`pbkdf2x` 提供 `Verify*`;把 `utilsx.VerifyPassword` 改为常量时间比较。
10. 用 `bcryptx` 替换 `utilsx.HashPassword`/`VerifyPassword`(或直接删除后者);补充 **Argon2id**。
11. **移除 `gpgx`** 或迁移到 `github.com/ProtonMail/go-crypto`(GO-2026-5932 无修复版本)。
12. 统一 `PKCS7UnPadding` 的错误信息,避免 padding oracle。
### 第三批(简洁与工程化)
13. **抽 `cipherx` 通用层**,消除 `aesx`/`desx`/`tdesx` 的 777 行重复与三份 PKCS7 副本;删除 `AESCBCEncrypt`/`AESCBCDecrypter` 这套有毒的重复 API。
14. 给 `EncryptType` 加 `String()`/`Valid()`;考虑显式赋值而非 `iota` 以保证数值稳定。
15. 用强类型 Options 替换 `map[string]interface{}`。
16. 补 README(含"算法选型建议表":什么场景用什么)、LICENSE、`.gitignore`、GitHub Actions(build + vet + test + govulncheck)。
17. 升级 `go.mod`(Go 版本与 `x/crypto`),并加 `govulncheck` 到 CI。
18. 补错误路径测试与畸形输入测试(fuzz),把覆盖率目标定在有断言的用例上而非行数上。
---
## 附:本次评审的验证命令与原始证据
```powershell
$env:GOCACHE="<ws>\.gocache"; $env:GOMODCACHE="<ws>\.gomodcache"
go build ./... # BUILD_EXIT=0
go vet ./... # VET_EXIT=0
go test ./... # TEST_EXIT=1 (encryptx / rsax panic)
go test -cover ./... # gpgx 0%, md5x 0%, pbkdf2x 0%, sha256x 0%
$env:GOSUMDB="off"
go run golang.org/x/vuln/cmd/govulncheck@latest ./... # GO-2026-5932
```
PoC 输出(12 项断言,全部复现)见本报告第二、三节内嵌的实测片段。评审用的临时 PoC 与构建缓存已清理,工作区已恢复为干净状态(`git status --short` 无输出)。