500 lines
29 KiB
Markdown
500 lines
29 KiB
Markdown
# 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` 无输出)。
|