diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..826b15a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,116 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +permissions: + contents: read + +jobs: + build: + name: 构建 / 静态检查 / 测试 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # 格式检查:任何未 gofmt 的文件都会让 CI 失败 + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "以下文件未通过 gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + # staticcheck 能发现 go vet 覆盖不到的问题(死代码、永不匹配的条件、 + # 误用标准库等)。本仓库在 ecdsax/ 下有 staticcheck.conf,对 SA1019 做了 + # 定向豁免并写明了理由。 + - name: staticcheck + run: | + go install honnef.co/go/tools/cmd/staticcheck@latest + staticcheck ./... + + - name: go build + run: go build ./... + + # -race 能发现并发使用中的问题;本库的函数应全部可并发调用 + - name: go test + run: go test -race -covermode=atomic -coverprofile=coverage.txt ./... + + - name: 覆盖率摘要 + run: go tool cover -func=coverage.txt | tail -n 20 + + - name: 上传覆盖率 + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.txt + + vulncheck: + name: 依赖漏洞扫描 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # govulncheck 只报告「代码确实会走到」的漏洞,比单纯比对版本号更准 + - name: govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + + fuzz: + name: 模糊测试冒烟 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # 每个目标短跑一段时间。解密路径的输入直接来自不可信来源,这里是对 + # "绝不 panic" 承诺的持续验证。 + - name: fuzz 冒烟 + run: | + for target in FuzzDecryptNeverPanics FuzzDecryptWithIVNeverPanics \ + FuzzSymmetricRoundTrip FuzzAEADTamperDetection; do + echo "===== $target =====" + go test -run='^$' -fuzz="^${target}$" -fuzztime=30s . + done + + tidy: + name: go.mod 一致性 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # 确保 go.mod / go.sum 与源码 import 保持一致 + - name: go mod tidy 检查 + run: | + cp go.mod go.mod.bak + cp go.sum go.sum.bak + go mod tidy + diff -u go.mod.bak go.mod + diff -u go.sum.bak go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9791500 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# 构建与测试产物 +*.exe +*.test +*.out +coverage.txt + +# 沙箱化的 Go 工具链缓存/模块缓存(评审环境用,不入库) +/.gopath/ +/.gocache/ +/.gomodcache/ +/.review/ + +# RSA / OpenPGP 测试密钥 +*.pem +*.asc +private.pem +public.pem + +# 编辑器 +.idea/ +.vscode/ +*.swp +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb46911 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The encryptx Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d335dc --- /dev/null +++ b/README.md @@ -0,0 +1,277 @@ +# encryptx + +Go 加密工具库:哈希、对称加密、AEAD、消息认证码、密钥派生、口令哈希、非对称加密与数字签名。 + +模块路径:`code.yun.ink/pkg/encryptx` + +--- + +## 快速开始 + +```go +package main + +import ( + "fmt" + + "code.yun.ink/pkg/encryptx" + "code.yun.ink/pkg/encryptx/aesx" +) + +func main() { + // 推荐路径:直接使用子包,参数强类型、错误明确 + key, err := aesx.GenerateKey(aesx.KeySize256) + if err != nil { + panic(err) + } + + ciphertext, err := aesx.SealGCM([]byte("要保护的数据"), key, nil) + if err != nil { + panic(err) + } + + plaintext, err := aesx.OpenGCM(ciphertext, key, nil) + if err != nil { + panic(err) // 认证失败:密文被篡改、nonce 不对或密钥错误 + } + fmt.Println(string(plaintext)) +} +``` + +配置驱动(算法名由配置/管理后台决定)时使用顶层门面: + +```go +key, _ := encryptx.GenerateKey(encryptx.AES_GCM, nil) + +ciphertext, err := encryptx.Encrypt(encryptx.AES_GCM, "要保护的数据", key, encryptx.Options{ + "aad": "v1|user=42", // 附加认证数据,参与完整性校验但不加密 +}) +if err != nil { + panic(err) +} + +plaintext, err := encryptx.Decrypt(encryptx.AES_GCM, ciphertext, key, encryptx.Options{ + "aad": "v1|user=42", +}) +``` + +> 门面的 `Options` 采用**严格校验**:键名写错、类型不符都会返回错误,不再静默回退到默认值。这是刻意设计——加密参数被静默忽略属于安全隐患。 + +--- + +## 算法选型建议 + +**如果你只想知道该用什么,看这张表就够了。** + +| 需求 | 推荐 | 不要用 | +| --- | --- | --- | +| 对称加密(有 AES 硬件加速) | `AES-GCM`(`aesx.SealGCM`) | ECB、CBC、CFB、OFB、CTR | +| 对称加密(无 AES 加速 / 移动端) | `ChaCha20-Poly1305`、`XChaCha20-Poly1305`(`chachax`) | 同上 | +| 口令存储 | `Argon2id`(`argon2x`)、其次 `scrypt`、`bcrypt` | MD5、SHA-1、SHA-256 裸哈希 | +| 从高熵密钥派生多个子密钥 | `HKDF`(`hkdfx`) | — | +| 从口令派生密钥 | `Argon2id` / `scrypt` / `PBKDF2`(迭代 ≥ 600000) | 无盐、单次哈希 | +| 数字签名 | `Ed25519`(`ed25519x`)、`ECDSA P-256`、`RSA-PSS` | RSA PKCS#1 v1.5 签名 | +| 非对称加密 | `RSA-OAEP`(`rsax`) | RSA PKCS#1 v1.5 加密 | +| 通用哈希 | `SHA-256`、`SHA-512`、`SHA3-256`、`BLAKE2b` | MD5、SHA-1 | +| 消息认证 | `HMAC-SHA256`(`hmacx`),校验务必用 `Verify*` | 用 `==` 比较 MAC | +| 密钥/口令比较 | `hmacx.SecureCompare`、`utilsx.SecureCompare` | `==`、`bytes.Equal` | + +### 已攻破或不推荐的算法 + +以下算法仍然保留,**只为与无法改动的既有系统互操作**(老银行接口、老终端协议、遗留数据库字段)。门面通过 `SecurityLevel()` 与 `IsDeprecated()` 暴露这一信息,管理后台可以据此提示或禁选: + +| 算法 | 状态 | 原因 | +| --- | --- | --- | +| `MD5`、`SHA1` | 已攻破 | 碰撞攻击已实用化(SHAttered) | +| `DES`(全部模式) | 已攻破 | 56 位密钥可被暴力破解 | +| `TDES`(3DES) | 遗留 | 64 位分组,Sweet32;NIST 已弃用 | +| `Blowfish` | 遗留 | 64 位分组,Sweet32 | +| `ECB`(任意分组密码) | 遗留 | 无语义安全性:相同明文块产生相同密文块 | +| `RIPEMD-160` | 遗留 | 上游已标记 deprecated;仅用于如比特币地址等场景 | +| `AES-CBC/CFB/OFB/CTR`、`DES-*` | 无完整性保护 | 密文可被篡改而不被发现;必须另行配合 HMAC | + +--- + +## 安全设计要点 + +理解以下几条可以避免绝大多数误用: + +1. **只有 AEAD 提供完整性保护。** `AES-GCM`、`ChaCha20-Poly1305`、`XChaCha20-Poly1305` 会检测篡改;其余分组密码模式**不会**。需要它们时必须自行采用「先加密后认证」(Encrypt-then-MAC)。 +2. **Nonce 绝不能在同一密钥下重复。** GCM 一旦重用 nonce,攻击者可恢复认证密钥并伪造密文。`aesx.EncryptGCM` 每次都生成随机 nonce 并单独返回;`SealGCM` 把 nonce 前置在密文中,单字段存储最省事。`XChaCha20` 的 24 字节 nonce 允许安全地随机生成,不需要计数器。 +3. **密钥是 `[]byte`,不是字符串。** 不要传入十六进制或 Base64 文本——那是编码,不是密钥。用各包的 `GenerateKey` 拿到密钥,需要落库时再自行编码。 +4. **口令哈希不要自己加盐或再做一次哈希。** `bcryptx`、`argon2x` 的输出已经把算法、参数、随机盐编码在内,直接入库即可。 +5. **比较密钥与 MAC 一律用常量时间函数。** 本库提供 `hmacx.SecureCompare`、`utilsx.SecureCompare`,以及各 `Verify*` 函数。 +6. **所有函数都可并发调用**,不共享可变状态。 +7. **密钥强度策略区分「生成」与「使用」。** `rsax.GenerateKey` 拒绝低于 2048 位的密钥;但解析与使用**既有的** 1024 位密钥是被允许的(否则持有旧密钥的调用方将无法解密自己的历史数据),此时可用 `rsax.IsWeakPrivateKey` / `rsax.IsWeakPublicKey` 得知它已不达标。低于 `rsax.MinUsableKeyBits`(1024,与 Go 标准库下限一致)的密钥一律拒绝。 +8. **bcrypt 只使用口令的前 72 字节。** 本库的哈希与校验**两条路径**都会拒绝超过 72 字节的口令并返回 `bcryptx.ErrPasswordTooLong`,绝不静默截断——静默截断会让共享前 72 字节的不同口令互相通过校验。需要支持更长口令时请改用 `argon2x`。 + +--- + +## 包一览 + +| 包 | 内容 | +| --- | --- | +| `aesx` | AES:ECB/CBC/CFB/OFB/CTR + **GCM** | +| `desx` / `tdesx` / `blowfishx` | DES / 3DES / Blowfish 的同一套模式(仅兼容用) | +| `chachax` | **ChaCha20-Poly1305** 与 **XChaCha20-Poly1305**(AEAD) | +| `cipherx` | 分组密码通用层:PKCS#7、模式实现、`Suite`、统一错误与长度校验 | +| `rsax` | RSA:密钥生成/编解码、**OAEP**(含长明文分块)、PKCS#1 v1.5、PSS 签名 | +| `ed25519x` | **Ed25519** 签名(首选) | +| `ecdsax` | ECDSA P-256/P-384/P-521 签名 | +| `hmacx` | HMAC-SHA1/256/384/512 + 常量时间校验 | +| `md5x` `sha1x` `sha256x` `sha512x` `sha3x` `blake2x` `ripemd160x` | 哈希(含 SHA-224/384、SHA3-224、BLAKE2b/s、SHAKE) | +| `pbkdf2x` `hkdfx` `scryptx` `argon2x` | 密钥派生与口令哈希(Argon2id 带 PHC 编码) | +| `bcryptx` | bcrypt 口令哈希 | +| `gpgx` | OpenPGP 加解密与签名(基于 `ProtonMail/go-crypto`) | +| `base64x` `hexx` | Base64 四种变体、十六进制编解码 | +| `utilsx` | 安全随机数、UUID、API Key、令牌、常量时间比较 | +| `encryptx`(根包) | 配置驱动的统一门面 | + +--- + +## 顶层门面 + +适合「算法由配置决定」的场景。若在写业务代码,优先直接用子包。 + +```go +// 生成密钥(返回值可直接用作 key 参数,无需任何编码转换) +key, err := encryptx.GenerateKey(encryptx.AES_GCM, encryptx.Options{"keyLength": 32}) + +// 加密 / 解密 +ciphertext, err := encryptx.Encrypt(encryptx.AES_GCM, data, key, options) +plaintext, err := encryptx.Decrypt(encryptx.AES_GCM, ciphertext, key, options) + +// 密钥派生:同时返回盐值,两者必须一起保存 +key, salt, err := encryptx.DeriveKey(encryptx.ARGON2ID, password, encryptx.Options{ + "time": 3, "memory": 64 * 1024, "threads": 4, "keyLength": 32, +}) + +// 口令哈希 +encoded, err := encryptx.HashPassword(encryptx.ARGON2ID, password, nil) +ok, err := encryptx.VerifyPassword(encryptx.ARGON2ID, password, encoded, nil) + +// 签名 / 验签 +privatePEM, publicPEM, err := encryptx.GenerateKeyPair(encryptx.ED25519, nil) +signature, err := encryptx.Sign(encryptx.ED25519, message, privatePEM, nil) +err = encryptx.Verify(encryptx.ED25519, message, signature, publicPEM, nil) +``` + +### 输出编码 + +| 算法族 | 编码 | +| --- | --- | +| 哈希、HMAC、`HEX` | 小写十六进制 | +| `BASE64`、`BASE64_URL` | 标准 / URL 安全 Base64 | +| 分组密码(ECB/CBC/CFB/OFB/CTR) | **大写**十六进制,无换行 | +| AEAD(AES-GCM、ChaCha20) | `密文十六进制:nonce十六进制`(小写) | +| RSA 加密、数字签名 | 标准 Base64 | + +### 门面选项 + +| 选项 | 适用算法 | 说明 | +| --- | --- | --- | +| `keyLength` | AES、Blowfish、HMAC、KDF、Argon2 | 密钥/输出长度(字节) | +| `salt` / `saltLength` | PBKDF2、scrypt、Argon2、HKDF | 盐值(十六进制字符串或 `[]byte`)/ 随机盐长度 | +| `iterations` | PBKDF2 | 迭代次数 | +| `n` `r` `p` | scrypt | CPU/内存代价、块大小、并行度 | +| `time` `memory` `threads` | Argon2id | 迭代次数、内存(KiB)、并行度 | +| `cost` | bcrypt | 代价 | +| `iv` | CBC/CFB/OFB/CTR | 显式 IV(十六进制),仅用于兼容固定 IV 的旧系统 | +| `aad` | AES-GCM、ChaCha20 | 附加认证数据 | +| `info` | HKDF | 上下文信息 | +| `bits` | RSA | 密钥位数(默认 2048,最小 2048) | +| `publicKeyFile` / `privateKeyFile` | RSA、签名 | 从文件读取密钥 | + +**字符串形式的 `salt` 与 `iv` 一律按十六进制解析**,需要原始字节时请直接传 `[]byte`。 + +--- + +## 迁移指南(从旧版本升级) + +旧版本存在若干严重缺陷,本次为破坏性重构。逐条对照: + +### 1. 密钥类型:`string` → `[]byte` + +旧版 `GenerateKey` 返回十六进制文本(32 字节密钥变成 64 字符),而加密函数把它当原始密钥字节使用,于是**「生成密钥 → 加密」这条最基本的路径必然失败**,报 `crypto/aes: invalid key size 64`。 + +```go +// 旧 +key, _ := encryptx.GenerateKey(encryptx.AES_CBC, nil) // "ee85f726...f94f" (64 字符) +ciphertext, err := encryptx.Encrypt(encryptx.AES_CBC, data, key, nil) // 必然失败 + +// 新 +key, _ := encryptx.GenerateKey(encryptx.AES_CBC, nil) // []byte, 32 字节 +ciphertext, err := encryptx.Encrypt(encryptx.AES_CBC, data, key, nil) +``` + +### 2. 函数改名 + +| 旧 | 新 | +| --- | --- | +| `md5x.Md5String(s)` | `md5x.Hex([]byte(s))` | +| `md5x.Md5File(p)` | `md5x.HexFile(p)` | +| `md5x.Md5Stream(r)` | `md5x.HexReader(r)` | +| `sha1x.Sha1(s)` | `sha1x.Hex([]byte(s))` | +| `sha256x.Sha256(s)` | `sha256x.Hex256([]byte(s))` | +| `sha512x.Sha512(s)` | `sha512x.Hex512([]byte(s))` | +| `sha3x.Sha3_256(s)` | `sha3x.Hex256([]byte(s))` | +| `sha3x.Shake128(s, n)` | `sha3x.Shake128Hex([]byte(s), n)`(现在返回 error) | +| `hmacx.HMACSHA256(m, k)` | `hmacx.HexSHA256([]byte(m), []byte(k))` | +| `base64x.StdEncode(s)` | `base64x.Encode([]byte(s))` | +| `base64x.StdDecode(s)` | `base64x.Decode(s)`(返回 `[]byte`) | +| `pbkdf2x.DeriveKeyFromPassword(pw, saltHex, it, len)` | `pbkdf2x.DeriveKey([]byte(pw), salt, it, len)`(盐为 `[]byte`) | +| `utilsx.GenerateRandomHexString(n)` | `utilsx.RandomHex(n)` | +| `utilsx.GenerateToken(n)` | `utilsx.RandomToken(n)` | +| `utilsx.HashPassword(pw, salt)` / `VerifyPassword` | **已删除**,改用 `bcryptx` 或 `argon2x` | +| `rsax.RSA_Encrypt(data, path)` | `rsax.EncryptOAEP(pub, data)` 或 `EncryptOAEPLong` | +| `rsax.RSA_Decrypt(data, path)` | `rsax.DecryptOAEP(priv, data)` | +| `rsax.GenerateRSAKey(bits)` | `rsax.GenerateKeyPairPEM(bits)` / `rsax.SaveKeyPair(privPath, pubPath, bits)` | +| `gpgx.Demo()` | `gpgx.Encrypt` / `Decrypt` / `Sign` / `Verify` 等 | +| `aesx.AESCBCEncrypt` / `AESCBCDecrypter` | `aesx.EncryptCBC` / `aesx.DecryptCBC`(旧版带换行且不去填充,已删除) | + +### 3. 行为变化 + +- **不再 panic。** 旧版 `rsax` 有 9 处 `panic`,公钥文件缺失会让进程崩溃。现在全部返回 `error`。 +- **不再有隐藏的文件 I/O。** RSA 加解密改为接收 PEM **内容**;需要按路径加载时用 `rsax.LoadPublicKey`,或门面的 `publicKeyFile` 选项。 +- **RSA 加密默认改为 OAEP(SHA-256)。** 旧版是 PKCS#1 v1.5,旧密文不再兼容。 +- **数据被静默忽略的情况已消除。** 未知选项名、类型不符的选项一律报错;给不使用密钥的算法(`BASE64`、哈希、口令哈希)传 `key` 会返回 `ErrUnexpectedKey`,而不是像旧版本那样静默忽略——后者会让调用方误以为数据受到了密钥保护。 +- **`EncryptType` 数值保持稳定。** 原有 0–24 的取值未变,新算法从 25 起追加。 +- **选项字符串一律按十六进制解析。** 旧版把 `salt` 当任意字符串使用。 + +### 4. 依赖与工具链 + +- Go 版本要求提升至 `go 1.26`。 +- `golang.org/x/crypto` 升级到 `v0.57.0`。 +- `golang.org/x/crypto/openpgp` 已废弃且被 `govulncheck` 判定为「设计上不安全、无修复版本」(`GO-2026-5932`),`gpgx` 已迁移到 `github.com/ProtonMail/go-crypto`。 + +--- + +## 开发 + +```bash +go build ./... +go vet ./... +gofmt -l . +go test ./... +go test -race ./... # 需要 cgo +go test -cover ./... +go test -fuzz=FuzzDecryptNeverPanics -fuzztime=30s . # 模糊测试 +staticcheck ./... # 需要 go install honnef.co/go/tools/cmd/staticcheck@latest +govulncheck ./... +``` + +`encryptx_test.go` 中的四个 `Fuzz*` 目标在普通 `go test` 下以种子语料运行,也可用 `-fuzz` 持续变异。它们集中覆盖**解密路径**——这些函数的输入直接来自网络或数据库等不可信来源,也是历史上出现过 `slice bounds out of range` 崩溃的地方。 + +CI(`.github/workflows/ci.yml`)在每次推送时执行 gofmt、vet、staticcheck、build、`-race` 测试、fuzz 冒烟、govulncheck,并校验 `go.mod`/`go.sum` 与源码 import 一致。 + +### 关于静态检查的豁免 + +`ecdsax/staticcheck.conf` 对 `SA1019` 做了定向豁免。原因是该包必须**读取** `ecdsa.PrivateKey.D` 与 `PublicKey.X/Y` 做判空:标准库在这些字段缺失时会直接空指针 panic(已实测),而本库承诺「任何畸形密钥都返回 error,绝不 panic」。我们只读取、从不修改这些字段,不构成 Go 1.26 弃用说明所警告的风险。 + +其余仅存于本库的废弃 API 用法(Blowfish、RIPEMD-160、CFB/OFB、RSA PKCS#1 v1.5)都在调用点用 `//lint:ignore` 标注了保留理由——它们的存在本身就是「与既有系统互操作」这一需求。 + +--- + +## 许可 + +MIT,见 [LICENSE](LICENSE)。 diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..711736c --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,499 @@ +# 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= <-- 只有它能用 +``` + +用户能生成密钥、能在 `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= <-- 填充未去除 +``` + +调用方拿到带 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= (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="\.gocache"; $env:GOMODCACHE="\.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` 无输出)。 diff --git a/aesx/aesx.go b/aesx/aesx.go index d53df07..e0ac375 100644 --- a/aesx/aesx.go +++ b/aesx/aesx.go @@ -1,7 +1,20 @@ +// Package aesx 提供 AES 分组密码的加解密封装,支持 ECB/CBC/CFB/OFB/CTR 与 GCM。 +// +// AES 是当前使用最广泛的对称分组密码:分组长度固定 16 字节,密钥长度可以是 +// 16(AES-128)、24(AES-192)或 32(AES-256)字节。新代码建议使用 AES-256。 +// +// # 模式选择建议 +// +// - AES-GCM:带认证的加密(AEAD),**首选**,同时保证机密性与完整性; +// - AES-CTR / CFB / OFB:流式模式,**不提供完整性保护**,密文比特可被随意翻转; +// - AES-CBC:需要填充,不提供完整性保护,且存在 padding oracle 风险; +// - AES-ECB:**不具备语义安全性**,相同明文块会产生相同密文块,仅用于兼容旧系统。 +// +// 除 GCM 之外的所有模式都必须另行配合消息认证码(例如 HMAC)使用,并采用 +// 「先加密后认证」(Encrypt-then-MAC)的组合方式,否则无法发现密文篡改。 package aesx import ( - "bytes" "crypto/aes" "crypto/cipher" "crypto/rand" @@ -9,363 +22,368 @@ import ( "errors" "fmt" "io" - "strings" + + "code.yun.ink/pkg/encryptx/cipherx" ) -// AES-256-ECB 加密,返回大写的十六进制字符串 -func EncryptECBHex(input, key string) (string, error) { - // 创建AES加密器 - block, err := aes.NewCipher([]byte(key)) - if err != nil { - return "", err - } +const ( + // KeySize128 是 AES-128 的密钥长度(字节)。 + KeySize128 = 16 + // KeySize192 是 AES-192 的密钥长度(字节)。 + KeySize192 = 24 + // KeySize256 是 AES-256 的密钥长度(字节)。 + KeySize256 = 32 + // BlockSize 是 AES 的分组长度(字节)。 + BlockSize = aes.BlockSize + // GCMNonceSize 是 GCM 推荐使用的 nonce 长度(字节)。 + // + // 12 字节是与 GCM 设计匹配的「标准」长度:其他长度会先在内部做 GHASH 派生, + // 既慢又更容易因实现差异导致互通问题。 + GCMNonceSize = 12 + // GCMTagSize 是 GCM 认证标签的长度(字节)。 + GCMTagSize = 16 +) - // 对输入数据进行PKCS7填充 - paddedInput := PKCS7Padding([]byte(input), block.BlockSize()) +// GCM 相关的错误定义。 +var ( + // ErrInvalidNonceSize 表示 GCM 的 nonce 长度不等于 GCMNonceSize。 + ErrInvalidNonceSize = errors.New("aesx: invalid GCM nonce size") + // ErrCiphertextTooShort 表示密文长度不足以容纳认证标签。 + ErrCiphertextTooShort = errors.New("aesx: ciphertext is shorter than the GCM tag") + // ErrAuthenticationFailed 表示 GCM 认证失败:密文、nonce 或 AAD 被篡改, + // 或密钥错误。解密结果不可信,调用方不得使用任何返回的明文。 + ErrAuthenticationFailed = errors.New("aesx: GCM authentication failed") +) - // ECB模式加密 - encrypted := make([]byte, len(paddedInput)) - for i := 0; i < len(paddedInput); i += aes.BlockSize { - block.Encrypt(encrypted[i:i+aes.BlockSize], paddedInput[i:i+aes.BlockSize]) - } +// Suite 绑定 AES 的分组构造方式与合法密钥长度,模式实现由 cipherx 提供。 +// +// 导出它是为了让调用方能够以统一方式驱动任意模式(例如由配置决定模式时), +// 而无需在本包与 cipherx 之间写 switch。 +var Suite = cipherx.NewSuite("AES", aes.NewCipher, BlockSize, KeySize128, KeySize192, KeySize256) - // 转换为十六进制并大写 - result := hex.EncodeToString(encrypted) - return strings.ToUpper(result), nil +// ValidKeySize 报告 keySize 是否为合法的 AES 密钥长度。 +func ValidKeySize(keySize int) bool { + return keySize == KeySize128 || keySize == KeySize192 || keySize == KeySize256 } -// AES-256-ECB 解密,输入大写的十六进制字符串 -func DecryptECBHex(encryptedHex, key string) (string, error) { - encrypted, err := hex.DecodeString(encryptedHex) - if err != nil { - return "", fmt.Errorf("hex解码失败: %v", err) +// GenerateKey 用 crypto/rand 生成指定长度的 AES 密钥。 +// +// keySize 必须是 16、24 或 32;调用方应妥善保存返回值,密钥一旦丢失或泄漏, +// 已加密的数据将无法保护。 +func GenerateKey(keySize int) ([]byte, error) { + if !ValidKeySize(keySize) { + return nil, fmt.Errorf("aesx: 密钥长度 %d 非法, 允许 16/24/32: %w", keySize, cipherx.ErrInvalidKeySize) } - - block, err := aes.NewCipher([]byte(key)) - if err != nil { - return "", fmt.Errorf("创建AES解密器失败: %v", err) + key := make([]byte, keySize) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return nil, fmt.Errorf("aesx: 生成密钥失败: %w", err) } - - // ECB模式解密 - 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]) - } - - // 去除PKCS7填充 - unpaddedData, err := PKCS7UnPadding(decrypted) - if err != nil { - return "", fmt.Errorf("去除填充失败: %v", err) - } - return string(unpaddedData), nil + return key, nil } -// AES-256-CBC 加密 -func EncryptCBCHex(plaintext, key string) (string, error) { - block, err := aes.NewCipher([]byte(key)) +// GenerateKeyHex 生成 AES 密钥并以十六进制字符串返回,便于写入配置文件。 +// +// 注意:返回值是**编码后的文本**,不能直接当密钥使用。读回后必须先解码 +// (encoding/hex 的 DecodeString,或本库的 hexx.Decode),再传给 Encrypt/Decrypt; +// 直接把字符串当密钥传入会因长度翻倍而被拒绝。 +func GenerateKeyHex(keySize int) (string, error) { + key, err := GenerateKey(keySize) if err != nil { return "", err } - blockSize := block.BlockSize() - plainBytes := PKCS7Padding([]byte(plaintext), blockSize) - - // 创建密文字节切片,包含IV空间 - cipherbyte := make([]byte, aes.BlockSize+len(plainBytes)) - - // 生成随机IV - iv := cipherbyte[:aes.BlockSize] - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return "", err - } - - // 创建CBC模式加密器 - blockMode := cipher.NewCBCEncrypter(block, iv) - - // 加密数据 - blockMode.CryptBlocks(cipherbyte[aes.BlockSize:], plainBytes) - - // 转换为十六进制并大写 - result := hex.EncodeToString(cipherbyte) - return strings.ToUpper(result), nil + return hex.EncodeToString(key), nil } -// AES-256-CBC 解密 -func DecryptCBCHex(encryptedHex, key string) (string, error) { - cipherbyte, err := hex.DecodeString(encryptedHex) - if err != nil { - return "", fmt.Errorf("hex解码失败: %v", err) - } +// ---- ECB ---- +// +// 警告:ECB 不具备语义安全性,相同的明文分组会产生相同的密文分组,攻击者可以 +// 借此识别明文中的重复结构与规律。仅为兼容既有系统而提供。 - if len(cipherbyte) < aes.BlockSize { - return "", errors.New("ciphertext too short") - } - - block, err := aes.NewCipher([]byte(key)) - if err != nil { - return "", err - } - - iv := cipherbyte[:aes.BlockSize] - cipherbyte = cipherbyte[aes.BlockSize:] - if len(cipherbyte)%aes.BlockSize != 0 { - return "", errors.New("ciphertext is not a multiple of the block size") - } - - blockMode := cipher.NewCBCDecrypter(block, iv) - origData := make([]byte, len(cipherbyte)) - blockMode.CryptBlocks(origData, cipherbyte) - origBy, err := PKCS7UnPadding(origData) - if err != nil { - return "", err - } - return string(origBy), nil +// EncryptECB 使用 AES-ECB 加密并做 PKCS#7 填充,返回大写十六进制字符串。 +func EncryptECB(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.ECB, plaintext, key) } -/* -AES CBC 加密 -key:加密key -plaintext:加密明文 -ciphertext:解密返回字节字符串[ 整型以十六进制方式显示] -*/ -func AESCBCEncrypt(key, plaintext string) (ciphertext string, err error) { - - block, err := aes.NewCipher([]byte(key)) - if err != nil { - return "", err - } - - padding := aes.BlockSize - (len(plaintext) % aes.BlockSize) - padtest := bytes.Repeat([]byte{byte(padding)}, padding) - plainBytes := append([]byte(plaintext), padtest...) - - cipherbyte := make([]byte, aes.BlockSize+len(plainBytes)) - iv := cipherbyte[:aes.BlockSize] - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return "", err - } - - mode := cipher.NewCBCEncrypter(block, iv) - mode.CryptBlocks(cipherbyte[aes.BlockSize:], plainBytes) - - ciphertext = fmt.Sprintf("%x\n", cipherbyte) - return +// DecryptECB 解密 AES-ECB 密文。 +func DecryptECB(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.ECB, ciphertext, key) } -// AES CBC 解码 -// key:解密key -// ciphertext:加密返回的串 -// plaintext:解密后的字符串 -func AESCBCDecrypter(key, ciphertext string) (plaintext string, err error) { - cipherbyte, _ := hex.DecodeString(ciphertext) - keybyte := []byte(key) - block, err := aes.NewCipher(keybyte) - if err != nil { - return "", err - } - if len(cipherbyte) < aes.BlockSize { - return "", errors.New("ciphertext too short") - } - - iv := cipherbyte[:aes.BlockSize] - cipherbyte = cipherbyte[aes.BlockSize:] - if len(cipherbyte)%aes.BlockSize != 0 { - return "", errors.New("ciphertext is not a multiple of the block size") - } - - mode := cipher.NewCBCDecrypter(block, iv) - mode.CryptBlocks(cipherbyte, cipherbyte) - - //fmt.Printf("%s\n", ciphertext) - plaintext = string(cipherbyte[:]) - return +// EncryptECBHex 与 EncryptECB 相同,返回大写十六进制字符串。 +func EncryptECBHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.ECB, plaintext, key) } -// AES GCM 加密 -// key:加密key -// plaintext:加密明文 -// ciphertext:解密返回字节字符串[ 整型以十六进制方式显示] -func EncryptGCM(key, plaintext string) (ciphertext, noncetext string, err error) { - plainbyte := []byte(plaintext) - keybyte := []byte(key) - block, err := aes.NewCipher(keybyte) - if err != nil { - return "", "", err - } +// DecryptECBHex 解密十六进制 AES-ECB 密文,大小写均可。 +func DecryptECBHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.ECB, ciphertextHex, key) +} - // 由于存在重复的风险,请勿使用给定密钥使用超过2^32个随机值。 - nonce := make([]byte, 12) +// ---- CBC ---- + +// EncryptCBC 使用 AES-CBC 加密,随机 IV 前置在密文之前。 +func EncryptCBC(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.CBC, plaintext, key) +} + +// DecryptCBC 解密 AES-CBC 密文(IV 前置格式)。 +func DecryptCBC(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.CBC, ciphertext, key) +} + +// EncryptCBCHex 与 EncryptCBC 相同,返回大写十六进制字符串。 +func EncryptCBCHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.CBC, plaintext, key) +} + +// DecryptCBCHex 解密十六进制 AES-CBC 密文。 +func DecryptCBCHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.CBC, ciphertextHex, key) +} + +// EncryptCBCWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +// +// 仅用于与规定了固定 IV 的既有系统互操作;自行管理 IV 时必须保证 IV 随机且 +// 不可预测,并且在同一密钥下绝不重用。 +func EncryptCBCWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.CBC, plaintext, key, iv) +} + +// DecryptCBCWithIV 使用调用方指定的 IV 解密。 +func DecryptCBCWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.CBC, ciphertext, key, iv) +} + +// ---- CFB ---- + +// EncryptCFB 使用 AES-CFB 加密,随机 IV 前置在密文之前。 +func EncryptCFB(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.CFB, plaintext, key) +} + +// DecryptCFB 解密 AES-CFB 密文(IV 前置格式)。 +func DecryptCFB(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.CFB, ciphertext, key) +} + +// EncryptCFBHex 与 EncryptCFB 相同,返回大写十六进制字符串。 +func EncryptCFBHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.CFB, plaintext, key) +} + +// DecryptCFBHex 解密十六进制 AES-CFB 密文。 +func DecryptCFBHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.CFB, ciphertextHex, key) +} + +// EncryptCFBWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +func EncryptCFBWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.CFB, plaintext, key, iv) +} + +// DecryptCFBWithIV 使用调用方指定的 IV 解密。 +func DecryptCFBWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.CFB, ciphertext, key, iv) +} + +// ---- OFB ---- + +// EncryptOFB 使用 AES-OFB 加密,随机 IV 前置在密文之前。 +func EncryptOFB(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.OFB, plaintext, key) +} + +// DecryptOFB 解密 AES-OFB 密文(IV 前置格式)。 +func DecryptOFB(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.OFB, ciphertext, key) +} + +// EncryptOFBHex 与 EncryptOFB 相同,返回大写十六进制字符串。 +func EncryptOFBHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.OFB, plaintext, key) +} + +// DecryptOFBHex 解密十六进制 AES-OFB 密文。 +func DecryptOFBHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.OFB, ciphertextHex, key) +} + +// EncryptOFBWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +func EncryptOFBWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.OFB, plaintext, key, iv) +} + +// DecryptOFBWithIV 使用调用方指定的 IV 解密。 +func DecryptOFBWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.OFB, ciphertext, key, iv) +} + +// ---- CTR ---- + +// EncryptCTR 使用 AES-CTR 加密,随机 IV 前置在密文之前。 +func EncryptCTR(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.CTR, plaintext, key) +} + +// DecryptCTR 解密 AES-CTR 密文(IV 前置格式)。 +// +// CTR 是对合的:加密与解密是同一个运算,因此本函数与 EncryptCTR 等价。 +func DecryptCTR(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.CTR, ciphertext, key) +} + +// EncryptCTRHex 与 EncryptCTR 相同,返回大写十六进制字符串。 +func EncryptCTRHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.CTR, plaintext, key) +} + +// DecryptCTRHex 解密十六进制 AES-CTR 密文。 +func DecryptCTRHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.CTR, ciphertextHex, key) +} + +// EncryptCTRWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +func EncryptCTRWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.CTR, plaintext, key, iv) +} + +// DecryptCTRWithIV 使用调用方指定的 IV 解密。 +func DecryptCTRWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.CTR, ciphertext, key, iv) +} + +// ---- GCM ---- +// +// GCM 是本包唯一提供完整性保护的 AEAD 模式,应作为默认选择。 + +func newGCM(key []byte) (cipher.AEAD, error) { + block, err := Suite.NewBlock(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("aesx: 创建 GCM 失败: %w", err) + } + return aead, nil +} + +// NewGCMNonce 生成一个随机的 12 字节 GCM nonce。 +// +// nonce 本身无需保密,但**在同一密钥下绝不能重复**:GCM 一旦重用 nonce, +// 攻击者即可恢复认证密钥并伪造密文。使用同一个密钥加密超过 2^32 条消息时 +// 应更换密钥。 +func NewGCMNonce() ([]byte, error) { + nonce := make([]byte, GCMNonceSize) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", "", err + return nil, fmt.Errorf("aesx: 生成 GCM nonce 失败: %w", err) } + return nonce, nil +} - aesgcm, err := cipher.NewGCM(block) +// EncryptGCM 使用 AES-GCM 加密,随机生成 nonce 并单独返回。 +// +// aad(附加认证数据)会参与完整性校验但不被加密,可用于绑定协议头、版本号、 +// 消息 ID 等上下文;不需要时传 nil。密文返回值已包含认证标签。 +// +// 调用方必须把 nonce 与密文一起保存或传输。 +func EncryptGCM(plaintext, key, aad []byte) (ciphertext, nonce []byte, err error) { + aead, err := newGCM(key) + if err != nil { + return nil, nil, err + } + nonce, err = NewGCMNonce() + if err != nil { + return nil, nil, err + } + return aead.Seal(nil, nonce, plaintext, aad), nonce, nil +} + +// EncryptGCMWithNonce 使用调用方提供的 nonce 加密,返回值不含 nonce。 +// +// 仅用于与既有系统互操作。传入的 nonce 必须与密文一同保存,且在同一密钥下 +// 绝不重复使用。 +func EncryptGCMWithNonce(plaintext, key, nonce, aad []byte) ([]byte, error) { + aead, err := newGCM(key) + if err != nil { + return nil, err + } + if len(nonce) != aead.NonceSize() { + return nil, fmt.Errorf("aesx: GCM nonce 长度 %d, 期望 %d: %w", + len(nonce), aead.NonceSize(), ErrInvalidNonceSize) + } + return aead.Seal(nil, nonce, plaintext, aad), nil +} + +// DecryptGCM 使用 AES-GCM 解密并验证完整性。 +// +// 认证失败时返回包装了 ErrAuthenticationFailed 的错误,此时**返回的明文为 +// nil**,调用方绝不可使用任何部分解密结果。 +func DecryptGCM(ciphertext, key, nonce, aad []byte) ([]byte, error) { + aead, err := newGCM(key) + if err != nil { + return nil, err + } + if len(nonce) != aead.NonceSize() { + return nil, fmt.Errorf("aesx: GCM nonce 长度 %d, 期望 %d: %w", + len(nonce), aead.NonceSize(), ErrInvalidNonceSize) + } + // 长度校验必须在 Open 之前:认证标签至少要占 Overhead 字节。 + if len(ciphertext) < aead.Overhead() { + return nil, fmt.Errorf("aesx: GCM 密文长度 %d 小于认证标签长度 %d: %w", + len(ciphertext), aead.Overhead(), ErrCiphertextTooShort) + } + plaintext, err := aead.Open(nil, nonce, ciphertext, aad) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrAuthenticationFailed, err) + } + return plaintext, nil +} + +// SealGCM 加密并把 nonce 前置在密文中,返回 nonce||密文。 +// +// 单字段存储场景(数据库一个列、一个 token)用这个函数最省事:nonce 随密文 +// 一起存放,不会漏掉。 +func SealGCM(plaintext, key, aad []byte) ([]byte, error) { + ciphertext, nonce, err := EncryptGCM(plaintext, key, aad) + if err != nil { + return nil, err + } + sealed := make([]byte, 0, len(nonce)+len(ciphertext)) + sealed = append(sealed, nonce...) + sealed = append(sealed, ciphertext...) + return sealed, nil +} + +// OpenGCM 解密 SealGCM 产生的数据(nonce||密文 格式)。 +func OpenGCM(sealed, key, aad []byte) ([]byte, error) { + if len(sealed) < GCMNonceSize+GCMTagSize { + return nil, fmt.Errorf("aesx: 数据长度 %d 不足以容纳 nonce 与认证标签: %w", + len(sealed), ErrCiphertextTooShort) + } + nonce := sealed[:GCMNonceSize] + ciphertext := sealed[GCMNonceSize:] + return DecryptGCM(ciphertext, key, nonce, aad) +} + +// EncryptGCMHex 与 EncryptGCM 相同,但密文与 nonce 均以十六进制字符串返回。 +func EncryptGCMHex(plaintext, key, aad []byte) (ciphertextHex, nonceHex string, err error) { + ciphertext, nonce, err := EncryptGCM(plaintext, key, aad) if err != nil { return "", "", err } - - cipherbyte := aesgcm.Seal(nil, nonce, plainbyte, nil) - ciphertext = fmt.Sprintf("%x\n", cipherbyte) - noncetext = fmt.Sprintf("%x\n", nonce) - return + return hex.EncodeToString(ciphertext), hex.EncodeToString(nonce), nil } -// AES GCM 解码 -// key:解密key -// ciphertext:加密返回的串 -// plaintext:解密后的字符串 -func DecryptGCM(key, ciphertext, noncetext string) (plaintext string, err error) { - cipherbyte, _ := hex.DecodeString(ciphertext) - nonce, _ := hex.DecodeString(noncetext) - keybyte := []byte(key) - block, err := aes.NewCipher(keybyte) +// DecryptGCMHex 解密十六进制形式的 GCM 密文与 nonce。 +func DecryptGCMHex(ciphertextHex, nonceHex string, key, aad []byte) ([]byte, error) { + ciphertext, err := hex.DecodeString(ciphertextHex) if err != nil { - return "", err + return nil, fmt.Errorf("aesx: 密文十六进制解码失败: %w", err) } - - aesgcm, err := cipher.NewGCM(block) + nonce, err := hex.DecodeString(nonceHex) if err != nil { - return "", err + return nil, fmt.Errorf("aesx: nonce 十六进制解码失败: %w", err) } - - plainbyte, err := aesgcm.Open(nil, nonce, cipherbyte, nil) - if err != nil { - return "", err - } - - //fmt.Printf("%s\n", ciphertext) - plaintext = string(plainbyte[:]) - return + return DecryptGCM(ciphertext, key, nonce, aad) } - -// AES CFB 加密 -func EncryptCFBHex(plaintext, key string) (string, error) { - block, err := aes.NewCipher([]byte(key)) - if err != nil { - return "", err - } - - // 创建密文字节切片,包含IV空间 - cipherbyte := make([]byte, aes.BlockSize+len(plaintext)) - - // 生成随机IV - iv := cipherbyte[:aes.BlockSize] - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return "", err - } - - // 创建CFB模式加密器 - stream := cipher.NewCFBEncrypter(block, iv) - stream.XORKeyStream(cipherbyte[aes.BlockSize:], []byte(plaintext)) - - // 转换为十六进制并大写 - result := hex.EncodeToString(cipherbyte) - return strings.ToUpper(result), nil -} - -// AES CFB 解密 -func DecryptCFBHex(encryptedHex, key string) (string, error) { - cipherbyte, err := hex.DecodeString(encryptedHex) - if err != nil { - return "", fmt.Errorf("hex解码失败: %v", err) - } - - if len(cipherbyte) < aes.BlockSize { - return "", errors.New("ciphertext too short") - } - - block, err := aes.NewCipher([]byte(key)) - if err != nil { - return "", err - } - - // 获取IV - iv := cipherbyte[:aes.BlockSize] - cipherbyte = cipherbyte[aes.BlockSize:] - - // 创建CFB模式解密器 - stream := cipher.NewCFBDecrypter(block, iv) - stream.XORKeyStream(cipherbyte, cipherbyte) - - return string(cipherbyte), nil -} - -// AES OFB 加密 -func EncryptOFBHex(plaintext, key string) (string, error) { - block, err := aes.NewCipher([]byte(key)) - if err != nil { - return "", err - } - - // 创建密文字节切片,包含IV空间 - cipherbyte := make([]byte, aes.BlockSize+len(plaintext)) - - // 生成随机IV - iv := cipherbyte[:aes.BlockSize] - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return "", err - } - - // 创建OFB模式加密器 - stream := cipher.NewOFB(block, iv) - stream.XORKeyStream(cipherbyte[aes.BlockSize:], []byte(plaintext)) - - // 转换为十六进制并大写 - result := hex.EncodeToString(cipherbyte) - return strings.ToUpper(result), nil -} - -// AES OFB 解密 -func DecryptOFBHex(encryptedHex, key string) (string, error) { - cipherbyte, err := hex.DecodeString(encryptedHex) - if err != nil { - return "", fmt.Errorf("hex解码失败: %v", err) - } - - if len(cipherbyte) < aes.BlockSize { - return "", errors.New("ciphertext too short") - } - - block, err := aes.NewCipher([]byte(key)) - if err != nil { - return "", err - } - - // 获取IV - iv := cipherbyte[:aes.BlockSize] - cipherbyte = cipherbyte[aes.BlockSize:] - - // 创建OFB模式解密器 - stream := cipher.NewOFB(block, iv) - stream.XORKeyStream(cipherbyte, cipherbyte) - - return string(cipherbyte), nil -} - -// PKCS7补码 -func PKCS7Padding(ciphertext []byte, blockSize int) []byte { - padding := blockSize - len(ciphertext)%blockSize - padtext := bytes.Repeat([]byte{byte(padding)}, padding) - return append(ciphertext, padtext...) -} - -// 去除PKCS7补码 -func PKCS7UnPadding(data []byte) ([]byte, error) { - length := len(data) - if length == 0 { - return nil, errors.New("ciphertext is empty") - } - - unpadding := int(data[length-1]) - if unpadding > length || unpadding == 0 { - return nil, errors.New("unpadding size is invalid") - } - - // 检查填充是否正确 - for i := length - unpadding; i < length; i++ { - if data[i] != byte(unpadding) { - return nil, errors.New("invalid padding") - } - } - - return data[:(length - unpadding)], nil -} \ No newline at end of file diff --git a/aesx/aesx_test.go b/aesx/aesx_test.go index 82ce40e..0da35cd 100644 --- a/aesx/aesx_test.go +++ b/aesx/aesx_test.go @@ -1,117 +1,478 @@ package aesx_test import ( - "fmt" + "bytes" + "errors" "testing" "code.yun.ink/pkg/encryptx/aesx" + "code.yun.ink/pkg/encryptx/cipherx" ) -func TestGCM(t *testing.T) { - key := "example key 1234" - plaintext := "exampleplaintext" - - // aesx.AesEncrypt(plaintext,key) - // aesx.AesDecrypt() - - ciphertext, err := aesx.AESCBCEncrypt(key, plaintext) - fmt.Println(ciphertext, err) - - plaintext, err = aesx.AESCBCDecrypter(key, ciphertext) - fmt.Println(plaintext, err) - ///GCM - noncetext := "" - ciphertext, noncetext, err = aesx.EncryptGCM(key, plaintext) - fmt.Println(ciphertext, err) - - plaintext, err = aesx.DecryptGCM(key, ciphertext, noncetext) - fmt.Println(plaintext, err) +func testKey(t *testing.T, size int) []byte { + t.Helper() + key, err := aesx.GenerateKey(size) + if err != nil { + t.Fatalf("GenerateKey(%d) 失败: %v", size, err) + } + if len(key) != size { + t.Fatalf("GenerateKey(%d) 返回长度 %d", size, len(key)) + } + return key } -func TestECB(t *testing.T) { - key := "example key 1234" - plaintext := "exampleplaintext" - - // 测试ECB加密和解密 - encrypted, err := aesx.EncryptECBHex(plaintext, key) - if err != nil { - t.Fatalf("ECB加密失败: %v", err) +func TestGenerateKey(t *testing.T) { + for _, size := range []int{aesx.KeySize128, aesx.KeySize192, aesx.KeySize256} { + key := testKey(t, size) + if !aesx.ValidKeySize(len(key)) { + t.Errorf("ValidKeySize(%d) = false, 期望 true", len(key)) + } } - fmt.Printf("ECB加密结果: %s\n", encrypted) - decrypted, err := aesx.DecryptECBHex(encrypted, key) - if err != nil { - t.Fatalf("ECB解密失败: %v", err) + // 非法长度必须被拒绝 + for _, size := range []int{-1, 0, 1, 15, 17, 31, 33, 64} { + if _, err := aesx.GenerateKey(size); err == nil { + t.Errorf("GenerateKey(%d) 期望错误, 得到 nil", size) + } + if aesx.ValidKeySize(size) { + t.Errorf("ValidKeySize(%d) = true, 期望 false", size) + } } - fmt.Printf("ECB解密结果: %s\n", decrypted) - if decrypted != plaintext { - t.Fatalf("ECB解密结果不匹配,原文: %s, 解密结果: %s", plaintext, decrypted) + // 两次生成的密钥不得相同 + k1, _ := aesx.GenerateKey(aesx.KeySize256) + k2, _ := aesx.GenerateKey(aesx.KeySize256) + if bytes.Equal(k1, k2) { + t.Error("两次 GenerateKey 产生了相同密钥") + } + + hexKey, err := aesx.GenerateKeyHex(aesx.KeySize256) + if err != nil { + t.Fatalf("GenerateKeyHex 失败: %v", err) + } + if len(hexKey) != 64 { + t.Errorf("GenerateKeyHex(32) 长度 = %d, 期望 64", len(hexKey)) + } + if _, err := aesx.GenerateKeyHex(7); err == nil { + t.Error("GenerateKeyHex(7) 期望错误") } } -func TestCBC(t *testing.T) { - key := "example key 1234" - plaintext := "exampleplaintext" +// TestKeySizeValidation 是 P0-2 的回归测试。 +// +// 旧实现的 GenerateKey 返回十六进制字符串(32 字节 → 64 字符),而加密函数把 +// 入参直接当原始密钥字节用,导致「生成密钥 → 加密」这条最基本的路径必然失败 +// (invalid key size 64)。现在密钥统一为 []byte,两者天然一致。 +func TestKeySizeValidation(t *testing.T) { + plaintext := []byte("hello world") - // 测试CBC加密和解密 - encrypted, err := aesx.EncryptCBCHex(plaintext, key) - if err != nil { - t.Fatalf("CBC加密失败: %v", err) + for _, wrongLen := range []int{5, 15, 17, 31, 33, 64} { + key := bytes.Repeat([]byte{0x01}, wrongLen) + if _, err := aesx.EncryptCBC(plaintext, key); !errors.Is(err, cipherx.ErrInvalidKeySize) { + t.Errorf("密钥长度 %d: 期望 ErrInvalidKeySize, 得到 %v", wrongLen, err) + } } - fmt.Printf("CBC加密结果: %s\n", encrypted) - decrypted, err := aesx.DecryptCBCHex(encrypted, key) + // GenerateKey 的返回值必须能直接用于加密(旧实现做不到) + key, err := aesx.GenerateKey(aesx.KeySize256) if err != nil { - t.Fatalf("CBC解密失败: %v", err) + t.Fatalf("GenerateKey 失败: %v", err) } - fmt.Printf("CBC解密结果: %s\n", decrypted) - - if decrypted != plaintext { - t.Fatalf("CBC解密结果不匹配,原文: %s, 解密结果: %s", plaintext, decrypted) + ciphertext, err := aesx.EncryptCBC(plaintext, key) + if err != nil { + t.Fatalf("用 GenerateKey 生成的密钥加密失败: %v", err) + } + decrypted, err := aesx.DecryptCBC(ciphertext, key) + if err != nil { + t.Fatalf("解密失败: %v", err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("往返不一致: 得到 %q, 期望 %q", decrypted, plaintext) } } -func TestCFB(t *testing.T) { - key := "example key 1234" - plaintext := "exampleplaintext" +func TestAllModesRoundTrip(t *testing.T) { + key := testKey(t, aesx.KeySize256) - // 测试CFB加密和解密 - encrypted, err := aesx.EncryptCFBHex(plaintext, key) - if err != nil { - t.Fatalf("CFB加密失败: %v", err) + encryptors := map[string]func([]byte, []byte) ([]byte, error){ + "ECB": aesx.EncryptECB, "CBC": aesx.EncryptCBC, "CFB": aesx.EncryptCFB, + "OFB": aesx.EncryptOFB, "CTR": aesx.EncryptCTR, } - fmt.Printf("CFB加密结果: %s\n", encrypted) - - decrypted, err := aesx.DecryptCFBHex(encrypted, key) - if err != nil { - t.Fatalf("CFB解密失败: %v", err) + decryptors := map[string]func([]byte, []byte) ([]byte, error){ + "ECB": aesx.DecryptECB, "CBC": aesx.DecryptCBC, "CFB": aesx.DecryptCFB, + "OFB": aesx.DecryptOFB, "CTR": aesx.DecryptCTR, } - fmt.Printf("CFB解密结果: %s\n", decrypted) - if decrypted != plaintext { - t.Fatalf("CFB解密结果不匹配,原文: %s, 解密结果: %s", plaintext, decrypted) + plaintexts := [][]byte{ + {}, []byte("a"), []byte("0123456789abcde"), []byte("0123456789abcdef"), + []byte("0123456789abcdefg"), bytes.Repeat([]byte("AES"), 500), + } + + for name, encrypt := range encryptors { + for i, plaintext := range plaintexts { + ciphertext, err := encrypt(plaintext, key) + if err != nil { + t.Fatalf("%s 明文#%d 加密失败: %v", name, i, err) + } + decrypted, err := decryptors[name](ciphertext, key) + if err != nil { + t.Fatalf("%s 明文#%d 解密失败: %v", name, i, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s 明文#%d 往返不一致", name, i) + } + } } } -func TestOFB(t *testing.T) { - key := "example key 1234" - plaintext := "exampleplaintext" +func TestHexAPIRoundTrip(t *testing.T) { + key := testKey(t, aesx.KeySize128) + plaintext := []byte("十六进制 API 往返测试") - // 测试OFB加密和解密 - encrypted, err := aesx.EncryptOFBHex(plaintext, key) + cases := []struct { + name string + encrypt func([]byte, []byte) (string, error) + decrypt func(string, []byte) ([]byte, error) + }{ + {"ECB", aesx.EncryptECBHex, aesx.DecryptECBHex}, + {"CBC", aesx.EncryptCBCHex, aesx.DecryptCBCHex}, + {"CFB", aesx.EncryptCFBHex, aesx.DecryptCFBHex}, + {"OFB", aesx.EncryptOFBHex, aesx.DecryptOFBHex}, + {"CTR", aesx.EncryptCTRHex, aesx.DecryptCTRHex}, + } + + for _, c := range cases { + hexStr, err := c.encrypt(plaintext, key) + if err != nil { + t.Fatalf("%s Hex 加密失败: %v", c.name, err) + } + // 输出必须是大写十六进制,且不含换行符(旧实现会带上 \n) + if bytes.ContainsAny([]byte(hexStr), "\n\r \t") { + t.Errorf("%s Hex 输出含空白字符: %q", c.name, hexStr) + } + for _, r := range hexStr { + if (r < '0' || r > '9') && (r < 'A' || r > 'F') { + t.Fatalf("%s Hex 输出含非大写十六进制字符: %q", c.name, hexStr) + } + } + + decrypted, err := c.decrypt(hexStr, key) + if err != nil { + t.Fatalf("%s Hex 解密失败: %v", c.name, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s Hex 往返不一致", c.name) + } + } +} + +func TestWithIVRoundTrip(t *testing.T) { + key := testKey(t, aesx.KeySize256) + iv := bytes.Repeat([]byte{0x5A}, aesx.BlockSize) + plaintext := []byte("fixed iv interop") + + cases := []struct { + name string + encrypt func([]byte, []byte, []byte) ([]byte, error) + decrypt func([]byte, []byte, []byte) ([]byte, error) + }{ + {"CBC", aesx.EncryptCBCWithIV, aesx.DecryptCBCWithIV}, + {"CFB", aesx.EncryptCFBWithIV, aesx.DecryptCFBWithIV}, + {"OFB", aesx.EncryptOFBWithIV, aesx.DecryptOFBWithIV}, + {"CTR", aesx.EncryptCTRWithIV, aesx.DecryptCTRWithIV}, + } + for _, c := range cases { + ciphertext, err := c.encrypt(plaintext, key, iv) + if err != nil { + t.Fatalf("%s WithIV 加密失败: %v", c.name, err) + } + decrypted, err := c.decrypt(ciphertext, key, iv) + if err != nil { + t.Fatalf("%s WithIV 解密失败: %v", c.name, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s WithIV 往返不一致", c.name) + } + // 错误 IV 下,CBC 通常会因填充校验失败报错,流式模式则会静默返回垃圾 + // 明文(这正是这些模式不提供完整性保护的体现)。无论哪种情况,都绝不 + // 允许还原出正确明文。 + wrongIV, err := c.decrypt(ciphertext, key, bytes.Repeat([]byte{0x00}, aesx.BlockSize)) + if err == nil && bytes.Equal(wrongIV, plaintext) { + t.Errorf("%s 使用错误 IV 却解出了正确明文", c.name) + } + } +} + +// TestECBIsDeterministic 记录 ECB 的固有弱点(相同明文块 → 相同密文块), +// 与 CBC 对比以证明文档中的安全警告属实。 +func TestECBIsDeterministic(t *testing.T) { + key := testKey(t, aesx.KeySize256) + plaintext := bytes.Repeat([]byte("0123456789abcdef"), 4) + + ecb1, err := aesx.EncryptECB(plaintext, key) if err != nil { - t.Fatalf("OFB加密失败: %v", err) + t.Fatalf("ECB 加密失败: %v", err) } - fmt.Printf("OFB加密结果: %s\n", encrypted) - - decrypted, err := aesx.DecryptOFBHex(encrypted, key) + ecb2, err := aesx.EncryptECB(plaintext, key) if err != nil { - t.Fatalf("OFB解密失败: %v", err) + t.Fatalf("ECB 加密失败: %v", err) + } + if !bytes.Equal(ecb1, ecb2) { + t.Error("ECB 应当是确定性的(这正是它的弱点)") } - fmt.Printf("OFB解密结果: %s\n", decrypted) - if decrypted != plaintext { - t.Fatalf("OFB解密结果不匹配,原文: %s, 解密结果: %s", plaintext, decrypted) + cbc1, err := aesx.EncryptCBC(plaintext, key) + if err != nil { + t.Fatalf("CBC 加密失败: %v", err) } -} \ No newline at end of file + cbc2, err := aesx.EncryptCBC(plaintext, key) + if err != nil { + t.Fatalf("CBC 加密失败: %v", err) + } + if bytes.Equal(cbc1, cbc2) { + t.Error("CBC 使用了随机 IV,两次加密结果不应相同") + } +} + +func TestMalformedCiphertextNeverPanics(t *testing.T) { + key := testKey(t, aesx.KeySize256) + decryptors := map[string]func([]byte, []byte) ([]byte, error){ + "ECB": aesx.DecryptECB, "CBC": aesx.DecryptCBC, "CFB": aesx.DecryptCFB, + "OFB": aesx.DecryptOFB, "CTR": aesx.DecryptCTR, + } + for _, decrypt := range decryptors { + for n := 0; n <= 40; n++ { + // 不应 panic + _, _ = decrypt(bytes.Repeat([]byte{0x7F}, n), key) + } + } + + // ECB / CBC 的非对齐密文必须返回 ErrNotBlockAligned + nonAligned := bytes.Repeat([]byte{0xAB}, 20) + if _, err := aesx.DecryptECB(nonAligned, key); !errors.Is(err, cipherx.ErrNotBlockAligned) { + t.Errorf("ECB 非对齐: 期望 ErrNotBlockAligned, 得到 %v", err) + } + if _, err := aesx.DecryptCBC(nonAligned, key); !errors.Is(err, cipherx.ErrNotBlockAligned) { + t.Errorf("CBC 非对齐: 期望 ErrNotBlockAligned, 得到 %v", err) + } +} + +// ---- GCM ---- + +func TestGCMRoundTrip(t *testing.T) { + key := testKey(t, aesx.KeySize256) + plaintexts := [][]byte{ + {}, []byte("a"), []byte("GCM 往返测试"), bytes.Repeat([]byte("x"), 100000), + } + for i, plaintext := range plaintexts { + ciphertext, nonce, err := aesx.EncryptGCM(plaintext, key, nil) + if err != nil { + t.Fatalf("GCM 加密#%d 失败: %v", i, err) + } + if len(nonce) != aesx.GCMNonceSize { + t.Fatalf("nonce 长度 = %d, 期望 %d", len(nonce), aesx.GCMNonceSize) + } + // 密文应等于明文长度 + 认证标签 + if len(ciphertext) != len(plaintext)+aesx.GCMTagSize { + t.Errorf("密文长度 = %d, 期望 %d", len(ciphertext), len(plaintext)+aesx.GCMTagSize) + } + + decrypted, err := aesx.DecryptGCM(ciphertext, key, nonce, nil) + if err != nil { + t.Fatalf("GCM 解密#%d 失败: %v", i, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("GCM 往返#%d 不一致", i) + } + } +} + +func TestGCMNonceIsRandom(t *testing.T) { + key := testKey(t, aesx.KeySize256) + seen := make(map[string]bool) + for i := 0; i < 64; i++ { + _, nonce, err := aesx.EncryptGCM([]byte("same"), key, nil) + if err != nil { + t.Fatalf("GCM 加密失败: %v", err) + } + if seen[string(nonce)] { + t.Fatal("GCM nonce 重复,会导致灾难性的密钥恢复攻击") + } + seen[string(nonce)] = true + } +} + +func TestGCMBindsAAD(t *testing.T) { + key := testKey(t, aesx.KeySize256) + plaintext := []byte("authenticated data test") + aad := []byte("v1|user=42") + + ciphertext, nonce, err := aesx.EncryptGCM(plaintext, key, aad) + if err != nil { + t.Fatalf("GCM 加密失败: %v", err) + } + + // 正确的 AAD 可解密 + if _, err := aesx.DecryptGCM(ciphertext, key, nonce, aad); err != nil { + t.Fatalf("正确 AAD 解密失败: %v", err) + } + // 不同的 AAD 必须失败 + if _, err := aesx.DecryptGCM(ciphertext, key, nonce, []byte("v1|user=43")); !errors.Is(err, aesx.ErrAuthenticationFailed) { + t.Errorf("错误 AAD: 期望 ErrAuthenticationFailed, 得到 %v", err) + } + // 缺少 AAD 必须失败 + if _, err := aesx.DecryptGCM(ciphertext, key, nonce, nil); !errors.Is(err, aesx.ErrAuthenticationFailed) { + t.Errorf("缺失 AAD: 期望 ErrAuthenticationFailed, 得到 %v", err) + } +} + +// TestGCMTamperDetection 验证任何一处篡改都会导致解密失败,且不返回明文。 +func TestGCMTamperDetection(t *testing.T) { + key := testKey(t, aesx.KeySize256) + plaintext := []byte("tamper detection") + ciphertext, nonce, err := aesx.EncryptGCM(plaintext, key, nil) + if err != nil { + t.Fatalf("GCM 加密失败: %v", err) + } + + // 翻转密文(含标签)中每一个字节 + for i := range ciphertext { + tampered := bytes.Clone(ciphertext) + tampered[i] ^= 0x01 + got, err := aesx.DecryptGCM(tampered, key, nonce, nil) + if !errors.Is(err, aesx.ErrAuthenticationFailed) { + t.Errorf("篡改密文第 %d 字节: 期望认证失败, 得到 %v", i, err) + } + if got != nil { + t.Errorf("篡改密文第 %d 字节: 认证失败却返回了明文 %q", i, got) + } + } + + // 翻转 nonce 中每一个字节 + for i := range nonce { + tampered := bytes.Clone(nonce) + tampered[i] ^= 0x01 + if _, err := aesx.DecryptGCM(ciphertext, key, tampered, nil); !errors.Is(err, aesx.ErrAuthenticationFailed) { + t.Errorf("篡改 nonce 第 %d 字节: 期望认证失败, 得到 %v", i, err) + } + } + + // 错误密钥 + wrongKey := testKey(t, aesx.KeySize256) + if _, err := aesx.DecryptGCM(ciphertext, wrongKey, nonce, nil); !errors.Is(err, aesx.ErrAuthenticationFailed) { + t.Errorf("错误密钥: 期望认证失败, 得到 %v", err) + } +} + +func TestGCMInvalidInputs(t *testing.T) { + key := testKey(t, aesx.KeySize256) + ciphertext, nonce, err := aesx.EncryptGCM([]byte("data"), key, nil) + if err != nil { + t.Fatalf("GCM 加密失败: %v", err) + } + + // nonce 长度错误 + for _, badNonce := range [][]byte{nil, {}, bytes.Repeat([]byte{1}, 8), bytes.Repeat([]byte{1}, 16)} { + if _, err := aesx.DecryptGCM(ciphertext, key, badNonce, nil); !errors.Is(err, aesx.ErrInvalidNonceSize) { + t.Errorf("nonce 长度 %d: 期望 ErrInvalidNonceSize, 得到 %v", len(badNonce), err) + } + } + + // 密文短于认证标签 + if _, err := aesx.DecryptGCM(bytes.Repeat([]byte{1}, aesx.GCMTagSize-1), key, nonce, nil); !errors.Is(err, aesx.ErrCiphertextTooShort) { + t.Errorf("超短密文: 期望 ErrCiphertextTooShort, 得到 %v", err) + } + // 空密文 + if _, err := aesx.DecryptGCM(nil, key, nonce, nil); !errors.Is(err, aesx.ErrCiphertextTooShort) { + t.Errorf("空密文: 期望 ErrCiphertextTooShort, 得到 %v", err) + } + // 密钥长度错误 + if _, err := aesx.DecryptGCM(ciphertext, []byte("short"), nonce, nil); !errors.Is(err, cipherx.ErrInvalidKeySize) { + t.Errorf("错误密钥长度: 期望 ErrInvalidKeySize, 得到 %v", err) + } +} + +func TestSealOpenGCM(t *testing.T) { + key := testKey(t, aesx.KeySize128) + plaintext := []byte("single field storage") + aad := []byte("ctx") + + sealed, err := aesx.SealGCM(plaintext, key, aad) + if err != nil { + t.Fatalf("SealGCM 失败: %v", err) + } + if len(sealed) != aesx.GCMNonceSize+len(plaintext)+aesx.GCMTagSize { + t.Errorf("SealGCM 长度 = %d, 期望 %d", len(sealed), aesx.GCMNonceSize+len(plaintext)+aesx.GCMTagSize) + } + + opened, err := aesx.OpenGCM(sealed, key, aad) + if err != nil { + t.Fatalf("OpenGCM 失败: %v", err) + } + if !bytes.Equal(opened, plaintext) { + t.Errorf("Seal/Open 往返不一致") + } + + // AAD 不匹配 + if _, err := aesx.OpenGCM(sealed, key, []byte("other")); !errors.Is(err, aesx.ErrAuthenticationFailed) { + t.Errorf("错误 AAD: 期望认证失败, 得到 %v", err) + } + // 数据过短 + for n := 0; n < aesx.GCMNonceSize+aesx.GCMTagSize; n++ { + if _, err := aesx.OpenGCM(bytes.Repeat([]byte{0x01}, n), key, aad); err == nil { + t.Errorf("长度 %d 的数据: 期望错误, 得到 nil", n) + } + } + // 篡改 + tampered := bytes.Clone(sealed) + tampered[len(tampered)-1] ^= 0x01 + if _, err := aesx.OpenGCM(tampered, key, aad); !errors.Is(err, aesx.ErrAuthenticationFailed) { + t.Errorf("篡改数据: 期望认证失败, 得到 %v", err) + } +} + +func TestGCMHexRoundTrip(t *testing.T) { + key := testKey(t, aesx.KeySize256) + plaintext := []byte("hex gcm") + + ciphertextHex, nonceHex, err := aesx.EncryptGCMHex(plaintext, key, nil) + if err != nil { + t.Fatalf("EncryptGCMHex 失败: %v", err) + } + decrypted, err := aesx.DecryptGCMHex(ciphertextHex, nonceHex, key, nil) + if err != nil { + t.Fatalf("DecryptGCMHex 失败: %v", err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Error("GCM Hex 往返不一致") + } + + if _, err := aesx.DecryptGCMHex("not hex", nonceHex, key, nil); err == nil { + t.Error("非法密文十六进制: 期望错误") + } + if _, err := aesx.DecryptGCMHex(ciphertextHex, "not hex", key, nil); err == nil { + t.Error("非法 nonce 十六进制: 期望错误") + } +} + +func TestGCMWithNonceInterop(t *testing.T) { + key := testKey(t, aesx.KeySize256) + nonce := bytes.Repeat([]byte{0x42}, aesx.GCMNonceSize) + plaintext := []byte("interop") + + ciphertext, err := aesx.EncryptGCMWithNonce(plaintext, key, nonce, nil) + if err != nil { + t.Fatalf("EncryptGCMWithNonce 失败: %v", err) + } + decrypted, err := aesx.DecryptGCM(ciphertext, key, nonce, nil) + if err != nil { + t.Fatalf("解密失败: %v", err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Error("往返不一致") + } + + if _, err := aesx.EncryptGCMWithNonce(plaintext, key, []byte("bad"), nil); !errors.Is(err, aesx.ErrInvalidNonceSize) { + t.Errorf("非法 nonce 长度: 期望 ErrInvalidNonceSize, 得到 %v", err) + } +} diff --git a/argon2x/argon2x.go b/argon2x/argon2x.go new file mode 100644 index 0000000..d14ddcb --- /dev/null +++ b/argon2x/argon2x.go @@ -0,0 +1,365 @@ +// Package argon2x 基于 Argon2id(RFC 9106)提供口令哈希与密钥派生,是当前 +// 口令存储与低熵口令派生的首选方案。 +// +// Argon2id 同时具备数据无关(Argon2i 式)与数据相关(Argon2d 式)的内存访问 +// 模式,兼顾侧信道抵抗与 GPU/ASIC 抵抗能力。 +// +// 安全注意事项: +// - 参数选择依据:OWASP 口令存储建议 Argon2id 至少满足 +// m=19456 KiB/t=2/p=1 或 m=47104 KiB/t=1/p=1;本包默认 +// t=3、m=64 MiB、p=4,对应 RFC 9106 的第二推荐档,适合服务器登录场景。 +// 移动端或高并发服务应实测后下调 t/m,但不能低于本包的下限。 +// - 每个口令必须使用独立的随机盐(默认 16 字节),盐是公开值,需与哈希串 +// 一起保存。 +// - 推荐直接用 HashPassword 返回的 PHC 字符串(自描述参数)落库,用 +// VerifyPassword 校验;手工管理参数时参数一旦变更旧哈希将无法校验。 +// - 口令、盐、哈希都不得写入日志或错误文本,本包的所有 error 只包含长度与 +// 参数信息。 +// +// 本包任何情况下都不会 panic:所有非法输入都返回 error。 +package argon2x + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "io" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + // DefaultTime 是默认的迭代次数(time cost)。次数越高,攻击者每条候选口令 + // 需要重复的内存填充越多,耗时线性增长。 + DefaultTime = 3 + + // DefaultMemory 是默认的内存代价,单位 KiB,64*1024 即 64 MiB。内存是 + // 抵抗 GPU/ASIC 的主要参数,不能低于 8*1024。 + DefaultMemory = 64 * 1024 + + // DefaultThreads 是默认的并行度(lanes)。它只影响本机计算速度、不改变 + // 总内存占用,一般取 CPU 物理核心数。 + DefaultThreads = 4 + + // DefaultSaltLength 是推荐的盐长度(字节)。 + DefaultSaltLength = 16 + + // MinSaltLength 是允许的最小盐长度(字节),更短的盐会显著提高碰撞概率。 + MinSaltLength = 8 + + // DefaultKeyLength 是推荐的输出长度(字节),32 字节可直接作为 + // AES-256 或 HMAC-SHA256 的密钥。 + DefaultKeyLength = 32 + + // MaxKeyLength 是允许的最大输出长度(字节),用于拦截误传的缓冲区大小。 + MaxKeyLength = 1024 +) + +const ( + // minKeyLength 是允许的最小输出长度(字节)。16 字节是口令校验值的最低 + // 可接受强度,低于该值的 PHC 串一律拒绝。 + minKeyLength = 16 + + // maxSaltLength 是允许的最大盐长度(字节)。 + maxSaltLength = 1024 + + // maxMemory 是允许的最大内存代价(KiB),2*1024*1024 即 2 GiB,用于拦截 + // 误传或恶意构造的超大 memory 值导致的 OOM(进程被杀死,无法恢复)。 + maxMemory = 2 * 1024 * 1024 + + // maxWork 是单次派生允许的"内存代价 × 迭代次数"上限(单位 KiB·次), + // 用于约束最坏情况下的 CPU 时间。VerifyPassword 的参数来自可被篡改的 PHC + // 字符串,如果没有该上限,攻击者只要写入 t=4294967295 就能让校验线程 + // 长时间占用 CPU(拒绝服务)。4<<20 约对应 4 GiB 的内存填充量, + // 默认参数(m=64 MiB、t=3)只占该预算的 5%。 + maxWork = 4 << 20 + + // phcAlgorithm 是本包使用的 PHC 算法标识。 + phcAlgorithm = "argon2id" +) + +// GenerateSalt 返回 length 字节的密码学安全随机盐,供 DeriveKey 使用。 +// +// length 必须落在 [MinSaltLength, 1024],推荐 DefaultSaltLength。每次哈希口令 +// 都必须生成新盐并与派生结果一起保存;盐是公开值,缺失它无法复现密钥。 +// 随机源不可用时返回 error,绝不退化为可预测的盐。 +func GenerateSalt(length int) ([]byte, error) { + if length < MinSaltLength { + return nil, fmt.Errorf("argon2x: 盐长度 %d 小于最小值 %d", length, MinSaltLength) + } + if length > maxSaltLength { + return nil, fmt.Errorf("argon2x: 盐长度 %d 超过最大值 %d", length, maxSaltLength) + } + + salt := make([]byte, length) + // 用 io.ReadFull(rand.Reader) 而非 rand.Read:后者在随机源故障时会直接 + // 终止进程,而本包要求把失败作为 error 返回。 + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, fmt.Errorf("argon2x: 读取随机盐失败: %w", err) + } + return salt, nil +} + +// DeriveKey 使用 Argon2id 从 password 与 salt 派生 keyLength 字节密钥。 +// +// time 为迭代次数(>=1),memory 为内存代价(KiB,>=8*1024 且 <=2 GiB), +// threads 为并行度(>=1),keyLength 为输出长度([16, 1024])。此外 memory×time +// 不得超过单次派生的计算量上限(约 4 GiB 内存填充量)。任一参数越界都返回 +// error,绝不 panic。 +// +// 返回的密钥是确定性函数:相同 (password, salt, time, memory, threads, +// keyLength) 必然得到相同结果,因此必须保存盐与参数才能复现。 +func DeriveKey(password, salt []byte, time, memory uint32, threads uint8, keyLength uint32) ([]byte, error) { + if err := validateParams(salt, time, memory, threads, keyLength); err != nil { + return nil, err + } + + key := argon2.IDKey(password, salt, time, memory, threads, keyLength) + return key, nil +} + +// DeriveKeyWithRandomSalt 生成 saltLength 字节随机盐并立即派生密钥,一次性返回 +// (key, salt, error)。调用方必须把 salt 与参数一起保存,否则派生结果无法复现。 +// +// saltLength 通常传 DefaultSaltLength;其余参数含义同 DeriveKey。参数非法或 +// 随机源失败时返回 error,此时 key 与 salt 均为 nil。 +func DeriveKeyWithRandomSalt(password []byte, saltLength int, time, memory uint32, threads uint8, keyLength uint32) (key, salt []byte, err error) { + salt, err = GenerateSalt(saltLength) + if err != nil { + return nil, nil, err + } + + key, err = DeriveKey(password, salt, time, memory, threads, keyLength) + if err != nil { + return nil, nil, err + } + return key, salt, nil +} + +// Verify 用相同参数重新派生并以常量时间比较 expectedKey,返回是否匹配。 +// +// 参数非法时返回 error;参数合法但密钥不匹配时返回 (false, nil)。expectedKey +// 长度与 keyLength 不一致时判定为不匹配。 +// +// 安全提示:必须用本函数而不是 bytes.Equal/== 比较口令哈希,后者会提前返回并 +// 泄漏匹配前缀长度,构成时序侧信道。 +func Verify(password, salt, expectedKey []byte, time, memory uint32, threads uint8, keyLength uint32) (bool, error) { + derived, err := DeriveKey(password, salt, time, memory, threads, keyLength) + if err != nil { + return false, err + } + // 比较后立即清零本地派生结果,缩短口令哈希在内存中的驻留时间。 + defer func() { + for i := range derived { + derived[i] = 0 + } + }() + + if len(expectedKey) != len(derived) { + return false, nil + } + return subtle.ConstantTimeCompare(derived, expectedKey) == 1, nil +} + +// HashPassword 使用默认参数(time=3、memory=64 MiB、threads=4、keyLength=32) +// 与 16 字节随机盐,把口令编码成自描述的 PHC 字符串返回,形如: +// +// $argon2id$v=19$m=65536,t=3,p=4$$ +// +// 盐与哈希使用 encoding/base64.RawStdEncoding(无填充)编码。返回值不含口令 +// 明文,可以安全落库;每个口令必须重新调用本函数以获得新的随机盐。校验请使用 +// VerifyPassword,不要手工比较字符串。 +// +// 安全提示:默认参数在常见服务器上约需 50~200ms,请结合部署环境压测后调整; +// 需要自定义参数时使用 HashPasswordWithParams。 +func HashPassword(password []byte) (string, error) { + return HashPasswordWithParams(password, DefaultTime, DefaultMemory, DefaultThreads, DefaultKeyLength) +} + +// HashPasswordWithParams 使用指定参数与 16 字节随机盐生成 PHC 格式的口令哈希。 +// +// 参数含义与校验规则同 DeriveKey:time>=1、memory>=8*1024 且 <=2 GiB、 +// threads>=1、keyLength 在 [16, 1024]。调大 time/memory 会提高暴力破解成本, +// 也会等比增加登录耗时,应压测后再定。 +func HashPasswordWithParams(password []byte, time, memory uint32, threads uint8, keyLength uint32) (string, error) { + salt, err := GenerateSalt(DefaultSaltLength) + if err != nil { + return "", err + } + + key, err := DeriveKey(password, salt, time, memory, threads, keyLength) + if err != nil { + return "", err + } + return encodePHC(salt, key, time, memory, threads), nil +} + +// VerifyPassword 解析 PHC 字符串并用其中的算法参数重新派生口令哈希,以常量时间 +// 比较后返回是否匹配。 +// +// 格式非法(字段数量、算法标识、版本、参数、base64、长度不合法或非规范编码)时 +// 返回 error,绝不静默返回 false;格式合法但口令错误时返回 (false, nil)。 +// 由于参数来自被校验的字符串本身,本函数会自动兼容历史参数(例如旧数据使用 +// m=65536,t=3,p=4,新数据使用 m=131072,t=4,p=8)。 +// +// 安全提示:encodedHash 属于可被篡改的输入,本函数对其做严格解析并限制参数 +// 上限(memory <= 2 GiB、memory×time 计算量 <= 4 GiB 填充量),避免攻击者构造 +// 超大 memory 造成 OOM、或超大 time 长时间占用 CPU。 +func VerifyPassword(password []byte, encodedHash string) (bool, error) { + time, memory, threads, salt, expected, err := decodePHC(encodedHash) + if err != nil { + return false, err + } + return Verify(password, salt, expected, time, memory, threads, uint32(len(expected))) +} + +// validateParams 是所有派生入口共用的参数闸门,保证非法输入在进入 Argon2 之前 +// 就变成明确的 error,而不是 panic 或 OOM。 +func validateParams(salt []byte, time, memory uint32, threads uint8, keyLength uint32) error { + if len(salt) == 0 { + return fmt.Errorf("argon2x: 盐不能为空") + } + if len(salt) < MinSaltLength { + return fmt.Errorf("argon2x: 盐长度 %d 小于最小值 %d", len(salt), MinSaltLength) + } + if len(salt) > maxSaltLength { + return fmt.Errorf("argon2x: 盐长度 %d 超过最大值 %d", len(salt), maxSaltLength) + } + if time < 1 { + return fmt.Errorf("argon2x: 迭代次数 time=%d 必须大于等于 1", time) + } + if memory < 8*1024 { + return fmt.Errorf("argon2x: 内存代价 memory=%d KiB 小于下限 %d KiB", memory, 8*1024) + } + if memory > maxMemory { + return fmt.Errorf("argon2x: 内存代价 memory=%d KiB 超过上限 %d KiB", memory, maxMemory) + } + if threads < 1 { + return fmt.Errorf("argon2x: 并行度 threads=%d 必须大于等于 1", threads) + } + if uint32(threads)*8 > memory { + // RFC 9106 要求 m >= 8p;本包下限已隐含满足,这里仅作防御性校验。 + return fmt.Errorf("argon2x: 内存代价 memory=%d KiB 不足以支撑 %d 个 lane(至少需要 %d KiB)", memory, threads, uint32(threads)*8) + } + // memory 与 time 都是 uint32,乘积最大 (2^32-1)^2 < 2^64,uint64 乘法不会溢出。 + if uint64(memory)*uint64(time) > maxWork { + return fmt.Errorf("argon2x: 参数组合 memory=%d KiB × time=%d 超过单次派生的计算量上限 %d KiB·次", memory, time, maxWork) + } + if keyLength < minKeyLength { + return fmt.Errorf("argon2x: 输出长度 %d 小于最小值 %d", keyLength, minKeyLength) + } + if keyLength > MaxKeyLength { + return fmt.Errorf("argon2x: 输出长度 %d 超过最大值 %d", keyLength, MaxKeyLength) + } + return nil +} + +// encodePHC 生成标准 PHC 字符串:$argon2id$v=19$m=,t=<次>,p=<并行度>$<盐>$<哈希>, +// 盐与哈希用无填充 base64(RawStdEncoding)编码。 +func encodePHC(salt, key []byte, time, memory uint32, threads uint8) string { + return fmt.Sprintf("$%s$v=%d$m=%d,t=%d,p=%d$%s$%s", + phcAlgorithm, + argon2.Version, + memory, + time, + threads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key), + ) +} + +// decodePHC 严格解析 PHC 字符串并返回其中的参数、盐与期望哈希。 +// 任何字段缺失、顺序错误、非规范数字或非规范 base64 都返回 error。 +func decodePHC(encodedHash string) (time, memory uint32, threads uint8, salt, key []byte, err error) { + if encodedHash == "" { + return 0, 0, 0, nil, nil, fmt.Errorf("argon2x: PHC 字符串不能为空") + } + + // 严格按 PHC 字符串格式(RFC 9106 / phc-string-format)解析:必须以 "$" 开头, + // 共 6 个字段,省略首字符或增删字段都视为非法输入。 + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 || parts[0] != "" { + return 0, 0, 0, nil, nil, fmt.Errorf("argon2x: PHC 字符串格式非法:期望 6 个由 $ 分隔的字段") + } + if parts[1] != phcAlgorithm { + return 0, 0, 0, nil, nil, fmt.Errorf("argon2x: 不支持的算法标识,仅支持 %s", phcAlgorithm) + } + + version, err := parseUintField(parts[2], "v=", 32) + if err != nil { + return 0, 0, 0, nil, nil, err + } + if version != uint64(argon2.Version) { + return 0, 0, 0, nil, nil, fmt.Errorf("argon2x: 不支持的 Argon2 版本 v=%d,仅支持 v=%d", version, argon2.Version) + } + + params := strings.Split(parts[3], ",") + if len(params) != 3 { + return 0, 0, 0, nil, nil, fmt.Errorf("argon2x: PHC 参数段格式非法, 期望形如 m=65536,t=3,p=4") + } + m, err := parseUintField(params[0], "m=", 32) + if err != nil { + return 0, 0, 0, nil, nil, err + } + t, err := parseUintField(params[1], "t=", 32) + if err != nil { + return 0, 0, 0, nil, nil, err + } + p, err := parseUintField(params[2], "p=", 8) + if err != nil { + return 0, 0, 0, nil, nil, err + } + + salt, err = decodeBase64Field(parts[4]) + if err != nil { + return 0, 0, 0, nil, nil, fmt.Errorf("argon2x: 解析盐失败: %w", err) + } + key, err = decodeBase64Field(parts[5]) + if err != nil { + return 0, 0, 0, nil, nil, fmt.Errorf("argon2x: 解析哈希失败: %w", err) + } + if len(salt) == 0 { + return 0, 0, 0, nil, nil, fmt.Errorf("argon2x: 盐不能为空") + } + if len(key) == 0 { + return 0, 0, 0, nil, nil, fmt.Errorf("argon2x: 哈希不能为空") + } + + return uint32(t), uint32(m), uint8(p), salt, key, nil +} + +// parseUintField 解析 "<十进制整数>" 形式的字段,并拒绝前导零、正负号等 +// 非规范写法,避免同一组参数存在多种字符串表示。 +func parseUintField(field, prefix string, bitSize int) (uint64, error) { + digits, ok := strings.CutPrefix(field, prefix) + if !ok || digits == "" { + return 0, fmt.Errorf("argon2x: PHC 字段缺少前缀 %q 或值为空", prefix) + } + value, err := strconv.ParseUint(digits, 10, bitSize) + if err != nil { + return 0, fmt.Errorf("argon2x: PHC 字段 %q 不是合法的十进制整数: %w", prefix, err) + } + if strconv.FormatUint(value, 10) != digits { + return 0, fmt.Errorf("argon2x: PHC 字段 %q 不是规范十进制写法", prefix) + } + return value, nil +} + +// decodeBase64Field 解码无填充 base64 字段,并要求重新编码后与原文完全一致, +// 从而拒绝 "=" 填充、空白字符、非规范尾比特等畸形输入。 +func decodeBase64Field(field string) ([]byte, error) { + if field == "" { + return nil, fmt.Errorf("字段为空") + } + raw, err := base64.RawStdEncoding.DecodeString(field) + if err != nil { + return nil, fmt.Errorf("无填充 base64 解码失败: %w", err) + } + if base64.RawStdEncoding.EncodeToString(raw) != field { + return nil, fmt.Errorf("不是规范的无填充 base64 编码") + } + return raw, nil +} diff --git a/argon2x/argon2x_test.go b/argon2x/argon2x_test.go new file mode 100644 index 0000000..20fb6ca --- /dev/null +++ b/argon2x/argon2x_test.go @@ -0,0 +1,468 @@ +package argon2x_test + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "math" + "strings" + "testing" + + "golang.org/x/crypto/argon2" + + "code.yun.ink/pkg/encryptx/argon2x" +) + +// 测试统一使用远小于默认值的内存/迭代参数,避免测试机耗时过长。 +const ( + testTime = 1 + testMemory = 8 * 1024 // 8 MiB,恰为本包允许的下限 + testThreads = 1 + testKeyLen = 32 +) + +// TestDeriveKeyKnownVector 使用 OpenSSL 3.5.7 的 ARGON2ID KDF 作为独立实现的 +// 已知答案: +// +// openssl kdf -keylen 32 -kdfopt pass:password -kdfopt salt:saltsaltsalt \ +// -kdfopt iter:2 -kdfopt memcost:8192 -kdfopt lanes:1 -kdfopt threads:1 ARGON2ID +func TestDeriveKeyKnownVector(t *testing.T) { + const want = "5c118b7084f1df7207ae2e4a594ea22c50b22396651abd35b9224f1d4ba0173a" + + got, err := argon2x.DeriveKey([]byte("password"), []byte("saltsaltsalt"), 2, 8192, 1, 32) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + if hex.EncodeToString(got) != want { + t.Errorf("与 OpenSSL 的 Argon2id 结果不一致\n got: %s\nwant: %s", hex.EncodeToString(got), want) + } +} + +// TestDeriveKeyMatchesArgon2id 交叉验证本包确实使用 Argon2id(而不是 Argon2i/2d), +// 且参数透传正确。 +func TestDeriveKeyMatchesArgon2id(t *testing.T) { + password := []byte("password") + salt := []byte("saltsaltsalt") + + got, err := argon2x.DeriveKey(password, salt, 2, 16384, 2, 48) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + want := argon2.IDKey(password, salt, 2, 16384, 2, 48) + if !bytes.Equal(got, want) { + t.Error("DeriveKey 与 argon2.IDKey(Argon2id) 结果不一致") + } + + // 与 Argon2i 必须不同,否则说明模式选错了。 + if bytes.Equal(got, argon2.Key(password, salt, 2, 16384, 2, 48)) { + t.Error("DeriveKey 的结果与 Argon2i 相同,未使用 Argon2id") + } +} + +func TestDeriveKeyReproducible(t *testing.T) { + password := []byte("正确的马电池订书钉") + salt := []byte("0123456789abcdef") + + k1, err := argon2x.DeriveKey(password, salt, testTime, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + k2, err := argon2x.DeriveKey(password, salt, testTime, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + if !bytes.Equal(k1, k2) { + t.Fatal("相同输入两次派生结果不一致") + } + if len(k1) != testKeyLen { + t.Fatalf("派生长度 = %d, 期望 %d", len(k1), testKeyLen) + } + + otherSalt, err := argon2x.DeriveKey(password, []byte("fedcba9876543210"), testTime, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("换盐派生失败: %v", err) + } + if bytes.Equal(k1, otherSalt) { + t.Error("不同盐得到了相同密钥") + } + + otherTime, err := argon2x.DeriveKey(password, salt, testTime+1, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("换迭代次数派生失败: %v", err) + } + if bytes.Equal(k1, otherTime) { + t.Error("不同迭代次数得到了相同密钥") + } + + otherMemory, err := argon2x.DeriveKey(password, salt, testTime, testMemory*2, testThreads, testKeyLen) + if err != nil { + t.Fatalf("换内存代价派生失败: %v", err) + } + if bytes.Equal(k1, otherMemory) { + t.Error("不同内存代价得到了相同密钥") + } + + otherThreads, err := argon2x.DeriveKey(password, salt, testTime, testMemory, testThreads+1, testKeyLen) + if err != nil { + t.Fatalf("换并行度派生失败: %v", err) + } + if bytes.Equal(k1, otherThreads) { + t.Error("不同并行度得到了相同密钥") + } +} + +// TestDeriveKeyWithRandomSalt 断言返回的盐确实能复现同一个密钥。 +func TestDeriveKeyWithRandomSalt(t *testing.T) { + password := []byte("hunter2") + + key, salt, err := argon2x.DeriveKeyWithRandomSalt(password, argon2x.DefaultSaltLength, testTime, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("DeriveKeyWithRandomSalt 返回错误: %v", err) + } + if len(salt) != argon2x.DefaultSaltLength { + t.Fatalf("返回的盐长度 = %d, 期望 %d", len(salt), argon2x.DefaultSaltLength) + } + if len(key) != testKeyLen { + t.Fatalf("返回的密钥长度 = %d, 期望 %d", len(key), testKeyLen) + } + + reproduced, err := argon2x.DeriveKey(password, salt, testTime, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("用返回的盐复现失败: %v", err) + } + if !bytes.Equal(key, reproduced) { + t.Fatal("用 DeriveKeyWithRandomSalt 返回的盐无法复现同一密钥") + } + + key2, salt2, err := argon2x.DeriveKeyWithRandomSalt(password, argon2x.DefaultSaltLength, testTime, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("第二次 DeriveKeyWithRandomSalt 失败: %v", err) + } + if bytes.Equal(salt, salt2) { + t.Error("两次调用返回了相同的盐") + } + if bytes.Equal(key, key2) { + t.Error("两次调用返回了相同的密钥") + } + + for _, bad := range []int{0, -1, argon2x.MinSaltLength - 1, 1025, math.MaxInt} { + if _, _, err := argon2x.DeriveKeyWithRandomSalt(password, bad, testTime, testMemory, testThreads, testKeyLen); err == nil { + t.Errorf("saltLength=%d 时未返回错误", bad) + } + } +} + +func TestVerify(t *testing.T) { + password := []byte("s3cret-口令") + salt := []byte("0123456789abcdef") + key, err := argon2x.DeriveKey(password, salt, testTime, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + + ok, err := argon2x.Verify(password, salt, key, testTime, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("Verify 返回错误: %v", err) + } + if !ok { + t.Error("正确口令未通过校验") + } + + cases := []struct { + name string + password []byte + salt []byte + expected []byte + }{ + {"错误口令", []byte("s3cret-口令x"), salt, key}, + {"错误盐", password, []byte("fedcba9876543210"), key}, + {"哈希被篡改", password, salt, append(append([]byte{}, key[:len(key)-1]...), key[len(key)-1]^0x01)}, + {"哈希被截断", password, salt, key[:len(key)-1]}, + {"哈希被加长", password, salt, append(append([]byte{}, key...), 0x00)}, + {"空哈希", password, salt, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ok, err := argon2x.Verify(tc.password, tc.salt, tc.expected, testTime, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("参数合法时 Verify 不应返回错误: %v", err) + } + if ok { + t.Error("不匹配的哈希被判为匹配") + } + }) + } +} + +// TestParameterValidation 覆盖非法参数:必须返回 error 且不 panic。 +func TestParameterValidation(t *testing.T) { + validSalt := bytes.Repeat([]byte{0xA5}, argon2x.MinSaltLength) + password := []byte("pw") + + cases := []struct { + name string + salt []byte + time uint32 + memory uint32 + threads uint8 + keyLen uint32 + saltInvalid bool + }{ + {"盐为 nil", nil, testTime, testMemory, testThreads, testKeyLen, true}, + {"盐为空切片", []byte{}, testTime, testMemory, testThreads, testKeyLen, true}, + {"盐过短", []byte("1234567"), testTime, testMemory, testThreads, testKeyLen, true}, + {"盐过长", bytes.Repeat([]byte{0x01}, 1025), testTime, testMemory, testThreads, testKeyLen, true}, + {"time 为 0", validSalt, 0, testMemory, testThreads, testKeyLen, false}, + {"time 为 MaxUint32", validSalt, math.MaxUint32, testMemory, testThreads, testKeyLen, false}, + {"计算量超上限", validSalt, 1 << 20, 64 * 1024, testThreads, testKeyLen, false}, + {"memory 为 0", validSalt, testTime, 0, testThreads, testKeyLen, false}, + {"memory 低于下限", validSalt, testTime, 8*1024 - 1, testThreads, testKeyLen, false}, + {"memory 超上限", validSalt, testTime, 2*1024*1024 + 1, testThreads, testKeyLen, false}, + {"memory 为 MaxUint32", validSalt, testTime, math.MaxUint32, testThreads, testKeyLen, false}, + {"threads 为 0", validSalt, testTime, testMemory, 0, testKeyLen, false}, + {"keyLength 为 0", validSalt, testTime, testMemory, testThreads, 0, false}, + {"keyLength 低于下限", validSalt, testTime, testMemory, testThreads, 15, false}, + {"keyLength 超上限", validSalt, testTime, testMemory, testThreads, argon2x.MaxKeyLength + 1, false}, + {"keyLength 为 MaxUint32", validSalt, testTime, testMemory, testThreads, math.MaxUint32, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + key, err := argon2x.DeriveKey(password, tc.salt, tc.time, tc.memory, tc.threads, tc.keyLen) + if err == nil { + t.Fatalf("未返回错误,得到 %d 字节密钥", len(key)) + } + if key != nil { + t.Errorf("出错时密钥应为 nil,实际长度 %d", len(key)) + } + if _, err := argon2x.Verify(password, tc.salt, validSalt, tc.time, tc.memory, tc.threads, tc.keyLen); err == nil { + t.Error("Verify 对非法参数未返回错误") + } + // DeriveKeyWithRandomSalt 与 HashPasswordWithParams 自行生成盐, + // 因此仅在 time/memory/threads/keyLength 非法时才应报错。 + if !tc.saltInvalid { + if _, _, err := argon2x.DeriveKeyWithRandomSalt(password, argon2x.DefaultSaltLength, tc.time, tc.memory, tc.threads, tc.keyLen); err == nil { + t.Error("DeriveKeyWithRandomSalt 对非法参数未返回错误") + } + if _, err := argon2x.HashPasswordWithParams(password, tc.time, tc.memory, tc.threads, tc.keyLen); err == nil { + t.Error("HashPasswordWithParams 对非法参数未返回错误") + } + } + }) + } +} + +// TestHashPasswordDefault 验证默认参数与 PHC 串格式。 +func TestHashPasswordDefault(t *testing.T) { + encoded, err := argon2x.HashPassword([]byte("正确的马电池订书钉")) + if err != nil { + t.Fatalf("HashPassword 返回错误: %v", err) + } + + if !strings.HasPrefix(encoded, "$argon2id$v=19$m=65536,t=3,p=4$") { + t.Errorf("PHC 串前缀不符合预期: %q", encoded) + } + if strings.Contains(encoded, "正确的马电池订书钉") { + t.Error("PHC 串中不应出现口令明文") + } + + parts := strings.Split(encoded, "$") + if len(parts) != 6 { + t.Fatalf("PHC 字段数 = %d, 期望 6", len(parts)) + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + t.Fatalf("盐不是合法的无填充 base64: %v", err) + } + key, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + t.Fatalf("哈希不是合法的无填充 base64: %v", err) + } + if len(salt) != argon2x.DefaultSaltLength { + t.Errorf("盐长度 = %d, 期望 %d", len(salt), argon2x.DefaultSaltLength) + } + if len(key) != argon2x.DefaultKeyLength { + t.Errorf("哈希长度 = %d, 期望 %d", len(key), argon2x.DefaultKeyLength) + } + if strings.ContainsAny(parts[4]+parts[5], "=") { + t.Error("盐与哈希必须使用无填充 base64") + } + + // 同一口令两次调用必须得到不同(随机盐)的 PHC 串,且都能校验通过。 + second, err := argon2x.HashPassword([]byte("正确的马电池订书钉")) + if err != nil { + t.Fatalf("HashPassword 返回错误: %v", err) + } + if second == encoded { + t.Error("两次 HashPassword 结果相同,随机盐未生效") + } + for _, e := range []string{encoded, second} { + ok, err := argon2x.VerifyPassword([]byte("正确的马电池订书钉"), e) + if err != nil { + t.Fatalf("VerifyPassword 返回错误: %v", err) + } + if !ok { + t.Error("正确口令未通过 PHC 校验") + } + } +} + +// TestHashPasswordWithParams 验证自定义参数会写进 PHC 串并能往返校验。 +func TestHashPasswordWithParams(t *testing.T) { + encoded, err := argon2x.HashPasswordWithParams([]byte("hunter2"), testTime, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("HashPasswordWithParams 返回错误: %v", err) + } + if !strings.HasPrefix(encoded, "$argon2id$v=19$m=8192,t=1,p=1$") { + t.Errorf("PHC 参数段不符合预期: %q", encoded) + } + + ok, err := argon2x.VerifyPassword([]byte("hunter2"), encoded) + if err != nil { + t.Fatalf("VerifyPassword 返回错误: %v", err) + } + if !ok { + t.Error("正确口令未通过校验") + } + + // 错误口令返回 (false, nil)。 + ok, err = argon2x.VerifyPassword([]byte("hunter3"), encoded) + if err != nil { + t.Fatalf("口令错误时不应返回 error: %v", err) + } + if ok { + t.Error("错误口令被判为匹配") + } + + // 空口令与 nil 口令也必须能正确处理,不 panic。 + if _, err := argon2x.HashPasswordWithParams(nil, testTime, testMemory, testThreads, testKeyLen); err != nil { + t.Errorf("空口令哈希失败: %v", err) + } +} + +// TestVerifyPasswordInvalidFormat 覆盖格式非法:必须返回 error(不能静默返回 false)。 +func TestVerifyPasswordInvalidFormat(t *testing.T) { + valid, err := argon2x.HashPasswordWithParams([]byte("hunter2"), testTime, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("准备测试数据失败: %v", err) + } + parts := strings.Split(valid, "$") + + cases := []struct { + name string + encoded string + }{ + {"空串", ""}, + {"完全垃圾", "not-a-phc-string"}, + {"缺少首字符 $", valid[1:]}, + {"缺少尾部哈希", strings.Join(parts[:5], "$")}, + {"多出一个字段", valid + "$extra"}, + {"算法标识错误", "$argon2i$v=19$m=8192,t=1,p=1$" + parts[4] + "$" + parts[5]}, + {"算法标识缺失", "$$v=19$m=8192,t=1,p=1$" + parts[4] + "$" + parts[5]}, + {"版本不支持", "$argon2id$v=18$m=8192,t=1,p=1$" + parts[4] + "$" + parts[5]}, + {"版本字段缺少前缀", "$argon2id$19$m=8192,t=1,p=1$" + parts[4] + "$" + parts[5]}, + {"参数段顺序错误", "$argon2id$v=19$t=1,m=8192,p=1$" + parts[4] + "$" + parts[5]}, + {"参数段缺少 p", "$argon2id$v=19$m=8192,t=1$" + parts[4] + "$" + parts[5]}, + {"参数非数字", "$argon2id$v=19$m=abc,t=1,p=1$" + parts[4] + "$" + parts[5]}, + {"参数为负数", "$argon2id$v=19$m=-8192,t=1,p=1$" + parts[4] + "$" + parts[5]}, + {"参数带前导零", "$argon2id$v=19$m=08192,t=1,p=1$" + parts[4] + "$" + parts[5]}, + {"参数超上限", "$argon2id$v=19$m=4294967295,t=1,p=1$" + parts[4] + "$" + parts[5]}, + {"盐为空", "$argon2id$v=19$m=8192,t=1,p=1$$" + parts[5]}, + {"哈希为空", "$argon2id$v=19$m=8192,t=1,p=1$" + parts[4] + "$"}, + {"盐使用填充 base64", "$argon2id$v=19$m=8192,t=1,p=1$" + parts[4] + "=$" + parts[5]}, + {"盐含非法字符", "$argon2id$v=19$m=8192,t=1,p=1$" + parts[4] + "!$" + parts[5]}, + {"哈希含空白字符", "$argon2id$v=19$m=8192,t=1,p=1$" + parts[4] + "$\n" + parts[5]}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ok, err := argon2x.VerifyPassword([]byte("hunter2"), tc.encoded) + if err == nil { + t.Fatalf("格式非法时必须返回 error,实际 ok=%v", ok) + } + if ok { + t.Error("格式非法时不应判定为匹配") + } + }) + } +} + +// TestVerifyPasswordTampered 覆盖语义被篡改的合法格式字符串:必须返回 error 或 +// false,绝不 panic,也绝不判定为匹配。 +func TestVerifyPasswordTampered(t *testing.T) { + password := []byte("hunter2") + valid, err := argon2x.HashPasswordWithParams(password, testTime, testMemory, testThreads, testKeyLen) + if err != nil { + t.Fatalf("准备测试数据失败: %v", err) + } + parts := strings.Split(valid, "$") + salt, hash := parts[4], parts[5] + + // flip 翻转 base64 字符串中的某个字符,保持长度不变。 + flip := func(s string, i int) string { + b := []byte(s) + if b[i] == 'A' { + b[i] = 'B' + } else { + b[i] = 'A' + } + return string(b) + } + + cases := []struct { + name string + encoded string + }{ + {"加大 m", "$argon2id$v=19$m=16384,t=1,p=1$" + salt + "$" + hash}, + {"减小 m", "$argon2id$v=19$m=4096,t=1,p=1$" + salt + "$" + hash}, + {"加大 t", "$argon2id$v=19$m=8192,t=2,p=1$" + salt + "$" + hash}, + {"加大 p", "$argon2id$v=19$m=8192,t=1,p=2$" + salt + "$" + hash}, + {"篡改盐", "$argon2id$v=19$m=8192,t=1,p=1$" + flip(salt, 0) + "$" + hash}, + {"篡改哈希", "$argon2id$v=19$m=8192,t=1,p=1$" + salt + "$" + flip(hash, 0)}, + {"截断哈希", "$argon2id$v=19$m=8192,t=1,p=1$" + salt + "$" + hash[:len(hash)-1]}, + {"截断整串", valid[:len(valid)-1]}, + {"截断到一半", valid[:len(valid)/2]}, + {"交换盐与哈希", "$argon2id$v=19$m=8192,t=1,p=1$" + hash + "$" + salt}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ok, err := argon2x.VerifyPassword(password, tc.encoded) + if ok { + t.Errorf("被篡改的 PHC 串仍然校验通过: %q", tc.encoded) + } + // err 允许为 nil(参数合法但口令哈希不匹配)或非 nil(参数非法)。 + _ = err + }) + } +} + +func TestGenerateSalt(t *testing.T) { + for _, length := range []int{argon2x.MinSaltLength, argon2x.DefaultSaltLength, 32} { + salt, err := argon2x.GenerateSalt(length) + if err != nil { + t.Fatalf("GenerateSalt(%d) 返回错误: %v", length, err) + } + if len(salt) != length { + t.Errorf("盐长度 = %d, 期望 %d", len(salt), length) + } + } + + a, err := argon2x.GenerateSalt(argon2x.DefaultSaltLength) + if err != nil { + t.Fatalf("GenerateSalt 返回错误: %v", err) + } + b, err := argon2x.GenerateSalt(argon2x.DefaultSaltLength) + if err != nil { + t.Fatalf("GenerateSalt 返回错误: %v", err) + } + if bytes.Equal(a, b) { + t.Error("两次生成的盐相同") + } + + for _, length := range []int{0, -1, argon2x.MinSaltLength - 1, 1025, math.MaxInt} { + if _, err := argon2x.GenerateSalt(length); err == nil { + t.Errorf("GenerateSalt(%d) 未返回错误", length) + } + } +} diff --git a/base64x/base64x.go b/base64x/base64x.go index ee69793..d4f0eb7 100644 --- a/base64x/base64x.go +++ b/base64x/base64x.go @@ -1,52 +1,149 @@ +// Package base64x 提供 Base64 编解码的统一 []byte API。 +// +// 本包是 encoding/base64 的薄封装,统一了输入输出类型(一律使用 []byte) +// 与错误行为(解码失败一律返回 nil 切片与包装后的 error,绝不返回部分结果), +// 并提供友好、可读的错误信息。 +// +// 四个变体的适用场景: +// +// - Encode / Decode:RFC 4648 标准字母表(+/)且带 = 填充。 +// 适合 MIME、邮件、PEM 等传统场景;字符 + 与 / 不能直接放进 URL 查询串 +// 或文件名(会被转义或截断)。 +// - EncodeURL / DecodeURL:URL 与文件名安全字母表(-_)且带 = 填充。 +// 适合 Cookie、HTTP 头、文件名,但 = 在某些上下文(如 JWT、部分 URL 解析器) +// 仍需转义。 +// - EncodeRaw / DecodeRaw:标准字母表、**无填充**。 +// 适合放进 JSON、YAML、定长字段(长度必须是 4 的倍数约束被去掉)。 +// - EncodeRawURL / DecodeRawURL:URL 安全字母表、**无填充**。 +// 适合 URL 路径/查询串、文件名与 JSON(最通用的可打印编码); +// 本包推荐在不确定场景时优先使用它。 +// +// 安全注意:Base64 是**编码**而非加密,不提供任何机密性, +// 任何人都能还原原文;敏感数据必须先加密再编码。 +// 解码不可信输入时应注意结果长度可能约为输入的 3/4, +// 需要限制输入大小以防内存放大类拒绝服务。 package base64x -import "encoding/base64" +import ( + "encoding/base64" + "fmt" +) -// 普通的 -func StdEncode(s string) string { - return base64.StdEncoding.EncodeToString([]byte(s)) +// Encode 使用标准 Base64 字母表(+/)编码数据,输出带 = 填充。 +// +// 参数: +// - data: 待编码的字节串,可以为 nil(返回空字符串)。 +// +// 返回值:标准 Base64 字符串,长度总是 4 的倍数。 +// +// 安全注意:Base64 不提供机密性;且标准字母表含 + 与 /, +// 不要直接用于 URL 或文件名,请使用 EncodeURL 或 EncodeRawURL。 +func Encode(data []byte) string { + return base64.StdEncoding.EncodeToString(data) } -func StdDecode(sEnc string) (string, error) { - sDec, err := base64.StdEncoding.DecodeString(sEnc) +// Decode 解码标准 Base64 字符串(要求带正确的 = 填充)。 +// +// 参数: +// - s: 待解码的标准 Base64 字符串。 +// +// 返回值:解码后的字节串;失败时返回 nil 与包装后的 error。 +// +// 安全注意:失败时不会返回任何部分解码结果,避免调用方误用不完整数据; +// 解码结果可能很长,处理不可信输入前应限制输入长度。 +func Decode(s string) ([]byte, error) { + decoded, err := base64.StdEncoding.DecodeString(s) if err != nil { - return "", err + return nil, fmt.Errorf("标准 Base64 解码失败: %w", err) } - return string(sDec), nil + return decoded, nil } -// URL和文件名安全的 -func UrlEncode(s string) string { - return base64.URLEncoding.EncodeToString([]byte(s)) +// EncodeURL 使用 URL/文件名安全字母表(-_)编码数据,输出带 = 填充。 +// +// 参数: +// - data: 待编码的字节串,可以为 nil(返回空字符串)。 +// +// 返回值:URL 安全 Base64 字符串,长度总是 4 的倍数。 +// +// 安全注意:字母表不含 + 与 /,可安全用于 URL 与文件名; +// 但 = 填充在某些上下文仍需转义,若需彻底避免请使用 EncodeRawURL。 +func EncodeURL(data []byte) string { + return base64.URLEncoding.EncodeToString(data) } -func UrlDecode(sEnc string) (string, error) { - sDec, err := base64.URLEncoding.DecodeString(sEnc) +// DecodeURL 解码 URL 安全 Base64 字符串(要求带正确的 = 填充)。 +// +// 参数: +// - s: 待解码的 URL 安全 Base64 字符串。 +// +// 返回值:解码后的字节串;失败时返回 nil 与包装后的 error。 +// +// 安全注意:失败时不会返回部分结果;本函数不接受标准字母表中的 + 与 /。 +func DecodeURL(s string) ([]byte, error) { + decoded, err := base64.URLEncoding.DecodeString(s) if err != nil { - return "", err + return nil, fmt.Errorf("URL 安全 Base64 解码失败: %w", err) } - return string(sDec), nil + return decoded, nil } -// 无填充 -func RawEncode(s string) string { - return base64.RawStdEncoding.EncodeToString([]byte(s)) +// EncodeRaw 使用标准 Base64 字母表(+/)编码数据,输出**不带** = 填充。 +// +// 参数: +// - data: 待编码的字节串,可以为 nil(返回空字符串)。 +// +// 返回值:无填充的标准 Base64 字符串,长度不再保证是 4 的倍数。 +// +// 安全注意:无填充版本更适合放进 JSON、定长字段;但字母表仍含 + 与 /, +// 不要直接用于 URL 或文件名。 +func EncodeRaw(data []byte) string { + return base64.RawStdEncoding.EncodeToString(data) } -func RawDecode(sEnc string) (string, error) { - sDec, err := base64.RawStdEncoding.DecodeString(sEnc) +// DecodeRaw 解码无填充的标准 Base64 字符串。 +// +// 参数: +// - s: 待解码的无填充标准 Base64 字符串。 +// +// 返回值:解码后的字节串;失败时返回 nil 与包装后的 error。 +// +// 安全注意:失败时不会返回部分结果;带 = 填充的输入会被拒绝。 +func DecodeRaw(s string) ([]byte, error) { + decoded, err := base64.RawStdEncoding.DecodeString(s) if err != nil { - return "", err + return nil, fmt.Errorf("无填充标准 Base64 解码失败: %w", err) } - return string(sDec), nil + return decoded, nil } -// URL和文件名安全的,无填充 -func RawUrlEncode(s string) string { - return base64.RawURLEncoding.EncodeToString([]byte(s)) +// EncodeRawURL 使用 URL/文件名安全字母表(-_)编码数据,输出**不带** = 填充。 +// +// 参数: +// - data: 待编码的字节串,可以为 nil(返回空字符串)。 +// +// 返回值:URL 安全且无填充的 Base64 字符串,可直接放进 URL 路径、 +// 查询串、文件名与 JSON 字符串。 +// +// 安全注意:这是本包适用面最广的编码,但仍然只是编码: +// 若数据是秘密,必须先加密(例如 AES-GCM)后再编码。 +func EncodeRawURL(data []byte) string { + return base64.RawURLEncoding.EncodeToString(data) } -func RawUrlDecode(s string) (string, error) { +// DecodeRawURL 解码 URL 安全且无填充的 Base64 字符串。 +// +// 参数: +// - s: 待解码的 URL 安全无填充 Base64 字符串。 +// +// 返回值:解码后的字节串;失败时返回 nil 与包装后的 error。 +// +// 安全注意:失败时不会返回部分结果;输入中的 = 填充、 +// +、/ 以及空白字符都会导致解码失败。 +func DecodeRawURL(s string) ([]byte, error) { decoded, err := base64.RawURLEncoding.DecodeString(s) - return string(decoded), err + if err != nil { + return nil, fmt.Errorf("URL 安全无填充 Base64 解码失败: %w", err) + } + return decoded, nil } diff --git a/base64x/base64x_test.go b/base64x/base64x_test.go index a053880..9175a7e 100644 --- a/base64x/base64x_test.go +++ b/base64x/base64x_test.go @@ -1,43 +1,363 @@ package base64x_test import ( + "bytes" + "encoding/base64" + "strings" "testing" "code.yun.ink/pkg/encryptx/base64x" ) -func TestBase64xStd(t *testing.T) { - original := "Hello, World!" - encoded := base64x.StdEncode(original) - decoded, err := base64x.StdDecode(encoded) - if err != nil { - t.Error(err) - } - if original != decoded { - t.Errorf("expected %q, got %q", original, decoded) +// variants 汇总四个编解码变体,便于表驱动测试。 +var variants = []struct { + name string + encode func([]byte) string + decode func(string) ([]byte, error) + // alphabetURL 表示该变体使用 URL 安全字母表(不含 + 与 /)。 + alphabetURL bool + // padded 表示该变体输出带 = 填充。 + padded bool +}{ + {"标准", base64x.Encode, base64x.Decode, false, true}, + {"URL安全", base64x.EncodeURL, base64x.DecodeURL, true, true}, + {"标准无填充", base64x.EncodeRaw, base64x.DecodeRaw, false, false}, + {"URL安全无填充", base64x.EncodeRawURL, base64x.DecodeRawURL, true, false}, +} + +// payloads 覆盖空、短、UTF-8 与二进制等各类输入。 +var payloads = [][]byte{ + nil, + {}, + []byte("a"), + []byte("ab"), + []byte("abc"), + []byte("Hello, World!"), + []byte("中文与 emoji 🚀 混合内容"), + bytes.Repeat([]byte{0x00}, 64), + bytes.Repeat([]byte{0xff, 0x00, 0x7f, 0x80}, 128), + func() []byte { + b := make([]byte, 256) + for i := range b { + b[i] = byte(i) + } + return b + }(), +} + +// TestRoundTrip 校验四个变体各自的编解码往返一致。 +func TestRoundTrip(t *testing.T) { + for _, v := range variants { + for i, payload := range payloads { + encoded := v.encode(payload) + decoded, err := v.decode(encoded) + if err != nil { + t.Fatalf("%s 用例 %d: 解码失败: %v", v.name, i, err) + } + if !bytes.Equal(decoded, payload) { + t.Errorf("%s 用例 %d: 往返不一致,得到 %x,期望 %x", v.name, i, decoded, payload) + } + } } } -func TestBase64xUrl(t *testing.T) { - original := "Hello, World!" - encoded := base64x.UrlEncode(original) - decoded, err := base64x.UrlDecode(encoded) - if err != nil { - t.Error(err) +// TestEncodingFormat 校验各变体的字符集与填充约定。 +func TestEncodingFormat(t *testing.T) { + // 选用会使标准 Base64 产生 "+" 与 "/" 的字节,以验证 URL 安全变体确实替换了它们。 + payload := []byte{0xfb, 0xff, 0xbf, 0x00, 0xfe, 0x3e, 0x3f} + + if got := base64x.Encode(payload); !strings.ContainsAny(got, "+/") { + t.Fatalf("测试数据未能触发标准字母表的 +/ 字符,实际编码为 %q", got) } - if original != decoded { - t.Errorf("expected %q, got %q", original, decoded) + + for _, v := range variants { + encoded := v.encode(payload) + if v.alphabetURL { + if strings.ContainsAny(encoded, "+/") { + t.Errorf("%s 编码结果含非 URL 安全字符: %q", v.name, encoded) + } + } + hasPadding := strings.HasSuffix(encoded, "=") + if hasPadding != v.padded { + t.Errorf("%s 填充状态错误: 编码为 %q, 期望带填充=%v", v.name, encoded, v.padded) + } + if v.padded && len(encoded)%4 != 0 { + t.Errorf("%s 带填充编码长度应为 4 的倍数: %q", v.name, encoded) + } + if !isASCII(encoded) { + t.Errorf("%s 编码结果含非 ASCII 字符: %q", v.name, encoded) + } } } -func TestBase64xRaw(t *testing.T) { - original := "Hello, World!" - encoded := base64x.RawEncode(original) - decoded, err := base64x.RawDecode(encoded) - if err != nil { - t.Error(err) +// TestEncodeEdgeLengths 校验填充边界(1/2/3 字节分组)的编码结果符合 RFC 4648。 +func TestEncodeEdgeLengths(t *testing.T) { + cases := []struct { + in []byte + std string + raw string + url string + rawU string + }{ + {[]byte("f"), "Zg==", "Zg", "Zg==", "Zg"}, + {[]byte("fo"), "Zm8=", "Zm8", "Zm8=", "Zm8"}, + {[]byte("foo"), "Zm9v", "Zm9v", "Zm9v", "Zm9v"}, + {[]byte("foob"), "Zm9vYg==", "Zm9vYg", "Zm9vYg==", "Zm9vYg"}, + {[]byte("fooba"), "Zm9vYmE=", "Zm9vYmE", "Zm9vYmE=", "Zm9vYmE"}, + {[]byte("foobar"), "Zm9vYmFy", "Zm9vYmFy", "Zm9vYmFy", "Zm9vYmFy"}, + {[]byte{0xfb, 0xff, 0xbf}, "+/+/", "+/+/", "-_-_", "-_-_"}, } - if original != decoded { - t.Errorf("expected %q, got %q", original, decoded) + for _, c := range cases { + if got := base64x.Encode(c.in); got != c.std { + t.Errorf("Encode(%q) = %q, 期望 %q", c.in, got, c.std) + } + if got := base64x.EncodeRaw(c.in); got != c.raw { + t.Errorf("EncodeRaw(%q) = %q, 期望 %q", c.in, got, c.raw) + } + if got := base64x.EncodeURL(c.in); got != c.url { + t.Errorf("EncodeURL(%q) = %q, 期望 %q", c.in, got, c.url) + } + if got := base64x.EncodeRawURL(c.in); got != c.rawU { + t.Errorf("EncodeRawURL(%q) = %q, 期望 %q", c.in, got, c.rawU) + } } } + +// TestDecodeRejectsGarbage 校验 Decode* 对垃圾输入返回 nil 切片与 error。 +// +// 所有用例都已用实验确认对四个变体都真正非法(见 TestStdlibBehavior)。 +func TestDecodeRejectsGarbage(t *testing.T) { + garbage := []struct { + name string + in string + }{ + {"非字母表字符", "!!!not base64!!!"}, + {"含空格", "SGVs bG8="}, + {"含制表符", "SGVs\tbG8="}, + {"只有填充", "===="}, + {"单个填充", "="}, + {"首字符即填充", "=AAAA"}, + {"残留 1 个字符", "Z"}, + {"错误填充位置", "SG=VsbG8="}, + {"两段拼接的填充", "Zg==Zg=="}, + {"填充数量过多", "A==="}, + {"emoji 输入", "🚀🚀🚀🚀"}, + {"中文输入", "你好世界"}, + {"二进制控制字符", "\x00\x01\x02\x03"}, + } + + for _, v := range variants { + for _, g := range garbage { + decoded, err := v.decode(g.in) + if err == nil { + t.Errorf("%s 解码 %q(%s)应当失败,却成功得到 %x", v.name, g.in, g.name, decoded) + } + if decoded != nil { + t.Errorf("%s 解码 %q(%s)失败时应返回 nil,实际 %x", v.name, g.in, g.name, decoded) + } + if err != nil && err.Error() == "" { + t.Errorf("%s 解码 %q 返回了空错误信息", v.name, g.in) + } + } + } +} + +// TestStdlibBehavior 固定住 Go encoding/base64 解码器的实际行为 +// (以下断言全部由实验测得,不是推测)。这些行为会随封装一起暴露给调用方: +// +// 1. 解码器会**忽略** \r 与 \n(MIME 历史行为),但空格与制表符仍然非法; +// 2. 标准与 URL 两套字母表在解码时**不通用**:Std 只接受 '+'/'/', +// URL 只接受 '-'/'_'(编码方向同样区分); +// 3. 无填充解码器**拒绝**任何 '=' 填充,即使位置正确; +// 4. 带填充解码器接受"多余填充"与非规范尾部比特, +// 例如 "SGV=" 会被解码为 2 字节而不报错。 +// +// 因此调用方不能依赖解码器拒绝所有畸形数据,需要自行约束输入格式。 +func TestStdlibBehavior(t *testing.T) { + // 1. CR/LF 被忽略,空格与制表符非法。 + if got, err := base64x.Decode("SGVsbG8=\n"); err != nil || !bytes.Equal(got, []byte("Hello")) { + t.Errorf("预期换行被忽略并解码出 Hello,得到 %x, err=%v", got, err) + } + if got, err := base64x.Decode("SGVsbG8=\r\n"); err != nil || !bytes.Equal(got, []byte("Hello")) { + t.Errorf("预期 CRLF 被忽略并解码出 Hello,得到 %x, err=%v", got, err) + } + if _, err := base64x.Decode("SGVs bG8="); err == nil { + t.Error("空格应导致解码失败") + } + if _, err := base64x.Decode("SGVs\tbG8="); err == nil { + t.Error("制表符应导致解码失败") + } + // 无填充解码器遇到换行同样失败(因为输入本身不完整/带填充)。 + if _, err := base64x.DecodeRaw("SGVsbG8=\n"); err == nil { + t.Error("无填充解码器应拒绝含填充的输入") + } + + // 2. 字母表不通用。 + if got, err := base64x.Decode("a+b/"); err != nil || !bytes.Equal(got, []byte{0x6b, 0xe6, 0xff}) { + t.Errorf("标准解码器应接受 a+b/,得到 %x, err=%v", got, err) + } + if _, err := base64x.Decode("a-b_"); err == nil { + t.Error("标准解码器应拒绝 URL 字母表 a-b_") + } + if got, err := base64x.DecodeURL("a-b_"); err != nil || !bytes.Equal(got, []byte{0x6b, 0xe6, 0xff}) { + t.Errorf("URL 解码器应接受 a-b_,得到 %x, err=%v", got, err) + } + if _, err := base64x.DecodeURL("a+b/"); err == nil { + t.Error("URL 解码器应拒绝标准字母表 a+b/") + } + // 编码方向严格区分字母表。 + if got := base64x.Encode([]byte{0xfb, 0xff, 0xbf}); got != "+/+/" { + t.Errorf("标准编码应为 +/+/,得到 %q", got) + } + if got := base64x.EncodeURL([]byte{0xfb, 0xff, 0xbf}); got != "-_-_" { + t.Errorf("URL 编码应为 -_-_,得到 %q", got) + } + + // 3. 无填充解码器拒绝一切填充。 + for _, in := range []string{"Zg==", "Zm8=", "SGVsbG8=", "AA==", "AAA="} { + if _, err := base64x.DecodeRaw(in); err == nil { + t.Errorf("无填充解码器应拒绝带填充输入 %q", in) + } + if _, err := base64x.DecodeRawURL(in); err == nil { + t.Errorf("无填充 URL 解码器应拒绝带填充输入 %q", in) + } + } + + // 4. 带填充解码器接受多余填充与非规范尾部比特。 + if got, err := base64x.Decode("SGV="); err != nil || !bytes.Equal(got, []byte("He")) { + t.Errorf("预期 SGV= 被宽松接受为 He(非规范尾部比特),得到 %x, err=%v", got, err) + } + // 而无填充解码器接受非规范尾部比特,但长度必须合法。 + if got, err := base64x.DecodeRaw("SGV"); err != nil || !bytes.Equal(got, []byte("He")) { + t.Errorf("无填充解码器预期接受 SGV 为 He,得到 %x, err=%v", got, err) + } + if got, err := base64x.DecodeRaw("AA"); err != nil || !bytes.Equal(got, []byte{0x00}) { + t.Errorf("无填充解码器预期接受 AA 为 00,得到 %x, err=%v", got, err) + } +} + +// TestPaddingSensitiveLengths 记录"长度与填充不匹配"时的实际行为: +// 带填充解码器要求输入长度为 4 的倍数,无填充解码器要求长度不为 4k+1。 +func TestPaddingSensitiveLengths(t *testing.T) { + // 应当成功的用例(合法长度)。 + ok := []struct { + name string + in string + dec func(string) ([]byte, error) + want []byte + }{ + {"标准 4 字节分组", "AAAA", base64x.Decode, []byte{0x00, 0x00, 0x00}}, + {"标准带填充 1 字节", "Zg==", base64x.Decode, []byte("f")}, + {"标准带填充 2 字节", "Zm8=", base64x.Decode, []byte("fo")}, + {"无填充 2 字符", "Zg", base64x.DecodeRaw, []byte("f")}, + {"无填充 3 字符", "Zm8", base64x.DecodeRaw, []byte("fo")}, + {"无填充 4 字符", "AAAA", base64x.DecodeRaw, []byte{0x00, 0x00, 0x00}}, + {"无填充 7 字符", "SGVsbG8", base64x.DecodeRaw, []byte("Hello")}, + {"无填充 URL 7 字符", "SGVsbG8", base64x.DecodeRawURL, []byte("Hello")}, + {"带填充 URL 8 字符", "SGVsbG8=", base64x.DecodeURL, []byte("Hello")}, + } + for _, c := range ok { + got, err := c.dec(c.in) + if err != nil { + t.Errorf("%s: 解码 %q 意外失败: %v", c.name, c.in, err) + continue + } + if !bytes.Equal(got, c.want) { + t.Errorf("%s: 解码 %q = %x, 期望 %x", c.name, c.in, got, c.want) + } + } + + // 应当失败的用例。 + failures := []struct { + name string + in string + dec func(string) ([]byte, error) + }{ + {"带填充解码器遇到缺失填充", "Zg", base64x.Decode}, + {"带填充解码器遇到缺失填充(2)", "Zm8", base64x.Decode}, + {"带填充解码器遇到长度非 4 倍数", "SGVsbG8", base64x.Decode}, + {"带填充 URL 解码器遇到缺失填充", "Zg", base64x.DecodeURL}, + {"带填充 URL 解码器遇到长度非 4 倍数", "SGVsbG8", base64x.DecodeURL}, + {"无填充解码器遇到 1 个残留字符", "Z", base64x.DecodeRaw}, + {"无填充 URL 解码器遇到 1 个残留字符", "Z", base64x.DecodeRawURL}, + {"带填充解码器遇到 1 个残留字符", "Z", base64x.Decode}, + {"无填充解码器遇到 5 个字符", "AAAAA", base64x.DecodeRaw}, + {"无填充解码器遇到带填充 4 字符", "Zg==", base64x.DecodeRaw}, + {"无填充 URL 解码器遇到带填充 4 字符", "Zg==", base64x.DecodeRawURL}, + } + for _, c := range failures { + got, err := c.dec(c.in) + if err == nil { + t.Errorf("%s: 解码 %q 应当失败,却成功得到 %x", c.name, c.in, got) + } + if got != nil { + t.Errorf("%s: 解码 %q 失败时应返回 nil,实际 %x", c.name, c.in, got) + } + } +} + +// TestDecodeEmptyInput 明确各变体对空串的影响(空串是合法的"零长度数据")。 +func TestDecodeEmptyInput(t *testing.T) { + for _, v := range variants { + decoded, err := v.decode("") + if err != nil { + t.Errorf("%s 解码空串应成功: %v", v.name, err) + continue + } + if len(decoded) != 0 { + t.Errorf("%s 解码空串得到 %d 字节,期望 0", v.name, len(decoded)) + } + } +} + +// TestCrossCheckWithStdlib 与标准库交叉验证,确保封装没有引入行为差异。 +func TestCrossCheckWithStdlib(t *testing.T) { + payload := []byte("cross check with encoding/base64") + + if got, want := base64x.Encode(payload), base64.StdEncoding.EncodeToString(payload); got != want { + t.Errorf("Encode = %q, 标准库 = %q", got, want) + } + if got, want := base64x.EncodeURL(payload), base64.URLEncoding.EncodeToString(payload); got != want { + t.Errorf("EncodeURL = %q, 标准库 = %q", got, want) + } + if got, want := base64x.EncodeRaw(payload), base64.RawStdEncoding.EncodeToString(payload); got != want { + t.Errorf("EncodeRaw = %q, 标准库 = %q", got, want) + } + if got, want := base64x.EncodeRawURL(payload), base64.RawURLEncoding.EncodeToString(payload); got != want { + t.Errorf("EncodeRawURL = %q, 标准库 = %q", got, want) + } + + decoded, err := base64x.Decode(base64.StdEncoding.EncodeToString(payload)) + if err != nil || !bytes.Equal(decoded, payload) { + t.Errorf("Decode 与标准库编码互操作失败: %v", err) + } +} + +// TestNoPanicOnAnyInput 用一批畸形输入整体验证解码与编码都不 panic。 +func TestNoPanicOnAnyInput(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("畸形输入导致 panic: %v", r) + } + }() + + inputs := []string{"", "=", "==", "====", "A", "AA", "AAA", "AAAA", "\x00\x01", strings.Repeat("=", 100), strings.Repeat("A", 1000)} + for _, in := range inputs { + _, _ = base64x.Decode(in) + _, _ = base64x.DecodeURL(in) + _, _ = base64x.DecodeRaw(in) + _, _ = base64x.DecodeRawURL(in) + _ = base64x.Encode([]byte(in)) + _ = base64x.EncodeRaw([]byte(in)) + } +} + +// isASCII 判断字符串是否只含 ASCII 可打印字符。 +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] > 0x7e { + return false + } + } + return true +} diff --git a/bcryptx/bcryptx.go b/bcryptx/bcryptx.go index 598bd6d..1869c27 100644 --- a/bcryptx/bcryptx.go +++ b/bcryptx/bcryptx.go @@ -1,19 +1,144 @@ +// Package bcryptx 提供基于 bcrypt 的口令哈希。 +// +// bcrypt 是经过长期考验的口令哈希函数,内置随机盐并把代价参数编码在输出串中, +// 因此适合直接存储。它的缺点是内存开销固定且较小,抗 GPU/ASIC 能力不如 +// Argon2id;新项目在无兼容性约束时建议优先考虑 argon2x。 +// +// # 使用要点 +// +// - 输出串已经包含算法标识、代价与盐,直接入库即可,不要自行加盐或再做哈希; +// - bcrypt 只使用口令的**前 72 字节**,本包会对超长口令直接报错而不是静默截断, +// 以免出现「不同口令却能互相通过校验」的意外; +// - 校验必须走本包的 VerifyPassword,内部使用恒定时间比较,绝不要用 == 比较 +// 哈希串。 package bcryptx -import "golang.org/x/crypto/bcrypt" +import ( + "errors" + "fmt" -// HashPassword 使用 bcrypt 算法对密码进行哈希处理 -func HashPassword(password string) (string, error) { - // 第二个参数是 cost 值,代表计算强度 - bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return "", err + "golang.org/x/crypto/bcrypt" +) + +const ( + // MinCost 是最小代价,仅适合测试:代价每 +1,计算量翻倍。 + MinCost = bcrypt.MinCost + // DefaultCost 是默认代价(10),在安全性与延迟之间较均衡。 + DefaultCost = bcrypt.DefaultCost + // MaxCost 是最大代价(31)。在普通硬件上几乎无法完成,慎用。 + MaxCost = bcrypt.MaxCost + // MaxPasswordLength 是 bcrypt 实际参与运算的口令长度上限(字节)。 + // + // 超出部分会被算法忽略,因此本包对超长口令返回错误,而不是静默截断: + // 静默截断会让两个不同的长口令互相通过校验。 + MaxPasswordLength = 72 +) + +// ErrInvalidHash 表示哈希串格式非法(不是合法的 bcrypt 输出)。 +var ErrInvalidHash = errors.New("bcryptx: invalid bcrypt hash") + +// ErrPasswordTooLong 表示口令超过 bcrypt 能处理的长度上限(MaxPasswordLength)。 +// +// 哈希与校验两条路径都会返回它,绝不静默截断:bcrypt 只使用前 72 字节,静默 +// 截断会让两个只有后缀不同的长口令互相通过校验。 +var ErrPasswordTooLong = errors.New("bcryptx: password is too long for bcrypt") + +// ValidCost 报告 cost 是否落在 bcrypt 支持的范围内。 +func ValidCost(cost int) bool { + return cost >= MinCost && cost <= MaxCost +} + +// HashPassword 使用默认代价(DefaultCost)对口令做哈希,返回可直接入库的字符串。 +func HashPassword(password []byte) (string, error) { + return HashPasswordWithCost(password, DefaultCost) +} + +// HashPasswordWithCost 使用指定代价对口令做哈希。 +// +// 代价越高越难被暴力破解,但验证时的耗时也同步增加。cost 必须落在 +// [MinCost, MaxCost] 内。每次调用都会使用新的随机盐,因此同一口令两次调用的 +// 结果不同,这是预期行为。 +func HashPasswordWithCost(password []byte, cost int) (string, error) { + if !ValidCost(cost) { + return "", fmt.Errorf("bcryptx: 代价 %d 超出 [%d,%d]", cost, MinCost, MaxCost) } - return string(bytes), nil + if len(password) == 0 { + return "", errors.New("bcryptx: 口令不能为空") + } + if len(password) > MaxPasswordLength { + return "", fmt.Errorf("%w: 长度 %d 超过上限 %d 字节;"+ + "超出部分会被 bcrypt 忽略,静默截断会让不同长口令互相通过校验;"+ + "请改用 argon2x 或先对长口令做一次摘要", + ErrPasswordTooLong, len(password), MaxPasswordLength) + } + + hashed, err := bcrypt.GenerateFromPassword(password, cost) + if err != nil { + return "", fmt.Errorf("bcryptx: 生成本口令哈希失败: %w", err) + } + return string(hashed), nil } -// CheckPasswordHash 检查密码是否与哈希值匹配 +// VerifyPassword 校验口令是否与哈希匹配。 +// +// 返回值语义经过刻意区分: +// - (true, nil):匹配; +// - (false, nil):不匹配(口令错误)——这是正常的业务分支; +// - (false, err):哈希串非法、或口令超过 bcrypt 的长度上限——属于调用方 +// 需要处理的问题,应当记录告警而不是当作"密码错误"。 +// +// 与 HashPassword 一致,本函数**不会截断**超长口令:若允许截断,任何超过 72 字节 +// 且在 72 字节处共享前缀的口令都能通过校验。 +// +// 内部使用 bcrypt 自带的恒定时间比较,不会泄漏时序信息。 +func VerifyPassword(password []byte, hash string) (bool, error) { + if hash == "" { + return false, ErrInvalidHash + } + if len(password) > MaxPasswordLength { + return false, fmt.Errorf("%w: 长度 %d 超过上限 %d 字节", ErrPasswordTooLong, len(password), MaxPasswordLength) + } + err := bcrypt.CompareHashAndPassword([]byte(hash), password) + switch { + case err == nil: + return true, nil + case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): + return false, nil + case errors.Is(err, bcrypt.ErrHashTooShort): + return false, fmt.Errorf("%w: %w", ErrInvalidHash, err) + default: + return false, fmt.Errorf("%w: %w", ErrInvalidHash, err) + } +} + +// CheckPasswordHash 是 VerifyPassword 的便捷布尔包装。 +// +// 它把「哈希串非法」与「口令不匹配」都归为 false,适合不关心失败原因的场合。 +// 需要区分两者时请使用 VerifyPassword。 func CheckPasswordHash(password, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil + ok, err := VerifyPassword([]byte(password), hash) + return err == nil && ok +} + +// Cost 从哈希串中解析出代价参数。 +// +// 用于在登录成功后判断旧哈希是否需要按新的代价标准重算。 +func Cost(hash string) (int, error) { + cost, err := bcrypt.Cost([]byte(hash)) + if err != nil { + return 0, fmt.Errorf("%w: %w", ErrInvalidHash, err) + } + return cost, nil +} + +// NeedsRehash 报告给定哈希的代价是否低于 targetCost。 +// +// 典型用法:登录校验通过后调用本函数,若为 true 则用新代价重新哈希并更新存储, +// 从而在不强制用户改密的前提下逐步提升整体强度。 +func NeedsRehash(hash string, targetCost int) bool { + cost, err := Cost(hash) + if err != nil { + return true + } + return cost < targetCost } diff --git a/bcryptx/bcryptx_test.go b/bcryptx/bcryptx_test.go index d101bbc..65db433 100644 --- a/bcryptx/bcryptx_test.go +++ b/bcryptx/bcryptx_test.go @@ -1,20 +1,195 @@ package bcryptx_test import ( + "bytes" + "errors" + "strings" "testing" "code.yun.ink/pkg/encryptx/bcryptx" ) -func TestDecryptBcrypt(t *testing.T) { - str, err := bcryptx.HashPassword("password") +func TestHashAndVerify(t *testing.T) { + password := []byte("correct horse battery staple") + + hash, err := bcryptx.HashPassword(password) if err != nil { - t.Error(err) + t.Fatalf("HashPassword 失败: %v", err) } - t.Log(str) - if bcryptx.CheckPasswordHash("password", str) { - t.Log("ok") - } else { - t.Error("error") + if !strings.HasPrefix(hash, "$2") { + t.Errorf("哈希串前缀异常: %q", hash) + } + + ok, err := bcryptx.VerifyPassword(password, hash) + if err != nil { + t.Fatalf("VerifyPassword 返回错误: %v", err) + } + if !ok { + t.Error("正确口令校验失败") + } + + // 错误口令:必须是 (false, nil),而不是 error + ok, err = bcryptx.VerifyPassword([]byte("wrong password"), hash) + if err != nil { + t.Errorf("错误口令不应返回 error, 得到 %v", err) + } + if ok { + t.Error("错误口令通过了校验") + } + + if !bcryptx.CheckPasswordHash(string(password), hash) { + t.Error("CheckPasswordHash 对正确口令返回 false") + } + if bcryptx.CheckPasswordHash("wrong", hash) { + t.Error("CheckPasswordHash 对错误口令返回 true") + } +} + +// TestSaltIsRandom 验证同一口令两次哈希结果不同(内置随机盐)。 +func TestSaltIsRandom(t *testing.T) { + password := []byte("same password") + a, err := bcryptx.HashPassword(password) + if err != nil { + t.Fatalf("HashPassword 失败: %v", err) + } + b, err := bcryptx.HashPassword(password) + if err != nil { + t.Fatalf("HashPassword 失败: %v", err) + } + if a == b { + t.Error("两次哈希结果相同,随机盐可能未生效") + } + // 但两者都必须能校验通过 + for i, hash := range []string{a, b} { + if !bcryptx.CheckPasswordHash(string(password), hash) { + t.Errorf("哈希 #%d 校验失败", i) + } + } +} + +func TestCostValidation(t *testing.T) { + for _, cost := range []int{bcryptx.DefaultCost, bcryptx.MinCost, bcryptx.MaxCost} { + if !bcryptx.ValidCost(cost) { + t.Errorf("ValidCost(%d) = false, 期望 true", cost) + } + } + for _, cost := range []int{-1, 0, 3, 32, 100} { + if bcryptx.ValidCost(cost) { + t.Errorf("ValidCost(%d) = true, 期望 false", cost) + } + if _, err := bcryptx.HashPasswordWithCost([]byte("pw"), cost); err == nil { + t.Errorf("HashPasswordWithCost(cost=%d) 期望错误, 得到 nil", cost) + } + } +} + +// TestExplicitCost 用 MinCost 保证测试快速,同时验证代价被写入哈希串。 +func TestExplicitCost(t *testing.T) { + hash, err := bcryptx.HashPasswordWithCost([]byte("pw"), bcryptx.MinCost) + if err != nil { + t.Fatalf("HashPasswordWithCost 失败: %v", err) + } + cost, err := bcryptx.Cost(hash) + if err != nil { + t.Fatalf("Cost 失败: %v", err) + } + if cost != bcryptx.MinCost { + t.Errorf("解析出的代价 = %d, 期望 %d", cost, bcryptx.MinCost) + } +} + +// TestLongPasswordRejected 验证超长口令被拒绝而不是被静默截断到 72 字节。 +// +// bcrypt 只使用前 72 字节;若静默截断,两个前 72 字节相同的不同口令将互相 +// 通过校验,这是一个容易被忽略的认证缺陷。 +func TestLongPasswordRejected(t *testing.T) { + tooLong := bytes.Repeat([]byte("a"), bcryptx.MaxPasswordLength+1) + if _, err := bcryptx.HashPassword(tooLong); !errors.Is(err, bcryptx.ErrPasswordTooLong) { + t.Errorf("超长口令期望 ErrPasswordTooLong, 得到 %v", err) + } + + // 恰好 72 字节应当可用 + exact := bytes.Repeat([]byte("a"), bcryptx.MaxPasswordLength) + hash, err := bcryptx.HashPasswordWithCost(exact, bcryptx.MinCost) + if err != nil { + t.Fatalf("72 字节口令应当被接受, 得到 %v", err) + } + if !bcryptx.CheckPasswordHash(string(exact), hash) { + t.Error("72 字节口令校验失败") + } + + // 关键回归:校验路径也必须拒绝超长口令,而不是截断到 72 字节后放行。 + // + // bcrypt 只看前 72 字节,若校验时不校验长度,任何"前 72 字节与真实口令相同" + // 的超长口令都会通过——等于凭空扩大了可接受的口令集合。 + ok, err := bcryptx.VerifyPassword(tooLong, hash) + if !errors.Is(err, bcryptx.ErrPasswordTooLong) { + t.Errorf("超长口令校验期望 ErrPasswordTooLong, 得到 %v", err) + } + if ok { + t.Error("超长口令被截断后通过了校验(安全缺陷)") + } + if bcryptx.CheckPasswordHash(string(tooLong), hash) { + t.Error("CheckPasswordHash 对超长口令返回 true") + } +} + +func TestEmptyPasswordRejected(t *testing.T) { + if _, err := bcryptx.HashPassword(nil); err == nil { + t.Error("空口令期望被拒绝") + } + if _, err := bcryptx.HashPassword([]byte{}); err == nil { + t.Error("空口令期望被拒绝") + } +} + +func TestInvalidHashInputs(t *testing.T) { + badHashes := []string{ + "", + "not-a-hash", + "$2a$10$", + "$2a$10$tooshort", + "$9x$10$abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstu", + strings.Repeat("x", 100), + } + for _, bad := range badHashes { + // 不得 panic + ok, err := bcryptx.VerifyPassword([]byte("pw"), bad) + if ok { + t.Errorf("非法哈希 %q 校验返回 true", bad) + } + if err == nil { + t.Errorf("非法哈希 %q 期望返回 error, 得到 nil", bad) + } + if !errors.Is(err, bcryptx.ErrInvalidHash) { + t.Errorf("非法哈希 %q: 期望 ErrInvalidHash, 得到 %v", bad, err) + } + + if bcryptx.CheckPasswordHash("pw", bad) { + t.Errorf("CheckPasswordHash 对非法哈希 %q 返回 true", bad) + } + if _, err := bcryptx.Cost(bad); err == nil { + t.Errorf("Cost(%q) 期望错误", bad) + } + } +} + +func TestNeedsRehash(t *testing.T) { + hash, err := bcryptx.HashPasswordWithCost([]byte("pw"), bcryptx.MinCost) + if err != nil { + t.Fatalf("HashPasswordWithCost 失败: %v", err) + } + + // 当前代价低于目标 → 需要重算 + if !bcryptx.NeedsRehash(hash, bcryptx.DefaultCost) { + t.Error("低代价哈希应当需要重算") + } + // 当前代价等于目标 → 不需要 + if bcryptx.NeedsRehash(hash, bcryptx.MinCost) { + t.Error("代价已达标不应需要重算") + } + // 无法解析的哈希 → 视为需要重算 + if !bcryptx.NeedsRehash("garbage", bcryptx.DefaultCost) { + t.Error("非法哈希应当需要重算") } } diff --git a/blake2x/blake2x.go b/blake2x/blake2x.go new file mode 100644 index 0000000..85bacc6 --- /dev/null +++ b/blake2x/blake2x.go @@ -0,0 +1,227 @@ +// Package blake2x 提供 BLAKE2b 与 BLAKE2s 摘要计算,以及十六进制/Base64 编码与流式、文件接口。 +// +// BLAKE2 是现代高速哈希(RFC 7693),在同等安全强度下通常比 SHA-2 更快, +// 常用于校验和、内容寻址(如 IPFS CID)与文件指纹。 +// +// 命名约定: +// - 带 B 后缀的函数(Sum256B、New256B、Hex256B 等)使用 BLAKE2b,摘要长度 1..64 字节; +// - 带 S 后缀的函数(Sum256S、New256S、Hex256S 等)使用 BLAKE2s,摘要长度固定 256 位; +// - Sum512、Sum384 是 BLAKE2b 的固定长度便捷函数; +// - 不带后缀的 Sum、Hex、Base64、New、SumReader、HexReader、HexFile 等价于 BLAKE2b-512(默认变体)。 +// +// 注意 BLAKE2b-256 与 BLAKE2s-256 摘要长度相同但结果完全不同,不可互换。 +// +// 本包只暴露无密钥(unkeyed)哈希。带密钥的 BLAKE2 等同于 MAC,应按 MAC 的规范使用, +// 请使用专门的 MAC 包(如 hmacx)或直接调用 golang.org/x/crypto/blake2b.New(size, key)。 +package blake2x + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "hash" + "io" + "os" + + "golang.org/x/crypto/blake2b" + "golang.org/x/crypto/blake2s" +) + +const ( + // BlockSizeB 是 BLAKE2b 分组的字节数(128)。 + BlockSizeB = blake2b.BlockSize + // BlockSizeS 是 BLAKE2s 分组的字节数(64)。 + BlockSizeS = blake2s.BlockSize + // SizeB512 是 BLAKE2b-512 摘要的字节数(64)。 + SizeB512 = blake2b.Size + // SizeB384 是 BLAKE2b-384 摘要的字节数(48)。 + SizeB384 = blake2b.Size384 + // SizeB256 是 BLAKE2b-256 摘要的字节数(32)。 + SizeB256 = blake2b.Size256 + // SizeS256 是 BLAKE2s-256 摘要的字节数(32)。 + SizeS256 = blake2s.Size + // MinSizeB 是 BLAKE2b 允许的最小摘要字节数。 + MinSizeB = 1 + // MaxSizeB 是 BLAKE2b 允许的最大摘要字节数(64)。 + MaxSizeB = blake2b.Size +) + +// NewB 按指定摘要长度创建 BLAKE2b 哈希;size 必须在 [MinSizeB, MaxSizeB] 内,否则返回 error。 +func NewB(size int) (hash.Hash, error) { + if size < MinSizeB || size > MaxSizeB { + return nil, fmt.Errorf("blake2x: BLAKE2b 摘要长度 %d 超出范围 [%d, %d]", size, MinSizeB, MaxSizeB) + } + h, err := blake2b.New(size, nil) + if err != nil { + return nil, fmt.Errorf("blake2x: 创建 BLAKE2b-%d 失败: %w", size, err) + } + return h, nil +} + +// New512 返回 BLAKE2b-512 哈希。 +func New512() (hash.Hash, error) { + return NewB(SizeB512) +} + +// New384 返回 BLAKE2b-384 哈希。 +func New384() (hash.Hash, error) { + return NewB(SizeB384) +} + +// New256B 返回 BLAKE2b-256 哈希。 +func New256B() (hash.Hash, error) { + return NewB(SizeB256) +} + +// New256S 返回 BLAKE2s-256 哈希。 +func New256S() (hash.Hash, error) { + h, err := blake2s.New256(nil) + if err != nil { + return nil, fmt.Errorf("blake2x: 创建 BLAKE2s-256 失败: %w", err) + } + return h, nil +} + +// New 等价于 New512。 +func New() (hash.Hash, error) { + return New512() +} + +// SumB 返回 data 的 BLAKE2b 摘要,长度由 size 指定; +// size 必须在 [MinSizeB, MaxSizeB] 内,否则返回 error。 +func SumB(size int, data []byte) ([]byte, error) { + h, err := NewB(size) + if err != nil { + return nil, err + } + _, _ = h.Write(data) + return h.Sum(nil), nil +} + +// Sum512 返回 data 的 BLAKE2b-512 摘要(SizeB512 字节原始数据)。 +func Sum512(data []byte) []byte { + s := blake2b.Sum512(data) + return s[:] +} + +// Sum384 返回 data 的 BLAKE2b-384 摘要(SizeB384 字节原始数据)。 +func Sum384(data []byte) []byte { + s := blake2b.Sum384(data) + return s[:] +} + +// Sum256B 返回 data 的 BLAKE2b-256 摘要(SizeB256 字节原始数据)。 +func Sum256B(data []byte) []byte { + s := blake2b.Sum256(data) + return s[:] +} + +// Sum256S 返回 data 的 BLAKE2s-256 摘要(SizeS256 字节原始数据)。 +func Sum256S(data []byte) []byte { + s := blake2s.Sum256(data) + return s[:] +} + +// Sum 等价于 Sum512。 +func Sum(data []byte) []byte { + return Sum512(data) +} + +// HexB 返回 data 的 BLAKE2b 摘要的小写十六进制字符串,长度由 size 指定。 +func HexB(size int, data []byte) (string, error) { + sum, err := SumB(size, data) + if err != nil { + return "", err + } + return hex.EncodeToString(sum), nil +} + +// Hex512 返回 data 的 BLAKE2b-512 摘要的小写十六进制字符串。 +func Hex512(data []byte) string { + return hex.EncodeToString(Sum512(data)) +} + +// Hex384 返回 data 的 BLAKE2b-384 摘要的小写十六进制字符串。 +func Hex384(data []byte) string { + return hex.EncodeToString(Sum384(data)) +} + +// Hex256B 返回 data 的 BLAKE2b-256 摘要的小写十六进制字符串。 +func Hex256B(data []byte) string { + return hex.EncodeToString(Sum256B(data)) +} + +// Hex256S 返回 data 的 BLAKE2s-256 摘要的小写十六进制字符串。 +func Hex256S(data []byte) string { + return hex.EncodeToString(Sum256S(data)) +} + +// Hex 等价于 Hex512。 +func Hex(data []byte) string { + return Hex512(data) +} + +// Base64Sum512 返回 data 的 BLAKE2b-512 摘要的标准 Base64 字符串。 +func Base64Sum512(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum512(data)) +} + +// Base64Sum384 返回 data 的 BLAKE2b-384 摘要的标准 Base64 字符串。 +func Base64Sum384(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum384(data)) +} + +// Base64Sum256B 返回 data 的 BLAKE2b-256 摘要的标准 Base64 字符串。 +func Base64Sum256B(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum256B(data)) +} + +// Base64Sum256S 返回 data 的 BLAKE2s-256 摘要的标准 Base64 字符串。 +func Base64Sum256S(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum256S(data)) +} + +// Base64 等价于 Base64Sum512。 +func Base64(data []byte) string { + return Base64Sum512(data) +} + +// SumReader 读取 r 中的全部数据并返回其 BLAKE2b-512 摘要。 +func SumReader(r io.Reader) ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("blake2x: 读取器为 nil") + } + h, err := New512() + if err != nil { + return nil, err + } + if _, err := io.Copy(h, r); err != nil { + return nil, fmt.Errorf("blake2x: 读取数据失败: %w", err) + } + return h.Sum(nil), nil +} + +// HexReader 读取 r 中的全部数据并返回其 BLAKE2b-512 摘要的小写十六进制字符串。 +func HexReader(r io.Reader) (string, error) { + sum, err := SumReader(r) + if err != nil { + return "", err + } + return hex.EncodeToString(sum), nil +} + +// HexFile 计算 path 所指向文件的 BLAKE2b-512 摘要并返回小写十六进制字符串; +// 文件不存在或读取失败时返回包装后的 error,不 panic。 +func HexFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("blake2x: 打开文件 %q 失败: %w", path, err) + } + defer f.Close() + + sum, err := SumReader(f) + if err != nil { + return "", fmt.Errorf("blake2x: 计算文件 %q 摘要失败: %w", path, err) + } + return hex.EncodeToString(sum), nil +} diff --git a/blake2x/blake2x_test.go b/blake2x/blake2x_test.go new file mode 100644 index 0000000..a07df0c --- /dev/null +++ b/blake2x/blake2x_test.go @@ -0,0 +1,348 @@ +package blake2x_test + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "code.yun.ink/pkg/encryptx/blake2x" +) + +// 测试向量来源:RFC 7693 / BLAKE2 官方测试向量,并经 Python 3 hashlib 独立复算确认。 +var vectors = []struct { + name string + newHash func() (hashLike, error) + sum func([]byte) []byte + hex func([]byte) string + b64 func([]byte) string + size int + empty string + abc string +}{ + { + name: "BLAKE2b-512", + newHash: func() (hashLike, error) { return blake2x.New512() }, + sum: blake2x.Sum512, + hex: blake2x.Hex512, + b64: blake2x.Base64Sum512, + size: blake2x.SizeB512, + empty: "786a02f742015903c6c6fd852552d272912f4740e15847618a86e217f71f5419d25e1031afee585313896444934eb04b903a685b1448b755d56f701afe9be2ce", + abc: "ba80a53f981c4d0d6a2797b69f12f6e94c212f14685ac4b74b12bb6fdbffa2d17d87c5392aab792dc252d5de4533cc9518d38aa8dbf1925ab92386edd4009923", + }, + { + name: "BLAKE2b-384", + newHash: func() (hashLike, error) { return blake2x.New384() }, + sum: blake2x.Sum384, + hex: blake2x.Hex384, + b64: blake2x.Base64Sum384, + size: blake2x.SizeB384, + empty: "b32811423377f52d7862286ee1a72ee540524380fda1724a6f25d7978c6fd3244a6caf0498812673c5e05ef583825100", + abc: "6f56a82c8e7ef526dfe182eb5212f7db9df1317e57815dbda46083fc30f54ee6c66ba83be64b302d7cba6ce15bb556f4", + }, + { + name: "BLAKE2b-256", + newHash: func() (hashLike, error) { return blake2x.New256B() }, + sum: blake2x.Sum256B, + hex: blake2x.Hex256B, + b64: blake2x.Base64Sum256B, + size: blake2x.SizeB256, + empty: "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8", + abc: "bddd813c634239723171ef3fee98579b94964e3bb1cb3e427262c8c068d52319", + }, + { + name: "BLAKE2s-256", + newHash: func() (hashLike, error) { return blake2x.New256S() }, + sum: blake2x.Sum256S, + hex: blake2x.Hex256S, + b64: blake2x.Base64Sum256S, + size: blake2x.SizeS256, + empty: "69217a3079908094e11121d042354a7c1f55b6482ca1a51e1b250dfd1ed0eef9", + abc: "508c5e8c327c14e2e1a72ba34eeb452f37458b209ed63a294d999b4c86675982", + }, +} + +type hashLike interface { + Write([]byte) (int, error) + Sum([]byte) []byte + Size() int +} + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("模拟读取失败") } + +func TestConstants(t *testing.T) { + if blake2x.BlockSizeB != 128 || blake2x.BlockSizeS != 64 { + t.Errorf("BlockSizeB/BlockSizeS = %d/%d, 期望 128/64", blake2x.BlockSizeB, blake2x.BlockSizeS) + } + if blake2x.SizeB512 != 64 || blake2x.SizeB384 != 48 || blake2x.SizeB256 != 32 || blake2x.SizeS256 != 32 { + t.Errorf("摘要长度常量错误: %d/%d/%d/%d", blake2x.SizeB512, blake2x.SizeB384, blake2x.SizeB256, blake2x.SizeS256) + } + if blake2x.MinSizeB != 1 || blake2x.MaxSizeB != 64 { + t.Errorf("MinSizeB/MaxSizeB = %d/%d, 期望 1/64", blake2x.MinSizeB, blake2x.MaxSizeB) + } +} + +func TestVectors(t *testing.T) { + for _, v := range vectors { + t.Run(v.name, func(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"空串", "", v.empty}, + {"abc", "abc", v.abc}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + data := []byte(tc.input) + sum := v.sum(data) + if len(sum) != v.size { + t.Fatalf("Sum 长度 = %d, 期望 %d", len(sum), v.size) + } + if got := hex.EncodeToString(sum); got != tc.want { + t.Errorf("Sum = %s, 期望 %s", got, tc.want) + } + if got := v.hex(data); got != tc.want { + t.Errorf("Hex = %s, 期望 %s", got, tc.want) + } + if want := base64.StdEncoding.EncodeToString(sum); v.b64(data) != want { + t.Errorf("Base64 = %s, 期望 %s", v.b64(data), want) + } + h, err := v.newHash() + if err != nil { + t.Fatalf("创建哈希失败: %v", err) + } + if h.Size() != v.size { + t.Errorf("Size() = %d, 期望 %d", h.Size(), v.size) + } + if _, err := h.Write(data); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != tc.want { + t.Errorf("New+Write = %s, 期望 %s", got, tc.want) + } + }) + } + }) + } +} + +func TestDefaultAliasesMatch512(t *testing.T) { + data := []byte("encryptx blake2 alias check") + if !bytes.Equal(blake2x.Sum(data), blake2x.Sum512(data)) { + t.Error("Sum 应等价于 Sum512") + } + if blake2x.Hex(data) != blake2x.Hex512(data) { + t.Error("Hex 应等价于 Hex512") + } + if blake2x.Base64(data) != blake2x.Base64Sum512(data) { + t.Error("Base64 应等价于 Base64Sum512") + } + h, err := blake2x.New() + if err != nil { + t.Fatalf("New 失败: %v", err) + } + if h.Size() != blake2x.SizeB512 { + t.Errorf("New().Size() = %d, 期望 %d", h.Size(), blake2x.SizeB512) + } +} + +func TestVariant256BAnd256SDiffer(t *testing.T) { + data := []byte("abc") + if blake2x.Hex256B(data) == blake2x.Hex256S(data) { + t.Error("BLAKE2b-256 与 BLAKE2s-256 的摘要不应相同") + } +} + +func TestSumBSizes(t *testing.T) { + data := []byte("arbitrary length blake2b") + for size := blake2x.MinSizeB; size <= blake2x.MaxSizeB; size++ { + sum, err := blake2x.SumB(size, data) + if err != nil { + t.Fatalf("SumB(%d) 失败: %v", size, err) + } + if len(sum) != size { + t.Errorf("SumB(%d) 长度 = %d", size, len(sum)) + } + h, err := blake2x.NewB(size) + if err != nil { + t.Fatalf("NewB(%d) 失败: %v", size, err) + } + if h.Size() != size { + t.Errorf("NewB(%d).Size() = %d", size, h.Size()) + } + if _, err := h.Write(data); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if !bytes.Equal(h.Sum(nil), sum) { + t.Errorf("SumB(%d) 与 NewB(%d) 结果不一致", size, size) + } + hexStr, err := blake2x.HexB(size, data) + if err != nil { + t.Fatalf("HexB(%d) 失败: %v", size, err) + } + if hexStr != hex.EncodeToString(sum) { + t.Errorf("HexB(%d) 与 SumB(%d) 结果不一致", size, size) + } + } + + if got, err := blake2x.SumB(blake2x.SizeB512, data); err != nil { + t.Fatalf("SumB(64) 失败: %v", err) + } else if !bytes.Equal(got, blake2x.Sum512(data)) { + t.Error("SumB(64) 应等于 Sum512") + } + if got, err := blake2x.SumB(blake2x.SizeB384, data); err != nil { + t.Fatalf("SumB(48) 失败: %v", err) + } else if !bytes.Equal(got, blake2x.Sum384(data)) { + t.Error("SumB(48) 应等于 Sum384") + } + if got, err := blake2x.SumB(blake2x.SizeB256, data); err != nil { + t.Fatalf("SumB(32) 失败: %v", err) + } else if !bytes.Equal(got, blake2x.Sum256B(data)) { + t.Error("SumB(32) 应等于 Sum256B") + } +} + +func TestInvalidSizes(t *testing.T) { + for _, size := range []int{-1, 0, 65, 128, 1 << 20} { + if _, err := blake2x.NewB(size); err == nil { + t.Errorf("NewB(%d) 应返回 error", size) + } + if _, err := blake2x.SumB(size, []byte("x")); err == nil { + t.Errorf("SumB(%d) 应返回 error", size) + } + if _, err := blake2x.HexB(size, []byte("x")); err == nil { + t.Errorf("HexB(%d) 应返回 error", size) + } + } +} + +func TestStreamingMatchesSum(t *testing.T) { + data := []byte(strings.Repeat("encryptx-blake2-", 128)) + for _, v := range vectors { + t.Run(v.name, func(t *testing.T) { + h, err := v.newHash() + if err != nil { + t.Fatalf("创建哈希失败: %v", err) + } + for i := 0; i < len(data); i += 23 { + end := i + 23 + if end > len(data) { + end = len(data) + } + if _, err := h.Write(data[i:end]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + } + if got, want := h.Sum(nil), v.sum(data); !bytes.Equal(got, want) { + t.Errorf("分块写入 = %x, 期望 %x", got, want) + } + }) + } +} + +func TestReaderConsistency(t *testing.T) { + const s = "encryptx blake2 reader" + sum, err := blake2x.SumReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("SumReader 失败: %v", err) + } + if want := blake2x.Sum512([]byte(s)); !bytes.Equal(sum, want) { + t.Errorf("SumReader = %x, 期望 %x", sum, want) + } + hexStr, err := blake2x.HexReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if want := blake2x.Hex512([]byte(s)); hexStr != want { + t.Errorf("HexReader = %s, 期望 %s", hexStr, want) + } +} + +func TestReaderErrors(t *testing.T) { + if _, err := blake2x.SumReader(nil); err == nil { + t.Fatal("nil 读取器应返回 error") + } + if _, err := blake2x.HexReader(nil); err == nil { + t.Fatal("nil 读取器应返回 error") + } + if _, err := blake2x.SumReader(failingReader{}); err == nil { + t.Fatal("读取失败时应返回 error") + } +} + +func TestHexFile(t *testing.T) { + dir := t.TempDir() + content := []byte("文件内容 file content\n") + path := filepath.Join(dir, "sample.bin") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("写入测试文件失败: %v", err) + } + got, err := blake2x.HexFile(path) + if err != nil { + t.Fatalf("HexFile 失败: %v", err) + } + if want := blake2x.Hex512(content); got != want { + t.Errorf("HexFile = %s, 期望 %s", got, want) + } +} + +func TestHexFileErrors(t *testing.T) { + if _, err := blake2x.HexFile(filepath.Join(t.TempDir(), "不存在.bin")); err == nil { + t.Fatal("不存在的文件应返回 error") + } + if _, err := blake2x.HexFile(""); err == nil { + t.Fatal("空路径应返回 error") + } + if _, err := blake2x.HexFile(t.TempDir()); err == nil { + t.Fatal("目录路径应返回 error") + } +} + +func TestLargeInput(t *testing.T) { + data := largeData(1 << 20) + for _, v := range vectors { + t.Run(v.name, func(t *testing.T) { + want := v.hex(data) + if got := v.hex(data); got != want { + t.Errorf("重复计算结果不一致: %s vs %s", got, want) + } + h, err := v.newHash() + if err != nil { + t.Fatalf("创建哈希失败: %v", err) + } + if _, err := h.Write(data[:1<<19]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if _, err := h.Write(data[1<<19:]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + t.Errorf("1MiB 分块 = %s, 期望 %s", got, want) + } + }) + } + got, err := blake2x.HexReader(bytes.NewReader(data)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if want := blake2x.Hex512(data); got != want { + t.Errorf("1MiB HexReader = %s, 期望 %s", got, want) + } +} + +// largeData 生成确定性的 n 字节数据。 +func largeData(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i*31 + i/7) + } + return b +} diff --git a/blowfishx/blowfishx.go b/blowfishx/blowfishx.go new file mode 100644 index 0000000..ee444b5 --- /dev/null +++ b/blowfishx/blowfishx.go @@ -0,0 +1,229 @@ +// Package blowfishx 提供 Blowfish 分组密码的加解密封装,支持 ECB/CBC/CFB/OFB/CTR。 +// +// Blowfish 本身没有被攻破,但它的分组长度只有 64 位,与 3DES 一样存在 Sweet32 +// 风险:同一密钥下加密超过约 32 GB 数据后,生日攻击可能恢复明文分组。因此它 +// 适合短消息,不适合大批量数据。作为 AES 的替代品,ChaCha20-Poly1305 或 +// AES-GCM 是更好的选择。 +// +// 另外,本包所有模式都**不提供完整性保护**,密文可被篡改而不被发现。若需要 +// 完整性,请使用 AEAD 或额外配合 HMAC 做「先加密后认证」。 +package blowfishx + +import ( + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + + //lint:ignore SA1019 Blowfish 已被上游标记为废弃(建议改用 AES 或 ChaCha20);本包保留它只为与既有系统互操作,包注释已说明其 64 位分组的 Sweet32 风险。 + "golang.org/x/crypto/blowfish" + + "code.yun.ink/pkg/encryptx/cipherx" +) + +const ( + // BlockSize 是 Blowfish 的分组长度(字节)。64 位分组存在 Sweet32 风险。 + BlockSize = blowfish.BlockSize + // MinKeySize 是本包允许的最小密钥长度(字节)。 + // + // 底层实现接受 1 字节起的任意长度,但过短的密钥强度过低,故本包要求至少 4 字节。 + MinKeySize = 4 + // MaxKeySize 是 Blowfish 允许的最大密钥长度(字节)。 + MaxKeySize = 56 + // DefaultKeySize 是推荐的默认密钥长度(字节)。 + DefaultKeySize = 32 +) + +// Suite 绑定 Blowfish 的分组构造方式。密钥长度由底层实现校验(1..56 字节), +// Suite 会把失败统一包装为 cipherx.ErrInvalidKeySize。 +// +// 导出它是为了让调用方能够以统一方式驱动任意模式,而无需在本包与 cipherx +// 之间写 switch。 +var Suite = cipherx.NewSuite("Blowfish", newBlock, BlockSize) + +// newBlock 把 blowfish.NewCipher 适配为 cipherx.Suite 需要的签名。 +func newBlock(key []byte) (cipher.Block, error) { + return blowfish.NewCipher(key) +} + +// GenerateKey 用 crypto/rand 生成指定长度的 Blowfish 密钥。 +// +// keySize 必须在 [MinKeySize, MaxKeySize] 范围内;不确定时使用 DefaultKeySize。 +func GenerateKey(keySize int) ([]byte, error) { + if keySize < MinKeySize || keySize > MaxKeySize { + return nil, fmt.Errorf("blowfishx: 密钥长度 %d 超出 [%d,%d]: %w", + keySize, MinKeySize, MaxKeySize, cipherx.ErrInvalidKeySize) + } + key := make([]byte, keySize) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return nil, fmt.Errorf("blowfishx: 生成密钥失败: %w", err) + } + return key, nil +} + +// GenerateKeyHex 生成 Blowfish 密钥并以十六进制字符串返回。 +// +// 注意:返回值是**编码后的文本**,不能直接当密钥使用。读回后必须先解码 +// (encoding/hex 的 DecodeString,或本库的 hexx.Decode),再传给 Encrypt/Decrypt。 +func GenerateKeyHex(keySize int) (string, error) { + key, err := GenerateKey(keySize) + if err != nil { + return "", err + } + return hex.EncodeToString(key), nil +} + +// ---- ECB ---- +// +// 警告:ECB 不具备语义安全性,相同的明文分组会产生相同的密文分组。 + +// EncryptECB 使用 Blowfish-ECB 加密并做 PKCS#7 填充。 +func EncryptECB(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.ECB, plaintext, key) +} + +// DecryptECB 解密 Blowfish-ECB 密文。 +func DecryptECB(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.ECB, ciphertext, key) +} + +// EncryptECBHex 与 EncryptECB 相同,返回大写十六进制字符串。 +func EncryptECBHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.ECB, plaintext, key) +} + +// DecryptECBHex 解密十六进制 Blowfish-ECB 密文,大小写均可。 +func DecryptECBHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.ECB, ciphertextHex, key) +} + +// ---- CBC ---- + +// EncryptCBC 使用 Blowfish-CBC 加密,随机 IV 前置在密文之前。 +func EncryptCBC(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.CBC, plaintext, key) +} + +// DecryptCBC 解密 Blowfish-CBC 密文(IV 前置格式)。 +func DecryptCBC(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.CBC, ciphertext, key) +} + +// EncryptCBCHex 与 EncryptCBC 相同,返回大写十六进制字符串。 +func EncryptCBCHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.CBC, plaintext, key) +} + +// DecryptCBCHex 解密十六进制 Blowfish-CBC 密文。 +func DecryptCBCHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.CBC, ciphertextHex, key) +} + +// EncryptCBCWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +// +// 仅用于与规定了固定 IV 的既有系统互操作。 +func EncryptCBCWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.CBC, plaintext, key, iv) +} + +// DecryptCBCWithIV 使用调用方指定的 IV 解密。 +func DecryptCBCWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.CBC, ciphertext, key, iv) +} + +// ---- CFB ---- + +// EncryptCFB 使用 Blowfish-CFB 加密,随机 IV 前置在密文之前。 +func EncryptCFB(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.CFB, plaintext, key) +} + +// DecryptCFB 解密 Blowfish-CFB 密文(IV 前置格式)。 +func DecryptCFB(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.CFB, ciphertext, key) +} + +// EncryptCFBHex 与 EncryptCFB 相同,返回大写十六进制字符串。 +func EncryptCFBHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.CFB, plaintext, key) +} + +// DecryptCFBHex 解密十六进制 Blowfish-CFB 密文。 +func DecryptCFBHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.CFB, ciphertextHex, key) +} + +// EncryptCFBWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +func EncryptCFBWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.CFB, plaintext, key, iv) +} + +// DecryptCFBWithIV 使用调用方指定的 IV 解密。 +func DecryptCFBWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.CFB, ciphertext, key, iv) +} + +// ---- OFB ---- + +// EncryptOFB 使用 Blowfish-OFB 加密,随机 IV 前置在密文之前。 +func EncryptOFB(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.OFB, plaintext, key) +} + +// DecryptOFB 解密 Blowfish-OFB 密文(IV 前置格式)。 +func DecryptOFB(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.OFB, ciphertext, key) +} + +// EncryptOFBHex 与 EncryptOFB 相同,返回大写十六进制字符串。 +func EncryptOFBHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.OFB, plaintext, key) +} + +// DecryptOFBHex 解密十六进制 Blowfish-OFB 密文。 +func DecryptOFBHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.OFB, ciphertextHex, key) +} + +// EncryptOFBWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +func EncryptOFBWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.OFB, plaintext, key, iv) +} + +// DecryptOFBWithIV 使用调用方指定的 IV 解密。 +func DecryptOFBWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.OFB, ciphertext, key, iv) +} + +// ---- CTR ---- + +// EncryptCTR 使用 Blowfish-CTR 加密,随机 IV 前置在密文之前。 +func EncryptCTR(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.CTR, plaintext, key) +} + +// DecryptCTR 解密 Blowfish-CTR 密文(IV 前置格式)。 +func DecryptCTR(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.CTR, ciphertext, key) +} + +// EncryptCTRHex 与 EncryptCTR 相同,返回大写十六进制字符串。 +func EncryptCTRHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.CTR, plaintext, key) +} + +// DecryptCTRHex 解密十六进制 Blowfish-CTR 密文。 +func DecryptCTRHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.CTR, ciphertextHex, key) +} + +// EncryptCTRWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +func EncryptCTRWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.CTR, plaintext, key, iv) +} + +// DecryptCTRWithIV 使用调用方指定的 IV 解密。 +func DecryptCTRWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.CTR, ciphertext, key, iv) +} diff --git a/blowfishx/blowfishx_test.go b/blowfishx/blowfishx_test.go new file mode 100644 index 0000000..0306a25 --- /dev/null +++ b/blowfishx/blowfishx_test.go @@ -0,0 +1,201 @@ +package blowfishx_test + +import ( + "bytes" + "errors" + "testing" + + "code.yun.ink/pkg/encryptx/blowfishx" + "code.yun.ink/pkg/encryptx/cipherx" +) + +func TestGenerateKey(t *testing.T) { + for _, size := range []int{blowfishx.MinKeySize, 16, blowfishx.DefaultKeySize, blowfishx.MaxKeySize} { + key, err := blowfishx.GenerateKey(size) + if err != nil { + t.Fatalf("GenerateKey(%d) 失败: %v", size, err) + } + if len(key) != size { + t.Fatalf("GenerateKey(%d) 返回长度 %d", size, len(key)) + } + } + + // 超出范围必须被拒绝 + for _, size := range []int{-1, 0, 1, 3, 57, 64, 128} { + if _, err := blowfishx.GenerateKey(size); !errors.Is(err, cipherx.ErrInvalidKeySize) { + t.Errorf("GenerateKey(%d): 期望 ErrInvalidKeySize, 得到 %v", size, err) + } + } + + key1, _ := blowfishx.GenerateKey(blowfishx.DefaultKeySize) + key2, _ := blowfishx.GenerateKey(blowfishx.DefaultKeySize) + if bytes.Equal(key1, key2) { + t.Error("两次 GenerateKey 产生了相同密钥") + } + + hexKey, err := blowfishx.GenerateKeyHex(16) + if err != nil { + t.Fatalf("GenerateKeyHex 失败: %v", err) + } + if len(hexKey) != 32 { + t.Errorf("GenerateKeyHex(16) 长度 = %d, 期望 32", len(hexKey)) + } + if _, err := blowfishx.GenerateKeyHex(100); err == nil { + t.Error("GenerateKeyHex(100): 期望错误") + } +} + +// TestInvalidKeySizeRejected 验证底层 blowfish 的密钥长度限制被正确包装为 +// cipherx.ErrInvalidKeySize,而不是泄漏一个难以判断的裸错误。 +func TestInvalidKeySizeRejected(t *testing.T) { + plaintext := []byte("hello") + for _, wrongLen := range []int{0, 57, 100} { + key := bytes.Repeat([]byte{0x01}, wrongLen) + if _, err := blowfishx.EncryptCBC(plaintext, key); !errors.Is(err, cipherx.ErrInvalidKeySize) { + t.Errorf("密钥长度 %d: 期望 ErrInvalidKeySize, 得到 %v", wrongLen, err) + } + } +} + +func TestAllModesRoundTrip(t *testing.T) { + key, err := blowfishx.GenerateKey(blowfishx.DefaultKeySize) + if err != nil { + t.Fatalf("GenerateKey 失败: %v", err) + } + + encryptors := map[string]func([]byte, []byte) ([]byte, error){ + "ECB": blowfishx.EncryptECB, "CBC": blowfishx.EncryptCBC, "CFB": blowfishx.EncryptCFB, + "OFB": blowfishx.EncryptOFB, "CTR": blowfishx.EncryptCTR, + } + decryptors := map[string]func([]byte, []byte) ([]byte, error){ + "ECB": blowfishx.DecryptECB, "CBC": blowfishx.DecryptCBC, "CFB": blowfishx.DecryptCFB, + "OFB": blowfishx.DecryptOFB, "CTR": blowfishx.DecryptCTR, + } + plaintexts := [][]byte{ + {}, []byte("a"), []byte("1234567"), []byte("12345678"), []byte("123456789"), + bytes.Repeat([]byte("BF"), 200), + } + + for name, encrypt := range encryptors { + for i, plaintext := range plaintexts { + ciphertext, err := encrypt(plaintext, key) + if err != nil { + t.Fatalf("%s 明文#%d 加密失败: %v", name, i, err) + } + decrypted, err := decryptors[name](ciphertext, key) + if err != nil { + t.Fatalf("%s 明文#%d 解密失败: %v", name, i, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s 明文#%d 往返不一致", name, i) + } + } + } +} + +// TestVariableKeyLength 验证 Blowfish 支持的多种密钥长度都能正常工作。 +func TestVariableKeyLength(t *testing.T) { + plaintext := []byte("variable key length") + for _, size := range []int{blowfishx.MinKeySize, 8, 16, 32, 56} { + key, err := blowfishx.GenerateKey(size) + if err != nil { + t.Fatalf("GenerateKey(%d) 失败: %v", size, err) + } + ciphertext, err := blowfishx.EncryptCBC(plaintext, key) + if err != nil { + t.Fatalf("密钥长度 %d 加密失败: %v", size, err) + } + decrypted, err := blowfishx.DecryptCBC(ciphertext, key) + if err != nil { + t.Fatalf("密钥长度 %d 解密失败: %v", size, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("密钥长度 %d 往返不一致", size) + } + } +} + +func TestHexAPIRoundTrip(t *testing.T) { + key, _ := blowfishx.GenerateKey(16) + plaintext := []byte("十六进制往返") + + cases := []struct { + name string + encrypt func([]byte, []byte) (string, error) + decrypt func(string, []byte) ([]byte, error) + }{ + {"ECB", blowfishx.EncryptECBHex, blowfishx.DecryptECBHex}, + {"CBC", blowfishx.EncryptCBCHex, blowfishx.DecryptCBCHex}, + {"CFB", blowfishx.EncryptCFBHex, blowfishx.DecryptCFBHex}, + {"OFB", blowfishx.EncryptOFBHex, blowfishx.DecryptOFBHex}, + {"CTR", blowfishx.EncryptCTRHex, blowfishx.DecryptCTRHex}, + } + for _, c := range cases { + hexStr, err := c.encrypt(plaintext, key) + if err != nil { + t.Fatalf("%s Hex 加密失败: %v", c.name, err) + } + if bytes.ContainsAny([]byte(hexStr), "\n\r \t") { + t.Errorf("%s Hex 输出含空白字符", c.name) + } + decrypted, err := c.decrypt(hexStr, key) + if err != nil { + t.Fatalf("%s Hex 解密失败: %v", c.name, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s Hex 往返不一致", c.name) + } + } +} + +func TestWithIVRoundTrip(t *testing.T) { + key, _ := blowfishx.GenerateKey(16) + iv := bytes.Repeat([]byte{0x5A}, blowfishx.BlockSize) + plaintext := []byte("fixed iv") + + cases := []struct { + name string + encrypt func([]byte, []byte, []byte) ([]byte, error) + decrypt func([]byte, []byte, []byte) ([]byte, error) + }{ + {"CBC", blowfishx.EncryptCBCWithIV, blowfishx.DecryptCBCWithIV}, + {"CFB", blowfishx.EncryptCFBWithIV, blowfishx.DecryptCFBWithIV}, + {"OFB", blowfishx.EncryptOFBWithIV, blowfishx.DecryptOFBWithIV}, + {"CTR", blowfishx.EncryptCTRWithIV, blowfishx.DecryptCTRWithIV}, + } + for _, c := range cases { + ciphertext, err := c.encrypt(plaintext, key, iv) + if err != nil { + t.Fatalf("%s WithIV 加密失败: %v", c.name, err) + } + decrypted, err := c.decrypt(ciphertext, key, iv) + if err != nil { + t.Fatalf("%s WithIV 解密失败: %v", c.name, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s WithIV 往返不一致", c.name) + } + } +} + +// TestMalformedCiphertextNeverPanics 是 P0-3 在 Blowfish 上的回归测试。 +func TestMalformedCiphertextNeverPanics(t *testing.T) { + key, _ := blowfishx.GenerateKey(16) + decryptors := map[string]func([]byte, []byte) ([]byte, error){ + "ECB": blowfishx.DecryptECB, "CBC": blowfishx.DecryptCBC, "CFB": blowfishx.DecryptCFB, + "OFB": blowfishx.DecryptOFB, "CTR": blowfishx.DecryptCTR, + } + for _, decrypt := range decryptors { + for n := 0; n <= 24; n++ { + _, _ = decrypt(bytes.Repeat([]byte{0x7F}, n), key) + } + } + + nonAligned := bytes.Repeat([]byte{0xAB}, 12) + if _, err := blowfishx.DecryptECB(nonAligned, key); !errors.Is(err, cipherx.ErrNotBlockAligned) { + t.Errorf("ECB 非对齐: 期望 ErrNotBlockAligned, 得到 %v", err) + } + if _, err := blowfishx.DecryptCBC(nonAligned, key); !errors.Is(err, cipherx.ErrNotBlockAligned) { + t.Errorf("CBC 非对齐: 期望 ErrNotBlockAligned, 得到 %v", err) + } +} diff --git a/chachax/chachax.go b/chachax/chachax.go new file mode 100644 index 0000000..c40913f --- /dev/null +++ b/chachax/chachax.go @@ -0,0 +1,294 @@ +// Package chachax 提供 ChaCha20-Poly1305 与 XChaCha20-Poly1305 两种 AEAD +// (带关联数据的认证加密)封装。 +// +// ChaCha20-Poly1305 是 RFC 8439 定义的 AEAD,也是 TLS 1.3 的强制套件,在没有 AES +// 硬件加速(如部分 ARM、旧 x86、受限云环境)时是首选;XChaCha20-Poly1305 使用 24 +// 字节 nonce,允许在同一条密钥下安全地随机生成 nonce。 +// +// 本包只做输入校验、nonce 生成与格式封装,密码学运算全部交给 +// golang.org/x/crypto/chacha20poly1305,不自行实现任何密码学原语,也不做常量时间 +// 比较(认证由底层 Open 保证)。 +// +// 安全注意事项: +// - nonce 在同一条密钥下绝不能重复。ChaCha20-Poly1305 的 12 字节 nonce 若随机 +// 生成,同一密钥下最多安全使用约 2^32 次;XChaCha20-Poly1305 的 24 字节 nonce +// 则可以放心随机生成。 +// - 关联数据(AAD)可以为 nil,但加密与解密两次调用必须使用完全一致的 AAD, +// 否则认证失败。 +// - 本包的错误信息绝不包含密钥、明文、nonce 或密文内容,可以安全记录到日志。 +package chachax + +import ( + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + + "golang.org/x/crypto/chacha20poly1305" +) + +const ( + // KeySize 是 ChaCha20-Poly1305 与 XChaCha20-Poly1305 的密钥长度(字节),固定为 32。 + // 任何长度不等于该值的密钥都会被拒绝。 + KeySize = chacha20poly1305.KeySize + // NonceSize 是 ChaCha20-Poly1305 的 nonce 长度(字节),固定为 12。 + // + // 安全注意:12 字节 nonce 太短,无法在同一条密钥下无限次安全地随机生成; + // 若需要为每条消息随机取 nonce 而不必担心碰撞,请改用 XChaCha20-Poly1305。 + NonceSize = chacha20poly1305.NonceSize + // NonceSizeX 是 XChaCha20-Poly1305 的 nonce 长度(字节),固定为 24。 + // + // 安全注意:24 字节 nonce 允许在同一条密钥下安全地随机生成,不必担心碰撞。 + NonceSizeX = chacha20poly1305.NonceSizeX + // Overhead 是 Poly1305 认证标签的长度(字节),固定为 16。 + // 密文长度恒等于明文长度加该值。 + Overhead = chacha20poly1305.Overhead +) + +// 哨兵错误。调用方可用 errors.Is 区分失败类别,而无需解析错误文本。 +var ( + // ErrInvalidKeySize 表示密钥长度不等于 KeySize(32 字节)。 + ErrInvalidKeySize = errors.New("chachax: invalid key size") + // ErrInvalidNonceSize 表示 nonce 长度与该模式的 NonceSize / NonceSizeX 不符。 + ErrInvalidNonceSize = errors.New("chachax: invalid nonce size") + // ErrCiphertextTooShort 表示密文长度不足以容纳认证标签(或 sealed 数据不足以 + // 容纳 nonce 与标签)。 + ErrCiphertextTooShort = errors.New("chachax: ciphertext is too short") + // ErrAuthenticationFailed 表示认证失败:密文、nonce 或 AAD 被篡改,或密钥错误。 + // + // 出现该错误时**不得使用任何返回的明文**(本包在所有失败分支都返回 nil 明文)。 + ErrAuthenticationFailed = errors.New("chachax: authentication failed") +) + +// Encrypt 使用 ChaCha20-Poly1305(12 字节 nonce)加密 plaintext,并用 aad 做关联数据认证。 +// +// 参数: +// - key:长度必须恰好为 KeySize(32 字节),否则返回错误。 +// - plaintext:明文,可以为 nil 或空切片,此时密文只包含认证标签。 +// - aad:关联数据,可以为 nil;不保密但必须认证,解密时必须传入完全相同的值。 +// +// 返回值: +// - ciphertext:格式为「密文 ‖ 认证标签」,长度恒为 len(plaintext)+Overhead;调用方后续可以安全地自行修改。 +// - nonce:本次加密随机生成的 NonceSize(12)字节 nonce。 +// - err:密钥长度错误或随机数源不可用时返回错误,此时 ciphertext 与 nonce 均为 nil。 +// +// 安全注意:返回的 nonce 必须与密文一起保存/传输,且**同一条密钥下绝不能重复使用** +// 同一个 nonce;一旦重复,机密性与完整性同时失效。调用方需自行负责 nonce 的存储。 +// 返回的 key、plaintext、aad 切片不会被修改,也不会被保留引用。 +func Encrypt(key, plaintext, aad []byte) (ciphertext, nonce []byte, err error) { + aead, err := newAEAD(key) + if err != nil { + return nil, nil, err + } + + nonce = make([]byte, NonceSize) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, fmt.Errorf("chachax: 生成 nonce 失败: %w", err) + } + + return aead.Seal(nil, nonce, plaintext, aad), nonce, nil +} + +// Decrypt 使用 ChaCha20-Poly1305(12 字节 nonce)解密 ciphertext 并校验认证标签。 +// +// 参数: +// - key:长度必须恰好为 KeySize(32 字节),否则返回错误。 +// - ciphertext:Encrypt 返回的「密文 ‖ 认证标签」,长度必须 >= Overhead(16 字节)。 +// - nonce:加密时使用的 NonceSize(12)字节 nonce,长度必须精确匹配。 +// - aad:关联数据,必须与加密时传入的值完全一致(nil 与空切片等价)。 +// +// 返回值:认证通过时返回解密出的明文;任何失败都返回错误且明文为 nil。 +// +// 安全注意:本函数绝不返回未经认证的明文——密文被篡改、标签不匹配、nonce 或 aad +// 不一致时一律返回错误。调用方必须把错误当作「数据不可信」处理,不要复用或记录 +// 半成品明文。 +func Decrypt(key, ciphertext, nonce, aad []byte) ([]byte, error) { + aead, err := newAEAD(key) + if err != nil { + return nil, err + } + if len(nonce) != aead.NonceSize() { + return nil, fmt.Errorf("%w: 期望 %d 字节,实际 %d 字节", ErrInvalidNonceSize, aead.NonceSize(), len(nonce)) + } + if len(ciphertext) < Overhead { + return nil, fmt.Errorf("%w: 至少需要 %d 字节(认证标签),实际 %d 字节", ErrCiphertextTooShort, Overhead, len(ciphertext)) + } + + plaintext, err := aead.Open(nil, nonce, ciphertext, aad) + if err != nil { + return nil, fmt.Errorf("%w: 密文已被篡改或密钥/nonce/AAD 不匹配: %w", ErrAuthenticationFailed, err) + } + return plaintext, nil +} + +// Seal 是 Encrypt 的便利形式:使用 ChaCha20-Poly1305(12 字节 nonce)加密,并把 +// nonce 前置到返回值中,得到便于单字段存储的 sealed 格式。 +// +// 返回的 sealed 格式固定为:nonce(NonceSize 字节) ‖ 密文 ‖ 认证标签, +// 总长度恒为 NonceSize + len(plaintext) + Overhead。 +// +// 参数与安全注意同 Encrypt:key 长度必须为 KeySize,aad 可以为 nil 但解密时必须一致, +// 随机 nonce 由本函数内部生成并随返回值一起携带。 +func Seal(key, plaintext, aad []byte) ([]byte, error) { + ciphertext, nonce, err := Encrypt(key, plaintext, aad) + if err != nil { + return nil, err + } + return append(nonce, ciphertext...), nil +} + +// Open 解开 Seal 生成的 sealed 数据(格式固定为 nonce ‖ 密文 ‖ 认证标签)。 +// +// 参数: +// - key:长度必须恰好为 KeySize(32 字节),否则返回错误。 +// - sealed:Seal 的返回值,长度必须 >= NonceSize+Overhead(28 字节); +// 长度不足时返回错误,绝不越界切片。 +// - aad:关联数据,必须与 Seal 时传入的值完全一致(nil 与空切片等价)。 +// +// 返回值:认证通过时返回明文;任何失败都返回错误且明文为 nil。 +// +// 安全注意:与 Decrypt 相同,认证失败绝不返回未经认证的明文。 +func Open(key, sealed, aad []byte) ([]byte, error) { + if len(sealed) < NonceSize+Overhead { + return nil, fmt.Errorf("%w: sealed 数据至少需要 %d 字节(nonce %d + 标签 %d),实际 %d 字节", + ErrCiphertextTooShort, NonceSize+Overhead, NonceSize, Overhead, len(sealed)) + } + return Decrypt(key, sealed[NonceSize:], sealed[:NonceSize], aad) +} + +// EncryptX 使用 XChaCha20-Poly1305(24 字节 nonce)加密 plaintext,并用 aad 做关联数据认证。 +// +// 参数与返回值含义同 Encrypt,区别仅在于 nonce 长度是 NonceSizeX(24)字节, +// 且返回的 ciphertext 仍为「密文 ‖ 认证标签」。 +// +// 安全注意:24 字节 nonce 允许在同一条密钥下安全地随机生成而无需担心碰撞,因此 +// 在无法可靠维护 nonce 计数器时,应优先使用本函数而不是 Encrypt。但即便 nonce 空间 +// 很大,也仍不允许人为重复使用同一个 nonce。 +func EncryptX(key, plaintext, aad []byte) (ciphertext, nonce []byte, err error) { + aead, err := newAEADX(key) + if err != nil { + return nil, nil, err + } + + nonce = make([]byte, NonceSizeX) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, fmt.Errorf("chachax: 生成 XChaCha20 nonce 失败: %w", err) + } + + return aead.Seal(nil, nonce, plaintext, aad), nonce, nil +} + +// DecryptX 使用 XChaCha20-Poly1305(24 字节 nonce)解密 ciphertext 并校验认证标签。 +// +// 参数: +// - key:长度必须恰好为 KeySize(32 字节),否则返回错误。 +// - ciphertext:EncryptX 返回的「密文 ‖ 认证标签」,长度必须 >= Overhead(16 字节)。 +// - nonce:长度必须恰好为 NonceSizeX(24 字节),否则返回错误。 +// - aad:必须与加密时传入的值完全一致(nil 与空切片等价)。 +// +// 返回值:认证通过时返回明文;任何失败都返回错误且明文为 nil。 +func DecryptX(key, ciphertext, nonce, aad []byte) ([]byte, error) { + aead, err := newAEADX(key) + if err != nil { + return nil, err + } + if len(nonce) != aead.NonceSize() { + return nil, fmt.Errorf("%w: XChaCha20 期望 %d 字节,实际 %d 字节", ErrInvalidNonceSize, aead.NonceSize(), len(nonce)) + } + if len(ciphertext) < Overhead { + return nil, fmt.Errorf("%w: 至少需要 %d 字节(认证标签),实际 %d 字节", ErrCiphertextTooShort, Overhead, len(ciphertext)) + } + + plaintext, err := aead.Open(nil, nonce, ciphertext, aad) + if err != nil { + return nil, fmt.Errorf("%w: 密文已被篡改或密钥/nonce/AAD 不匹配: %w", ErrAuthenticationFailed, err) + } + return plaintext, nil +} + +// SealX 是 EncryptX 的便利形式:使用 XChaCha20-Poly1305(24 字节 nonce)加密,并把 +// nonce 前置到返回值中。 +// +// 返回的 sealed 格式固定为:nonce(NonceSizeX 字节) ‖ 密文 ‖ 认证标签, +// 总长度恒为 NonceSizeX + len(plaintext) + Overhead。 +// +// 由于 nonce 随机生成且空间足够大,本函数适合「一次加密、一个字段存全部」的场景。 +func SealX(key, plaintext, aad []byte) ([]byte, error) { + ciphertext, nonce, err := EncryptX(key, plaintext, aad) + if err != nil { + return nil, err + } + return append(nonce, ciphertext...), nil +} + +// OpenX 解开 SealX 生成的 sealed 数据(格式固定为 nonce ‖ 密文 ‖ 认证标签)。 +// +// 参数: +// - key:长度必须恰好为 KeySize(32 字节),否则返回错误。 +// - sealed:SealX 的返回值,长度必须 >= NonceSizeX+Overhead(40 字节); +// 长度不足时返回错误,绝不越界切片。 +// - aad:关联数据,必须与 SealX 时传入的值完全一致(nil 与空切片等价)。 +// +// 返回值:认证通过时返回明文;任何失败都返回错误且明文为 nil。 +func OpenX(key, sealed, aad []byte) ([]byte, error) { + if len(sealed) < NonceSizeX+Overhead { + return nil, fmt.Errorf("%w: sealed 数据至少需要 %d 字节(nonce %d + 标签 %d),实际 %d 字节", + ErrCiphertextTooShort, NonceSizeX+Overhead, NonceSizeX, Overhead, len(sealed)) + } + return DecryptX(key, sealed[NonceSizeX:], sealed[:NonceSizeX], aad) +} + +// GenerateKey 使用 crypto/rand 生成一条长度恰好为 KeySize(32 字节)的随机密钥。 +// +// 返回值:新生成的密钥字节,每次调用都不同;随机数源不可用时返回错误且密钥为 nil。 +// +// 安全注意:返回的切片就是密钥本身,请妥善保管——不要写进日志、错误信息或 +// stdout/stderr,用完可以调用方自行清零。 +func GenerateKey() ([]byte, error) { + key := make([]byte, KeySize) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return nil, fmt.Errorf("chachax: 生成密钥失败: %w", err) + } + return key, nil +} + +// GenerateKeyHex 使用 crypto/rand 生成随机密钥并返回其小写十六进制编码。 +// +// 返回值:长度固定为 2*KeySize(64)的小写十六进制字符串;随机数源不可用时返回错误。 +// +// 安全注意:该字符串是密钥的等价表示(十六进制只是编码,不是哈希或口令派生), +// 拿到它就能解密,因此必须按密钥级别保护;如需重新作为密钥使用,请先 +// hex.DecodeString 得到原始字节再交给本包的函数。 +func GenerateKeyHex() (string, error) { + key, err := GenerateKey() + if err != nil { + return "", err + } + return hex.EncodeToString(key), nil +} + +// newAEAD 构造 12 字节 nonce 的 ChaCha20-Poly1305 实例,并统一包装密钥长度错误。 +func newAEAD(key []byte) (cipher.AEAD, error) { + if len(key) != KeySize { + return nil, fmt.Errorf("%w: ChaCha20-Poly1305 要求 %d 字节,实际 %d 字节", ErrInvalidKeySize, KeySize, len(key)) + } + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, fmt.Errorf("chachax: 创建 ChaCha20-Poly1305 实例失败: %w", err) + } + return aead, nil +} + +// newAEADX 构造 24 字节 nonce 的 XChaCha20-Poly1305 实例,并统一包装密钥长度错误。 +func newAEADX(key []byte) (cipher.AEAD, error) { + if len(key) != KeySize { + return nil, fmt.Errorf("%w: XChaCha20-Poly1305 要求 %d 字节,实际 %d 字节", ErrInvalidKeySize, KeySize, len(key)) + } + aead, err := chacha20poly1305.NewX(key) + if err != nil { + return nil, fmt.Errorf("chachax: 创建 XChaCha20-Poly1305 实例失败: %w", err) + } + return aead, nil +} diff --git a/chachax/chachax_test.go b/chachax/chachax_test.go new file mode 100644 index 0000000..c20dcbd --- /dev/null +++ b/chachax/chachax_test.go @@ -0,0 +1,1073 @@ +package chachax_test + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "testing" + + "golang.org/x/crypto/chacha20poly1305" + + "code.yun.ink/pkg/encryptx/chachax" +) + +// testKey 是测试专用的固定 32 字节密钥。测试密钥可以硬编码,生产密钥绝不能。 +var testKey = []byte("0123456789abcdef0123456789abcdef") + +// mustHex 把十六进制字符串解码为字节,解码失败直接终止测试。 +func mustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("测试向量十六进制解码失败: %v", err) + } + return b +} + +// wantDecryptError 断言解密失败,且失败时返回的明文必须为 nil(绝不返回未认证数据)。 +func wantDecryptError(t *testing.T, name string, got []byte, err error) { + t.Helper() + if err == nil { + t.Fatalf("%s: 期望解密失败,实际成功且返回 %d 字节明文", name, len(got)) + } + if got != nil { + t.Fatalf("%s: 解密失败时明文必须为 nil,实际为 %d 字节", name, len(got)) + } +} + +// fillPlaintext 生成确定性的、非全零的测试明文,便于定位篡改位置。 +func fillPlaintext(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i*7 + 13) + } + return b +} + +// --------------------------------------------------------------------------- +// 1. 加解密往返(空明文、1 字节、分组边界附近、100 KB) +// --------------------------------------------------------------------------- + +func TestRoundTripSizes(t *testing.T) { + sizes := []int{0, 1, 15, 16, 17, 63, 64, 65, 127, 128, 129, 1024, 100 * 1024} + for _, size := range sizes { + size := size + t.Run(itoa(size), func(t *testing.T) { + plaintext := fillPlaintext(size) + + ciphertext, nonce, err := chachax.Encrypt(testKey, plaintext, nil) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + if len(nonce) != chachax.NonceSize { + t.Fatalf("nonce 长度 = %d,期望 %d", len(nonce), chachax.NonceSize) + } + if len(ciphertext) != size+chachax.Overhead { + t.Fatalf("密文长度 = %d,期望 %d", len(ciphertext), size+chachax.Overhead) + } + + got, err := chachax.Decrypt(testKey, ciphertext, nonce, nil) + if err != nil { + t.Fatalf("Decrypt 失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("往返结果不一致: 得到 %d 字节,期望 %d 字节", len(got), len(plaintext)) + } + }) + } +} + +func TestRoundTripXSizes(t *testing.T) { + sizes := []int{0, 1, 15, 16, 17, 63, 64, 65, 127, 128, 129, 1024, 100 * 1024} + for _, size := range sizes { + size := size + t.Run(itoa(size), func(t *testing.T) { + plaintext := fillPlaintext(size) + + ciphertext, nonce, err := chachax.EncryptX(testKey, plaintext, nil) + if err != nil { + t.Fatalf("EncryptX 失败: %v", err) + } + if len(nonce) != chachax.NonceSizeX { + t.Fatalf("XChaCha20 nonce 长度 = %d,期望 %d", len(nonce), chachax.NonceSizeX) + } + if len(nonce) != 24 { + t.Fatalf("XChaCha20 nonce 长度 = %d,期望 24", len(nonce)) + } + if len(ciphertext) != size+chachax.Overhead { + t.Fatalf("密文长度 = %d,期望 %d", len(ciphertext), size+chachax.Overhead) + } + + got, err := chachax.DecryptX(testKey, ciphertext, nonce, nil) + if err != nil { + t.Fatalf("DecryptX 失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("往返结果不一致: 得到 %d 字节,期望 %d 字节", len(got), len(plaintext)) + } + }) + } +} + +func TestEncryptUsesFreshNonce(t *testing.T) { + c1, n1, err := chachax.Encrypt(testKey, []byte("same plaintext"), nil) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + c2, n2, err := chachax.Encrypt(testKey, []byte("same plaintext"), nil) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + if bytes.Equal(n1, n2) { + t.Fatal("两次 Encrypt 返回了相同的 nonce,同一密钥下 nonce 绝不能重复") + } + if bytes.Equal(c1, c2) { + t.Fatal("两次 Encrypt 相同明文得到了相同密文,说明没有随机 nonce") + } + + _, xn1, err := chachax.EncryptX(testKey, []byte("same plaintext"), nil) + if err != nil { + t.Fatalf("EncryptX 失败: %v", err) + } + _, xn2, err := chachax.EncryptX(testKey, []byte("same plaintext"), nil) + if err != nil { + t.Fatalf("EncryptX 失败: %v", err) + } + if bytes.Equal(xn1, xn2) { + t.Fatal("两次 EncryptX 返回了相同的 nonce") + } +} + +// --------------------------------------------------------------------------- +// 2. AAD 绑定 +// --------------------------------------------------------------------------- + +func TestAADBinding(t *testing.T) { + plaintext := []byte("payload that must be bound to its context") + aad := []byte("v1|tenant=42|") + + ciphertext, nonce, err := chachax.Encrypt(testKey, plaintext, aad) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + + got, err := chachax.Decrypt(testKey, ciphertext, nonce, aad) + if err != nil { + t.Fatalf("相同 AAD 解密应当成功,实际失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("相同 AAD 解密结果不一致") + } + + // 用非空 AAD 加密后,任何不同的 AAD 都必须认证失败; + // nil / 空切片与 aad 不等价(它们彼此等价,见 TestNilAndEmptyAADEquivalent)。 + badAADs := map[string][]byte{ + "不同 AAD": []byte("v1|tenant=43|"), + "多一个字节": append(append([]byte{}, aad...), 'x'), + "少一个字节": aad[:len(aad)-1], + "nil AAD": nil, + "空 AAD": {}, + "大小写不同": []byte("V1|TENANT=42|"), + } + for name, bad := range badAADs { + got, err := chachax.Decrypt(testKey, ciphertext, nonce, bad) + wantDecryptError(t, "AAD "+name, got, err) + } + + // 反向:用 nil AAD 加密,用非空 AAD 解密必须失败。 + ct2, n2, err := chachax.Encrypt(testKey, plaintext, nil) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + got, err = chachax.Decrypt(testKey, ct2, n2, aad) + wantDecryptError(t, "nil 加密 + 非空 AAD 解密", got, err) +} + +func TestAADBindingX(t *testing.T) { + plaintext := []byte("payload that must be bound to its context") + aad := []byte("v1|tenant=42|") + + ciphertext, nonce, err := chachax.EncryptX(testKey, plaintext, aad) + if err != nil { + t.Fatalf("EncryptX 失败: %v", err) + } + + got, err := chachax.DecryptX(testKey, ciphertext, nonce, aad) + if err != nil { + t.Fatalf("相同 AAD 解密应当成功,实际失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("相同 AAD 解密结果不一致") + } + + got, err = chachax.DecryptX(testKey, ciphertext, nonce, []byte("v1|tenant=43|")) + wantDecryptError(t, "XChaCha20 不同 AAD", got, err) + + got, err = chachax.DecryptX(testKey, ciphertext, nonce, nil) + wantDecryptError(t, "XChaCha20 nil AAD", got, err) +} + +// TestNilAndEmptyAADEquivalent 断言 nil AAD 与 []byte{} AAD 行为完全一致: +// 用其中一个加密,用另一个也能解密,且密文逐字节相同。 +func TestNilAndEmptyAADEquivalent(t *testing.T) { + plaintext := []byte("nil and empty aad must behave the same") + + ctNil, nonceNil, err := chachax.Encrypt(testKey, plaintext, nil) + if err != nil { + t.Fatalf("Encrypt(nil AAD) 失败: %v", err) + } + ctEmpty, nonceEmpty, err := chachax.Encrypt(testKey, plaintext, []byte{}) + if err != nil { + t.Fatalf("Encrypt(空 AAD) 失败: %v", err) + } + + // nonce 随机,所以用固定 nonce 的底层 API 直接比较密文更有意义: + // 这里改为断言「交叉解密成功」这一可观察的等价行为。 + got, err := chachax.Decrypt(testKey, ctEmpty, nonceEmpty, nil) + if err != nil { + t.Fatalf("空 AAD 加密 + nil AAD 解密应当成功: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("空 AAD 加密 + nil AAD 解密结果不一致") + } + + got, err = chachax.Decrypt(testKey, ctNil, nonceNil, []byte{}) + if err != nil { + t.Fatalf("nil AAD 加密 + 空 AAD 解密应当成功: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("nil AAD 加密 + 空 AAD 解密结果不一致") + } + + // 用同一个固定 nonce 直接比较 nil 与空 AAD 的密文,逐字节必须相同。 + aead, err := chacha20poly1305.New(testKey) + if err != nil { + t.Fatalf("构造参考 AEAD 失败: %v", err) + } + fixedNonce := make([]byte, chachax.NonceSize) + if _, err := rand.Read(fixedNonce); err != nil { + t.Fatalf("生成固定 nonce 失败: %v", err) + } + if !bytes.Equal(aead.Seal(nil, fixedNonce, plaintext, nil), aead.Seal(nil, fixedNonce, plaintext, []byte{})) { + t.Fatal("nil AAD 与空 AAD 的密文不相同,两者行为不等价") + } + + // XChaCha20 同理。 + xNil, xnNil, err := chachax.EncryptX(testKey, plaintext, nil) + if err != nil { + t.Fatalf("EncryptX(nil AAD) 失败: %v", err) + } + if _, err := chachax.DecryptX(testKey, xNil, xnNil, []byte{}); err != nil { + t.Fatalf("XChaCha20 nil AAD 加密 + 空 AAD 解密应当成功: %v", err) + } + xEmpty, xnEmpty, err := chachax.EncryptX(testKey, plaintext, []byte{}) + if err != nil { + t.Fatalf("EncryptX(空 AAD) 失败: %v", err) + } + if _, err := chachax.DecryptX(testKey, xEmpty, xnEmpty, nil); err != nil { + t.Fatalf("XChaCha20 空 AAD 加密 + nil AAD 解密应当成功: %v", err) + } +} + +// --------------------------------------------------------------------------- +// 3. 篡改检测:翻转密文、翻转标签、截断、追加 +// --------------------------------------------------------------------------- + +func TestTamperDetection(t *testing.T) { + plaintext := fillPlaintext(96) + aad := []byte("tamper-aad") + + ciphertext, nonce, err := chachax.Encrypt(testKey, plaintext, aad) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + tagStart := len(ciphertext) - chachax.Overhead + + // 逐字节翻转密文主体 + 逐字节翻转标签,每一处都必须被检出。 + for i := 0; i < len(ciphertext); i++ { + tampered := append([]byte{}, ciphertext...) + tampered[i] ^= 0x01 + region := "密文主体" + if i >= tagStart { + region = "认证标签" + } + got, err := chachax.Decrypt(testKey, tampered, nonce, aad) + wantDecryptError(t, "翻转 "+region+" 第 "+itoa(i)+" 字节", got, err) + } + + // 翻转密文最后 1 字节(标签末字节)单独再确认一次。 + tampered := append([]byte{}, ciphertext...) + tampered[len(tampered)-1] ^= 0x80 + got, err := chachax.Decrypt(testKey, tampered, nonce, aad) + wantDecryptError(t, "翻转标签末字节", got, err) + + // 截断:从合法的空明文长度一直到只剩 1 字节都必须报错。 + for cut := 1; cut <= len(ciphertext); cut++ { + got, err := chachax.Decrypt(testKey, ciphertext[:len(ciphertext)-cut], nonce, aad) + wantDecryptError(t, "截断 "+itoa(cut)+" 字节", got, err) + } + + // 追加任意字节必须报错。 + for _, extra := range [][]byte{{0x00}, {0xff}, []byte("extra")} { + got, err := chachax.Decrypt(testKey, append(append([]byte{}, ciphertext...), extra...), nonce, aad) + wantDecryptError(t, "追加 "+itoa(len(extra))+" 字节", got, err) + } + + // 原始数据仍然可解,确认上面的篡改没有破坏基线。 + if _, err := chachax.Decrypt(testKey, ciphertext, nonce, aad); err != nil { + t.Fatalf("未被篡改的密文解密失败: %v", err) + } +} + +func TestTamperDetectionX(t *testing.T) { + plaintext := fillPlaintext(96) + aad := []byte("tamper-aad-x") + + ciphertext, nonce, err := chachax.EncryptX(testKey, plaintext, aad) + if err != nil { + t.Fatalf("EncryptX 失败: %v", err) + } + + for i := 0; i < len(ciphertext); i++ { + tampered := append([]byte{}, ciphertext...) + tampered[i] ^= 0x01 + got, err := chachax.DecryptX(testKey, tampered, nonce, aad) + wantDecryptError(t, "XChaCha20 翻转第 "+itoa(i)+" 字节", got, err) + } + + for cut := 1; cut <= len(ciphertext); cut++ { + got, err := chachax.DecryptX(testKey, ciphertext[:len(ciphertext)-cut], nonce, aad) + wantDecryptError(t, "XChaCha20 截断 "+itoa(cut)+" 字节", got, err) + } + + got, err := chachax.DecryptX(testKey, append(append([]byte{}, ciphertext...), 0x00), nonce, aad) + wantDecryptError(t, "XChaCha20 追加 1 字节", got, err) +} + +func TestTamperDetectionSealed(t *testing.T) { + plaintext := fillPlaintext(64) + sealed, err := chachax.Seal(testKey, plaintext, nil) + if err != nil { + t.Fatalf("Seal 失败: %v", err) + } + for i := 0; i < len(sealed); i++ { + tampered := append([]byte{}, sealed...) + tampered[i] ^= 0x01 + got, err := chachax.Open(testKey, tampered, nil) + wantDecryptError(t, "sealed 翻转第 "+itoa(i)+" 字节", got, err) + } + + sealedX, err := chachax.SealX(testKey, plaintext, nil) + if err != nil { + t.Fatalf("SealX 失败: %v", err) + } + for i := 0; i < len(sealedX); i++ { + tampered := append([]byte{}, sealedX...) + tampered[i] ^= 0x01 + got, err := chachax.OpenX(testKey, tampered, nil) + wantDecryptError(t, "sealedX 翻转第 "+itoa(i)+" 字节", got, err) + } +} + +// --------------------------------------------------------------------------- +// 4. nonce 错误 +// --------------------------------------------------------------------------- + +func TestNonceErrors(t *testing.T) { + plaintext := []byte("nonce must be exact") + ciphertext, nonce, err := chachax.Encrypt(testKey, plaintext, nil) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + + badNonces := map[string][]byte{ + "nil nonce": nil, + "空 nonce": {}, + "11 字节": nonce[:11], + "13 字节": append(append([]byte{}, nonce...), 0x00), + "24 字节": make([]byte, chachax.NonceSizeX), + "32 字节": make([]byte, 32), + } + for name, bad := range badNonces { + got, err := chachax.Decrypt(testKey, ciphertext, bad, nil) + wantDecryptError(t, "ChaCha20 错误 nonce "+name, got, err) + } + + // nonce 被篡改(长度合法但内容不同)必须认证失败。 + for i := 0; i < len(nonce); i++ { + tampered := append([]byte{}, nonce...) + tampered[i] ^= 0x01 + got, err := chachax.Decrypt(testKey, ciphertext, tampered, nil) + wantDecryptError(t, "ChaCha20 篡改 nonce 第 "+itoa(i)+" 字节", got, err) + } + + // XChaCha20:错误长度 nonce 必须报错。 + xCT, xNonce, err := chachax.EncryptX(testKey, plaintext, nil) + if err != nil { + t.Fatalf("EncryptX 失败: %v", err) + } + badXNonces := map[string][]byte{ + "nil nonce": nil, + "空 nonce": {}, + "12 字节": make([]byte, chachax.NonceSize), + "23 字节": xNonce[:23], + "25 字节": append(append([]byte{}, xNonce...), 0x00), + } + for name, bad := range badXNonces { + got, err := chachax.DecryptX(testKey, xCT, bad, nil) + wantDecryptError(t, "XChaCha20 错误 nonce "+name, got, err) + } + for i := 0; i < len(xNonce); i++ { + tampered := append([]byte{}, xNonce...) + tampered[i] ^= 0x01 + got, err := chachax.DecryptX(testKey, xCT, tampered, nil) + wantDecryptError(t, "XChaCha20 篡改 nonce 第 "+itoa(i)+" 字节", got, err) + } +} + +// --------------------------------------------------------------------------- +// 5. 密钥错误 +// --------------------------------------------------------------------------- + +func TestKeyLengthErrors(t *testing.T) { + plaintext := []byte("key length must be exact") + ciphertext, nonce, err := chachax.Encrypt(testKey, plaintext, nil) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + + lengths := []int{0, 1, 15, 16, 24, 31, 33, 48, 64} + for _, n := range lengths { + if n == chachax.KeySize { + continue + } + badKey := make([]byte, n) + + if _, _, err := chachax.Encrypt(badKey, plaintext, nil); err == nil { + t.Fatalf("Encrypt 使用 %d 字节密钥应当报错", n) + } + got, err := chachax.Decrypt(badKey, ciphertext, nonce, nil) + wantDecryptError(t, "Decrypt 使用 "+itoa(n)+" 字节密钥", got, err) + if _, err := chachax.Seal(badKey, plaintext, nil); err == nil { + t.Fatalf("Seal 使用 %d 字节密钥应当报错", n) + } + if _, _, err := chachax.EncryptX(badKey, plaintext, nil); err == nil { + t.Fatalf("EncryptX 使用 %d 字节密钥应当报错", n) + } + got, err = chachax.DecryptX(badKey, ciphertext, nonce, nil) + wantDecryptError(t, "DecryptX 使用 "+itoa(n)+" 字节密钥", got, err) + } +} + +func TestNilKeyDoesNotPanic(t *testing.T) { + if _, _, err := chachax.Encrypt(nil, []byte("x"), nil); err == nil { + t.Fatal("nil 密钥 Encrypt 应当报错") + } + if _, err := chachax.Decrypt(nil, make([]byte, chachax.Overhead), make([]byte, chachax.NonceSize), nil); err == nil { + t.Fatal("nil 密钥 Decrypt 应当报错") + } + if _, err := chachax.Seal(nil, []byte("x"), nil); err == nil { + t.Fatal("nil 密钥 Seal 应当报错") + } + if _, err := chachax.Open(nil, make([]byte, chachax.NonceSize+chachax.Overhead), nil); err == nil { + t.Fatal("nil 密钥 Open 应当报错") + } + if _, _, err := chachax.EncryptX(nil, []byte("x"), nil); err == nil { + t.Fatal("nil 密钥 EncryptX 应当报错") + } + if _, err := chachax.DecryptX(nil, make([]byte, chachax.Overhead), make([]byte, chachax.NonceSizeX), nil); err == nil { + t.Fatal("nil 密钥 DecryptX 应当报错") + } + if _, err := chachax.SealX(nil, []byte("x"), nil); err == nil { + t.Fatal("nil 密钥 SealX 应当报错") + } + if _, err := chachax.OpenX(nil, make([]byte, chachax.NonceSizeX+chachax.Overhead), nil); err == nil { + t.Fatal("nil 密钥 OpenX 应当报错") + } +} + +func TestWrongKeyFails(t *testing.T) { + other := make([]byte, chachax.KeySize) + copy(other, testKey) + other[0] ^= 0xff // 只差 1 bit 的另一条 32 字节密钥 + + plaintext := []byte("wrong key must not decrypt") + ciphertext, nonce, err := chachax.Encrypt(testKey, plaintext, nil) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + got, err := chachax.Decrypt(other, ciphertext, nonce, nil) + wantDecryptError(t, "另一条 32 字节密钥", got, err) + + xCT, xNonce, err := chachax.EncryptX(testKey, plaintext, nil) + if err != nil { + t.Fatalf("EncryptX 失败: %v", err) + } + got, err = chachax.DecryptX(other, xCT, xNonce, nil) + wantDecryptError(t, "XChaCha20 另一条 32 字节密钥", got, err) + + otherKey, err := chachax.GenerateKey() + if err != nil { + t.Fatalf("GenerateKey 失败: %v", err) + } + if bytes.Equal(otherKey, testKey) { + t.Fatal("GenerateKey 返回了与测试密钥相同的密钥") + } + got, err = chachax.Decrypt(otherKey, ciphertext, nonce, nil) + wantDecryptError(t, "随机生成的另一条密钥", got, err) +} + +// --------------------------------------------------------------------------- +// 6. Seal/Open 与 Encrypt/Decrypt 的格式与兼容性 +// --------------------------------------------------------------------------- + +func TestSealFormatAndCompatibility(t *testing.T) { + sizes := []int{0, 1, 31, 32, 33, 1000} + for _, size := range sizes { + plaintext := fillPlaintext(size) + sealed, err := chachax.Seal(testKey, plaintext, nil) + if err != nil { + t.Fatalf("Seal 失败: %v", err) + } + wantLen := chachax.NonceSize + size + chachax.Overhead + if len(sealed) != wantLen { + t.Fatalf("Seal 输出长度 = %d,期望 NonceSize+len(明文)+Overhead = %d", len(sealed), wantLen) + } + + got, err := chachax.Open(testKey, sealed, nil) + if err != nil { + t.Fatalf("Open 失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("Seal/Open 往返结果不一致") + } + + // 手工拆分 sealed,验证格式确实是 nonce ‖ 密文‖标签,且能被 Decrypt 解开。 + nonce, ciphertext := sealed[:chachax.NonceSize], sealed[chachax.NonceSize:] + if len(ciphertext) != size+chachax.Overhead { + t.Fatalf("sealed 中密文部分长度 = %d,期望 %d", len(ciphertext), size+chachax.Overhead) + } + got, err = chachax.Decrypt(testKey, ciphertext, nonce, nil) + if err != nil { + t.Fatalf("用 Decrypt 解开 Seal 拆出的密文失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("Seal 拆解后 Decrypt 结果不一致") + } + + // 反向:Encrypt 的输出手工拼成 sealed,必须能被 Open 解开。 + ct, n, err := chachax.Encrypt(testKey, plaintext, nil) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + manual := append(append([]byte{}, n...), ct...) + got, err = chachax.Open(testKey, manual, nil) + if err != nil { + t.Fatalf("Open 解开手工拼接的 sealed 失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("手工拼接 sealed 的 Open 结果不一致") + } + } +} + +func TestSealFormatAndCompatibilityX(t *testing.T) { + sizes := []int{0, 1, 31, 32, 33, 1000} + for _, size := range sizes { + plaintext := fillPlaintext(size) + sealed, err := chachax.SealX(testKey, plaintext, nil) + if err != nil { + t.Fatalf("SealX 失败: %v", err) + } + wantLen := chachax.NonceSizeX + size + chachax.Overhead + if len(sealed) != wantLen { + t.Fatalf("SealX 输出长度 = %d,期望 NonceSizeX+len(明文)+Overhead = %d", len(sealed), wantLen) + } + + got, err := chachax.OpenX(testKey, sealed, nil) + if err != nil { + t.Fatalf("OpenX 失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("SealX/OpenX 往返结果不一致") + } + + nonce, ciphertext := sealed[:chachax.NonceSizeX], sealed[chachax.NonceSizeX:] + if len(nonce) != 24 { + t.Fatalf("sealedX 中 nonce 长度 = %d,期望 24", len(nonce)) + } + got, err = chachax.DecryptX(testKey, ciphertext, nonce, nil) + if err != nil { + t.Fatalf("用 DecryptX 解开 SealX 拆出的密文失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("SealX 拆解后 DecryptX 结果不一致") + } + + ct, n, err := chachax.EncryptX(testKey, plaintext, nil) + if err != nil { + t.Fatalf("EncryptX 失败: %v", err) + } + manual := append(append([]byte{}, n...), ct...) + got, err = chachax.OpenX(testKey, manual, nil) + if err != nil { + t.Fatalf("OpenX 解开手工拼接的 sealed 失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("手工拼接 sealedX 的 OpenX 结果不一致") + } + } +} + +func TestSealOpenAADBinding(t *testing.T) { + plaintext := []byte("sealed with aad") + aad := []byte("ctx") + sealed, err := chachax.Seal(testKey, plaintext, aad) + if err != nil { + t.Fatalf("Seal 失败: %v", err) + } + got, err := chachax.Open(testKey, sealed, aad) + if err != nil { + t.Fatalf("相同 AAD Open 应当成功: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("Open 结果不一致") + } + bad, err := chachax.Open(testKey, sealed, []byte("ctx2")) + wantDecryptError(t, "Open 不同 AAD", bad, err) + + sealedX, err := chachax.SealX(testKey, plaintext, aad) + if err != nil { + t.Fatalf("SealX 失败: %v", err) + } + if _, err := chachax.OpenX(testKey, sealedX, aad); err != nil { + t.Fatalf("相同 AAD OpenX 应当成功: %v", err) + } + bad, err = chachax.OpenX(testKey, sealedX, nil) + wantDecryptError(t, "OpenX 不同 AAD", bad, err) +} + +// --------------------------------------------------------------------------- +// 7. 常量与密钥生成 +// --------------------------------------------------------------------------- + +func TestConstants(t *testing.T) { + cases := []struct { + name string + got int + want int + }{ + {"KeySize", chachax.KeySize, 32}, + {"NonceSize", chachax.NonceSize, 12}, + {"NonceSizeX", chachax.NonceSizeX, 24}, + {"Overhead", chachax.Overhead, 16}, + {"KeySize==x/crypto", chachax.KeySize, chacha20poly1305.KeySize}, + {"NonceSize==x/crypto", chachax.NonceSize, chacha20poly1305.NonceSize}, + {"NonceSizeX==x/crypto", chachax.NonceSizeX, chacha20poly1305.NonceSizeX}, + {"Overhead==x/crypto", chachax.Overhead, chacha20poly1305.Overhead}, + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("%s = %d,期望 %d", c.name, c.got, c.want) + } + } +} + +func TestGenerateKey(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 16; i++ { + key, err := chachax.GenerateKey() + if err != nil { + t.Fatalf("GenerateKey 失败: %v", err) + } + if len(key) != chachax.KeySize { + t.Fatalf("GenerateKey 返回 %d 字节,期望 %d", len(key), chachax.KeySize) + } + if seen[string(key)] { + t.Fatal("GenerateKey 返回了重复的密钥") + } + seen[string(key)] = true + + // 生成的密钥必须真的可用于加解密。 + plaintext := []byte("generated key must be usable") + ciphertext, nonce, err := chachax.Encrypt(key, plaintext, nil) + if err != nil { + t.Fatalf("用生成的密钥 Encrypt 失败: %v", err) + } + got, err := chachax.Decrypt(key, ciphertext, nonce, nil) + if err != nil { + t.Fatalf("用生成的密钥 Decrypt 失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("用生成的密钥往返结果不一致") + } + + // XChaCha20 也应接受同一条密钥。 + xCT, xNonce, err := chachax.EncryptX(key, plaintext, nil) + if err != nil { + t.Fatalf("用生成的密钥 EncryptX 失败: %v", err) + } + if _, err := chachax.DecryptX(key, xCT, xNonce, nil); err != nil { + t.Fatalf("用生成的密钥 DecryptX 失败: %v", err) + } + } +} + +func TestGenerateKeyHex(t *testing.T) { + const wantLen = 2 * chachax.KeySize + seen := make(map[string]bool) + for i := 0; i < 16; i++ { + s, err := chachax.GenerateKeyHex() + if err != nil { + t.Fatalf("GenerateKeyHex 失败: %v", err) + } + if len(s) != wantLen { + t.Fatalf("GenerateKeyHex 返回长度 = %d,期望 %d", len(s), wantLen) + } + key, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("GenerateKeyHex 返回的不是合法十六进制: %v", err) + } + if len(key) != chachax.KeySize { + t.Fatalf("解码后密钥长度 = %d,期望 %d", len(key), chachax.KeySize) + } + if seen[s] { + t.Fatal("GenerateKeyHex 返回了重复的密钥") + } + seen[s] = true + + // 解码后的字节必须可直接作为密钥使用。 + plaintext := []byte("hex key round trip") + ciphertext, nonce, err := chachax.Encrypt(key, plaintext, nil) + if err != nil { + t.Fatalf("用 hex 解码出的密钥 Encrypt 失败: %v", err) + } + got, err := chachax.Decrypt(key, ciphertext, nonce, nil) + if err != nil { + t.Fatalf("用 hex 解码出的密钥 Decrypt 失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("用 hex 解码出的密钥往返结果不一致") + } + } +} + +// --------------------------------------------------------------------------- +// 8. 非法输入:绝不 panic,一律返回 error +// --------------------------------------------------------------------------- + +func TestMalformedInputDoesNotPanic(t *testing.T) { + // 长度边界附近的所有输入都必须安全返回。 + type decryptCase struct { + name string + key []byte + ciphertext []byte + nonce []byte + x bool + } + var cases []decryptCase + for _, n := range []int{0, 1, 15, 16, 17, 27, 28} { + cases = append(cases, decryptCase{ + name: "ChaCha20 密文 " + itoa(n) + " 字节", + key: testKey, + ciphertext: make([]byte, n), + nonce: make([]byte, chachax.NonceSize), + }) + } + for _, n := range []int{0, 1, 15, 16, 17, 39, 40} { + cases = append(cases, decryptCase{ + name: "XChaCha20 密文 " + itoa(n) + " 字节", + key: testKey, + ciphertext: make([]byte, n), + nonce: make([]byte, chachax.NonceSizeX), + x: true, + }) + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + var err error + var got []byte + if c.x { + got, err = chachax.DecryptX(c.key, c.ciphertext, c.nonce, nil) + } else { + got, err = chachax.Decrypt(c.key, c.ciphertext, c.nonce, nil) + } + if err == nil { + t.Fatalf("畸形输入应当报错,实际成功返回 %d 字节", len(got)) + } + if got != nil { + t.Fatalf("畸形输入失败时明文必须为 nil,实际 %d 字节", len(got)) + } + }) + } + + // Open/OpenX 的超短输入必须在切片之前就被拒绝。 + for _, n := range []int{0, 1, 11, 12, 27, 28} { + if _, err := chachax.Open(testKey, make([]byte, n), nil); err == nil { + t.Fatalf("Open 输入 %d 字节应当报错", n) + } + } + for _, n := range []int{0, 1, 23, 24, 39, 40} { + if _, err := chachax.OpenX(testKey, make([]byte, n), nil); err == nil { + t.Fatalf("OpenX 输入 %d 字节应当报错", n) + } + } +} + +// TestMalformedInputTable 用一张表把「nil / 空 / 超短」三类非法输入集中覆盖一遍, +// 确保没有一条路径会 panic 或返回未认证明文。 +func TestMalformedInputTable(t *testing.T) { + emptyish := map[string][]byte{"nil": nil, "空": {}} + + for name, b := range emptyish { + t.Run("ChaCha20 "+name+" 密文", func(t *testing.T) { + got, err := chachax.Decrypt(testKey, b, make([]byte, chachax.NonceSize), nil) + wantDecryptError(t, "空密文", got, err) + }) + t.Run("XChaCha20 "+name+" 密文", func(t *testing.T) { + got, err := chachax.DecryptX(testKey, b, make([]byte, chachax.NonceSizeX), nil) + wantDecryptError(t, "空密文", got, err) + }) + t.Run("Open "+name+" 输入", func(t *testing.T) { + got, err := chachax.Open(testKey, b, nil) + wantDecryptError(t, "空 sealed", got, err) + }) + t.Run("OpenX "+name+" 输入", func(t *testing.T) { + got, err := chachax.OpenX(testKey, b, nil) + wantDecryptError(t, "空 sealedX", got, err) + }) + } +} + +// TestSealedShortInputBoundary 逐字节扫过 Seal/Open 与 SealX/OpenX 的长度边界, +// 专门验证「先校验长度、再切片」这一步: +// +// nonce 开销刚好够但密文主体为空(ChaCha20 28 字节 / XChaCha20 40 字节)是可解密的 +// 最小长度,短 1 字节就必须被拒绝,绝不能越界切片或 panic。 +// +// 这条测试能区分「本包的显式长度校验」与「底层库的认证失败」:前者是可解密的边界, +// 后者在更短处就会返回错误。 +func TestSealedShortInputBoundary(t *testing.T) { + plaintext := []byte("boundary") + + sealed, err := chachax.Seal(testKey, plaintext, nil) + if err != nil { + t.Fatalf("Seal 失败: %v", err) + } + minSealed := chachax.NonceSize + chachax.Overhead + if len(sealed) != minSealed+len(plaintext) { + t.Fatalf("sealed 长度 = %d,期望 %d", len(sealed), minSealed+len(plaintext)) + } + for n := 0; n < len(sealed); n++ { + got, err := chachax.Open(testKey, sealed[:n], nil) + if n < minSealed { + // 本包的显式长度校验负责这一段(短 1 字节即拒绝)。 + wantDecryptError(t, "Open 输入 "+itoa(n)+" 字节(本包长度校验)", got, err) + continue + } + // 长度足够、内容被截断:必须由认证失败拦下。 + wantDecryptError(t, "Open 输入 "+itoa(n)+" 字节(认证失败)", got, err) + } + + sealedX, err := chachax.SealX(testKey, plaintext, nil) + if err != nil { + t.Fatalf("SealX 失败: %v", err) + } + minSealedX := chachax.NonceSizeX + chachax.Overhead + for n := 0; n < len(sealedX); n++ { + if n == minSealedX { + continue + } + got, err := chachax.OpenX(testKey, sealedX[:n], nil) + if n < minSealedX { + wantDecryptError(t, "OpenX 输入 "+itoa(n)+" 字节(本包长度校验)", got, err) + continue + } + wantDecryptError(t, "OpenX 输入 "+itoa(n)+" 字节(认证失败)", got, err) + } + + // 可解密的最小长度本身必须能正常工作:空明文。 + minSealedEnc, err := chachax.Seal(testKey, nil, nil) + if err != nil { + t.Fatalf("Seal 空明文失败: %v", err) + } + if len(minSealedEnc) != minSealed { + t.Fatalf("空明文的 sealed 长度 = %d,期望 %d", len(minSealedEnc), minSealed) + } + got, err := chachax.Open(testKey, minSealedEnc, nil) + if err != nil { + t.Fatalf("解开空明文 sealed 失败: %v", err) + } + if len(got) != 0 { + t.Fatalf("空明文解密结果长度 = %d,期望 0", len(got)) + } + + minSealedXEnc, err := chachax.SealX(testKey, nil, nil) + if err != nil { + t.Fatalf("SealX 空明文失败: %v", err) + } + if len(minSealedXEnc) != minSealedX { + t.Fatalf("空明文的 sealedX 长度 = %d,期望 %d", len(minSealedXEnc), minSealedX) + } + got, err = chachax.OpenX(testKey, minSealedXEnc, nil) + if err != nil { + t.Fatalf("解开空明文 sealedX 失败: %v", err) + } + if len(got) != 0 { + t.Fatalf("空明文 OpenX 结果长度 = %d,期望 0", len(got)) + } +} + +// --------------------------------------------------------------------------- +// 9. 已知测试向量 +// --------------------------------------------------------------------------- + +// TestKnownVectorRFC8439 使用 RFC 8439 §2.8.2 的 ChaCha20-Poly1305 AEAD 测试向量, +// 验证本包与标准实现的接线正确(密钥/nonce/AAD 的传递顺序没有搞错)。 +// +// 向量来源:RFC 8439 "ChaCha20 and Poly1305 for IETF Protocols" 第 2.8.2 节。 +// 期望值已用 golang.org/x/crypto/chacha20poly1305 独立复算核对一致 +// (明文 114 字节,密文 114 字节,标签 16 字节,合计 130 字节)。 +// +// 明文以十六进制给出,避免源码编码影响其中的撇号等非 ASCII 字符。 +func TestKnownVectorRFC8439(t *testing.T) { + key := mustHex(t, "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f") + nonce := mustHex(t, "070000004041424344454647") + aad := mustHex(t, "50515253c0c1c2c3c4c5c6c7") + plaintext := mustHex(t, "4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a204966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e") + wantCiphertext := mustHex(t, "d31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d63dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b3692ddbd7f2d778b8c9803aee328091b58fab324e4fad675945585808b4831d7bc3ff4def08e4b7a9de576d26586cec64b6116") + wantTag := mustHex(t, "1ae10b594f09e26a7e902ecbd0600691") + + if len(plaintext) != 114 { + t.Fatalf("测试向量明文长度 = %d,期望 114", len(plaintext)) + } + if len(wantCiphertext)+len(wantTag) != len(plaintext)+chachax.Overhead { + t.Fatalf("向量自检失败: 密文 %d + 标签 %d 与明文 %d + 开销 %d 不匹配", + len(wantCiphertext), len(wantTag), len(plaintext), chachax.Overhead) + } + + aead, err := chacha20poly1305.New(key) + if err != nil { + t.Fatalf("构造参考 AEAD 失败: %v", err) + } + if got := aead.Seal(nil, nonce, plaintext, aad); !bytes.Equal(got, append(append([]byte{}, wantCiphertext...), wantTag...)) { + t.Fatalf("底层实现的向量自检失败,说明测试向量本身写错了\n got=%x", got) + } + + // 1) 本包 Decrypt 必须能解开标准向量。 + got, err := chachax.Decrypt(key, append(append([]byte{}, wantCiphertext...), wantTag...), nonce, aad) + if err != nil { + t.Fatalf("Decrypt 解开 RFC 8439 向量失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("RFC 8439 向量解密结果不一致\n got=%x\nwant=%x", got, plaintext) + } + + // 2) 本包 Encrypt 的结果结构必须与向量一致:密文 ‖ 标签,且长度正确。 + // nonce 由 Encrypt 随机生成,无法与向量逐字节比对,因此这里显式用 + // 底层实现 + 固定 nonce 验证「密文 ‖ 标签」的拼接顺序与向量一致。 + pkgAEAD, err := chacha20poly1305.New(key) + if err != nil { + t.Fatalf("构造 AEAD 失败: %v", err) + } + sealedFixed := pkgAEAD.Seal(nil, nonce, plaintext, aad) + if len(sealedFixed) != chachax.Overhead+len(plaintext) { + t.Fatalf("Seal 输出长度 = %d,期望 %d", len(sealedFixed), chachax.Overhead+len(plaintext)) + } + if !bytes.Equal(sealedFixed[:len(wantCiphertext)], wantCiphertext) { + t.Fatalf("密文部分与向量不一致\n got=%x\nwant=%x", sealedFixed[:len(wantCiphertext)], wantCiphertext) + } + if !bytes.Equal(sealedFixed[len(wantCiphertext):], wantTag) { + t.Fatalf("标签部分与向量不一致\n got=%x\nwant=%x", sealedFixed[len(wantCiphertext):], wantTag) + } + + // 3) 用错 AAD 解标准向量必须失败。 + badAAD, err := chachax.Decrypt(key, append(append([]byte{}, wantCiphertext...), wantTag...), nonce, []byte("wrong aad")) + wantDecryptError(t, "RFC 8439 向量错误 AAD", badAAD, err) + + // 4) 向量密文被篡改 1 字节必须失败。 + tampered := append(append([]byte{}, wantCiphertext...), wantTag...) + tampered[0] ^= 0x01 + bad, err := chachax.Decrypt(key, tampered, nonce, aad) + wantDecryptError(t, "RFC 8439 向量篡改密文", bad, err) +} + +// TestKnownVectorXChaCha20 使用 draft-irtf-cfrg-xchacha-01 附录 A.3.1 的 +// XChaCha20-Poly1305 AEAD 测试向量。 +// +// 向量来源:draft-irtf-cfrg-xchacha-01 "XChaCha20-Poly1305 AEAD" A.3.1。 +// 期望值已用 golang.org/x/crypto/chacha20poly1305 的 NewX 独立复算核对一致。 +func TestKnownVectorXChaCha20(t *testing.T) { + key := mustHex(t, "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f") + nonce := mustHex(t, "404142434445464748494a4b4c4d4e4f5051525354555657") + aad := mustHex(t, "50515253c0c1c2c3c4c5c6c7") + plaintext := mustHex(t, "4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a204966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e") + wantCiphertext := mustHex(t, "bd6d179d3e83d43b9576579493c0e939572a1700252bfaccbed2902c21396cbb731c7f1b0b4aa6440bf3a82f4eda7e39ae64c6708c54c216cb96b72e1213b4522f8c9ba40db5d945b11b69b982c1bb9e3f3fac2bc369488f76b2383565d3fff921f9664c97637da9768812f615c68b13b52e") + wantTag := mustHex(t, "c0875924c1c7987947deafd8780acf49") + + if len(nonce) != chachax.NonceSizeX { + t.Fatalf("向量 nonce 长度 = %d,期望 %d", len(nonce), chachax.NonceSizeX) + } + + aead, err := chacha20poly1305.NewX(key) + if err != nil { + t.Fatalf("构造参考 XChaCha20 AEAD 失败: %v", err) + } + if got := aead.Seal(nil, nonce, plaintext, aad); !bytes.Equal(got, append(append([]byte{}, wantCiphertext...), wantTag...)) { + t.Fatalf("底层实现的 XChaCha20 向量自检失败,说明测试向量本身写错了\n got=%x", got) + } + + got, err := chachax.DecryptX(key, append(append([]byte{}, wantCiphertext...), wantTag...), nonce, aad) + if err != nil { + t.Fatalf("DecryptX 解开 XChaCha20 向量失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("XChaCha20 向量解密结果不一致\n got=%x\nwant=%x", got, plaintext) + } + + bad, err := chachax.DecryptX(key, append(append([]byte{}, wantCiphertext...), wantTag...), nonce, nil) + wantDecryptError(t, "XChaCha20 向量错误 AAD", bad, err) + + // 用 12 字节 nonce 走 XChaCha20 通道必须被拒绝(长度精确匹配)。 + bad, err = chachax.DecryptX(key, append(append([]byte{}, wantCiphertext...), wantTag...), nonce[:chachax.NonceSize], aad) + wantDecryptError(t, "XChaCha20 用 12 字节 nonce", bad, err) +} + +// TestCrossVariantIncompatible 断言 12 字节与 24 字节两套 API 不会互相误解: +// 用 ChaCha20-Poly1305 加密的数据不能被 DecryptX 解开,反之亦然。 +func TestCrossVariantIncompatible(t *testing.T) { + plaintext := []byte("variants must not be interchangeable") + + ct, nonce, err := chachax.Encrypt(testKey, plaintext, nil) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + // ChaCha20 的 12 字节 nonce 在 XChaCha20 下长度非法。 + xNonce := make([]byte, chachax.NonceSizeX) + copy(xNonce, nonce) + got, err := chachax.DecryptX(testKey, ct, xNonce, nil) + wantDecryptError(t, "DecryptX 解 ChaCha20 密文", got, err) + + xCT, xNonceReal, err := chachax.EncryptX(testKey, plaintext, nil) + if err != nil { + t.Fatalf("EncryptX 失败: %v", err) + } + got, err = chachax.Decrypt(testKey, xCT, xNonceReal[:chachax.NonceSize], nil) + wantDecryptError(t, "Decrypt 解 XChaCha20 密文", got, err) +} + +// itoa 避免在测试里反复引入 strconv,仅用于拼接子测试名。 +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/chachax/sentinel_test.go b/chachax/sentinel_test.go new file mode 100644 index 0000000..18e5731 --- /dev/null +++ b/chachax/sentinel_test.go @@ -0,0 +1,82 @@ +package chachax_test + +import ( + "errors" + "testing" + + "code.yun.ink/pkg/encryptx/chachax" +) + +// TestSentinelErrors 确认调用方可以用 errors.Is 区分失败类别, +// 而不必解析错误文本——这决定了上层能否把「被篡改」与「参数写错」分开处理。 +func TestSentinelErrors(t *testing.T) { + key, err := chachax.GenerateKey() + if err != nil { + t.Fatalf("GenerateKey 失败: %v", err) + } + + // 密钥长度错误 + for _, badKey := range [][]byte{nil, {}, make([]byte, 16), make([]byte, 31), make([]byte, 33)} { + if _, _, err := chachax.Encrypt(badKey, []byte("x"), nil); !errors.Is(err, chachax.ErrInvalidKeySize) { + t.Errorf("密钥长度 %d: 期望 ErrInvalidKeySize, 得到 %v", len(badKey), err) + } + if _, _, err := chachax.EncryptX(badKey, []byte("x"), nil); !errors.Is(err, chachax.ErrInvalidKeySize) { + t.Errorf("XChaCha 密钥长度 %d: 期望 ErrInvalidKeySize, 得到 %v", len(badKey), err) + } + } + + // nonce 长度错误 + ciphertext, nonce, err := chachax.Encrypt(key, []byte("payload"), nil) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + for _, badNonce := range [][]byte{nil, {}, make([]byte, 8), make([]byte, 24)} { + if _, err := chachax.Decrypt(key, ciphertext, badNonce, nil); !errors.Is(err, chachax.ErrInvalidNonceSize) { + t.Errorf("nonce 长度 %d: 期望 ErrInvalidNonceSize, 得到 %v", len(badNonce), err) + } + } + // XChaCha 期望 24 字节,12 字节同样应被拒 + xCiphertext, xNonce, err := chachax.EncryptX(key, []byte("payload"), nil) + if err != nil { + t.Fatalf("EncryptX 失败: %v", err) + } + if _, err := chachax.DecryptX(key, xCiphertext, nonce, nil); !errors.Is(err, chachax.ErrInvalidNonceSize) { + t.Errorf("XChaCha 用 12 字节 nonce: 期望 ErrInvalidNonceSize, 得到 %v", err) + } + + // 密文过短(不足认证标签) + for n := 0; n < chachax.Overhead; n++ { + if _, err := chachax.Decrypt(key, make([]byte, n), nonce, nil); !errors.Is(err, chachax.ErrCiphertextTooShort) { + t.Errorf("密文长度 %d: 期望 ErrCiphertextTooShort, 得到 %v", n, err) + } + if _, err := chachax.DecryptX(key, make([]byte, n), xNonce, nil); !errors.Is(err, chachax.ErrCiphertextTooShort) { + t.Errorf("XChaCha 密文长度 %d: 期望 ErrCiphertextTooShort, 得到 %v", n, err) + } + } + + // Seal 数据过短 + for n := 0; n < chachax.NonceSize+chachax.Overhead; n++ { + if _, err := chachax.Open(key, make([]byte, n), nil); !errors.Is(err, chachax.ErrCiphertextTooShort) { + t.Errorf("Open 数据长度 %d: 期望 ErrCiphertextTooShort, 得到 %v", n, err) + } + } + for n := 0; n < chachax.NonceSizeX+chachax.Overhead; n++ { + if _, err := chachax.OpenX(key, make([]byte, n), nil); !errors.Is(err, chachax.ErrCiphertextTooShort) { + t.Errorf("OpenX 数据长度 %d: 期望 ErrCiphertextTooShort, 得到 %v", n, err) + } + } + + // 认证失败:篡改密文、篡改 AAD、用错密钥 + tampered := append([]byte(nil), ciphertext...) + tampered[0] ^= 0x01 + if _, err := chachax.Decrypt(key, tampered, nonce, nil); !errors.Is(err, chachax.ErrAuthenticationFailed) { + t.Errorf("篡改密文: 期望 ErrAuthenticationFailed, 得到 %v", err) + } + if _, err := chachax.Decrypt(key, ciphertext, nonce, []byte("wrong aad")); !errors.Is(err, chachax.ErrAuthenticationFailed) { + t.Errorf("错误 AAD: 期望 ErrAuthenticationFailed, 得到 %v", err) + } + otherKey, _ := chachax.GenerateKey() + if _, err := chachax.Decrypt(otherKey, ciphertext, nonce, nil); !errors.Is(err, chachax.ErrAuthenticationFailed) { + t.Errorf("错误密钥: 期望 ErrAuthenticationFailed, 得到 %v", err) + } +} diff --git a/cipherx/cipherx.go b/cipherx/cipherx.go new file mode 100644 index 0000000..ecf295d --- /dev/null +++ b/cipherx/cipherx.go @@ -0,0 +1,386 @@ +// Package cipherx 提供分组密码的通用封装。 +// +// 设计目标: +// - 统一 PKCS#7 填充、IV 生成与前缀格式、长度校验与错误语义; +// - 消除 aesx / desx / tdesx / blowfishx 之间约 95% 的重复实现; +// - 对来自外部的密文一律「先校验长度、再切片」,杜绝越界 panic。 +// +// # 安全提示 +// +// - ECB 模式不具备语义安全性:相同明文块会产生相同密文块,攻击者可据此 +// 识别重复内容与结构。该模式仅为兼容既有系统而保留,新代码不应使用。 +// - ECB / CBC / CFB / OFB / CTR **均不提供完整性保护**,密文被篡改时解密 +// 不会报错(CBC 的填充校验只能概率性发现篡改)。新代码应优先使用 AEAD, +// 例如 AES-GCM 或 ChaCha20-Poly1305。若因兼容性必须使用这些模式,请自行 +// 采用「先加密后认证」(Encrypt-then-MAC)的组合方式。 +// - CBC 解密时所有填充校验失败均返回同一个 ErrInvalidPadding,且校验过程 +// 不提前短路,以免通过错误信息或响应时间区分失败原因(padding oracle)。 +package cipherx + +import ( + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" +) + +// Mode 表示分组密码的工作模式。 +type Mode int + +const ( + // ECB 电子密码本模式。需要填充;相同明文块产生相同密文块,不推荐使用。 + ECB Mode = iota + // CBC 密码分组链接模式。需要填充,并需要随机且不可预测的 IV。 + CBC + // CFB 密码反馈模式。不需要填充,按流式处理,解密不具有随机访问能力。 + CFB + // OFB 输出反馈模式。不需要填充,按流式处理。 + OFB + // CTR 计数器模式。不需要填充,按流式处理,可并行与随机访问。 + CTR +) + +var modeNames = [...]string{"ECB", "CBC", "CFB", "OFB", "CTR"} + +// String 返回模式名称,便于日志与错误信息使用。 +func (m Mode) String() string { + if !m.Valid() { + return fmt.Sprintf("Mode(%d)", int(m)) + } + return modeNames[m] +} + +// Valid 报告模式是否为已支持的模式。 +func (m Mode) Valid() bool { return int(m) >= 0 && int(m) < len(modeNames) } + +// UsesIV 报告该模式是否需要初始化向量。 +func (m Mode) UsesIV() bool { return m != ECB } + +// RequiresPadding 报告该模式是否需要 PKCS#7 填充。 +func (m Mode) RequiresPadding() bool { return m == ECB || m == CBC } + +// 错误定义。调用方可用 errors.Is 精确判断失败类型。 +var ( + // ErrNilBlock 表示传入的 cipher.Block 为 nil。 + ErrNilBlock = errors.New("cipherx: cipher block is nil") + // ErrInvalidBlockSize 表示块大小或填充块大小非法。 + ErrInvalidBlockSize = errors.New("cipherx: invalid block size") + // ErrInvalidIVSize 表示 IV 长度与分组长度不一致。 + ErrInvalidIVSize = errors.New("cipherx: invalid IV size") + // ErrInvalidMode 表示模式非法,或对该模式使用了不适用的操作(如给 ECB 传 IV)。 + ErrInvalidMode = errors.New("cipherx: invalid cipher mode") + // ErrEmptyCiphertext 表示密文为空。 + ErrEmptyCiphertext = errors.New("cipherx: ciphertext is empty") + // ErrCiphertextTooShort 表示密文长度不足以容纳 IV 或一个完整分组。 + ErrCiphertextTooShort = errors.New("cipherx: ciphertext is too short") + // ErrNotBlockAligned 表示密文长度不是分组长度的整数倍。 + ErrNotBlockAligned = errors.New("cipherx: ciphertext is not a multiple of the block size") + // ErrInvalidHex 表示十六进制文本无法解码。 + ErrInvalidHex = errors.New("cipherx: invalid hexadecimal input") + // ErrInvalidPadding 统一表示 PKCS#7 填充校验失败。 + // + // 刻意不区分「填充长度非法」「填充字节不一致」等具体原因:区分它们会让 + // 攻击者借助错误信息实施 padding oracle 攻击。 + ErrInvalidPadding = errors.New("cipherx: invalid padding") +) + +// PKCS7Pad 按 PKCS#7 规则填充 data,返回新切片(不修改入参)。 +// +// blockSize 必须在 [1, 255] 范围内:填充长度需要用一个字节表示。 +// 若 len(data) 恰好是 blockSize 的整数倍,会额外填充一个完整分组。 +func PKCS7Pad(data []byte, blockSize int) ([]byte, error) { + if blockSize <= 0 || blockSize > 255 { + return nil, fmt.Errorf("cipherx: 填充块大小 %d 超出 [1,255]: %w", blockSize, ErrInvalidBlockSize) + } + padding := blockSize - len(data)%blockSize + out := make([]byte, len(data)+padding) + copy(out, data) + for i := len(data); i < len(out); i++ { + out[i] = byte(padding) + } + return out, nil +} + +// PKCS7Unpad 校验并去除 PKCS#7 填充。 +// +// 所有失败原因统一返回 ErrInvalidPadding;校验循环不提前中断,以减小时序差异。 +// 返回的切片是新分配的,不与入参共享底层数组。 +func PKCS7Unpad(data []byte, blockSize int) ([]byte, error) { + if blockSize <= 0 || blockSize > 255 { + return nil, fmt.Errorf("cipherx: 填充块大小 %d 超出 [1,255]: %w", blockSize, ErrInvalidBlockSize) + } + length := len(data) + if length == 0 || length%blockSize != 0 { + return nil, ErrInvalidPadding + } + + padding := int(data[length-1]) + if padding == 0 || padding > blockSize || padding > length { + return nil, ErrInvalidPadding + } + + // 恒定时间风格的整块比较:即使发现不一致也继续遍历完,避免时序泄漏。 + var diff byte + for _, b := range data[length-padding:] { + diff |= b ^ byte(padding) + } + if diff != 0 { + return nil, ErrInvalidPadding + } + + out := make([]byte, length-padding) + copy(out, data[:length-padding]) + return out, nil +} + +// NewIV 用 crypto/rand 生成一个长度为 blockSize 的随机 IV。 +func NewIV(blockSize int) ([]byte, error) { + if blockSize <= 0 { + return nil, fmt.Errorf("cipherx: 块大小 %d 非法: %w", blockSize, ErrInvalidBlockSize) + } + iv := make([]byte, blockSize) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return nil, fmt.Errorf("cipherx: 生成随机 IV 失败: %w", err) + } + return iv, nil +} + +func validateBlock(block cipher.Block) error { + if block == nil { + return ErrNilBlock + } + return nil +} + +func validateMode(mode Mode) error { + if !mode.Valid() { + return fmt.Errorf("cipherx: 不支持的模式 %d: %w", int(mode), ErrInvalidMode) + } + return nil +} + +// Encrypt 使用给定分组与模式加密明文,IV 由本函数随机生成。 +// +// 返回值格式: +// - ECB:密文(已做 PKCS#7 填充) +// - 其余模式:iv || 密文(IV 前置,便于随密文一起存储与传输) +func Encrypt(block cipher.Block, mode Mode, plaintext []byte) ([]byte, error) { + if err := validateBlock(block); err != nil { + return nil, err + } + if err := validateMode(mode); err != nil { + return nil, err + } + if mode == ECB { + return encryptECB(block, plaintext) + } + + iv, err := NewIV(block.BlockSize()) + if err != nil { + return nil, err + } + body, err := EncryptWithIV(block, mode, plaintext, iv) + if err != nil { + return nil, err + } + + out := make([]byte, 0, len(iv)+len(body)) + out = append(out, iv...) + out = append(out, body...) + return out, nil +} + +// Decrypt 解密由 Encrypt 产生的数据(IV 前置格式)。 +func Decrypt(block cipher.Block, mode Mode, ciphertext []byte) ([]byte, error) { + if err := validateBlock(block); err != nil { + return nil, err + } + if err := validateMode(mode); err != nil { + return nil, err + } + if mode == ECB { + return decryptECB(block, ciphertext) + } + + bs := block.BlockSize() + if len(ciphertext) < bs { + return nil, fmt.Errorf("cipherx: %s 密文长度 %d 不足以容纳 %d 字节 IV: %w", + mode, len(ciphertext), bs, ErrCiphertextTooShort) + } + iv := ciphertext[:bs] + body := ciphertext[bs:] + return DecryptWithIV(block, mode, body, iv) +} + +// EncryptWithIV 使用调用方提供的 IV 加密,返回值**只包含密文本身**(不前置 IV)。 +// +// 该函数用于与规定了固定 IV 的既有系统互操作。自行管理 IV 时必须保证: +// - IV 在同一密钥下绝不重复(CBC/CFB 要求不可预测,CTR/OFB 要求唯一); +// - IV 无需保密,但应与密文一同传递。 +// +// ECB 模式不接受 IV,调用会返回 ErrInvalidMode。 +func EncryptWithIV(block cipher.Block, mode Mode, plaintext, iv []byte) ([]byte, error) { + if err := validateBlock(block); err != nil { + return nil, err + } + if err := validateMode(mode); err != nil { + return nil, err + } + if mode == ECB { + return nil, fmt.Errorf("cipherx: ECB 模式不接受 IV: %w", ErrInvalidMode) + } + + bs := block.BlockSize() + if len(iv) != bs { + return nil, fmt.Errorf("cipherx: IV 长度 %d, 期望 %d: %w", len(iv), bs, ErrInvalidIVSize) + } + + switch mode { + case CBC: + padded, err := PKCS7Pad(plaintext, bs) + if err != nil { + return nil, err + } + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded) + return out, nil + case CFB: + out := make([]byte, len(plaintext)) + //lint:ignore SA1019 CFB 自 Go 1.24 起被标准库标记为废弃(无认证);本库保留它是为了与既有系统互操作,包注释已明确警告其不提供完整性保护。 + cipher.NewCFBEncrypter(block, iv).XORKeyStream(out, plaintext) + return out, nil + case OFB: + out := make([]byte, len(plaintext)) + //lint:ignore SA1019 同上:OFB 仅为兼容既有系统而保留,不提供完整性保护。 + cipher.NewOFB(block, iv).XORKeyStream(out, plaintext) + return out, nil + case CTR: + out := make([]byte, len(plaintext)) + cipher.NewCTR(block, iv).XORKeyStream(out, plaintext) + return out, nil + } + return nil, fmt.Errorf("cipherx: 不支持的模式 %d: %w", int(mode), ErrInvalidMode) +} + +// DecryptWithIV 使用调用方提供的 IV 解密 EncryptWithIV 产生的密文。 +func DecryptWithIV(block cipher.Block, mode Mode, ciphertext, iv []byte) ([]byte, error) { + if err := validateBlock(block); err != nil { + return nil, err + } + if err := validateMode(mode); err != nil { + return nil, err + } + if mode == ECB { + return nil, fmt.Errorf("cipherx: ECB 模式不接受 IV: %w", ErrInvalidMode) + } + + bs := block.BlockSize() + if len(iv) != bs { + return nil, fmt.Errorf("cipherx: IV 长度 %d, 期望 %d: %w", len(iv), bs, ErrInvalidIVSize) + } + + switch mode { + case CBC: + if len(ciphertext) == 0 { + return nil, ErrEmptyCiphertext + } + if len(ciphertext)%bs != 0 { + return nil, fmt.Errorf("cipherx: CBC 密文长度 %d: %w", len(ciphertext), ErrNotBlockAligned) + } + out := make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(out, ciphertext) + return PKCS7Unpad(out, bs) + case CFB: + out := make([]byte, len(ciphertext)) + //lint:ignore SA1019 CFB 自 Go 1.24 起被标准库标记为废弃(无认证);本库保留它是为了与既有系统互操作,包注释已明确警告其不提供完整性保护。 + cipher.NewCFBDecrypter(block, iv).XORKeyStream(out, ciphertext) + return out, nil + case OFB: + out := make([]byte, len(ciphertext)) + //lint:ignore SA1019 同上:OFB 仅为兼容既有系统而保留,不提供完整性保护。 + cipher.NewOFB(block, iv).XORKeyStream(out, ciphertext) + return out, nil + case CTR: + out := make([]byte, len(ciphertext)) + cipher.NewCTR(block, iv).XORKeyStream(out, ciphertext) + return out, nil + } + return nil, fmt.Errorf("cipherx: 不支持的模式 %d: %w", int(mode), ErrInvalidMode) +} + +func encryptECB(block cipher.Block, plaintext []byte) ([]byte, error) { + bs := block.BlockSize() + padded, err := PKCS7Pad(plaintext, bs) + if err != nil { + return nil, err + } + out := make([]byte, len(padded)) + for i := 0; i < len(padded); i += bs { + block.Encrypt(out[i:i+bs], padded[i:i+bs]) + } + return out, nil +} + +func decryptECB(block cipher.Block, ciphertext []byte) ([]byte, error) { + bs := block.BlockSize() + if len(ciphertext) == 0 { + return nil, ErrEmptyCiphertext + } + // 先校验长度再切片:旧实现缺少这一步,畸形密文会直接触发 + // "slice bounds out of range" panic,构成可远程触发的拒绝服务。 + if len(ciphertext)%bs != 0 { + return nil, fmt.Errorf("cipherx: ECB 密文长度 %d: %w", len(ciphertext), ErrNotBlockAligned) + } + out := make([]byte, len(ciphertext)) + for i := 0; i < len(ciphertext); i += bs { + block.Decrypt(out[i:i+bs], ciphertext[i:i+bs]) + } + return PKCS7Unpad(out, bs) +} + +// EncryptHex 与 Encrypt 相同,但把结果编码为**大写**十六进制字符串。 +func EncryptHex(block cipher.Block, mode Mode, plaintext []byte) (string, error) { + ciphertext, err := Encrypt(block, mode, plaintext) + if err != nil { + return "", err + } + return strings.ToUpper(hex.EncodeToString(ciphertext)), nil +} + +// DecryptHex 解密十六进制密文,大小写均可,允许首尾空白。 +func DecryptHex(block cipher.Block, mode Mode, ciphertextHex string) ([]byte, error) { + ciphertext, err := decodeHex(ciphertextHex) + if err != nil { + return nil, err + } + return Decrypt(block, mode, ciphertext) +} + +// EncryptWithIVHex 与 EncryptWithIV 相同,返回大写十六进制字符串。 +func EncryptWithIVHex(block cipher.Block, mode Mode, plaintext, iv []byte) (string, error) { + ciphertext, err := EncryptWithIV(block, mode, plaintext, iv) + if err != nil { + return "", err + } + return strings.ToUpper(hex.EncodeToString(ciphertext)), nil +} + +// DecryptWithIVHex 解密十六进制密文,使用调用方提供的 IV。 +func DecryptWithIVHex(block cipher.Block, mode Mode, ciphertextHex string, iv []byte) ([]byte, error) { + ciphertext, err := decodeHex(ciphertextHex) + if err != nil { + return nil, err + } + return DecryptWithIV(block, mode, ciphertext, iv) +} + +func decodeHex(s string) ([]byte, error) { + decoded, err := hex.DecodeString(strings.TrimSpace(s)) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidHex, err) + } + return decoded, nil +} diff --git a/cipherx/cipherx_test.go b/cipherx/cipherx_test.go new file mode 100644 index 0000000..17edbfb --- /dev/null +++ b/cipherx/cipherx_test.go @@ -0,0 +1,403 @@ +package cipherx_test + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/des" + "errors" + "testing" + + "code.yun.ink/pkg/encryptx/cipherx" +) + +// newBlock 按分组长度需要构造 AES(16 字节分组)或 DES(8 字节分组)分组密码。 +func newBlock(useDES bool, key []byte) (cipher.Block, error) { + if useDES { + return des.NewCipher(key) + } + return aes.NewCipher(key) +} + +func mustBlock(t *testing.T, useDES bool, key []byte) cipher.Block { + t.Helper() + b, err := newBlock(useDES, key) + if err != nil { + t.Fatalf("构造分组失败: %v", err) + } + return b +} + +func TestModeMetadata(t *testing.T) { + cases := []struct { + mode cipherx.Mode + name string + usesIV bool + needsPad bool + }{ + {cipherx.ECB, "ECB", false, true}, + {cipherx.CBC, "CBC", true, true}, + {cipherx.CFB, "CFB", true, false}, + {cipherx.OFB, "OFB", true, false}, + {cipherx.CTR, "CTR", true, false}, + } + for _, c := range cases { + if got := c.mode.String(); got != c.name { + t.Errorf("Mode(%d).String() = %q, 期望 %q", int(c.mode), got, c.name) + } + if !c.mode.Valid() { + t.Errorf("%s.Valid() = false, 期望 true", c.name) + } + if got := c.mode.UsesIV(); got != c.usesIV { + t.Errorf("%s.UsesIV() = %v, 期望 %v", c.name, got, c.usesIV) + } + if got := c.mode.RequiresPadding(); got != c.needsPad { + t.Errorf("%s.RequiresPadding() = %v, 期望 %v", c.name, got, c.needsPad) + } + } + + if cipherx.Mode(99).Valid() { + t.Error("Mode(99).Valid() = true, 期望 false") + } + if got := cipherx.Mode(99).String(); got != "Mode(99)" { + t.Errorf("Mode(99).String() = %q, 期望 %q", got, "Mode(99)") + } +} + +func TestPKCS7PadUnpadRoundTrip(t *testing.T) { + for _, blockSize := range []int{8, 16} { + for length := 0; length <= 2*blockSize+1; length++ { + original := bytes.Repeat([]byte{0xAB}, length) + padded, err := cipherx.PKCS7Pad(original, blockSize) + if err != nil { + t.Fatalf("PKCS7Pad(len=%d, bs=%d) 失败: %v", length, blockSize, err) + } + if len(padded)%blockSize != 0 { + t.Fatalf("填充后长度 %d 不是 %d 的整数倍", len(padded), blockSize) + } + if len(padded) <= len(original) { + if length%blockSize == 0 { + // 恰好对齐时必须额外填充一整个分组 + t.Errorf("len=%d bs=%d 对齐时未补充完整分组", length, blockSize) + } + } + // 入参不得被修改 + if !bytes.Equal(original, bytes.Repeat([]byte{0xAB}, length)) { + t.Fatalf("PKCS7Pad 修改了入参") + } + + unpadded, err := cipherx.PKCS7Unpad(padded, blockSize) + if err != nil { + t.Fatalf("PKCS7Unpad(len=%d, bs=%d) 失败: %v", length, blockSize, err) + } + if !bytes.Equal(unpadded, original) { + t.Errorf("往返不一致: 得到 %x, 期望 %x", unpadded, original) + } + } + } +} + +func TestPKCS7PadInvalidBlockSize(t *testing.T) { + for _, blockSize := range []int{-1, 0, 256, 1000} { + if _, err := cipherx.PKCS7Pad([]byte("x"), blockSize); err == nil { + t.Errorf("PKCS7Pad(bs=%d) 期望错误, 得到 nil", blockSize) + } else if !errors.Is(err, cipherx.ErrInvalidBlockSize) { + t.Errorf("PKCS7Pad(bs=%d) 错误类型不对: %v", blockSize, err) + } + if _, err := cipherx.PKCS7Unpad([]byte("12345678"), blockSize); err == nil { + t.Errorf("PKCS7Unpad(bs=%d) 期望错误, 得到 nil", blockSize) + } + } +} + +// TestPKCS7UnpadUniformError 验证所有填充失败原因都返回同一个错误, +// 以免调用方通过错误信息区分失败原因(padding oracle)。 +func TestPKCS7UnpadUniformError(t *testing.T) { + cases := []struct { + name string + data []byte + }{ + {"空数据", nil}, + {"长度不是分组整数倍", []byte{1, 2, 3}}, + {"填充字节为 0", append(bytes.Repeat([]byte{0x00}, 15), 0x00)}, + {"填充长度超出分组", append(bytes.Repeat([]byte{0x00}, 15), 0x11)}, + {"填充字节不一致", append(bytes.Repeat([]byte{0x00}, 13), 0x03, 0x03, 0x04)}, + {"全零块", bytes.Repeat([]byte{0x00}, 16)}, + } + for _, c := range cases { + _, err := cipherx.PKCS7Unpad(c.data, 16) + if err == nil { + t.Errorf("%s: 期望错误, 得到 nil", c.name) + continue + } + if !errors.Is(err, cipherx.ErrInvalidPadding) { + t.Errorf("%s: 期望 ErrInvalidPadding, 得到 %v", c.name, err) + } + if err.Error() != cipherx.ErrInvalidPadding.Error() { + t.Errorf("%s: 错误信息泄漏了失败原因: %q", c.name, err.Error()) + } + } +} + +// TestPKCS7UnpadReturnsCopy 验证去填充返回的切片不与入参共享底层数组。 +func TestPKCS7UnpadReturnsCopy(t *testing.T) { + data := append(bytes.Repeat([]byte{0x41}, 15), 0x01) + out, err := cipherx.PKCS7Unpad(data, 16) + if err != nil { + t.Fatalf("PKCS7Unpad 失败: %v", err) + } + out[0] = 0xFF + if data[0] != 0x41 { + t.Error("PKCS7Unpad 返回的切片与入参共享底层数组") + } +} + +// TestAllModesRoundTrip 覆盖所有模式 × 两种分组长度 × 多种明文长度。 +func TestAllModesRoundTrip(t *testing.T) { + modes := []cipherx.Mode{cipherx.ECB, cipherx.CBC, cipherx.CFB, cipherx.OFB, cipherx.CTR} + + plaintexts := [][]byte{ + {}, + []byte("a"), + []byte("0123456789abcde"), // 15 字节 + []byte("0123456789abcdef"), // 16 字节 + []byte("0123456789abcdefg"), // 17 字节 + bytes.Repeat([]byte("x"), 1000), + } + + for _, useDES := range []bool{false, true} { + var key []byte + if useDES { + key = bytes.Repeat([]byte{0x22}, 8) + } else { + key = bytes.Repeat([]byte{0x11}, 16) + } + algName := "AES" + if useDES { + algName = "DES" + } + + for _, mode := range modes { + for i, plaintext := range plaintexts { + block, err := newBlock(useDES, key) + if err != nil { + t.Fatalf("构造分组失败: %v", err) + } + ciphertext, err := cipherx.Encrypt(block, mode, plaintext) + if err != nil { + t.Fatalf("%s-%s 明文#%d 加密失败: %v", algName, mode, i, err) + } + + block2, err := newBlock(useDES, key) + if err != nil { + t.Fatalf("构造分组失败: %v", err) + } + decrypted, err := cipherx.Decrypt(block2, mode, ciphertext) + if err != nil { + t.Fatalf("%s-%s 明文#%d 解密失败: %v", algName, mode, i, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s-%s 明文#%d 往返不一致: 得到 %q, 期望 %q", + algName, mode, i, decrypted, plaintext) + } + + // IV 前置格式:除 ECB 外,密文前缀必须等于 IV + if mode.UsesIV() { + bs := block.BlockSize() + if len(ciphertext) < bs { + t.Fatalf("%s-%s 密文短于分组长度", algName, mode) + } + iv := ciphertext[:bs] + body := ciphertext[bs:] + block3, _ := newBlock(useDES, key) + decrypted2, err := cipherx.DecryptWithIV(block3, mode, body, iv) + if err != nil { + t.Fatalf("%s-%s WithIV 解密失败: %v", algName, mode, err) + } + if !bytes.Equal(decrypted2, plaintext) { + t.Errorf("%s-%s WithIV 往返不一致", algName, mode) + } + } + } + } + } +} + +// TestMalformedCiphertextNeverPanics 是 P0-3 的回归测试。 +// +// 旧实现在 ECB 解密时直接用 len(密文) 循环并按分组切片,密文长度不是分组 +// 整数倍时会触发 "slice bounds out of range" panic,攻击者用一段畸形密文即可 +// 让服务崩溃。现在必须返回 ErrNotBlockAligned。 +func TestMalformedCiphertextNeverPanics(t *testing.T) { + modes := []cipherx.Mode{cipherx.ECB, cipherx.CBC, cipherx.CFB, cipherx.OFB, cipherx.CTR} + + for _, useDES := range []bool{false, true} { + key := bytes.Repeat([]byte{0x33}, 16) + bs := 16 + if useDES { + key = bytes.Repeat([]byte{0x33}, 8) + bs = 8 + } + block := mustBlock(t, useDES, key) + + for _, mode := range modes { + // 长度 0 到 3 个分组 + 半个分组,全部枚举 + for n := 0; n <= 3*bs+bs/2; n++ { + input := bytes.Repeat([]byte{0x7F}, n) + // 不应 panic;返回什么都行,但必须安全返回 + _, _ = cipherx.Decrypt(block, mode, input) + _, _ = cipherx.DecryptWithIV(block, mode, input, bytes.Repeat([]byte{0x01}, bs)) + } + } + } +} + +// TestECBAndCBCRejectNonAligned 精确断言非对齐密文返回 ErrNotBlockAligned。 +func TestECBAndCBCRejectNonAligned(t *testing.T) { + block := mustBlock(t, false, bytes.Repeat([]byte{0x11}, 16)) + + // 20 字节:不是 16 的整数倍,正是旧实现 panic 的输入 + nonAligned := bytes.Repeat([]byte{0xAB}, 20) + + if _, err := cipherx.Decrypt(block, cipherx.ECB, nonAligned); !errors.Is(err, cipherx.ErrNotBlockAligned) { + t.Errorf("ECB 非对齐密文: 期望 ErrNotBlockAligned, 得到 %v", err) + } + + // CBC 的 IV 前置格式:20 字节去掉 16 字节 IV 后剩 4 字节,同样非对齐 + if _, err := cipherx.Decrypt(block, cipherx.CBC, nonAligned); !errors.Is(err, cipherx.ErrNotBlockAligned) { + t.Errorf("CBC 非对齐密文: 期望 ErrNotBlockAligned, 得到 %v", err) + } +} + +func TestEmptyCiphertext(t *testing.T) { + block := mustBlock(t, false, bytes.Repeat([]byte{0x11}, 16)) + for _, mode := range []cipherx.Mode{cipherx.ECB, cipherx.CBC} { + if _, err := cipherx.Decrypt(block, mode, nil); err == nil { + t.Errorf("%s 空密文: 期望错误, 得到 nil", mode) + } + } + // IV 模式:空密文不足以容纳 IV + for _, mode := range []cipherx.Mode{cipherx.CFB, cipherx.OFB, cipherx.CTR} { + if _, err := cipherx.Decrypt(block, mode, nil); !errors.Is(err, cipherx.ErrCiphertextTooShort) { + t.Errorf("%s 空密文: 期望 ErrCiphertextTooShort, 得到 %v", mode, err) + } + } +} + +func TestNilBlockAndInvalidMode(t *testing.T) { + if _, err := cipherx.Encrypt(nil, cipherx.CBC, []byte("x")); !errors.Is(err, cipherx.ErrNilBlock) { + t.Errorf("nil block: 期望 ErrNilBlock, 得到 %v", err) + } + if _, err := cipherx.Decrypt(nil, cipherx.CBC, []byte("x")); !errors.Is(err, cipherx.ErrNilBlock) { + t.Errorf("nil block: 期望 ErrNilBlock, 得到 %v", err) + } + + block := mustBlock(t, false, bytes.Repeat([]byte{0x11}, 16)) + if _, err := cipherx.Encrypt(block, cipherx.Mode(42), []byte("x")); !errors.Is(err, cipherx.ErrInvalidMode) { + t.Errorf("非法模式: 期望 ErrInvalidMode, 得到 %v", err) + } + if _, err := cipherx.Decrypt(block, cipherx.Mode(-1), []byte("x")); !errors.Is(err, cipherx.ErrInvalidMode) { + t.Errorf("非法模式: 期望 ErrInvalidMode, 得到 %v", err) + } +} + +func TestECBRejectsIV(t *testing.T) { + block := mustBlock(t, false, bytes.Repeat([]byte{0x11}, 16)) + iv := bytes.Repeat([]byte{0x02}, 16) + if _, err := cipherx.EncryptWithIV(block, cipherx.ECB, []byte("x"), iv); !errors.Is(err, cipherx.ErrInvalidMode) { + t.Errorf("ECB WithIV: 期望 ErrInvalidMode, 得到 %v", err) + } + if _, err := cipherx.DecryptWithIV(block, cipherx.ECB, []byte("x"), iv); !errors.Is(err, cipherx.ErrInvalidMode) { + t.Errorf("ECB WithIV 解密: 期望 ErrInvalidMode, 得到 %v", err) + } +} + +func TestInvalidIVSize(t *testing.T) { + block := mustBlock(t, false, bytes.Repeat([]byte{0x11}, 16)) + for _, ivLen := range []int{0, 8, 15, 17, 32} { + iv := bytes.Repeat([]byte{0x02}, ivLen) + if _, err := cipherx.EncryptWithIV(block, cipherx.CBC, []byte("x"), iv); !errors.Is(err, cipherx.ErrInvalidIVSize) { + t.Errorf("IV 长度 %d: 期望 ErrInvalidIVSize, 得到 %v", ivLen, err) + } + } +} + +// TestRandomIVPerEncryption 验证相同明文两次加密得到不同密文(IV 随机)。 +func TestRandomIVPerEncryption(t *testing.T) { + block := mustBlock(t, false, bytes.Repeat([]byte{0x11}, 16)) + plaintext := []byte("same plaintext") + + seen := make(map[string]bool) + for i := 0; i < 32; i++ { + ciphertext, err := cipherx.Encrypt(block, cipherx.CBC, plaintext) + if err != nil { + t.Fatalf("加密失败: %v", err) + } + if seen[string(ciphertext)] { + t.Fatal("相同明文产生了完全相同的密文,IV 可能未随机化") + } + seen[string(ciphertext)] = true + } +} + +// TestWrongKeyDoesNotPanic 验证错误密钥不会 panic(CBC 通常会因填充校验失败报错)。 +func TestWrongKeyDoesNotPanic(t *testing.T) { + right := mustBlock(t, false, bytes.Repeat([]byte{0x11}, 16)) + wrong := mustBlock(t, false, bytes.Repeat([]byte{0x99}, 16)) + + ciphertext, err := cipherx.Encrypt(right, cipherx.CBC, []byte("secret data")) + if err != nil { + t.Fatalf("加密失败: %v", err) + } + // 允许返回错误(填充校验失败)或返回不同的明文,但绝不能 panic 或返回原文 + decrypted, err := cipherx.Decrypt(wrong, cipherx.CBC, ciphertext) + if err == nil && bytes.Equal(decrypted, []byte("secret data")) { + t.Error("错误密钥解出了正确明文") + } +} + +func TestHexHelpers(t *testing.T) { + block := mustBlock(t, false, bytes.Repeat([]byte{0x11}, 16)) + plaintext := []byte("hex round trip 中文") + + for _, mode := range []cipherx.Mode{cipherx.ECB, cipherx.CBC, cipherx.CFB, cipherx.OFB, cipherx.CTR} { + hexStr, err := cipherx.EncryptHex(block, mode, plaintext) + if err != nil { + t.Fatalf("%s EncryptHex 失败: %v", mode, err) + } + // 必须是大写、无换行 + for _, r := range hexStr { + if (r < '0' || r > '9') && (r < 'A' || r > 'F') { + t.Fatalf("%s EncryptHex 输出含非大写十六进制字符: %q", mode, hexStr) + } + } + + decrypted, err := cipherx.DecryptHex(block, mode, hexStr) + if err != nil { + t.Fatalf("%s DecryptHex 失败: %v", mode, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s Hex 往返不一致", mode) + } + } + + if _, err := cipherx.DecryptHex(block, cipherx.CBC, "这不是十六进制"); err == nil { + t.Error("非法十六进制: 期望错误, 得到 nil") + } + if _, err := cipherx.DecryptHex(block, cipherx.CBC, "ABC"); err == nil { + t.Error("奇数长度十六进制: 期望错误, 得到 nil") + } +} + +func TestNewIV(t *testing.T) { + iv, err := cipherx.NewIV(16) + if err != nil { + t.Fatalf("NewIV 失败: %v", err) + } + if len(iv) != 16 { + t.Errorf("NewIV(16) 长度 = %d, 期望 16", len(iv)) + } + if _, err := cipherx.NewIV(0); err == nil { + t.Error("NewIV(0): 期望错误, 得到 nil") + } +} diff --git a/cipherx/suite.go b/cipherx/suite.go new file mode 100644 index 0000000..2fe261e --- /dev/null +++ b/cipherx/suite.go @@ -0,0 +1,155 @@ +package cipherx + +import ( + "crypto/cipher" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +// ErrInvalidKeySize 表示密钥长度不被算法接受。 +var ErrInvalidKeySize = errors.New("cipherx: invalid key size") + +// Suite 把一个具体的分组密码算法(分组构造方式 + 允许的密钥长度)与通用模式 +// 操作绑定在一起。 +// +// 引入 Suite 的目的是消除各算法包之间重复的模式实现:aesx / desx / tdesx / +// blowfishx 只需声明「如何用密钥构造分组」,ECB/CBC/CFB/OFB/CTR 的加解密、 +// 填充、IV 处理与长度校验全部由 Suite 统一提供。 +type Suite struct { + name string + newBlock func(key []byte) (cipher.Block, error) + keySizes []int + blockSize int +} + +// NewSuite 创建一个算法套件。 +// +// name 用于错误信息(如 "AES");newBlock 通常是标准库的 aes.NewCipher / +// des.NewCipher / des.NewTripleDESCipher 或 x/crypto 的 blowfish.NewCipher; +// blockSize 是该算法的分组长度;keySizes 为允许的密钥长度(字节)。 +// keySizes 留空表示密钥长度由 newBlock 自行校验(适用于 Blowfish 这类 +// 密钥长度可变且非离散取值的算法)。 +func NewSuite(name string, newBlock func(key []byte) (cipher.Block, error), blockSize int, keySizes ...int) *Suite { + sizes := make([]int, len(keySizes)) + copy(sizes, keySizes) + return &Suite{ + name: name, + newBlock: newBlock, + keySizes: sizes, + blockSize: blockSize, + } +} + +// Name 返回算法名称。 +func (s *Suite) Name() string { return s.name } + +// BlockSize 返回分组长度(字节)。 +func (s *Suite) BlockSize() int { return s.blockSize } + +// KeySizes 返回允许的密钥长度(字节)。若为空,表示不限制为离散取值。 +func (s *Suite) KeySizes() []int { + out := make([]int, len(s.keySizes)) + copy(out, s.keySizes) + return out +} + +// NewBlock 校验密钥长度后构造分组密码。 +// +// 所有算法包都应通过本方法构造分组,以便统一错误语义:密钥长度非法时返回的 +// 错误可以用 errors.Is(err, ErrInvalidKeySize) 判断。 +func (s *Suite) NewBlock(key []byte) (cipher.Block, error) { + if len(s.keySizes) > 0 { + ok := false + for _, size := range s.keySizes { + if len(key) == size { + ok = true + break + } + } + if !ok { + return nil, fmt.Errorf("%s: 密钥长度 %d 非法, 允许 %v: %w", + s.name, len(key), s.keySizes, ErrInvalidKeySize) + } + } + + block, err := s.newBlock(key) + if err != nil { + // 底层实现(如 blowfish)也会校验密钥长度,统一归类。 + return nil, fmt.Errorf("%s: 构造分组密码失败: %w", s.name, errors.Join(ErrInvalidKeySize, err)) + } + return block, nil +} + +// Encrypt 用随机 IV 加密(IV 前置,格式为 iv||ciphertext;ECB 无 IV)。 +func (s *Suite) Encrypt(mode Mode, plaintext, key []byte) ([]byte, error) { + block, err := s.NewBlock(key) + if err != nil { + return nil, err + } + return Encrypt(block, mode, plaintext) +} + +// Decrypt 解密 Encrypt 产生的数据(IV 前置格式)。 +func (s *Suite) Decrypt(mode Mode, ciphertext, key []byte) ([]byte, error) { + block, err := s.NewBlock(key) + if err != nil { + return nil, err + } + return Decrypt(block, mode, ciphertext) +} + +// EncryptWithIV 使用调用方提供的 IV 加密,返回值不含 IV。 +func (s *Suite) EncryptWithIV(mode Mode, plaintext, key, iv []byte) ([]byte, error) { + block, err := s.NewBlock(key) + if err != nil { + return nil, err + } + return EncryptWithIV(block, mode, plaintext, iv) +} + +// DecryptWithIV 使用调用方提供的 IV 解密。 +func (s *Suite) DecryptWithIV(mode Mode, ciphertext, key, iv []byte) ([]byte, error) { + block, err := s.NewBlock(key) + if err != nil { + return nil, err + } + return DecryptWithIV(block, mode, ciphertext, iv) +} + +// EncryptHex 与 Encrypt 相同,返回大写十六进制字符串。 +func (s *Suite) EncryptHex(mode Mode, plaintext, key []byte) (string, error) { + ciphertext, err := s.Encrypt(mode, plaintext, key) + if err != nil { + return "", err + } + return strings.ToUpper(hex.EncodeToString(ciphertext)), nil +} + +// DecryptHex 解密十六进制密文,大小写均可。 +func (s *Suite) DecryptHex(mode Mode, ciphertextHex string, key []byte) ([]byte, error) { + ciphertext, err := decodeHex(ciphertextHex) + if err != nil { + return nil, err + } + return s.Decrypt(mode, ciphertext, key) +} + +// EncryptWithIVHex 与 EncryptWithIV 相同,返回大写十六进制字符串。 +func (s *Suite) EncryptWithIVHex(mode Mode, plaintext, key, iv []byte) (string, error) { + ciphertext, err := s.EncryptWithIV(mode, plaintext, key, iv) + if err != nil { + return "", err + } + return strings.ToUpper(hex.EncodeToString(ciphertext)), nil +} + +// DecryptWithIVHex 解密十六进制密文,使用调用方提供的 IV。 +func (s *Suite) DecryptWithIVHex(mode Mode, ciphertextHex string, key, iv []byte) ([]byte, error) { + ciphertext, err := decodeHex(ciphertextHex) + if err != nil { + return nil, err + } + return s.DecryptWithIV(mode, ciphertext, key, iv) +} diff --git a/cipherx/suite_test.go b/cipherx/suite_test.go new file mode 100644 index 0000000..98b40ff --- /dev/null +++ b/cipherx/suite_test.go @@ -0,0 +1,199 @@ +package cipherx_test + +import ( + "bytes" + "crypto/aes" + "errors" + "testing" + + "code.yun.ink/pkg/encryptx/cipherx" +) + +// testSuite 返回一个以 AES 为实现、覆盖两种密钥长度的套件。 +func testSuite() *cipherx.Suite { + return cipherx.NewSuite("TEST", aes.NewCipher, 16, 16, 32) +} + +func TestSuiteMetadata(t *testing.T) { + suite := testSuite() + + if got := suite.Name(); got != "TEST" { + t.Errorf("Name() = %q, 期望 TEST", got) + } + if got := suite.BlockSize(); got != 16 { + t.Errorf("BlockSize() = %d, 期望 16", got) + } + sizes := suite.KeySizes() + if len(sizes) != 2 || sizes[0] != 16 || sizes[1] != 32 { + t.Errorf("KeySizes() = %v, 期望 [16 32]", sizes) + } + + // KeySizes 必须返回副本,改动它不应影响套件本身 + sizes[0] = 999 + if again := suite.KeySizes(); again[0] != 16 { + t.Error("KeySizes 返回了内部切片,调用方可意外篡改套件配置") + } +} + +func TestSuiteNewBlockValidatesKeyLength(t *testing.T) { + suite := testSuite() + + for _, size := range []int{16, 32} { + key := bytes.Repeat([]byte{0x11}, size) + if _, err := suite.NewBlock(key); err != nil { + t.Errorf("NewBlock(密钥长度 %d) 失败: %v", size, err) + } + } + + for _, size := range []int{0, 8, 15, 17, 24, 31, 64} { + key := bytes.Repeat([]byte{0x11}, size) + if _, err := suite.NewBlock(key); !errors.Is(err, cipherx.ErrInvalidKeySize) { + t.Errorf("NewBlock(密钥长度 %d): 期望 ErrInvalidKeySize, 得到 %v", size, err) + } + } +} + +// TestSuiteWithoutKeySizes 验证未声明密钥长度时由底层实现校验, +// 且失败仍被统一归类为 ErrInvalidKeySize(Blowfish 走的就是这条路径)。 +func TestSuiteWithoutKeySizes(t *testing.T) { + suite := cipherx.NewSuite("NOCHECK", aes.NewCipher, 16) + + if _, err := suite.NewBlock(bytes.Repeat([]byte{0x11}, 16)); err != nil { + t.Fatalf("合法的 16 字节密钥被拒绝: %v", err) + } + if _, err := suite.NewBlock(bytes.Repeat([]byte{0x11}, 17)); !errors.Is(err, cipherx.ErrInvalidKeySize) { + t.Errorf("非法密钥长度: 期望 ErrInvalidKeySize, 得到 %v", err) + } +} + +func TestSuiteEncryptDecryptAllModes(t *testing.T) { + suite := testSuite() + key := bytes.Repeat([]byte{0x42}, 32) + plaintext := []byte("suite 往返测试 payload") + + modes := []cipherx.Mode{cipherx.ECB, cipherx.CBC, cipherx.CFB, cipherx.OFB, cipherx.CTR} + for _, mode := range modes { + ciphertext, err := suite.Encrypt(mode, plaintext, key) + if err != nil { + t.Fatalf("%s: Encrypt 失败: %v", mode, err) + } + decrypted, err := suite.Decrypt(mode, ciphertext, key) + if err != nil { + t.Fatalf("%s: Decrypt 失败: %v", mode, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s: 往返不一致", mode) + } + } + + // 密钥长度非法时必须在构造阶段就失败 + if _, err := suite.Encrypt(cipherx.CBC, plaintext, []byte("short")); !errors.Is(err, cipherx.ErrInvalidKeySize) { + t.Errorf("Encrypt 非法密钥: 期望 ErrInvalidKeySize, 得到 %v", err) + } + if _, err := suite.Decrypt(cipherx.CBC, []byte("x"), []byte("short")); !errors.Is(err, cipherx.ErrInvalidKeySize) { + t.Errorf("Decrypt 非法密钥: 期望 ErrInvalidKeySize, 得到 %v", err) + } +} + +func TestSuiteWithIV(t *testing.T) { + suite := testSuite() + key := bytes.Repeat([]byte{0x42}, 16) + iv := bytes.Repeat([]byte{0x7E}, 16) + plaintext := []byte("explicit iv via suite") + + for _, mode := range []cipherx.Mode{cipherx.CBC, cipherx.CFB, cipherx.OFB, cipherx.CTR} { + ciphertext, err := suite.EncryptWithIV(mode, plaintext, key, iv) + if err != nil { + t.Fatalf("%s: EncryptWithIV 失败: %v", mode, err) + } + decrypted, err := suite.DecryptWithIV(mode, ciphertext, key, iv) + if err != nil { + t.Fatalf("%s: DecryptWithIV 失败: %v", mode, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s: WithIV 往返不一致", mode) + } + } + + if _, err := suite.EncryptWithIV(cipherx.CBC, plaintext, key, []byte("bad")); !errors.Is(err, cipherx.ErrInvalidIVSize) { + t.Errorf("非法 IV 长度: 期望 ErrInvalidIVSize, 得到 %v", err) + } + if _, err := suite.EncryptWithIV(cipherx.ECB, plaintext, key, iv); !errors.Is(err, cipherx.ErrInvalidMode) { + t.Errorf("ECB 传 IV: 期望 ErrInvalidMode, 得到 %v", err) + } +} + +func TestSuiteHexHelpers(t *testing.T) { + suite := testSuite() + key := bytes.Repeat([]byte{0x42}, 16) + iv := bytes.Repeat([]byte{0x7E}, 16) + plaintext := []byte("hex 辅助函数") + + for _, mode := range []cipherx.Mode{cipherx.ECB, cipherx.CBC, cipherx.CFB, cipherx.OFB, cipherx.CTR} { + hexStr, err := suite.EncryptHex(mode, plaintext, key) + if err != nil { + t.Fatalf("%s: EncryptHex 失败: %v", mode, err) + } + decrypted, err := suite.DecryptHex(mode, hexStr, key) + if err != nil { + t.Fatalf("%s: DecryptHex 失败: %v", mode, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s: Hex 往返不一致", mode) + } + } + + // 带显式 IV 的十六进制辅助函数 + for _, mode := range []cipherx.Mode{cipherx.CBC, cipherx.CFB, cipherx.OFB, cipherx.CTR} { + hexStr, err := suite.EncryptWithIVHex(mode, plaintext, key, iv) + if err != nil { + t.Fatalf("%s: EncryptWithIVHex 失败: %v", mode, err) + } + decrypted, err := suite.DecryptWithIVHex(mode, hexStr, key, iv) + if err != nil { + t.Fatalf("%s: DecryptWithIVHex 失败: %v", mode, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s: WithIVHex 往返不一致", mode) + } + } + + // 非法十六进制 + if _, err := suite.DecryptHex(cipherx.CBC, "zz", key); err == nil { + t.Error("非法十六进制期望错误") + } + if _, err := suite.EncryptWithIVHex(cipherx.CBC, plaintext, []byte("short"), iv); !errors.Is(err, cipherx.ErrInvalidKeySize) { + t.Errorf("非法密钥: 期望 ErrInvalidKeySize, 得到 %v", err) + } + if _, err := suite.DecryptWithIVHex(cipherx.CBC, "zz", key, iv); err == nil { + t.Error("非法十六进制期望错误") + } +} + +// TestSuiteMemberVariants 覆盖 Suite 各方法的失败分支,确保错误在 +// 构造阶段就被发现,而不是留到更难排查的位置。 +func TestSuiteMemberVariants(t *testing.T) { + suite := testSuite() + key := bytes.Repeat([]byte{0x42}, 16) + iv := bytes.Repeat([]byte{0x7E}, 16) + + if _, err := suite.Encrypt(cipherx.Mode(99), []byte("x"), key); !errors.Is(err, cipherx.ErrInvalidMode) { + t.Errorf("Encrypt 非法模式: 期望 ErrInvalidMode, 得到 %v", err) + } + if _, err := suite.Decrypt(cipherx.Mode(99), []byte("x"), key); !errors.Is(err, cipherx.ErrInvalidMode) { + t.Errorf("Decrypt 非法模式: 期望 ErrInvalidMode, 得到 %v", err) + } + if _, err := suite.EncryptWithIV(cipherx.Mode(99), []byte("x"), key, iv); !errors.Is(err, cipherx.ErrInvalidMode) { + t.Errorf("EncryptWithIV 非法模式: 期望 ErrInvalidMode, 得到 %v", err) + } + if _, err := suite.DecryptWithIV(cipherx.Mode(99), []byte("x"), key, iv); !errors.Is(err, cipherx.ErrInvalidMode) { + t.Errorf("DecryptWithIV 非法模式: 期望 ErrInvalidMode, 得到 %v", err) + } + + if _, err := suite.EncryptHex(cipherx.CBC, []byte("x"), []byte("short")); !errors.Is(err, cipherx.ErrInvalidKeySize) { + t.Errorf("EncryptHex 非法密钥: 期望 ErrInvalidKeySize, 得到 %v", err) + } + if _, err := suite.DecryptWithIV(cipherx.CBC, []byte("x"), key, []byte("bad")); !errors.Is(err, cipherx.ErrInvalidIVSize) { + t.Errorf("DecryptWithIV 非法 IV: 期望 ErrInvalidIVSize, 得到 %v", err) + } +} diff --git a/desx/desx.go b/desx/desx.go index b847900..7f0a44c 100644 --- a/desx/desx.go +++ b/desx/desx.go @@ -1,203 +1,214 @@ +// Package desx 提供 DES 分组密码的加解密封装,支持 ECB/CBC/CFB/OFB/CTR。 +// +// # 安全警告 +// +// DES 的有效密钥长度只有 56 位,早已可被专用硬件在数小时内暴力破解, +// **不应用于保护任何机密数据**。本包仅为兼容无法改动的既有系统(老银行接口、 +// 老式终端、遗留数据库字段)而保留,新系统请使用 AES-GCM 或 +// ChaCha20-Poly1305。 +// +// 此外,本包所有模式都**不提供完整性保护**:攻击者可以篡改密文而不被发现 +// (CFB/OFB/CTR 甚至可以直接翻转明文的任意比特)。若必须使用 DES,请额外 +// 配合 HMAC 做「先加密后认证」。 package desx import ( - "bytes" - "crypto/cipher" "crypto/des" "crypto/rand" "encoding/hex" - "errors" "fmt" "io" - "strings" + + "code.yun.ink/pkg/encryptx/cipherx" ) -// DES-ECB 加密,返回大写的十六进制字符串 -func EncryptECBHex(input, key string) (string, error) { - // 创建DES加密器 - block, err := des.NewCipher([]byte(key)) +const ( + // KeySize 是 DES 的密钥长度(字节)。56 位有效强度,已不安全。 + KeySize = 8 + // BlockSize 是 DES 的分组长度(字节)。 + BlockSize = des.BlockSize +) + +// Suite 绑定 DES 的分组构造方式与合法密钥长度,模式实现由 cipherx 提供。 +// +// 导出它是为了让调用方能够以统一方式驱动任意模式,而无需在本包与 cipherx +// 之间写 switch。 +var Suite = cipherx.NewSuite("DES", des.NewCipher, BlockSize, KeySize) + +// GenerateKey 用 crypto/rand 生成 8 字节 DES 密钥。 +// +// 注意:DES 密钥中的每第 8 位是奇偶校验位,不参与加密,因此实际强度只有 +// 56 位。该函数生成的密钥未做校验位设置,可直接使用。 +func GenerateKey() ([]byte, error) { + key := make([]byte, KeySize) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return nil, fmt.Errorf("desx: 生成密钥失败: %w", err) + } + return key, nil +} + +// GenerateKeyHex 生成 DES 密钥并以十六进制字符串返回。 +// +// 注意:返回值是**编码后的文本**,不能直接当密钥使用。读回后必须先解码 +// (encoding/hex 的 DecodeString,或本库的 hexx.Decode),再传给 Encrypt/Decrypt。 +func GenerateKeyHex() (string, error) { + key, err := GenerateKey() if err != nil { return "", err } - - // 对输入数据进行PKCS7填充 - paddedInput := PKCS7Padding([]byte(input), block.BlockSize()) - - // ECB模式加密 - encrypted := make([]byte, len(paddedInput)) - for i := 0; i < len(paddedInput); i += des.BlockSize { - block.Encrypt(encrypted[i:i+des.BlockSize], paddedInput[i:i+des.BlockSize]) - } - - // 转换为十六进制并大写 - result := hex.EncodeToString(encrypted) - return strings.ToUpper(result), nil + return hex.EncodeToString(key), nil } -// DES-ECB 解密,输入大写的十六进制字符串 -func DecryptECBHex(encryptedHex, key string) (string, error) { - encrypted, err := hex.DecodeString(encryptedHex) - if err != nil { - return "", fmt.Errorf("hex解码失败: %v", err) - } +// ---- ECB ---- +// +// 警告:ECB 不具备语义安全性,相同的明文分组会产生相同的密文分组。 - block, err := des.NewCipher([]byte(key)) - if err != nil { - return "", fmt.Errorf("创建DES解密器失败: %v", err) - } - - // ECB模式解密 - decrypted := make([]byte, len(encrypted)) - for i := 0; i < len(encrypted); i += des.BlockSize { - block.Decrypt(decrypted[i:i+des.BlockSize], encrypted[i:i+des.BlockSize]) - } - - // 去除PKCS7填充 - unpaddedData, err := PKCS7UnPadding(decrypted) - if err != nil { - return "", fmt.Errorf("去除填充失败: %v", err) - } - return string(unpaddedData), nil +// EncryptECB 使用 DES-ECB 加密并做 PKCS#7 填充。 +func EncryptECB(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.ECB, plaintext, key) } -// DES-CBC 加密 -func EncryptCBCHex(plaintext, key string) (string, error) { - block, err := des.NewCipher([]byte(key)) - if err != nil { - return "", err - } - blockSize := block.BlockSize() - plainBytes := PKCS7Padding([]byte(plaintext), blockSize) - - // 创建密文字节切片,包含IV空间 - cipherbyte := make([]byte, des.BlockSize+len(plainBytes)) - - // 生成随机IV - iv := cipherbyte[:des.BlockSize] - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return "", err - } - - // 创建CBC模式加密器 - blockMode := cipher.NewCBCEncrypter(block, iv) - - // 加密数据 - blockMode.CryptBlocks(cipherbyte[des.BlockSize:], plainBytes) - - // 转换为十六进制并大写 - result := hex.EncodeToString(cipherbyte) - return strings.ToUpper(result), nil +// DecryptECB 解密 DES-ECB 密文。 +func DecryptECB(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.ECB, ciphertext, key) } -// DES-CBC 解密 -func DecryptCBCHex(encryptedHex, key string) (string, error) { - cipherbyte, err := hex.DecodeString(encryptedHex) - if err != nil { - return "", fmt.Errorf("hex解码失败: %v", err) - } - - if len(cipherbyte) < des.BlockSize { - return "", errors.New("ciphertext too short") - } - - block, err := des.NewCipher([]byte(key)) - if err != nil { - return "", err - } - - iv := cipherbyte[:des.BlockSize] - cipherbyte = cipherbyte[des.BlockSize:] - if len(cipherbyte)%des.BlockSize != 0 { - return "", errors.New("ciphertext is not a multiple of the block size") - } - - blockMode := cipher.NewCBCDecrypter(block, iv) - origData := make([]byte, len(cipherbyte)) - blockMode.CryptBlocks(origData, cipherbyte) - origBy, err := PKCS7UnPadding(origData) - if err != nil { - return "", err - } - return string(origBy), nil +// EncryptECBHex 与 EncryptECB 相同,返回大写十六进制字符串。 +func EncryptECBHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.ECB, plaintext, key) } -// DES-CFB 加密 -func EncryptCFBHex(plaintext, key string) (string, error) { - block, err := des.NewCipher([]byte(key)) - if err != nil { - return "", err - } - - // 创建密文字节切片,包含IV空间 - cipherbyte := make([]byte, des.BlockSize+len(plaintext)) - - // 生成随机IV - iv := cipherbyte[:des.BlockSize] - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return "", err - } - - // 创建CFB模式加密器 - stream := cipher.NewCFBEncrypter(block, iv) - stream.XORKeyStream(cipherbyte[des.BlockSize:], []byte(plaintext)) - - // 转换为十六进制并大写 - result := hex.EncodeToString(cipherbyte) - return strings.ToUpper(result), nil +// DecryptECBHex 解密十六进制 DES-ECB 密文,大小写均可。 +func DecryptECBHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.ECB, ciphertextHex, key) } -// DES-CFB 解密 -func DecryptCFBHex(encryptedHex, key string) (string, error) { - cipherbyte, err := hex.DecodeString(encryptedHex) - if err != nil { - return "", fmt.Errorf("hex解码失败: %v", err) - } +// ---- CBC ---- - if len(cipherbyte) < des.BlockSize { - return "", errors.New("ciphertext too short") - } - - block, err := des.NewCipher([]byte(key)) - if err != nil { - return "", err - } - - // 获取IV - iv := cipherbyte[:des.BlockSize] - cipherbyte = cipherbyte[des.BlockSize:] - - // 创建CFB模式解密器 - stream := cipher.NewCFBDecrypter(block, iv) - stream.XORKeyStream(cipherbyte, cipherbyte) - - return string(cipherbyte), nil +// EncryptCBC 使用 DES-CBC 加密,随机 IV 前置在密文之前。 +func EncryptCBC(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.CBC, plaintext, key) } -// PKCS7补码 -func PKCS7Padding(ciphertext []byte, blockSize int) []byte { - padding := blockSize - len(ciphertext)%blockSize - padtext := bytes.Repeat([]byte{byte(padding)}, padding) - return append(ciphertext, padtext...) +// DecryptCBC 解密 DES-CBC 密文(IV 前置格式)。 +func DecryptCBC(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.CBC, ciphertext, key) } -// 去除PKCS7补码 -func PKCS7UnPadding(data []byte) ([]byte, error) { - length := len(data) - if length == 0 { - return nil, errors.New("ciphertext is empty") - } - - unpadding := int(data[length-1]) - if unpadding > length || unpadding == 0 { - return nil, errors.New("unpadding size is invalid") - } - - // 检查填充是否正确 - for i := length - unpadding; i < length; i++ { - if data[i] != byte(unpadding) { - return nil, errors.New("invalid padding") - } - } - - return data[:(length - unpadding)], nil +// EncryptCBCHex 与 EncryptCBC 相同,返回大写十六进制字符串。 +func EncryptCBCHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.CBC, plaintext, key) +} + +// DecryptCBCHex 解密十六进制 DES-CBC 密文。 +func DecryptCBCHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.CBC, ciphertextHex, key) +} + +// EncryptCBCWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +// +// 仅用于与规定了固定 IV 的既有系统互操作。 +func EncryptCBCWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.CBC, plaintext, key, iv) +} + +// DecryptCBCWithIV 使用调用方指定的 IV 解密。 +func DecryptCBCWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.CBC, ciphertext, key, iv) +} + +// ---- CFB ---- + +// EncryptCFB 使用 DES-CFB 加密,随机 IV 前置在密文之前。 +func EncryptCFB(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.CFB, plaintext, key) +} + +// DecryptCFB 解密 DES-CFB 密文(IV 前置格式)。 +func DecryptCFB(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.CFB, ciphertext, key) +} + +// EncryptCFBHex 与 EncryptCFB 相同,返回大写十六进制字符串。 +func EncryptCFBHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.CFB, plaintext, key) +} + +// DecryptCFBHex 解密十六进制 DES-CFB 密文。 +func DecryptCFBHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.CFB, ciphertextHex, key) +} + +// EncryptCFBWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +func EncryptCFBWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.CFB, plaintext, key, iv) +} + +// DecryptCFBWithIV 使用调用方指定的 IV 解密。 +func DecryptCFBWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.CFB, ciphertext, key, iv) +} + +// ---- OFB ---- + +// EncryptOFB 使用 DES-OFB 加密,随机 IV 前置在密文之前。 +func EncryptOFB(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.OFB, plaintext, key) +} + +// DecryptOFB 解密 DES-OFB 密文(IV 前置格式)。 +func DecryptOFB(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.OFB, ciphertext, key) +} + +// EncryptOFBHex 与 EncryptOFB 相同,返回大写十六进制字符串。 +func EncryptOFBHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.OFB, plaintext, key) +} + +// DecryptOFBHex 解密十六进制 DES-OFB 密文。 +func DecryptOFBHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.OFB, ciphertextHex, key) +} + +// EncryptOFBWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +func EncryptOFBWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.OFB, plaintext, key, iv) +} + +// DecryptOFBWithIV 使用调用方指定的 IV 解密。 +func DecryptOFBWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.OFB, ciphertext, key, iv) +} + +// ---- CTR ---- + +// EncryptCTR 使用 DES-CTR 加密,随机 IV 前置在密文之前。 +func EncryptCTR(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.CTR, plaintext, key) +} + +// DecryptCTR 解密 DES-CTR 密文(IV 前置格式)。 +func DecryptCTR(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.CTR, ciphertext, key) +} + +// EncryptCTRHex 与 EncryptCTR 相同,返回大写十六进制字符串。 +func EncryptCTRHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.CTR, plaintext, key) +} + +// DecryptCTRHex 解密十六进制 DES-CTR 密文。 +func DecryptCTRHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.CTR, ciphertextHex, key) +} + +// EncryptCTRWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +func EncryptCTRWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.CTR, plaintext, key, iv) +} + +// DecryptCTRWithIV 使用调用方指定的 IV 解密。 +func DecryptCTRWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.CTR, ciphertext, key, iv) } diff --git a/desx/desx_test.go b/desx/desx_test.go index 722633f..30b0b07 100644 --- a/desx/desx_test.go +++ b/desx/desx_test.go @@ -1,94 +1,181 @@ -package desx +package desx_test import ( + "bytes" + "errors" "testing" + + "code.yun.ink/pkg/encryptx/cipherx" + "code.yun.ink/pkg/encryptx/desx" ) -func TestEncryptECBHex(t *testing.T) { - // DES密钥需要8字节 - key := "12345678" - input := "hello world" - - encrypted, err := EncryptECBHex(input, key) +func TestGenerateKey(t *testing.T) { + key, err := desx.GenerateKey() if err != nil { - t.Errorf("加密失败: %v", err) - return + t.Fatalf("GenerateKey 失败: %v", err) + } + if len(key) != desx.KeySize { + t.Fatalf("密钥长度 = %d, 期望 %d", len(key), desx.KeySize) } - decrypted, err := DecryptECBHex(encrypted, key) - if err != nil { - t.Errorf("解密失败: %v", err) - return + other, _ := desx.GenerateKey() + if bytes.Equal(key, other) { + t.Error("两次 GenerateKey 产生了相同密钥") } - if decrypted != input { - t.Errorf("解密结果不匹配,期望: %s, 实际: %s", input, decrypted) + hexKey, err := desx.GenerateKeyHex() + if err != nil { + t.Fatalf("GenerateKeyHex 失败: %v", err) + } + if len(hexKey) != desx.KeySize*2 { + t.Errorf("GenerateKeyHex 长度 = %d, 期望 %d", len(hexKey), desx.KeySize*2) } } -func TestEncryptCBCHex(t *testing.T) { - // DES密钥需要8字节 - key := "12345678" - input := "hello world" - - encrypted, err := EncryptCBCHex(input, key) - if err != nil { - t.Errorf("CBC加密失败: %v", err) - return - } - - decrypted, err := DecryptCBCHex(encrypted, key) - if err != nil { - t.Errorf("CBC解密失败: %v", err) - return - } - - if decrypted != input { - t.Errorf("CBC解密结果不匹配,期望: %s, 实际: %s", input, decrypted) +func TestKeySizeValidation(t *testing.T) { + plaintext := []byte("hello") + for _, wrongLen := range []int{0, 7, 9, 16, 24} { + key := bytes.Repeat([]byte{0x01}, wrongLen) + if _, err := desx.EncryptCBC(plaintext, key); !errors.Is(err, cipherx.ErrInvalidKeySize) { + t.Errorf("密钥长度 %d: 期望 ErrInvalidKeySize, 得到 %v", wrongLen, err) + } } } -func TestEncryptCFBHex(t *testing.T) { - // DES密钥需要8字节 - key := "12345678" - input := "hello world" - - encrypted, err := EncryptCFBHex(input, key) +func TestAllModesRoundTrip(t *testing.T) { + key, err := desx.GenerateKey() if err != nil { - t.Errorf("CFB加密失败: %v", err) - return + t.Fatalf("GenerateKey 失败: %v", err) } - decrypted, err := DecryptCFBHex(encrypted, key) - if err != nil { - t.Errorf("CFB解密失败: %v", err) - return + encryptors := map[string]func([]byte, []byte) ([]byte, error){ + "ECB": desx.EncryptECB, "CBC": desx.EncryptCBC, "CFB": desx.EncryptCFB, + "OFB": desx.EncryptOFB, "CTR": desx.EncryptCTR, + } + decryptors := map[string]func([]byte, []byte) ([]byte, error){ + "ECB": desx.DecryptECB, "CBC": desx.DecryptCBC, "CFB": desx.DecryptCFB, + "OFB": desx.DecryptOFB, "CTR": desx.DecryptCTR, + } + plaintexts := [][]byte{ + {}, []byte("a"), []byte("1234567"), []byte("12345678"), []byte("123456789"), + bytes.Repeat([]byte("DES"), 200), } - if decrypted != input { - t.Errorf("CFB解密结果不匹配,期望: %s, 实际: %s", input, decrypted) + for name, encrypt := range encryptors { + for i, plaintext := range plaintexts { + ciphertext, err := encrypt(plaintext, key) + if err != nil { + t.Fatalf("%s 明文#%d 加密失败: %v", name, i, err) + } + decrypted, err := decryptors[name](ciphertext, key) + if err != nil { + t.Fatalf("%s 明文#%d 解密失败: %v", name, i, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s 明文#%d 往返不一致", name, i) + } + } } } -func TestPKCS7Padding(t *testing.T) { - // 测试PKCS7填充和去除 - data := []byte("hello") - blockSize := 8 +func TestHexAPIRoundTrip(t *testing.T) { + key, _ := desx.GenerateKey() + plaintext := []byte("十六进制往返") - // 填充 - padded := PKCS7Padding(data, blockSize) - if len(padded) != blockSize { - t.Errorf("填充长度错误,期望: %d, 实际: %d", blockSize, len(padded)) + cases := []struct { + name string + encrypt func([]byte, []byte) (string, error) + decrypt func(string, []byte) ([]byte, error) + }{ + {"ECB", desx.EncryptECBHex, desx.DecryptECBHex}, + {"CBC", desx.EncryptCBCHex, desx.DecryptCBCHex}, + {"CFB", desx.EncryptCFBHex, desx.DecryptCFBHex}, + {"OFB", desx.EncryptOFBHex, desx.DecryptOFBHex}, + {"CTR", desx.EncryptCTRHex, desx.DecryptCTRHex}, } + for _, c := range cases { + hexStr, err := c.encrypt(plaintext, key) + if err != nil { + t.Fatalf("%s Hex 加密失败: %v", c.name, err) + } + if bytes.ContainsAny([]byte(hexStr), "\n\r \t") { + t.Errorf("%s Hex 输出含空白字符", c.name) + } + decrypted, err := c.decrypt(hexStr, key) + if err != nil { + t.Fatalf("%s Hex 解密失败: %v", c.name, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s Hex 往返不一致", c.name) + } + } +} + +func TestWithIVRoundTrip(t *testing.T) { + key, _ := desx.GenerateKey() + iv := bytes.Repeat([]byte{0x5A}, desx.BlockSize) + plaintext := []byte("fixed iv") + + cases := []struct { + name string + encrypt func([]byte, []byte, []byte) ([]byte, error) + decrypt func([]byte, []byte, []byte) ([]byte, error) + }{ + {"CBC", desx.EncryptCBCWithIV, desx.DecryptCBCWithIV}, + {"CFB", desx.EncryptCFBWithIV, desx.DecryptCFBWithIV}, + {"OFB", desx.EncryptOFBWithIV, desx.DecryptOFBWithIV}, + {"CTR", desx.EncryptCTRWithIV, desx.DecryptCTRWithIV}, + } + for _, c := range cases { + ciphertext, err := c.encrypt(plaintext, key, iv) + if err != nil { + t.Fatalf("%s WithIV 加密失败: %v", c.name, err) + } + decrypted, err := c.decrypt(ciphertext, key, iv) + if err != nil { + t.Fatalf("%s WithIV 解密失败: %v", c.name, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s WithIV 往返不一致", c.name) + } + } +} + +// TestMalformedCiphertextNeverPanics 是 P0-3 在 DES 上的回归测试。 +func TestMalformedCiphertextNeverPanics(t *testing.T) { + key, _ := desx.GenerateKey() + decryptors := map[string]func([]byte, []byte) ([]byte, error){ + "ECB": desx.DecryptECB, "CBC": desx.DecryptCBC, "CFB": desx.DecryptCFB, + "OFB": desx.DecryptOFB, "CTR": desx.DecryptCTR, + } + for _, decrypt := range decryptors { + for n := 0; n <= 24; n++ { + _, _ = decrypt(bytes.Repeat([]byte{0x7F}, n), key) + } + } + + nonAligned := bytes.Repeat([]byte{0xAB}, 12) + if _, err := desx.DecryptECB(nonAligned, key); !errors.Is(err, cipherx.ErrNotBlockAligned) { + t.Errorf("ECB 非对齐: 期望 ErrNotBlockAligned, 得到 %v", err) + } + if _, err := desx.DecryptCBC(nonAligned, key); !errors.Is(err, cipherx.ErrNotBlockAligned) { + t.Errorf("CBC 非对齐: 期望 ErrNotBlockAligned, 得到 %v", err) + } +} + +func TestECBIsDeterministic(t *testing.T) { + key, _ := desx.GenerateKey() + plaintext := bytes.Repeat([]byte("12345678"), 3) - // 去除填充 - unpadded, err := PKCS7UnPadding(padded) - if err != nil { - t.Errorf("去除填充失败: %v", err) - return + a, _ := desx.EncryptECB(plaintext, key) + b, _ := desx.EncryptECB(plaintext, key) + if !bytes.Equal(a, b) { + t.Error("ECB 应当是确定性的") } - if string(unpadded) != string(data) { - t.Errorf("去除填充后数据不匹配,期望: %s, 实际: %s", string(data), string(unpadded)) + c, _ := desx.EncryptCBC(plaintext, key) + d, _ := desx.EncryptCBC(plaintext, key) + if bytes.Equal(c, d) { + t.Error("CBC 使用了随机 IV,两次结果不应相同") } } diff --git a/dispatch.go b/dispatch.go new file mode 100644 index 0000000..5c1d9d5 --- /dev/null +++ b/dispatch.go @@ -0,0 +1,1144 @@ +package encryptx + +import ( + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "os" + "strings" + + "code.yun.ink/pkg/encryptx/aesx" + "code.yun.ink/pkg/encryptx/argon2x" + "code.yun.ink/pkg/encryptx/base64x" + "code.yun.ink/pkg/encryptx/bcryptx" + "code.yun.ink/pkg/encryptx/blowfishx" + "code.yun.ink/pkg/encryptx/chachax" + "code.yun.ink/pkg/encryptx/cipherx" + "code.yun.ink/pkg/encryptx/desx" + "code.yun.ink/pkg/encryptx/ecdsax" + "code.yun.ink/pkg/encryptx/ed25519x" + "code.yun.ink/pkg/encryptx/hexx" + "code.yun.ink/pkg/encryptx/hkdfx" + "code.yun.ink/pkg/encryptx/hmacx" + "code.yun.ink/pkg/encryptx/pbkdf2x" + "code.yun.ink/pkg/encryptx/rsax" + "code.yun.ink/pkg/encryptx/scryptx" + "code.yun.ink/pkg/encryptx/tdesx" + "code.yun.ink/pkg/encryptx/utilsx" +) + +// 选项键名。集中定义以免拼写错误,也便于在文档中统一说明。 +const ( + optKeyLength = "keyLength" // 对称/HMAC 密钥长度(字节) + optSalt = "salt" // KDF 盐值(十六进制字符串或 []byte) + optSaltLength = "saltLength" // 随机盐值长度(字节) + optIterations = "iterations" // PBKDF2 迭代次数 + optInfo = "info" // HKDF 上下文信息 + optScryptN = "n" // scrypt CPU/内存代价 + optScryptR = "r" // scrypt 块大小 + optScryptP = "p" // scrypt 并行度 + optArgon2Time = "time" // Argon2 迭代次数 + optArgon2Memory = "memory" // Argon2 内存开销(KiB) + optArgon2Threads = "threads" // Argon2 并行度 + optCost = "cost" // bcrypt 代价 + optIV = "iv" // 显式 IV(十六进制),用于兼容固定 IV 的旧系统 + optAAD = "aad" // AEAD 附加认证数据 + optBits = "bits" // RSA 密钥位数 + optPublicKeyFile = "publicKeyFile" // 公钥文件路径 + optPrivateKeyFile = "privateKeyFile" // 私钥文件路径 +) + +// aeadSeparator 分隔 AEAD 的密文与 nonce。十六进制字符集不含冒号,因此不会冲突。 +const aeadSeparator = ":" + +// Encrypt 使用指定算法处理数据并返回编码后的结果。 +// +// 参数: +// - encryptType:算法,必须是本包支持的取值; +// - data:待处理的数据(哈希/加密的明文,或编码的原文); +// - key:密钥。对称算法为**原始密钥字节**;RSA 为 PEM 编码的公钥; +// 编码类算法可传 nil; +// - options:算法参数,可传 nil。 +// +// 返回值的编码方式按算法族不同,详见包文档。 +// +// 与历史版本的差别:不再接受任何字符串形式的密钥,也不再对无法识别的选项 +// 静默取默认值——键名拼错或类型不符都会返回错误。此外,给不使用密钥的算法 +// (编码、哈希、口令哈希)传入 key 会返回 ErrUnexpectedKey,而不是像历史版本 +// 那样静默忽略,让人误以为数据受到了密钥保护。 +func Encrypt(encryptType EncryptType, data string, key []byte, options Options) (string, error) { + if !encryptType.Valid() { + return "", fmt.Errorf("%w: %d", ErrUnsupportedType, int(encryptType)) + } + if options == nil { + options = Options{} + } + if err := checkKeyUsage(encryptType, key, options); err != nil { + return "", err + } + + switch encryptType { + // ---- 哈希 ---- + case MD5, SHA1, SHA224, SHA256, SHA384, SHA512, + SHA3_224, SHA3_256, SHA3_384, SHA3_512, + BLAKE2B_256, BLAKE2S_256, RIPEMD160: + if err := options.rejectUnknown(); err != nil { + return "", err + } + return hashHex(encryptType, []byte(data)) + + // ---- 消息认证码 ---- + case HMAC_SHA1, HMAC_SHA256, HMAC_SHA384, HMAC_SHA512: + if err := options.rejectUnknown(); err != nil { + return "", err + } + return hmacHex(encryptType, data, key), nil + + // ---- 编码 ---- + case BASE64: + if err := options.rejectUnknown(); err != nil { + return "", err + } + return base64x.Encode([]byte(data)), nil + case BASE64_URL: + if err := options.rejectUnknown(); err != nil { + return "", err + } + return base64x.EncodeURL([]byte(data)), nil + case HEX: + if err := options.rejectUnknown(); err != nil { + return "", err + } + return hexx.Encode([]byte(data)), nil + + // ---- 口令哈希:单向处理,因此也走 Encrypt ---- + case BCRYPT: + if err := options.rejectUnknown(optCost); err != nil { + return "", err + } + return bcryptHash(data, options) + case ARGON2ID: + if err := options.rejectUnknown(optArgon2Time, optArgon2Memory, optArgon2Threads, optKeyLength); err != nil { + return "", err + } + return argon2Hash(data, options) + + // ---- 密钥派生:必须显式给出盐值才能复现 ---- + case PBKDF2, HKDF, SCRYPT: + return deriveHex(encryptType, data, options) + + // ---- AEAD ---- + case AES_GCM: + if err := options.rejectUnknown(optAAD); err != nil { + return "", err + } + aad, err := optionalStringOption(encryptType, options, optAAD) + if err != nil { + return "", err + } + ciphertext, nonce, err := aesx.EncryptGCM([]byte(data), key, aad) + if err != nil { + return "", err + } + return hexx.Encode(ciphertext) + aeadSeparator + hexx.Encode(nonce), nil + + case CHACHA20_POLY1305, XCHACHA20_POLY1305: + if err := options.rejectUnknown(optAAD); err != nil { + return "", err + } + aad, err := optionalStringOption(encryptType, options, optAAD) + if err != nil { + return "", err + } + var ( + ciphertext, nonce []byte + encErr error + ) + if encryptType == CHACHA20_POLY1305 { + ciphertext, nonce, encErr = chachax.Encrypt(key, []byte(data), aad) + } else { + ciphertext, nonce, encErr = chachax.EncryptX(key, []byte(data), aad) + } + if encErr != nil { + return "", encErr + } + return hexx.Encode(ciphertext) + aeadSeparator + hexx.Encode(nonce), nil + + // ---- 分组密码(含 ECB 这类历史模式)---- + case DES_ECB, DES_CBC, DES_CFB, DES_OFB, DES_CTR, + TDES_ECB, TDES_CBC, TDES_CFB, TDES_OFB, TDES_CTR, + AES_ECB, AES_CBC, AES_CFB, AES_OFB, AES_CTR, + BLOWFISH_ECB, BLOWFISH_CBC, BLOWFISH_CFB, BLOWFISH_OFB, BLOWFISH_CTR: + if err := options.rejectUnknown(optIV); err != nil { + return "", err + } + suite, mode, err := suiteAndMode(encryptType) + if err != nil { + return "", err + } + iv, hasIV, err := optionalBytesValue(encryptType, options, optIV) + if err != nil { + return "", err + } + if hasIV { + return suite.EncryptWithIVHex(mode, []byte(data), key, iv) + } + return suite.EncryptHex(mode, []byte(data), key) + + // ---- 非对称加密 ---- + case RSA, RSA_OAEP: + if err := options.rejectUnknown(optPublicKeyFile); err != nil { + return "", err + } + pub, err := resolvePublicKey(key, options) + if err != nil { + return "", err + } + ciphertext, err := rsax.EncryptOAEPLong(pub, []byte(data)) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(ciphertext), nil + } + + return "", fmt.Errorf("%w: %d", ErrUnsupportedType, int(encryptType)) +} + +// Decrypt 撤销 Encrypt 的处理,返回原始数据。 +// +// 只有可逆算法可以调用本函数;哈希、HMAC、密钥派生与口令哈希都会返回包装了 +// ErrIrreversible 的错误。 +func Decrypt(encryptType EncryptType, data string, key []byte, options Options) (string, error) { + if !encryptType.Valid() { + return "", fmt.Errorf("%w: %d", ErrUnsupportedType, int(encryptType)) + } + if !encryptType.IsReversible() { + return "", fmt.Errorf("%w: %s", ErrIrreversible, encryptType) + } + if options == nil { + options = Options{} + } + if err := checkKeyUsage(encryptType, key, options); err != nil { + return "", err + } + + switch encryptType { + case BASE64: + if err := options.rejectUnknown(); err != nil { + return "", err + } + out, err := base64x.Decode(data) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidInput, err) + } + return string(out), nil + case BASE64_URL: + if err := options.rejectUnknown(); err != nil { + return "", err + } + out, err := base64x.DecodeURL(data) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidInput, err) + } + return string(out), nil + case HEX: + if err := options.rejectUnknown(); err != nil { + return "", err + } + out, err := hexx.Decode(data) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidInput, err) + } + return string(out), nil + + case AES_GCM: + if err := options.rejectUnknown(optAAD); err != nil { + return "", err + } + aad, err := optionalStringOption(encryptType, options, optAAD) + if err != nil { + return "", err + } + ciphertextHex, nonceHex, err := splitAEAD(data) + if err != nil { + return "", err + } + ciphertext, err := hexx.Decode(ciphertextHex) + if err != nil { + return "", fmt.Errorf("%w: 密文解码失败: %w", ErrInvalidInput, err) + } + nonce, err := hexx.Decode(nonceHex) + if err != nil { + return "", fmt.Errorf("%w: nonce 解码失败: %w", ErrInvalidInput, err) + } + out, err := aesx.DecryptGCM(ciphertext, key, nonce, aad) + if err != nil { + return "", classifyDecryptError(err) + } + return string(out), nil + + case CHACHA20_POLY1305, XCHACHA20_POLY1305: + if err := options.rejectUnknown(optAAD); err != nil { + return "", err + } + aad, err := optionalStringOption(encryptType, options, optAAD) + if err != nil { + return "", err + } + ciphertextHex, nonceHex, err := splitAEAD(data) + if err != nil { + return "", err + } + ciphertext, err := hexx.Decode(ciphertextHex) + if err != nil { + return "", fmt.Errorf("%w: 密文解码失败: %w", ErrInvalidInput, err) + } + nonce, err := hexx.Decode(nonceHex) + if err != nil { + return "", fmt.Errorf("%w: nonce 解码失败: %w", ErrInvalidInput, err) + } + + var plaintext []byte + if encryptType == CHACHA20_POLY1305 { + plaintext, err = chachax.Decrypt(key, ciphertext, nonce, aad) + } else { + plaintext, err = chachax.DecryptX(key, ciphertext, nonce, aad) + } + if err != nil { + return "", classifyDecryptError(err) + } + return string(plaintext), nil + + case DES_ECB, DES_CBC, DES_CFB, DES_OFB, DES_CTR, + TDES_ECB, TDES_CBC, TDES_CFB, TDES_OFB, TDES_CTR, + AES_ECB, AES_CBC, AES_CFB, AES_OFB, AES_CTR, + BLOWFISH_ECB, BLOWFISH_CBC, BLOWFISH_CFB, BLOWFISH_OFB, BLOWFISH_CTR: + if err := options.rejectUnknown(optIV); err != nil { + return "", err + } + suite, mode, err := suiteAndMode(encryptType) + if err != nil { + return "", err + } + iv, hasIV, err := optionalBytesValue(encryptType, options, optIV) + if err != nil { + return "", err + } + var plaintext []byte + if hasIV { + plaintext, err = suite.DecryptWithIVHex(mode, data, key, iv) + } else { + plaintext, err = suite.DecryptHex(mode, data, key) + } + if err != nil { + return "", classifyDecryptError(err) + } + return string(plaintext), nil + + case RSA, RSA_OAEP: + if err := options.rejectUnknown(optPrivateKeyFile); err != nil { + return "", err + } + priv, err := resolvePrivateKey(key, options) + if err != nil { + return "", err + } + ciphertext, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return "", fmt.Errorf("%w: Base64 解码失败: %w", ErrInvalidInput, err) + } + plaintext, err := rsax.DecryptOAEPLong(priv, ciphertext) + if err != nil { + return "", err + } + return string(plaintext), nil + } + + return "", fmt.Errorf("%w: %d", ErrUnsupportedType, int(encryptType)) +} + +// GenerateKey 生成随机密钥或盐值。 +// +// 返回的字节可以直接作为 Encrypt/Decrypt 的 key 参数,不存在任何编码转换—— +// 这正是历史版本最严重缺陷的修复点:旧版返回十六进制文本,长度翻倍后任何 +// 加密调用都会因密钥长度非法而失败。 +// +// 对 RSA,返回 PKCS#8 私钥的 PEM 内容。若同时给出 privateKeyFile 与 +// publicKeyFile 选项,还会把密钥对写入这两个文件。 +func GenerateKey(encryptType EncryptType, options Options) ([]byte, error) { + if !encryptType.Valid() { + return nil, fmt.Errorf("%w: %d", ErrUnsupportedType, int(encryptType)) + } + if options == nil { + options = Options{} + } + + switch encryptType { + case DES_ECB, DES_CBC, DES_CFB, DES_OFB, DES_CTR: + if err := options.rejectUnknown(); err != nil { + return nil, err + } + return desx.GenerateKey() + + case TDES_ECB, TDES_CBC, TDES_CFB, TDES_OFB, TDES_CTR: + if err := options.rejectUnknown(); err != nil { + return nil, err + } + return tdesx.GenerateKey() + + case AES_ECB, AES_CBC, AES_CFB, AES_OFB, AES_CTR, AES_GCM: + if err := options.rejectUnknown(optKeyLength); err != nil { + return nil, err + } + keyLength, err := optionalInt(encryptType, options, optKeyLength, aesx.KeySize256) + if err != nil { + return nil, err + } + return aesx.GenerateKey(keyLength) + + case BLOWFISH_ECB, BLOWFISH_CBC, BLOWFISH_CFB, BLOWFISH_OFB, BLOWFISH_CTR: + if err := options.rejectUnknown(optKeyLength); err != nil { + return nil, err + } + keyLength, err := optionalInt(encryptType, options, optKeyLength, blowfishx.DefaultKeySize) + if err != nil { + return nil, err + } + return blowfishx.GenerateKey(keyLength) + + case CHACHA20_POLY1305, XCHACHA20_POLY1305: + if err := options.rejectUnknown(); err != nil { + return nil, err + } + return chachax.GenerateKey() + + case HMAC_SHA1, HMAC_SHA256, HMAC_SHA384, HMAC_SHA512: + if err := options.rejectUnknown(optKeyLength); err != nil { + return nil, err + } + keyLength, err := optionalInt(encryptType, options, optKeyLength, hmacx.DefaultKeyLength) + if err != nil { + return nil, err + } + if keyLength < hmacx.MinKeyLength { + return nil, fmt.Errorf("encryptx: HMAC 密钥长度 %d 小于最小值 %d 字节", + keyLength, hmacx.MinKeyLength) + } + return utilsx.RandomBytes(keyLength) + + case PBKDF2, HKDF, SCRYPT, ARGON2ID: + if err := options.rejectUnknown(optSaltLength); err != nil { + return nil, err + } + saltLength, err := optionalInt(encryptType, options, optSaltLength, pbkdf2x.DefaultSaltLength) + if err != nil { + return nil, err + } + return utilsx.RandomBytes(saltLength) + + case RSA, RSA_OAEP: + if err := options.rejectUnknown(optBits, optPrivateKeyFile, optPublicKeyFile); err != nil { + return nil, err + } + bits, err := optionalInt(encryptType, options, optBits, rsax.DefaultKeyBits) + if err != nil { + return nil, err + } + privatePEM, publicPEM, err := rsax.GenerateKeyPairPEM(bits) + if err != nil { + return nil, err + } + if err := maybeSaveKeyPair(privatePEM, publicPEM, options); err != nil { + return nil, err + } + return privatePEM, nil + + case MD5, SHA1, SHA224, SHA256, SHA384, SHA512, + SHA3_224, SHA3_256, SHA3_384, SHA3_512, + BLAKE2B_256, BLAKE2S_256, RIPEMD160: + return nil, fmt.Errorf("encryptx: %s 是哈希算法, 不需要密钥", encryptType) + + case BCRYPT: + return nil, fmt.Errorf("encryptx: bcrypt 的盐值内嵌在哈希结果中, 无需单独生成; 请直接调用 HashPassword") + + case BASE64, BASE64_URL, HEX: + return nil, fmt.Errorf("encryptx: %s 是编码而非加密, 不需要密钥", encryptType) + } + + return nil, fmt.Errorf("%w: %d", ErrUnsupportedType, int(encryptType)) +} + +// GenerateKeyPair 生成非对称密钥对,返回 PEM 编码的私钥与公钥。 +// +// 对 RSA 可用 options["bits"] 指定位数(默认 2048,最小 2048);Ed25519 与 +// ECDSA 忽略该选项(曲线由 SignType 决定)。 +func GenerateKeyPair(signType SignType, options Options) (privatePEM, publicPEM []byte, err error) { + if !signType.Valid() { + return nil, nil, fmt.Errorf("%w: 签名算法 %d", ErrUnsupportedType, int(signType)) + } + if options == nil { + options = Options{} + } + + switch signType { + case ED25519: + if err := options.rejectUnknown(); err != nil { + return nil, nil, err + } + return ed25519x.GenerateKeyPEM() + + case ECDSA_P256, ECDSA_P384, ECDSA_P521: + if err := options.rejectUnknown(); err != nil { + return nil, nil, err + } + return ecdsax.GenerateKeyPEM(curveForSignType(signType)) + + case RSA_PSS, RSA_PKCS1V15: + if err := options.rejectUnknown(optBits); err != nil { + return nil, nil, err + } + bits, err := optionalInt(RSA, options, optBits, rsax.DefaultKeyBits) + if err != nil { + return nil, nil, err + } + return rsax.GenerateKeyPairPEM(bits) + } + + return nil, nil, fmt.Errorf("%w: 签名算法 %d", ErrUnsupportedType, int(signType)) +} + +// DeriveKey 从口令/密钥材料派生密钥,**同时返回实际使用的盐值**。 +// +// 这是密钥派生推荐的入口:盐值必须与派生结果一起保存,否则无法复现。历史版本 +// 的门面只返回密钥而丢弃盐值,使派生结果永远无法复现。 +// +// 支持 PBKDF2、HKDF、SCRYPT、ARGON2ID。HKDF 用于高熵密钥材料的派生与分离, +// 不适合低熵口令;口令存储请用 ARGON2ID 或 SCRYPT,或直接使用 HashPassword。 +// +// 若 options 提供了 salt 就使用它,否则生成随机盐并随返回值一并给出。 +func DeriveKey(encryptType EncryptType, secret []byte, options Options) (key, salt []byte, err error) { + if options == nil { + options = Options{} + } + if len(secret) == 0 { + return nil, nil, fmt.Errorf("%w: 派生用的口令/密钥材料不能为空", ErrInvalidInput) + } + + switch encryptType { + case PBKDF2: + if err := options.rejectUnknown(optSalt, optSaltLength, optIterations, optKeyLength); err != nil { + return nil, nil, err + } + iterations, err := optionalInt(encryptType, options, optIterations, pbkdf2x.DefaultIterations) + if err != nil { + return nil, nil, err + } + keyLength, err := optionalInt(encryptType, options, optKeyLength, pbkdf2x.DefaultKeyLength) + if err != nil { + return nil, nil, err + } + saltLength, err := optionalInt(encryptType, options, optSaltLength, pbkdf2x.DefaultSaltLength) + if err != nil { + return nil, nil, err + } + return deriveWithOptionalSalt(encryptType, options, secret, + func(s []byte) ([]byte, error) { + return pbkdf2x.DeriveKey(secret, s, iterations, keyLength) + }, + func() ([]byte, []byte, error) { + return pbkdf2x.DeriveKeyWithRandomSalt(secret, saltLength, iterations, keyLength) + }) + + case HKDF: + if err := options.rejectUnknown(optSalt, optInfo, optKeyLength); err != nil { + return nil, nil, err + } + keyLength, err := optionalInt(encryptType, options, optKeyLength, hkdfx.DefaultKeyLength) + if err != nil { + return nil, nil, err + } + info, err := optionalBytes(encryptType, options, optInfo) + if err != nil { + return nil, nil, err + } + salt, provided, err := optionalBytesValue(encryptType, options, optSalt) + if err != nil { + return nil, nil, err + } + if !provided { + // HKDF 允许空盐(标准等价于全零盐),无需强制随机化。 + salt = nil + } + key, err = hkdfx.DeriveKey(secret, salt, info, keyLength) + if err != nil { + return nil, nil, err + } + return key, salt, nil + + case SCRYPT: + if err := options.rejectUnknown(optSalt, optSaltLength, optScryptN, optScryptR, optScryptP, optKeyLength); err != nil { + return nil, nil, err + } + n, err := optionalInt(encryptType, options, optScryptN, scryptx.DefaultN) + if err != nil { + return nil, nil, err + } + r, err := optionalInt(encryptType, options, optScryptR, scryptx.DefaultR) + if err != nil { + return nil, nil, err + } + p, err := optionalInt(encryptType, options, optScryptP, scryptx.DefaultP) + if err != nil { + return nil, nil, err + } + keyLength, err := optionalInt(encryptType, options, optKeyLength, scryptx.DefaultKeyLength) + if err != nil { + return nil, nil, err + } + saltLength, err := optionalInt(encryptType, options, optSaltLength, scryptx.DefaultSaltLength) + if err != nil { + return nil, nil, err + } + return deriveWithOptionalSalt(encryptType, options, secret, + func(s []byte) ([]byte, error) { + return scryptx.DeriveKey(secret, s, n, r, p, keyLength) + }, + func() ([]byte, []byte, error) { + return scryptx.DeriveKeyWithRandomSalt(secret, saltLength, n, r, p, keyLength) + }) + + case ARGON2ID: + if err := options.rejectUnknown(optSalt, optSaltLength, optArgon2Time, optArgon2Memory, optArgon2Threads, optKeyLength); err != nil { + return nil, nil, err + } + timeCost, err := optionalInt(encryptType, options, optArgon2Time, argon2x.DefaultTime) + if err != nil { + return nil, nil, err + } + memory, err := optionalInt(encryptType, options, optArgon2Memory, argon2x.DefaultMemory) + if err != nil { + return nil, nil, err + } + threads, err := optionalInt(encryptType, options, optArgon2Threads, argon2x.DefaultThreads) + if err != nil { + return nil, nil, err + } + keyLength, err := optionalInt(encryptType, options, optKeyLength, argon2x.DefaultKeyLength) + if err != nil { + return nil, nil, err + } + saltLength, err := optionalInt(encryptType, options, optSaltLength, argon2x.DefaultSaltLength) + if err != nil { + return nil, nil, err + } + return deriveWithOptionalSalt(encryptType, options, secret, + func(s []byte) ([]byte, error) { + return argon2x.DeriveKey(secret, s, + uint32(timeCost), uint32(memory), uint8(threads), uint32(keyLength)) + }, + func() ([]byte, []byte, error) { + return argon2x.DeriveKeyWithRandomSalt(secret, saltLength, + uint32(timeCost), uint32(memory), uint8(threads), uint32(keyLength)) + }) + } + + return nil, nil, fmt.Errorf("%w: %s 不是密钥派生算法", ErrUnsupportedType, encryptType) +} + +// Sign 使用非对称私钥对数据签名,返回标准 Base64 编码的签名。 +// +// key 必须是 PEM 编码的私钥内容,也可以用 options["privateKeyFile"] 指定文件路径。 +func Sign(signType SignType, data string, key []byte, options Options) (string, error) { + if !signType.Valid() { + return "", fmt.Errorf("%w: 签名算法 %d", ErrUnsupportedType, int(signType)) + } + if options == nil { + options = Options{} + } + if err := options.rejectUnknown(optPrivateKeyFile); err != nil { + return "", err + } + keyPEM, err := resolveKeyMaterial(key, options, optPrivateKeyFile, "私钥") + if err != nil { + return "", err + } + + switch signType { + case ED25519: + priv, err := ed25519x.ParsePrivateKeyPEM(keyPEM) + if err != nil { + return "", err + } + return ed25519x.SignToBase64(priv, []byte(data)) + + case ECDSA_P256, ECDSA_P384, ECDSA_P521: + priv, err := ecdsax.ParsePrivateKeyPEM(keyPEM) + if err != nil { + return "", err + } + if priv.Curve != curveForSignType(signType) { + return "", fmt.Errorf("encryptx: 私钥曲线与 %s 不匹配", signType) + } + return ecdsax.SignToBase64(priv, []byte(data)) + + case RSA_PSS, RSA_PKCS1V15: + priv, err := rsax.ParsePrivateKeyPEM(keyPEM) + if err != nil { + return "", err + } + var signature []byte + if signType == RSA_PSS { + signature, err = rsax.SignPSS(priv, []byte(data)) + } else { + signature, err = rsax.SignPKCS1v15(priv, []byte(data)) + } + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(signature), nil + } + + return "", fmt.Errorf("%w: 签名算法 %d", ErrUnsupportedType, int(signType)) +} + +// Verify 校验签名:有效返回 nil,无效返回错误。 +// +// key 必须是 PEM 编码的公钥内容,也可以用 options["publicKeyFile"] 指定文件路径。 +func Verify(signType SignType, data string, signature string, key []byte, options Options) error { + if !signType.Valid() { + return fmt.Errorf("%w: 签名算法 %d", ErrUnsupportedType, int(signType)) + } + if options == nil { + options = Options{} + } + if err := options.rejectUnknown(optPublicKeyFile); err != nil { + return err + } + keyPEM, err := resolveKeyMaterial(key, options, optPublicKeyFile, "公钥") + if err != nil { + return err + } + + switch signType { + case ED25519: + pub, err := ed25519x.ParsePublicKeyPEM(keyPEM) + if err != nil { + return err + } + return ed25519x.VerifyFromBase64(pub, []byte(data), signature) + + case ECDSA_P256, ECDSA_P384, ECDSA_P521: + pub, err := ecdsax.ParsePublicKeyPEM(keyPEM) + if err != nil { + return err + } + if pub.Curve != curveForSignType(signType) { + return fmt.Errorf("encryptx: 公钥曲线与 %s 不匹配", signType) + } + return ecdsax.VerifyFromBase64(pub, []byte(data), signature) + + case RSA_PSS, RSA_PKCS1V15: + pub, err := rsax.ParsePublicKeyPEM(keyPEM) + if err != nil { + return err + } + signatureBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return fmt.Errorf("%w: 签名 Base64 解码失败: %w", ErrInvalidInput, err) + } + if signType == RSA_PSS { + return rsax.VerifyPSS(pub, []byte(data), signatureBytes) + } + return rsax.VerifyPKCS1v15(pub, []byte(data), signatureBytes) + } + + return fmt.Errorf("%w: 签名算法 %d", ErrUnsupportedType, int(signType)) +} + +// HashPassword 对用户口令做单向哈希,返回可直接入库的编码串。 +// +// 支持 BCRYPT 与 ARGON2ID:两者都把参数与随机盐编码在结果中,因此不需要另行 +// 保存盐值。无兼容性约束时建议使用 ARGON2ID。 +func HashPassword(encryptType EncryptType, password []byte, options Options) (string, error) { + if options == nil { + options = Options{} + } + switch encryptType { + case BCRYPT: + if err := options.rejectUnknown(optCost); err != nil { + return "", err + } + return bcryptHash(string(password), options) + case ARGON2ID: + if err := options.rejectUnknown(optArgon2Time, optArgon2Memory, optArgon2Threads, optKeyLength); err != nil { + return "", err + } + return argon2Hash(string(password), options) + } + return "", fmt.Errorf("%w: %s 不是口令哈希算法", ErrUnsupportedType, encryptType) +} + +// VerifyPassword 校验口令是否与 HashPassword 产生的编码串匹配。 +// +// 返回值语义:编码串本身非法时返回 error,口令不匹配时返回 (false, nil)。 +func VerifyPassword(encryptType EncryptType, password []byte, encoded string, options Options) (bool, error) { + if options == nil { + options = Options{} + } + switch encryptType { + case BCRYPT: + if err := options.rejectUnknown(); err != nil { + return false, err + } + return bcryptx.VerifyPassword(password, encoded) + case ARGON2ID: + if err := options.rejectUnknown(); err != nil { + return false, err + } + return argon2x.VerifyPassword(password, encoded) + } + return false, fmt.Errorf("%w: %s 不是口令哈希算法", ErrUnsupportedType, encryptType) +} + +// ---- 内部辅助 ---- + +// suiteAndMode 把分组密码算法枚举映射为 (套件, 模式)。 +func suiteAndMode(encryptType EncryptType) (*cipherx.Suite, cipherx.Mode, error) { + switch encryptType { + case DES_ECB: + return desx.Suite, cipherx.ECB, nil + case DES_CBC: + return desx.Suite, cipherx.CBC, nil + case DES_CFB: + return desx.Suite, cipherx.CFB, nil + case DES_OFB: + return desx.Suite, cipherx.OFB, nil + case DES_CTR: + return desx.Suite, cipherx.CTR, nil + + case TDES_ECB: + return tdesx.Suite, cipherx.ECB, nil + case TDES_CBC: + return tdesx.Suite, cipherx.CBC, nil + case TDES_CFB: + return tdesx.Suite, cipherx.CFB, nil + case TDES_OFB: + return tdesx.Suite, cipherx.OFB, nil + case TDES_CTR: + return tdesx.Suite, cipherx.CTR, nil + + case AES_ECB: + return aesx.Suite, cipherx.ECB, nil + case AES_CBC: + return aesx.Suite, cipherx.CBC, nil + case AES_CFB: + return aesx.Suite, cipherx.CFB, nil + case AES_OFB: + return aesx.Suite, cipherx.OFB, nil + case AES_CTR: + return aesx.Suite, cipherx.CTR, nil + + case BLOWFISH_ECB: + return blowfishx.Suite, cipherx.ECB, nil + case BLOWFISH_CBC: + return blowfishx.Suite, cipherx.CBC, nil + case BLOWFISH_CFB: + return blowfishx.Suite, cipherx.CFB, nil + case BLOWFISH_OFB: + return blowfishx.Suite, cipherx.OFB, nil + case BLOWFISH_CTR: + return blowfishx.Suite, cipherx.CTR, nil + } + return nil, 0, fmt.Errorf("%w: %s 不是分组密码算法", ErrUnsupportedType, encryptType) +} + +// splitAEAD 拆分 "密文:nonce" 形式的 AEAD 输出。 +func splitAEAD(data string) (ciphertextHex, nonceHex string, err error) { + parts := strings.Split(data, aeadSeparator) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("%w: AEAD 密文格式应为 \"密文十六进制:nonce十六进制\"", ErrInvalidInput) + } + return parts[0], parts[1], nil +} + +// optionalInt 读取整数选项,缺省时返回 defaultValue。 +func optionalInt(encryptType EncryptType, options Options, key string, defaultValue int) (int, error) { + value, ok, err := options.Int(key) + if err != nil { + return 0, fmt.Errorf("%s: %w", encryptType, err) + } + if !ok { + return defaultValue, nil + } + return value, nil +} + +// optionalStringOption 读取字符串选项并按原样取字节,缺省返回 nil。 +func optionalStringOption(encryptType EncryptType, options Options, key string) ([]byte, error) { + value, ok, err := options.String(key) + if err != nil { + return nil, fmt.Errorf("%s: %w", encryptType, err) + } + if !ok { + return nil, nil + } + return []byte(value), nil +} + +// optionalBytes 读取二进制选项(接受 []byte 或十六进制字符串),缺省返回 nil。 +func optionalBytes(encryptType EncryptType, options Options, key string) ([]byte, error) { + value, _, err := optionalBytesValue(encryptType, options, key) + return value, err +} + +// optionalBytesValue 读取二进制选项,同时报告该键是否存在。 +func optionalBytesValue(encryptType EncryptType, options Options, key string) ([]byte, bool, error) { + if _, exists := options[key]; !exists { + return nil, false, nil + } + value, ok, err := options.Bytes(key) + if err != nil { + return nil, false, fmt.Errorf("%s: %w", encryptType, err) + } + return value, ok, nil +} + +// decodeOptionBytes 把字符串选项解释为十六进制字节。 +// +// 约定字符串一律是十六进制:这样盐值、IV 等参数在配置文件与数据库里都是可读 +// 文本,也不存在「这串字符到底是编码还是原文」的歧义。需要传原始字节时请直接 +// 传 []byte。 +func decodeOptionBytes(key, value string) ([]byte, error) { + decoded, err := hex.DecodeString(strings.TrimSpace(value)) + if err != nil { + return nil, fmt.Errorf("%w: 选项 %q 需要十六进制字符串: %w", ErrInvalidOption, key, err) + } + return decoded, nil +} + +// hmacHex 按算法计算 HMAC 并返回小写十六进制。 +func hmacHex(encryptType EncryptType, message string, key []byte) string { + switch encryptType { + case HMAC_SHA1: + return hmacx.HexSHA1([]byte(message), key) + case HMAC_SHA256: + return hmacx.HexSHA256([]byte(message), key) + case HMAC_SHA384: + return hmacx.HexSHA384([]byte(message), key) + default: + return hmacx.HexSHA512([]byte(message), key) + } +} + +// bcryptHash 处理 BCRYPT 的口令哈希。 +func bcryptHash(password string, options Options) (string, error) { + cost, err := optionalInt(BCRYPT, options, optCost, bcryptx.DefaultCost) + if err != nil { + return "", err + } + return bcryptx.HashPasswordWithCost([]byte(password), cost) +} + +// argon2Hash 处理 ARGON2ID 的口令哈希。 +func argon2Hash(password string, options Options) (string, error) { + timeCost, err := optionalInt(ARGON2ID, options, optArgon2Time, argon2x.DefaultTime) + if err != nil { + return "", err + } + memory, err := optionalInt(ARGON2ID, options, optArgon2Memory, argon2x.DefaultMemory) + if err != nil { + return "", err + } + threads, err := optionalInt(ARGON2ID, options, optArgon2Threads, argon2x.DefaultThreads) + if err != nil { + return "", err + } + keyLength, err := optionalInt(ARGON2ID, options, optKeyLength, argon2x.DefaultKeyLength) + if err != nil { + return "", err + } + return argon2x.HashPasswordWithParams([]byte(password), + uint32(timeCost), uint32(memory), uint8(threads), uint32(keyLength)) +} + +// deriveWithOptionalSalt 统一处理「给了盐就用给定的、没给就随机生成」两条分支, +// 保证两种路径都返回实际使用的盐值。 +func deriveWithOptionalSalt( + encryptType EncryptType, + options Options, + _ []byte, + withSalt func(salt []byte) ([]byte, error), + withRandomSalt func() (key, salt []byte, err error), +) (key, salt []byte, err error) { + providedSalt, provided, err := optionalBytesValue(encryptType, options, optSalt) + if err != nil { + return nil, nil, err + } + if provided { + key, err := withSalt(providedSalt) + if err != nil { + return nil, nil, err + } + return key, providedSalt, nil + } + return withRandomSalt() +} + +// deriveHex 让 Encrypt 支持密钥派生算法。 +// +// 为避免重蹈「盐值被丢弃」的覆辙,本路径要求除 HKDF 之外的算法显式提供 salt +// 选项;需要自动生成盐值并把它取回时,请调用 DeriveKey。 +// +// HKDF 是例外:它的盐值可以为空(标准规定等价于全零盐),且其输入本就是高熵 +// 密钥材料,因此允许缺省,与 DeriveKey 的行为保持一致。 +func deriveHex(encryptType EncryptType, data string, options Options) (string, error) { + if _, ok, err := optionalBytesValue(encryptType, options, optSalt); err != nil { + return "", err + } else if !ok && encryptType != HKDF { + return "", fmt.Errorf("%w: %s 需要 %q 选项才能复现结果; "+ + "若希望自动生成盐值并同时取回, 请使用 DeriveKey", ErrMissingOption, encryptType, optSalt) + } + derived, _, err := DeriveKey(encryptType, []byte(data), options) + if err != nil { + return "", err + } + return hexx.Encode(derived), nil +} + +// checkKeyUsage 统一校验密钥的"有无"是否符合算法预期。 +// +// 需要密钥的算法缺密钥 → ErrMissingKey;不需要密钥的算法却传了密钥 → +// ErrUnexpectedKey。后者尤其重要:历史版本会静默忽略多余的密钥,让调用方 +// 误以为 Base64 编码或哈希提供了机密性保护。 +func checkKeyUsage(encryptType EncryptType, key []byte, options Options) error { + if encryptType.NeedsKey() { + if len(key) == 0 && !keyMayComeFromFile(options) { + return fmt.Errorf("%w: %s", ErrMissingKey, encryptType) + } + return nil + } + if len(key) > 0 { + return fmt.Errorf("%w: %s 不使用密钥, 却传入了 %d 字节的 key", + ErrUnexpectedKey, encryptType, len(key)) + } + return nil +} + +// classifyDecryptError 让门面的错误分类保持自洽。 +// +// 密文的编码/长度问题归类为 ErrInvalidInput(这些是调用方传错了数据);而认证 +// 失败必须保持可区分——若把"被篡改"也归为"输入格式错误",调用方就无法把它和 +// 参数写错区分开,安全告警会被淹没在普通错误里。 +func classifyDecryptError(err error) error { + if err == nil { + return nil + } + switch { + case errors.Is(err, aesx.ErrAuthenticationFailed), + errors.Is(err, chachax.ErrAuthenticationFailed): + return err + case errors.Is(err, cipherx.ErrCiphertextTooShort), + errors.Is(err, cipherx.ErrNotBlockAligned), + errors.Is(err, cipherx.ErrEmptyCiphertext), + errors.Is(err, cipherx.ErrInvalidIVSize), + errors.Is(err, cipherx.ErrInvalidHex), + errors.Is(err, aesx.ErrInvalidNonceSize), + errors.Is(err, aesx.ErrCiphertextTooShort), + errors.Is(err, chachax.ErrInvalidNonceSize), + errors.Is(err, chachax.ErrCiphertextTooShort): + return fmt.Errorf("%w: %w", ErrInvalidInput, err) + default: + return err + } +} + +// keyMayComeFromFile 报告密钥是否可能由选项里的文件路径提供。 +// +// 非对称算法的 key 参数允许留空,改用 publicKeyFile / privateKeyFile 指定文件, +// 因此"密钥为空"不能一概判为缺参。 +func keyMayComeFromFile(options Options) bool { + if v, ok := options[optPublicKeyFile].(string); ok && v != "" { + return true + } + if v, ok := options[optPrivateKeyFile].(string); ok && v != "" { + return true + } + return false +} + +// resolveKeyMaterial 返回密钥 PEM 内容:优先用显式传入的字节,其次按选项读文件。 +func resolveKeyMaterial(key []byte, options Options, fileOption, what string) ([]byte, error) { + if len(key) > 0 { + return key, nil + } + path, ok, err := options.String(fileOption) + if err != nil { + return nil, err + } + if !ok || path == "" { + return nil, fmt.Errorf("%w: 需要 %s(PEM 内容, 或 %s 选项指定文件)", ErrMissingKey, what, fileOption) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("encryptx: 读取 %s 文件失败: %w", what, err) + } + return data, nil +} + +// resolvePublicKey 解析 RSA 公钥(PEM 内容或文件)。 +func resolvePublicKey(key []byte, options Options) (*rsa.PublicKey, error) { + keyPEM, err := resolveKeyMaterial(key, options, optPublicKeyFile, "公钥") + if err != nil { + return nil, err + } + return rsax.ParsePublicKeyPEM(keyPEM) +} + +// resolvePrivateKey 解析 RSA 私钥(PEM 内容或文件)。 +func resolvePrivateKey(key []byte, options Options) (*rsa.PrivateKey, error) { + keyPEM, err := resolveKeyMaterial(key, options, optPrivateKeyFile, "私钥") + if err != nil { + return nil, err + } + return rsax.ParsePrivateKeyPEM(keyPEM) +} + +// maybeSaveKeyPair 在提供了文件路径选项时把密钥对写入磁盘。 +func maybeSaveKeyPair(privatePEM, publicPEM []byte, options Options) error { + privatePath, hasPrivate, err := options.String(optPrivateKeyFile) + if err != nil { + return err + } + publicPath, hasPublic, err := options.String(optPublicKeyFile) + if err != nil { + return err + } + if !hasPrivate && !hasPublic { + return nil + } + if !hasPrivate || !hasPublic { + return fmt.Errorf("encryptx: %s 与 %s 必须同时提供", optPrivateKeyFile, optPublicKeyFile) + } + if err := os.WriteFile(privatePath, privatePEM, 0o600); err != nil { + return fmt.Errorf("encryptx: 写入私钥文件失败: %w", err) + } + if err := os.WriteFile(publicPath, publicPEM, 0o644); err != nil { + return fmt.Errorf("encryptx: 写入公钥文件失败: %w", err) + } + return nil +} + +// curveForSignType 返回签名类型对应的椭圆曲线。 +func curveForSignType(signType SignType) elliptic.Curve { + switch signType { + case ECDSA_P256: + return elliptic.P256() + case ECDSA_P384: + return elliptic.P384() + default: + return elliptic.P521() + } +} diff --git a/ecdsax/ecdsax.go b/ecdsax/ecdsax.go new file mode 100644 index 0000000..c8697df --- /dev/null +++ b/ecdsax/ecdsax.go @@ -0,0 +1,380 @@ +// Package ecdsax 基于标准库 crypto/ecdsa 提供 ECDSA 数字签名的密钥生成、 +// PEM 编解码、签名与验签能力,支持 NIST P-256 / P-384 / P-521 三条曲线。 +// +// 摘要算法按曲线自动选择,调用方无需(也不允许)自行指定: +// +// P-256 -> SHA-256 +// P-384 -> SHA-384 +// P-521 -> SHA-512 +// +// 安全说明: +// - 仅接受标准库的三条 NIST 曲线单例(elliptic.P256/P384/P521), +// P-224 已不再安全、自定义曲线可能实现错误或被替换,一律拒绝。 +// - Sign 内部使用 crypto/ecdsa 的加固实现:每个签名都消耗系统安全随机源 +// 生成 nonce,调用方不提供也不能复用 nonce,从而避免 nonce 重复导致 +// 私钥泄露(这是 ECDSA 最经典的事故来源)。 +// - Verify 使用标准库实现,签名校验过程为常量时间语义,不会因签名的部分 +// 字节正确而提前返回。 +// - 私钥(*ecdsa.PrivateKey)泄露等同于身份被完全冒充;本包不会把私钥、 +// 明文或随机数写入错误信息或标准输出,也不提供自动清零,调用方用完应 +// 自行零覆盖 priv.D 等敏感值。 +// - 本包只做签名/验签,不提供加密与密钥协商;如需加密请使用 AES-GCM 或 +// RSA-OAEP 等方案。 +// - ECDSA 的兼容性好,但签名非确定且有参数(曲线、哈希);新系统若没有 +// 互操作要求,签名场景优先考虑 Ed25519(见包 ed25519x)。 +package ecdsax + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "hash" + "os" +) + +// PEM 块类型标签:PKCS#8 私钥、SEC1 私钥(兼容 OpenSSL ecparam 产物)、PKIX 公钥。 +const ( + pemTypePrivateKeyPKCS8 = "PRIVATE KEY" + pemTypePrivateKeySEC1 = "EC PRIVATE KEY" + pemTypePublicKey = "PUBLIC KEY" + pemTypePublicKeySEC1 = "EC PUBLIC KEY" +) + +// 文件权限:私钥仅所有者可读写,公钥可被其他用户读取。 +const ( + privateFileMode os.FileMode = 0o600 + publicFileMode os.FileMode = 0o644 +) + +// hashForCurve 返回曲线对应的摘要构造函数,同时完成曲线白名单校验。 +// +// 用接口值相等(而非曲线名字符串)判断,只接受标准库的曲线单例:名字相同 +// 的自定义曲线可能伪装成 P-256 却使用可预测的运算,必须拒绝。 +func hashForCurve(curve elliptic.Curve) (func() hash.Hash, error) { + switch curve { + case elliptic.P256(): + return sha256.New, nil + case elliptic.P384(): + return sha512.New384, nil + case elliptic.P521(): + return sha512.New, nil + default: + return nil, fmt.Errorf("不支持的椭圆曲线 %s: 仅支持 P-256、P-384、P-521", curveName(curve)) + } +} + +// curveName 安全地取出曲线名,用于错误信息;任何情况下都不会因 nil 而 panic。 +func curveName(curve elliptic.Curve) string { + if curve == nil { + return "" + } + if params := curve.Params(); params != nil { + return params.Name + } + return fmt.Sprintf("%T", curve) +} + +// validatePrivateKey 校验私钥并返回其摘要构造函数。 +// +// nil、曲线不受支持、坐标或私钥标量缺失、点不在曲线上等情形都返回错误; +// 校验放在签名之前,可避免底层实现遇到畸形密钥时 panic 或产生有泄漏风险的运算。 +func validatePrivateKey(priv *ecdsa.PrivateKey) (func() hash.Hash, error) { + if priv == nil { + return nil, errors.New("ECDSA 私钥为 nil") + } + newHash, err := hashForCurve(priv.Curve) + if err != nil { + return nil, err + } + if priv.D == nil { + return nil, errors.New("非法的 ECDSA 私钥: 缺少私钥标量 D") + } + if priv.X == nil || priv.Y == nil { + return nil, errors.New("非法的 ECDSA 私钥: 缺少公钥坐标 X/Y") + } + if _, err := priv.ECDH(); err != nil { + return nil, fmt.Errorf("非法的 ECDSA 私钥: %w", err) + } + return newHash, nil +} + +// validatePublicKey 校验公钥并返回其摘要构造函数。 +// +// nil、曲线不受支持、坐标缺失、点不在曲线上或为无穷远点等情形都返回错误; +// 校验放在验签之前,可避免底层实现对畸形公钥 panic。 +func validatePublicKey(pub *ecdsa.PublicKey) (func() hash.Hash, error) { + if pub == nil { + return nil, errors.New("ECDSA 公钥为 nil") + } + newHash, err := hashForCurve(pub.Curve) + if err != nil { + return nil, err + } + if pub.X == nil || pub.Y == nil { + return nil, errors.New("非法的 ECDSA 公钥: 缺少坐标 X/Y") + } + if _, err := pub.ECDH(); err != nil { + return nil, fmt.Errorf("非法的 ECDSA 公钥: %w", err) + } + return newHash, nil +} + +// GenerateKey 生成一条指定曲线的 ECDSA 私钥。 +// +// curve 只能是 elliptic.P256()、elliptic.P384() 或 elliptic.P521();传入 nil、 +// P-224 或自定义曲线返回错误(P-224 的安全强度已不足,且标准库在部分模式下 +// 已不再支持)。私钥包含公钥,请按私钥级别保护返回值。 +func GenerateKey(curve elliptic.Curve) (*ecdsa.PrivateKey, error) { + if _, err := hashForCurve(curve); err != nil { + return nil, err + } + priv, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + return nil, fmt.Errorf("生成 ECDSA 私钥失败: %w", err) + } + return priv, nil +} + +// GenerateKeyPEM 生成一条指定曲线的 ECDSA 密钥对并以 PEM 文本返回。 +// +// privatePEM 为 PKCS#8 编码("PRIVATE KEY"),publicPEM 为 PKIX 编码 +// ("PUBLIC KEY")。privatePEM 是明文私钥,落盘时务必使用严格权限。 +func GenerateKeyPEM(curve elliptic.Curve) (privatePEM, publicPEM []byte, err error) { + priv, err := GenerateKey(curve) + if err != nil { + return nil, nil, err + } + privatePEM, err = MarshalPrivateKeyPEM(priv) + if err != nil { + return nil, nil, err + } + publicPEM, err = MarshalPublicKeyPEM(&priv.PublicKey) + if err != nil { + return nil, nil, err + } + return privatePEM, publicPEM, nil +} + +// MarshalPrivateKeyPEM 把 ECDSA 私钥编码为 PKCS#8 的 PEM 文本。 +// +// priv 为 nil、曲线不受支持或密钥字段不完整时返回错误,不会 panic。 +// 返回值是未加密的明文私钥,请按私钥要求保护。 +func MarshalPrivateKeyPEM(priv *ecdsa.PrivateKey) ([]byte, error) { + if _, err := validatePrivateKey(priv); err != nil { + return nil, err + } + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, fmt.Errorf("PKCS#8 编码 ECDSA 私钥失败: %w", err) + } + return pem.EncodeToMemory(&pem.Block{Type: pemTypePrivateKeyPKCS8, Bytes: der}), nil +} + +// MarshalPublicKeyPEM 把 ECDSA 公钥编码为 PKIX 的 PEM 文本。 +// +// pub 为 nil、曲线不受支持或坐标非法时返回错误,不会 panic。 +func MarshalPublicKeyPEM(pub *ecdsa.PublicKey) ([]byte, error) { + if _, err := validatePublicKey(pub); err != nil { + return nil, err + } + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return nil, fmt.Errorf("PKIX 编码 ECDSA 公钥失败: %w", err) + } + return pem.EncodeToMemory(&pem.Block{Type: pemTypePublicKey, Bytes: der}), nil +} + +// ParsePrivateKeyPEM 解析 ECDSA 私钥 PEM。 +// +// 接受 PKCS#8("PRIVATE KEY")与 SEC1("EC PRIVATE KEY",OpenSSL 传统格式) +// 两种块类型;块缺失、DER 非法、内容是别的算法,或曲线为 P-224/自定义曲线时 +// 均返回错误。错误文本只包含类型、块类型与曲线名,不含任何私钥材料。 +func ParsePrivateKeyPEM(data []byte) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("解析 ECDSA 私钥 PEM 失败: 数据不是合法的 PEM") + } + + var ( + priv *ecdsa.PrivateKey + err error + ) + switch block.Type { + case pemTypePrivateKeyPKCS8: + var key any + key, err = x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("解析 PKCS#8 私钥失败: %w", err) + } + parsed, ok := key.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("PEM 中的私钥类型为 %T, 不是 ECDSA 私钥", key) + } + priv = parsed + case pemTypePrivateKeySEC1: + priv, err = x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("解析 SEC1 EC 私钥失败: %w", err) + } + default: + return nil, fmt.Errorf("不支持解析 ECDSA 私钥的 PEM 块类型 %q: 期望 %q 或 %q", block.Type, pemTypePrivateKeyPKCS8, pemTypePrivateKeySEC1) + } + + if _, err := validatePrivateKey(priv); err != nil { + return nil, err + } + return priv, nil +} + +// ParsePublicKeyPEM 解析 PKIX 的 ECDSA 公钥 PEM。 +// +// 只接受块类型 "PUBLIC KEY"(以及 OpenSSL 早期输出的 "EC PUBLIC KEY",其 +// DER 仍是 PKIX),并强制校验解析结果的具体类型与曲线:传入 RSA/Ed25519 公钥、 +// 证书或截断数据都会返回错误,绝不会因非受检类型断言而 panic。 +func ParsePublicKeyPEM(data []byte) (*ecdsa.PublicKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("解析 ECDSA 公钥 PEM 失败: 数据不是合法的 PEM") + } + if block.Type != pemTypePublicKey && block.Type != pemTypePublicKeySEC1 { + return nil, fmt.Errorf("不支持解析 ECDSA 公钥的 PEM 块类型 %q: 期望 %q", block.Type, pemTypePublicKey) + } + + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("解析 PKIX 公钥失败: %w", err) + } + pub, ok := key.(*ecdsa.PublicKey) + if !ok { + return nil, fmt.Errorf("PEM 中的公钥类型为 %T, 不是 ECDSA 公钥", key) + } + if _, err := validatePublicKey(pub); err != nil { + return nil, err + } + return pub, nil +} + +// LoadPrivateKey 从文件读取并解析 ECDSA 私钥。 +// +// 文件不存在、不可读或内容非法都返回错误。请确保该路径的访问权限受控, +// 私钥内容不会出现在任何返回值或日志里。 +func LoadPrivateKey(path string) (*ecdsa.PrivateKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("读取 ECDSA 私钥文件 %q 失败: %w", path, err) + } + return ParsePrivateKeyPEM(data) +} + +// LoadPublicKey 从文件读取并解析 PKIX 的 ECDSA 公钥。 +func LoadPublicKey(path string) (*ecdsa.PublicKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("读取 ECDSA 公钥文件 %q 失败: %w", path, err) + } + return ParsePublicKeyPEM(data) +} + +// SaveKeyPair 生成一条指定曲线的新 ECDSA 密钥并写入两个文件。 +// +// privatePath 以 0600 权限写入(PKCS#8 PEM),publicPath 以 0644 权限写入 +// (PKIX PEM);已存在的同名文件会被覆盖。若公钥写入失败,会尽力删除刚写入的 +// 私钥文件以免留下不一致的残片。注意:在 Windows 上 os.WriteFile 的权限位只 +// 影响只读属性,私钥文件的访问控制需依赖目录权限另行加固。 +func SaveKeyPair(privatePath, publicPath string, curve elliptic.Curve) error { + privatePEM, publicPEM, err := GenerateKeyPEM(curve) + if err != nil { + return err + } + + if err := os.WriteFile(privatePath, privatePEM, privateFileMode); err != nil { + return fmt.Errorf("写入 ECDSA 私钥文件 %q 失败: %w", privatePath, err) + } + if err := os.WriteFile(publicPath, publicPEM, publicFileMode); err != nil { + if removeErr := os.Remove(privatePath); removeErr != nil { + return fmt.Errorf("写入 ECDSA 公钥文件 %q 失败: %w(回滚私钥文件 %q 也失败: %v)", publicPath, err, privatePath, removeErr) + } + return fmt.Errorf("写入 ECDSA 公钥文件 %q 失败: %w(已回滚私钥文件)", publicPath, err) + } + return nil +} + +// Sign 使用 ECDSA 私钥对 message 签名,返回 ASN.1 DER 编码的签名。 +// +// 摘要算法由曲线自动决定(P-256→SHA-256,P-384→SHA-384,P-521→SHA-512), +// 不需要也不允许调用方预哈希。每次签名都会使用系统安全随机源生成新的 nonce, +// 因此同一消息的两次签名不同,这是正常现象。 +// +// priv 为 nil、曲线不受支持或密钥字段非法时返回错误,不会 panic。 +// DER 签名长度随曲线和随机值浮动(P-256 通常约 70~72 字节), +// 存储时应留足空间,不要按固定长度截断。 +func Sign(priv *ecdsa.PrivateKey, message []byte) ([]byte, error) { + newHash, err := validatePrivateKey(priv) + if err != nil { + return nil, err + } + h := newHash() + if _, err := h.Write(message); err != nil { + return nil, fmt.Errorf("计算 ECDSA 摘要失败: %w", err) + } + signature, err := ecdsa.SignASN1(rand.Reader, priv, h.Sum(nil)) + if err != nil { + return nil, fmt.Errorf("ECDSA 签名失败: %w", err) + } + return signature, nil +} + +// Verify 校验 signature 是否为 pub 对 message 的有效 ECDSA 签名。 +// +// signature 必须是 Sign 产生的 ASN.1 DER 编码签名;校验通过返回 nil,否则返回 +// 错误。签名为空、DER 非法或被篡改,以及消息不匹配、公钥不匹配时都返回错误; +// pub 为 nil 或曲线不受支持时同样返回错误,不会 panic。 +// +// 标准库的签名校验为常量时间语义,但错误信息不区分失败原因,以免向攻击者 +// 泄露可用于伪造的信息;验签失败只应表现为"拒绝"。 +func Verify(pub *ecdsa.PublicKey, message, signature []byte) error { + newHash, err := validatePublicKey(pub) + if err != nil { + return err + } + if len(signature) == 0 { + return errors.New("ECDSA 签名为空") + } + h := newHash() + if _, err := h.Write(message); err != nil { + return fmt.Errorf("计算 ECDSA 摘要失败: %w", err) + } + if !ecdsa.VerifyASN1(pub, h.Sum(nil), signature) { + return errors.New("ECDSA 验签失败: 签名与消息或公钥不匹配") + } + return nil +} + +// SignToBase64 对 message 签名并返回标准 Base64 编码的 DER 签名文本, +// 便于放进 JSON、HTTP 头或数据库字段。私钥非法时返回错误。 +func SignToBase64(priv *ecdsa.PrivateKey, message []byte) (string, error) { + signature, err := Sign(priv, message) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(signature), nil +} + +// VerifyFromBase64 校验标准 Base64 编码的 DER 签名。 +// +// signatureB64 非法 Base64,或解码后验签不通过,或公钥非法时均返回错误, +// 不会 panic。 +func VerifyFromBase64(pub *ecdsa.PublicKey, message []byte, signatureB64 string) error { + signature, err := base64.StdEncoding.DecodeString(signatureB64) + if err != nil { + return fmt.Errorf("Base64 解码 ECDSA 签名失败: %w", err) + } + return Verify(pub, message, signature) +} diff --git a/ecdsax/ecdsax_test.go b/ecdsax/ecdsax_test.go new file mode 100644 index 0000000..497c313 --- /dev/null +++ b/ecdsax/ecdsax_test.go @@ -0,0 +1,777 @@ +package ecdsax_test + +import ( + "bytes" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "math/big" + mrand "math/rand" + "os" + "path/filepath" + "runtime" + "testing" + + "code.yun.ink/pkg/encryptx/ecdsax" +) + +// curveCase 描述一条受支持的曲线,便于表驱动覆盖 P-256/P-384/P-521。 +type curveCase struct { + name string + curve elliptic.Curve +} + +func supportedCurves() []curveCase { + return []curveCase{ + {"P-256", elliptic.P256()}, + {"P-384", elliptic.P384()}, + {"P-521", elliptic.P521()}, + } +} + +// requireError 断言操作失败返回了 error(而非 panic 或静默成功)。 +func requireError(t *testing.T, name string, err error) { + t.Helper() + if err == nil { + t.Errorf("%s: 期望返回错误, 实际返回 nil", name) + } +} + +// mustGenerateKey 生成指定曲线的密钥,供其余用例复用。 +func mustGenerateKey(t *testing.T, curve elliptic.Curve) *ecdsa.PrivateKey { + t.Helper() + priv, err := ecdsax.GenerateKey(curve) + if err != nil { + t.Fatalf("GenerateKey 返回错误: %v", err) + } + if priv == nil { + t.Fatal("GenerateKey 返回 nil 私钥且无错误") + } + if priv.Curve != curve { + t.Fatalf("生成的私钥曲线 = %v, 期望 %v", priv.Curve, curve) + } + if priv.D == nil || priv.X == nil || priv.Y == nil { + t.Fatal("生成的私钥缺少 D 或 X/Y 字段") + } + return priv +} + +// TestGenerateKeyRejectsUnsupportedCurves 覆盖:nil 与 P-224 必须被拒绝。 +func TestGenerateKeyRejectsUnsupportedCurves(t *testing.T) { + cases := []struct { + name string + curve elliptic.Curve + }{ + {"nil 曲线", nil}, + {"P-224 曲线", elliptic.P224()}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + priv, err := ecdsax.GenerateKey(tc.curve) + requireError(t, "GenerateKey("+tc.name+")", err) + if priv != nil { + t.Error("GenerateKey 失败时应返回 nil 私钥") + } + + privatePEM, publicPEM, err := ecdsax.GenerateKeyPEM(tc.curve) + requireError(t, "GenerateKeyPEM("+tc.name+")", err) + if privatePEM != nil || publicPEM != nil { + t.Error("GenerateKeyPEM 失败时应返回 nil PEM") + } + + dir := t.TempDir() + requireError(t, "SaveKeyPair("+tc.name+")", ecdsax.SaveKeyPair( + filepath.Join(dir, "priv.pem"), filepath.Join(dir, "pub.pem"), tc.curve)) + }) + } +} + +// TestSignVerifyRoundTrip 覆盖:P-256/P-384/P-521 三条曲线均能完成签名/验签往返。 +func TestSignVerifyRoundTrip(t *testing.T) { + for _, tc := range supportedCurves() { + t.Run(tc.name, func(t *testing.T) { + priv := mustGenerateKey(t, tc.curve) + pub := &priv.PublicKey + message := []byte("encryptx ecdsax 往返测试消息 / " + tc.name) + + signature, err := ecdsax.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + if len(signature) == 0 { + t.Fatal("Sign 返回空签名") + } + if err := ecdsax.Verify(pub, message, signature); err != nil { + t.Fatalf("Verify 应通过, 实际返回错误: %v", err) + } + + // ECDSA 每次签名使用新的随机 nonce,两次结果都应能通过校验。 + second, err := ecdsax.Sign(priv, message) + if err != nil { + t.Fatalf("第二次 Sign 返回错误: %v", err) + } + if err := ecdsax.Verify(pub, message, second); err != nil { + t.Fatalf("第二次签名的 Verify 应通过, 实际返回错误: %v", err) + } + if bytes.Equal(signature, second) { + t.Log("提示: 两次 ECDSA 签名完全相同, 概率极低, 请确认随机源正常") + } + + // 空消息与较长消息同样应能往返。 + for _, msg := range [][]byte{nil, {}, bytes.Repeat([]byte("B"), 1<<16)} { + sig, err := ecdsax.Sign(priv, msg) + if err != nil { + t.Fatalf("Sign(len=%d) 返回错误: %v", len(msg), err) + } + if err := ecdsax.Verify(pub, msg, sig); err != nil { + t.Fatalf("Verify(len=%d) 应通过, 实际返回错误: %v", len(msg), err) + } + } + }) + } +} + +// TestPEMRoundTrip 覆盖:GenerateKeyPEM -> 解析 -> 用公钥验签成功,且曲线保持不变。 +func TestPEMRoundTrip(t *testing.T) { + for _, tc := range supportedCurves() { + t.Run(tc.name, func(t *testing.T) { + privatePEM, publicPEM, err := ecdsax.GenerateKeyPEM(tc.curve) + if err != nil { + t.Fatalf("GenerateKeyPEM 返回错误: %v", err) + } + + privateBlock, _ := pem.Decode(privatePEM) + if privateBlock == nil { + t.Fatal("私钥 PEM 无法解码") + } + if privateBlock.Type != "PRIVATE KEY" { + t.Errorf("私钥 PEM 块类型 = %q, 期望 %q", privateBlock.Type, "PRIVATE KEY") + } + publicBlock, _ := pem.Decode(publicPEM) + if publicBlock == nil { + t.Fatal("公钥 PEM 无法解码") + } + if publicBlock.Type != "PUBLIC KEY" { + t.Errorf("公钥 PEM 块类型 = %q, 期望 %q", publicBlock.Type, "PUBLIC KEY") + } + + priv, err := ecdsax.ParsePrivateKeyPEM(privatePEM) + if err != nil { + t.Fatalf("ParsePrivateKeyPEM 返回错误: %v", err) + } + pub, err := ecdsax.ParsePublicKeyPEM(publicPEM) + if err != nil { + t.Fatalf("ParsePublicKeyPEM 返回错误: %v", err) + } + if priv.Curve != tc.curve { + t.Errorf("解析出的私钥曲线 = %v, 期望 %v", priv.Curve, tc.curve) + } + if pub.Curve != tc.curve { + t.Errorf("解析出的公钥曲线 = %v, 期望 %v", pub.Curve, tc.curve) + } + if !priv.PublicKey.Equal(pub) { + t.Error("私钥内嵌公钥与解析出的公钥不一致") + } + + message := []byte("PEM 往返后验签 / " + tc.name) + signature, err := ecdsax.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + if err := ecdsax.Verify(pub, message, signature); err != nil { + t.Fatalf("Verify 应通过, 实际返回错误: %v", err) + } + }) + } +} + +// TestMarshalParsePEMRoundTrip 覆盖:Marshal -> Parse 后私钥标量与公钥坐标完全一致。 +func TestMarshalParsePEMRoundTrip(t *testing.T) { + for _, tc := range supportedCurves() { + t.Run(tc.name, func(t *testing.T) { + priv := mustGenerateKey(t, tc.curve) + + privatePEM, err := ecdsax.MarshalPrivateKeyPEM(priv) + if err != nil { + t.Fatalf("MarshalPrivateKeyPEM 返回错误: %v", err) + } + parsedPriv, err := ecdsax.ParsePrivateKeyPEM(privatePEM) + if err != nil { + t.Fatalf("ParsePrivateKeyPEM 返回错误: %v", err) + } + if parsedPriv.D.Cmp(priv.D) != 0 { + t.Error("私钥 PEM 往返后 D 不一致") + } + if !parsedPriv.PublicKey.Equal(&priv.PublicKey) { + t.Error("私钥 PEM 往返后公钥不一致") + } + + publicPEM, err := ecdsax.MarshalPublicKeyPEM(&priv.PublicKey) + if err != nil { + t.Fatalf("MarshalPublicKeyPEM 返回错误: %v", err) + } + parsedPub, err := ecdsax.ParsePublicKeyPEM(publicPEM) + if err != nil { + t.Fatalf("ParsePublicKeyPEM 返回错误: %v", err) + } + if !parsedPub.Equal(&priv.PublicKey) { + t.Error("公钥 PEM 往返后不一致") + } + }) + } +} + +// TestBase64RoundTrip 覆盖:Base64 便利函数的正常往返。 +func TestBase64RoundTrip(t *testing.T) { + for _, tc := range supportedCurves() { + t.Run(tc.name, func(t *testing.T) { + priv := mustGenerateKey(t, tc.curve) + message := []byte("Base64 签名 / " + tc.name) + + encoded, err := ecdsax.SignToBase64(priv, message) + if err != nil { + t.Fatalf("SignToBase64 返回错误: %v", err) + } + if err := ecdsax.VerifyFromBase64(&priv.PublicKey, message, encoded); err != nil { + t.Fatalf("VerifyFromBase64 应通过, 实际返回错误: %v", err) + } + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("Base64 解码失败: %v", err) + } + if len(raw) == 0 { + t.Fatal("Base64 解码后签名为空") + } + }) + } +} + +// TestTamperedMessageFails 覆盖:篡改消息后验签必须失败。 +func TestTamperedMessageFails(t *testing.T) { + for _, tc := range supportedCurves() { + t.Run(tc.name, func(t *testing.T) { + priv := mustGenerateKey(t, tc.curve) + pub := &priv.PublicKey + message := []byte("原始消息 payload") + signature, err := ecdsax.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + + tampered := [][]byte{ + []byte("原始消息 payloa"), // 少一个字节 + []byte("原始消息 payload "), // 多一个空格 + []byte("篡改消息 payload"), // 同长度改写 + bytes.Repeat([]byte{0x00}, len(message)), + } + for _, msg := range tampered { + if bytes.Equal(msg, message) { + t.Fatal("用例构造错误: 篡改消息与原始消息相同") + } + requireError(t, "篡改消息后 Verify", ecdsax.Verify(pub, msg, signature)) + requireError(t, "篡改消息后 VerifyFromBase64", + ecdsax.VerifyFromBase64(pub, msg, base64.StdEncoding.EncodeToString(signature))) + } + + // 空消息的签名不能通过非空消息的校验。 + emptySignature, err := ecdsax.Sign(priv, nil) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + requireError(t, "空消息签名用于非空消息", ecdsax.Verify(pub, message, emptySignature)) + }) + } +} + +// TestTamperedSignatureFails 覆盖:翻转签名字节、截断、全零签名后验签必须失败。 +func TestTamperedSignatureFails(t *testing.T) { + for _, tc := range supportedCurves() { + t.Run(tc.name, func(t *testing.T) { + priv := mustGenerateKey(t, tc.curve) + pub := &priv.PublicKey + message := []byte("签名篡改测试") + signature, err := ecdsax.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + + for _, pos := range []int{0, 1, len(signature) / 2, len(signature) - 1} { + flipped := append([]byte{}, signature...) + flipped[pos] ^= 0x01 + if bytes.Equal(flipped, signature) { + t.Fatalf("位置 %d 翻转后签名未变化", pos) + } + requireError(t, "翻转签名字节后 Verify", ecdsax.Verify(pub, message, flipped)) + } + + requireError(t, "截断签名", ecdsax.Verify(pub, message, signature[:len(signature)-1])) + requireError(t, "全零签名", ecdsax.Verify(pub, message, make([]byte, len(signature)))) + requireError(t, "全 0xFF 签名", ecdsax.Verify(pub, message, bytes.Repeat([]byte{0xFF}, len(signature)))) + + // 合法签名的 Base64 文本被破坏后必须失败。 + encoded := []byte(base64.StdEncoding.EncodeToString(signature)) + if encoded[0] == 'A' { + encoded[0] = 'B' + } else { + encoded[0] = 'A' + } + requireError(t, "Base64 破坏后 VerifyFromBase64", + ecdsax.VerifyFromBase64(pub, message, string(encoded))) + }) + } +} + +// TestWrongPublicKeyFails 覆盖:用另一对密钥的公钥验签必须失败。 +func TestWrongPublicKeyFails(t *testing.T) { + for _, tc := range supportedCurves() { + t.Run(tc.name, func(t *testing.T) { + privA := mustGenerateKey(t, tc.curve) + privB := mustGenerateKey(t, tc.curve) + if privA.PublicKey.Equal(&privB.PublicKey) { + t.Fatal("两次生成的密钥不应相同") + } + + message := []byte("错误公钥测试 / " + tc.name) + signature, err := ecdsax.Sign(privA, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + if err := ecdsax.Verify(&privA.PublicKey, message, signature); err != nil { + t.Fatalf("使用正确公钥应当通过, 实际返回错误: %v", err) + } + requireError(t, "使用错误公钥 Verify", ecdsax.Verify(&privB.PublicKey, message, signature)) + requireError(t, "使用错误公钥 VerifyFromBase64", + ecdsax.VerifyFromBase64(&privB.PublicKey, message, base64.StdEncoding.EncodeToString(signature))) + }) + } +} + +// TestCrossCurveVerifyFails 覆盖:跨曲线的公钥不能验证另一条曲线产生的签名。 +func TestCrossCurveVerifyFails(t *testing.T) { + signer := mustGenerateKey(t, elliptic.P256()) + message := []byte("跨曲线验签测试") + signature, err := ecdsax.Sign(signer, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + + for _, tc := range supportedCurves() { + if tc.curve == elliptic.P256() { + continue + } + t.Run(tc.name, func(t *testing.T) { + other := mustGenerateKey(t, tc.curve) + requireError(t, "跨曲线 Verify", ecdsax.Verify(&other.PublicKey, message, signature)) + }) + } +} + +// TestSEC1PrivateKeyPEM 覆盖:兼容 OpenSSL 的 SEC1("EC PRIVATE KEY")私钥解析。 +func TestSEC1PrivateKeyPEM(t *testing.T) { + priv := mustGenerateKey(t, elliptic.P256()) + + der, err := x509.MarshalECPrivateKey(priv) + if err != nil { + t.Fatalf("MarshalECPrivateKey 返回错误: %v", err) + } + sec1PEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) + + parsed, err := ecdsax.ParsePrivateKeyPEM(sec1PEM) + if err != nil { + t.Fatalf("ParsePrivateKeyPEM(SEC1) 返回错误: %v", err) + } + if parsed.D.Cmp(priv.D) != 0 { + t.Error("SEC1 私钥往返后 D 不一致") + } + + message := []byte("SEC1 私钥签名") + signature, err := ecdsax.Sign(parsed, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + if err := ecdsax.Verify(&priv.PublicKey, message, signature); err != nil { + t.Fatalf("Verify 应通过, 实际返回错误: %v", err) + } +} + +// TestInvalidPEMInputs 覆盖:空 PEM、垃圾字节、其他算法、P-224 与错误块类型都必须返回 error。 +func TestInvalidPEMInputs(t *testing.T) { + priv := mustGenerateKey(t, elliptic.P256()) + validPrivatePEM, err := ecdsax.MarshalPrivateKeyPEM(priv) + if err != nil { + t.Fatalf("MarshalPrivateKeyPEM 返回错误: %v", err) + } + validPublicPEM, err := ecdsax.MarshalPublicKeyPEM(&priv.PublicKey) + if err != nil { + t.Fatalf("MarshalPublicKeyPEM 返回错误: %v", err) + } + privateBlock, _ := pem.Decode(validPrivatePEM) + publicBlock, _ := pem.Decode(validPublicPEM) + if privateBlock == nil || publicBlock == nil { + t.Fatal("自产 PEM 无法解码, 测试前提不成立") + } + + // 其他算法:Ed25519 与 RSA 的 PKCS#8 私钥、PKIX 公钥。 + edPub, edPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("生成 Ed25519 测试密钥失败: %v", err) + } + edPrivateDER, err := x509.MarshalPKCS8PrivateKey(edPriv) + if err != nil { + t.Fatalf("编码 Ed25519 私钥失败: %v", err) + } + edPublicDER, err := x509.MarshalPKIXPublicKey(edPub) + if err != nil { + t.Fatalf("编码 Ed25519 公钥失败: %v", err) + } + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("生成 RSA 测试密钥失败: %v", err) + } + rsaPrivateDER, err := x509.MarshalPKCS8PrivateKey(rsaKey) + if err != nil { + t.Fatalf("编码 RSA 私钥失败: %v", err) + } + rsaPublicDER, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey) + if err != nil { + t.Fatalf("编码 RSA 公钥失败: %v", err) + } + + // P-224 密钥:标准库仍能解析,但本包必须因曲线不受支持而拒绝。 + p224Priv, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + if err != nil { + t.Fatalf("生成 P-224 测试密钥失败: %v", err) + } + p224PrivateDER, err := x509.MarshalPKCS8PrivateKey(p224Priv) + if err != nil { + t.Fatalf("编码 P-224 私钥失败: %v", err) + } + p224PublicDER, err := x509.MarshalPKIXPublicKey(&p224Priv.PublicKey) + if err != nil { + t.Fatalf("编码 P-224 公钥失败: %v", err) + } + + cases := []struct { + name string + fn func() error + }{ + {"空 PEM 私钥(nil)", func() error { _, err := ecdsax.ParsePrivateKeyPEM(nil); return err }}, + {"空 PEM 私钥(空切片)", func() error { _, err := ecdsax.ParsePrivateKeyPEM([]byte{}); return err }}, + {"空 PEM 私钥(仅空白)", func() error { _, err := ecdsax.ParsePrivateKeyPEM([]byte(" \n\t")); return err }}, + {"垃圾字节私钥", func() error { _, err := ecdsax.ParsePrivateKeyPEM([]byte("这不是 PEM 数据")); return err }}, + {"随机字节私钥", func() error { + _, err := ecdsax.ParsePrivateKeyPEM(bytes.Repeat([]byte{0xCD}, 256)) + return err + }}, + {"无 PEM 头尾的裸 DER 私钥", func() error { _, err := ecdsax.ParsePrivateKeyPEM(privateBlock.Bytes); return err }}, + {"块类型为 CERTIFICATE 的私钥", func() error { + _, err := ecdsax.ParsePrivateKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: privateBlock.Bytes})) + return err + }}, + {"块内容为垃圾的 PKCS#8 私钥", func() error { + _, err := ecdsax.ParsePrivateKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte("garbage")})) + return err + }}, + {"块内容为垃圾的 SEC1 私钥", func() error { + _, err := ecdsax.ParsePrivateKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: []byte("garbage")})) + return err + }}, + {"Ed25519 私钥 PEM", func() error { + _, err := ecdsax.ParsePrivateKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: edPrivateDER})) + return err + }}, + {"RSA 私钥 PEM", func() error { + _, err := ecdsax.ParsePrivateKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: rsaPrivateDER})) + return err + }}, + {"P-224 私钥 PEM", func() error { + _, err := ecdsax.ParsePrivateKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: p224PrivateDER})) + return err + }}, + {"公钥 PEM 传给 ParsePrivateKeyPEM", func() error { _, err := ecdsax.ParsePrivateKeyPEM(validPublicPEM); return err }}, + {"空 PEM 公钥(nil)", func() error { _, err := ecdsax.ParsePublicKeyPEM(nil); return err }}, + {"垃圾字节公钥", func() error { + _, err := ecdsax.ParsePublicKeyPEM([]byte("-----BEGIN PUBLIC KEY-----\n不是Base64\n-----END PUBLIC KEY-----")) + return err + }}, + {"块类型为 PRIVATE KEY 的公钥", func() error { + _, err := ecdsax.ParsePublicKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: publicBlock.Bytes})) + return err + }}, + {"块内容为垃圾的 PKIX 公钥", func() error { + _, err := ecdsax.ParsePublicKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("garbage")})) + return err + }}, + {"Ed25519 公钥 PEM", func() error { + _, err := ecdsax.ParsePublicKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: edPublicDER})) + return err + }}, + {"RSA 公钥 PEM", func() error { + _, err := ecdsax.ParsePublicKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: rsaPublicDER})) + return err + }}, + {"P-224 公钥 PEM", func() error { + _, err := ecdsax.ParsePublicKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: p224PublicDER})) + return err + }}, + {"私钥 PEM 传给 ParsePublicKeyPEM", func() error { _, err := ecdsax.ParsePublicKeyPEM(validPrivatePEM); return err }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + requireError(t, tc.name, tc.fn()) + }) + } +} + +// TestInvalidKeyAndSignatureInputs 覆盖:nil/残缺密钥、空或畸形签名、非法 Base64 都必须返回 error 且不 panic。 +func TestInvalidKeyAndSignatureInputs(t *testing.T) { + priv := mustGenerateKey(t, elliptic.P256()) + message := []byte("非法输入测试") + validSignature, err := ecdsax.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + + // 手工构造的残缺密钥:用于证明入口处的校验能挡住会让标准库 panic 的输入。 + nilCurveKey := &ecdsa.PrivateKey{} + nilScalarKey := &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: elliptic.P256()}} + missingPointKey := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{Curve: elliptic.P256()}, + D: big.NewInt(1), + } + offCurveKey := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{Curve: elliptic.P256(), X: big.NewInt(1), Y: big.NewInt(1)}, + D: big.NewInt(1), + } + + nilCurvePub := &ecdsa.PublicKey{} + missingPointPub := &ecdsa.PublicKey{Curve: elliptic.P256()} + offCurvePub := &ecdsa.PublicKey{Curve: elliptic.P256(), X: big.NewInt(1), Y: big.NewInt(1)} + infinityPub := &ecdsa.PublicKey{Curve: elliptic.P256(), X: big.NewInt(0), Y: big.NewInt(0)} + + cases := []struct { + name string + fn func() error + }{ + {"Sign 私钥为 nil", func() error { _, err := ecdsax.Sign(nil, message); return err }}, + {"Sign 私钥曲线为 nil", func() error { _, err := ecdsax.Sign(nilCurveKey, message); return err }}, + {"Sign 私钥缺少标量 D", func() error { _, err := ecdsax.Sign(nilScalarKey, message); return err }}, + {"Sign 私钥缺少坐标", func() error { _, err := ecdsax.Sign(missingPointKey, message); return err }}, + {"Sign 私钥公钥点不在曲线上", func() error { _, err := ecdsax.Sign(offCurveKey, message); return err }}, + {"SignToBase64 私钥为 nil", func() error { _, err := ecdsax.SignToBase64(nil, message); return err }}, + {"MarshalPrivateKeyPEM 私钥为 nil", func() error { _, err := ecdsax.MarshalPrivateKeyPEM(nil); return err }}, + {"MarshalPrivateKeyPEM 私钥缺少标量 D", func() error { _, err := ecdsax.MarshalPrivateKeyPEM(nilScalarKey); return err }}, + {"Verify 公钥为 nil", func() error { return ecdsax.Verify(nil, message, validSignature) }}, + {"Verify 公钥曲线为 nil", func() error { return ecdsax.Verify(nilCurvePub, message, validSignature) }}, + {"Verify 公钥缺少坐标", func() error { return ecdsax.Verify(missingPointPub, message, validSignature) }}, + {"Verify 公钥点不在曲线上", func() error { return ecdsax.Verify(offCurvePub, message, validSignature) }}, + {"Verify 公钥为无穷远点", func() error { return ecdsax.Verify(infinityPub, message, validSignature) }}, + {"MarshalPublicKeyPEM 公钥为 nil", func() error { _, err := ecdsax.MarshalPublicKeyPEM(nil); return err }}, + {"MarshalPublicKeyPEM 公钥缺少坐标", func() error { _, err := ecdsax.MarshalPublicKeyPEM(missingPointPub); return err }}, + {"Verify 签名为 nil", func() error { return ecdsax.Verify(&priv.PublicKey, message, nil) }}, + {"Verify 签名为空切片", func() error { return ecdsax.Verify(&priv.PublicKey, message, []byte{}) }}, + {"Verify 签名被截断", func() error { + return ecdsax.Verify(&priv.PublicKey, message, validSignature[:len(validSignature)-1]) + }}, + {"Verify 签名非 DER", func() error { return ecdsax.Verify(&priv.PublicKey, message, []byte("not-a-der-signature")) }}, + {"VerifyFromBase64 非法 Base64", func() error { + return ecdsax.VerifyFromBase64(&priv.PublicKey, message, "!!!not-base64!!!") + }}, + {"VerifyFromBase64 空字符串", func() error { return ecdsax.VerifyFromBase64(&priv.PublicKey, message, "") }}, + {"VerifyFromBase64 公钥为 nil", func() error { + return ecdsax.VerifyFromBase64(nil, message, base64.StdEncoding.EncodeToString(validSignature)) + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + requireError(t, tc.name, tc.fn()) + }) + } +} + +// TestSaveLoadKeyPair 覆盖:三条曲线的 SaveKeyPair -> LoadPrivateKey/LoadPublicKey 往返。 +func TestSaveLoadKeyPair(t *testing.T) { + for _, tc := range supportedCurves() { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + privatePath := filepath.Join(dir, "ec_private.pem") + publicPath := filepath.Join(dir, "ec_public.pem") + + if err := ecdsax.SaveKeyPair(privatePath, publicPath, tc.curve); err != nil { + t.Fatalf("SaveKeyPair 返回错误: %v", err) + } + + priv, err := ecdsax.LoadPrivateKey(privatePath) + if err != nil { + t.Fatalf("LoadPrivateKey 返回错误: %v", err) + } + pub, err := ecdsax.LoadPublicKey(publicPath) + if err != nil { + t.Fatalf("LoadPublicKey 返回错误: %v", err) + } + if priv.Curve != tc.curve { + t.Errorf("加载的私钥曲线 = %v, 期望 %v", priv.Curve, tc.curve) + } + if !priv.PublicKey.Equal(pub) { + t.Error("加载的私钥与公钥不匹配") + } + + message := []byte("落盘往返测试 / " + tc.name) + signature, err := ecdsax.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + if err := ecdsax.Verify(pub, message, signature); err != nil { + t.Fatalf("Verify 应通过, 实际返回错误: %v", err) + } + + // 权限位只在类 Unix 系统上有意义,Windows 只用 mode 表示只读属性。 + if runtime.GOOS != "windows" { + privateInfo, err := os.Stat(privatePath) + if err != nil { + t.Fatalf("Stat 私钥文件失败: %v", err) + } + if perm := privateInfo.Mode().Perm(); perm != 0o600 { + t.Errorf("私钥文件权限 = %o, 期望 600", perm) + } + publicInfo, err := os.Stat(publicPath) + if err != nil { + t.Fatalf("Stat 公钥文件失败: %v", err) + } + if perm := publicInfo.Mode().Perm(); perm != 0o644 { + t.Errorf("公钥文件权限 = %o, 期望 644", perm) + } + } + }) + } +} + +// TestLoadKeyErrors 覆盖:路径不存在、路径是目录、文件内容非法时都必须返回 error。 +func TestLoadKeyErrors(t *testing.T) { + dir := t.TempDir() + + requireError(t, "LoadPrivateKey 路径不存在", func() error { + _, err := ecdsax.LoadPrivateKey(filepath.Join(dir, "missing.pem")) + return err + }()) + requireError(t, "LoadPublicKey 路径不存在", func() error { + _, err := ecdsax.LoadPublicKey(filepath.Join(dir, "missing.pem")) + return err + }()) + requireError(t, "LoadPrivateKey 路径为目录", func() error { + _, err := ecdsax.LoadPrivateKey(dir) + return err + }()) + + junkPath := filepath.Join(dir, "junk.pem") + if err := os.WriteFile(junkPath, []byte("这不是 PEM"), 0o600); err != nil { + t.Fatalf("写入测试文件失败: %v", err) + } + requireError(t, "LoadPrivateKey 内容非法", func() error { + _, err := ecdsax.LoadPrivateKey(junkPath) + return err + }()) + requireError(t, "LoadPublicKey 内容非法", func() error { + _, err := ecdsax.LoadPublicKey(junkPath) + return err + }()) +} + +// flipRandomBits 复制一份数据并随机翻转若干位,用于构造逼近真实攻击面的畸形输入。 +func flipRandomBits(rng *mrand.Rand, data []byte) []byte { + out := append([]byte{}, data...) + for i := 0; i < 1+rng.Intn(4); i++ { + out[rng.Intn(len(out))] ^= byte(1 << rng.Intn(8)) + } + return out +} + +// randomBytes 生成指定长度的随机字节串。 +func randomBytes(rng *mrand.Rand, size int) []byte { + out := make([]byte, size) + for i := range out { + out[i] = byte(rng.Intn(256)) + } + return out +} + +// TestMalformedInputsDoNotPanic 覆盖:随机字节、随机位翻转的 PEM/签名,以及坐标随机的公钥, +// 都不会触发 panic。这是对"任何情况下不得 panic"这一硬性要求的直接验证, +// 因此只要求不 panic,不约束具体错误内容。 +func TestMalformedInputsDoNotPanic(t *testing.T) { + privatePEM, publicPEM, err := ecdsax.GenerateKeyPEM(elliptic.P256()) + if err != nil { + t.Fatalf("GenerateKeyPEM 返回错误: %v", err) + } + priv, err := ecdsax.ParsePrivateKeyPEM(privatePEM) + if err != nil { + t.Fatalf("ParsePrivateKeyPEM 返回错误: %v", err) + } + pub, err := ecdsax.ParsePublicKeyPEM(publicPEM) + if err != nil { + t.Fatalf("ParsePublicKeyPEM 返回错误: %v", err) + } + message := []byte("畸形输入测试") + signature, err := ecdsax.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + + rng := mrand.New(mrand.NewSource(20240517)) + for i := 0; i < 200; i++ { + // 完全随机的字节串。 + raw := make([]byte, rng.Intn(256)) + for j := range raw { + raw[j] = byte(rng.Intn(256)) + } + _, _ = ecdsax.ParsePrivateKeyPEM(raw) + _, _ = ecdsax.ParsePublicKeyPEM(raw) + _ = ecdsax.Verify(pub, message, raw) + _ = ecdsax.VerifyFromBase64(pub, message, string(raw)) + + // 在合法 PEM 上随机翻转比特位,更容易命中解析器的深层分支。 + _, _ = ecdsax.ParsePrivateKeyPEM(flipRandomBits(rng, privatePEM)) + _, _ = ecdsax.ParsePublicKeyPEM(flipRandomBits(rng, publicPEM)) + + // 在合法签名上随机翻转比特位。 + _ = ecdsax.Verify(pub, message, flipRandomBits(rng, signature)) + _ = ecdsax.VerifyFromBase64(pub, message, base64.StdEncoding.EncodeToString(flipRandomBits(rng, signature))) + + // 坐标与标量完全是随机数的密钥:标准库对畸形点会报错, + // 本包必须在进入标准库之前就拦住,不能 panic。 + randomPub := &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: new(big.Int).SetBytes(randomBytes(rng, 32)), + Y: new(big.Int).SetBytes(randomBytes(rng, 32)), + } + randomPriv := &ecdsa.PrivateKey{ + PublicKey: *randomPub, + D: new(big.Int).SetBytes(randomBytes(rng, 32)), + } + _ = ecdsax.Verify(randomPub, message, signature) + _, _ = ecdsax.Sign(randomPriv, message) + _, _ = ecdsax.MarshalPublicKeyPEM(randomPub) + _, _ = ecdsax.MarshalPrivateKeyPEM(randomPriv) + } +} + +// TestSaveKeyPairErrors 覆盖:写入路径非法时返回 error 而不是 panic。 +func TestSaveKeyPairErrors(t *testing.T) { + dir := t.TempDir() + requireError(t, "SaveKeyPair 私钥路径为目录", + ecdsax.SaveKeyPair(dir, filepath.Join(dir, "pub.pem"), elliptic.P256())) + + privatePath := filepath.Join(dir, "priv.pem") + requireError(t, "SaveKeyPair 公钥路径目录不存在", + ecdsax.SaveKeyPair(privatePath, filepath.Join(dir, "no-such-dir", "pub.pem"), elliptic.P256())) + if _, err := os.Stat(privatePath); err == nil { + t.Error("公钥写入失败后应删除已写入的私钥文件") + } +} diff --git a/ecdsax/staticcheck.conf b/ecdsax/staticcheck.conf new file mode 100644 index 0000000..bf80dab --- /dev/null +++ b/ecdsax/staticcheck.conf @@ -0,0 +1,14 @@ +# 本包有意读取 crypto/ecdsa 的已废弃字段:PrivateKey.D 与 PublicKey.X / PublicKey.Y。 +# +# 原因是标准库自身在这些字段缺失时会崩溃,而不是返回错误: +# +# (&ecdsa.PublicKey{}).ECDH() -> panic: invalid memory address or nil pointer dereference +# (&ecdsa.PrivateKey{}).ECDH() -> panic: invalid memory address or nil pointer dereference +# ecdsa.VerifyASN1 / SignASN1 -> 同样会解引用 X / Y / D +# +# 本包对外承诺「任何畸形密钥都返回 error,绝不 panic」,因此必须在调用标准库 +# 之前先判空。我们只**读取**这些字段,从不修改,不构成 Go 1.26 弃用说明所警告的 +# 风险(该说明针对的是修改原始值,以及用 big.Int 做大数运算)。 +# +# 测试文件同样需要这些字段来构造畸形密钥,故整包排除 SA1019。其余检查保持开启。 +checks = ["all", "-SA1019"] diff --git a/ed25519x/ed25519x.go b/ed25519x/ed25519x.go new file mode 100644 index 0000000..5d4cf48 --- /dev/null +++ b/ed25519x/ed25519x.go @@ -0,0 +1,270 @@ +// Package ed25519x 基于标准库 crypto/ed25519 提供 Ed25519 数字签名的 +// 密钥生成、PEM 编解码、签名与验签能力。 +// +// 安全说明: +// - Ed25519 是确定性签名算法,不使用随机数,因此不存在 ECDSA 中"随机 +// nonce 重复导致私钥泄露"的风险,也不需要调用方选择曲线或哈希算法。 +// 它是新系统的推荐默认签名算法。 +// - 私钥固定为 64 字节(32 字节种子 || 32 字节公钥)。私钥泄露等同于身份 +// 被完全冒充,请仅存放在可信位置;本包不会把私钥写入日志或错误信息, +// 也不提供自动清零,调用方使用完毕后应自行零覆盖相关内存。 +// - 本包只做签名/验签,不提供加密与密钥协商;Ed25519 的签名结果不能当作 +// 对称密钥或随机数使用。 +// - 签名只保证完整性与来源真实性,不提供保密性:消息本身仍可能被读取。 +package ed25519x + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "os" +) + +const ( + // PublicKeySize Ed25519 公钥的固定字节长度(32)。 + PublicKeySize = ed25519.PublicKeySize + // PrivateKeySize Ed25519 私钥的固定字节长度(64,即 32 字节种子加 32 字节公钥)。 + PrivateKeySize = ed25519.PrivateKeySize + // SignatureSize Ed25519 签名的固定字节长度(64)。 + SignatureSize = ed25519.SignatureSize +) + +// PEM 块类型标签,解析时严格校验,避免把别的算法或别的对象当成密钥使用。 +const ( + pemTypePrivateKey = "PRIVATE KEY" + pemTypePublicKey = "PUBLIC KEY" +) + +// 文件权限:私钥仅所有者可读写,公钥可被其他用户读取。 +const ( + privateFileMode os.FileMode = 0o600 + publicFileMode os.FileMode = 0o644 +) + +// GenerateKey 生成一对新的 Ed25519 密钥。 +// +// 返回值均为原始字节,长度分别恒为 PublicKeySize 与 PrivateKeySize; +// privateKey 是敏感材料,不要打印到日志、不要经网络发送,也不要以明文落盘。 +// +// 返回的 publicKey 与 privateKey[32:] 相同,调用方只需保存 privateKey +// 即可,但本包不做裁剪,保持与标准库一致的 64 字节形态。 +func GenerateKey() (publicKey, privateKey []byte, err error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("生成 Ed25519 密钥失败: %w", err) + } + return []byte(pub), []byte(priv), nil +} + +// GenerateKeyPEM 生成一对新的 Ed25519 密钥并以 PEM 文本返回。 +// +// privatePEM 为 PKCS#8 编码(块类型 "PRIVATE KEY"),publicPEM 为 PKIX +// 编码(块类型 "PUBLIC KEY"),二者可直接写入 .pem 文件。 +// +// 安全提醒:privatePEM 是明文私钥,落盘时必须使用 0600 之类的严格权限 +// (可参考 SaveKeyPair),不要把私钥 PEM 提交到版本库或打进镜像层。 +func GenerateKeyPEM() (privatePEM, publicPEM []byte, err error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("生成 Ed25519 密钥失败: %w", err) + } + privatePEM, err = MarshalPrivateKeyPEM(priv) + if err != nil { + return nil, nil, err + } + publicPEM, err = MarshalPublicKeyPEM(pub) + if err != nil { + return nil, nil, err + } + return privatePEM, publicPEM, nil +} + +// MarshalPrivateKeyPEM 把 Ed25519 私钥编码为 PKCS#8 的 PEM 文本。 +// +// 私钥长度不是 PrivateKeySize 时返回错误,不会 panic。 +// 返回值是未加密的明文私钥,请按私钥要求保护。 +func MarshalPrivateKeyPEM(priv ed25519.PrivateKey) ([]byte, error) { + if len(priv) != PrivateKeySize { + return nil, fmt.Errorf("Ed25519 私钥长度非法: 期望 %d 字节, 实际 %d 字节", PrivateKeySize, len(priv)) + } + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, fmt.Errorf("PKCS#8 编码 Ed25519 私钥失败: %w", err) + } + return pem.EncodeToMemory(&pem.Block{Type: pemTypePrivateKey, Bytes: der}), nil +} + +// MarshalPublicKeyPEM 把 Ed25519 公钥编码为 PKIX 的 PEM 文本。 +// +// 公钥长度不是 PublicKeySize 时返回错误,不会 panic。 +func MarshalPublicKeyPEM(pub ed25519.PublicKey) ([]byte, error) { + if len(pub) != PublicKeySize { + return nil, fmt.Errorf("Ed25519 公钥长度非法: 期望 %d 字节, 实际 %d 字节", PublicKeySize, len(pub)) + } + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return nil, fmt.Errorf("PKIX 编码 Ed25519 公钥失败: %w", err) + } + return pem.EncodeToMemory(&pem.Block{Type: pemTypePublicKey, Bytes: der}), nil +} + +// ParsePrivateKeyPEM 解析 PKCS#8 的 Ed25519 私钥 PEM。 +// +// 只接受块类型为 "PRIVATE KEY" 的 PEM;块缺失、DER 非法、长度不符,或内容 +// 是 RSA/ECDSA 等其他算法时均返回错误(错误文本只包含类型与长度,不含密钥材料)。 +// 加密的 PKCS#8("ENCRYPTED PRIVATE KEY")不被支持。 +func ParsePrivateKeyPEM(data []byte) (ed25519.PrivateKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("解析 Ed25519 私钥 PEM 失败: 数据不是合法的 PEM") + } + if block.Type != pemTypePrivateKey { + return nil, fmt.Errorf("解析 Ed25519 私钥 PEM 失败: PEM 块类型为 %q, 期望 %q(不支持加密私钥或其他算法私钥)", block.Type, pemTypePrivateKey) + } + + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("解析 PKCS#8 私钥失败: %w", err) + } + priv, ok := key.(ed25519.PrivateKey) + if !ok { + return nil, fmt.Errorf("PEM 中的私钥类型为 %T, 不是 Ed25519 私钥", key) + } + if len(priv) != PrivateKeySize { + return nil, fmt.Errorf("Ed25519 私钥长度非法: 期望 %d 字节, 实际 %d 字节", PrivateKeySize, len(priv)) + } + return priv, nil +} + +// ParsePublicKeyPEM 解析 PKIX 的 Ed25519 公钥 PEM。 +// +// 只接受块类型为 "PUBLIC KEY" 的 PEM,并强制校验解析结果的具体类型与长度: +// 传入 RSA/ECDSA 公钥、证书或截断数据都会返回错误,绝不会因类型断言而 panic。 +func ParsePublicKeyPEM(data []byte) (ed25519.PublicKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("解析 Ed25519 公钥 PEM 失败: 数据不是合法的 PEM") + } + if block.Type != pemTypePublicKey { + return nil, fmt.Errorf("解析 Ed25519 公钥 PEM 失败: PEM 块类型为 %q, 期望 %q", block.Type, pemTypePublicKey) + } + + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("解析 PKIX 公钥失败: %w", err) + } + pub, ok := key.(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf("PEM 中的公钥类型为 %T, 不是 Ed25519 公钥", key) + } + if len(pub) != PublicKeySize { + return nil, fmt.Errorf("Ed25519 公钥长度非法: 期望 %d 字节, 实际 %d 字节", PublicKeySize, len(pub)) + } + return pub, nil +} + +// LoadPrivateKey 从文件读取并解析 PKCS#8 的 Ed25519 私钥。 +// +// 文件不存在、不可读或内容非法都返回错误。请确保该路径的访问权限受控, +// 私钥内容不会出现在任何返回值或日志里。 +func LoadPrivateKey(path string) (ed25519.PrivateKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("读取 Ed25519 私钥文件 %q 失败: %w", path, err) + } + return ParsePrivateKeyPEM(data) +} + +// LoadPublicKey 从文件读取并解析 PKIX 的 Ed25519 公钥。 +func LoadPublicKey(path string) (ed25519.PublicKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("读取 Ed25519 公钥文件 %q 失败: %w", path, err) + } + return ParsePublicKeyPEM(data) +} + +// SaveKeyPair 生成一对新的 Ed25519 密钥并写入两个文件。 +// +// privatePath 以 0600 权限写入(PKCS#8 PEM),publicPath 以 0644 权限写入 +// (PKIX PEM);已存在的同名文件会被覆盖。若公钥写入失败,会尽力删除刚写入的 +// 私钥文件以免留下不一致的残片。注意:在 Windows 上 os.WriteFile 的权限位只 +// 影响只读属性,私钥文件的访问控制需依赖目录权限另行加固。 +func SaveKeyPair(privatePath, publicPath string) error { + privatePEM, publicPEM, err := GenerateKeyPEM() + if err != nil { + return err + } + + if err := os.WriteFile(privatePath, privatePEM, privateFileMode); err != nil { + return fmt.Errorf("写入 Ed25519 私钥文件 %q 失败: %w", privatePath, err) + } + if err := os.WriteFile(publicPath, publicPEM, publicFileMode); err != nil { + if removeErr := os.Remove(privatePath); removeErr != nil { + return fmt.Errorf("写入 Ed25519 公钥文件 %q 失败: %w(回滚私钥文件 %q 也失败: %v)", publicPath, err, privatePath, removeErr) + } + return fmt.Errorf("写入 Ed25519 公钥文件 %q 失败: %w(已回滚私钥文件)", publicPath, err) + } + return nil +} + +// Sign 使用 Ed25519 私钥对 message 签名,返回 SignatureSize 字节的原始签名。 +// +// message 可以为空或任意长度,Ed25519 内部会先做哈希,调用方无需自行预哈希。 +// 私钥长度不等于 PrivateKeySize(含 nil)时返回错误,不会 panic。 +// 签名是确定性的:同一私钥与同一消息总是得到相同签名,便于做可复现的比对。 +func Sign(priv ed25519.PrivateKey, message []byte) ([]byte, error) { + if len(priv) != PrivateKeySize { + return nil, fmt.Errorf("Ed25519 私钥长度非法: 期望 %d 字节, 实际 %d 字节", PrivateKeySize, len(priv)) + } + return ed25519.Sign(priv, message), nil +} + +// Verify 校验 signature 是否为 pub 对 message 的有效 Ed25519 签名。 +// +// 校验通过返回 nil,否则返回错误。签名长度不是 SignatureSize、公钥长度不是 +// PublicKeySize(含 nil)时同样返回错误:标准库 ed25519.Verify 对错误长度会 +// panic,本函数在调用前已完成长度校验。 +// +// 安全提醒:返回的错误不区分"消息被篡改"与"签名被篡改",以免给攻击者提供 +// 额外信息;验签失败只应表现为"拒绝",不要据此重试或降级到其他算法。 +func Verify(pub ed25519.PublicKey, message, signature []byte) error { + if len(pub) != PublicKeySize { + return fmt.Errorf("Ed25519 公钥长度非法: 期望 %d 字节, 实际 %d 字节", PublicKeySize, len(pub)) + } + if len(signature) != SignatureSize { + return fmt.Errorf("Ed25519 签名长度非法: 期望 %d 字节, 实际 %d 字节", SignatureSize, len(signature)) + } + if !ed25519.Verify(pub, message, signature) { + return errors.New("Ed25519 验签失败: 签名与消息或公钥不匹配") + } + return nil +} + +// SignToBase64 对 message 签名并返回标准 Base64 编码的签名文本, +// 便于放进 JSON、HTTP 头或数据库字段。 +// +// 私钥非法时返回错误;返回值不是密钥材料,可以安全传输。 +func SignToBase64(priv ed25519.PrivateKey, message []byte) (string, error) { + signature, err := Sign(priv, message) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(signature), nil +} + +// VerifyFromBase64 校验标准 Base64 编码的签名。 +// +// signatureB64 非法 Base64、解码后长度不为 SignatureSize,或验签不通过时 +// 均返回错误;公钥非法同样返回错误,不会 panic。 +func VerifyFromBase64(pub ed25519.PublicKey, message []byte, signatureB64 string) error { + signature, err := base64.StdEncoding.DecodeString(signatureB64) + if err != nil { + return fmt.Errorf("Base64 解码 Ed25519 签名失败: %w", err) + } + return Verify(pub, message, signature) +} diff --git a/ed25519x/ed25519x_test.go b/ed25519x/ed25519x_test.go new file mode 100644 index 0000000..cfb0444 --- /dev/null +++ b/ed25519x/ed25519x_test.go @@ -0,0 +1,604 @@ +package ed25519x_test + +import ( + "bytes" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + mrand "math/rand" + "os" + "path/filepath" + "runtime" + "testing" + + "code.yun.ink/pkg/encryptx/ed25519x" +) + +// mustGenerateKey 生成密钥并断言长度,供其余用例复用。 +func mustGenerateKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519x.GenerateKey() + if err != nil { + t.Fatalf("GenerateKey 返回错误: %v", err) + } + if len(pub) != ed25519x.PublicKeySize { + t.Fatalf("公钥长度 = %d, 期望 %d", len(pub), ed25519x.PublicKeySize) + } + if len(priv) != ed25519x.PrivateKeySize { + t.Fatalf("私钥长度 = %d, 期望 %d", len(priv), ed25519x.PrivateKeySize) + } + return pub, priv +} + +// requireError 断言操作失败返回了 error(而非 panic 或静默成功)。 +func requireError(t *testing.T, name string, err error) { + t.Helper() + if err == nil { + t.Errorf("%s: 期望返回错误, 实际返回 nil", name) + } +} + +// TestGenerateKeySignVerifyRoundTrip 覆盖:生成密钥 -> 原始字节签名 -> 验签成功。 +func TestGenerateKeySignVerifyRoundTrip(t *testing.T) { + pub, priv := mustGenerateKey(t) + + message := []byte("encryptx ed25519x 往返测试消息") + signature, err := ed25519x.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + if len(signature) != ed25519x.SignatureSize { + t.Fatalf("签名长度 = %d, 期望 %d", len(signature), ed25519x.SignatureSize) + } + if err := ed25519x.Verify(pub, message, signature); err != nil { + t.Fatalf("Verify 应通过, 实际返回错误: %v", err) + } + + // 空消息与较长消息同样应能往返。 + for _, msg := range [][]byte{nil, {}, bytes.Repeat([]byte("A"), 1<<16)} { + sig, err := ed25519x.Sign(priv, msg) + if err != nil { + t.Fatalf("Sign(len=%d) 返回错误: %v", len(msg), err) + } + if err := ed25519x.Verify(pub, msg, sig); err != nil { + t.Fatalf("Verify(len=%d) 应通过, 实际返回错误: %v", len(msg), err) + } + } +} + +// TestSignatureIsDeterministic 覆盖:Ed25519 签名确定,同消息两次签名完全相同。 +func TestSignatureIsDeterministic(t *testing.T) { + pub, priv := mustGenerateKey(t) + message := []byte("确定性签名") + + first, err := ed25519x.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + second, err := ed25519x.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + if !bytes.Equal(first, second) { + t.Error("Ed25519 签名应为确定性, 但两次结果不同") + } + if err := ed25519x.Verify(pub, message, first); err != nil { + t.Fatalf("Verify 应通过, 实际返回错误: %v", err) + } +} + +// TestPEMRoundTrip 覆盖:GenerateKeyPEM -> 解析 -> 用公钥验签成功。 +func TestPEMRoundTrip(t *testing.T) { + privatePEM, publicPEM, err := ed25519x.GenerateKeyPEM() + if err != nil { + t.Fatalf("GenerateKeyPEM 返回错误: %v", err) + } + + privateBlock, _ := pem.Decode(privatePEM) + if privateBlock == nil { + t.Fatal("私钥 PEM 无法解码") + } + if privateBlock.Type != "PRIVATE KEY" { + t.Errorf("私钥 PEM 块类型 = %q, 期望 %q", privateBlock.Type, "PRIVATE KEY") + } + publicBlock, _ := pem.Decode(publicPEM) + if publicBlock == nil { + t.Fatal("公钥 PEM 无法解码") + } + if publicBlock.Type != "PUBLIC KEY" { + t.Errorf("公钥 PEM 块类型 = %q, 期望 %q", publicBlock.Type, "PUBLIC KEY") + } + + priv, err := ed25519x.ParsePrivateKeyPEM(privatePEM) + if err != nil { + t.Fatalf("ParsePrivateKeyPEM 返回错误: %v", err) + } + pub, err := ed25519x.ParsePublicKeyPEM(publicPEM) + if err != nil { + t.Fatalf("ParsePublicKeyPEM 返回错误: %v", err) + } + if len(priv) != ed25519x.PrivateKeySize { + t.Fatalf("解析出的私钥长度 = %d, 期望 %d", len(priv), ed25519x.PrivateKeySize) + } + if len(pub) != ed25519x.PublicKeySize { + t.Fatalf("解析出的公钥长度 = %d, 期望 %d", len(pub), ed25519x.PublicKeySize) + } + // 私钥内嵌的公钥必须与解析出的公钥一致,否则说明往返过程出错。 + embedded, ok := priv.Public().(ed25519.PublicKey) + if !ok { + t.Fatalf("私钥内嵌公钥类型 = %T, 期望 ed25519.PublicKey", priv.Public()) + } + if !bytes.Equal(embedded, pub) { + t.Error("私钥内嵌公钥与解析出的公钥不一致") + } + + message := []byte("PEM 往返后验签") + signature, err := ed25519x.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + if err := ed25519x.Verify(pub, message, signature); err != nil { + t.Fatalf("Verify 应通过, 实际返回错误: %v", err) + } +} + +// TestMarshalParsePEMRoundTrip 覆盖:已生成的密钥经 Marshal -> Parse 后字节完全一致。 +func TestMarshalParsePEMRoundTrip(t *testing.T) { + pub, priv := mustGenerateKey(t) + + privatePEM, err := ed25519x.MarshalPrivateKeyPEM(priv) + if err != nil { + t.Fatalf("MarshalPrivateKeyPEM 返回错误: %v", err) + } + parsedPriv, err := ed25519x.ParsePrivateKeyPEM(privatePEM) + if err != nil { + t.Fatalf("ParsePrivateKeyPEM 返回错误: %v", err) + } + if !bytes.Equal([]byte(priv), []byte(parsedPriv)) { + t.Error("私钥 PEM 往返后字节不一致") + } + + publicPEM, err := ed25519x.MarshalPublicKeyPEM(pub) + if err != nil { + t.Fatalf("MarshalPublicKeyPEM 返回错误: %v", err) + } + parsedPub, err := ed25519x.ParsePublicKeyPEM(publicPEM) + if err != nil { + t.Fatalf("ParsePublicKeyPEM 返回错误: %v", err) + } + if !bytes.Equal([]byte(pub), []byte(parsedPub)) { + t.Error("公钥 PEM 往返后字节不一致") + } +} + +// TestBase64RoundTrip 覆盖:Base64 便利函数的正常往返。 +func TestBase64RoundTrip(t *testing.T) { + pub, priv := mustGenerateKey(t) + message := []byte("Base64 签名") + + encoded, err := ed25519x.SignToBase64(priv, message) + if err != nil { + t.Fatalf("SignToBase64 返回错误: %v", err) + } + if err := ed25519x.VerifyFromBase64(pub, message, encoded); err != nil { + t.Fatalf("VerifyFromBase64 应通过, 实际返回错误: %v", err) + } + // Base64 文本必须能还原成标准长度的原始签名。 + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("Base64 解码失败: %v", err) + } + if len(raw) != ed25519x.SignatureSize { + t.Fatalf("解码后签名长度 = %d, 期望 %d", len(raw), ed25519x.SignatureSize) + } +} + +// TestTamperedMessageFails 覆盖:篡改消息后验签必须失败。 +func TestTamperedMessageFails(t *testing.T) { + pub, priv := mustGenerateKey(t) + message := []byte("原始消息 payload") + signature, err := ed25519x.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + + tampered := [][]byte{ + []byte("原始消息 payloa"), // 少一个字节 + []byte("原始消息 payload "), // 多一个空格 + []byte("篡改消息 payload"), + bytes.Repeat([]byte{0x00}, len(message)), + append(append([]byte{}, message...), 0x00), + } + for i, msg := range tampered { + requireError(t, "篡改消息后 Verify", ed25519x.Verify(pub, msg, signature)) + requireError(t, "篡改消息后 VerifyFromBase64", ed25519x.VerifyFromBase64(pub, msg, base64.StdEncoding.EncodeToString(signature))) + if i == 0 && bytes.Equal(msg, message) { + t.Fatal("用例构造错误: 篡改消息与原始消息相同") + } + } + // 空消息的签名不能通过非空消息的校验。 + emptySig, err := ed25519x.Sign(priv, nil) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + requireError(t, "空消息签名用于非空消息", ed25519x.Verify(pub, message, emptySig)) +} + +// TestTamperedSignatureFails 覆盖:翻转签名中的任意字节后验签必须失败。 +func TestTamperedSignatureFails(t *testing.T) { + pub, priv := mustGenerateKey(t) + message := []byte("签名篡改测试") + signature, err := ed25519x.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + + positions := []int{0, 1, ed25519x.SignatureSize / 2, ed25519x.SignatureSize - 1} + for _, pos := range positions { + flipped := append([]byte{}, signature...) + flipped[pos] ^= 0x01 + if bytes.Equal(flipped, signature) { + t.Fatalf("位置 %d 翻转后签名未变化", pos) + } + requireError(t, "翻转签名字节后 Verify", ed25519x.Verify(pub, message, flipped)) + } + + // 截断、加长、全零签名同样必须被拒绝。 + requireError(t, "截断签名(63 字节)", ed25519x.Verify(pub, message, signature[:ed25519x.SignatureSize-1])) + requireError(t, "加长签名(65 字节)", ed25519x.Verify(pub, message, append(append([]byte{}, signature...), 0x00))) + requireError(t, "全零签名", ed25519x.Verify(pub, message, make([]byte, ed25519x.SignatureSize))) + + // 合法签名的 Base64 文本被翻转一个字符后也必须失败。 + encoded := base64.StdEncoding.EncodeToString(signature) + chars := []byte(encoded) + if chars[0] == 'A' { + chars[0] = 'B' + } else { + chars[0] = 'A' + } + requireError(t, "Base64 篡改后 VerifyFromBase64", ed25519x.VerifyFromBase64(pub, message, string(chars))) +} + +// TestWrongPublicKeyFails 覆盖:用另一对密钥的公钥验签必须失败。 +func TestWrongPublicKeyFails(t *testing.T) { + pubA, privA := mustGenerateKey(t) + pubB, _ := mustGenerateKey(t) + + message := []byte("错误公钥测试") + signature, err := ed25519x.Sign(privA, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + if err := ed25519x.Verify(pubA, message, signature); err != nil { + t.Fatalf("使用正确公钥应当通过, 实际返回错误: %v", err) + } + requireError(t, "使用错误公钥 Verify", ed25519x.Verify(pubB, message, signature)) + requireError(t, "使用错误公钥 VerifyFromBase64", ed25519x.VerifyFromBase64(pubB, message, base64.StdEncoding.EncodeToString(signature))) +} + +// TestInvalidPEMInputs 覆盖:空 PEM、垃圾字节、其他算法/错误类型的合法 PEM 都必须返回 error。 +func TestInvalidPEMInputs(t *testing.T) { + pub, priv := mustGenerateKey(t) + validPrivatePEM, err := ed25519x.MarshalPrivateKeyPEM(priv) + if err != nil { + t.Fatalf("MarshalPrivateKeyPEM 返回错误: %v", err) + } + validPublicPEM, err := ed25519x.MarshalPublicKeyPEM(pub) + if err != nil { + t.Fatalf("MarshalPublicKeyPEM 返回错误: %v", err) + } + privateDER, _ := pem.Decode(validPrivatePEM) + publicDER, _ := pem.Decode(validPublicPEM) + if privateDER == nil || publicDER == nil { + t.Fatal("自产 PEM 无法解码, 测试前提不成立") + } + + // 其他算法:ECDSA(P-256) 与 RSA(2048) 的 PKCS#8 私钥、PKIX 公钥。 + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("生成 ECDSA 测试密钥失败: %v", err) + } + ecPrivateDER, err := x509.MarshalPKCS8PrivateKey(ecKey) + if err != nil { + t.Fatalf("编码 ECDSA 私钥失败: %v", err) + } + ecPublicDER, err := x509.MarshalPKIXPublicKey(&ecKey.PublicKey) + if err != nil { + t.Fatalf("编码 ECDSA 公钥失败: %v", err) + } + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("生成 RSA 测试密钥失败: %v", err) + } + rsaPrivateDER, err := x509.MarshalPKCS8PrivateKey(rsaKey) + if err != nil { + t.Fatalf("编码 RSA 私钥失败: %v", err) + } + rsaPublicDER, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey) + if err != nil { + t.Fatalf("编码 RSA 公钥失败: %v", err) + } + + cases := []struct { + name string + fn func() error + }{ + {"空 PEM 私钥(nil)", func() error { _, err := ed25519x.ParsePrivateKeyPEM(nil); return err }}, + {"空 PEM 私钥(空切片)", func() error { _, err := ed25519x.ParsePrivateKeyPEM([]byte{}); return err }}, + {"空 PEM 私钥(仅空白)", func() error { _, err := ed25519x.ParsePrivateKeyPEM([]byte(" \n\t")); return err }}, + {"垃圾字节私钥", func() error { _, err := ed25519x.ParsePrivateKeyPEM([]byte("这不是 PEM 数据")); return err }}, + {"随机字节私钥", func() error { _, err := ed25519x.ParsePrivateKeyPEM(bytes.Repeat([]byte{0xAB}, 256)); return err }}, + {"无 PEM 头尾的裸 DER 私钥", func() error { _, err := ed25519x.ParsePrivateKeyPEM(privateDER.Bytes); return err }}, + {"块类型为 CERTIFICATE 的私钥", func() error { + _, err := ed25519x.ParsePrivateKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: privateDER.Bytes})) + return err + }}, + {"块内容为垃圾的 PKCS#8 私钥", func() error { + _, err := ed25519x.ParsePrivateKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte("garbage")})) + return err + }}, + {"ECDSA 私钥 PEM", func() error { + _, err := ed25519x.ParsePrivateKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: ecPrivateDER})) + return err + }}, + {"RSA 私钥 PEM", func() error { + _, err := ed25519x.ParsePrivateKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: rsaPrivateDER})) + return err + }}, + {"公钥 PEM 传给 ParsePrivateKeyPEM", func() error { _, err := ed25519x.ParsePrivateKeyPEM(validPublicPEM); return err }}, + {"空 PEM 公钥(nil)", func() error { _, err := ed25519x.ParsePublicKeyPEM(nil); return err }}, + {"垃圾字节公钥", func() error { + _, err := ed25519x.ParsePublicKeyPEM([]byte("-----BEGIN PUBLIC KEY-----\n不是Base64\n-----END PUBLIC KEY-----")) + return err + }}, + {"块类型为 PRIVATE KEY 的公钥", func() error { + _, err := ed25519x.ParsePublicKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: publicDER.Bytes})) + return err + }}, + {"块内容为垃圾的 PKIX 公钥", func() error { + _, err := ed25519x.ParsePublicKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("garbage")})) + return err + }}, + {"ECDSA 公钥 PEM", func() error { + _, err := ed25519x.ParsePublicKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: ecPublicDER})) + return err + }}, + {"RSA 公钥 PEM", func() error { + _, err := ed25519x.ParsePublicKeyPEM(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: rsaPublicDER})) + return err + }}, + {"私钥 PEM 传给 ParsePublicKeyPEM", func() error { _, err := ed25519x.ParsePublicKeyPEM(validPrivatePEM); return err }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + requireError(t, tc.name, tc.fn()) + }) + } +} + +// TestInvalidKeyAndSignatureInputs 覆盖:nil 密钥、长度不对的密钥/签名、非法 Base64 都必须返回 error 且不 panic。 +func TestInvalidKeyAndSignatureInputs(t *testing.T) { + pub, priv := mustGenerateKey(t) + message := []byte("非法输入测试") + validSignature, err := ed25519x.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + + cases := []struct { + name string + fn func() error + }{ + {"Sign 私钥为 nil", func() error { _, err := ed25519x.Sign(nil, message); return err }}, + {"Sign 私钥长度为 32(仅种子)", func() error { _, err := ed25519x.Sign(make([]byte, 32), message); return err }}, + {"Sign 私钥长度为 63", func() error { _, err := ed25519x.Sign(make([]byte, 63), message); return err }}, + {"Sign 私钥长度为 65", func() error { _, err := ed25519x.Sign(make([]byte, 65), message); return err }}, + {"SignToBase64 私钥为 nil", func() error { _, err := ed25519x.SignToBase64(nil, message); return err }}, + {"Verify 公钥为 nil", func() error { return ed25519x.Verify(nil, message, validSignature) }}, + {"Verify 公钥长度为 31", func() error { return ed25519x.Verify(make([]byte, 31), message, validSignature) }}, + {"Verify 公钥长度为 33", func() error { return ed25519x.Verify(make([]byte, 33), message, validSignature) }}, + {"Verify 签名为 nil", func() error { return ed25519x.Verify(pub, message, nil) }}, + {"Verify 签名为空切片", func() error { return ed25519x.Verify(pub, message, []byte{}) }}, + {"Verify 签名长度为 63", func() error { return ed25519x.Verify(pub, message, validSignature[:63]) }}, + {"Verify 签名长度为 128", func() error { return ed25519x.Verify(pub, message, make([]byte, 128)) }}, + {"Verify 公钥与签名均为 nil", func() error { return ed25519x.Verify(nil, message, nil) }}, + {"VerifyFromBase64 非法 Base64", func() error { return ed25519x.VerifyFromBase64(pub, message, "!!!not-base64!!!") }}, + {"VerifyFromBase64 空字符串", func() error { return ed25519x.VerifyFromBase64(pub, message, "") }}, + {"VerifyFromBase64 公钥为 nil", func() error { + return ed25519x.VerifyFromBase64(nil, message, base64.StdEncoding.EncodeToString(validSignature)) + }}, + {"MarshalPrivateKeyPEM 私钥为 nil", func() error { _, err := ed25519x.MarshalPrivateKeyPEM(nil); return err }}, + {"MarshalPrivateKeyPEM 长度为 32", func() error { _, err := ed25519x.MarshalPrivateKeyPEM(make([]byte, 32)); return err }}, + {"MarshalPublicKeyPEM 公钥为 nil", func() error { _, err := ed25519x.MarshalPublicKeyPEM(nil); return err }}, + {"MarshalPublicKeyPEM 长度为 31", func() error { _, err := ed25519x.MarshalPublicKeyPEM(make([]byte, 31)); return err }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + requireError(t, tc.name, tc.fn()) + }) + } +} + +// TestSaveLoadKeyPair 覆盖:SaveKeyPair 落盘后能被 LoadPrivateKey/LoadPublicKey 读回并完成验签。 +func TestSaveLoadKeyPair(t *testing.T) { + dir := t.TempDir() + privatePath := filepath.Join(dir, "ed25519_private.pem") + publicPath := filepath.Join(dir, "ed25519_public.pem") + + if err := ed25519x.SaveKeyPair(privatePath, publicPath); err != nil { + t.Fatalf("SaveKeyPair 返回错误: %v", err) + } + + priv, err := ed25519x.LoadPrivateKey(privatePath) + if err != nil { + t.Fatalf("LoadPrivateKey 返回错误: %v", err) + } + pub, err := ed25519x.LoadPublicKey(publicPath) + if err != nil { + t.Fatalf("LoadPublicKey 返回错误: %v", err) + } + if len(priv) != ed25519x.PrivateKeySize { + t.Fatalf("加载的私钥长度 = %d, 期望 %d", len(priv), ed25519x.PrivateKeySize) + } + if len(pub) != ed25519x.PublicKeySize { + t.Fatalf("加载的公钥长度 = %d, 期望 %d", len(pub), ed25519x.PublicKeySize) + } + + message := []byte("落盘往返测试") + signature, err := ed25519x.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + if err := ed25519x.Verify(pub, message, signature); err != nil { + t.Fatalf("Verify 应通过, 实际返回错误: %v", err) + } + + // 覆盖写:再次调用不应报错,且新密钥与旧密钥不同。 + previousPriv := append([]byte{}, priv...) + if err := ed25519x.SaveKeyPair(privatePath, publicPath); err != nil { + t.Fatalf("第二次 SaveKeyPair 返回错误: %v", err) + } + regenerated, err := ed25519x.LoadPrivateKey(privatePath) + if err != nil { + t.Fatalf("LoadPrivateKey 返回错误: %v", err) + } + if bytes.Equal(previousPriv, []byte(regenerated)) { + t.Error("两次 SaveKeyPair 应生成不同密钥") + } + + // 权限位只在类 Unix 系统上有意义,Windows 只用 mode 表示只读属性。 + if runtime.GOOS != "windows" { + privateInfo, err := os.Stat(privatePath) + if err != nil { + t.Fatalf("Stat 私钥文件失败: %v", err) + } + if perm := privateInfo.Mode().Perm(); perm != 0o600 { + t.Errorf("私钥文件权限 = %o, 期望 600", perm) + } + publicInfo, err := os.Stat(publicPath) + if err != nil { + t.Fatalf("Stat 公钥文件失败: %v", err) + } + if perm := publicInfo.Mode().Perm(); perm != 0o644 { + t.Errorf("公钥文件权限 = %o, 期望 644", perm) + } + } +} + +// TestLoadKeyErrors 覆盖:路径不存在、路径是目录、文件内容非法时都必须返回 error。 +func TestLoadKeyErrors(t *testing.T) { + dir := t.TempDir() + + requireError(t, "LoadPrivateKey 路径不存在", func() error { + _, err := ed25519x.LoadPrivateKey(filepath.Join(dir, "missing.pem")) + return err + }()) + requireError(t, "LoadPublicKey 路径不存在", func() error { + _, err := ed25519x.LoadPublicKey(filepath.Join(dir, "missing.pem")) + return err + }()) + requireError(t, "LoadPrivateKey 路径为目录", func() error { + _, err := ed25519x.LoadPrivateKey(dir) + return err + }()) + + junkPath := filepath.Join(dir, "junk.pem") + if err := os.WriteFile(junkPath, []byte("这不是 PEM"), 0o600); err != nil { + t.Fatalf("写入测试文件失败: %v", err) + } + requireError(t, "LoadPrivateKey 内容非法", func() error { + _, err := ed25519x.LoadPrivateKey(junkPath) + return err + }()) + requireError(t, "LoadPublicKey 内容非法", func() error { + _, err := ed25519x.LoadPublicKey(junkPath) + return err + }()) +} + +// flipRandomBits 复制一份数据并随机翻转若干位,用于构造逼近真实攻击面的畸形输入。 +func flipRandomBits(rng *mrand.Rand, data []byte) []byte { + out := append([]byte{}, data...) + for i := 0; i < 1+rng.Intn(4); i++ { + out[rng.Intn(len(out))] ^= byte(1 << rng.Intn(8)) + } + return out +} + +// randomBytes 生成指定长度的随机字节串。 +func randomBytes(rng *mrand.Rand, size int) []byte { + out := make([]byte, size) + for i := range out { + out[i] = byte(rng.Intn(256)) + } + return out +} + +// TestMalformedInputsDoNotPanic 覆盖:随机字节与随机位翻转的 PEM/签名都不会触发 panic。 +// 这是对"任何情况下不得 panic"这一硬性要求的直接验证,因此只要求不 panic,不约束具体错误内容。 +func TestMalformedInputsDoNotPanic(t *testing.T) { + privatePEM, publicPEM, err := ed25519x.GenerateKeyPEM() + if err != nil { + t.Fatalf("GenerateKeyPEM 返回错误: %v", err) + } + priv, err := ed25519x.ParsePrivateKeyPEM(privatePEM) + if err != nil { + t.Fatalf("ParsePrivateKeyPEM 返回错误: %v", err) + } + pub, err := ed25519x.ParsePublicKeyPEM(publicPEM) + if err != nil { + t.Fatalf("ParsePublicKeyPEM 返回错误: %v", err) + } + message := []byte("畸形输入测试") + signature, err := ed25519x.Sign(priv, message) + if err != nil { + t.Fatalf("Sign 返回错误: %v", err) + } + + rng := mrand.New(mrand.NewSource(20240517)) + for i := 0; i < 300; i++ { + // 完全随机的字节串。 + raw := make([]byte, rng.Intn(256)) + for j := range raw { + raw[j] = byte(rng.Intn(256)) + } + _, _ = ed25519x.ParsePrivateKeyPEM(raw) + _, _ = ed25519x.ParsePublicKeyPEM(raw) + _ = ed25519x.Verify(pub, message, raw) + _ = ed25519x.VerifyFromBase64(pub, message, string(raw)) + + // 合法长度但内容随机的密钥与签名,必须返回 error 而不是 panic。 + _, _ = ed25519x.MarshalPrivateKeyPEM(randomBytes(rng, ed25519x.PrivateKeySize)) + _, _ = ed25519x.MarshalPublicKeyPEM(randomBytes(rng, ed25519x.PublicKeySize)) + _, _ = ed25519x.Sign(randomBytes(rng, ed25519x.PrivateKeySize), message) + _ = ed25519x.Verify(randomBytes(rng, ed25519x.PublicKeySize), message, randomBytes(rng, ed25519x.SignatureSize)) + + // 在合法 PEM 上随机翻转比特位,更容易命中解析器的深层分支。 + _, _ = ed25519x.ParsePrivateKeyPEM(flipRandomBits(rng, privatePEM)) + _, _ = ed25519x.ParsePublicKeyPEM(flipRandomBits(rng, publicPEM)) + + // 在合法签名上随机翻转比特位。 + _ = ed25519x.Verify(pub, message, flipRandomBits(rng, signature)) + _ = ed25519x.VerifyFromBase64(pub, message, base64.StdEncoding.EncodeToString(flipRandomBits(rng, signature))) + } +} + +// TestSaveKeyPairErrors 覆盖:写入路径非法时返回 error 而不是 panic。 +func TestSaveKeyPairErrors(t *testing.T) { + dir := t.TempDir() + // 私钥路径指向一个已存在的目录,写入必然失败。 + requireError(t, "SaveKeyPair 私钥路径为目录", ed25519x.SaveKeyPair(dir, filepath.Join(dir, "pub.pem"))) + // 公钥路径指向不存在的目录,私钥会先写入成功,随后应回滚。 + privatePath := filepath.Join(dir, "priv.pem") + requireError(t, "SaveKeyPair 公钥路径目录不存在", ed25519x.SaveKeyPair(privatePath, filepath.Join(dir, "no-such-dir", "pub.pem"))) + if _, err := os.Stat(privatePath); err == nil { + t.Error("公钥写入失败后应删除已写入的私钥文件") + } +} diff --git a/encryptx.go b/encryptx.go index d6f9940..a5edc67 100644 --- a/encryptx.go +++ b/encryptx.go @@ -1,371 +1,482 @@ +// Package encryptx 提供一个统一的、字符串驱动的加解密门面,覆盖哈希、对称加密、 +// 消息认证码、密钥派生、口令哈希、非对称加密与数字签名。 +// +// # 定位与建议 +// +// 本包适合「算法由配置/管理后台决定」的场景:调用方从配置里读到算法名,转成 +// EncryptType 后交给 Encrypt/Decrypt 即可,无需为每种算法写分支。 +// +// 如果你在写业务代码而不是配置驱动的调度代码,**优先直接使用子包** +// (aesx、chachax、rsax、ed25519x、argon2x 等):它们有强类型的参数、 +// 更明确的错误,并且不需要在运行时解析选项。 +// +// # 密钥约定 +// +// 密钥一律是 []byte,绝不使用字符串: +// +// - 对称算法、HMAC、KDF:key 是**原始密钥字节**(不是十六进制、不是 Base64)。 +// 用相应子包的 GenerateKey 获取; +// - RSA / Ed25519 / ECDSA:key 是 **PEM 编码的密钥内容**(公钥用于加密/验签, +// 私钥用于解密/签名)。本包不会读取文件,需要从文件加载时请调用 +// rsax.LoadPublicKey 等函数。 +// +// 历史版本把密钥当字符串并直接按字节使用,而 GenerateKey 返回的却是十六进制 +// 文本,导致「生成密钥 → 加密」这条路径必然失败。统一为 []byte 后这类错误在 +// 类型层面就不可能出现。 +// +// # 输出格式 +// +// 不同算法族的输出编码不同,这是为了与既有系统保持一致: +// +// - 哈希 / HMAC / HEX 编码:小写十六进制; +// - Base64 编码:标准 Base64(带填充); +// - 分组密码模式(ECB/CBC/CFB/OFB/CTR):**大写**十六进制,且不含换行; +// - AEAD(AES-GCM、ChaCha20-Poly1305):"密文十六进制:nonce十六进制"(小写、 +// 无换行),两者以冒号分隔; +// - RSA 加密、数字签名:标准 Base64。 package encryptx import ( - "crypto/rand" - "encoding/base64" - "encoding/hex" + "errors" "fmt" "sort" - "strings" - - "code.yun.ink/pkg/encryptx/aesx" - "code.yun.ink/pkg/encryptx/base64x" - "code.yun.ink/pkg/encryptx/desx" - "code.yun.ink/pkg/encryptx/hmacx" - "code.yun.ink/pkg/encryptx/md5x" - "code.yun.ink/pkg/encryptx/pbkdf2x" - "code.yun.ink/pkg/encryptx/rsax" - "code.yun.ink/pkg/encryptx/sha1x" - "code.yun.ink/pkg/encryptx/sha256x" - "code.yun.ink/pkg/encryptx/sha3x" - "code.yun.ink/pkg/encryptx/sha512x" - "code.yun.ink/pkg/encryptx/tdesx" - "code.yun.ink/pkg/encryptx/utilsx" ) -// EncryptType 加密类型枚举 +// EncryptType 标识一种密码学算法。 +// +// 枚举值显式赋值而非常量递增(iota),因为历史版本用的是连续 iota:一旦在中间 +// 插入新算法,所有后续算法的数值都会平移,导致已经落库的配置指向错误的算法。 +// 这里保留原有数值不变,新算法一律追加在末尾。 type EncryptType int +// 哈希算法。 const ( - // 哈希算法 - MD5 EncryptType = iota - SHA1 - SHA256 - SHA512 - SHA3_256 - SHA3_384 - SHA3_512 - - // 对称加密算法 - DES_ECB - DES_CBC - DES_CFB - TDES_ECB - TDES_CBC - TDES_CFB - AES_ECB - AES_CBC - AES_CFB - AES_GCM - BLOWFISH_ECB - BLOWFISH_CBC - BLOWFISH_CFB - - // 非对称加密算法 - RSA - - // 消息认证码 - HMAC_SHA256 - HMAC_SHA512 - - // 密钥派生 - PBKDF2 - - // 编码 - BASE64 + MD5 EncryptType = 0 + SHA1 EncryptType = 1 + SHA256 EncryptType = 2 + SHA512 EncryptType = 3 + SHA3_256 EncryptType = 4 + SHA3_384 EncryptType = 5 + SHA3_512 EncryptType = 6 ) -var encryptTypeNames = map[EncryptType]string{ - MD5: "MD5", - SHA1: "SHA1", - SHA256: "SHA256", - SHA512: "SHA512", - SHA3_256: "SHA3-256", - SHA3_384: "SHA3-384", - SHA3_512: "SHA3-512", - DES_ECB: "DES-ECB", - DES_CBC: "DES-CBC", - DES_CFB: "DES-CFB", - TDES_ECB: "3DES-ECB", - TDES_CBC: "3DES-CBC", - TDES_CFB: "3DES-CFB", - AES_ECB: "AES-ECB", - AES_CBC: "AES-CBC", - AES_CFB: "AES-CFB", - AES_GCM: "AES-GCM", - BLOWFISH_ECB: "Blowfish-ECB", - BLOWFISH_CBC: "Blowfish-CBC", - BLOWFISH_CFB: "Blowfish-CFB", - RSA: "RSA", - HMAC_SHA256: "HMAC-SHA256", - HMAC_SHA512: "HMAC-SHA512", - PBKDF2: "PBKDF2", - BASE64: "Base64", +// 对称加密算法(历史数值区间)。 +const ( + DES_ECB EncryptType = 7 + DES_CBC EncryptType = 8 + DES_CFB EncryptType = 9 + TDES_ECB EncryptType = 10 + TDES_CBC EncryptType = 11 + TDES_CFB EncryptType = 12 + AES_ECB EncryptType = 13 + AES_CBC EncryptType = 14 + AES_CFB EncryptType = 15 + AES_GCM EncryptType = 16 + BLOWFISH_ECB EncryptType = 17 + BLOWFISH_CBC EncryptType = 18 + BLOWFISH_CFB EncryptType = 19 +) + +// 非对称加密、消息认证码、密钥派生与编码(历史数值区间)。 +const ( + RSA EncryptType = 20 + HMAC_SHA256 EncryptType = 21 + HMAC_SHA512 EncryptType = 22 + PBKDF2 EncryptType = 23 + BASE64 EncryptType = 24 +) + +// 以下为新增算法,从 25 开始追加,保证既有数值稳定。 +const ( + // 哈希 + SHA224 EncryptType = 25 + SHA384 EncryptType = 26 + SHA3_224 EncryptType = 27 + BLAKE2B_256 EncryptType = 28 + BLAKE2S_256 EncryptType = 29 + RIPEMD160 EncryptType = 30 + + // 对称加密(补齐 OFB/CTR 与 AEAD) + DES_OFB EncryptType = 31 + DES_CTR EncryptType = 32 + TDES_OFB EncryptType = 33 + TDES_CTR EncryptType = 34 + AES_OFB EncryptType = 35 + AES_CTR EncryptType = 36 + BLOWFISH_OFB EncryptType = 37 + BLOWFISH_CTR EncryptType = 38 + CHACHA20_POLY1305 EncryptType = 39 + XCHACHA20_POLY1305 EncryptType = 40 + + // 消息认证码 + HMAC_SHA1 EncryptType = 41 + HMAC_SHA384 EncryptType = 42 + + // 密钥派生与口令哈希 + HKDF EncryptType = 43 + SCRYPT EncryptType = 44 + ARGON2ID EncryptType = 45 + BCRYPT EncryptType = 46 + + // 非对称加密(OAEP 为现代默认) + RSA_OAEP EncryptType = 47 + + // 编码 + BASE64_URL EncryptType = 48 + HEX EncryptType = 49 +) + +// SignType 标识一种数字签名算法。 +// +// 签名与加密的语义不同(签名用私钥、验签用公钥,且不涉及机密性),因此使用 +// 独立的枚举与独立的 Sign/Verify 入口,而不是塞进 EncryptType。 +type SignType int + +const ( + // ED25519 是当前首选的签名算法:实现简单、无参数、签名确定、抗侧信道。 + ED25519 SignType = iota + // ECDSA_P256 是 NIST P-256 曲线上的 ECDSA。 + ECDSA_P256 + // ECDSA_P384 是 NIST P-384 曲线上的 ECDSA。 + ECDSA_P384 + // ECDSA_P521 是 NIST P-521 曲线上的 ECDSA。 + ECDSA_P521 + // RSA_PSS 是带 PSS 填充的 RSA 签名,是现代推荐形式。 + RSA_PSS + // RSA_PKCS1V15 是传统的 PKCS#1 v1.5 签名,仅为兼容旧系统保留。 + RSA_PKCS1V15 +) + +var signTypeNames = map[SignType]string{ + ED25519: "ED25519", + ECDSA_P256: "ECDSA-P256", + ECDSA_P384: "ECDSA-P384", + ECDSA_P521: "ECDSA-P521", + RSA_PSS: "RSA-PSS", + RSA_PKCS1V15: "RSA-PKCS1v15", } -// GetEncryptTypeName 获取加密类型的名称 -func GetEncryptTypeName(encryptType EncryptType) string { - return encryptTypeNames[encryptType] +// String 返回签名算法名称。 +func (s SignType) String() string { + if name, ok := signTypeNames[s]; ok { + return name + } + return fmt.Sprintf("SignType(%d)", int(s)) } -// GetAllEncryptTypes 获取所有加密类型 -func GetAllEncryptTypes() []EncryptType { - types := make([]EncryptType, 0, len(encryptTypeNames)) - for t := range encryptTypeNames { +// Valid 报告签名算法是否受支持。 +func (s SignType) Valid() bool { + _, ok := signTypeNames[s] + return ok +} + +// UsesRSA 报告该签名算法是否基于 RSA(密钥格式为 RSA PEM)。 +func (s SignType) UsesRSA() bool { + return s == RSA_PSS || s == RSA_PKCS1V15 +} + +// GetAllSignTypes 返回全部签名算法,按数值升序。 +func GetAllSignTypes() []SignType { + types := make([]SignType, 0, len(signTypeNames)) + for t := range signTypeNames { types = append(types, t) } - sort.Slice(types, func(i, j int) bool { - return int(types[i]) < int(types[j]) - }) + sort.Slice(types, func(i, j int) bool { return int(types[i]) < int(types[j]) }) return types } -// Encrypt 加密数据 -// encryptType: 加密类型 -// data: 要加密的数据 -// key: 密钥(某些算法需要) -// options: 额外选项(如盐值、迭代次数等) -func Encrypt(encryptType EncryptType, data string, key string, options map[string]interface{}) (string, error) { - switch encryptType { - // 哈希算法 - case MD5: - return md5x.Md5String(data), nil - case SHA1: - return sha1x.Sha1(data), nil - case SHA256: - return sha256x.Sha256(data), nil - case SHA512: - return sha512x.Sha512(data), nil - case SHA3_256: - return sha3x.Sha3_256(data), nil - case SHA3_384: - return sha3x.Sha3_384(data), nil - case SHA3_512: - return sha3x.Sha3_512(data), nil +// SecurityLevel 表示算法的安全等级,供管理界面给出风险提示或禁止选择。 +type SecurityLevel int - // 对称加密算法 - case DES_ECB: - return desx.EncryptECBHex(data, key) - case DES_CBC: - return desx.EncryptCBCHex(data, key) - case DES_CFB: - return desx.EncryptCFBHex(data, key) - case TDES_ECB: - return tdesx.EncryptECBHex(data, key) - case TDES_CBC: - return tdesx.EncryptCBCHex(data, key) - case TDES_CFB: - return tdesx.EncryptCFBHex(data, key) - case AES_ECB: - return aesx.EncryptECBHex(data, key) - case AES_CBC: - return aesx.EncryptCBCHex(data, key) - case AES_CFB: - return aesx.EncryptCFBHex(data, key) - case AES_GCM: - ciphertext, nonce, err := aesx.EncryptGCM(key, data) +const ( + // SecurityBroken 表示算法已被实际攻破,任何情况下都不应使用。 + SecurityBroken SecurityLevel = iota + // SecurityLegacy 表示算法未被攻破但已不推荐,仅用于与既有系统互操作。 + SecurityLegacy + // SecurityAcceptable 表示算法仍可接受,但存在更好的现代替代。 + SecurityAcceptable + // SecurityRecommended 表示当前推荐使用的算法。 + SecurityRecommended +) + +// String 返回安全等级的中文说明。 +func (l SecurityLevel) String() string { + switch l { + case SecurityBroken: + return "已攻破" + case SecurityLegacy: + return "遗留" + case SecurityAcceptable: + return "可接受" + case SecurityRecommended: + return "推荐" + default: + return fmt.Sprintf("SecurityLevel(%d)", int(l)) + } +} + +// typeInfo 描述一种算法的元数据。集中放在一张表里,便于 GetAllEncryptTypes +// 与界面直接消费,也避免在多处 switch 里各写一遍。 +type typeInfo struct { + name string + level SecurityLevel + reversible bool // 是否可逆(能否 Decrypt) + needsKey bool // 是否需要密钥 + aead bool // 是否提供完整性保护(AEAD) + note string +} + +var typeInfos = map[EncryptType]typeInfo{ + // ---- 哈希(不可逆、不需要密钥)---- + MD5: {"MD5", SecurityBroken, false, false, false, "已被实际碰撞攻击攻破,仅可用于非安全的校验和"}, + SHA1: {"SHA1", SecurityBroken, false, false, false, "已被 SHAttered 碰撞攻击攻破,仅可用于非安全的校验和"}, + SHA224: {"SHA-224", SecurityAcceptable, false, false, false, "SHA-256 的截断版本,输出 28 字节"}, + SHA256: {"SHA-256", SecurityRecommended, false, false, false, ""}, + SHA384: {"SHA-384", SecurityRecommended, false, false, false, "SHA-512 的截断版本,输出 48 字节"}, + SHA512: {"SHA-512", SecurityRecommended, false, false, false, ""}, + SHA3_224: {"SHA3-224", SecurityAcceptable, false, false, false, ""}, + SHA3_256: {"SHA3-256", SecurityRecommended, false, false, false, "与 SHA-256 无继承关系,抗长度扩展攻击"}, + SHA3_384: {"SHA3-384", SecurityRecommended, false, false, false, ""}, + SHA3_512: {"SHA3-512", SecurityRecommended, false, false, false, ""}, + BLAKE2B_256: {"BLAKE2b-256", SecurityRecommended, false, false, false, "速度快于 SHA-2,适合大数据量校验"}, + BLAKE2S_256: {"BLAKE2s-256", SecurityRecommended, false, false, false, "面向 32 位平台优化的 BLAKE2"}, + RIPEMD160: {"RIPEMD-160", SecurityLegacy, false, false, false, "仅用于兼容旧系统(如比特币地址)"}, + + // ---- 对称加密 ---- + DES_ECB: {"DES-ECB", SecurityBroken, true, true, false, "56 位密钥可被暴力破解,且 ECB 无语义安全性"}, + DES_CBC: {"DES-CBC", SecurityBroken, true, true, false, "56 位密钥可被暴力破解"}, + DES_CFB: {"DES-CFB", SecurityBroken, true, true, false, "56 位密钥可被暴力破解"}, + DES_OFB: {"DES-OFB", SecurityBroken, true, true, false, "56 位密钥可被暴力破解"}, + DES_CTR: {"DES-CTR", SecurityBroken, true, true, false, "56 位密钥可被暴力破解"}, + TDES_ECB: {"3DES-ECB", SecurityLegacy, true, true, false, "64 位分组存在 Sweet32 风险,且 ECB 无语义安全性"}, + TDES_CBC: {"3DES-CBC", SecurityLegacy, true, true, false, "64 位分组存在 Sweet32 风险"}, + TDES_CFB: {"3DES-CFB", SecurityLegacy, true, true, false, "64 位分组存在 Sweet32 风险"}, + TDES_OFB: {"3DES-OFB", SecurityLegacy, true, true, false, "64 位分组存在 Sweet32 风险"}, + TDES_CTR: {"3DES-CTR", SecurityLegacy, true, true, false, "64 位分组存在 Sweet32 风险"}, + BLOWFISH_ECB: {"Blowfish-ECB", SecurityLegacy, true, true, false, "64 位分组存在 Sweet32 风险,且 ECB 无语义安全性"}, + BLOWFISH_CBC: {"Blowfish-CBC", SecurityLegacy, true, true, false, "64 位分组存在 Sweet32 风险"}, + BLOWFISH_CFB: {"Blowfish-CFB", SecurityLegacy, true, true, false, "64 位分组存在 Sweet32 风险"}, + BLOWFISH_OFB: {"Blowfish-OFB", SecurityLegacy, true, true, false, "64 位分组存在 Sweet32 风险"}, + BLOWFISH_CTR: {"Blowfish-CTR", SecurityLegacy, true, true, false, "64 位分组存在 Sweet32 风险"}, + AES_ECB: {"AES-ECB", SecurityLegacy, true, true, false, "ECB 无语义安全性,相同明文块产生相同密文块"}, + AES_CBC: {"AES-CBC", SecurityAcceptable, true, true, false, "无完整性保护;建议改用 AES-GCM"}, + AES_CFB: {"AES-CFB", SecurityAcceptable, true, true, false, "无完整性保护;建议改用 AES-GCM"}, + AES_OFB: {"AES-OFB", SecurityAcceptable, true, true, false, "无完整性保护;建议改用 AES-GCM"}, + AES_CTR: {"AES-CTR", SecurityAcceptable, true, true, false, "无完整性保护;建议改用 AES-GCM"}, + AES_GCM: {"AES-GCM", SecurityRecommended, true, true, true, "推荐;务必保证 nonce 在同一密钥下不重复"}, + CHACHA20_POLY1305: {"ChaCha20-Poly1305", SecurityRecommended, true, true, true, "推荐;无 AES 硬件加速时的首选"}, + XCHACHA20_POLY1305: {"XChaCha20-Poly1305", SecurityRecommended, true, true, true, "推荐;24 字节 nonce 可安全随机生成"}, + + // ---- 消息认证码 ---- + HMAC_SHA1: {"HMAC-SHA1", SecurityLegacy, false, true, true, "HMAC 本身仍安全,但底层 SHA-1 已不推荐"}, + HMAC_SHA256: {"HMAC-SHA256", SecurityRecommended, false, true, true, ""}, + HMAC_SHA384: {"HMAC-SHA384", SecurityRecommended, false, true, true, ""}, + HMAC_SHA512: {"HMAC-SHA512", SecurityRecommended, false, true, true, ""}, + + // ---- 密钥派生与口令哈希 ---- + PBKDF2: {"PBKDF2-HMAC-SHA256", SecurityAcceptable, false, false, false, "迭代次数需按硬件能力调整,建议 ≥ 600000"}, + HKDF: {"HKDF-SHA256", SecurityRecommended, false, false, false, "用于高熵密钥材料的派生与分离,不适合低熵口令"}, + SCRYPT: {"scrypt", SecurityRecommended, false, false, false, "内存硬,适合口令存储;Encrypt 走原始密钥派生,需显式提供 salt"}, + ARGON2ID: {"Argon2id", SecurityRecommended, false, false, false, "当前口令哈希的首选;Encrypt 走 PHC 口令哈希,DeriveKey 走原始密钥派生"}, + BCRYPT: {"bcrypt", SecurityRecommended, false, false, false, "经过长期考验;内存开销较小"}, + + // ---- 非对称 ---- + RSA: {"RSA-OAEP(SHA-256)", SecurityRecommended, true, true, true, "带 OAEP 填充,支持长明文自动分块"}, + RSA_OAEP: {"RSA-OAEP(SHA-256)", SecurityRecommended, true, true, true, "与 RSA 等价,保留独立取值便于配置区分"}, + + // ---- 编码 ---- + BASE64: {"Base64", SecurityRecommended, true, false, false, "不是加密,仅编码"}, + BASE64_URL: {"Base64-URL", SecurityRecommended, true, false, false, "URL 与文件名安全的 Base64"}, + HEX: {"Hex", SecurityRecommended, true, false, false, "不是加密,仅编码"}, +} + +// String 返回算法名称;未知取值返回带数值的占位串。 +func (t EncryptType) String() string { + if info, ok := typeInfos[t]; ok { + return info.name + } + return fmt.Sprintf("EncryptType(%d)", int(t)) +} + +// Valid 报告该算法是否受支持。 +func (t EncryptType) Valid() bool { + _, ok := typeInfos[t] + return ok +} + +// SecurityLevel 返回算法的安全等级。 +func (t EncryptType) SecurityLevel() SecurityLevel { + if info, ok := typeInfos[t]; ok { + return info.level + } + return SecurityBroken +} + +// IsReversible 报告算法是否可逆(即能否调用 Decrypt 还原)。 +func (t EncryptType) IsReversible() bool { + info, ok := typeInfos[t] + return ok && info.reversible +} + +// NeedsKey 报告算法是否需要密钥。 +func (t EncryptType) NeedsKey() bool { + info, ok := typeInfos[t] + return ok && info.needsKey +} + +// IsAEAD 报告算法是否提供完整性保护(AEAD 或消息认证码)。 +func (t EncryptType) IsAEAD() bool { + info, ok := typeInfos[t] + return ok && info.aead +} + +// Note 返回该算法的补充说明(安全提示、使用建议),可能为空。 +func (t EncryptType) Note() string { + return typeInfos[t].note +} + +// IsDeprecated 报告算法是否已不推荐使用(含"已攻破"与"遗留"两级)。 +func (t EncryptType) IsDeprecated() bool { + return t.SecurityLevel() <= SecurityLegacy +} + +// GetEncryptTypeName 返回算法名称,兼容历史 API。 +// +// 新代码请直接使用 EncryptType.String。 +func GetEncryptTypeName(encryptType EncryptType) string { + return encryptType.String() +} + +// GetAllEncryptTypes 返回全部受支持的算法,按数值升序。 +func GetAllEncryptTypes() []EncryptType { + types := make([]EncryptType, 0, len(typeInfos)) + for t := range typeInfos { + types = append(types, t) + } + sort.Slice(types, func(i, j int) bool { return int(types[i]) < int(types[j]) }) + return types +} + +// 门面层错误。调用方可用 errors.Is 精确判断。 +var ( + // ErrUnsupportedType 表示算法取值不在支持范围内。 + ErrUnsupportedType = errors.New("encryptx: unsupported algorithm") + // ErrIrreversible 表示对所给算法调用了 Decrypt,但该算法不可逆。 + ErrIrreversible = errors.New("encryptx: algorithm is not reversible") + // ErrMissingKey 表示算法需要密钥但未提供。 + ErrMissingKey = errors.New("encryptx: key is required") + // ErrMissingOption 表示缺少必需的选项。 + ErrMissingOption = errors.New("encryptx: required option is missing") + // ErrUnknownOption 表示传入了该算法不认识的选项。 + // + // 历史版本会静默忽略拼错或多余的选项,导致「以为设置了参数、实际用的却是 + // 默认值」这类安全隐患。现在一律报错。 + ErrUnknownOption = errors.New("encryptx: unknown option") + // ErrInvalidOption 表示选项类型不正确。 + ErrInvalidOption = errors.New("encryptx: invalid option type") + // ErrInvalidInput 表示输入数据格式非法(如非法的十六进制密文、长度不足的密文)。 + ErrInvalidInput = errors.New("encryptx: invalid input") + // ErrUnexpectedKey 表示给一个不使用密钥的算法传入了密钥。 + // + // 典型场景:把 BASE64、哈希或口令哈希误当成加密算法,却仍然按加密的方式 + // 传了密钥。历史版本会静默忽略这个密钥,让调用方以为数据受到了保护。 + ErrUnexpectedKey = errors.New("encryptx: unexpected key for a keyless algorithm") +) + +// Options 承载算法的额外参数。 +// +// 取值务必使用与本包配套的构造函数(如 IntOption、BytesOption)或直接传 +// int/string/bool/[]byte,以免类型不符。所有取值都会在解析时校验: +// 类型不对或键名不认识都会返回错误,不再静默回退到默认值。 +type Options map[string]any + +// Int 读取一个整数选项。第二个返回值表示该键是否存在。 +// +// 接受 int、int64,以及能精确表示为整数的 float64(便于处理 JSON 解码结果)。 +func (o Options) Int(key string) (int, bool, error) { + raw, ok := o[key] + if !ok { + return 0, false, nil + } + switch v := raw.(type) { + case int: + return v, true, nil + case int64: + return int(v), true, nil + case float64: + if v != float64(int(v)) { + return 0, false, fmt.Errorf("%w: 选项 %q 的值 %v 不是整数", ErrInvalidOption, key, v) + } + return int(v), true, nil + default: + return 0, false, fmt.Errorf("%w: 选项 %q 期望整数, 得到 %T", ErrInvalidOption, key, raw) + } +} + +// String 读取一个字符串选项。 +func (o Options) String(key string) (string, bool, error) { + raw, ok := o[key] + if !ok { + return "", false, nil + } + v, ok := raw.(string) + if !ok { + return "", false, fmt.Errorf("%w: 选项 %q 期望字符串, 得到 %T", ErrInvalidOption, key, raw) + } + return v, true, nil +} + +// Bool 读取一个布尔选项。 +func (o Options) Bool(key string) (bool, bool, error) { + raw, ok := o[key] + if !ok { + return false, false, nil + } + v, ok := raw.(bool) + if !ok { + return false, false, fmt.Errorf("%w: 选项 %q 期望布尔值, 得到 %T", ErrInvalidOption, key, raw) + } + return v, true, nil +} + +// Bytes 读取一个二进制选项,接受 []byte 或十六进制/Base64 字符串。 +func (o Options) Bytes(key string) ([]byte, bool, error) { + raw, ok := o[key] + if !ok { + return nil, false, nil + } + switch v := raw.(type) { + case []byte: + return v, true, nil + case string: + decoded, err := decodeOptionBytes(key, v) if err != nil { - return "", err + return nil, false, err } - return ciphertext + ":" + nonce, nil - - // 非对称加密算法 - case RSA: - publicKeyPath, ok := options["publicKeyPath"].(string) - if !ok { - return "", fmt.Errorf("RSA加密需要publicKeyPath选项") - } - cipherBytes := rsax.RSA_Encrypt([]byte(data), publicKeyPath) - return base64.StdEncoding.EncodeToString(cipherBytes), nil - - // 消息认证码 - case HMAC_SHA256: - return hmacx.HMACSHA256(data, key), nil - case HMAC_SHA512: - return hmacx.HMACSHA512(data, key), nil - - // 密钥派生 - case PBKDF2: - salt, ok := options["salt"].(string) - if !ok { - // 如果没有提供盐值,生成一个随机盐值 - saltBytes := make([]byte, 16) - if _, err := rand.Read(saltBytes); err != nil { - return "", fmt.Errorf("生成随机盐值失败: %v", err) - } - salt = hex.EncodeToString(saltBytes) - } - - iterations, ok := options["iterations"].(int) - if !ok { - iterations = 10000 // 默认迭代次数 - } - - keyLength, ok := options["keyLength"].(int) - if !ok { - keyLength = 32 // 默认密钥长度(32字节) - } - - return pbkdf2x.DeriveKeyFromPassword(data, salt, iterations, keyLength) - - // 编码 - case BASE64: - return base64x.StdEncode(data), nil - + return decoded, true, nil default: - return "", fmt.Errorf("不支持的加密类型: %d", encryptType) + return nil, false, fmt.Errorf("%w: 选项 %q 期望 []byte 或字符串, 得到 %T", ErrInvalidOption, key, raw) } } -// Decrypt 解密数据 -// encryptType: 加密类型 -// data: 要解密的数据 -// key: 密钥(某些算法需要) -// options: 额外选项(如盐值、迭代次数等) -func Decrypt(encryptType EncryptType, data string, key string, options map[string]interface{}) (string, error) { - switch encryptType { - // 哈希算法(不可逆) - case MD5, SHA1, SHA256, SHA512, SHA3_256, SHA3_384, SHA3_512: - return "", fmt.Errorf("哈希算法不可逆") - - // 对称加密算法 - case DES_ECB: - return desx.DecryptECBHex(data, key) - case DES_CBC: - return desx.DecryptCBCHex(data, key) - case DES_CFB: - return desx.DecryptCFBHex(data, key) - case TDES_ECB: - return tdesx.DecryptECBHex(data, key) - case TDES_CBC: - return tdesx.DecryptCBCHex(data, key) - case TDES_CFB: - return tdesx.DecryptCFBHex(data, key) - case AES_ECB: - return aesx.DecryptECBHex(data, key) - case AES_CBC: - return aesx.DecryptCBCHex(data, key) - case AES_CFB: - return aesx.DecryptCFBHex(data, key) - case AES_GCM: - parts := strings.Split(data, ":") - if len(parts) != 2 { - return "", fmt.Errorf("AES-GCM解密需要格式为'密文:nonce'") - } - return aesx.DecryptGCM(key, parts[0], parts[1]) - - // 非对称加密算法 - case RSA: - privateKeyPath, ok := options["privateKeyPath"].(string) - if !ok { - return "", fmt.Errorf("RSA解密需要privateKeyPath选项") - } - cipherBytes, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return "", fmt.Errorf("Base64解码失败: %v", err) - } - plainBytes := rsax.RSA_Decrypt(cipherBytes, privateKeyPath) - return string(plainBytes), nil - - // 消息认证码(不可逆) - case HMAC_SHA256, HMAC_SHA512: - return "", fmt.Errorf("消息认证码不可逆") - - // 密钥派生(不可逆) - case PBKDF2: - return "", fmt.Errorf("密钥派生不可逆") - - // 编码 - case BASE64: - return base64x.StdDecode(data) - - default: - return "", fmt.Errorf("不支持的加密类型: %d", encryptType) +// rejectUnknown 校验没有传入未声明的选项,避免参数被静默忽略。 +func (o Options) rejectUnknown(allowed ...string) error { + if len(o) == 0 { + return nil } -} - -// GenerateKey 生成密钥 -// encryptType: 加密类型 -// options: 额外选项 -func GenerateKey(encryptType EncryptType, options map[string]interface{}) (string, error) { - switch encryptType { - // 哈希算法(不需要密钥) - case MD5, SHA1, SHA256, SHA512, SHA3_256, SHA3_384, SHA3_512: - return "", fmt.Errorf("哈希算法不需要密钥") - - // 对称加密算法 - case DES_ECB, DES_CBC, DES_CFB: - // DES密钥长度为8字节 - keyBytes := make([]byte, 8) - if _, err := rand.Read(keyBytes); err != nil { - return "", fmt.Errorf("生成DES密钥失败: %v", err) - } - return hex.EncodeToString(keyBytes), nil - - case TDES_ECB, TDES_CBC, TDES_CFB: - // 3DES密钥长度为24字节 - keyBytes := make([]byte, 24) - if _, err := rand.Read(keyBytes); err != nil { - return "", fmt.Errorf("生成3DES密钥失败: %v", err) - } - return hex.EncodeToString(keyBytes), nil - - case AES_ECB, AES_CBC, AES_CFB, AES_GCM: - // AES密钥长度为16、24或32字节 - keyLength, ok := options["keyLength"].(int) - if !ok { - keyLength = 32 // 默认使用AES-256 - } - if keyLength != 16 && keyLength != 24 && keyLength != 32 { - return "", fmt.Errorf("AES密钥长度必须为16、24或32字节") - } - keyBytes := make([]byte, keyLength) - if _, err := rand.Read(keyBytes); err != nil { - return "", fmt.Errorf("生成AES密钥失败: %v", err) - } - return hex.EncodeToString(keyBytes), nil - - case BLOWFISH_ECB, BLOWFISH_CBC, BLOWFISH_CFB: - // Blowfish密钥长度可以是4-56字节 - keyLength, ok := options["keyLength"].(int) - if !ok { - keyLength = 16 // 默认使用16字节 - } - if keyLength < 4 || keyLength > 56 { - return "", fmt.Errorf("Blowfish密钥长度必须为4-56字节") - } - keyBytes := make([]byte, keyLength) - if _, err := rand.Read(keyBytes); err != nil { - return "", fmt.Errorf("生成Blowfish密钥失败: %v", err) - } - return hex.EncodeToString(keyBytes), nil - - // 非对称加密算法 - case RSA: - bits, ok := options["bits"].(int) - if !ok { - bits = 2048 // 默认使用2048位 - } - rsax.GenerateRSAKey(bits) - return "RSA密钥已生成并保存到文件", nil - - // 消息认证码 - case HMAC_SHA256, HMAC_SHA512: - // HMAC密钥长度可以是任意值 - keyLength, ok := options["keyLength"].(int) - if !ok { - keyLength = 32 // 默认使用32字节 - } - keyBytes := make([]byte, keyLength) - if _, err := rand.Read(keyBytes); err != nil { - return "", fmt.Errorf("生成HMAC密钥失败: %v", err) - } - return hex.EncodeToString(keyBytes), nil - - // 密钥派生 - case PBKDF2: - // PBKDF2需要盐值 - saltLength, ok := options["saltLength"].(int) - if !ok { - saltLength = 16 // 默认使用16字节盐值 - } - return utilsx.GenerateRandomHexString(saltLength) - - // 编码 - case BASE64: - return "", fmt.Errorf("Base64编码不需要密钥") - - default: - return "", fmt.Errorf("不支持的加密类型: %d", encryptType) + known := make(map[string]struct{}, len(allowed)) + for _, k := range allowed { + known[k] = struct{}{} } + var unknown []string + for k := range o { + if _, ok := known[k]; !ok { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return fmt.Errorf("%w: %v (该算法接受的选项: %v)", ErrUnknownOption, unknown, allowed) + } + return nil } diff --git a/encryptx_fuzz_test.go b/encryptx_fuzz_test.go new file mode 100644 index 0000000..41cc887 --- /dev/null +++ b/encryptx_fuzz_test.go @@ -0,0 +1,177 @@ +package encryptx_test + +import ( + "strings" + "testing" + + "code.yun.ink/pkg/encryptx" +) + +// 本文件提供 Go 原生 fuzz 目标。它们有两个作用: +// +// 1. 普通 `go test` 时以种子语料运行,作为常规回归测试; +// 2. `go test -fuzz=FuzzXxx` 时持续变异输入,穷举解码/解密路径上的边界。 +// +// 之所以重点 fuzz 解密路径:这些函数的输入直接来自网络或数据库等不可信来源, +// 且历史上正是在这里出现过 "slice bounds out of range" 崩溃。 +// +// 有意排除 RSA 与 PBKDF2/scrypt/Argon2:它们单次调用开销过大,会让 fuzz 的 +// 变异吞吐低到没有意义。这些算法的错误路径由各自的单元测试覆盖。 + +// fuzzTargets 是适合高频 fuzz 的算法:对称加密与编码。 +func fuzzTargets() []encryptx.EncryptType { + targets := make([]encryptx.EncryptType, 0, len(symmetricTypes)+3) + targets = append(targets, symmetricTypes...) + targets = append(targets, encryptx.BASE64, encryptx.BASE64_URL, encryptx.HEX) + return targets +} + +// precomputedKeys 预生成各算法的密钥,避免每次 fuzz 迭代重复生成。 +// 不需要密钥的算法(编码类)不参与。 +func precomputedKeys(f *testing.F) map[encryptx.EncryptType][]byte { + f.Helper() + keys := make(map[encryptx.EncryptType][]byte) + for _, encryptType := range fuzzTargets() { + if !encryptType.NeedsKey() { + continue + } + key, err := encryptx.GenerateKey(encryptType, nil) + if err != nil { + f.Fatalf("%s: GenerateKey 失败: %v", encryptType, err) + } + keys[encryptType] = key + } + return keys +} + +// FuzzDecryptNeverPanics 是 P0-3 的模糊测试形态。 +// +// 无论喂进什么字符串,解密都必须安全返回,绝不能因为切片越界、除零或空指针 +// 而崩溃——攻击者只要能构造一段畸形的十六进制密文,就能用 panic 打掉服务。 +func FuzzDecryptNeverPanics(f *testing.F) { + keys := precomputedKeys(f) + targets := fuzzTargets() + + // 种子语料:空、奇数长度、非十六进制、非分组对齐、超长、含分隔符 + f.Add("") + f.Add("A") + f.Add("AB") + f.Add("zz") + f.Add(":::") + f.Add("0:0") + f.Add(strings.Repeat("AB", 7)) + f.Add(strings.Repeat("AB", 8)) + f.Add(strings.Repeat("AB", 9)) + f.Add(strings.Repeat("AB", 17)) + f.Add(strings.Repeat("FF", 64)) + f.Add("not-hex-at-all") + + f.Fuzz(func(t *testing.T, data string) { + for _, encryptType := range targets { + // 只要求"不 panic";返回值无论是什么都可以接受。 + _, _ = encryptx.Decrypt(encryptType, data, keys[encryptType], nil) + } + }) +} + +// FuzzDecryptWithIVNeverPanics 覆盖带显式 IV 的解密路径。 +// +// IV 由选项传入,长度可能任意;这条路径额外接受一个十六进制字符串, +// 是另一个容易越界的入口。 +func FuzzDecryptWithIVNeverPanics(f *testing.F) { + keys := precomputedKeys(f) + // 只挑接受 iv 选项的模式(ECB 不接受) + targets := []encryptx.EncryptType{ + encryptx.AES_CBC, encryptx.AES_CFB, encryptx.AES_OFB, encryptx.AES_CTR, + encryptx.DES_CBC, encryptx.TDES_CBC, + } + + f.Add("AB", "0011223344556677") + f.Add("", "") + f.Add(strings.Repeat("AB", 20), "zz") + + f.Fuzz(func(t *testing.T, data, iv string) { + for _, encryptType := range targets { + _, _ = encryptx.Decrypt(encryptType, data, keys[encryptType], + encryptx.Options{"iv": iv}) + } + }) +} + +// FuzzSymmetricRoundTrip 验证「加密再解密必然还原」这一基本性质对任意明文成立, +// 包括空串、跨分组边界长度与多字节 UTF-8。 +func FuzzSymmetricRoundTrip(f *testing.F) { + keys := precomputedKeys(f) + + f.Add("") + f.Add("a") + f.Add(strings.Repeat("x", 15)) + f.Add(strings.Repeat("x", 16)) + f.Add(strings.Repeat("x", 17)) + f.Add("中文与 emoji 🔐") + f.Add(strings.Repeat("payload", 100)) + + f.Fuzz(func(t *testing.T, plaintext string) { + for _, encryptType := range symmetricTypes { + key := keys[encryptType] + ciphertext, err := encryptx.Encrypt(encryptType, plaintext, key, nil) + if err != nil { + t.Fatalf("%s: 加密失败: %v", encryptType, err) + } + decrypted, err := encryptx.Decrypt(encryptType, ciphertext, key, nil) + if err != nil { + t.Fatalf("%s: 解密失败: %v", encryptType, err) + } + if decrypted != plaintext { + t.Fatalf("%s: 往返不一致(明文长度 %d)", encryptType, len(plaintext)) + } + } + }) +} + +// FuzzAEADTamperDetection 验证 AEAD 的认证性质:对密文做任何单字节改动, +// 解密都必须失败。 +func FuzzAEADTamperDetection(f *testing.F) { + keys := precomputedKeys(f) + aeads := []encryptx.EncryptType{ + encryptx.AES_GCM, encryptx.CHACHA20_POLY1305, encryptx.XCHACHA20_POLY1305, + } + + f.Add("payload", 0) + f.Add("", 3) + f.Add(strings.Repeat("x", 200), 42) + + f.Fuzz(func(t *testing.T, plaintext string, offset int) { + for _, encryptType := range aeads { + key := keys[encryptType] + ciphertext, err := encryptx.Encrypt(encryptType, plaintext, key, encryptx.Options{"aad": "ctx"}) + if err != nil { + t.Fatalf("%s: 加密失败: %v", encryptType, err) + } + if len(ciphertext) == 0 { + continue + } + + // 把 offset 映射到合法下标,并避开分隔符 + idx := offset % len(ciphertext) + if idx < 0 { + idx += len(ciphertext) + } + if ciphertext[idx] == ':' { + continue + } + + tampered := []byte(ciphertext) + if tampered[idx] == '0' { + tampered[idx] = '1' + } else { + tampered[idx] = '0' + } + + if _, err := encryptx.Decrypt(encryptType, string(tampered), key, + encryptx.Options{"aad": "ctx"}); err == nil { + t.Fatalf("%s: 篡改第 %d 个字符后仍然解密成功", encryptType, idx) + } + } + }) +} diff --git a/encryptx_regression_test.go b/encryptx_regression_test.go new file mode 100644 index 0000000..1d8c4b7 --- /dev/null +++ b/encryptx_regression_test.go @@ -0,0 +1,214 @@ +package encryptx_test + +import ( + "bytes" + "errors" + "strings" + "testing" + + "code.yun.ink/pkg/encryptx" + "code.yun.ink/pkg/encryptx/aesx" + "code.yun.ink/pkg/encryptx/chachax" +) + +// 本文件固化第二轮复查中发现的问题,防止回归。 + +// TestUnexpectedKeyRejected 验证给不使用密钥的算法传密钥会被明确拒绝。 +// +// 旧行为是静默忽略:调用方把 BASE64 或哈希误当成加密算法时,会以为数据已经 +// 受到密钥保护,实际上完全没有。这类"看起来做了加密"的误用必须报错。 +func TestUnexpectedKeyRejected(t *testing.T) { + key := bytes.Repeat([]byte{0x01}, 32) + + keyless := []encryptx.EncryptType{ + encryptx.MD5, encryptx.SHA256, encryptx.SHA3_512, encryptx.BLAKE2B_256, + encryptx.BASE64, encryptx.BASE64_URL, encryptx.HEX, + encryptx.BCRYPT, encryptx.ARGON2ID, + encryptx.PBKDF2, + } + + for _, encryptType := range keyless { + options := encryptx.Options{} + if encryptType == encryptx.PBKDF2 { + // PBKDF2 需要盐值,否则会先报缺参,掩盖我们要测的分支 + options["salt"] = bytes.Repeat([]byte{0x02}, 16) + options["iterations"] = 10000 + } + if encryptType == encryptx.ARGON2ID { + options["time"] = 1 + options["memory"] = 8 * 1024 + options["threads"] = 1 + } + + _, err := encryptx.Encrypt(encryptType, "payload", key, options) + if !errors.Is(err, encryptx.ErrUnexpectedKey) { + t.Errorf("%s 传入密钥: 期望 ErrUnexpectedKey, 得到 %v", encryptType, err) + } + } + + // 反向:真正需要密钥的算法仍然照常工作 + aesKey, err := encryptx.GenerateKey(encryptx.AES_GCM, nil) + if err != nil { + t.Fatalf("GenerateKey 失败: %v", err) + } + if _, err := encryptx.Encrypt(encryptx.AES_GCM, "payload", aesKey, nil); err != nil { + t.Errorf("AES-GCM 带密钥加密失败: %v", err) + } +} + +// TestMalformedCiphertextClassifiedAsInvalidInput 验证门面的错误分类自洽。 +// +// 密文的编码与长度问题都应该能通过 errors.Is(err, ErrInvalidInput) 命中, +// 这样调用方才有一套统一的"输入不合法"判定标准。 +func TestMalformedCiphertextClassifiedAsInvalidInput(t *testing.T) { + aesKey, _ := encryptx.GenerateKey(encryptx.AES_CBC, nil) + gcmKey, _ := encryptx.GenerateKey(encryptx.AES_GCM, nil) + chachaKey, _ := encryptx.GenerateKey(encryptx.CHACHA20_POLY1305, nil) + + cases := []struct { + name string + encryptType encryptx.EncryptType + key []byte + input string + }{ + {"CBC 空密文", encryptx.AES_CBC, aesKey, ""}, + {"CBC 过短密文", encryptx.AES_CBC, aesKey, "AB"}, + {"CBC 非分组对齐", encryptx.AES_CBC, aesKey, strings.Repeat("AB", 17)}, + {"CBC 非法十六进制", encryptx.AES_CBC, aesKey, "zz"}, + {"ECB 非分组对齐", encryptx.AES_ECB, aesKey, strings.Repeat("AB", 17)}, + {"GCM 缺少分隔符", encryptx.AES_GCM, gcmKey, "abcd"}, + {"GCM nonce 长度错误", encryptx.AES_GCM, gcmKey, "abcd:ef"}, + {"ChaCha 缺少分隔符", encryptx.CHACHA20_POLY1305, chachaKey, "abcd"}, + {"Base64 非法", encryptx.BASE64, nil, "!!!!"}, + {"Hex 奇数长度", encryptx.HEX, nil, "ABC"}, + } + + for _, c := range cases { + _, err := encryptx.Decrypt(c.encryptType, c.input, c.key, nil) + if err == nil { + t.Errorf("%s: 期望错误, 得到 nil", c.name) + continue + } + if !errors.Is(err, encryptx.ErrInvalidInput) { + t.Errorf("%s: 期望 ErrInvalidInput, 得到 %v", c.name, err) + } + } +} + +// TestAuthenticationFailureStaysDistinguishable 验证认证失败不会被混进 +// "输入格式错误"里。 +// +// 若把"密文被篡改"也归类为 ErrInvalidInput,调用方就无法把它和参数写错区分开, +// 真正的安全告警会被淹没在普通错误中——这比分类不统一更危险。 +func TestAuthenticationFailureStaysDistinguishable(t *testing.T) { + cases := []struct { + encryptType encryptx.EncryptType + sentinel error + }{ + {encryptx.AES_GCM, aesx.ErrAuthenticationFailed}, + {encryptx.CHACHA20_POLY1305, chachax.ErrAuthenticationFailed}, + {encryptx.XCHACHA20_POLY1305, chachax.ErrAuthenticationFailed}, + } + + for _, c := range cases { + key, _ := encryptx.GenerateKey(c.encryptType, nil) + ciphertext, err := encryptx.Encrypt(c.encryptType, "authenticated payload", key, nil) + if err != nil { + t.Fatalf("%s: %v", c.encryptType, err) + } + + // 翻转密文中的一个字符,制造认证失败 + tampered := []byte(ciphertext) + for i := range tampered { + if tampered[i] != ':' { + if tampered[i] == '0' { + tampered[i] = '1' + } else { + tampered[i] = '0' + } + break + } + } + + _, err = encryptx.Decrypt(c.encryptType, string(tampered), key, nil) + if !errors.Is(err, c.sentinel) { + t.Errorf("%s 篡改: 期望 %v, 得到 %v", c.encryptType, c.sentinel, err) + } + if errors.Is(err, encryptx.ErrInvalidInput) { + t.Errorf("%s 篡改被错误地归类为 ErrInvalidInput: %v", c.encryptType, err) + } + } +} + +// TestHKDFConsistentBetweenEncryptAndDeriveKey 验证两条 HKDF 路径对空盐的态度一致。 +// +// 复查发现 Encrypt 强制要求 salt 选项,而 DeriveKey 允许缺省(HKDF 的空盐在标准 +// 中等价于全零盐),同一算法两条入口行为不一致会误导调用方。 +func TestHKDFConsistentBetweenEncryptAndDeriveKey(t *testing.T) { + secret := []byte("high-entropy-secret-material") + + // DeriveKey 允许空盐,并返回 nil 盐 + key, salt, err := encryptx.DeriveKey(encryptx.HKDF, secret, nil) + if err != nil { + t.Fatalf("DeriveKey(HKDF) 无盐失败: %v", err) + } + if salt != nil { + t.Errorf("HKDF 空盐应当返回 nil, 得到 %v", salt) + } + + // Encrypt 也必须允许空盐,且结果与 DeriveKey 一致 + derived, err := encryptx.Encrypt(encryptx.HKDF, string(secret), nil, nil) + if err != nil { + t.Fatalf("Encrypt(HKDF) 无盐失败: %v", err) + } + if !strings.EqualFold(derived, hexEncode(key)) { + t.Errorf("Encrypt(HKDF) 与 DeriveKey(HKDF) 结果不一致: %q vs %q", derived, hexEncode(key)) + } + + // 其余 KDF 仍然必须显式给盐 + for _, encryptType := range []encryptx.EncryptType{encryptx.PBKDF2, encryptx.SCRYPT} { + if _, err := encryptx.Encrypt(encryptType, "password", nil, + fastKDFOptions(encryptType)); !errors.Is(err, encryptx.ErrMissingOption) { + t.Errorf("%s 缺盐: 期望 ErrMissingOption, 得到 %v", encryptType, err) + } + } +} + +// TestGenerateKeyForAllKDFs 验证四种 KDF 都能生成盐值。 +// +// 复查发现 ARGON2ID 被排除在盐值生成之外,而它同样是需要盐的 KDF(DeriveKey +// 路径),同一类算法行为不一致。 +func TestGenerateKeyForAllKDFs(t *testing.T) { + for _, encryptType := range kdfTypes { + salt, err := encryptx.GenerateKey(encryptType, encryptx.Options{"saltLength": 16}) + if err != nil { + t.Errorf("%s: GenerateKey 失败: %v", encryptType, err) + continue + } + if len(salt) != 16 { + t.Errorf("%s: 盐值长度 = %d, 期望 16", encryptType, len(salt)) + } + + // 生成的盐必须能用于派生 + options := fastKDFOptions(encryptType) + options["salt"] = salt + if _, _, err := encryptx.DeriveKey(encryptType, []byte("password"), options); err != nil { + t.Errorf("%s: 用生成的盐派生失败: %v", encryptType, err) + } + } + + // bcrypt 的盐内嵌在哈希结果中,仍然应当拒绝生成 + if _, err := encryptx.GenerateKey(encryptx.BCRYPT, nil); err == nil { + t.Error("GenerateKey(BCRYPT) 应当返回错误") + } +} + +// hexEncode 是测试用的小工具,避免为一行格式化再引入依赖。 +func hexEncode(data []byte) string { + const digits = "0123456789abcdef" + out := make([]byte, 0, len(data)*2) + for _, b := range data { + out = append(out, digits[b>>4], digits[b&0x0f]) + } + return string(out) +} diff --git a/encryptx_test.go b/encryptx_test.go index a506ebc..0cdb039 100644 --- a/encryptx_test.go +++ b/encryptx_test.go @@ -1,299 +1,1068 @@ -package encryptx +package encryptx_test import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" "testing" + + "code.yun.ink/pkg/encryptx" + "code.yun.ink/pkg/encryptx/rsax" ) -func TestGetEncryptTypeName(t *testing.T) { - // 测试获取加密类型名称 - if name := GetEncryptTypeName(MD5); name != "MD5" { - t.Errorf("MD5类型名称错误,期望: MD5, 实际: %s", name) - } - - if name := GetEncryptTypeName(AES_CBC); name != "AES-CBC" { - t.Errorf("AES-CBC类型名称错误,期望: AES-CBC, 实际: %s", name) - } - - if name := GetEncryptTypeName(RSA); name != "RSA" { - t.Errorf("RSA类型名称错误,期望: RSA, 实际: %s", name) - } +// 所有对称加密算法(含 AEAD),用于表驱动测试。 +var symmetricTypes = []encryptx.EncryptType{ + encryptx.DES_ECB, encryptx.DES_CBC, encryptx.DES_CFB, encryptx.DES_OFB, encryptx.DES_CTR, + encryptx.TDES_ECB, encryptx.TDES_CBC, encryptx.TDES_CFB, encryptx.TDES_OFB, encryptx.TDES_CTR, + encryptx.AES_ECB, encryptx.AES_CBC, encryptx.AES_CFB, encryptx.AES_OFB, encryptx.AES_CTR, + encryptx.AES_GCM, + encryptx.BLOWFISH_ECB, encryptx.BLOWFISH_CBC, encryptx.BLOWFISH_CFB, + encryptx.BLOWFISH_OFB, encryptx.BLOWFISH_CTR, + encryptx.CHACHA20_POLY1305, encryptx.XCHACHA20_POLY1305, } -func TestGetAllEncryptTypes(t *testing.T) { - // 测试获取所有加密类型 - types := GetAllEncryptTypes() - if len(types) != len(encryptTypeNames) { - t.Errorf("加密类型数量不匹配,期望: %d, 实际: %d", len(encryptTypeNames), len(types)) - } +var hashTypes = []encryptx.EncryptType{ + encryptx.MD5, encryptx.SHA1, encryptx.SHA224, encryptx.SHA256, encryptx.SHA384, encryptx.SHA512, + encryptx.SHA3_224, encryptx.SHA3_256, encryptx.SHA3_384, encryptx.SHA3_512, + encryptx.BLAKE2B_256, encryptx.BLAKE2S_256, encryptx.RIPEMD160, +} - // 检查是否包含所有类型 - for _, tt := range types { - if _, ok := encryptTypeNames[tt]; !ok { - t.Errorf("未知的加密类型: %d", tt) +var hmacTypes = []encryptx.EncryptType{ + encryptx.HMAC_SHA1, encryptx.HMAC_SHA256, encryptx.HMAC_SHA384, encryptx.HMAC_SHA512, +} + +var kdfTypes = []encryptx.EncryptType{ + encryptx.PBKDF2, encryptx.HKDF, encryptx.SCRYPT, encryptx.ARGON2ID, +} + +// ---- 枚举元数据 ---- + +func TestEncryptTypeMetadata(t *testing.T) { + // 历史数值必须保持不变,否则已落库的配置会指向错误的算法。 + legacyValues := map[encryptx.EncryptType]int{ + encryptx.MD5: 0, encryptx.SHA1: 1, encryptx.SHA256: 2, encryptx.SHA512: 3, + encryptx.SHA3_256: 4, encryptx.SHA3_384: 5, encryptx.SHA3_512: 6, + encryptx.DES_ECB: 7, encryptx.DES_CBC: 8, encryptx.DES_CFB: 9, + encryptx.TDES_ECB: 10, encryptx.TDES_CBC: 11, encryptx.TDES_CFB: 12, + encryptx.AES_ECB: 13, encryptx.AES_CBC: 14, encryptx.AES_CFB: 15, encryptx.AES_GCM: 16, + encryptx.BLOWFISH_ECB: 17, encryptx.BLOWFISH_CBC: 18, encryptx.BLOWFISH_CFB: 19, + encryptx.RSA: 20, encryptx.HMAC_SHA256: 21, encryptx.HMAC_SHA512: 22, + encryptx.PBKDF2: 23, encryptx.BASE64: 24, + } + for encryptType, want := range legacyValues { + if got := int(encryptType); got != want { + t.Errorf("%s 的数值 = %d, 期望 %d(破坏兼容性)", encryptType, got, want) } } } -func TestEncryptHashAlgorithms(t *testing.T) { - data := "test data" +func TestGetAllEncryptTypes(t *testing.T) { + types := encryptx.GetAllEncryptTypes() + if len(types) < 40 { + t.Errorf("算法数量 = %d, 期望至少 40 个", len(types)) + } + for i := 1; i < len(types); i++ { + if types[i-1] >= types[i] { + t.Errorf("GetAllEncryptTypes 未按升序返回: 位置 %d", i) + } + } + for _, encryptType := range types { + if !encryptType.Valid() { + t.Errorf("%d.Valid() = false", int(encryptType)) + } + if encryptType.String() == "" { + t.Errorf("%d 的名称为空", int(encryptType)) + } + } +} - // 测试哈希算法 - md5Hash, err := Encrypt(MD5, data, "", nil) - if err != nil { - t.Errorf("MD5加密失败: %v", err) +func TestSecurityMetadata(t *testing.T) { + // 已被攻破的算法必须被标记为 SecurityBroken + for _, encryptType := range []encryptx.EncryptType{encryptx.MD5, encryptx.SHA1, + encryptx.DES_ECB, encryptx.DES_CBC, encryptx.DES_CFB} { + if got := encryptType.SecurityLevel(); got != encryptx.SecurityBroken { + t.Errorf("%s 的安全等级 = %v, 期望已攻破", encryptType, got) + } + if !encryptType.IsDeprecated() { + t.Errorf("%s 应当被标记为已弃用", encryptType) + } } - sha1Hash, err := Encrypt(SHA1, data, "", nil) - if err != nil { - t.Errorf("SHA1加密失败: %v", err) + // 推荐的现代算法 + for _, encryptType := range []encryptx.EncryptType{encryptx.AES_GCM, encryptx.CHACHA20_POLY1305, + encryptx.XCHACHA20_POLY1305, encryptx.SHA256, encryptx.SHA3_256, encryptx.ARGON2ID} { + if got := encryptType.SecurityLevel(); got != encryptx.SecurityRecommended { + t.Errorf("%s 的安全等级 = %v, 期望推荐", encryptType, got) + } } - sha256Hash, err := Encrypt(SHA256, data, "", nil) - if err != nil { - t.Errorf("SHA256加密失败: %v", err) + // 只有 AEAD 类算法才应标记完整性保护 + if !encryptx.AES_GCM.IsAEAD() || !encryptx.CHACHA20_POLY1305.IsAEAD() { + t.Error("AEAD 算法未被标记为提供完整性保护") + } + if encryptx.AES_CBC.IsAEAD() { + t.Error("AES-CBC 不应被标记为提供完整性保护") } - sha512Hash, err := Encrypt(SHA512, data, "", nil) - if err != nil { - t.Errorf("SHA512加密失败: %v", err) + // 哈希不可逆、不需要密钥 + for _, encryptType := range hashTypes { + if encryptType.IsReversible() { + t.Errorf("%s 不应可逆", encryptType) + } + if encryptType.NeedsKey() { + t.Errorf("%s 不应需要密钥", encryptType) + } + } +} + +func TestUnsupportedType(t *testing.T) { + bogus := encryptx.EncryptType(9999) + if bogus.Valid() { + t.Error("EncryptType(9999).Valid() = true") + } + if _, err := encryptx.Encrypt(bogus, "x", nil, nil); !errors.Is(err, encryptx.ErrUnsupportedType) { + t.Errorf("Encrypt: 期望 ErrUnsupportedType, 得到 %v", err) + } + if _, err := encryptx.Decrypt(bogus, "x", nil, nil); !errors.Is(err, encryptx.ErrUnsupportedType) { + t.Errorf("Decrypt: 期望 ErrUnsupportedType, 得到 %v", err) + } + if _, err := encryptx.GenerateKey(bogus, nil); !errors.Is(err, encryptx.ErrUnsupportedType) { + t.Errorf("GenerateKey: 期望 ErrUnsupportedType, 得到 %v", err) + } +} + +// ---- 哈希 ---- + +func TestHashAlgorithms(t *testing.T) { + // 权威测试向量(与各子包测试一致,这里验证门面接线正确) + vectors := map[encryptx.EncryptType]string{ + encryptx.MD5: "900150983cd24fb0d6963f7d28e17f72", + encryptx.SHA1: "a9993e364706816aba3e25717850c26c9cd0d89d", + encryptx.SHA224: "23097d223405d8228642a477bda255b32aadbce4bda0b3f7e36c9da7", + encryptx.SHA256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + encryptx.SHA384: "cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7", + encryptx.SHA512: "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f", + encryptx.SHA3_224: "e642824c3f8cf24ad09234ee7d3c766fc9a3a5168d0c94ad73b46fdf", + encryptx.SHA3_256: "3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532", + encryptx.SHA3_384: "ec01498288516fc926459f58e2c6ad8df9b473cb0fc08c2596da7cf0e49be4b298d88cea927ac7f539f1edf228376d25", + encryptx.SHA3_512: "b751850b1a57168a5693cd924b6b096e08f621827444f70d884f5d0240d2712e10e116e9192af3c91a7ec57647e3934057340b4cf408d5a56592f8274eec53f0", + encryptx.BLAKE2B_256: "bddd813c634239723171ef3fee98579b94964e3bb1cb3e427262c8c068d52319", + encryptx.BLAKE2S_256: "508c5e8c327c14e2e1a72ba34eeb452f37458b209ed63a294d999b4c86675982", + encryptx.RIPEMD160: "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc", } - sha3_256Hash, err := Encrypt(SHA3_256, data, "", nil) - if err != nil { - t.Errorf("SHA3-256加密失败: %v", err) + for encryptType, want := range vectors { + got, err := encryptx.Encrypt(encryptType, "abc", nil, nil) + if err != nil { + t.Errorf("%s: 计算失败: %v", encryptType, err) + continue + } + if got != want { + t.Errorf("%s(\"abc\") = %s, 期望 %s", encryptType, got, want) + } } - sha3_384Hash, err := Encrypt(SHA3_384, data, "", nil) - if err != nil { - t.Errorf("SHA3-384加密失败: %v", err) + // 哈希不接受任何选项 + if _, err := encryptx.Encrypt(encryptx.SHA256, "x", nil, encryptx.Options{"bogus": 1}); !errors.Is(err, encryptx.ErrUnknownOption) { + t.Errorf("哈希传入未知选项: 期望 ErrUnknownOption, 得到 %v", err) + } +} + +func TestHashAlgorithmsProduceDistinctOutput(t *testing.T) { + seen := make(map[string]encryptx.EncryptType) + for _, encryptType := range hashTypes { + got, err := encryptx.Encrypt(encryptType, "same input", nil, nil) + if err != nil { + t.Fatalf("%s: %v", encryptType, err) + } + if prev, dup := seen[got]; dup { + t.Errorf("%s 与 %s 产生了相同摘要", encryptType, prev) + } + seen[got] = encryptType + } +} + +// ---- HMAC ---- + +func TestHMACAlgorithms(t *testing.T) { + // RFC 4231 Test Case 1:key = 20 字节 0x0b,data = "Hi There" + key := bytes.Repeat([]byte{0x0b}, 20) + + vectors := map[encryptx.EncryptType]string{ + encryptx.HMAC_SHA256: "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7", + encryptx.HMAC_SHA512: "87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cdedaa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854", + } + for encryptType, want := range vectors { + got, err := encryptx.Encrypt(encryptType, "Hi There", key, nil) + if err != nil { + t.Fatalf("%s: %v", encryptType, err) + } + if got != want { + t.Errorf("%s = %s, 期望 %s", encryptType, got, want) + } } - sha3_512Hash, err := Encrypt(SHA3_512, data, "", nil) - if err != nil { - t.Errorf("SHA3-512加密失败: %v", err) + // RFC 2202 未直接给出的 SHA-1/SHA-384 分支至少要有正确长度 + for _, tc := range []struct { + encryptType encryptx.EncryptType + hexLength int + }{ + {encryptx.HMAC_SHA1, 40}, {encryptx.HMAC_SHA384, 96}, + } { + got, err := encryptx.Encrypt(tc.encryptType, "Hi There", key, nil) + if err != nil { + t.Fatalf("%s: %v", tc.encryptType, err) + } + if len(got) != tc.hexLength { + t.Errorf("%s 长度 = %d, 期望 %d", tc.encryptType, len(got), tc.hexLength) + } } - // 确保所有哈希值都不同 - hashes := []string{md5Hash, sha1Hash, sha256Hash, sha512Hash, sha3_256Hash, sha3_384Hash, sha3_512Hash} - for i := 0; i < len(hashes); i++ { - for j := i + 1; j < len(hashes); j++ { - if hashes[i] == hashes[j] { - t.Errorf("不同的哈希算法产生了相同的哈希值") + // HMAC 需要密钥 + if _, err := encryptx.Encrypt(encryptx.HMAC_SHA256, "x", nil, nil); !errors.Is(err, encryptx.ErrMissingKey) { + t.Errorf("HMAC 无密钥: 期望 ErrMissingKey, 得到 %v", err) + } +} + +// ---- 编码 ---- + +func TestEncodingAlgorithms(t *testing.T) { + original := "hello 世界 + / = 编码" + + cases := []struct { + encryptType encryptx.EncryptType + }{ + {encryptx.BASE64}, + {encryptx.BASE64_URL}, + {encryptx.HEX}, + } + for _, c := range cases { + encoded, err := encryptx.Encrypt(c.encryptType, original, nil, nil) + if err != nil { + t.Fatalf("%s 编码失败: %v", c.encryptType, err) + } + decoded, err := encryptx.Decrypt(c.encryptType, encoded, nil, nil) + if err != nil { + t.Fatalf("%s 解码失败: %v", c.encryptType, err) + } + if decoded != original { + t.Errorf("%s 往返不一致: 得到 %q, 期望 %q", c.encryptType, decoded, original) + } + } + + // 非法输入必须报错而不是返回垃圾 + if _, err := encryptx.Decrypt(encryptx.HEX, "这不是十六进制", nil, nil); !errors.Is(err, encryptx.ErrInvalidInput) { + t.Errorf("非法 HEX: 期望 ErrInvalidInput, 得到 %v", err) + } + if _, err := encryptx.Decrypt(encryptx.BASE64, "!!!!", nil, nil); !errors.Is(err, encryptx.ErrInvalidInput) { + t.Errorf("非法 Base64: 期望 ErrInvalidInput, 得到 %v", err) + } +} + +// ---- 对称加密 ---- + +// TestGenerateKeyThenEncrypt 是 P0-2 的核心回归测试。 +// +// 历史缺陷:GenerateKey 返回十六进制文本(32 字节 → 64 字符),而加密函数把它 +// 当作原始密钥字节使用,于是「生成密钥 → 加密」必然失败,报 +// "crypto/aes: invalid key size 64"。现在密钥统一为 []byte,这条路径必须畅通。 +func TestGenerateKeyThenEncrypt(t *testing.T) { + const plaintext = "生成密钥后直接加密" + + for _, encryptType := range symmetricTypes { + key, err := encryptx.GenerateKey(encryptType, nil) + if err != nil { + t.Errorf("%s: GenerateKey 失败: %v", encryptType, err) + continue + } + if len(key) == 0 { + t.Errorf("%s: GenerateKey 返回空密钥", encryptType) + continue + } + + ciphertext, err := encryptx.Encrypt(encryptType, plaintext, key, nil) + if err != nil { + t.Errorf("%s: 用生成的密钥加密失败: %v", encryptType, err) + continue + } + decrypted, err := encryptx.Decrypt(encryptType, ciphertext, key, nil) + if err != nil { + t.Errorf("%s: 解密失败: %v", encryptType, err) + continue + } + if decrypted != plaintext { + t.Errorf("%s: 往返不一致, 得到 %q", encryptType, decrypted) + } + } +} + +func TestSymmetricRoundTripVariousLengths(t *testing.T) { + plaintexts := []string{"", "a", "0123456789abcde", "0123456789abcdef", + "0123456789abcdefg", strings.Repeat("x", 5000), "中文与 emoji 🔐"} + + for _, encryptType := range symmetricTypes { + key, err := encryptx.GenerateKey(encryptType, nil) + if err != nil { + t.Fatalf("%s: GenerateKey 失败: %v", encryptType, err) + } + for i, plaintext := range plaintexts { + ciphertext, err := encryptx.Encrypt(encryptType, plaintext, key, nil) + if err != nil { + t.Fatalf("%s 明文#%d: 加密失败: %v", encryptType, i, err) + } + decrypted, err := encryptx.Decrypt(encryptType, ciphertext, key, nil) + if err != nil { + t.Fatalf("%s 明文#%d: 解密失败: %v", encryptType, i, err) + } + if decrypted != plaintext { + t.Errorf("%s 明文#%d: 往返不一致", encryptType, i) } } } } -func TestEncryptSymmetricAlgorithms(t *testing.T) { - data := "test data" - key := "test_key_1234567" // 16字节,适用于AES - - // 测试对称加密算法 - aesECBEncrypted, err := Encrypt(AES_ECB, data, key, nil) - if err != nil { - t.Errorf("AES-ECB加密失败: %v", err) - } - - aesCBCEncrypted, err := Encrypt(AES_CBC, data, key, nil) - if err != nil { - t.Errorf("AES-CBC加密失败: %v", err) - } - - aesCFBEncrypted, err := Encrypt(AES_CFB, data, key, nil) - if err != nil { - t.Errorf("AES-CFB加密失败: %v", err) - } - - // 测试解密 - aesECBDecrypted, err := Decrypt(AES_ECB, aesECBEncrypted, key, nil) - if err != nil { - t.Errorf("AES-ECB解密失败: %v", err) - } - if aesECBDecrypted != data { - t.Errorf("AES-ECB解密结果不匹配,期望: %s, 实际: %s", data, aesECBDecrypted) - } - - aesCBCDecrypted, err := Decrypt(AES_CBC, aesCBCEncrypted, key, nil) - if err != nil { - t.Errorf("AES-CBC解密失败: %v", err) - } - if aesCBCDecrypted != data { - t.Errorf("AES-CBC解密结果不匹配,期望: %s, 实际: %s", data, aesCBCDecrypted) - } - - aesCFBDecrypted, err := Decrypt(AES_CFB, aesCFBEncrypted, key, nil) - if err != nil { - t.Errorf("AES-CFB解密失败: %v", err) - } - if aesCFBDecrypted != data { - t.Errorf("AES-CFB解密结果不匹配,期望: %s, 实际: %s", data, aesCFBDecrypted) +// TestSymmetricOutputHasNoWhitespace 是 P1-6 的回归测试。 +// +// 旧实现在 AES-GCM 的输出里用 fmt.Sprintf("%x\n", ...) 带上了换行符,再靠忽略 +// hex.DecodeString 的错误才勉强往返成功。现在输出必须干净。 +func TestSymmetricOutputHasNoWhitespace(t *testing.T) { + for _, encryptType := range symmetricTypes { + key, _ := encryptx.GenerateKey(encryptType, nil) + ciphertext, err := encryptx.Encrypt(encryptType, "check format", key, nil) + if err != nil { + t.Fatalf("%s: %v", encryptType, err) + } + if strings.ContainsAny(ciphertext, "\n\r\t ") { + t.Errorf("%s 的密文含空白字符: %q", encryptType, ciphertext) + } + if encryptType == encryptx.AES_GCM || + encryptType == encryptx.CHACHA20_POLY1305 || + encryptType == encryptx.XCHACHA20_POLY1305 { + if parts := strings.Split(ciphertext, ":"); len(parts) != 2 { + t.Errorf("%s 的密文格式应为 \"密文:nonce\", 得到 %q", encryptType, ciphertext) + } + } } } -func TestEncryptAsymmetricAlgorithms(t *testing.T) { - // RSA需要密钥文件,这里只测试函数调用不会崩溃 - data := "test data" - options := map[string]interface{}{ - "publicKeyPath": "public.pem", - } +func TestSymmetricWrongKeyDoesNotReturnPlaintext(t *testing.T) { + const plaintext = "secret payload" - // 测试RSA加密(可能会失败,因为没有密钥文件,但不应该崩溃) - _, err := Encrypt(RSA, data, "", options) - // 这里不检查错误,因为密钥文件可能不存在 - _ = err -} + for _, encryptType := range symmetricTypes { + key, _ := encryptx.GenerateKey(encryptType, nil) + otherKey, _ := encryptx.GenerateKey(encryptType, nil) -func TestEncryptMACAlgorithms(t *testing.T) { - data := "test data" - key := "test_key" - - // 测试消息认证码 - hmac256, err := Encrypt(HMAC_SHA256, data, key, nil) - if err != nil { - t.Errorf("HMAC-SHA256加密失败: %v", err) - } - - hmac512, err := Encrypt(HMAC_SHA512, data, key, nil) - if err != nil { - t.Errorf("HMAC-SHA512加密失败: %v", err) - } - - // 确保两种算法产生不同的结果 - if hmac256 == hmac512 { - t.Errorf("HMAC-SHA256和HMAC-SHA512产生了相同的结果") + ciphertext, err := encryptx.Encrypt(encryptType, plaintext, key, nil) + if err != nil { + t.Fatalf("%s: %v", encryptType, err) + } + // 允许返回错误,也允许返回垃圾,但绝不能还原出正确明文 + decrypted, err := encryptx.Decrypt(encryptType, ciphertext, otherKey, nil) + if err == nil && decrypted == plaintext { + t.Errorf("%s: 错误密钥解出了正确明文", encryptType) + } } } -func TestEncryptPBKDF2(t *testing.T) { - password := "test_password" - options := map[string]interface{}{ - "salt": "test_salt", - "iterations": 1000, - "keyLength": 32, - } +func TestSymmetricWrongKeySizeRejected(t *testing.T) { + // 57 字节对 DES(8)、3DES(24)、AES(16/24/32)、Blowfish(≤56) 全都非法。 + // 注意不能用"短密钥"来测 Blowfish:它接受 4..56 字节的任意长度。 + tooLong := bytes.Repeat([]byte{0x01}, 57) - // 测试PBKDF2 - key, err := Encrypt(PBKDF2, password, "", options) - if err != nil { - t.Errorf("PBKDF2失败: %v", err) - } - - // 确保密钥长度正确 - if len(key) != 64 { // 32字节 = 64个十六进制字符 - t.Errorf("PBKDF2密钥长度错误,期望: 64, 实际: %d", len(key)) + for _, encryptType := range symmetricTypes { + if _, err := encryptx.Encrypt(encryptType, "x", tooLong, nil); err == nil { + t.Errorf("%s: 57 字节的密钥应当被拒绝", encryptType) + } + // 空密钥等同于未提供密钥 + if _, err := encryptx.Encrypt(encryptType, "x", nil, nil); !errors.Is(err, encryptx.ErrMissingKey) { + t.Errorf("%s: 空密钥期望 ErrMissingKey, 得到 %v", encryptType, err) + } } } -func TestEncryptBase64(t *testing.T) { - data := "test data" +// TestSymmetricTamperDetection 验证带完整性保护的算法能发现篡改。 +func TestSymmetricTamperDetection(t *testing.T) { + for _, encryptType := range []encryptx.EncryptType{ + encryptx.AES_GCM, encryptx.CHACHA20_POLY1305, encryptx.XCHACHA20_POLY1305, + } { + key, _ := encryptx.GenerateKey(encryptType, nil) + ciphertext, err := encryptx.Encrypt(encryptType, "authenticated payload", key, nil) + if err != nil { + t.Fatalf("%s: %v", encryptType, err) + } - // 测试Base64编码 - encoded, err := Encrypt(BASE64, data, "", nil) - if err != nil { - t.Errorf("Base64编码失败: %v", err) - } - - // 测试Base64解码 - decoded, err := Decrypt(BASE64, encoded, "", nil) - if err != nil { - t.Errorf("Base64解码失败: %v", err) - } - - if decoded != data { - t.Errorf("Base64解码结果不匹配,期望: %s, 实际: %s", data, decoded) + // 逐个字符翻转,必须全部被检出 + for i := 0; i < len(ciphertext); i++ { + if ciphertext[i] == ':' { + continue + } + tampered := []byte(ciphertext) + if tampered[i] == '0' { + tampered[i] = '1' + } else { + tampered[i] = '0' + } + if _, err := encryptx.Decrypt(encryptType, string(tampered), key, nil); err == nil { + t.Errorf("%s: 篡改第 %d 个字符后仍然解密成功", encryptType, i) + } + } } } -func TestGenerateKey(t *testing.T) { - // 测试生成AES密钥 - aesKey, err := GenerateKey(AES_CBC, map[string]interface{}{"keyLength": 32}) +func TestAEADAADBinding(t *testing.T) { + for _, encryptType := range []encryptx.EncryptType{encryptx.AES_GCM, encryptx.CHACHA20_POLY1305} { + key, _ := encryptx.GenerateKey(encryptType, nil) + aad := encryptx.Options{"aad": "user=42"} + + ciphertext, err := encryptx.Encrypt(encryptType, "payload", key, aad) + if err != nil { + t.Fatalf("%s: %v", encryptType, err) + } + if _, err := encryptx.Decrypt(encryptType, ciphertext, key, aad); err != nil { + t.Errorf("%s: 正确 AAD 解密失败: %v", encryptType, err) + } + if _, err := encryptx.Decrypt(encryptType, ciphertext, key, encryptx.Options{"aad": "user=43"}); err == nil { + t.Errorf("%s: 错误 AAD 竟然解密成功", encryptType) + } + if _, err := encryptx.Decrypt(encryptType, ciphertext, key, nil); err == nil { + t.Errorf("%s: 缺失 AAD 竟然解密成功", encryptType) + } + } +} + +func TestExplicitIVOption(t *testing.T) { + // 只有 CBC/CFB/OFB/CTR 接受显式 IV;ECB 不接受。 + cases := []encryptx.EncryptType{ + encryptx.AES_CBC, encryptx.AES_CFB, encryptx.AES_OFB, encryptx.AES_CTR, + encryptx.DES_CBC, encryptx.TDES_CBC, + } + for _, encryptType := range cases { + key, _ := encryptx.GenerateKey(encryptType, nil) + + // AES 的 IV 是 16 字节,DES/3DES 是 8 字节 + ivLen := 16 + if encryptType == encryptx.DES_CBC || encryptType == encryptx.TDES_CBC { + ivLen = 8 + } + ivHex := strings.Repeat("0011223344556677", 1) + if ivLen == 16 { + ivHex = strings.Repeat("0011223344556677", 2) + } + + options := encryptx.Options{"iv": ivHex} + ciphertext, err := encryptx.Encrypt(encryptType, "fixed iv", key, options) + if err != nil { + t.Fatalf("%s: 带显式 IV 加密失败: %v", encryptType, err) + } + decrypted, err := encryptx.Decrypt(encryptType, ciphertext, key, options) + if err != nil { + t.Fatalf("%s: 带显式 IV 解密失败: %v", encryptType, err) + } + if decrypted != "fixed iv" { + t.Errorf("%s: 往返不一致", encryptType) + } + + // 相同 IV 下相同明文应产生相同密文(这正说明调用方需自行保证 IV 唯一) + again, _ := encryptx.Encrypt(encryptType, "fixed iv", key, options) + if again != ciphertext { + t.Errorf("%s: 固定 IV 下加密结果不确定", encryptType) + } + + // IV 长度不对必须报错 + if _, err := encryptx.Encrypt(encryptType, "x", key, encryptx.Options{"iv": "00"}); err == nil { + t.Errorf("%s: 非法 IV 长度应当报错", encryptType) + } + } + + // ECB 不接受 IV + key, _ := encryptx.GenerateKey(encryptx.AES_ECB, nil) + if _, err := encryptx.Encrypt(encryptx.AES_ECB, "x", key, + encryptx.Options{"iv": strings.Repeat("00", 16)}); err == nil { + t.Error("ECB 不应接受 IV") + } +} + +// ---- 密钥派生 ---- + +// TestPBKDF2RequiresSalt 是 P0-4 的回归测试。 +// +// 历史缺陷:未提供盐值时门面会自动生成随机盐,却只返回密钥、丢弃盐值,导致 +// 派生结果永远无法复现。现在要么显式给盐,要么用 DeriveKey 一并拿回盐值。 +func TestPBKDF2RequiresSalt(t *testing.T) { + if _, err := encryptx.Encrypt(encryptx.PBKDF2, "password", nil, nil); !errors.Is(err, encryptx.ErrMissingOption) { + t.Errorf("PBKDF2 缺少盐值: 期望 ErrMissingOption, 得到 %v", err) + } +} + +func TestDeriveKeyReproducible(t *testing.T) { + password := []byte("correct horse battery staple") + + for _, encryptType := range kdfTypes { + // 用很小的参数,保证测试快速 + options := fastKDFOptions(encryptType) + + key1, salt, err := encryptx.DeriveKey(encryptType, password, options) + if err != nil { + t.Fatalf("%s: DeriveKey 失败: %v", encryptType, err) + } + if len(key1) == 0 { + t.Fatalf("%s: 派生出的密钥为空", encryptType) + } + + // 用返回的盐值重新派生,结果必须完全一致——这才是"可用"的密钥派生 + withSalt := encryptx.Options{} + for k, v := range options { + withSalt[k] = v + } + withSalt["salt"] = salt + key2, _, err := encryptx.DeriveKey(encryptType, password, withSalt) + if err != nil { + t.Fatalf("%s: 用返回的盐值重新派生失败: %v", encryptType, err) + } + if !bytes.Equal(key1, key2) { + t.Errorf("%s: 派生结果不可复现", encryptType) + } + + // 不同的盐必须产生不同的密钥(HKDF 允许空盐,故跳过该断言) + if encryptType != encryptx.HKDF { + otherOptions := encryptx.Options{} + for k, v := range options { + otherOptions[k] = v + } + otherOptions["salt"] = bytes.Repeat([]byte{0xAA}, len(salt)+1) + key3, _, err := encryptx.DeriveKey(encryptType, password, otherOptions) + if err != nil { + t.Fatalf("%s: %v", encryptType, err) + } + if bytes.Equal(key1, key3) { + t.Errorf("%s: 不同盐值产生了相同密钥", encryptType) + } + } + } +} + +func TestDeriveKeyRejectsEmptySecret(t *testing.T) { + for _, encryptType := range kdfTypes { + if _, _, err := encryptx.DeriveKey(encryptType, nil, fastKDFOptions(encryptType)); !errors.Is(err, encryptx.ErrInvalidInput) { + t.Errorf("%s: 空口令应被拒绝, 得到 %v", encryptType, err) + } + } +} + +// TestEncryptKDFWithExplicitSalt 验证 Encrypt 走 KDF 时结果可复现。 +// +// 不含 ARGON2ID:它在 Encrypt 路径上是 PHC 口令哈希(自带盐),原始密钥派生 +// 请走 DeriveKey。 +func TestEncryptKDFWithExplicitSalt(t *testing.T) { + salt := bytes.Repeat([]byte{0x5A}, 16) + for _, encryptType := range []encryptx.EncryptType{encryptx.PBKDF2, encryptx.SCRYPT} { + options := fastKDFOptions(encryptType) + options["salt"] = salt + + first, err := encryptx.Encrypt(encryptType, "password", nil, options) + if err != nil { + t.Fatalf("%s: %v", encryptType, err) + } + second, err := encryptx.Encrypt(encryptType, "password", nil, options) + if err != nil { + t.Fatalf("%s: %v", encryptType, err) + } + if first != second { + t.Errorf("%s: 同一盐值下派生结果不可复现", encryptType) + } + } +} + +// fastKDFOptions 返回各 KDF 的最小可用参数,避免测试耗时过长。 +func fastKDFOptions(encryptType encryptx.EncryptType) encryptx.Options { + switch encryptType { + case encryptx.PBKDF2: + return encryptx.Options{"iterations": 10000, "keyLength": 32} + case encryptx.SCRYPT: + return encryptx.Options{"n": 1 << 10, "r": 8, "p": 1, "keyLength": 32} + case encryptx.ARGON2ID: + return encryptx.Options{"time": 1, "memory": 8 * 1024, "threads": 1, "keyLength": 32} + default: + return encryptx.Options{"keyLength": 32} + } +} + +// ---- 不可逆算法 ---- + +func TestDecryptIrreversible(t *testing.T) { + irreversible := append([]encryptx.EncryptType{}, hashTypes...) + irreversible = append(irreversible, hmacTypes...) + irreversible = append(irreversible, kdfTypes...) + irreversible = append(irreversible, encryptx.BCRYPT) + + for _, encryptType := range irreversible { + if _, err := encryptx.Decrypt(encryptType, "whatever", nil, nil); !errors.Is(err, encryptx.ErrIrreversible) { + t.Errorf("%s: 期望 ErrIrreversible, 得到 %v", encryptType, err) + } + } +} + +func TestMissingKey(t *testing.T) { + for _, encryptType := range symmetricTypes { + if _, err := encryptx.Encrypt(encryptType, "x", nil, nil); !errors.Is(err, encryptx.ErrMissingKey) { + t.Errorf("%s: 期望 ErrMissingKey, 得到 %v", encryptType, err) + } + } +} + +func TestGenerateKeyForKeylessAlgorithms(t *testing.T) { + for _, encryptType := range append(append([]encryptx.EncryptType{}, hashTypes...), + encryptx.BASE64, encryptx.BASE64_URL, encryptx.HEX, encryptx.BCRYPT) { + if _, err := encryptx.GenerateKey(encryptType, nil); err == nil { + t.Errorf("%s: 不应能生成密钥", encryptType) + } + } +} + +// ---- 选项严格性 ---- + +// TestUnknownOptionRejected 是 P2-4 的回归测试。 +// +// 历史版本用 map[string]interface{} 取参,键名拼错会被静默忽略并回退默认值, +// 导致「以为设置了参数、实际用的是默认值」这类安全隐患。现在必须报错。 +func TestUnknownOptionRejected(t *testing.T) { + key, _ := encryptx.GenerateKey(encryptx.AES_CBC, nil) + + if _, err := encryptx.Encrypt(encryptx.AES_CBC, "x", key, + encryptx.Options{"keyLen": 32}); !errors.Is(err, encryptx.ErrUnknownOption) { + t.Errorf("拼错的选项名: 期望 ErrUnknownOption, 得到 %v", err) + } + if _, err := encryptx.Encrypt(encryptx.PBKDF2, "x", nil, + encryptx.Options{"salt": bytes.Repeat([]byte{1}, 16), "iterration": 1000}); !errors.Is(err, encryptx.ErrUnknownOption) { + t.Errorf("拼错的 KDF 选项名: 期望 ErrUnknownOption, 得到 %v", err) + } + if _, err := encryptx.GenerateKey(encryptx.AES_CBC, + encryptx.Options{"keylength": 32}); !errors.Is(err, encryptx.ErrUnknownOption) { + t.Errorf("GenerateKey 未知选项: 期望 ErrUnknownOption, 得到 %v", err) + } +} + +// TestOptionTypeMismatchRejected 验证类型不符时不再静默取默认值。 +// +// 历史版本 `iterations, ok := options["iterations"].(int)` 在类型不符时 ok 为 +// false,于是静默回退默认值——调用方以为设置了参数,实际用的却是默认值。 +func TestOptionTypeMismatchRejected(t *testing.T) { + aesKey, err := encryptx.GenerateKey(encryptx.AES_CBC, nil) if err != nil { - t.Errorf("生成AES密钥失败: %v", err) + t.Fatalf("GenerateKey 失败: %v", err) + } + salt := bytes.Repeat([]byte{0x11}, 16) + + cases := []struct { + name string + encryptType encryptx.EncryptType + key []byte + options encryptx.Options + }{ + {"keyLength 传字符串", encryptx.PBKDF2, nil, + encryptx.Options{"salt": salt, "keyLength": "32"}}, + {"keyLength 传结构体", encryptx.PBKDF2, nil, + encryptx.Options{"salt": salt, "keyLength": struct{}{}}}, + {"keyLength 传布尔", encryptx.PBKDF2, nil, + encryptx.Options{"salt": salt, "keyLength": true}}, + {"aad 传整数", encryptx.AES_GCM, aesKey, encryptx.Options{"aad": 123}}, + {"iv 传整数", encryptx.AES_CBC, aesKey, encryptx.Options{"iv": 4242}}, + {"salt 传整数", encryptx.PBKDF2, nil, encryptx.Options{"salt": 42}}, + {"iterations 传字符串", encryptx.PBKDF2, nil, + encryptx.Options{"salt": salt, "iterations": "many"}}, + {"aad 传布尔", encryptx.CHACHA20_POLY1305, aesKey, encryptx.Options{"aad": true}}, } - // AES-256密钥应该是64个字符(32字节转换为十六进制) - if len(aesKey) != 64 { - t.Errorf("AES-256密钥长度错误,期望: 64, 实际: %d", len(aesKey)) + for _, c := range cases { + _, err := encryptx.Encrypt(c.encryptType, "payload", c.key, c.options) + if !errors.Is(err, encryptx.ErrInvalidOption) { + t.Errorf("%s: 期望 ErrInvalidOption, 得到 %v", c.name, err) + } } - // 测试生成HMAC密钥 - hmacKey, err := GenerateKey(HMAC_SHA256, map[string]interface{}{"keyLength": 32}) + // GenerateKey 同样不得静默回退 + if _, err := encryptx.GenerateKey(encryptx.AES_CBC, encryptx.Options{"keyLength": "32"}); !errors.Is(err, encryptx.ErrInvalidOption) { + t.Errorf("GenerateKey 类型不符: 期望 ErrInvalidOption, 得到 %v", err) + } +} + +// TestOptionsAcceptInt64 验证 JSON 解码常见的 int64 能被正确接受。 +func TestOptionsAcceptInt64(t *testing.T) { + key, err := encryptx.GenerateKey(encryptx.AES_CBC, encryptx.Options{"keyLength": int64(16)}) if err != nil { - t.Errorf("生成HMAC密钥失败: %v", err) + t.Fatalf("int64 形式的 keyLength 被拒绝: %v", err) + } + if len(key) != 16 { + t.Errorf("密钥长度 = %d, 期望 16", len(key)) } - // HMAC密钥应该是64个字符(32字节转换为十六进制) - if len(hmacKey) != 64 { - t.Errorf("HMAC密钥长度错误,期望: 64, 实际: %d", len(hmacKey)) - } - - // 测试生成PBKDF2盐值 - salt, err := GenerateKey(PBKDF2, map[string]interface{}{"saltLength": 16}) + key32, err := encryptx.GenerateKey(encryptx.AES_CBC, encryptx.Options{"keyLength": float64(32)}) if err != nil { - t.Errorf("生成PBKDF2盐值失败: %v", err) + t.Fatalf("float64 形式的 keyLength 被拒绝: %v", err) + } + if len(key32) != 32 { + t.Errorf("密钥长度 = %d, 期望 32", len(key32)) } - // 盐值应该是32个字符(16字节转换为十六进制) - if len(salt) != 32 { - t.Errorf("PBKDF2盐值长度错误,期望: 32, 实际: %d", len(salt)) + if _, err := encryptx.GenerateKey(encryptx.AES_CBC, encryptx.Options{"keyLength": 16.5}); !errors.Is(err, encryptx.ErrInvalidOption) { + t.Errorf("非整数 float64: 期望 ErrInvalidOption, 得到 %v", err) } } -func TestDecryptHashAlgorithms(t *testing.T) { - // 测试哈希算法解密(应该失败) - _, err := Decrypt(MD5, "test", "", nil) - if err == nil { - t.Errorf("MD5解密应该失败") +// ---- 口令哈希 ---- + +func TestPasswordHashing(t *testing.T) { + password := []byte("S3cr3t-P@ssw0rd") + + cases := []struct { + encryptType encryptx.EncryptType + options encryptx.Options + prefix string + }{ + {encryptx.BCRYPT, encryptx.Options{"cost": 4}, "$2"}, + {encryptx.ARGON2ID, encryptx.Options{"time": 1, "memory": 8 * 1024, "threads": 1}, "$argon2id$"}, } - _, err = Decrypt(SHA1, "test", "", nil) - if err == nil { - t.Errorf("SHA1解密应该失败") - } + for _, c := range cases { + encoded, err := encryptx.HashPassword(c.encryptType, password, c.options) + if err != nil { + t.Fatalf("%s: HashPassword 失败: %v", c.encryptType, err) + } + if !strings.HasPrefix(encoded, c.prefix) { + t.Errorf("%s: 哈希串前缀 = %q, 期望以 %q 开头", c.encryptType, encoded, c.prefix) + } - _, err = Decrypt(SHA256, "test", "", nil) - if err == nil { - t.Errorf("SHA256解密应该失败") - } + ok, err := encryptx.VerifyPassword(c.encryptType, password, encoded, nil) + if err != nil { + t.Fatalf("%s: VerifyPassword 返回错误: %v", c.encryptType, err) + } + if !ok { + t.Errorf("%s: 正确口令校验失败", c.encryptType) + } - _, err = Decrypt(SHA512, "test", "", nil) - if err == nil { - t.Errorf("SHA512解密应该失败") - } + ok, err = encryptx.VerifyPassword(c.encryptType, []byte("wrong"), encoded, nil) + if err != nil { + t.Errorf("%s: 错误口令不应返回 error, 得到 %v", c.encryptType, err) + } + if ok { + t.Errorf("%s: 错误口令通过了校验", c.encryptType) + } - _, err = Decrypt(SHA3_256, "test", "", nil) - if err == nil { - t.Errorf("SHA3-256解密应该失败") - } + // 非法哈希串应返回 error + if _, err := encryptx.VerifyPassword(c.encryptType, password, "not-a-hash", nil); err == nil { + t.Errorf("%s: 非法哈希串期望 error", c.encryptType) + } - _, err = Decrypt(SHA3_384, "test", "", nil) - if err == nil { - t.Errorf("SHA3-384解密应该失败") - } + // Encrypt 也应支持口令哈希 + viaEncrypt, err := encryptx.Encrypt(c.encryptType, string(password), nil, c.options) + if err != nil { + t.Fatalf("%s: Encrypt 形式的口令哈希失败: %v", c.encryptType, err) + } + if ok, _ := encryptx.VerifyPassword(c.encryptType, password, viaEncrypt, nil); !ok { + t.Errorf("%s: Encrypt 产生的哈希无法校验通过", c.encryptType) + } - _, err = Decrypt(SHA3_512, "test", "", nil) - if err == nil { - t.Errorf("SHA3-512解密应该失败") + // 同一口令两次哈希必须不同(随机盐) + second, _ := encryptx.HashPassword(c.encryptType, password, c.options) + if second == encoded { + t.Errorf("%s: 两次哈希结果相同, 随机盐可能未生效", c.encryptType) + } } } -func TestDecryptMACAlgorithms(t *testing.T) { - // 测试消息认证码解密(应该失败) - _, err := Decrypt(HMAC_SHA256, "test", "", nil) - if err == nil { - t.Errorf("HMAC-SHA256解密应该失败") +// ---- 非对称加密与签名 ---- + +func TestRSARoundTrip(t *testing.T) { + privatePEM, err := encryptx.GenerateKey(encryptx.RSA, encryptx.Options{"bits": 2048}) + if err != nil { + t.Fatalf("GenerateKey(RSA) 失败: %v", err) + } + priv, err := rsax.ParsePrivateKeyPEM(privatePEM) + if err != nil { + t.Fatalf("解析私钥失败: %v", err) + } + publicPEM, err := rsax.MarshalPublicKeyPEM(&priv.PublicKey) + if err != nil { + t.Fatalf("导出公钥失败: %v", err) } - _, err = Decrypt(HMAC_SHA512, "test", "", nil) - if err == nil { - t.Errorf("HMAC-SHA512解密应该失败") + // GenerateKey 返回的是密钥材料本身,而不是像旧版本那样返回一句提示文本 + if !bytes.Contains(privatePEM, []byte("PRIVATE KEY")) { + t.Error("GenerateKey(RSA) 未返回 PEM 密钥") + } + + for _, plaintext := range []string{"short", strings.Repeat("long plaintext ", 400)} { + ciphertext, err := encryptx.Encrypt(encryptx.RSA, plaintext, publicPEM, nil) + if err != nil { + t.Fatalf("RSA 加密失败: %v", err) + } + decrypted, err := encryptx.Decrypt(encryptx.RSA, ciphertext, privatePEM, nil) + if err != nil { + t.Fatalf("RSA 解密失败: %v", err) + } + if decrypted != plaintext { + t.Errorf("RSA 往返不一致(明文长度 %d)", len(plaintext)) + } + } + + // 弱密钥位数必须被拒绝 + if _, err := encryptx.GenerateKey(encryptx.RSA, encryptx.Options{"bits": 1024}); err == nil { + t.Error("1024 位 RSA 密钥应当被拒绝") + } + + // 篡改密文必须失败 + ciphertext, _ := encryptx.Encrypt(encryptx.RSA, "tamper me", publicPEM, nil) + tampered := []byte(ciphertext) + tampered[0] ^= 0x01 + if _, err := encryptx.Decrypt(encryptx.RSA, string(tampered), privatePEM, nil); err == nil { + t.Error("篡改后的 RSA 密文竟然解密成功") } } -func TestDecryptPBKDF2(t *testing.T) { - // 测试PBKDF2解密(应该失败) - _, err := Decrypt(PBKDF2, "test", "", nil) - if err == nil { - t.Errorf("PBKDF2解密应该失败") +func TestRSAKeyFileWorkflow(t *testing.T) { + dir := t.TempDir() + privatePath := filepath.Join(dir, "private.pem") + publicPath := filepath.Join(dir, "public.pem") + + _, err := encryptx.GenerateKey(encryptx.RSA, encryptx.Options{ + "bits": 2048, + "privateKeyFile": privatePath, + "publicKeyFile": publicPath, + }) + if err != nil { + t.Fatalf("生成并落盘 RSA 密钥失败: %v", err) + } + + // 只给路径、不给密钥内容 + ciphertext, err := encryptx.Encrypt(encryptx.RSA, "via files", nil, + encryptx.Options{"publicKeyFile": publicPath}) + if err != nil { + t.Fatalf("按文件路径加密失败: %v", err) + } + decrypted, err := encryptx.Decrypt(encryptx.RSA, ciphertext, nil, + encryptx.Options{"privateKeyFile": privatePath}) + if err != nil { + t.Fatalf("按文件路径解密失败: %v", err) + } + if decrypted != "via files" { + t.Errorf("往返不一致: %q", decrypted) + } + + // 文件不存在必须返回 error,而不是像旧版本那样 panic + if _, err := encryptx.Encrypt(encryptx.RSA, "x", nil, + encryptx.Options{"publicKeyFile": filepath.Join(dir, "missing.pem")}); err == nil { + t.Error("公钥文件不存在时应当返回错误") + } + + if runtime.GOOS != "windows" { + // 私钥文件权限应为 0600 + info, err := os.Stat(privatePath) + if err != nil { + t.Fatalf("stat 私钥文件失败: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("私钥文件权限 = %o, 期望 600", perm) + } + } +} + +// keyPairOptions 返回生成该签名算法密钥对所需的选项。 +// +// 只有 RSA 有 bits 参数;Ed25519 与 ECDSA 的强度由算法/曲线决定,传入 bits +// 会被判为未知选项(这正是选项严格校验的体现)。 +func keyPairOptions(signType encryptx.SignType) encryptx.Options { + if signType.UsesRSA() { + return encryptx.Options{"bits": 2048} + } + return nil +} + +func TestSignAndVerify(t *testing.T) { + const message = "待签名的消息 with unicode 🔏" + for _, signType := range encryptx.GetAllSignTypes() { + privatePEM, publicPEM, err := encryptx.GenerateKeyPair(signType, keyPairOptions(signType)) + if err != nil { + t.Fatalf("%s: GenerateKeyPair 失败: %v", signType, err) + } + + signature, err := encryptx.Sign(signType, message, privatePEM, nil) + if err != nil { + t.Fatalf("%s: 签名失败: %v", signType, err) + } + if err := encryptx.Verify(signType, message, signature, publicPEM, nil); err != nil { + t.Errorf("%s: 正确签名验签失败: %v", signType, err) + } + + // 篡改消息 + if err := encryptx.Verify(signType, message+"!", signature, publicPEM, nil); err == nil { + t.Errorf("%s: 篡改消息后验签竟然通过", signType) + } + + // 篡改签名 + badSignature := []byte(signature) + badSignature[0] ^= 0x01 + if err := encryptx.Verify(signType, message, string(badSignature), publicPEM, nil); err == nil { + t.Errorf("%s: 篡改签名后验签竟然通过", signType) + } + + // 错误公钥 + _, otherPublicPEM, err := encryptx.GenerateKeyPair(signType, keyPairOptions(signType)) + if err != nil { + t.Fatalf("%s: %v", signType, err) + } + if err := encryptx.Verify(signType, message, signature, otherPublicPEM, nil); err == nil { + t.Errorf("%s: 用错误公钥验签竟然通过", signType) + } + + // 非法的签名 Base64 + if err := encryptx.Verify(signType, message, "!!!not-base64!!!", publicPEM, nil); err == nil { + t.Errorf("%s: 非法签名 Base64 期望错误", signType) + } + + // 把加密用的私钥当公钥传入应当报错 + if err := encryptx.Verify(signType, message, signature, privatePEM, nil); err == nil { + t.Errorf("%s: 用私钥验签竟然通过", signType) + } + } +} + +func TestSignInvalidInputs(t *testing.T) { + bogus := encryptx.SignType(9999) + if bogus.Valid() { + t.Error("SignType(9999).Valid() = true") + } + if _, err := encryptx.Sign(bogus, "x", nil, nil); !errors.Is(err, encryptx.ErrUnsupportedType) { + t.Errorf("Sign: 期望 ErrUnsupportedType, 得到 %v", err) + } + if err := encryptx.Verify(bogus, "x", "sig", nil, nil); !errors.Is(err, encryptx.ErrUnsupportedType) { + t.Errorf("Verify: 期望 ErrUnsupportedType, 得到 %v", err) + } + + // 缺少密钥 + if _, err := encryptx.Sign(encryptx.ED25519, "x", nil, nil); !errors.Is(err, encryptx.ErrMissingKey) { + t.Errorf("Sign 缺少密钥: 期望 ErrMissingKey, 得到 %v", err) + } + if err := encryptx.Verify(encryptx.ED25519, "x", "sig", nil, nil); !errors.Is(err, encryptx.ErrMissingKey) { + t.Errorf("Verify 缺少密钥: 期望 ErrMissingKey, 得到 %v", err) + } + + // 垃圾 PEM + if _, err := encryptx.Sign(encryptx.ED25519, "x", []byte("not a pem"), nil); err == nil { + t.Error("垃圾 PEM 期望错误") + } + if err := encryptx.Verify(encryptx.ED25519, "x", "c2ln", []byte("not a pem"), nil); err == nil { + t.Error("垃圾 PEM 期望错误") + } + + // 算法与密钥类型不匹配:把 Ed25519 密钥当 RSA 用 + privatePEM, _, err := encryptx.GenerateKeyPair(encryptx.ED25519, nil) + if err != nil { + t.Fatalf("GenerateKeyPair 失败: %v", err) + } + if _, err := encryptx.Sign(encryptx.RSA_PSS, "x", privatePEM, nil); err == nil { + t.Error("用 Ed25519 私钥做 RSA 签名应当报错") + } +} + +func TestGenerateKeyPairECDSA(t *testing.T) { + // ECDSA 的曲线必须与 SignType 匹配 + privatePEM, publicPEM, err := encryptx.GenerateKeyPair(encryptx.ECDSA_P256, nil) + if err != nil { + t.Fatalf("GenerateKeyPair(ECDSA_P256) 失败: %v", err) + } + signature, err := encryptx.Sign(encryptx.ECDSA_P256, "msg", privatePEM, nil) + if err != nil { + t.Fatalf("签名失败: %v", err) + } + if err := encryptx.Verify(encryptx.ECDSA_P256, "msg", signature, publicPEM, nil); err != nil { + t.Fatalf("验签失败: %v", err) + } + // 换一个曲线标识去验签,必须因曲线不匹配而失败 + if err := encryptx.Verify(encryptx.ECDSA_P384, "msg", signature, publicPEM, nil); err == nil { + t.Error("曲线不匹配时验签应当失败") + } +} + +// ---- 健壮性 ---- + +// TestDecryptGarbageNeverPanics 用大量畸形输入轰炸所有可逆算法。 +// +// 历史版本在 ECB 解密时对非分组对齐的密文会触发 +// "slice bounds out of range" panic,攻击者用一串伪造密文即可让服务崩溃。 +func TestDecryptGarbageNeverPanics(t *testing.T) { + garbage := []string{ + "", "0", "00", "zz", "!!!", "::::", "0:0", + strings.Repeat("A", 33), strings.Repeat("AB", 7), strings.Repeat("AB", 8), + strings.Repeat("AB", 9), strings.Repeat("ff", 100), "not-hex-at-all", + strings.Repeat("=", 10), "\n\n\n", + } + + reversible := append([]encryptx.EncryptType{}, symmetricTypes...) + reversible = append(reversible, encryptx.BASE64, encryptx.BASE64_URL, encryptx.HEX) + + for _, encryptType := range reversible { + var key []byte + if encryptType.NeedsKey() { + var err error + key, err = encryptx.GenerateKey(encryptType, nil) + if err != nil { + t.Fatalf("%s: GenerateKey 失败: %v", encryptType, err) + } + } + for i, input := range garbage { + // 不应 panic + _, _ = encryptx.Decrypt(encryptType, input, key, nil) + _ = i + } + } +} + +func TestEncryptNeverPanicsOnNilOptions(t *testing.T) { + key, _ := encryptx.GenerateKey(encryptx.AES_CBC, nil) + if _, err := encryptx.Encrypt(encryptx.AES_CBC, "x", key, nil); err != nil { + t.Errorf("nil options 应当被接受, 得到 %v", err) + } + if _, err := encryptx.GenerateKey(encryptx.AES_CBC, nil); err != nil { + t.Errorf("nil options 应当被接受, 得到 %v", err) + } +} + +// TestConcurrentUse 验证门面可被并发安全调用(无共享可变状态)。 +func TestConcurrentUse(t *testing.T) { + key, err := encryptx.GenerateKey(encryptx.AES_GCM, nil) + if err != nil { + t.Fatalf("GenerateKey 失败: %v", err) + } + + const goroutines = 16 + done := make(chan error, goroutines) + for i := 0; i < goroutines; i++ { + go func(n int) { + plaintext := fmt.Sprintf("goroutine %d payload", n) + ciphertext, err := encryptx.Encrypt(encryptx.AES_GCM, plaintext, key, nil) + if err != nil { + done <- err + return + } + decrypted, err := encryptx.Decrypt(encryptx.AES_GCM, ciphertext, key, nil) + if err != nil { + done <- err + return + } + if decrypted != plaintext { + done <- fmt.Errorf("往返不一致: %q", decrypted) + return + } + done <- nil + }(i) + } + for i := 0; i < goroutines; i++ { + if err := <-done; err != nil { + t.Errorf("并发调用失败: %v", err) + } } } diff --git a/go.mod b/go.mod index 29a1c3f..04d37db 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,13 @@ module code.yun.ink/pkg/encryptx -go 1.19 +go 1.26.0 -require golang.org/x/crypto v0.15.0 +require ( + github.com/ProtonMail/go-crypto v1.5.2 + golang.org/x/crypto v0.57.0 +) -require golang.org/x/sys v0.14.0 // indirect +require ( + github.com/cloudflare/circl v1.6.3 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/go.sum b/go.sum index 4888ad1..ef1db8f 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,8 @@ -golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= -golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= -golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +github.com/ProtonMail/go-crypto v1.5.2 h1:cucYnvqcY7UOXVD//mSyjeaPY0SSN3v5cDkYPxumINk= +github.com/ProtonMail/go-crypto v1.5.2/go.mod h1:/RaSu30DaKO4RY+XdV/ACcCcZkGr7AhUIduq5sjzzCo= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/gpgx/gpgx.go b/gpgx/gpgx.go index f9a698f..b04b2a4 100644 --- a/gpgx/gpgx.go +++ b/gpgx/gpgx.go @@ -1,41 +1,655 @@ +// Package gpgx 提供基于 OpenPGP(RFC 4880 / RFC 9580)的密钥环读取、 +// 加解密、签名验签与密钥生成能力。 +// +// 实现基于 github.com/ProtonMail/go-crypto/openpgp —— 官方推荐的 +// golang.org/x/crypto/openpgp 替代品(后者已被 govulncheck 判定为 +// GO-2026-5932:unmaintained, unsafe by design, and has known security +// issues,且没有修复版本)。 +// +// 设计约定: +// - 所有导出函数都不 panic,全部通过 error 返回失败(用 %w 包装以便 +// errors.Is / errors.As 判断)。 +// - 所有 io.WriteCloser 都会在写出后 Close,并检查 Close 返回的错误 +// (加密流的结尾数据在 Close 时才真正写出,漏检会导致密文不完整)。 +// - 需要私钥的操作(解密、签名)在传入 passphrase 时操作密钥环的副本, +// 不会修改调用方传入的 openpgp.EntityList。 +// - 本包不向 stdout/stderr 输出任何内容。 package gpgx import ( + "bytes" + "crypto" + "errors" "fmt" + "io" "os" + "time" - "golang.org/x/crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" ) -func Demo() { - keyRingReader, err := os.Open("signer-pubkey.asc") - if err != nil { - fmt.Println(err) - return - } +// 密钥长度约束。OpenPGP 允许更短的 RSA 密钥,但本包拒绝生成弱密钥。 +const ( + // MinRSABits 是 GenerateKeyPair 接受的 RSA 最小位数。 + MinRSABits = 2048 + // DefaultRSABits 是 GenerateKeyPair 的 RSA 默认位数。 + DefaultRSABits = 2048 +) - signature, err := os.Open("signature.asc") - if err != nil { - fmt.Println(err) - return - } +// armorHeader 用于快速判断输入是否为 ASCII armor 文本。 +const armorHeader = "-----BEGIN PGP" - verification_target, err := os.Open("MysqL-5.7.9-win32.zip") - if err != nil { - fmt.Println(err) - return - } +// ---------------------------------------------------------------- 密钥环读取 - keyring, err := openpgp.ReadArmoredKeyRing(keyRingReader) +// ReadKeyRing 从 r 读取 OpenPGP 密钥环,自动识别 armored 与二进制格式。 +// +// 返回的 EntityList 同时包含公钥与(可能已加密的)私钥材料。 +func ReadKeyRing(r io.Reader) (openpgp.EntityList, error) { + raw, err := readAll(r) if err != nil { - fmt.Println("Read Armored Key Ring: " + err.Error()) - return + return nil, fmt.Errorf("gpgx: 读取密钥环数据失败: %w", err) } - entity, err := openpgp.CheckArmoredDetachedSignature(keyring, verification_target, signature) - if err != nil { - fmt.Println("Check Detached Signature: " + err.Error()) - return + if len(raw) == 0 { + return nil, errors.New("gpgx: 密钥环数据为空") } + return parseKeyRing(raw) +} - fmt.Println(entity) +// ReadArmoredKeyRing 从 r 读取 ASCII armor 编码的 OpenPGP 密钥环。 +// +// 若输入不是合法的 armor 文本(例如二进制密钥环或垃圾数据),返回 error。 +func ReadArmoredKeyRing(r io.Reader) (openpgp.EntityList, error) { + raw, err := readAll(r) + if err != nil { + return nil, fmt.Errorf("gpgx: 读取 armored 密钥环数据失败: %w", err) + } + if len(raw) == 0 { + return nil, errors.New("gpgx: armored 密钥环数据为空") + } + if !isArmored(raw) { + return nil, errors.New("gpgx: 输入不是 ASCII armor 文本(缺少 \"-----BEGIN PGP\" 头)") + } + + entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("gpgx: 解析 armored 密钥环失败: %w", err) + } + if len(entities) == 0 { + return nil, errors.New("gpgx: armored 密钥环中不包含任何实体") + } + return entities, nil +} + +// ReadKeyRingFile 从文件读取 OpenPGP 密钥环,自动识别 armored 与二进制格式。 +// +// 内部使用 os.ReadFile,不会泄漏文件句柄。 +func ReadKeyRingFile(path string) (openpgp.EntityList, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("gpgx: 读取密钥环文件 %q 失败: %w", path, err) + } + if len(raw) == 0 { + return nil, fmt.Errorf("gpgx: 密钥环文件 %q 为空", path) + } + entities, err := parseKeyRing(raw) + if err != nil { + return nil, fmt.Errorf("gpgx: 解析密钥环文件 %q 失败: %w", path, err) + } + return entities, nil +} + +// parseKeyRing 按内容自动选择 armored 或二进制解析。 +func parseKeyRing(raw []byte) (openpgp.EntityList, error) { + var ( + entities openpgp.EntityList + err error + ) + if isArmored(raw) { + entities, err = openpgp.ReadArmoredKeyRing(bytes.NewReader(raw)) + } else { + entities, err = openpgp.ReadKeyRing(bytes.NewReader(raw)) + } + if err != nil { + return nil, fmt.Errorf("解析密钥环失败: %w", err) + } + if len(entities) == 0 { + return nil, errors.New("密钥环中不包含任何实体") + } + return entities, nil +} + +// isArmored 判断数据是否为 PGP ASCII armor(允许前置空白)。 +func isArmored(data []byte) bool { + return bytes.HasPrefix(bytes.TrimLeft(data, " \t\r\n"), []byte(armorHeader)) +} + +// maxUnwrappedArmorSize 限制单个 armor 块解出的原始报文大小,避免畸形输入 +// 造成无界内存占用。 +const maxUnwrappedArmorSize = 64 << 20 // 64 MiB + +// unwrapArmor 在输入为 ASCII armor 时返回其内部的原始报文字节;否则原样返回。 +// +// 该函数不校验 armor 块的 Type 字段:调用方(ReadMessage / +// CheckDetachedSignature)会对解出的报文本身做严格校验,而 armor 的 CRC +// 校验由 crypto/openpgp 的 packet 层与完整性保护(MDC/AEAD)负责。 +func unwrapArmor(data []byte) ([]byte, error) { + if !isArmored(data) { + return data, nil + } + block, err := armor.Decode(bytes.NewReader(data)) + if err != nil { + return nil, err + } + if block == nil { + return nil, errors.New("armor 块为空") + } + raw, err := io.ReadAll(io.LimitReader(block.Body, maxUnwrappedArmorSize+1)) + if err != nil { + return nil, err + } + if len(raw) > maxUnwrappedArmorSize { + return nil, fmt.Errorf("armor 块解出的报文超过 %d 字节上限", maxUnwrappedArmorSize) + } + if len(raw) == 0 { + return nil, errors.New("armor 块不包含任何数据") + } + return raw, nil +} + +// ------------------------------------------------------------------ 加密解密 + +// Encrypt 使用 recipients 中的公钥加密 plaintext,返回二进制 OpenPGP 报文。 +func Encrypt(recipients openpgp.EntityList, plaintext []byte) ([]byte, error) { + if err := validateRecipients(recipients); err != nil { + return nil, err + } + + var buf bytes.Buffer + plaintextWriter, err := openpgp.Encrypt(&buf, recipients, nil, nil, nil) + if err != nil { + return nil, fmt.Errorf("gpgx: 初始化加密流失败: %w", err) + } + if err := writeAndClose(plaintextWriter, plaintext); err != nil { + return nil, fmt.Errorf("gpgx: 加密失败: %w", err) + } + return buf.Bytes(), nil +} + +// EncryptArmored 使用 recipients 中的公钥加密 plaintext,返回 ASCII armor 报文。 +func EncryptArmored(recipients openpgp.EntityList, plaintext []byte) ([]byte, error) { + binaryMessage, err := Encrypt(recipients, plaintext) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + armorWriter, err := armor.Encode(&buf, openpgp.MessageType, nil) + if err != nil { + return nil, fmt.Errorf("gpgx: 初始化 armor 编码器失败: %w", err) + } + if _, err := armorWriter.Write(binaryMessage); err != nil { + _ = armorWriter.Close() + return nil, fmt.Errorf("gpgx: 写出 armor 数据失败: %w", err) + } + if err := armorWriter.Close(); err != nil { + return nil, fmt.Errorf("gpgx: 关闭 armor 编码器失败: %w", err) + } + return buf.Bytes(), nil +} + +// EncryptWithPassphrase 使用口令对称加密 plaintext(等价于 gpg -c), +// 返回二进制 OpenPGP 报文;解密端使用 DecryptWithPassphrase。 +// +// 对称加密同样受完整性保护(MDC/AEAD),不需要密钥环。 +func EncryptWithPassphrase(passphrase, plaintext []byte) ([]byte, error) { + if len(passphrase) == 0 { + return nil, errors.New("gpgx: passphrase 为空,无法进行对称加密") + } + + var buf bytes.Buffer + plaintextWriter, err := openpgp.SymmetricallyEncrypt(&buf, passphrase, nil, nil) + if err != nil { + return nil, fmt.Errorf("gpgx: 初始化对称加密流失败: %w", err) + } + if err := writeAndClose(plaintextWriter, plaintext); err != nil { + return nil, fmt.Errorf("gpgx: 对称加密失败: %w", err) + } + return buf.Bytes(), nil +} + +// Decrypt 使用 keyring 解密 ciphertext,自动识别 armored 与二进制格式。 +// +// 若会话密钥被公钥加密,keyring 中必须包含对应私钥;私钥本身可以是 +// 未加密的。已加密的私钥请使用 DecryptWithPassphrase。 +func Decrypt(keyring openpgp.EntityList, ciphertext []byte) ([]byte, error) { + return decryptWith(keyring, ciphertext, nil, nil) +} + +// DecryptWithPassphrase 使用 keyring 与 passphrase 解密 ciphertext。 +// +// passphrase 用于解开已加密的私钥;也可以直接解密「口令加密」(对称加密, +// gpg -c)的报文。若私钥未加密,传入非空 passphrase 同样可以正常工作。 +func DecryptWithPassphrase(keyring openpgp.EntityList, ciphertext, passphrase []byte) ([]byte, error) { + if len(keyring) == 0 { + return nil, errors.New("gpgx: 密钥环为空,无法解密") + } + + // 在副本上解密私钥,避免修改调用方传入的密钥环。 + copied := cloneKeyRing(keyring) + if err := decryptedPrivateKeys(copied, passphrase); err != nil { + return nil, err + } + + var prompt openpgp.PromptFunction + if len(passphrase) > 0 { + // copied 中的私钥已在上一步解开;此回调用于覆盖「口令加密」报文 + // 以及上一步未能解开的私钥,同时避免库无限重试同一口令。 + prompt = oneShotPassphrasePrompt(passphrase) + } + return decryptWith(copied, ciphertext, prompt, nil) +} + +// decryptWith 是解密路径的公共实现。keyring 由调用方保证不会被共享。 +func decryptWith(keyring openpgp.EntityList, ciphertext []byte, prompt openpgp.PromptFunction, config *packet.Config) ([]byte, error) { + if len(ciphertext) == 0 { + return nil, errors.New("gpgx: 密文为空") + } + if len(keyring) == 0 { + return nil, errors.New("gpgx: 密钥环为空,无法解密") + } + + // openpgp.ReadMessage 只接受二进制报文(packet.Reader 不会解析 armor), + // 因此 armored 输入需要先取出 armor 内部的原始字节。 + binaryMessage, err := unwrapArmor(ciphertext) + if err != nil { + return nil, fmt.Errorf("gpgx: 解析加密报文失败: %w", err) + } + + md, err := openpgp.ReadMessage(bytes.NewReader(binaryMessage), keyring, prompt, config) + if err != nil { + return nil, fmt.Errorf("gpgx: 解析加密报文失败: %w", err) + } + if md.UnverifiedBody == nil { + return nil, errors.New("gpgx: 加密报文不包含可读数据(可能不是加密报文)") + } + + // 必须读到 EOF:完整性保护(MDC/AEAD)在 EOF 时才校验,提前返回会 + // 跳过校验。 + plaintext, err := io.ReadAll(md.UnverifiedBody) + if err != nil { + return nil, fmt.Errorf("gpgx: 读取或校验明文失败: %w", err) + } + if md.IsSigned { + if md.SignatureError != nil { + return nil, fmt.Errorf("gpgx: 报文的签名校验失败: %w", md.SignatureError) + } + if md.Signature == nil { + return nil, errors.New("gpgx: 报文声称已签名,但未找到可验证的签名") + } + } + return plaintext, nil +} + +// ---------------------------------------------------------------- 签名与验签 + +// Sign 使用 keyring 中的私钥对 plaintext 生成内联签名报文 +// (明文内嵌于 PGP 报文内,可直接被 Verify 还原)。 +// +// 若私钥已加密,必须提供正确的 passphrase;私钥未加密时可传 nil。 +func Sign(keyring openpgp.EntityList, plaintext, passphrase []byte) ([]byte, error) { + if len(keyring) == 0 { + return nil, errors.New("gpgx: 密钥环为空,无法签名") + } + + copied := cloneKeyRing(keyring) + if err := decryptedPrivateKeys(copied, passphrase); err != nil { + return nil, err + } + + var buf bytes.Buffer + plaintextWriter, err := openpgp.Sign(&buf, copied[0], nil, nil) + if err != nil { + return nil, fmt.Errorf("gpgx: 初始化签名流失败: %w", err) + } + if err := writeAndClose(plaintextWriter, plaintext); err != nil { + return nil, fmt.Errorf("gpgx: 签名失败: %w", err) + } + return buf.Bytes(), nil +} + +// Verify 验证内联签名报文并返回其中的明文。 +// +// 返回 error 的情况包括:签名不合法、签名者不在 keyring 中、报文被篡改, +// 或报文并非合法的 OpenPGP 签名报文。 +func Verify(keyring openpgp.EntityList, signedMessage []byte) ([]byte, error) { + if len(signedMessage) == 0 { + return nil, errors.New("gpgx: 签名报文为空") + } + if len(keyring) == 0 { + return nil, errors.New("gpgx: 密钥环为空,无法验签") + } + + // openpgp.ReadMessage 只接受二进制报文,armored 输入需要先解出原始字节。 + binaryMessage, err := unwrapArmor(signedMessage) + if err != nil { + return nil, fmt.Errorf("gpgx: 解析签名报文失败: %w", err) + } + + md, err := openpgp.ReadMessage(bytes.NewReader(binaryMessage), cloneKeyRing(keyring), nil, nil) + if err != nil { + return nil, fmt.Errorf("gpgx: 解析签名报文失败: %w", err) + } + if md.UnverifiedBody == nil { + return nil, errors.New("gpgx: 签名报文不包含可读数据(可能不是签名报文)") + } + + // 签名在读取到 EOF 时才完成校验。 + plaintext, err := io.ReadAll(md.UnverifiedBody) + if err != nil { + return nil, fmt.Errorf("gpgx: 读取签名报文明文失败: %w", err) + } + if !md.IsSigned { + return nil, errors.New("gpgx: 报文未包含签名") + } + if md.SignatureError != nil { + return nil, fmt.Errorf("gpgx: 签名校验失败: %w", md.SignatureError) + } + if md.Signature == nil { + return nil, errors.New("gpgx: 未找到可验证的签名") + } + return plaintext, nil +} + +// SignDetachedArmored 为 plaintext 生成 ASCII armor 编码的分离签名。 +// +// 若私钥已加密,必须提供正确的 passphrase;私钥未加密时可传 nil。 +func SignDetachedArmored(keyring openpgp.EntityList, plaintext, passphrase []byte) ([]byte, error) { + if len(keyring) == 0 { + return nil, errors.New("gpgx: 密钥环为空,无法签名") + } + + copied := cloneKeyRing(keyring) + if err := decryptedPrivateKeys(copied, passphrase); err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := openpgp.ArmoredDetachSign(&buf, copied[0], bytes.NewReader(plaintext), nil); err != nil { + return nil, fmt.Errorf("gpgx: 生成分离签名失败: %w", err) + } + return buf.Bytes(), nil +} + +// VerifyDetached 校验分离签名(支持 armored 与二进制签名), +// 成功时返回签名者实体。 +// +// data 或 signature 任一被篡改、或签名者公钥不在 keyring 中,都会返回 error。 +func VerifyDetached(keyring openpgp.EntityList, data, signature []byte) (*openpgp.Entity, error) { + if len(signature) == 0 { + return nil, errors.New("gpgx: 分离签名为空") + } + if len(keyring) == 0 { + return nil, errors.New("gpgx: 密钥环为空,无法验签") + } + + signatureReader := bytes.NewReader(signature) + var ( + signer *openpgp.Entity + err error + ) + if isArmored(signature) { + signer, err = openpgp.CheckArmoredDetachedSignature(keyring, bytes.NewReader(data), signatureReader, nil) + } else { + signer, err = openpgp.CheckDetachedSignature(keyring, bytes.NewReader(data), signatureReader, nil) + } + if err != nil { + return nil, fmt.Errorf("gpgx: 分离签名校验失败: %w", err) + } + if signer == nil { + return nil, errors.New("gpgx: 分离签名校验未返回签名者") + } + return signer, nil +} + +// ---------------------------------------------------------------- 密钥生成 + +// GenerateKeyPair 生成一对 OpenPGP 密钥,返回 ASCII armor 编码的私钥与公钥。 +// +// bits 的含义: +// - bits <= 0:生成 Ed25519 + X25519 密钥(速度快,推荐用于测试与快速上手)。 +// - bits > 0:生成 RSA 密钥,位数不得小于 MinRSABits(否则返回 error, +// 而不是生成弱密钥)。 +// +// passphrase 非空时,私钥会被加密保护;为空则私钥为未加密形式。 +// name/email/comment 用于构造 User ID,不得包含 "()<>\x00" 等字符。 +func GenerateKeyPair(name, email, comment string, passphrase []byte, bits int) (privateArmored, publicArmored []byte, err error) { + config := &packet.Config{DefaultHash: defaultHashFor(bits)} + + switch { + case bits <= 0: + config.Algorithm = packet.PubKeyAlgoEd25519 + case bits < MinRSABits: + return nil, nil, fmt.Errorf("gpgx: RSA 位数 %d 过小,至少需要 %d 位", bits, MinRSABits) + default: + config.Algorithm = packet.PubKeyAlgoRSA + config.RSABits = bits + config.MinRSABits = uint16(MinRSABits) + } + + entity, err := openpgp.NewEntity(name, comment, email, config) + if err != nil { + return nil, nil, fmt.Errorf("gpgx: 生成密钥失败: %w", err) + } + + // 注意顺序:Entity.SerializePrivate 会重新签名 User ID 与子密钥绑定, + // 而签名需要未加密的私钥(否则签名包内部的私钥类型断言会 panic)。 + // 因此必须先把私钥写成明文形式,再加密私钥并重新序列化。 + privateArmored, err = armorEntity(openpgp.PrivateKeyType, func(w io.Writer) error { + return entity.SerializePrivate(w, config) + }) + if err != nil { + return nil, nil, fmt.Errorf("gpgx: 序列化私钥失败: %w", err) + } + + if len(passphrase) > 0 { + if err := entity.EncryptPrivateKeys(passphrase, config); err != nil { + return nil, nil, fmt.Errorf("gpgx: 加密私钥失败: %w", err) + } + // 私钥已加密,不能再触发重签名,使用不重签名的序列化路径。 + privateArmored, err = armorEntity(openpgp.PrivateKeyType, func(w io.Writer) error { + return entity.SerializePrivateWithoutSigning(w, config) + }) + if err != nil { + return nil, nil, fmt.Errorf("gpgx: 序列化加密私钥失败: %w", err) + } + } + + publicArmored, err = armorEntity(openpgp.PublicKeyType, func(w io.Writer) error { + return entity.Serialize(w) + }) + if err != nil { + return nil, nil, fmt.Errorf("gpgx: 序列化公钥失败: %w", err) + } + + return privateArmored, publicArmored, nil +} + +// defaultHashFor 依据密钥类型选择哈希:Ed25519 通常搭配 SHA-256,RSA 使用 +// SHA-512 以避免与部分实现的自签名哈希偏好冲突。 +func defaultHashFor(bits int) crypto.Hash { + if bits <= 0 { + return crypto.SHA256 + } + return crypto.SHA512 +} + +// armorEntity 把 serialize 写出的实体内容包装成 ASCII armor 块。 +func armorEntity(blockType string, serialize func(io.Writer) error) ([]byte, error) { + var buf bytes.Buffer + armorWriter, err := armor.Encode(&buf, blockType, nil) + if err != nil { + return nil, err + } + if err := serialize(armorWriter); err != nil { + _ = armorWriter.Close() + return nil, err + } + if err := armorWriter.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// -------------------------------------------------------------------- 工具 + +// validateRecipients 校验接收方密钥环至少包含一个可用于加密的公钥。 +func validateRecipients(recipients openpgp.EntityList) error { + if len(recipients) == 0 { + return errors.New("gpgx: 接收方密钥环为空,无法加密") + } + for _, entity := range recipients { + if entity == nil || entity.PrimaryKey == nil { + return errors.New("gpgx: 接收方密钥环包含空实体") + } + if _, ok := entity.EncryptionKey(time.Now()); ok { + return nil + } + } + return errors.New("gpgx: 接收方密钥环中没有可用的加密密钥(可能已过期或被吊销)") +} + +// writeAndClose 写入数据并关闭 WriteCloser,两处错误都会返回。 +// +// 加密/签名流的末尾数据(MDC、签名包)只有 Close 时才写出,因此 Close +// 的错误必须检查,否则会静默产生不完整的报文。 +func writeAndClose(w io.WriteCloser, data []byte) error { + if _, err := w.Write(data); err != nil { + // 尽力释放内部资源,但以写入错误为准。 + _ = w.Close() + return err + } + if err := w.Close(); err != nil { + return err + } + return nil +} + +// decryptedPrivateKeys 解开 entities 中所有已加密的私钥副本。 +// +// 若 passphrase 为空而私钥已加密,返回明确错误而不是让上层库反复重试。 +func decryptedPrivateKeys(entities openpgp.EntityList, passphrase []byte) error { + var encrypted int + for _, entity := range entities { + if entity == nil { + continue + } + encrypted += countEncryptedPrivateKeys(entity) + } + if encrypted == 0 { + // 私钥未加密:忽略传入的口令。 + return nil + } + if len(passphrase) == 0 { + return errors.New("gpgx: 私钥已加密,但未提供 passphrase") + } + for _, entity := range entities { + if entity == nil { + continue + } + if err := entity.DecryptPrivateKeys(passphrase); err != nil { + // 不把口令写进错误信息。 + return fmt.Errorf("gpgx: 使用给定 passphrase 解开私钥失败: %w", err) + } + } + return nil +} + +// countEncryptedPrivateKeys 统计实体中已加密的私钥数量。 +func countEncryptedPrivateKeys(entity *openpgp.Entity) int { + var n int + if entity.PrivateKey != nil && entity.PrivateKey.Encrypted { + n++ + } + for _, subkey := range entity.Subkeys { + if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted { + n++ + } + } + return n +} + +// oneShotPassphrasePrompt 返回一个最多成功响应一次的口令回调。 +// +// openpgp.ReadMessage 在口令不正确时会无限重试 prompt,因此这里在第二次 +// 调用时直接返回错误,避免死循环。 +func oneShotPassphrasePrompt(passphrase []byte) openpgp.PromptFunction { + used := false + return func(_ []openpgp.Key, _ bool) ([]byte, error) { + if used { + return nil, errors.New("gpgx: passphrase 不正确(已重试)") + } + used = true + // 返回副本,避免上层库持有调用方的切片。 + return append([]byte(nil), passphrase...), nil + } +} + +// cloneKeyRing 返回 keyring 的独立副本,用于需要解开私钥的操作。 +// +// 只有私钥结构会被复制:解密会就地修改 *packet.PrivateKey,复制后调用方 +// 传入的密钥环(以及其它并发使用者)不受影响。公钥与签名是只读的,直接共享。 +func cloneKeyRing(keyring openpgp.EntityList) openpgp.EntityList { + cloned := make(openpgp.EntityList, 0, len(keyring)) + for _, entity := range keyring { + if entity == nil { + continue + } + cloned = append(cloned, cloneEntity(entity)) + } + return cloned +} + +// cloneEntity 复制实体中可变的私钥材料。 +func cloneEntity(entity *openpgp.Entity) *openpgp.Entity { + cloned := *entity + cloned.PrivateKey = clonePrivateKey(entity.PrivateKey) + + if len(entity.Subkeys) > 0 { + subkeys := make([]openpgp.Subkey, len(entity.Subkeys)) + copy(subkeys, entity.Subkeys) + for i := range subkeys { + subkeys[i].PrivateKey = clonePrivateKey(subkeys[i].PrivateKey) + } + cloned.Subkeys = subkeys + } + return &cloned +} + +// clonePrivateKey 浅拷贝私钥结构。 +// +// 已加密的私钥清空 PrivateKey 字段,使 *packet.PrivateKey.Decrypt 在副本上 +// 重新解析出独立的私钥对象;未加密的私钥保留原有私钥对象指针(只读使用)。 +func clonePrivateKey(key *packet.PrivateKey) *packet.PrivateKey { + if key == nil { + return nil + } + cloned := *key + if key.Encrypted { + cloned.PrivateKey = nil + } + return &cloned +} + +// readAll 读取全部数据,并保证不会因为上游返回 (0, nil) 而死循环。 +func readAll(r io.Reader) ([]byte, error) { + if r == nil { + return nil, errors.New("reader 为 nil") + } + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + return nil, err + } + return buf.Bytes(), nil } diff --git a/gpgx/gpgx_test.go b/gpgx/gpgx_test.go new file mode 100644 index 0000000..20049d4 --- /dev/null +++ b/gpgx/gpgx_test.go @@ -0,0 +1,812 @@ +package gpgx_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "code.yun.ink/pkg/encryptx/gpgx" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" +) + +// 测试密钥统一在内存中生成一次(Ed25519,速度快),不依赖仓库里的任何 +// 固定密钥文件。生成失败直接 Fatal,保证后续用例不会在 nil 上继续。 +var ( + testKeysOnce sync.Once + + alicePrivateArmor []byte + alicePublicArmor []byte + alicePrivate openpgp.EntityList + alicePublic openpgp.EntityList + + bobPrivateArmor []byte + bobPublicArmor []byte + bobPublic openpgp.EntityList + + // 带口令的 Ed25519 密钥。 + passphrase = []byte("correct horse battery staple") + lockedPrivate []byte + lockedPublicArmor []byte + + testKeyErr error +) + +func initTestKeys(t *testing.T) { + t.Helper() + testKeysOnce.Do(func() { + alicePrivateArmor, alicePublicArmor, testKeyErr = gpgx.GenerateKeyPair( + "Alice Tester", "alice@example.com", "gpgx test", nil, 0) + if testKeyErr != nil { + return + } + alicePrivate, testKeyErr = gpgx.ReadArmoredKeyRing(bytes.NewReader(alicePrivateArmor)) + if testKeyErr != nil { + return + } + alicePublic, testKeyErr = gpgx.ReadArmoredKeyRing(bytes.NewReader(alicePublicArmor)) + if testKeyErr != nil { + return + } + + bobPrivateArmor, bobPublicArmor, testKeyErr = gpgx.GenerateKeyPair( + "Bob Tester", "bob@example.com", "gpgx test", nil, 0) + if testKeyErr != nil { + return + } + bobPublic, testKeyErr = gpgx.ReadArmoredKeyRing(bytes.NewReader(bobPublicArmor)) + if testKeyErr != nil { + return + } + + lockedPrivate, lockedPublicArmor, testKeyErr = gpgx.GenerateKeyPair( + "Carol Locked", "carol@example.com", "gpgx test", passphrase, 0) + }) + if testKeyErr != nil { + t.Fatalf("初始化测试密钥失败: %v", testKeyErr) + } +} + +// requireKeys 声明用例对已生成测试密钥的依赖。 +func requireKeys(t *testing.T) { + t.Helper() + if len(alicePrivate) == 0 || len(alicePublic) == 0 { + t.Fatal("Alice 测试密钥不可用") + } +} + +// mustNotPanic 断言 fn 既不 panic 也返回 error(用于所有错误路径)。 +func mustNotPanic(t *testing.T, name string, fn func() error) { + t.Helper() + defer func() { + if r := recover(); r != nil { + t.Fatalf("%s: 发生 panic: %v", name, r) + } + }() + if err := fn(); err == nil { + t.Fatalf("%s: 期望返回 error,实际返回 nil", name) + } +} + +// generatePlaintext 生成确定性的测试明文。 +func generatePlaintext(size int) []byte { + out := make([]byte, size) + for i := range out { + out[i] = byte((i * 7) % 251) + } + return out +} + +// tamperByte 翻转 data 中第 idx 个字节;若 idx 越界则返回原数据的副本。 +func tamperByte(data []byte, idx int) []byte { + out := append([]byte(nil), data...) + if idx >= 0 && idx < len(out) { + out[idx] ^= 0x01 + } + return out +} + +// ------------------------------------------------------------------ 测试用例 + +func TestGenerateKeyPairAndReadKeyRing(t *testing.T) { + initTestKeys(t) + requireKeys(t) + + if !strings.HasPrefix(string(alicePrivateArmor), "-----BEGIN PGP PRIVATE KEY BLOCK-----") { + t.Fatalf("私钥 armor 头不正确: %q", firstLine(alicePrivateArmor)) + } + if !strings.HasPrefix(string(alicePublicArmor), "-----BEGIN PGP PUBLIC KEY BLOCK-----") { + t.Fatalf("公钥 armor 头不正确: %q", firstLine(alicePublicArmor)) + } + + // armored 往返(私钥)。 + private, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(alicePrivateArmor)) + if err != nil { + t.Fatalf("ReadArmoredKeyRing(私钥) 失败: %v", err) + } + if len(private) != 1 { + t.Fatalf("期望 1 个实体,实际 %d", len(private)) + } + if private[0].PrivateKey == nil { + t.Fatal("私钥密钥环中缺少私钥材料") + } + if private[0].PrivateKey.Encrypted { + t.Fatal("无口令私钥不应处于加密状态") + } + if private[0].PrimaryIdentity() == nil { + t.Fatal("密钥环中的实体缺少 User ID") + } + + // armored 往返(公钥):公钥密钥环不应带私钥材料。 + public, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(alicePublicArmor)) + if err != nil { + t.Fatalf("ReadArmoredKeyRing(公钥) 失败: %v", err) + } + if len(public) != 1 { + t.Fatalf("期望 1 个实体,实际 %d", len(public)) + } + if public[0].PrivateKey != nil { + t.Fatal("公钥密钥环不应包含私钥材料") + } + if public[0].PrimaryKey.KeyId != private[0].PrimaryKey.KeyId { + t.Fatal("公私钥 KeyId 不一致") + } + + // ReadKeyRing 自动识别 binary-armored / armor 两种输入。 + auto, err := gpgx.ReadKeyRing(bytes.NewReader(alicePrivateArmor)) + if err != nil { + t.Fatalf("ReadKeyRing(armored) 失败: %v", err) + } + if len(auto) != 1 || auto[0].PrimaryKey.KeyId != private[0].PrimaryKey.KeyId { + t.Fatal("ReadKeyRing 未正确识别 armored 输入") + } + + // 二进制形式:armored 私钥去掉 armor 后应能被 ReadKeyRing 解析。 + binaryKeyRing := decodeArmor(t, alicePrivateArmor) + fromBinary, err := gpgx.ReadKeyRing(bytes.NewReader(binaryKeyRing)) + if err != nil { + t.Fatalf("ReadKeyRing(二进制) 失败: %v", err) + } + if len(fromBinary) != 1 || fromBinary[0].PrimaryKey.KeyId != private[0].PrimaryKey.KeyId { + t.Fatal("ReadKeyRing 未正确识别二进制输入") + } +} + +func TestReadKeyRingFile(t *testing.T) { + initTestKeys(t) + requireKeys(t) + + dir := t.TempDir() + armoredPath := filepath.Join(dir, "alice.asc") + if err := os.WriteFile(armoredPath, alicePrivateArmor, 0o600); err != nil { + t.Fatalf("写入 armored 密钥文件失败: %v", err) + } + entities, err := gpgx.ReadKeyRingFile(armoredPath) + if err != nil { + t.Fatalf("ReadKeyRingFile(armored) 失败: %v", err) + } + if len(entities) != 1 || entities[0].PrivateKey == nil { + t.Fatal("ReadKeyRingFile 未读回私钥材料") + } + + binaryPath := filepath.Join(dir, "alice.pgp") + if err := os.WriteFile(binaryPath, decodeArmor(t, alicePrivateArmor), 0o600); err != nil { + t.Fatalf("写入二进制密钥文件失败: %v", err) + } + if _, err := gpgx.ReadKeyRingFile(binaryPath); err != nil { + t.Fatalf("ReadKeyRingFile(二进制) 失败: %v", err) + } + + // 文件不存在。 + mustNotPanic(t, "ReadKeyRingFile(不存在)", func() error { + _, err := gpgx.ReadKeyRingFile(filepath.Join(dir, "missing.asc")) + return err + }) + + // 空文件。 + emptyPath := filepath.Join(dir, "empty.asc") + if err := os.WriteFile(emptyPath, nil, 0o600); err != nil { + t.Fatalf("写入空文件失败: %v", err) + } + mustNotPanic(t, "ReadKeyRingFile(空文件)", func() error { + _, err := gpgx.ReadKeyRingFile(emptyPath) + return err + }) + + // 垃圾内容。 + garbagePath := filepath.Join(dir, "garbage.asc") + if err := os.WriteFile(garbagePath, []byte("this is not a pgp key\n"), 0o600); err != nil { + t.Fatalf("写入垃圾文件失败: %v", err) + } + mustNotPanic(t, "ReadKeyRingFile(垃圾内容)", func() error { + _, err := gpgx.ReadKeyRingFile(garbagePath) + return err + }) +} + +func TestEncryptDecryptRoundTrip(t *testing.T) { + initTestKeys(t) + requireKeys(t) + + cases := []struct { + name string + plaintext []byte + }{ + {"空明文", []byte{}}, + {"普通明文", []byte("hello, openpgp! 你好,世界")}, + {"10KB 明文", generatePlaintext(10 * 1024)}, + } + + for _, tc := range cases { + t.Run("armored/"+tc.name, func(t *testing.T) { + ciphertext, err := gpgx.EncryptArmored(alicePublic, tc.plaintext) + if err != nil { + t.Fatalf("EncryptArmored 失败: %v", err) + } + if !strings.HasPrefix(string(ciphertext), "-----BEGIN PGP MESSAGE-----") { + t.Fatalf("密文 armor 头不正确: %q", firstLine(ciphertext)) + } + + plaintext, err := gpgx.Decrypt(alicePrivate, ciphertext) + if err != nil { + t.Fatalf("Decrypt 失败: %v", err) + } + if !bytes.Equal(plaintext, tc.plaintext) { + t.Fatalf("解密结果不匹配: 期望 %d 字节,实际 %d 字节", len(tc.plaintext), len(plaintext)) + } + }) + + t.Run("binary/"+tc.name, func(t *testing.T) { + ciphertext, err := gpgx.Encrypt(alicePublic, tc.plaintext) + if err != nil { + t.Fatalf("Encrypt 失败: %v", err) + } + if bytes.HasPrefix(ciphertext, []byte("-----BEGIN")) { + t.Fatal("Encrypt 应返回二进制报文,而不是 armor 文本") + } + + plaintext, err := gpgx.Decrypt(alicePrivate, ciphertext) + if err != nil { + t.Fatalf("Decrypt(二进制) 失败: %v", err) + } + if !bytes.Equal(plaintext, tc.plaintext) { + t.Fatalf("解密结果不匹配: 期望 %d 字节,实际 %d 字节", len(tc.plaintext), len(plaintext)) + } + }) + } + + // 多次加密同一明文应产生不同密文(会话密钥随机)。 + first, err := gpgx.EncryptArmored(alicePublic, []byte("same input")) + if err != nil { + t.Fatalf("EncryptArmored 失败: %v", err) + } + second, err := gpgx.EncryptArmored(alicePublic, []byte("same input")) + if err != nil { + t.Fatalf("EncryptArmored 失败: %v", err) + } + if bytes.Equal(first, second) { + t.Fatal("两次加密产生了完全相同的密文,会话密钥可能未随机化") + } +} + +func TestDecryptErrors(t *testing.T) { + initTestKeys(t) + requireKeys(t) + + ciphertext, err := gpgx.EncryptArmored(alicePublic, []byte("top secret payload")) + if err != nil { + t.Fatalf("EncryptArmored 失败: %v", err) + } + + // 用另一个密钥环解密。 + mustNotPanic(t, "错误密钥环解密", func() error { + _, err := gpgx.Decrypt(bobPublic, ciphertext) + return err + }) + + // 篡改密文 1 字节。 + mustNotPanic(t, "篡改密文解密", func() error { + _, err := gpgx.Decrypt(alicePrivate, tamperByte(ciphertext, len(ciphertext)/2)) + return err + }) + + // 丢掉 armor 尾部(截断密文)。 + mustNotPanic(t, "截断密文解密", func() error { + _, err := gpgx.Decrypt(alicePrivate, ciphertext[:len(ciphertext)/2]) + return err + }) + + // 空密文与垃圾字节。 + mustNotPanic(t, "空密文解密", func() error { + _, err := gpgx.Decrypt(alicePrivate, nil) + return err + }) + mustNotPanic(t, "垃圾密文解密", func() error { + _, err := gpgx.Decrypt(alicePrivate, []byte("not a pgp message at all")) + return err + }) + mustNotPanic(t, "随机字节解密", func() error { + _, err := gpgx.Decrypt(alicePrivate, generatePlaintext(256)) + return err + }) + + // 空密钥环。 + mustNotPanic(t, "空密钥环解密", func() error { + _, err := gpgx.Decrypt(nil, ciphertext) + return err + }) + + // 加密侧的参数校验。 + mustNotPanic(t, "空接收方加密", func() error { + _, err := gpgx.Encrypt(nil, []byte("x")) + return err + }) + mustNotPanic(t, "空接收方加密(armored)", func() error { + _, err := gpgx.EncryptArmored(openpgp.EntityList{}, []byte("x")) + return err + }) + mustNotPanic(t, "仅有私钥的接收方加密", func() error { + _, err := gpgx.Encrypt(openpgp.EntityList{}, []byte("x")) + return err + }) +} + +func TestSignVerifyRoundTrip(t *testing.T) { + initTestKeys(t) + requireKeys(t) + + cases := [][]byte{ + {}, + []byte("message to be signed"), + generatePlaintext(4096), + } + for _, plaintext := range cases { + signed, err := gpgx.Sign(alicePrivate, plaintext, nil) + if err != nil { + t.Fatalf("Sign 失败: %v", err) + } + got, err := gpgx.Verify(alicePublic, signed) + if err != nil { + t.Fatalf("Verify 失败: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("验签返回的明文不匹配: 期望 %d 字节,实际 %d 字节", len(plaintext), len(got)) + } + } + + // 私钥环也可以验签(其中包含公钥)。 + signed, err := gpgx.Sign(alicePrivate, []byte("verify with private keyring"), nil) + if err != nil { + t.Fatalf("Sign 失败: %v", err) + } + if _, err := gpgx.Verify(alicePrivate, signed); err != nil { + t.Fatalf("使用私钥环 Verify 失败: %v", err) + } + + // 分离签名。 + data := []byte("detached signature payload") + signature, err := gpgx.SignDetachedArmored(alicePrivate, data, nil) + if err != nil { + t.Fatalf("SignDetachedArmored 失败: %v", err) + } + if !strings.HasPrefix(string(signature), "-----BEGIN PGP SIGNATURE-----") { + t.Fatalf("分离签名 armor 头不正确: %q", firstLine(signature)) + } + signer, err := gpgx.VerifyDetached(alicePublic, data, signature) + if err != nil { + t.Fatalf("VerifyDetached 失败: %v", err) + } + if signer == nil || signer.PrimaryKey.KeyId != alicePublic[0].PrimaryKey.KeyId { + t.Fatal("VerifyDetached 返回的签名者不正确") + } + + // 二进制分离签名同样可验证。 + binarySignature := decodeArmor(t, signature) + if _, err := gpgx.VerifyDetached(alicePublic, data, binarySignature); err != nil { + t.Fatalf("VerifyDetached(二进制签名) 失败: %v", err) + } +} + +func TestVerifyTamperedMessageAndWrongKey(t *testing.T) { + initTestKeys(t) + requireKeys(t) + + data := []byte("important signed statement") + signature, err := gpgx.SignDetachedArmored(alicePrivate, data, nil) + if err != nil { + t.Fatalf("SignDetachedArmored 失败: %v", err) + } + + // 篡改被签名的消息。 + mustNotPanic(t, "篡改消息后验签", func() error { + _, err := gpgx.VerifyDetached(alicePublic, tamperByte(data, 3), signature) + return err + }) + + // 篡改签名本身。 + mustNotPanic(t, "篡改签名后验签", func() error { + _, err := gpgx.VerifyDetached(alicePublic, data, tamperByte(signature, len(signature)/2)) + return err + }) + + // 用错误的公钥环验签。 + mustNotPanic(t, "错误公钥环验签", func() error { + _, err := gpgx.VerifyDetached(bobPublic, data, signature) + return err + }) + + // 空/垃圾输入。 + mustNotPanic(t, "空签名验签", func() error { + _, err := gpgx.VerifyDetached(alicePublic, data, nil) + return err + }) + mustNotPanic(t, "垃圾签名验签", func() error { + _, err := gpgx.VerifyDetached(alicePublic, data, []byte("garbage signature")) + return err + }) + mustNotPanic(t, "空密钥环验签", func() error { + _, err := gpgx.VerifyDetached(nil, data, signature) + return err + }) + + // 内联签名报文的错误路径。 + signed, err := gpgx.Sign(alicePrivate, data, nil) + if err != nil { + t.Fatalf("Sign 失败: %v", err) + } + mustNotPanic(t, "篡改内联签名报文", func() error { + _, err := gpgx.Verify(alicePublic, tamperByte(signed, len(signed)/2)) + return err + }) + mustNotPanic(t, "错误密钥环验证内联签名", func() error { + _, err := gpgx.Verify(bobPublic, signed) + return err + }) + mustNotPanic(t, "空报文验签", func() error { + _, err := gpgx.Verify(alicePublic, nil) + return err + }) + mustNotPanic(t, "垃圾报文验签", func() error { + _, err := gpgx.Verify(alicePublic, []byte("definitely not signed")) + return err + }) + // 未签名的加密报文不能被当作签名报文验证。 + ciphertext, err := gpgx.EncryptArmored(alicePublic, data) + if err != nil { + t.Fatalf("EncryptArmored 失败: %v", err) + } + mustNotPanic(t, "加密报文当作签名报文", func() error { + _, err := gpgx.Verify(alicePublic, ciphertext) + return err + }) +} + +func TestPassphraseProtectedKey(t *testing.T) { + initTestKeys(t) + requireKeys(t) + if len(lockedPrivate) == 0 { + t.Fatal("带口令的测试密钥不可用") + } + + // 口令必须真的生效:读回来的私钥应处于加密状态。 + locked, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(lockedPrivate)) + if err != nil { + t.Fatalf("ReadArmoredKeyRing(带口令私钥) 失败: %v", err) + } + if locked[0].PrivateKey == nil || !locked[0].PrivateKey.Encrypted { + t.Fatal("GenerateKeyPair 未加密私钥") + } + lockedPublic, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(lockedPublicArmor)) + if err != nil { + t.Fatalf("ReadArmoredKeyRing(带口令公钥) 失败: %v", err) + } + + data := []byte("passphrase protected payload") + + // 正确口令:签名 + 验签。 + signature, err := gpgx.SignDetachedArmored(locked, data, passphrase) + if err != nil { + t.Fatalf("使用正确口令签名失败: %v", err) + } + if _, err := gpgx.VerifyDetached(lockedPublic, data, signature); err != nil { + t.Fatalf("验签失败: %v", err) + } + + // 正确口令:内联签名。 + signed, err := gpgx.Sign(locked, data, passphrase) + if err != nil { + t.Fatalf("使用正确口令生成内联签名失败: %v", err) + } + if got, err := gpgx.Verify(lockedPublic, signed); err != nil || !bytes.Equal(got, data) { + t.Fatalf("内联签名往返失败: err=%v", err) + } + + // 正确口令:解密。 + ciphertext, err := gpgx.EncryptArmored(lockedPublic, data) + if err != nil { + t.Fatalf("EncryptArmored 失败: %v", err) + } + plaintext, err := gpgx.DecryptWithPassphrase(locked, ciphertext, passphrase) + if err != nil { + t.Fatalf("使用正确口令解密失败: %v", err) + } + if !bytes.Equal(plaintext, data) { + t.Fatal("使用正确口令解密的结果不匹配") + } + + // 错误口令:签名与解密都必须失败且不 panic。 + wrong := []byte("totally wrong passphrase") + mustNotPanic(t, "错误口令签名", func() error { + _, err := gpgx.Sign(locked, data, wrong) + return err + }) + mustNotPanic(t, "错误口令生成分离签名", func() error { + _, err := gpgx.SignDetachedArmored(locked, data, wrong) + return err + }) + mustNotPanic(t, "错误口令解密", func() error { + _, err := gpgx.DecryptWithPassphrase(locked, ciphertext, wrong) + return err + }) + mustNotPanic(t, "未提供口令解密", func() error { + _, err := gpgx.DecryptWithPassphrase(locked, ciphertext, nil) + return err + }) + mustNotPanic(t, "未提供口令签名", func() error { + _, err := gpgx.Sign(locked, data, nil) + return err + }) + + // 关键回归断言:上面的失败尝试不得破坏调用方传入的密钥环 + // (实现应在副本上解密私钥)。 + plaintext, err = gpgx.DecryptWithPassphrase(locked, ciphertext, passphrase) + if err != nil { + t.Fatalf("错误口令尝试后,正确口令解密失败(密钥环被污染): %v", err) + } + if !bytes.Equal(plaintext, data) { + t.Fatal("错误口令尝试后,正确口令解密的结果不匹配") + } + if _, err := gpgx.Sign(locked, data, passphrase); err != nil { + t.Fatalf("错误口令尝试后,正确口令签名失败(密钥环被污染): %v", err) + } +} + +// TestRSAKeyPairRoundTrip 覆盖 RSA 密钥路径(GenerateKeyPair 的 bits > 0 分支)。 +func TestRSAKeyPairRoundTrip(t *testing.T) { + rsaPrivateArmor, rsaPublicArmor, err := gpgx.GenerateKeyPair( + "RSA Tester", "rsa@example.com", "gpgx rsa", nil, gpgx.MinRSABits) + if err != nil { + t.Fatalf("GenerateKeyPair(RSA %d) 失败: %v", gpgx.MinRSABits, err) + } + + private, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(rsaPrivateArmor)) + if err != nil { + t.Fatalf("ReadArmoredKeyRing(RSA 私钥) 失败: %v", err) + } + public, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(rsaPublicArmor)) + if err != nil { + t.Fatalf("ReadArmoredKeyRing(RSA 公钥) 失败: %v", err) + } + + data := []byte("rsa payload") + ciphertext, err := gpgx.EncryptArmored(public, data) + if err != nil { + t.Fatalf("EncryptArmored(RSA) 失败: %v", err) + } + plaintext, err := gpgx.Decrypt(private, ciphertext) + if err != nil { + t.Fatalf("Decrypt(RSA) 失败: %v", err) + } + if !bytes.Equal(plaintext, data) { + t.Fatal("RSA 加解密结果不匹配") + } + + signature, err := gpgx.SignDetachedArmored(private, data, nil) + if err != nil { + t.Fatalf("SignDetachedArmored(RSA) 失败: %v", err) + } + if _, err := gpgx.VerifyDetached(public, data, signature); err != nil { + t.Fatalf("VerifyDetached(RSA) 失败: %v", err) + } +} + +// TestKeyRingNotMutatedByFailedUnlock 断言解锁失败不会污染调用方传入的密钥环。 +func TestKeyRingNotMutatedByFailedUnlock(t *testing.T) { + initTestKeys(t) + requireKeys(t) + if len(lockedPrivate) == 0 { + t.Fatal("带口令的测试密钥不可用") + } + + locked, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(lockedPrivate)) + if err != nil { + t.Fatalf("ReadArmoredKeyRing 失败: %v", err) + } + if !locked[0].PrivateKey.Encrypted { + t.Fatal("测试前提不成立:私钥应为加密状态") + } + + // 未提供口令解密必须失败。 + mustNotPanic(t, "加密私钥无口令解密", func() error { + _, err := gpgx.Decrypt(locked, []byte("whatever")) + return err + }) + // 失败后调用方密钥环中的私钥仍应保持加密状态。 + if !locked[0].PrivateKey.Encrypted { + t.Fatal("失败的解密尝试修改了调用方密钥环(私钥被解锁)") + } + + // 后续用正确口令仍可正常使用。 + public, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(lockedPublicArmor)) + if err != nil { + t.Fatalf("ReadArmoredKeyRing(公钥) 失败: %v", err) + } + data := []byte("still usable after failures") + ciphertext, err := gpgx.EncryptArmored(public, data) + if err != nil { + t.Fatalf("EncryptArmored 失败: %v", err) + } + plaintext, err := gpgx.DecryptWithPassphrase(locked, ciphertext, passphrase) + if err != nil { + t.Fatalf("DecryptWithPassphrase 失败: %v", err) + } + if !bytes.Equal(plaintext, data) { + t.Fatal("解密结果不匹配") + } +} + +// TestSymmetricPassphraseRoundTrip 覆盖 gpg -c 风格的口令对称加解密。 +func TestSymmetricPassphraseRoundTrip(t *testing.T) { + initTestKeys(t) + requireKeys(t) + + data := []byte("symmetrically encrypted payload 对称加密") + ciphertext, err := gpgx.EncryptWithPassphrase(passphrase, data) + if err != nil { + t.Fatalf("EncryptWithPassphrase 失败: %v", err) + } + if bytes.HasPrefix(ciphertext, []byte("-----BEGIN")) { + t.Fatal("EncryptWithPassphrase 应返回二进制报文") + } + + // 对称加密的密文可以被任意密钥环(甚至空密钥环之外的调用)解密, + // 只要口令正确。 + got, err := gpgx.DecryptWithPassphrase(alicePrivate, ciphertext, passphrase) + if err != nil { + t.Fatalf("DecryptWithPassphrase(对称) 失败: %v", err) + } + if !bytes.Equal(got, data) { + t.Fatal("对称加解密结果不匹配") + } + + mustNotPanic(t, "错误口令对称解密", func() error { + _, err := gpgx.DecryptWithPassphrase(alicePrivate, ciphertext, []byte("wrong passphrase")) + return err + }) + mustNotPanic(t, "空口令对称解密", func() error { + _, err := gpgx.DecryptWithPassphrase(alicePrivate, ciphertext, nil) + return err + }) + mustNotPanic(t, "空口令对称加密", func() error { + _, err := gpgx.EncryptWithPassphrase(nil, data) + return err + }) + mustNotPanic(t, "篡改对称密文", func() error { + _, err := gpgx.DecryptWithPassphrase(alicePrivate, tamperByte(ciphertext, len(ciphertext)/2), passphrase) + return err + }) +} + +func TestGenerateKeyPairUnencryptedWithPassphrase(t *testing.T) { + initTestKeys(t) + requireKeys(t) + + // 私钥未加密时传入口令应被忽略,而不是报错。 + data := []byte("unencrypted key with passphrase argument") + signature, err := gpgx.SignDetachedArmored(alicePrivate, data, []byte("irrelevant")) + if err != nil { + t.Fatalf("未加密私钥传入口令时签名失败: %v", err) + } + if _, err := gpgx.VerifyDetached(alicePublic, data, signature); err != nil { + t.Fatalf("验签失败: %v", err) + } +} + +func TestReadKeyRingInvalidInput(t *testing.T) { + inputs := map[string][]byte{ + "nil 输入": nil, + "空输入": {}, + "垃圾字节": []byte("garbage not a keyring"), + "伪 armor": []byte("-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nnot base64!!!\n-----END PGP PUBLIC KEY BLOCK-----\n"), + "随机字节": generatePlaintext(512), + } + + for name, input := range inputs { + input := input + mustNotPanic(t, "ReadKeyRing("+name+")", func() error { + _, err := gpgx.ReadKeyRing(bytes.NewReader(input)) + return err + }) + mustNotPanic(t, "ReadArmoredKeyRing("+name+")", func() error { + _, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(input)) + return err + }) + } + + // ReadArmoredKeyRing 应拒绝二进制密钥环。 + initTestKeys(t) + requireKeys(t) + mustNotPanic(t, "ReadArmoredKeyRing(二进制密钥环)", func() error { + _, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(decodeArmor(t, alicePrivateArmor))) + return err + }) +} + +func TestGenerateKeyPairParameterValidation(t *testing.T) { + // 弱 RSA 密钥必须被拒绝,而不是生成。 + mustNotPanic(t, "GenerateKeyPair(1024)", func() error { + _, _, err := gpgx.GenerateKeyPair("Weak", "weak@example.com", "", nil, 1024) + return err + }) + // bits <= 0:按文档生成 Ed25519(这是合法调用,不应报错)。 + if _, _, err := gpgx.GenerateKeyPair("Negative", "neg@example.com", "", nil, -100); err != nil { + t.Fatalf("GenerateKeyPair(bits=-100) 应生成 Ed25519 密钥,实际报错: %v", err) + } + // 非法 User ID 字符必须报错而不是 panic。 + mustNotPanic(t, "GenerateKeyPair(非法 User ID)", func() error { + _, _, err := gpgx.GenerateKeyPair("Bad (Name)", "bad@example.com", "", nil, 0) + return err + }) +} + +// TestNoGoroutineOrFileLeak 做一个轻量的泄漏哨兵:所有操作都应在有限时间内 +// 结束(若存在未关闭的管道/句柄导致阻塞,此测试会超时失败)。 +func TestNoGoroutineOrFileLeak(t *testing.T) { + initTestKeys(t) + requireKeys(t) + + for i := 0; i < 5; i++ { + ciphertext, err := gpgx.EncryptArmored(alicePublic, generatePlaintext(1024)) + if err != nil { + t.Fatalf("第 %d 轮 EncryptArmored 失败: %v", i, err) + } + if _, err := gpgx.Decrypt(alicePrivate, ciphertext); err != nil { + t.Fatalf("第 %d 轮 Decrypt 失败: %v", i, err) + } + signed, err := gpgx.Sign(alicePrivate, []byte("leak check"), nil) + if err != nil { + t.Fatalf("第 %d 轮 Sign 失败: %v", i, err) + } + if _, err := gpgx.Verify(alicePublic, signed); err != nil { + t.Fatalf("第 %d 轮 Verify 失败: %v", i, err) + } + } +} + +// -------------------------------------------------------------------- 辅助 + +func firstLine(data []byte) string { + if idx := bytes.IndexByte(data, '\n'); idx >= 0 { + return string(data[:idx]) + } + return string(data) +} + +// decodeArmor 把 armored 文本转成底层二进制数据(仅用于测试)。 +func decodeArmor(t *testing.T, armored []byte) []byte { + t.Helper() + block, err := armor.Decode(bytes.NewReader(armored)) + if err != nil { + t.Fatalf("解析 armor 失败: %v", err) + } + raw, err := io.ReadAll(block.Body) + if err != nil { + t.Fatalf("读取 armor body 失败: %v", err) + } + return raw +} diff --git a/hash.go b/hash.go new file mode 100644 index 0000000..5ed1c19 --- /dev/null +++ b/hash.go @@ -0,0 +1,49 @@ +package encryptx + +import ( + "fmt" + + "code.yun.ink/pkg/encryptx/blake2x" + "code.yun.ink/pkg/encryptx/md5x" + "code.yun.ink/pkg/encryptx/ripemd160x" + "code.yun.ink/pkg/encryptx/sha1x" + "code.yun.ink/pkg/encryptx/sha256x" + "code.yun.ink/pkg/encryptx/sha3x" + "code.yun.ink/pkg/encryptx/sha512x" +) + +// hashHex 计算哈希并返回小写十六进制字符串。 +// +// 各哈希包都提供统一签名的 Hex([]byte) string,因此这里只是一张映射表; +// 带位数后缀的包(sha256x / sha512x / sha3x / blake2x)按具体变体取函数。 +func hashHex(encryptType EncryptType, data []byte) (string, error) { + switch encryptType { + case MD5: + return md5x.Hex(data), nil + case SHA1: + return sha1x.Hex(data), nil + case SHA224: + return sha256x.Hex224(data), nil + case SHA256: + return sha256x.Hex256(data), nil + case SHA384: + return sha512x.Hex384(data), nil + case SHA512: + return sha512x.Hex512(data), nil + case SHA3_224: + return sha3x.Hex224(data), nil + case SHA3_256: + return sha3x.Hex256(data), nil + case SHA3_384: + return sha3x.Hex384(data), nil + case SHA3_512: + return sha3x.Hex512(data), nil + case BLAKE2B_256: + return blake2x.Hex256B(data), nil + case BLAKE2S_256: + return blake2x.Hex256S(data), nil + case RIPEMD160: + return ripemd160x.Hex(data), nil + } + return "", fmt.Errorf("%w: %s 不是哈希算法", ErrUnsupportedType, encryptType) +} diff --git a/hexx/hexx.go b/hexx/hexx.go new file mode 100644 index 0000000..0d727e1 --- /dev/null +++ b/hexx/hexx.go @@ -0,0 +1,119 @@ +// Package hexx 提供十六进制编解码的语义清晰封装。 +// +// 本包基于 encoding/hex,额外提供: +// +// - 大小写混合解码(Decode 语义上接受 "DEADbeef" 这类输入); +// - 严格大写校验解码(DecodeUpper),用于需要规范化格式的场景; +// - 合法性检查(IsValid)与友好的中文错误信息。 +// +// 安全注意:十六进制是**编码**而非加密,不提供任何机密性, +// 任何人都能还原原文;密钥、口令、随机数等敏感数据即使转成十六进制 +// 也依然是明文,禁止据此认为可以安全地打印或记录到日志。 +// 另外十六进制编码会使数据膨胀一倍,解码不可信输入前应限制输入长度。 +package hexx + +import ( + "encoding/hex" + "fmt" + "strings" +) + +// Encode 把字节串编码为小写十六进制字符串(长度是输入的两倍)。 +// +// 参数: +// - data: 待编码的字节串,可以为 nil(返回空字符串)。 +// +// 返回值:仅含 0-9a-f 的小写十六进制字符串。 +// +// 安全注意:十六进制不提供机密性,编码后的秘密仍是明文,不要写入日志。 +func Encode(data []byte) string { + return hex.EncodeToString(data) +} + +// EncodeUpper 把字节串编码为大写十六进制字符串(长度是输入的两倍)。 +// +// 参数: +// - data: 待编码的字节串,可以为 nil(返回空字符串)。 +// +// 返回值:仅含 0-9A-F 的大写十六进制字符串。 +// +// 安全注意:十六进制不提供机密性;大小写不影响解码结果, +// 但如与外部系统约定固定大小写,请统一使用本函数或 Encode。 +func EncodeUpper(data []byte) string { + return strings.ToUpper(hex.EncodeToString(data)) +} + +// Decode 解码十六进制字符串,语义上接受大小写混合的输入。 +// +// 参数: +// - s: 待解码的十六进制字符串,长度必须为偶数。 +// +// 返回值:解码后的字节串;失败时返回 nil 与包装后的 error。 +// +// 安全注意:失败时不返回部分结果;本函数对 "0x" 前缀、空白字符、 +// 奇数长度都会报错,便于尽早发现被截断或污染的输入。 +func Decode(s string) ([]byte, error) { + decoded, err := hex.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("十六进制解码失败(长度 %d): %w", len(s), err) + } + return decoded, nil +} + +// DecodeUpper 解码十六进制字符串,并要求输入**确实是大写**形式。 +// +// 参数: +// - s: 待解码的十六进制字符串,只能包含 0-9 与 A-F,长度必须为偶数。 +// +// 返回值:解码后的字节串;包含小写字母或非法字符时返回 nil 与包装后的 error。 +// +// 安全注意:用于严格模式(例如校验外部系统给出的规范化大写格式), +// 可避免因大小写不一致导致的比对歧义;普通场景请使用 Decode。 +func DecodeUpper(s string) ([]byte, error) { + if strings.ContainsFunc(s, func(r rune) bool { + return r >= 'a' && r <= 'f' + }) { + return nil, fmt.Errorf("十六进制解码(严格大写)失败: 输入包含小写字母") + } + decoded, err := hex.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("十六进制解码(严格大写)失败(长度 %d): %w", len(s), err) + } + return decoded, nil +} + +// IsValid 判断字符串是否为合法的十六进制串。 +// +// 参数: +// - s: 待检查的字符串。 +// +// 返回值:长度偶数且每个字符都属于 [0-9a-fA-F] 时返回 true; +// 空串、奇数长度、含非法字符或空白时返回 false。 +// +// 安全注意:空串被视为非法,避免把"缺失的字段"误判为合法值; +// 本函数只做格式检查,不代表内容可信,来源不明的数据仍需按不可信输入处理。 +func IsValid(s string) bool { + if len(s) == 0 || len(s)%2 != 0 { + return false + } + for i := 0; i < len(s); i++ { + if !isHexDigit(s[i]) { + return false + } + } + return true +} + +// isHexDigit 判断字节是否为十六进制数字字符(0-9、a-f、A-F)。 +func isHexDigit(c byte) bool { + switch { + case c >= '0' && c <= '9': + return true + case c >= 'a' && c <= 'f': + return true + case c >= 'A' && c <= 'F': + return true + default: + return false + } +} diff --git a/hexx/hexx_test.go b/hexx/hexx_test.go new file mode 100644 index 0000000..8e94587 --- /dev/null +++ b/hexx/hexx_test.go @@ -0,0 +1,279 @@ +package hexx_test + +import ( + "bytes" + "encoding/hex" + "strings" + "testing" + + "code.yun.ink/pkg/encryptx/hexx" +) + +// payloads 覆盖空、单字节、ASCII、UTF-8 与全字节值等输入。 +var payloads = [][]byte{ + nil, + {}, + {0x00}, + {0x0f, 0xf0}, + []byte("Hello, World!"), + []byte("中文与 emoji 🚀"), + bytes.Repeat([]byte{0xde, 0xad, 0xbe, 0xef}, 64), + func() []byte { + b := make([]byte, 256) + for i := range b { + b[i] = byte(i) + } + return b + }(), +} + +// TestRoundTrip 校验大小写两种编码都能往返还原。 +func TestRoundTrip(t *testing.T) { + for i, payload := range payloads { + lower := hexx.Encode(payload) + upper := hexx.EncodeUpper(payload) + + if len(lower) != len(payload)*2 { + t.Errorf("用例 %d: Encode 长度 = %d, 期望 %d", i, len(lower), len(payload)*2) + } + if lower != strings.ToLower(lower) { + t.Errorf("用例 %d: Encode 应输出小写: %q", i, lower) + } + if upper != strings.ToUpper(upper) { + t.Errorf("用例 %d: EncodeUpper 应输出大写: %q", i, upper) + } + if !strings.EqualFold(lower, upper) { + t.Errorf("用例 %d: 大小写编码内容不一致: %q vs %q", i, lower, upper) + } + + decodedLower, err := hexx.Decode(lower) + if err != nil { + t.Fatalf("用例 %d: Decode(小写) 失败: %v", i, err) + } + if !bytes.Equal(decodedLower, payload) { + t.Errorf("用例 %d: Decode(小写) 往返不一致", i) + } + + decodedUpper, err := hexx.DecodeUpper(upper) + if err != nil { + t.Fatalf("用例 %d: DecodeUpper(大写) 失败: %v", i, err) + } + if !bytes.Equal(decodedUpper, payload) { + t.Errorf("用例 %d: DecodeUpper 往返不一致", i) + } + + // 大写编码也必须能被宽松的 Decode 正确解码。 + decodedLoose, err := hexx.Decode(upper) + if err != nil { + t.Fatalf("用例 %d: Decode(大写) 失败: %v", i, err) + } + if !bytes.Equal(decodedLoose, payload) { + t.Errorf("用例 %d: Decode(大写) 往返不一致", i) + } + } +} + +// TestDecodeMixedCase 校验 Decode 在语义上接受大小写混合输入。 +func TestDecodeMixedCase(t *testing.T) { + cases := []struct { + in string + want []byte + }{ + {"deadbeef", []byte{0xde, 0xad, 0xbe, 0xef}}, + {"DEADBEEF", []byte{0xde, 0xad, 0xbe, 0xef}}, + {"DeAdBeEf", []byte{0xde, 0xad, 0xbe, 0xef}}, + {"dEaDbEeF", []byte{0xde, 0xad, 0xbe, 0xef}}, + {"0a1B2c3D", []byte{0x0a, 0x1b, 0x2c, 0x3d}}, + } + for _, c := range cases { + got, err := hexx.Decode(c.in) + if err != nil { + t.Errorf("Decode(%q) 返回错误: %v", c.in, err) + continue + } + if !bytes.Equal(got, c.want) { + t.Errorf("Decode(%q) = %x, 期望 %x", c.in, got, c.want) + } + } +} + +// TestEncodeMatchesStdlib 与标准库交叉验证,确保封装未引入行为差异。 +func TestEncodeMatchesStdlib(t *testing.T) { + for i, payload := range payloads { + if got, want := hexx.Encode(payload), hex.EncodeToString(payload); got != want { + t.Errorf("用例 %d: Encode = %q, 标准库 = %q", i, got, want) + } + if got, want := hexx.EncodeUpper(payload), strings.ToUpper(hex.EncodeToString(payload)); got != want { + t.Errorf("用例 %d: EncodeUpper = %q, 标准库 = %q", i, got, want) + } + } +} + +// TestIsValid 校验 IsValid 对合法与非法输入的判定。 +func TestIsValid(t *testing.T) { + valid := []string{ + "00", "ff", "FF", "deadbeef", "DEADBEEF", "DeAdBeEf", + "0123456789abcdefABCDEF", + } + for _, s := range valid { + if !hexx.IsValid(s) { + t.Errorf("IsValid(%q) = false, 期望 true", s) + } + } + + invalid := []struct { + name string + in string + }{ + {"空串", ""}, + {"奇数长度", "a"}, + {"奇数长度三字符", "abc"}, + {"奇数长度长串", strings.Repeat("ab", 20) + "c"}, + {"非十六进制字母", "zz"}, + {"含 g", "0g"}, + {"含 0x 前缀", "0x1f"}, + {"含空格", "de ad"}, + {"含换行", "dead\n"}, + {"含连字符", "de-ad"}, + {"非 ASCII 字符", "中文"}, + {"emoji", "🚀"}, + {"含 tab", "aa\tbb"}, + } + for _, c := range invalid { + if hexx.IsValid(c.in) { + t.Errorf("IsValid(%q)(%s)= true, 期望 false", c.in, c.name) + } + } +} + +// TestDecodeRejectsInvalid 校验 Decode 对非法输入返回 nil 与 error。 +func TestDecodeRejectsInvalid(t *testing.T) { + invalid := []struct { + name string + in string + }{ + {"奇数长度", "abc"}, + {"奇数长度单字符", "a"}, + {"非十六进制字符", "zz"}, + {"包含 g", "0g"}, + {"0x 前缀", "0x1f"}, + {"含空格", "de ad"}, + {"含换行", "beef\n"}, + {"含连字符", "de-ad"}, + {"非 ASCII", "中文"}, + {"emoji", "🚀"}, + {"长度与内容都非法", "xyz"}, + } + + for _, c := range invalid { + got, err := hexx.Decode(c.in) + if err == nil { + t.Errorf("Decode(%q)(%s)应当失败,却成功得到 %x", c.in, c.name, got) + } + if got != nil { + t.Errorf("Decode(%q)(%s)失败时应返回 nil,实际 %x", c.in, c.name, got) + } + if err != nil && err.Error() == "" { + t.Errorf("Decode(%q) 返回了空错误信息", c.in) + } + } +} + +// TestDecodeUpperStrict 校验 DecodeUpper 在语义上要求输入是大写形式。 +func TestDecodeUpperStrict(t *testing.T) { + // 纯大写(含数字)必须成功。 + uppers := []struct { + in string + want []byte + }{ + {"DEADBEEF", []byte{0xde, 0xad, 0xbe, 0xef}}, + {"00FF", []byte{0x00, 0xff}}, + {"0A1B2C3D", []byte{0x0a, 0x1b, 0x2c, 0x3d}}, + } + for _, c := range uppers { + got, err := hexx.DecodeUpper(c.in) + if err != nil { + t.Errorf("DecodeUpper(%q) 返回错误: %v", c.in, err) + continue + } + if !bytes.Equal(got, c.want) { + t.Errorf("DecodeUpper(%q) = %x, 期望 %x", c.in, got, c.want) + } + } + + // 含小写字母的输入必须被拒绝,并返回 nil。 + rejected := []string{"deadbeef", "DeadBeef", "DEADbeef", "de"} + for _, in := range rejected { + got, err := hexx.DecodeUpper(in) + if err == nil { + t.Errorf("DecodeUpper(%q) 应当失败(含小写字母),却成功得到 %x", in, got) + } + if got != nil { + t.Errorf("DecodeUpper(%q) 失败时应返回 nil,实际 %x", in, got) + } + } + + // 大写但仍非法的输入同样必须失败。 + for _, in := range []string{"0X1F", "GG", "ABC", "DE AD"} { + got, err := hexx.DecodeUpper(in) + if err == nil { + t.Errorf("DecodeUpper(%q) 应当失败,却成功得到 %x", in, got) + } + if got != nil { + t.Errorf("DecodeUpper(%q) 失败时应返回 nil,实际 %x", in, got) + } + } +} + +// TestDecodeEmpty 校验空串的边界语义。 +func TestDecodeEmpty(t *testing.T) { + got, err := hexx.Decode("") + if err != nil { + t.Errorf("Decode(\"\") 应成功: %v", err) + } + if len(got) != 0 { + t.Errorf("Decode(\"\") 得到 %d 字节,期望 0", len(got)) + } + // IsValid 明确把空串视为非法。 + if hexx.IsValid("") { + t.Error("IsValid(\"\") 应为 false") + } +} + +// TestNoPanicOnAnyInput 用一批畸形输入整体验证不 panic。 +func TestNoPanicOnAnyInput(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("畸形输入导致 panic: %v", r) + } + }() + + inputs := []string{ + "", "0", "00", "000", strings.Repeat("f", 511), strings.Repeat("F", 512), + "\x00\x01\x02", "%$#@!", "ff", "🇨🇳", "AB", + } + for _, in := range inputs { + _ = hexx.IsValid(in) + _, _ = hexx.Decode(in) + _, _ = hexx.DecodeUpper(in) + _ = hexx.Encode([]byte(in)) + _ = hexx.EncodeUpper([]byte(in)) + } +} + +// TestEncodeDecodeNil 校验 nil 输入的处理。 +func TestEncodeDecodeNil(t *testing.T) { + if got := hexx.Encode(nil); got != "" { + t.Errorf("Encode(nil) = %q, 期望空串", got) + } + if got := hexx.EncodeUpper(nil); got != "" { + t.Errorf("EncodeUpper(nil) = %q, 期望空串", got) + } + got, err := hexx.Decode(hexx.Encode(nil)) + if err != nil { + t.Errorf("Decode(Encode(nil)) 失败: %v", err) + } + if len(got) != 0 { + t.Errorf("Decode(Encode(nil)) 得到 %d 字节,期望 0", len(got)) + } +} diff --git a/hkdfx/hkdfx.go b/hkdfx/hkdfx.go new file mode 100644 index 0000000..8b3bb82 --- /dev/null +++ b/hkdfx/hkdfx.go @@ -0,0 +1,85 @@ +// Package hkdfx 基于 HKDF(RFC 5869)实现 "extract-then-expand" 密钥派生。 +// +// 适用场景:把已有的高熵密钥材料(ECDH/X25519 共享密钥、KMS 下发的随机 +// master key、随机生成的根密钥)扩展成多个用途不同的子密钥,或用 info 做 +// 密钥分离。 +// +// 安全注意事项: +// - HKDF 不是慢哈希,没有任何迭代与内存硬化环节,绝对不能用于低熵口令。 +// 用 HKDF 处理用户口令等于把口令直接当密钥,攻击者可离线高速穷举; +// 口令请使用 argon2x(首选)或 scryptx。 +// - secret 必须是非空的高熵密钥材料;空材料被拒绝。 +// - salt 可以为 nil:此时按 RFC 5869 使用 HashLen 个零字节作为盐。salt 不必 +// 保密,但应当唯一,用于隔离不同会话/不同对端。 +// - info 是公开的上下文标签(如 "aes-key" / "hmac-key")。用同一份 secret +// 派生多个子密钥时必须给出互不相同的 info,否则会得到相同的密钥。 +// - 一次派生的长度不能超过 255*HashLen 字节(HKDF-SHA256 即 MaxKeyLength), +// 超过该上限在密码学上无意义,本包直接拒绝。 +package hkdfx + +import ( + "crypto/sha256" + "fmt" + "hash" + "io" + + "golang.org/x/crypto/hkdf" +) + +const ( + // DefaultKeyLength 是推荐的派生长度(字节),32 字节可直接作为 AES-256 + // 或 HMAC-SHA256 的密钥。 + DefaultKeyLength = 32 + + // MaxKeyLength 是 HKDF-SHA256 单次可派生的最大长度(字节),等于 + // 255 * SHA-256 输出长度 32。使用其他哈希时真实上限为 255*HashLen, + // DeriveKeyWithHash 会按传入哈希的实际输出长度校验。 + MaxKeyLength = 8160 +) + +// DeriveKey 使用 HKDF-SHA256 从高熵 secret 派生 keyLength 字节密钥。 +// +// secret 为输入密钥材料,必须非空;salt 可以为 nil(按 RFC 5869 使用全零盐); +// info 是用途/上下文标签,可以为 nil。keyLength 必须落在 [1, MaxKeyLength]。 +// +// 派生结果是 secret、salt、info 三者的确定性函数:三者相同则结果相同,任一不同 +// 则结果不同,因此调用方只要保存 salt 与 info(它们是公开值)即可复现。 +// +// 再次强调:不要用本函数处理用户口令,请改用 argon2x/scryptx。 +func DeriveKey(secret, salt, info []byte, keyLength int) ([]byte, error) { + return DeriveKeyWithHash(secret, salt, info, keyLength, sha256.New) +} + +// DeriveKeyWithHash 与 DeriveKey 相同,但允许指定底层 HMAC 哈希(如 sha256.New、 +// sha512.New、sha1.New)。h 为 nil 时返回 error。 +// +// 输出上限按 255*h().Size() 计算:SHA-256 为 8160 字节,SHA-512 为 16320 字节。 +// 安全提示:更换哈希会派生完全不同的密钥,属于不兼容变更;SHA-1 仅用于兼容旧协议。 +func DeriveKeyWithHash(secret, salt, info []byte, keyLength int, h func() hash.Hash) ([]byte, error) { + if len(secret) == 0 { + return nil, fmt.Errorf("hkdfx: 输入密钥材料 secret 不能为空") + } + if h == nil { + return nil, fmt.Errorf("hkdfx: 哈希构造函数不能为 nil") + } + if keyLength <= 0 { + return nil, fmt.Errorf("hkdfx: 派生长度 %d 必须大于 0", keyLength) + } + + hashSize := h().Size() + if hashSize <= 0 { + return nil, fmt.Errorf("hkdfx: 哈希输出长度非法: %d", hashSize) + } + // RFC 5869 的 L 上限为 255*HashLen;超出时底层 Reader 会返回 + // "hkdf: entropy limit reached",这里提前给出更明确的错误。 + if maxLen := 255 * hashSize; keyLength > maxLen { + return nil, fmt.Errorf("hkdfx: 派生长度 %d 超过该哈希的上限 %d (%d*%d)", keyLength, maxLen, 255, hashSize) + } + + out := make([]byte, keyLength) + // hkdf.New 内部执行 Extract + Expand;salt 为 nil 时按标准使用全零盐。 + if _, err := io.ReadFull(hkdf.New(h, secret, salt, info), out); err != nil { + return nil, fmt.Errorf("hkdfx: 派生密钥失败: %w", err) + } + return out, nil +} diff --git a/hkdfx/hkdfx_test.go b/hkdfx/hkdfx_test.go new file mode 100644 index 0000000..26fcd94 --- /dev/null +++ b/hkdfx/hkdfx_test.go @@ -0,0 +1,255 @@ +package hkdfx_test + +import ( + "bytes" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "hash" + "math" + "testing" + + "code.yun.ink/pkg/encryptx/hkdfx" +) + +// seq 生成从 start 开始、长度为 n 的连续字节,用于构造 RFC 5869 的测试输入。 +func seq(start byte, n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = start + byte(i) + } + return b +} + +func mustHexDecode(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("测试向量不是合法十六进制: %v", err) + } + return b +} + +// TestDeriveKeyRFC5869Vectors 使用 RFC 5869 附录 A 的官方向量做已知答案测试。 +// 这些向量同时覆盖了 SHA-256 与 SHA-1、空盐以及 82 字节的长输出。 +func TestDeriveKeyRFC5869Vectors(t *testing.T) { + cases := []struct { + name string + secret []byte + salt []byte + info []byte + keyLength int + useSHA1 bool + want string + }{ + { + name: "RFC5869-A.1 SHA-256 基本向量", + secret: bytes.Repeat([]byte{0x0b}, 22), + salt: seq(0x00, 13), + info: seq(0xf0, 10), + keyLength: 42, + want: "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865", + }, + { + name: "RFC5869-A.2 SHA-256 长输入长输出", + secret: seq(0x00, 80), + salt: seq(0x60, 80), + info: seq(0xb0, 80), + keyLength: 82, + want: "b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87", + }, + { + name: "RFC5869-A.3 SHA-256 空盐空 info", + secret: bytes.Repeat([]byte{0x0b}, 22), + salt: nil, + info: nil, + keyLength: 42, + want: "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8", + }, + { + name: "RFC5869-A.4 SHA-1 基本向量", + secret: bytes.Repeat([]byte{0x0b}, 11), + salt: seq(0x00, 13), + info: seq(0xf0, 10), + keyLength: 42, + useSHA1: true, + want: "085a01ea1b10f36933068b56efa5ad81a4f14b822f5b091568a9cdd4f155fda2c22e422478d305f3f896", + }, + { + name: "RFC5869-A.5 SHA-1 长输入长输出", + secret: seq(0x00, 80), + salt: seq(0x60, 80), + info: seq(0xb0, 80), + keyLength: 82, + useSHA1: true, + want: "0bd770a74d1160f7c9f12cd5912a06ebff6adcae899d92191fe4305673ba2ffe8fa3f1a4e5ad79f3f334b3b202b2173c486ea37ce3d397ed034c7f9dfeb15c5e927336d0441f4c4300e2cff0d0900b52d3b4", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var ( + got []byte + err error + ) + if tc.useSHA1 { + got, err = hkdfx.DeriveKeyWithHash(tc.secret, tc.salt, tc.info, tc.keyLength, sha1.New) + } else { + got, err = hkdfx.DeriveKey(tc.secret, tc.salt, tc.info, tc.keyLength) + } + if err != nil { + t.Fatalf("派生失败: %v", err) + } + if hex.EncodeToString(got) != tc.want { + t.Errorf("与 RFC 5869 向量不一致\n got: %s\nwant: %s", hex.EncodeToString(got), tc.want) + } + }) + } +} + +func TestDeriveKeyProperties(t *testing.T) { + secret := mustHexDecode(t, "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + salt := []byte("unique-salt") + info := []byte("aes-key") + + k1, err := hkdfx.DeriveKey(secret, salt, info, hkdfx.DefaultKeyLength) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + k2, err := hkdfx.DeriveKey(secret, salt, info, hkdfx.DefaultKeyLength) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + if !bytes.Equal(k1, k2) { + t.Error("相同输入两次派生结果不一致") + } + if len(k1) != hkdfx.DefaultKeyLength { + t.Errorf("派生长度 = %d, 期望 %d", len(k1), hkdfx.DefaultKeyLength) + } + + // 默认入口必须等价于显式传 SHA-256。 + explicit, err := hkdfx.DeriveKeyWithHash(secret, salt, info, hkdfx.DefaultKeyLength, sha256.New) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + if !bytes.Equal(k1, explicit) { + t.Error("DeriveKey 与 DeriveKeyWithHash(sha256) 结果不一致") + } + + otherInfo, err := hkdfx.DeriveKey(secret, salt, []byte("hmac-key"), hkdfx.DefaultKeyLength) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + if bytes.Equal(k1, otherInfo) { + t.Error("不同 info 得到了相同密钥(密钥分离失效)") + } + + otherSalt, err := hkdfx.DeriveKey(secret, []byte("another-salt"), info, hkdfx.DefaultKeyLength) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + if bytes.Equal(k1, otherSalt) { + t.Error("不同 salt 得到了相同密钥") + } + + otherSecret, err := hkdfx.DeriveKey(append([]byte{0x00}, secret...), salt, info, hkdfx.DefaultKeyLength) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + if bytes.Equal(k1, otherSecret) { + t.Error("不同 secret 得到了相同密钥") + } + + // 前缀性质:先派生 64 字节,再取前 32 字节应与直接派生 32 字节一致。 + long, err := hkdfx.DeriveKey(secret, salt, info, 64) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + if !bytes.Equal(long[:32], k1) { + t.Error("HKDF 输出不满足前缀一致性") + } +} + +// TestDeriveKeyNilSaltEqualsZeroSalt 验证 nil 盐按 RFC 5869 等价于 HashLen 个零字节。 +func TestDeriveKeyNilSaltEqualsZeroSalt(t *testing.T) { + secret := []byte("high-entropy-secret-material") + info := []byte("context") + + withNil, err := hkdfx.DeriveKey(secret, nil, info, 32) + if err != nil { + t.Fatalf("nil 盐派生失败: %v", err) + } + withEmpty, err := hkdfx.DeriveKey(secret, []byte{}, info, 32) + if err != nil { + t.Fatalf("空盐派生失败: %v", err) + } + withZeros, err := hkdfx.DeriveKey(secret, make([]byte, sha256.Size), info, 32) + if err != nil { + t.Fatalf("零盐派生失败: %v", err) + } + if !bytes.Equal(withNil, withEmpty) || !bytes.Equal(withNil, withZeros) { + t.Error("nil 盐、空盐与 HashLen 个零字节盐的派生结果不一致") + } +} + +func TestParameterValidation(t *testing.T) { + secret := []byte("high-entropy-secret-material") + + // secret 必须非空。 + for _, s := range [][]byte{nil, {}} { + if _, err := hkdfx.DeriveKey(s, nil, nil, 32); err == nil { + t.Error("空 secret 未返回错误") + } + if _, err := hkdfx.DeriveKeyWithHash(s, nil, nil, 32, sha256.New); err == nil { + t.Error("空 secret 未返回错误") + } + } + + // 长度非法。 + for _, length := range []int{0, -1, hkdfx.MaxKeyLength + 1, math.MaxInt} { + if _, err := hkdfx.DeriveKey(secret, nil, nil, length); err == nil { + t.Errorf("keyLength=%d 未返回错误", length) + } + } + + // 哈希为 nil。 + if _, err := hkdfx.DeriveKeyWithHash(secret, nil, nil, 32, nil); err == nil { + t.Error("哈希构造函数为 nil 时未返回错误") + } +} + +// TestKeyLengthLimits 验证上限按 255*HashLen 计算,并允许等于上限。 +func TestKeyLengthLimits(t *testing.T) { + secret := []byte("high-entropy-secret-material") + + cases := []struct { + name string + h func() hash.Hash + maxLength int + }{ + {"sha256", sha256.New, 255 * sha256.Size}, + {"sha512", sha512.New, 255 * sha512.Size}, + {"sha1", sha1.New, 255 * sha1.Size}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + key, err := hkdfx.DeriveKeyWithHash(secret, []byte("salt"), []byte("info"), tc.maxLength, tc.h) + if err != nil { + t.Fatalf("取上限 %d 时不应报错: %v", tc.maxLength, err) + } + if len(key) != tc.maxLength { + t.Errorf("派生长度 = %d, 期望 %d", len(key), tc.maxLength) + } + if _, err := hkdfx.DeriveKeyWithHash(secret, []byte("salt"), []byte("info"), tc.maxLength+1, tc.h); err == nil { + t.Errorf("超过哈希上限 %d 时未返回错误", tc.maxLength) + } + }) + } + + // 默认 SHA-256 入口的常量上限必须与 255*32 一致。 + if hkdfx.MaxKeyLength != 255*sha256.Size { + t.Errorf("MaxKeyLength = %d, 期望 %d", hkdfx.MaxKeyLength, 255*sha256.Size) + } +} diff --git a/hmacx/hmacx.go b/hmacx/hmacx.go index 167a03b..f313957 100644 --- a/hmacx/hmacx.go +++ b/hmacx/hmacx.go @@ -1,22 +1,277 @@ +// Package hmacx 提供基于 crypto/hmac 的消息认证码(MAC)计算与校验能力。 +// +// 设计要点与安全说明: +// +// 1. 本包所有计算函数都返回原始摘要字节([]byte),不返回可打印字符串, +// 以免调用方把密钥材料或摘要误写入日志。 +// 2. 校验必须使用本包提供的 Verify* 函数,它们内部调用 crypto/hmac 的 +// hmac.Equal 做常量时间比较;**绝不要**用 == 或 bytes.Equal 比较 MAC, +// 那会泄漏时序信息(timing side channel),使攻击者可以逐字节猜测正确 MAC。 +// 3. 密钥长度短于哈希分组大小(SHA-1/SHA-256 为 64 字节,SHA-384/SHA-512 为 +// 128 字节)时,HMAC 会先用 0x00 把密钥填充到分组长度,这是 RFC 2104 规定 +// 的正常行为,不会降低安全性;安全性取决于密钥的**熵**而非长度。 +// 但过短的密钥(少于 MinKeyLength 字节)会显著降低抗暴力破解能力, +// 应使用 crypto/rand 生成的随机密钥,长度建议为 DefaultKeyLength。 +// 4. SHA-1 仅用于兼容既有协议(如 TLS 1.0、旧版 OAuth 1.0 签名)。 +// SHA-1 的抗碰撞性已被攻破,新设计请使用 SHA-256 及以上。 package hmacx import ( "crypto/hmac" + "crypto/sha1" "crypto/sha256" "crypto/sha512" "encoding/hex" + "errors" + "fmt" + "hash" + "io" ) -// HMAC-SHA256 计算消息认证码 -func HMACSHA256(message, key string) string { - mac := hmac.New(sha256.New, []byte(key)) - mac.Write([]byte(message)) - return hex.EncodeToString(mac.Sum(nil)) +const ( + // MinKeyLength 是建议的最小 HMAC 密钥长度(字节)。 + // 短于该长度的密钥熵不足,容易被暴力枚举,安全性显著下降。 + MinKeyLength = 16 + + // DefaultKeyLength 是推荐的 HMAC 密钥长度(字节), + // 与 SHA-256 的摘要长度一致,可提供 256 位安全强度。 + DefaultKeyLength = 32 +) + +// errNilReader 表示调用方传入了 nil 的 io.Reader。 +var errNilReader = errors.New("reader 不能为 nil") + +// Sum 使用给定的哈希构造函数计算 HMAC 摘要,返回原始摘要字节。 +// +// 参数: +// - h: 哈希构造函数,例如 sha256.New;不能为 nil,否则返回 nil。 +// - message: 待认证的消息,可以为空。 +// - key: 认证密钥,可以为空(但空密钥等同于公开密钥,**没有认证意义**)。 +// +// 安全注意: +// - 密钥应来自 crypto/rand,长度不小于 MinKeyLength。 +// - 返回的摘要是敏感数据的完整性凭证,不要写进日志。 +// - 计算本身不会失败,因此没有 error 返回值;h 为 nil 时返回 nil 以便安全降级。 +func Sum(h func() hash.Hash, message, key []byte) []byte { + if h == nil { + return nil + } + mac := hmac.New(h, key) + _, _ = mac.Write(message) + return mac.Sum(nil) } -// HMAC-SHA512 计算消息认证码 -func HMACSHA512(message, key string) string { - mac := hmac.New(sha512.New, []byte(key)) - mac.Write([]byte(message)) - return hex.EncodeToString(mac.Sum(nil)) +// Verify 使用给定的哈希构造函数校验 HMAC 摘要是否匹配。 +// +// 参数: +// - h: 哈希构造函数,例如 sha256.New;为 nil 时直接返回 false。 +// - message: 待认证的消息。 +// - key: 认证密钥。 +// - expectedMAC: 期望的摘要字节,长度必须等于 h() 的摘要长度。 +// +// 返回值:仅在 expectedMAC 长度合法且常量时间比较相等时返回 true。 +// +// 安全注意: +// - 内部使用 hmac.Equal(常量时间比较),不会泄漏时序信息。 +// - expectedMAC 长度与摘要长度不符时立即返回 false,不会 panic。 +// - 绝不能先用 == 比较 MAC 再调用本函数,那样时序漏洞依然存在。 +func Verify(h func() hash.Hash, message, key, expectedMAC []byte) bool { + if h == nil { + return false + } + expectedLen := h().Size() + if len(expectedMAC) != expectedLen { + return false + } + mac := hmac.New(h, key) + _, _ = mac.Write(message) + return hmac.Equal(mac.Sum(nil), expectedMAC) +} + +// SecureCompare 以常量时间比较两个字节串是否相等。 +// +// 参数: +// - a, b: 待比较的字节串,长度不同时直接判定为不相等。 +// +// 返回值:相等返回 true,否则返回 false。 +// +// 安全注意: +// - 长度本身会通过返回结果与执行时间被区分出来,因此本函数只适用于比较 +// 长度固定的秘密(如摘要、MAC);长度不固定的秘密应先做定长处理。 +// - 底层使用 crypto/subtle.ConstantTimeCompare,比较耗时只与长度相关, +// 与内容无关,可抵御逐字节猜测攻击。 +func SecureCompare(a, b []byte) bool { + return hmac.Equal(a, b) +} + +// SumSHA1 计算 HMAC-SHA1 摘要(20 字节)。 +// +// 参数: +// - message: 待认证的消息。 +// - key: 认证密钥,建议长度不小于 MinKeyLength。 +// +// 安全注意:SHA-1 抗碰撞性已被攻破,仅用于兼容既有协议; +// 新设计请使用 SumSHA256 或 SumSHA512。密钥过短会降低抗暴力破解能力。 +func SumSHA1(message, key []byte) []byte { + return Sum(sha1.New, message, key) +} + +// SumSHA256 计算 HMAC-SHA256 摘要(32 字节),是推荐的默认选择。 +// +// 参数: +// - message: 待认证的消息。 +// - key: 认证密钥,建议长度不小于 MinKeyLength,推荐 DefaultKeyLength。 +// +// 安全注意:密钥必须保密且具有足够熵;本函数不校验密钥长度, +// 过短密钥不会报错但会降低安全性,调用方应自行确保密钥质量。 +func SumSHA256(message, key []byte) []byte { + return Sum(sha256.New, message, key) +} + +// SumSHA384 计算 HMAC-SHA384 摘要(48 字节)。 +// +// 参数: +// - message: 待认证的消息。 +// - key: 认证密钥,建议长度不小于 MinKeyLength。 +// +// 安全注意:SHA-384 的分组大小为 128 字节,短于该长度的密钥会被 0x00 填充, +// 这是正常行为;密钥熵不足才是真正的风险。 +func SumSHA384(message, key []byte) []byte { + return Sum(sha512.New384, message, key) +} + +// SumSHA512 计算 HMAC-SHA512 摘要(64 字节)。 +// +// 参数: +// - message: 待认证的消息。 +// - key: 认证密钥,建议长度不小于 MinKeyLength。 +// +// 安全注意:摘要更长但计算开销也更高;多数场景 SHA-256 已足够。 +func SumSHA512(message, key []byte) []byte { + return Sum(sha512.New, message, key) +} + +// HexSHA1 计算 HMAC-SHA1 并返回小写十六进制字符串(40 个字符)。 +// +// 参数: +// - message: 待认证的消息。 +// - key: 认证密钥。 +// +// 安全注意:十六进制字符串便于传输与比较,但**校验时不要用 == 比较**, +// 请使用 VerifySHA1;另注意该字符串是完整性凭证,不宜写入日志。 +func HexSHA1(message, key []byte) string { + return hex.EncodeToString(SumSHA1(message, key)) +} + +// HexSHA256 计算 HMAC-SHA256 并返回小写十六进制字符串(64 个字符)。 +// +// 参数: +// - message: 待认证的消息。 +// - key: 认证密钥。 +// +// 安全注意:**校验时不要用 == 比较**返回的字符串,请使用 VerifySHA256 +// 以避免时序侧信道。 +func HexSHA256(message, key []byte) string { + return hex.EncodeToString(SumSHA256(message, key)) +} + +// HexSHA384 计算 HMAC-SHA384 并返回小写十六进制字符串(96 个字符)。 +// +// 参数: +// - message: 待认证的消息。 +// - key: 认证密钥。 +// +// 安全注意:**校验时不要用 == 比较**返回的字符串,请使用 VerifySHA384。 +func HexSHA384(message, key []byte) string { + return hex.EncodeToString(SumSHA384(message, key)) +} + +// HexSHA512 计算 HMAC-SHA512 并返回小写十六进制字符串(128 个字符)。 +// +// 参数: +// - message: 待认证的消息。 +// - key: 认证密钥。 +// +// 安全注意:**校验时不要用 == 比较**返回的字符串,请使用 VerifySHA512。 +func HexSHA512(message, key []byte) string { + return hex.EncodeToString(SumSHA512(message, key)) +} + +// SumReaderSHA256 从 io.Reader 流式读取消息并计算 HMAC-SHA256 摘要。 +// +// 参数: +// - r: 消息来源,不能为 nil。 +// - key: 认证密钥,建议长度不小于 MinKeyLength。 +// +// 返回值:32 字节的原始摘要;读取失败时返回 nil 与包装后的 error。 +// +// 安全注意:流式读取适合大消息,可避免整个消息驻留内存; +// 但 MAC 只保证完整性,不提供机密性,敏感数据仍需先加密。 +func SumReaderSHA256(r io.Reader, key []byte) ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("计算 HMAC-SHA256: %w", errNilReader) + } + mac := hmac.New(sha256.New, key) + if _, err := io.Copy(mac, r); err != nil { + return nil, fmt.Errorf("读取消息以计算 HMAC-SHA256: %w", err) + } + return mac.Sum(nil), nil +} + +// VerifySHA1 校验 HMAC-SHA1 摘要是否匹配,使用常量时间比较。 +// +// 参数: +// - message: 待认证的消息。 +// - key: 认证密钥。 +// - expectedMAC: 期望的 20 字节摘要。 +// +// 返回值:长度合法且常量时间比较相等时返回 true,否则返回 false。 +// +// 安全注意:expectedMAC 长度不是 20 时立即返回 false(不会 panic); +// 本函数不会因为比较失败而泄漏任何时序信息。 +func VerifySHA1(message, key, expectedMAC []byte) bool { + return Verify(sha1.New, message, key, expectedMAC) +} + +// VerifySHA256 校验 HMAC-SHA256 摘要是否匹配,使用常量时间比较。 +// +// 参数: +// - message: 待认证的消息。 +// - key: 认证密钥。 +// - expectedMAC: 期望的 32 字节摘要。 +// +// 返回值:长度合法且常量时间比较相等时返回 true,否则返回 false。 +// +// 安全注意:expectedMAC 长度不是 32 时立即返回 false(不会 panic); +// 本函数是替代 == 比较的正确做法。 +func VerifySHA256(message, key, expectedMAC []byte) bool { + return Verify(sha256.New, message, key, expectedMAC) +} + +// VerifySHA384 校验 HMAC-SHA384 摘要是否匹配,使用常量时间比较。 +// +// 参数: +// - message: 待认证的消息。 +// - key: 认证密钥。 +// - expectedMAC: 期望的 48 字节摘要。 +// +// 返回值:长度合法且常量时间比较相等时返回 true,否则返回 false。 +// +// 安全注意:expectedMAC 长度不是 48 时立即返回 false(不会 panic)。 +func VerifySHA384(message, key, expectedMAC []byte) bool { + return Verify(sha512.New384, message, key, expectedMAC) +} + +// VerifySHA512 校验 HMAC-SHA512 摘要是否匹配,使用常量时间比较。 +// +// 参数: +// - message: 待认证的消息。 +// - key: 认证密钥。 +// - expectedMAC: 期望的 64 字节摘要。 +// +// 返回值:长度合法且常量时间比较相等时返回 true,否则返回 false。 +// +// 安全注意:expectedMAC 长度不是 64 时立即返回 false(不会 panic)。 +func VerifySHA512(message, key, expectedMAC []byte) bool { + return Verify(sha512.New, message, key, expectedMAC) } diff --git a/hmacx/hmacx_test.go b/hmacx/hmacx_test.go index 7566656..79823ea 100644 --- a/hmacx/hmacx_test.go +++ b/hmacx/hmacx_test.go @@ -1,75 +1,413 @@ -package hmacx +package hmacx_test import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "hash" + "strings" "testing" + + "code.yun.ink/pkg/encryptx/hmacx" ) -func TestHMACSHA256(t *testing.T) { - message := "hello world" - key := "secret_key" +// rfc4231Case 表示 RFC 4231 中的一个测试用例。 +// 来源:RFC 4231 "Identifiers and Test Vectors for HMAC-SHA-224, HMAC-SHA-256, +// HMAC-SHA-384, and HMAC-SHA-512"(https://www.rfc-editor.org/rfc/rfc4231.txt)。 +type rfc4231Case struct { + name string + key []byte + data []byte + sha256 string + sha384 string + sha512 string +} - // 计算HMAC-SHA256 - hmac1 := HMACSHA256(message, key) +// rfc4231Cases 为 RFC 4231 第 4.2~4.5 节(Test Case 1~4)的 SHA-256/384/512 摘要, +// 以及 RFC 2202 Test Case 1~4 的 HMAC-SHA1 摘要(两组定义的 key/data 相同)。 +var rfc4231Cases = []rfc4231Case{ + { + name: "RFC4231-TestCase1", + key: bytes.Repeat([]byte{0x0b}, 20), + data: []byte("Hi There"), + sha256: "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7", + sha384: "afd03944d84895626b0825f4ab46907f15f9dadbe4101ec682aa034c7cebc59cfaea9ea9076ede7f4af152e8b2fa9cb6", + sha512: "87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cdedaa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854", + }, + { + name: "RFC4231-TestCase2", + key: []byte("Jefe"), + data: []byte("what do ya want for nothing?"), + sha256: "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843", + sha384: "af45d2e376484031617f78d2b58a6b1b9c7ef464f5a01b47e42ec3736322445e8e2240ca5e69e2c78b3239ecfab21649", + sha512: "164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea2505549758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737", + }, + { + name: "RFC4231-TestCase3", + key: bytes.Repeat([]byte{0xaa}, 20), + data: bytes.Repeat([]byte{0xdd}, 50), + sha256: "773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe", + sha384: "88062608d3e6ad8a0aa2ace014c8a86f0aa635d947ac9febe83ef4e55966144b2a5ab39dc13814b94e3ab6e101a34f27", + sha512: "fa73b0089d56a284efb0f0756c890be9b1b5dbdd8ee81a3655f83e33b2279d39bf3e848279a722c806b485a47e67c807b946a337bee8942674278859e13292fb", + }, + { + name: "RFC4231-TestCase4", + key: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19}, + data: bytes.Repeat([]byte{0xcd}, 50), + sha256: "82558a389a443c0ea4cc819899f2083a85f0faa3e578f8077a2e3ff46729665b", + sha384: "3e8a69b7783c25851933ab6290af6ca77a9981480850009cc5577c6e1f573b4e6801dd23c4a7d679ccf8a386c674cffb", + sha512: "b0ba465637458c6990e5a8c5f61d4af7e576d97ff94b872de76f8050361ee3dba91ca5c11aa25eb4d679275cc5788063a5f19741120c4f2de2adebeb10a298dd", + }, +} - // 再次计算相同的消息和密钥 - hmac2 := HMACSHA256(message, key) +// rfc2202SHA1Cases 为 RFC 2202(HMAC-MD5/HMAC-SHA-1 测试向量)Test Case 1~4 的 +// HMAC-SHA1 期望值,key/data 与 rfc4231Cases 的前四项完全一致。 +var rfc2202SHA1Cases = []string{ + "b617318655057264e28bc0b6fb378c8ef146be00", + "effcdf6ae5eb2fa2d27416d5f184df9c259a7c79", + "125d7342b9ac11cd91a39af48aa17b4f63f175d3", + "4c9007f4026250c6bc8414f9bf50c86c2d7235da", +} - // 相同的消息和密钥应该产生相同的HMAC - if hmac1 != hmac2 { - t.Errorf("相同输入产生了不同的HMAC: %s vs %s", hmac1, hmac2) - } - - // 不同的消息应该产生不同的HMAC - hmac3 := HMACSHA256("different message", key) - if hmac1 == hmac3 { - t.Errorf("不同消息产生了相同的HMAC") - } - - // 不同的密钥应该产生不同的HMAC - hmac4 := HMACSHA256(message, "different_key") - if hmac1 == hmac4 { - t.Errorf("不同密钥产生了相同的HMAC") +// TestSumKnownVectors 用 RFC 4231 与 RFC 2202 的官方测试向量核对摘要计算。 +func TestSumKnownVectors(t *testing.T) { + for i, tc := range rfc4231Cases { + t.Run(tc.name, func(t *testing.T) { + if got := hex.EncodeToString(hmacx.SumSHA256(tc.data, tc.key)); got != tc.sha256 { + t.Errorf("HMAC-SHA256 不匹配:\n got %s\nwant %s", got, tc.sha256) + } + if got := hex.EncodeToString(hmacx.SumSHA384(tc.data, tc.key)); got != tc.sha384 { + t.Errorf("HMAC-SHA384 不匹配:\n got %s\nwant %s", got, tc.sha384) + } + if got := hex.EncodeToString(hmacx.SumSHA512(tc.data, tc.key)); got != tc.sha512 { + t.Errorf("HMAC-SHA512 不匹配:\n got %s\nwant %s", got, tc.sha512) + } + if got := hex.EncodeToString(hmacx.SumSHA1(tc.data, tc.key)); got != rfc2202SHA1Cases[i] { + t.Errorf("HMAC-SHA1 不匹配:\n got %s\nwant %s", got, rfc2202SHA1Cases[i]) + } + }) } } -func TestHMACSHA512(t *testing.T) { - message := "hello world" - key := "secret_key" +// TestSumGenericMatchesSpecialized 校验通用入口 Sum 与各专用函数一致。 +func TestSumGenericMatchesSpecialized(t *testing.T) { + key := []byte("0123456789abcdef0123456789abcdef") + msg := []byte("generic entry point") - // 计算HMAC-SHA512 - hmac1 := HMACSHA512(message, key) - - // 再次计算相同的消息和密钥 - hmac2 := HMACSHA512(message, key) - - // 相同的消息和密钥应该产生相同的HMAC - if hmac1 != hmac2 { - t.Errorf("相同输入产生了不同的HMAC: %s vs %s", hmac1, hmac2) + // Sum + sha256.New 必须与 SumSHA256 完全一致。 + if got, want := hmacx.Sum(sha256.New, msg, key), hmacx.SumSHA256(msg, key); !bytes.Equal(got, want) { + t.Errorf("Sum(sha256.New) 与 SumSHA256 不一致: %x vs %x", got, want) } - - // 不同的消息应该产生不同的HMAC - hmac3 := HMACSHA512("different message", key) - if hmac1 == hmac3 { - t.Errorf("不同消息产生了相同的HMAC") + // 摘要长度必须正确,否则 Verify* 的长度校验会失效。 + if got := len(hmacx.SumSHA1(msg, key)); got != 20 { + t.Errorf("HMAC-SHA1 摘要长度 = %d, 期望 20", got) } - - // 不同的密钥应该产生不同的HMAC - hmac4 := HMACSHA512(message, "different_key") - if hmac1 == hmac4 { - t.Errorf("不同密钥产生了相同的HMAC") + if got := len(hmacx.SumSHA256(msg, key)); got != 32 { + t.Errorf("HMAC-SHA256 摘要长度 = %d, 期望 32", got) + } + if got := len(hmacx.SumSHA384(msg, key)); got != 48 { + t.Errorf("HMAC-SHA384 摘要长度 = %d, 期望 48", got) + } + if got := len(hmacx.SumSHA512(msg, key)); got != 64 { + t.Errorf("HMAC-SHA512 摘要长度 = %d, 期望 64", got) } } -func TestHMACSHA256VsSHA512(t *testing.T) { - message := "hello world" - key := "secret_key" +// TestHexFunctions 校验十六进制便利函数与原始摘要一致且格式正确。 +func TestHexFunctions(t *testing.T) { + key := []byte("a-key-with-enough-entropy") + msg := []byte("hex convenience") - // 计算HMAC-SHA256和HMAC-SHA512 - hmac256 := HMACSHA256(message, key) - hmac512 := HMACSHA512(message, key) + cases := []struct { + name string + got string + raw []byte + }{ + {"HexSHA1", hmacx.HexSHA1(msg, key), hmacx.SumSHA1(msg, key)}, + {"HexSHA256", hmacx.HexSHA256(msg, key), hmacx.SumSHA256(msg, key)}, + {"HexSHA384", hmacx.HexSHA384(msg, key), hmacx.SumSHA384(msg, key)}, + {"HexSHA512", hmacx.HexSHA512(msg, key), hmacx.SumSHA512(msg, key)}, + } + for _, c := range cases { + if c.got != hex.EncodeToString(c.raw) { + t.Errorf("%s 与原始摘要不一致: %s", c.name, c.got) + } + if c.got != strings.ToLower(c.got) { + t.Errorf("%s 应为小写十六进制: %s", c.name, c.got) + } + if len(c.got) != len(c.raw)*2 { + t.Errorf("%s 长度 = %d, 期望 %d", c.name, len(c.got), len(c.raw)*2) + } + } +} - // SHA256和SHA512应该产生不同的HMAC - if hmac256 == hmac512 { - t.Errorf("SHA256和SHA512产生了相同的HMAC") +// TestVerifyAcceptsValidMAC 校验 Verify* 对正确 MAC 返回 true。 +func TestVerifyAcceptsValidMAC(t *testing.T) { + key := []byte("0123456789abcdef0123456789abcdef") + msg := []byte("verify the good mac") + + if !hmacx.VerifySHA1(msg, key, hmacx.SumSHA1(msg, key)) { + t.Error("VerifySHA1 对正确 MAC 返回了 false") + } + if !hmacx.VerifySHA256(msg, key, hmacx.SumSHA256(msg, key)) { + t.Error("VerifySHA256 对正确 MAC 返回了 false") + } + if !hmacx.VerifySHA384(msg, key, hmacx.SumSHA384(msg, key)) { + t.Error("VerifySHA384 对正确 MAC 返回了 false") + } + if !hmacx.VerifySHA512(msg, key, hmacx.SumSHA512(msg, key)) { + t.Error("VerifySHA512 对正确 MAC 返回了 false") + } + if !hmacx.Verify(sha256.New, msg, key, hmacx.Sum(sha256.New, msg, key)) { + t.Error("Verify(sha256.New) 对正确 MAC 返回了 false") + } +} + +// TestVerifyRejectsTamperedMAC 校验单比特翻转会被拒绝(且不 panic)。 +func TestVerifyRejectsTamperedMAC(t *testing.T) { + key := []byte("0123456789abcdef0123456789abcdef") + msg := []byte("verify the tampered mac") + + tamper := func(src []byte) []byte { + out := append([]byte(nil), src...) + out[0] ^= 0x01 // 翻转 1 bit + return out + } + + tests := []struct { + name string + good []byte + ver func(message, key, expectedMAC []byte) bool + }{ + {"SHA1", hmacx.SumSHA1(msg, key), hmacx.VerifySHA1}, + {"SHA256", hmacx.SumSHA256(msg, key), hmacx.VerifySHA256}, + {"SHA384", hmacx.SumSHA384(msg, key), hmacx.VerifySHA384}, + {"SHA512", hmacx.SumSHA512(msg, key), hmacx.VerifySHA512}, + } + for _, tc := range tests { + if tc.ver(msg, key, tamper(tc.good)) { + t.Errorf("Verify%s 接受了被篡改 1 bit 的 MAC", tc.name) + } + // 错误的密钥同样必须被拒绝。 + if tc.ver(msg, []byte("another-key-with-32-bytes-len!!"), tc.good) { + t.Errorf("Verify%s 接受了错误密钥下的 MAC", tc.name) + } + // 错误的消息同样必须被拒绝。 + if tc.ver([]byte("another message"), key, tc.good) { + t.Errorf("Verify%s 接受了错误消息下的 MAC", tc.name) + } + } +} + +// TestVerifyRejectsWrongLengthMAC 校验长度不符时返回 false 而不是 panic。 +func TestVerifyRejectsWrongLengthMAC(t *testing.T) { + key := []byte("0123456789abcdef") + msg := []byte("length check") + + good := hmacx.SumSHA256(msg, key) + + // 长度不足、超长、空切片、nil 都必须安全返回 false。 + lengths := [][]byte{ + nil, + {}, + good[:len(good)-1], + append(append([]byte(nil), good...), 0x00), + make([]byte, 64), + make([]byte, 1), + } + for _, mac := range lengths { + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("VerifySHA256 对长度 %d 的 MAC 发生了 panic: %v", len(mac), r) + } + }() + if hmacx.VerifySHA256(msg, key, mac) { + t.Errorf("VerifySHA256 接受了长度 %d(期望 32)的 MAC", len(mac)) + } + }() + } + + // 其余算法的长度校验同样必须生效。 + if hmacx.VerifySHA1(msg, key, good) { + t.Error("VerifySHA1 接受了 32 字节的 MAC(期望 20)") + } + if hmacx.VerifySHA384(msg, key, good) { + t.Error("VerifySHA384 接受了 32 字节的 MAC(期望 48)") + } + if hmacx.VerifySHA512(msg, key, good) { + t.Error("VerifySHA512 接受了 32 字节的 MAC(期望 64)") + } +} + +// TestVerifyEdgeCases 覆盖 nil 哈希构造函数等边界输入,确保不 panic。 +func TestVerifyEdgeCases(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("边界输入导致 panic: %v", r) + } + }() + + var nilHash func() hash.Hash + if hmacx.Verify(nilHash, []byte("msg"), []byte("key"), []byte("mac")) { + t.Error("Verify(nil) 应返回 false") + } + if hmacx.Sum(nilHash, []byte("msg"), []byte("key")) != nil { + t.Error("Sum(nil) 应返回 nil") + } + + // 空消息与空密钥是合法输入:HMAC 仍然可以计算,只是空密钥没有认证意义。 + if got := len(hmacx.SumSHA256(nil, nil)); got != 32 { + t.Errorf("空消息/空密钥的 HMAC-SHA256 长度 = %d, 期望 32", got) + } + if !hmacx.VerifySHA256(nil, nil, hmacx.SumSHA256(nil, nil)) { + t.Error("VerifySHA256 对空消息/空密钥的正确 MAC 返回了 false") + } +} + +// TestSecureCompare 校验常量时间比较函数的行为。 +func TestSecureCompare(t *testing.T) { + a := []byte("constant-time-comparison") + + if !hmacx.SecureCompare(a, append([]byte(nil), a...)) { + t.Error("SecureCompare 对相同内容返回了 false") + } + if hmacx.SecureCompare(a, []byte("constant-time-comparisou")) { + t.Error("SecureCompare 对仅末字节不同的内容返回了 true") + } + if hmacx.SecureCompare(a, a[:len(a)-1]) { + t.Error("SecureCompare 对长度不同的内容返回了 true") + } + if !hmacx.SecureCompare(nil, nil) { + t.Error("SecureCompare(nil, nil) 应为 true") + } + if hmacx.SecureCompare(nil, []byte{}) != true { + // nil 与空切片长度都为 0,应视为相等。 + t.Error("SecureCompare(nil, []byte{}) 应为 true") + } + // 与 Verify 的结果必须一致。 + key := []byte("0123456789abcdef") + msg := []byte("consistency") + mac := hmacx.SumSHA256(msg, key) + if !hmacx.SecureCompare(mac, hmacx.SumSHA256(msg, key)) { + t.Error("SecureCompare 对相同 MAC 返回了 false") + } + if !hmacx.VerifySHA256(msg, key, mac) { + t.Error("VerifySHA256 与 SecureCompare 结果不一致") + } +} + +// errReader 是一个总是返回错误的 io.Reader,用于测试流式读取的错误路径。 +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { + return 0, errors.New("模拟的读取失败") +} + +// shortReader 在返回全部数据后返回错误,模拟"读一半失败"的场景。 +type shortReader struct { + data []byte +} + +func (s *shortReader) Read(p []byte) (int, error) { + if len(s.data) == 0 { + return 0, errors.New("模拟的读取中断") + } + n := copy(p, s.data) + s.data = s.data[n:] + return n, nil +} + +// TestSumReaderSHA256 校验流式计算与整体计算一致,以及错误路径。 +func TestSumReaderSHA256(t *testing.T) { + key := []byte("0123456789abcdef0123456789abcdef") + + payloads := [][]byte{ + nil, + {}, + []byte("a"), + []byte("streaming payload that is longer than one internal block boundary 0123456789"), + bytes.Repeat([]byte("block"), 1000), + } + for i, payload := range payloads { + got, err := hmacx.SumReaderSHA256(bytes.NewReader(payload), key) + if err != nil { + t.Fatalf("用例 %d: SumReaderSHA256 返回错误: %v", i, err) + } + want := hmacx.SumSHA256(payload, key) + if !bytes.Equal(got, want) { + t.Errorf("用例 %d: SumReaderSHA256 与 SumSHA256 不一致: %x vs %x", i, got, want) + } + if !hmacx.VerifySHA256(payload, key, got) { + t.Errorf("用例 %d: 流式摘要未能通过 VerifySHA256", i) + } + } + + // 读取失败必须返回 error 与 nil 摘要,绝不返回部分结果。 + got, err := hmacx.SumReaderSHA256(errReader{}, key) + if err == nil { + t.Error("读取失败时 SumReaderSHA256 未返回错误") + } + if got != nil { + t.Errorf("读取失败时 SumReaderSHA256 应返回 nil,实际 %x", got) + } + + got, err = hmacx.SumReaderSHA256(&shortReader{data: []byte("partial data")}, key) + if err == nil { + t.Error("读取中断时 SumReaderSHA256 未返回错误") + } + if got != nil { + t.Errorf("读取中断时 SumReaderSHA256 应返回 nil,实际 %x", got) + } + + // nil reader 不能 panic。 + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("nil reader 导致 panic: %v", r) + } + }() + got, err := hmacx.SumReaderSHA256(nil, key) + if err == nil { + t.Error("nil reader 应返回错误") + } + if got != nil { + t.Errorf("nil reader 时应返回 nil,实际 %x", got) + } + }() +} + +// TestConstants 校验导出的长度常量为文档约定的值。 +func TestConstants(t *testing.T) { + if hmacx.MinKeyLength != 16 { + t.Errorf("MinKeyLength = %d, 期望 16", hmacx.MinKeyLength) + } + if hmacx.DefaultKeyLength != 32 { + t.Errorf("DefaultKeyLength = %d, 期望 32", hmacx.DefaultKeyLength) + } + if hmacx.DefaultKeyLength < hmacx.MinKeyLength { + t.Error("DefaultKeyLength 不应小于 MinKeyLength") + } +} + +// TestNoPanicOnAnyInput 用一批畸形输入整体验证不 panic。 +func TestNoPanicOnAnyInput(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("畸形输入导致 panic: %v", r) + } + }() + + msgs := [][]byte{nil, {}, []byte("x"), bytes.Repeat([]byte{0xff}, 1<<10)} + keys := [][]byte{nil, {}, []byte("k"), bytes.Repeat([]byte{0x00}, 200)} + for _, m := range msgs { + for _, k := range keys { + _ = hmacx.SumSHA1(m, k) + _ = hmacx.SumSHA384(m, k) + _ = hmacx.HexSHA512(m, k) + _ = hmacx.VerifySHA256(m, k, nil) + _ = hmacx.SecureCompare(m, k) + } } } diff --git a/md5x/md5x.go b/md5x/md5x.go index 3a61c3d..6e8b441 100644 --- a/md5x/md5x.go +++ b/md5x/md5x.go @@ -1,40 +1,99 @@ +// Package md5x 提供 MD5 摘要计算,以及十六进制/Base64 编码与流式、文件接口。 +// +// # 安全警告 +// +// MD5 已被证明无法抵抗碰撞攻击,攻击者可以构造出摘要完全相同的两份不同数据。 +// 本包仅用于兼容旧系统或非安全用途(如文件校验和、缓存键、内容指纹), +// 不可用于口令存储、口令派生、消息认证或数字签名。 +// 新代码请使用 sha256x、sha512x、sha3x 或 blake2x。 package md5x import ( "crypto/md5" + "encoding/base64" "encoding/hex" + "fmt" + "hash" "io" "os" ) -// 字符串的md5 -func Md5String(value string) string { - return Md5Byte([]byte(value)) +const ( + // Size 是 MD5 摘要的字节数(16)。 + Size = md5.Size + // BlockSize 是 MD5 分组的字节数(64)。 + BlockSize = md5.BlockSize +) + +// New 返回一个计算 MD5 摘要的 hash.Hash,可反复 Write 以流式处理数据。 +// +// 安全警告:MD5 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func New() hash.Hash { + return md5.New() } -func Md5Byte(b []byte) string { - m := md5.New() - m.Write(b) - return hex.EncodeToString(m.Sum(nil)) +// Sum 返回 data 的 MD5 摘要(Size 字节原始数据)。 +// +// 安全警告:MD5 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func Sum(data []byte) []byte { + h := md5.New() + _, _ = h.Write(data) + return h.Sum(nil) } -// 文件的md5 -func Md5File(path string) (string, error) { - file, err := os.Open(path) +// Hex 返回 data 的 MD5 摘要的小写十六进制字符串。 +// +// 安全警告:MD5 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func Hex(data []byte) string { + return hex.EncodeToString(Sum(data)) +} + +// Base64 返回 data 的 MD5 摘要的标准 Base64 字符串。 +// +// 安全警告:MD5 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func Base64(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum(data)) +} + +// SumReader 读取 r 中的全部数据并返回其 MD5 摘要;读取失败时返回包装后的 error。 +// +// 安全警告:MD5 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func SumReader(r io.Reader) ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("md5x: 读取器为 nil") + } + h := md5.New() + if _, err := io.Copy(h, r); err != nil { + return nil, fmt.Errorf("md5x: 读取数据失败: %w", err) + } + return h.Sum(nil), nil +} + +// HexReader 读取 r 中的全部数据并返回其 MD5 摘要的小写十六进制字符串。 +// +// 安全警告:MD5 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func HexReader(r io.Reader) (string, error) { + sum, err := SumReader(r) if err != nil { return "", err } - defer file.Close() - - return Md5Stream(file) + return hex.EncodeToString(sum), nil } -// 流式的md5 -func Md5Stream(reader io.Reader) (string, error) { - hash := md5.New() - _, err := io.Copy(hash, reader) +// HexFile 计算 path 所指向文件的 MD5 摘要并返回小写十六进制字符串; +// 文件不存在或读取失败时返回包装后的 error,不 panic。 +// +// 安全警告:MD5 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func HexFile(path string) (string, error) { + f, err := os.Open(path) if err != nil { - return "", err + return "", fmt.Errorf("md5x: 打开文件 %q 失败: %w", path, err) } - return hex.EncodeToString(hash.Sum(nil)), nil + defer f.Close() + + sum, err := SumReader(f) + if err != nil { + return "", fmt.Errorf("md5x: 计算文件 %q 摘要失败: %w", path, err) + } + return hex.EncodeToString(sum), nil } diff --git a/md5x/md5x_test.go b/md5x/md5x_test.go new file mode 100644 index 0000000..6d2dd8a --- /dev/null +++ b/md5x/md5x_test.go @@ -0,0 +1,178 @@ +package md5x_test + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "code.yun.ink/pkg/encryptx/md5x" +) + +// 测试向量来源:RFC 1321 附录 A.5,并经 Python 3 hashlib 独立复算确认。 +var vectors = []struct { + name string + input string + want string +}{ + {"空串", "", "d41d8cd98f00b204e9800998ecf8427e"}, + {"abc", "abc", "900150983cd24fb0d6963f7d28e17f72"}, + {"message digest", "message digest", "f96b697d7cb7938d525a2f31aaf161d0"}, + {"The quick brown fox jumps over the lazy dog", "The quick brown fox jumps over the lazy dog", "9e107d9d372bb6826bd81d3542a419d6"}, +} + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("模拟读取失败") } + +func TestConstants(t *testing.T) { + if md5x.Size != 16 { + t.Fatalf("Size = %d, 期望 16", md5x.Size) + } + if md5x.BlockSize != 64 { + t.Fatalf("BlockSize = %d, 期望 64", md5x.BlockSize) + } +} + +func TestVectors(t *testing.T) { + for _, v := range vectors { + t.Run(v.name, func(t *testing.T) { + data := []byte(v.input) + sum := md5x.Sum(data) + if len(sum) != md5x.Size { + t.Fatalf("Sum 长度 = %d, 期望 %d", len(sum), md5x.Size) + } + if got := hex.EncodeToString(sum); got != v.want { + t.Errorf("Sum = %s, 期望 %s", got, v.want) + } + if got := md5x.Hex(data); got != v.want { + t.Errorf("Hex = %s, 期望 %s", got, v.want) + } + if want := base64.StdEncoding.EncodeToString(sum); md5x.Base64(data) != want { + t.Errorf("Base64 = %s, 期望 %s", md5x.Base64(data), want) + } + h := md5x.New() + if _, err := h.Write(data); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != v.want { + t.Errorf("New+Write = %s, 期望 %s", got, v.want) + } + }) + } +} + +func TestStreamingMatchesSum(t *testing.T) { + data := []byte(strings.Repeat("encryptx-md5-", 100)) + h := md5x.New() + for i := 0; i < len(data); i += 7 { + end := i + 7 + if end > len(data) { + end = len(data) + } + if _, err := h.Write(data[i:end]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + } + if got, want := h.Sum(nil), md5x.Sum(data); !bytes.Equal(got, want) { + t.Errorf("分块写入 = %x, 期望 %x", got, want) + } +} + +func TestReaderConsistency(t *testing.T) { + const s = "encryptx 一致性检查" + sum, err := md5x.SumReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("SumReader 失败: %v", err) + } + if want := md5x.Sum([]byte(s)); !bytes.Equal(sum, want) { + t.Errorf("SumReader = %x, 期望 %x", sum, want) + } + hexStr, err := md5x.HexReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if want := md5x.Hex([]byte(s)); hexStr != want { + t.Errorf("HexReader = %s, 期望 %s", hexStr, want) + } +} + +func TestReaderErrors(t *testing.T) { + if _, err := md5x.SumReader(nil); err == nil { + t.Fatal("nil 读取器应返回 error") + } + if _, err := md5x.HexReader(nil); err == nil { + t.Fatal("nil 读取器应返回 error") + } + if _, err := md5x.SumReader(failingReader{}); err == nil { + t.Fatal("读取失败时应返回 error") + } +} + +func TestHexFile(t *testing.T) { + dir := t.TempDir() + content := []byte("文件内容 file content\n") + path := filepath.Join(dir, "sample.bin") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("写入测试文件失败: %v", err) + } + got, err := md5x.HexFile(path) + if err != nil { + t.Fatalf("HexFile 失败: %v", err) + } + if want := md5x.Hex(content); got != want { + t.Errorf("HexFile = %s, 期望 %s", got, want) + } +} + +func TestHexFileErrors(t *testing.T) { + if _, err := md5x.HexFile(filepath.Join(t.TempDir(), "不存在.bin")); err == nil { + t.Fatal("不存在的文件应返回 error") + } + if _, err := md5x.HexFile(""); err == nil { + t.Fatal("空路径应返回 error") + } + if _, err := md5x.HexFile(t.TempDir()); err == nil { + t.Fatal("目录路径应返回 error") + } +} + +func TestLargeInput(t *testing.T) { + data := largeData(1 << 20) + want := md5x.Hex(data) + if got := md5x.Hex(data); got != want { + t.Errorf("重复计算结果不一致: %s vs %s", got, want) + } + + got, err := md5x.HexReader(bytes.NewReader(data)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if got != want { + t.Errorf("HexReader = %s, 期望 %s", got, want) + } + + h := md5x.New() + if _, err := h.Write(data[:1<<19]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if _, err := h.Write(data[1<<19:]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + t.Errorf("1MiB 分块 = %s, 期望 %s", got, want) + } +} + +// largeData 生成确定性的 n 字节数据。 +func largeData(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i*31 + i/7) + } + return b +} diff --git a/pbkdf2x/pbkdf2x.go b/pbkdf2x/pbkdf2x.go index 7d51845..92df4eb 100644 --- a/pbkdf2x/pbkdf2x.go +++ b/pbkdf2x/pbkdf2x.go @@ -1,66 +1,196 @@ +// Package pbkdf2x 基于 PBKDF2(RFC 8018 / PKCS #5 v2.1)提供口令密钥派生与校验。 +// +// 安全注意事项: +// - PBKDF2 只有 CPU 代价、没有内存代价,抗 GPU/ASIC 暴力破解的能力弱于 +// Argon2id 与 scrypt。新系统的口令存储应优先选用 argon2x,其次 scryptx; +// 本包主要用于兼容既有系统与 FIPS 场景。 +// - 迭代次数必须随硬件能力上调。OWASP 2023 对 PBKDF2-HMAC-SHA256 的推荐值为 +// 600000,即 DefaultIterations;低于 MinIterations 的配置会被拒绝。 +// - 盐必须是密码学安全随机数、每个口令独立,长度不低于 MinSaltLength 字节, +// 推荐 DefaultSaltLength 字节。盐不是秘密,可与派生结果一起明文存储。 +// - 派生密钥属于敏感数据,调用方负责及时清零,且不得写入日志或错误信息。 +// +// 本包任何情况下都不会 panic:所有非法参数都返回 error,且错误文本中不含 +// 口令、盐或密钥材料。 package pbkdf2x import ( + "crypto/pbkdf2" "crypto/rand" "crypto/sha256" - "encoding/hex" + "crypto/subtle" "fmt" - - "golang.org/x/crypto/pbkdf2" + "hash" + "io" ) -// GenerateSalt 生成随机盐值 -func GenerateSalt(length int) (string, error) { - if length <= 0 { - return "", fmt.Errorf("盐值长度必须大于0") +const ( + // DefaultIterations 是 PBKDF2-HMAC-SHA256 的默认迭代次数,取 OWASP 2023 年 + // 口令存储建议值 600000。迭代次数越高,离线暴力破解成本越高,派生耗时 + // 也近似线性增长。 + DefaultIterations = 600000 + + // MinIterations 是允许的最小迭代次数。1 万次迭代对现代 GPU 几乎不构成 + // 防护,低于该值的请求一律拒绝,避免调用方误传 1 或 1000。 + MinIterations = 10000 + + // MaxIterations 是允许的最大迭代次数,用于拦截误把毫秒、字节数当成 + // 迭代次数传入的调用(例如 0xFFFFFFFF),避免单次请求长时间占用 CPU + // 而演变成拒绝服务放大面。 + MaxIterations = 10000000 + + // DefaultSaltLength 是推荐的盐长度(字节)。盐越长,彩虹表与多目标 + // 批量攻击的收益越低。 + DefaultSaltLength = 16 + + // MinSaltLength 是允许的最小盐长度(字节)。RFC 8018 建议至少 8 字节, + // 更短的盐会显著提高碰撞概率。 + MinSaltLength = 8 + + // DefaultKeyLength 是推荐的派生密钥长度(字节),32 字节可直接作为 + // AES-256 或 HMAC-SHA256 的密钥。 + DefaultKeyLength = 32 + + // MaxKeyLength 是允许的最大派生密钥长度(字节)。该上限用于防止调用方 + // 把"输出缓冲区大小"误传入,造成不必要的内存分配与计算开销。 + MaxKeyLength = 1024 +) + +// maxSaltLength 是允许的最大盐长度(字节)。盐不是秘密,超长盐没有额外收益, +// 这里设上限只是为了让明显误传的参数(如 1<<20)尽早失败。 +const maxSaltLength = 1024 + +// GenerateSalt 返回 length 字节的密码学安全随机盐,供 DeriveKey 使用。 +// +// length 必须落在 [MinSaltLength, 1024] 区间内,推荐传 DefaultSaltLength。 +// 每次注册、改密或加密新数据都必须生成新的盐,并把盐与派生结果一起持久化: +// 盐是公开值,缺失它会导致派生结果永远无法复现。 +// +// 随机源不可用时返回 error,绝不退化为可预测的盐。 +func GenerateSalt(length int) ([]byte, error) { + if length < MinSaltLength { + return nil, fmt.Errorf("pbkdf2x: 盐长度 %d 小于最小值 %d", length, MinSaltLength) + } + if length > maxSaltLength { + return nil, fmt.Errorf("pbkdf2x: 盐长度 %d 超过最大值 %d", length, maxSaltLength) } salt := make([]byte, length) - _, err := rand.Read(salt) - if err != nil { - return "", fmt.Errorf("生成盐值失败: %v", err) + // 使用 io.ReadFull(rand.Reader) 而不是 rand.Read:后者在随机源故障时 + // 会直接终止进程,而本包要求把失败作为 error 返回给调用方。 + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, fmt.Errorf("pbkdf2x: 读取随机盐失败: %w", err) } - - return hex.EncodeToString(salt), nil + return salt, nil } -// DeriveKeyFromPassword 使用PBKDF2从密码派生密钥 -// password: 原始密码 -// saltHex: 十六进制格式的盐值 -// iterations: 迭代次数,推荐至少10000次 -// keyLength: 派生密钥的长度(字节) -func DeriveKeyFromPassword(password, saltHex string, iterations, keyLength int) (string, error) { - // 解码盐值 - salt, err := hex.DecodeString(saltHex) - if err != nil { - return "", fmt.Errorf("盐值解码失败: %v", err) - } - - // 使用PBKDF2派生密钥 - key := pbkdf2.Key([]byte(password), salt, iterations, keyLength, sha256.New) - - // 返回十六进制格式的密钥 - return hex.EncodeToString(key), nil +// DeriveKey 使用 PBKDF2-HMAC-SHA256 从 password 与 salt 派生 keyLength 字节密钥。 +// +// password 为原始口令字节(不是十六进制串);salt 为 GenerateSalt 产生的随机盐 +// 或调用方自行管理的唯一盐。iterations 需落在 [MinIterations, MaxIterations], +// keyLength 需落在 [1, MaxKeyLength],推荐 DefaultIterations 与 DefaultKeyLength。 +// +// 相同的 (password, salt, iterations, keyLength) 必然得到相同密钥;只要盐变化, +// 结果即完全不同。因此派生结果必须连同盐与参数一起保存才能复现。 +// +// 安全提示:派生结果不可逆推口令,但如果口令熵不足,攻击者仍可离线穷举, +// 请配合口令强度策略与足够的迭代次数使用。 +func DeriveKey(password, salt []byte, iterations, keyLength int) ([]byte, error) { + return DeriveKeyWithHash(password, salt, iterations, keyLength, sha256.New) } -// DeriveKeyFromPasswordWithRandomSalt 使用PBKDF2从密码派生密钥,并自动生成随机盐值 -// password: 原始密码 -// saltLength: 盐值长度(字节) -// iterations: 迭代次数,推荐至少10000次 -// keyLength: 派生密钥的长度(字节) -// 返回值: 密钥(十六进制), 盐值(十六进制), 错误 -func DeriveKeyFromPasswordWithRandomSalt(password string, saltLength, iterations, keyLength int) (string, string, error) { - // 生成随机盐值 - saltHex, err := GenerateSalt(saltLength) - if err != nil { - return "", "", err +// DeriveKeyWithHash 与 DeriveKey 相同,但允许指定 PBKDF2 的伪随机函数底层哈希, +// 例如 sha1.New(仅用于兼容旧系统)、sha512.New。h 为 nil 时返回 error。 +// +// 参数校验与 DeriveKey 完全一致:盐长度、迭代次数、输出长度越界都会返回 error。 +// 安全提示:SHA-1/MD5 已被证明存在碰撞攻击,新系统不要使用;更换哈希属于不兼容 +// 变更,同一份存储数据必须固定使用创建时所用的哈希。 +func DeriveKeyWithHash(password, salt []byte, iterations, keyLength int, h func() hash.Hash) ([]byte, error) { + if err := validateParams(salt, iterations, keyLength); err != nil { + return nil, err + } + if h == nil { + return nil, fmt.Errorf("pbkdf2x: 哈希构造函数不能为 nil") } - // 派生密钥 - keyHex, err := DeriveKeyFromPassword(password, saltHex, iterations, keyLength) + // 使用标准库 crypto/pbkdf2:它返回 error;而 golang.org/x/crypto/pbkdf2.Key + // 在参数非法或 FIPS 140-only 模式下会 panic,与本包"绝不 panic"的约定冲突, + // 且 FIPS 限制无法通过入口校验完全规避。 + key, err := pbkdf2.Key(h, string(password), salt, iterations, keyLength) if err != nil { - return "", "", err + return nil, fmt.Errorf("pbkdf2x: 派生密钥失败: %w", err) } + return key, nil +} - return keyHex, saltHex, nil +// DeriveKeyWithRandomSalt 生成 saltLength 字节随机盐并立即派生密钥,一次性返回 +// (key, salt, error)。这是推荐入口:调用方必须把返回的 salt 与 key 一起保存 +// (例如 salt 存明文列、key 作为校验值),否则无法复现派生结果。 +// +// saltLength 通常传 DefaultSaltLength;iterations 与 keyLength 的含义同 DeriveKey。 +// 任何参数非法或随机源失败都会返回 error,此时 key 与 salt 均为 nil。 +func DeriveKeyWithRandomSalt(password []byte, saltLength, iterations, keyLength int) (key, salt []byte, err error) { + salt, err = GenerateSalt(saltLength) + if err != nil { + return nil, nil, err + } + + key, err = DeriveKey(password, salt, iterations, keyLength) + if err != nil { + return nil, nil, err + } + return key, salt, nil +} + +// Verify 用相同参数重新派生密钥,并以常量时间比较 expectedKey,返回是否匹配。 +// +// 参数非法(盐过短、迭代次数越界、keyLength 越界)时返回 error;参数合法但密钥 +// 不匹配时返回 (false, nil)。expectedKey 长度与 keyLength 不一致时直接判定为 +// 不匹配,不会泄漏比较进度。 +// +// 安全提示:必须使用本函数而不是 bytes.Equal 或 == 比较密钥,后者会因提前返回 +// 而泄漏前缀匹配长度,构成时序侧信道。 +func Verify(password, salt, expectedKey []byte, iterations, keyLength int) (bool, error) { + derived, err := DeriveKey(password, salt, iterations, keyLength) + if err != nil { + return false, err + } + // 比较完成后立即清零本地派生结果,缩短密钥在内存中的驻留时间。 + defer func() { + for i := range derived { + derived[i] = 0 + } + }() + + if len(expectedKey) != len(derived) { + return false, nil + } + return subtle.ConstantTimeCompare(derived, expectedKey) == 1, nil +} + +// validateParams 是所有派生入口共用的参数闸门,保证非法输入在进入底层实现 +// 之前就变成明确的 error,而不是 panic 或静默截断。 +func validateParams(salt []byte, iterations, keyLength int) error { + if len(salt) == 0 { + return fmt.Errorf("pbkdf2x: 盐不能为空") + } + if len(salt) < MinSaltLength { + return fmt.Errorf("pbkdf2x: 盐长度 %d 小于最小值 %d", len(salt), MinSaltLength) + } + if len(salt) > maxSaltLength { + return fmt.Errorf("pbkdf2x: 盐长度 %d 超过最大值 %d", len(salt), maxSaltLength) + } + if iterations < MinIterations { + return fmt.Errorf("pbkdf2x: 迭代次数 %d 小于最小值 %d", iterations, MinIterations) + } + if iterations > MaxIterations { + return fmt.Errorf("pbkdf2x: 迭代次数 %d 超过最大值 %d", iterations, MaxIterations) + } + if keyLength <= 0 { + return fmt.Errorf("pbkdf2x: 密钥长度 %d 必须大于 0", keyLength) + } + if keyLength > MaxKeyLength { + return fmt.Errorf("pbkdf2x: 密钥长度 %d 超过最大值 %d", keyLength, MaxKeyLength) + } + return nil } diff --git a/pbkdf2x/pbkdf2x_test.go b/pbkdf2x/pbkdf2x_test.go new file mode 100644 index 0000000..f5f9946 --- /dev/null +++ b/pbkdf2x/pbkdf2x_test.go @@ -0,0 +1,345 @@ +package pbkdf2x_test + +import ( + "bytes" + "crypto/sha1" + "crypto/sha512" + "encoding/hex" + "math" + "testing" + + "code.yun.ink/pkg/encryptx/pbkdf2x" +) + +// mustHexDecode 把测试向量里的十六进制串还原为字节。 +func mustHexDecode(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("测试向量不是合法十六进制: %v", err) + } + return b +} + +// TestDeriveKeyKnownVectors 固定本包的已知答案(KAT)。 +// 期望值由 Python 3.13 的 hashlib.pbkdf2_hmac(独立实现)生成,例如: +// +// hashlib.pbkdf2_hmac('sha256', b'password', b'saltsaltsalt', 10000, 32).hex() +func TestDeriveKeyKnownVectors(t *testing.T) { + cases := []struct { + name string + password []byte + saltHex string + iterations int + keyLength int + want string + }{ + { + name: "sha256-10000-32", + password: []byte("password"), + saltHex: hex.EncodeToString([]byte("saltsaltsalt")), + iterations: 10000, + keyLength: 32, + want: "63086a6bd6ba533c492ed8d8453f99d723a9fbc2c427c2b848784308fc88cad3", + }, + { + name: "sha256-10000-64", + password: []byte("password"), + saltHex: hex.EncodeToString([]byte("saltsaltsalt")), + iterations: 10000, + keyLength: 64, + want: "63086a6bd6ba533c492ed8d8453f99d723a9fbc2c427c2b848784308fc88cad33b33c8dad2c6f11785d281ae729f83877daa2b8afe1923791c46a8e226f92f4a", + }, + { + // 含 0x00 的二进制盐:验证盐是原始字节而不是十六进制字符串。 + name: "sha256-100000-32-binary-salt", + password: []byte("Correct Horse Battery Staple"), + saltHex: "000102030405060708090a0b0c0d0e0f", + iterations: 100000, + keyLength: 32, + want: "26707b0d34414adebe877b7345529a17095c7b56bdea133de943583d1e5e2efd", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := pbkdf2x.DeriveKey(tc.password, mustHexDecode(t, tc.saltHex), tc.iterations, tc.keyLength) + if err != nil { + t.Fatalf("DeriveKey 返回错误: %v", err) + } + if hex.EncodeToString(got) != tc.want { + t.Errorf("派生结果与已知向量不一致\n got: %s\nwant: %s", hex.EncodeToString(got), tc.want) + } + }) + } +} + +// TestDeriveKeyWithHashKnownVector 验证 PRF 可替换:期望值同样由 Python 的 +// hashlib.pbkdf2_hmac('sha512', ...) 独立生成。 +func TestDeriveKeyWithHashKnownVector(t *testing.T) { + const want = "c3d64081b5d8986fe81498e5cf0f5ed0c3bf9f011ac699ead49a7b192fe69ef239f970ba2de9827289e036f9d80fc18cc870c518d16f512cb623017df08820f8" + + got, err := pbkdf2x.DeriveKeyWithHash([]byte("password"), []byte("saltsaltsalt"), 10000, 64, sha512.New) + if err != nil { + t.Fatalf("DeriveKeyWithHash 返回错误: %v", err) + } + if hex.EncodeToString(got) != want { + t.Errorf("PBKDF2-HMAC-SHA512 结果与已知向量不一致\n got: %s\nwant: %s", hex.EncodeToString(got), want) + } + + // 换哈希必须得到不同结果,证明 h 真正生效而不是被写死为 SHA-256。 + sha256Key, err := pbkdf2x.DeriveKey([]byte("password"), []byte("saltsaltsalt"), 10000, 64) + if err != nil { + t.Fatalf("DeriveKey 返回错误: %v", err) + } + if bytes.Equal(sha256Key, got) { + t.Error("SHA-512 与 SHA-256 派生结果相同,说明哈希参数未生效") + } + + // SHA-1 仅用于兼容旧系统,这里只验证可用性与长度。 + sha1Key, err := pbkdf2x.DeriveKeyWithHash([]byte("password"), []byte("saltsaltsalt"), 10000, 20, sha1.New) + if err != nil { + t.Fatalf("DeriveKeyWithHash(sha1) 返回错误: %v", err) + } + if len(sha1Key) != 20 { + t.Errorf("SHA-1 派生长度 = %d, 期望 20", len(sha1Key)) + } +} + +// TestDeriveKeyReproducible 覆盖最核心的语义:同口令同盐同参数必须可复现。 +func TestDeriveKeyReproducible(t *testing.T) { + password := []byte("正确的马电池订书钉") + salt := []byte("0123456789abcdef") + + k1, err := pbkdf2x.DeriveKey(password, salt, pbkdf2x.MinIterations, pbkdf2x.DefaultKeyLength) + if err != nil { + t.Fatalf("第一次派生失败: %v", err) + } + k2, err := pbkdf2x.DeriveKey(password, salt, pbkdf2x.MinIterations, pbkdf2x.DefaultKeyLength) + if err != nil { + t.Fatalf("第二次派生失败: %v", err) + } + if !bytes.Equal(k1, k2) { + t.Fatal("相同输入两次派生结果不一致,无法复现") + } + if len(k1) != pbkdf2x.DefaultKeyLength { + t.Fatalf("派生长度 = %d, 期望 %d", len(k1), pbkdf2x.DefaultKeyLength) + } + + otherSalt, err := pbkdf2x.DeriveKey(password, []byte("fedcba9876543210"), pbkdf2x.MinIterations, pbkdf2x.DefaultKeyLength) + if err != nil { + t.Fatalf("换盐派生失败: %v", err) + } + if bytes.Equal(k1, otherSalt) { + t.Error("不同盐得到了相同密钥") + } + + otherPassword, err := pbkdf2x.DeriveKey([]byte("错误口令"), salt, pbkdf2x.MinIterations, pbkdf2x.DefaultKeyLength) + if err != nil { + t.Fatalf("换口令派生失败: %v", err) + } + if bytes.Equal(k1, otherPassword) { + t.Error("不同口令得到了相同密钥") + } + + otherIterations, err := pbkdf2x.DeriveKey(password, salt, pbkdf2x.MinIterations*2, pbkdf2x.DefaultKeyLength) + if err != nil { + t.Fatalf("换迭代次数派生失败: %v", err) + } + if bytes.Equal(k1, otherIterations) { + t.Error("不同迭代次数得到了相同密钥") + } +} + +// TestDeriveKeyWithRandomSalt 针对被修复的核心缺陷:随机盐必须一并返回, +// 且该盐必须能复现同一个密钥。 +func TestDeriveKeyWithRandomSalt(t *testing.T) { + password := []byte("hunter2") + + key, salt, err := pbkdf2x.DeriveKeyWithRandomSalt(password, pbkdf2x.DefaultSaltLength, pbkdf2x.MinIterations, pbkdf2x.DefaultKeyLength) + if err != nil { + t.Fatalf("DeriveKeyWithRandomSalt 返回错误: %v", err) + } + if len(salt) != pbkdf2x.DefaultSaltLength { + t.Fatalf("返回的盐长度 = %d, 期望 %d(盐必须是原始字节,不是十六进制字符串)", len(salt), pbkdf2x.DefaultSaltLength) + } + if len(key) != pbkdf2x.DefaultKeyLength { + t.Fatalf("返回的密钥长度 = %d, 期望 %d", len(key), pbkdf2x.DefaultKeyLength) + } + + // 用返回的盐复现:这是旧实现丢失盐值后无法做到的事情。 + reproduced, err := pbkdf2x.DeriveKey(password, salt, pbkdf2x.MinIterations, pbkdf2x.DefaultKeyLength) + if err != nil { + t.Fatalf("用返回的盐复现失败: %v", err) + } + if !bytes.Equal(key, reproduced) { + t.Fatal("用 DeriveKeyWithRandomSalt 返回的盐无法复现同一密钥") + } + + key2, salt2, err := pbkdf2x.DeriveKeyWithRandomSalt(password, pbkdf2x.DefaultSaltLength, pbkdf2x.MinIterations, pbkdf2x.DefaultKeyLength) + if err != nil { + t.Fatalf("第二次 DeriveKeyWithRandomSalt 失败: %v", err) + } + if bytes.Equal(salt, salt2) { + t.Error("两次调用返回了相同的盐,随机盐未生效") + } + if bytes.Equal(key, key2) { + t.Error("两次调用返回了相同的密钥") + } + + // 非法 saltLength 必须报错而不是 panic。 + for _, bad := range []int{0, -1, pbkdf2x.MinSaltLength - 1, 1025, math.MaxInt} { + if _, _, err := pbkdf2x.DeriveKeyWithRandomSalt(password, bad, pbkdf2x.MinIterations, pbkdf2x.DefaultKeyLength); err == nil { + t.Errorf("saltLength=%d 时未返回错误", bad) + } + } +} + +func TestVerify(t *testing.T) { + password := []byte("s3cret-口令") + salt := []byte("0123456789abcdef") + key, err := pbkdf2x.DeriveKey(password, salt, pbkdf2x.MinIterations, pbkdf2x.DefaultKeyLength) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + + ok, err := pbkdf2x.Verify(password, salt, key, pbkdf2x.MinIterations, pbkdf2x.DefaultKeyLength) + if err != nil { + t.Fatalf("Verify 返回错误: %v", err) + } + if !ok { + t.Error("正确口令 + 正确盐未通过校验") + } + + cases := []struct { + name string + password []byte + salt []byte + expected []byte + }{ + {"错误口令", []byte("s3cret-口令x"), salt, key}, + {"错误盐", password, []byte("fedcba9876543210"), key}, + {"密钥被篡改", password, salt, append(append([]byte{}, key[:len(key)-1]...), key[len(key)-1]^0x01)}, + {"密钥被截断", password, salt, key[:len(key)-1]}, + {"密钥被加长", password, salt, append(append([]byte{}, key...), 0x00)}, + {"空密钥", password, salt, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ok, err := pbkdf2x.Verify(tc.password, tc.salt, tc.expected, pbkdf2x.MinIterations, pbkdf2x.DefaultKeyLength) + if err != nil { + t.Fatalf("参数合法时 Verify 不应返回错误: %v", err) + } + if ok { + t.Error("不匹配的密钥被判为匹配") + } + }) + } +} + +// TestParameterValidation 覆盖所有非法参数路径:必须返回 error,且绝不 panic。 +func TestParameterValidation(t *testing.T) { + validSalt := bytes.Repeat([]byte{0xA5}, pbkdf2x.MinSaltLength) + password := []byte("pw") + + cases := []struct { + name string + salt []byte + iterations int + keyLength int + saltInvalid bool + }{ + {"盐为 nil", nil, pbkdf2x.MinIterations, 32, true}, + {"盐为空切片", []byte{}, pbkdf2x.MinIterations, 32, true}, + {"盐过短", []byte("1234567"), pbkdf2x.MinIterations, 32, true}, + {"盐过长", bytes.Repeat([]byte{0x01}, 1025), pbkdf2x.MinIterations, 32, true}, + {"迭代次数为 0", validSalt, 0, 32, false}, + {"迭代次数为负", validSalt, -1, 32, false}, + {"迭代次数低于下限", validSalt, pbkdf2x.MinIterations - 1, 32, false}, + {"迭代次数超上限", validSalt, pbkdf2x.MaxIterations + 1, 32, false}, + {"迭代次数为 MaxInt", validSalt, math.MaxInt, 32, false}, + {"密钥长度为 0", validSalt, pbkdf2x.MinIterations, 0, false}, + {"密钥长度为负", validSalt, pbkdf2x.MinIterations, -32, false}, + {"密钥长度超上限", validSalt, pbkdf2x.MinIterations, pbkdf2x.MaxKeyLength + 1, false}, + {"密钥长度为 MaxInt", validSalt, pbkdf2x.MinIterations, math.MaxInt, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + key, err := pbkdf2x.DeriveKey(password, tc.salt, tc.iterations, tc.keyLength) + if err == nil { + t.Fatalf("DeriveKey 未返回错误,得到 %d 字节密钥", len(key)) + } + if key != nil { + t.Errorf("出错时密钥应为 nil,实际长度 %d", len(key)) + } + + if _, err := pbkdf2x.Verify(password, tc.salt, validSalt, tc.iterations, tc.keyLength); err == nil { + t.Error("Verify 对非法参数未返回错误") + } + if _, err := pbkdf2x.DeriveKeyWithHash(password, tc.salt, tc.iterations, tc.keyLength, nil); err == nil { + t.Error("DeriveKeyWithHash 对非法参数未返回错误") + } + // DeriveKeyWithRandomSalt 自行生成盐,因此仅在迭代次数/长度非法时才应报错。 + if !tc.saltInvalid { + if _, _, err := pbkdf2x.DeriveKeyWithRandomSalt(password, pbkdf2x.DefaultSaltLength, tc.iterations, tc.keyLength); err == nil { + t.Error("DeriveKeyWithRandomSalt 对非法参数未返回错误") + } + } + }) + } + + // 合法的盐/迭代次数/长度组合但 h 为 nil:同样必须是 error 而不是 panic。 + if _, err := pbkdf2x.DeriveKeyWithHash(password, validSalt, pbkdf2x.MinIterations, 32, nil); err == nil { + t.Error("哈希构造函数为 nil 时未返回错误") + } +} + +// TestBoundaryValues 验证边界值落在"允许"一侧时确实可用。 +func TestBoundaryValues(t *testing.T) { + salt := bytes.Repeat([]byte{0x5A}, pbkdf2x.MinSaltLength) + + // 最小迭代次数 + 最大密钥长度。 + key, err := pbkdf2x.DeriveKey([]byte("pw"), salt, pbkdf2x.MinIterations, pbkdf2x.MaxKeyLength) + if err != nil { + t.Fatalf("边界参数派生失败: %v", err) + } + if len(key) != pbkdf2x.MaxKeyLength { + t.Errorf("密钥长度 = %d, 期望 %d", len(key), pbkdf2x.MaxKeyLength) + } + + // 最小密钥长度。 + if _, err := pbkdf2x.DeriveKey([]byte("pw"), salt, pbkdf2x.MinIterations, 1); err != nil { + t.Errorf("keyLength=1 应被接受: %v", err) + } +} + +func TestGenerateSalt(t *testing.T) { + for _, length := range []int{pbkdf2x.MinSaltLength, pbkdf2x.DefaultSaltLength, 32, 64} { + salt, err := pbkdf2x.GenerateSalt(length) + if err != nil { + t.Fatalf("GenerateSalt(%d) 返回错误: %v", length, err) + } + if len(salt) != length { + t.Errorf("盐长度 = %d, 期望 %d", len(salt), length) + } + } + + a, err := pbkdf2x.GenerateSalt(pbkdf2x.DefaultSaltLength) + if err != nil { + t.Fatalf("GenerateSalt 返回错误: %v", err) + } + b, err := pbkdf2x.GenerateSalt(pbkdf2x.DefaultSaltLength) + if err != nil { + t.Fatalf("GenerateSalt 返回错误: %v", err) + } + if bytes.Equal(a, b) { + t.Error("两次生成的盐相同,随机性有问题") + } + + for _, length := range []int{0, -1, pbkdf2x.MinSaltLength - 1, 1025, math.MaxInt} { + if _, err := pbkdf2x.GenerateSalt(length); err == nil { + t.Errorf("GenerateSalt(%d) 未返回错误", length) + } + } +} diff --git a/ripemd160x/ripemd160x.go b/ripemd160x/ripemd160x.go new file mode 100644 index 0000000..ff9da7c --- /dev/null +++ b/ripemd160x/ripemd160x.go @@ -0,0 +1,102 @@ +// Package ripemd160x 提供 RIPEMD-160 摘要计算,以及十六进制/Base64 编码与流式、文件接口。 +// +// # 安全警告 +// +// RIPEMD-160 的输出只有 160 位,且已不再被视为现代安全强度的哈希: +// 它仅用于兼容旧系统(典型场景是 Bitcoin 等加密货币的 P2PKH 地址, +// 即 RIPEMD160(SHA256(pubkey))),不可用于口令存储、口令派生或数字签名。 +// 其上游实现 golang.org/x/crypto/ripemd160 已被官方标记为废弃(deprecated)。 +// 新代码请使用 sha256x、sha512x、sha3x 或 blake2x。 +package ripemd160x + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "hash" + "io" + "os" + + //lint:ignore SA1019 RIPEMD-160 已被上游标记为废弃;本包保留它只为兼容既有系统(如比特币地址),包注释已明确警告其不可用于新设计。 + "golang.org/x/crypto/ripemd160" +) + +const ( + // Size 是 RIPEMD-160 摘要的字节数(20)。 + Size = ripemd160.Size + // BlockSize 是 RIPEMD-160 分组的字节数(64)。 + BlockSize = ripemd160.BlockSize +) + +// New 返回一个计算 RIPEMD-160 摘要的 hash.Hash,可反复 Write 以流式处理数据。 +// +// 安全警告:RIPEMD-160 仅用于兼容旧系统(如 Bitcoin 地址),不可用于口令或签名。 +func New() hash.Hash { + return ripemd160.New() +} + +// Sum 返回 data 的 RIPEMD-160 摘要(Size 字节原始数据)。 +// +// 安全警告:RIPEMD-160 仅用于兼容旧系统(如 Bitcoin 地址),不可用于口令或签名。 +func Sum(data []byte) []byte { + h := ripemd160.New() + _, _ = h.Write(data) + return h.Sum(nil) +} + +// Hex 返回 data 的 RIPEMD-160 摘要的小写十六进制字符串。 +// +// 安全警告:RIPEMD-160 仅用于兼容旧系统(如 Bitcoin 地址),不可用于口令或签名。 +func Hex(data []byte) string { + return hex.EncodeToString(Sum(data)) +} + +// Base64 返回 data 的 RIPEMD-160 摘要的标准 Base64 字符串。 +// +// 安全警告:RIPEMD-160 仅用于兼容旧系统(如 Bitcoin 地址),不可用于口令或签名。 +func Base64(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum(data)) +} + +// SumReader 读取 r 中的全部数据并返回其 RIPEMD-160 摘要;读取失败时返回包装后的 error。 +// +// 安全警告:RIPEMD-160 仅用于兼容旧系统(如 Bitcoin 地址),不可用于口令或签名。 +func SumReader(r io.Reader) ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("ripemd160x: 读取器为 nil") + } + h := ripemd160.New() + if _, err := io.Copy(h, r); err != nil { + return nil, fmt.Errorf("ripemd160x: 读取数据失败: %w", err) + } + return h.Sum(nil), nil +} + +// HexReader 读取 r 中的全部数据并返回其 RIPEMD-160 摘要的小写十六进制字符串。 +// +// 安全警告:RIPEMD-160 仅用于兼容旧系统(如 Bitcoin 地址),不可用于口令或签名。 +func HexReader(r io.Reader) (string, error) { + sum, err := SumReader(r) + if err != nil { + return "", err + } + return hex.EncodeToString(sum), nil +} + +// HexFile 计算 path 所指向文件的 RIPEMD-160 摘要并返回小写十六进制字符串; +// 文件不存在或读取失败时返回包装后的 error,不 panic。 +// +// 安全警告:RIPEMD-160 仅用于兼容旧系统(如 Bitcoin 地址),不可用于口令或签名。 +func HexFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("ripemd160x: 打开文件 %q 失败: %w", path, err) + } + defer f.Close() + + sum, err := SumReader(f) + if err != nil { + return "", fmt.Errorf("ripemd160x: 计算文件 %q 摘要失败: %w", path, err) + } + return hex.EncodeToString(sum), nil +} diff --git a/ripemd160x/ripemd160x_test.go b/ripemd160x/ripemd160x_test.go new file mode 100644 index 0000000..a6cbc35 --- /dev/null +++ b/ripemd160x/ripemd160x_test.go @@ -0,0 +1,180 @@ +package ripemd160x_test + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "code.yun.ink/pkg/encryptx/ripemd160x" +) + +// 测试向量来源:RIPEMD-160 官方测试向量(Bosselaers/Preneel), +// 与 golang.org/x/crypto/ripemd160 自带测试用例一致。 +var vectors = []struct { + name string + input string + want string +}{ + {"空串", "", "9c1185a5c5e9fc54612808977ee8f548b2258d31"}, + {"a", "a", "0bdc9d2d256b3ee9daae347be6f4dc835a467ffe"}, + {"abc", "abc", "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc"}, + {"message digest", "message digest", "5d0689ef49d2fae572b881b123a85ffa21595f36"}, + {"a-z", "abcdefghijklmnopqrstuvwxyz", "f71c27109c692c1b56bbdceb5b9d2865b3708dbc"}, +} + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("模拟读取失败") } + +func TestConstants(t *testing.T) { + if ripemd160x.Size != 20 { + t.Fatalf("Size = %d, 期望 20", ripemd160x.Size) + } + if ripemd160x.BlockSize != 64 { + t.Fatalf("BlockSize = %d, 期望 64", ripemd160x.BlockSize) + } +} + +func TestVectors(t *testing.T) { + for _, v := range vectors { + t.Run(v.name, func(t *testing.T) { + data := []byte(v.input) + sum := ripemd160x.Sum(data) + if len(sum) != ripemd160x.Size { + t.Fatalf("Sum 长度 = %d, 期望 %d", len(sum), ripemd160x.Size) + } + if got := hex.EncodeToString(sum); got != v.want { + t.Errorf("Sum = %s, 期望 %s", got, v.want) + } + if got := ripemd160x.Hex(data); got != v.want { + t.Errorf("Hex = %s, 期望 %s", got, v.want) + } + if want := base64.StdEncoding.EncodeToString(sum); ripemd160x.Base64(data) != want { + t.Errorf("Base64 = %s, 期望 %s", ripemd160x.Base64(data), want) + } + h := ripemd160x.New() + if _, err := h.Write(data); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != v.want { + t.Errorf("New+Write = %s, 期望 %s", got, v.want) + } + }) + } +} + +func TestStreamingMatchesSum(t *testing.T) { + data := []byte(strings.Repeat("encryptx-ripemd160-", 100)) + h := ripemd160x.New() + for i := 0; i < len(data); i += 19 { + end := i + 19 + if end > len(data) { + end = len(data) + } + if _, err := h.Write(data[i:end]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + } + if got, want := h.Sum(nil), ripemd160x.Sum(data); !bytes.Equal(got, want) { + t.Errorf("分块写入 = %x, 期望 %x", got, want) + } +} + +func TestReaderConsistency(t *testing.T) { + const s = "encryptx 一致性检查" + sum, err := ripemd160x.SumReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("SumReader 失败: %v", err) + } + if want := ripemd160x.Sum([]byte(s)); !bytes.Equal(sum, want) { + t.Errorf("SumReader = %x, 期望 %x", sum, want) + } + hexStr, err := ripemd160x.HexReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if want := ripemd160x.Hex([]byte(s)); hexStr != want { + t.Errorf("HexReader = %s, 期望 %s", hexStr, want) + } +} + +func TestReaderErrors(t *testing.T) { + if _, err := ripemd160x.SumReader(nil); err == nil { + t.Fatal("nil 读取器应返回 error") + } + if _, err := ripemd160x.HexReader(nil); err == nil { + t.Fatal("nil 读取器应返回 error") + } + if _, err := ripemd160x.SumReader(failingReader{}); err == nil { + t.Fatal("读取失败时应返回 error") + } +} + +func TestHexFile(t *testing.T) { + dir := t.TempDir() + content := []byte("文件内容 file content\n") + path := filepath.Join(dir, "sample.bin") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("写入测试文件失败: %v", err) + } + got, err := ripemd160x.HexFile(path) + if err != nil { + t.Fatalf("HexFile 失败: %v", err) + } + if want := ripemd160x.Hex(content); got != want { + t.Errorf("HexFile = %s, 期望 %s", got, want) + } +} + +func TestHexFileErrors(t *testing.T) { + if _, err := ripemd160x.HexFile(filepath.Join(t.TempDir(), "不存在.bin")); err == nil { + t.Fatal("不存在的文件应返回 error") + } + if _, err := ripemd160x.HexFile(""); err == nil { + t.Fatal("空路径应返回 error") + } + if _, err := ripemd160x.HexFile(t.TempDir()); err == nil { + t.Fatal("目录路径应返回 error") + } +} + +func TestLargeInput(t *testing.T) { + data := largeData(1 << 20) + want := ripemd160x.Hex(data) + if got := ripemd160x.Hex(data); got != want { + t.Errorf("重复计算结果不一致: %s vs %s", got, want) + } + + got, err := ripemd160x.HexReader(bytes.NewReader(data)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if got != want { + t.Errorf("HexReader = %s, 期望 %s", got, want) + } + + h := ripemd160x.New() + if _, err := h.Write(data[:1<<19]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if _, err := h.Write(data[1<<19:]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + t.Errorf("1MiB 分块 = %s, 期望 %s", got, want) + } +} + +// largeData 生成确定性的 n 字节数据。 +func largeData(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i*31 + i/7) + } + return b +} diff --git a/rsax/rsax.go b/rsax/rsax.go index 49901cf..3d5e9d9 100644 --- a/rsax/rsax.go +++ b/rsax/rsax.go @@ -1,119 +1,682 @@ +// Package rsax 提供 RSA 密钥生成、PEM 编解码、加解密与数字签名的安全封装。 +// +// 设计约定: +// - 任何失败路径都返回 error,绝不 panic; +// - 加解密默认使用 OAEP + SHA-256,签名默认使用 PSS + SHA-256; +// - 二进制数据(密钥、明文、密文、签名)一律使用 []byte; +// - 不把私钥、明文或随机数写入日志、标准输出或 error 文本。 +// +// 安全注意事项: +// - 密钥长度下限为 MinKeyBits(2048 位),更短的密钥会被拒绝; +// - PKCS#1 v1.5 加密(EncryptPKCS1v15)仅为兼容老旧系统保留,新代码应使用 OAEP; +// - EncryptOAEPLong 的分块格式自身不携带完整性校验与边界信息, +// 在需要抵御密文篡改或重排的场景,应先对明文做 AEAD/签名保护,或只使用单块 EncryptOAEP。 package rsax import ( + "crypto" "crypto/rand" "crypto/rsa" + "crypto/sha256" "crypto/x509" "encoding/pem" + "errors" + "fmt" + "hash" "os" ) -//生成RSA私钥和公钥,保存到文件中 -// 与下面命令等价 -// 生成私钥 -// openssl genrsa -out rsa_private_key.pem 1024 -// 生成公钥 -// openssl rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem -// bits 证书大小 1024/2048 -func GenerateRSAKey(bits int) { - //GenerateKey函数使用随机数据生成器random生成一对具有指定字位数的RSA密钥 - //Reader是一个全局、共享的密码用强随机数生成器 - privateKey, err := rsa.GenerateKey(rand.Reader, bits) - if err != nil { - panic(err) - } - //保存私钥 - //通过x509标准将得到的ras私钥序列化为ASN.1 的 DER编码字符串 - X509PrivateKey := x509.MarshalPKCS1PrivateKey(privateKey) - //使用pem格式对x509输出的内容进行编码 - //创建文件保存私钥 - privateFile, err := os.Create("private.pem") - if err != nil { - panic(err) - } - defer privateFile.Close() - //构建一个pem.Block结构体对象 - privateBlock := pem.Block{Type: "RSA Private Key", Bytes: X509PrivateKey} - //将数据保存到文件 - pem.Encode(privateFile, &privateBlock) +// cryptoHash 是 OAEP 与签名使用的标准库哈希标识,必须与 rsaHash 返回的构造器一致。 +// 安全注意事项:修改该值会同时改变 OAEP 填充与签名格式,导致与既有数据不兼容。 +const cryptoHash = crypto.SHA256 - //保存公钥 - //获取公钥的数据 - publicKey := privateKey.PublicKey - //X509对公钥编码 - X509PublicKey, err := x509.MarshalPKIXPublicKey(&publicKey) - if err != nil { - panic(err) +// 密钥长度等关键参数集中定义;调用方应直接引用这些常量,不要硬编码数字。 +const ( + // MinKeyBits 是允许**生成**的最小 RSA 密钥长度(位)。 + // 安全注意事项:小于 2048 位的 RSA 密钥已不满足现代安全强度, + // 所有生成入口都会拒绝更小的值。 + MinKeyBits = 2048 + + // MinUsableKeyBits 是允许**解析和使用既有密钥**的最小长度(位)。 + // + // 它低于 MinKeyBits 是刻意的:旧系统里真实存在 1024 位密钥,若连解析都 + // 拒绝,持有这类密钥的调用方将无法解密自己既有的数据——那是把安全策略 + // 变成了功能性故障。因此本库只在**生成**环节强制 2048 位,对既有密钥允许 + // 使用到该下限,并通过 IsWeakPrivateKey / IsWeakPublicKey 让调用方自行判断 + // 是否要告警或拒绝。 + // + // 低于 1024 位的 RSA 已可被低成本分解,无论何种理由都不接受。 + // 该值与 Go 标准库自身的下限一致(标准库已拒绝生成低于 1024 位的密钥)。 + MinUsableKeyBits = 1024 + + // WeakKeyBits 是"弱密钥"的判定阈值(位):低于该值即视为不满足当前建议强度。 + // IsWeakPrivateKey / IsWeakPublicKey 以此为判断依据。 + WeakKeyBits = 2048 + + // DefaultKeyBits 是未显式指定长度时使用的默认 RSA 密钥长度(位)。 + // 安全注意事项:2048 位是当前通用场景的最低推荐值, + // 需要长期保密或高价值数据时请显式指定更大长度。 + DefaultKeyBits = 2048 + + // MaxKeyBits 是允许生成的最大 RSA 密钥长度(位)。 + // 安全注意事项:设置上限用于防止误传超大位宽导致生成过程长时间占用 CPU 与内存。 + MaxKeyBits = 8192 + + // OAEPHash 是 OAEP 加解密与签名默认使用的哈希算法名。 + // 安全注意事项:SHA-256 与 2048 位密钥组合是当前推荐配置; + // 若生成 8192 位等超大密钥并需要更高强度,可另行封装 SHA-512 版本。 + OAEPHash = "SHA-256" +) + +// IsWeakPrivateKey 报告私钥是否弱于当前建议强度(WeakKeyBits)。 +// +// 用于在解析既有密钥后决定是否告警或强制轮换;nil 或参数缺失一律视为弱密钥。 +func IsWeakPrivateKey(priv *rsa.PrivateKey) bool { + if priv == nil || priv.N == nil { + return true } - //pem格式编码 - //创建用于保存公钥的文件 - publicFile, err := os.Create("public.pem") - if err != nil { - panic(err) - } - defer publicFile.Close() - //创建一个pem.Block结构体对象 - publicBlock := pem.Block{Type: "RSA Public Key", Bytes: X509PublicKey} - //保存到文件 - pem.Encode(publicFile, &publicBlock) + return priv.N.BitLen() < WeakKeyBits } -// RSA加密 -// plainText 要加密的数据 -// path 公钥匙文件地址 -func RSA_Encrypt(plainText []byte, path string) []byte { - //打开文件 - file, err := os.Open(path) - if err != nil { - panic(err) +// IsWeakPublicKey 报告公钥是否弱于当前建议强度(WeakKeyBits)。 +func IsWeakPublicKey(pub *rsa.PublicKey) bool { + if pub == nil || pub.N == nil { + return true } - defer file.Close() - //读取文件的内容 - info, _ := file.Stat() - buf := make([]byte, info.Size()) - file.Read(buf) - //pem解码 - block, _ := pem.Decode(buf) - //x509解码 - - publicKeyInterface, err := x509.ParsePKIXPublicKey(block.Bytes) - if err != nil { - panic(err) - } - //类型断言 - publicKey := publicKeyInterface.(*rsa.PublicKey) - //对明文进行加密 - cipherText, err := rsa.EncryptPKCS1v15(rand.Reader, publicKey, plainText) - if err != nil { - panic(err) - } - //返回密文 - return cipherText + return pub.N.BitLen() < WeakKeyBits } -//RSA解密 -// cipherText 需要解密的byte数据 -// path 私钥文件路径 -func RSA_Decrypt(cipherText []byte, path string) []byte { - //打开文件 - file, err := os.Open(path) - if err != nil { - panic(err) +const ( + // pemTypePublicKey 是 PKIX(SubjectPublicKeyInfo)公钥的 PEM 标签。 + pemTypePublicKey = "PUBLIC KEY" + // pemTypePKCS1PublicKey 是 PKCS#1 公钥的 PEM 标签。 + pemTypePKCS1PublicKey = "RSA PUBLIC KEY" + // pemTypePrivateKey 是 PKCS#8 私钥的 PEM 标签。 + pemTypePrivateKey = "PRIVATE KEY" + // pemTypePKCS1PrivateKey 是 PKCS#1 私钥的 PEM 标签。 + pemTypePKCS1PrivateKey = "RSA PRIVATE KEY" +) + +// 包级哨兵错误,调用方可用 errors.Is 判断失败类别,无需解析错误文本。 +var ( + // ErrInvalidKeySize 表示 RSA 密钥长度不在允许范围内。 + ErrInvalidKeySize = errors.New("rsax: invalid RSA key size") + // ErrNilKey 表示传入了 nil 密钥或缺少必要参数。 + ErrNilKey = errors.New("rsax: nil RSA key") + // ErrInvalidPEM 表示 PEM 输入为空、无法解码或标签不受支持。 + ErrInvalidPEM = errors.New("rsax: invalid PEM data") + // ErrInvalidCiphertext 表示密文为空、长度不合法或无法解密。 + ErrInvalidCiphertext = errors.New("rsax: invalid ciphertext") + // ErrInvalidSignature 表示签名验证失败。 + ErrInvalidSignature = errors.New("rsax: signature verification failed") + // ErrEmptyPath 表示文件路径为空。 + ErrEmptyPath = errors.New("rsax: empty file path") +) + +// 私钥文件、公钥文件的权限位(POSIX 语义)。 +const ( + privateKeyFileMode os.FileMode = 0o600 + publicKeyFileMode os.FileMode = 0o644 +) + +// rsaHash 返回加解密与签名使用的哈希构造器。 +// 安全注意事项:返回值必须与 OAEP 的单块长度计算保持一致, +// 修改此处会改变 padding 结构,导致新旧版本密文/签名互不兼容。 +func rsaHash() hash.Hash { return sha256.New() } + +// hashSize 是默认哈希(SHA-256)的输出长度(字节)。 +func hashSize() int { return sha256.Size } + +// checkKeySize 校验请求的密钥长度并补充空值情况说明。 +// +// bits 为期望的密钥长度(位)。安全注意事项:只有 [MinKeyBits, MaxKeyBits] +// 区间内的长度才被接受,拒绝弱密钥与可能耗尽资源的超大位宽。 +func checkKeySize(bits int) error { + if bits < MinKeyBits { + return fmt.Errorf("%w: need at least %d bits, got %d: 弱密钥会被拒绝", ErrInvalidKeySize, MinKeyBits, bits) } - defer file.Close() - //获取文件内容 - info, _ := file.Stat() - buf := make([]byte, info.Size()) - file.Read(buf) - //pem解码 - block, _ := pem.Decode(buf) - //X509解码 - privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) - if err != nil { - panic(err) + if bits > MaxKeyBits { + return fmt.Errorf("%w: need at most %d bits, got %d", ErrInvalidKeySize, MaxKeyBits, bits) } - //对密文进行解密 - plainText, _ := rsa.DecryptPKCS1v15(rand.Reader, privateKey, cipherText) - //返回明文 - return plainText + return nil +} + +// checkPrivateKey 校验私钥非 nil 且长度不低于可使用的下限。 +// +// priv 为待校验的私钥。这里用的是 MinUsableKeyBits 而非 MinKeyBits:本函数服务 +// 于"使用既有密钥"的路径(解析、加解密、签名),允许 2048 位以下的旧密钥继续 +// 工作,只拒绝低于绝对下限的密钥。是否对弱密钥告警请用 IsWeakPrivateKey。 +func checkPrivateKey(priv *rsa.PrivateKey) error { + if priv == nil { + return fmt.Errorf("%w: private key", ErrNilKey) + } + if priv.N == nil || priv.D == nil { + return fmt.Errorf("%w: private key 缺少 N/D 参数", ErrNilKey) + } + if bits := priv.N.BitLen(); bits < MinUsableKeyBits { + return fmt.Errorf("%w: private key 只有 %d 位, 少于可使用下限 %d 位", + ErrInvalidKeySize, bits, MinUsableKeyBits) + } + return nil +} + +// checkPublicKey 校验公钥非 nil 且长度不低于可使用的下限。 +// +// pub 为待校验的公钥。与 checkPrivateKey 同理,这里允许既有弱公钥继续使用, +// 只拒绝低于 MinUsableKeyBits 的密钥。 +func checkPublicKey(pub *rsa.PublicKey) error { + if pub == nil { + return fmt.Errorf("%w: public key", ErrNilKey) + } + if pub.N == nil { + return fmt.Errorf("%w: public key 缺少 N 参数", ErrNilKey) + } + if bits := pub.N.BitLen(); bits < MinUsableKeyBits { + return fmt.Errorf("%w: public key 只有 %d 位, 少于可使用下限 %d 位", + ErrInvalidKeySize, bits, MinUsableKeyBits) + } + return nil +} + +// GenerateKey 生成一对 RSA 密钥,返回私钥(公钥可通过 priv.PublicKey 获取)。 +// +// bits 为密钥长度(位),必须位于 [MinKeyBits, MaxKeyBits] 区间内; +// 传入 0 之外的越界值都会返回包装了错误原因的 error。 +// 安全注意事项:随机源固定为 crypto/rand,密钥生成失败时不会返回半成品密钥。 +func GenerateKey(bits int) (*rsa.PrivateKey, error) { + if err := checkKeySize(bits); err != nil { + return nil, err + } + priv, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return nil, fmt.Errorf("rsax: 生成 %d 位 RSA 密钥失败: %w", bits, err) + } + if err := priv.Validate(); err != nil { + return nil, fmt.Errorf("rsax: 生成的 RSA 私钥未通过自检: %w", err) + } + return priv, nil +} + +// GenerateKeyPairPEM 生成一对 RSA 密钥并直接返回 PEM 编码结果。 +// +// bits 为密钥长度(位),取值范围同 GenerateKey。私钥使用 PKCS#8 编码, +// 公钥使用 PKIX 编码。返回的切片仅在内存中流转,调用方负责在不再需要时清零。 +// 安全注意事项:私钥 PEM 属于高度敏感数据,不要写入日志或通过网络明文传输。 +func GenerateKeyPairPEM(bits int) (privatePEM, publicPEM []byte, err error) { + priv, err := GenerateKey(bits) + if err != nil { + return nil, nil, err + } + privatePEM, err = MarshalPrivateKeyPEM(priv) + if err != nil { + return nil, nil, err + } + publicPEM, err = MarshalPublicKeyPEM(&priv.PublicKey) + if err != nil { + return nil, nil, err + } + return privatePEM, publicPEM, nil +} + +// MarshalPrivateKeyPEM 把私钥编码为 PKCS#8 的 PEM 数据。 +// +// priv 为待编码私钥,必须非 nil 且长度不小于 MinKeyBits。 +// 安全注意事项:返回内容包含私钥明文材料,等价于私钥本身,须妥善保管。 +func MarshalPrivateKeyPEM(priv *rsa.PrivateKey) ([]byte, error) { + if err := checkPrivateKey(priv); err != nil { + return nil, err + } + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, fmt.Errorf("rsax: 编码 PKCS#8 私钥失败: %w", err) + } + block := &pem.Block{Type: pemTypePrivateKey, Bytes: der} + out := pem.EncodeToMemory(block) + if out == nil { + return nil, fmt.Errorf("rsax: 生成私钥 PEM 失败: %w", ErrInvalidPEM) + } + return out, nil +} + +// MarshalPublicKeyPEM 把公钥编码为 PKIX(SubjectPublicKeyInfo)的 PEM 数据。 +// +// pub 为待编码公钥,必须非 nil 且长度不小于 MinKeyBits。 +// 安全注意事项:公钥可公开分发,但仍应确认其来源真实,避免被替换为攻击者公钥。 +func MarshalPublicKeyPEM(pub *rsa.PublicKey) ([]byte, error) { + if err := checkPublicKey(pub); err != nil { + return nil, err + } + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return nil, fmt.Errorf("rsax: 编码 PKIX 公钥失败: %w", err) + } + block := &pem.Block{Type: pemTypePublicKey, Bytes: der} + out := pem.EncodeToMemory(block) + if out == nil { + return nil, fmt.Errorf("rsax: 生成公钥 PEM 失败: %w", ErrInvalidPEM) + } + return out, nil +} + +// ParsePrivateKeyPEM 从 PEM 数据解析 RSA 私钥,同时支持 PKCS#1 与 PKCS#8。 +// +// data 为 PEM 文本(通常来自文件或配置),不得为空。 +// 解析失败(空输入、非 PEM、DER 损坏、不是 RSA 私钥、密钥长度不足)一律返回 error。 +// 安全注意事项:不支持的算法(如 ECDSA/Ed25519 私钥)会被明确拒绝,绝不降级或猜测; +// 调用方应把解析失败的 PEM 视为不可信输入,不要回显其内容。 +func ParsePrivateKeyPEM(data []byte) (*rsa.PrivateKey, error) { + if len(data) == 0 { + return nil, fmt.Errorf("%w: 输入为空", ErrInvalidPEM) + } + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("%w: 无法解码 PEM(缺少 BEGIN/END 边界或格式非法)", ErrInvalidPEM) + } + if len(block.Bytes) == 0 { + return nil, fmt.Errorf("%w: PEM 块内容为空", ErrInvalidPEM) + } + + var ( + priv *rsa.PrivateKey + ok bool + err error + ) + switch block.Type { + case pemTypePKCS1PrivateKey: + priv, err = x509.ParsePKCS1PrivateKey(block.Bytes) + case pemTypePrivateKey: + var key any + key, err = x509.ParsePKCS8PrivateKey(block.Bytes) + if err == nil { + priv, ok = key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("%w: PKCS#8 私钥不是 RSA 类型(%T)", ErrInvalidPEM, key) + } + } + default: + return nil, fmt.Errorf("%w: 不支持的私钥 PEM 标签 %q", ErrInvalidPEM, block.Type) + } + if err != nil { + return nil, fmt.Errorf("rsax: 解析私钥失败: %w", err) + } + if err := checkPrivateKey(priv); err != nil { + return nil, err + } + if err := priv.Validate(); err != nil { + return nil, fmt.Errorf("rsax: 私钥参数不合法: %w", err) + } + return priv, nil +} + +// ParsePublicKeyPEM 从 PEM 数据解析 RSA 公钥,同时支持 PKIX 与 PKCS#1。 +// +// data 为 PEM 文本(通常来自文件或配置),不得为空。 +// 解析失败(空输入、非 PEM、DER 损坏、不是 RSA 公钥、密钥长度不足)一律返回 error。 +// 安全注意事项:非 RSA 公钥会被拒绝;调用方应通过可信渠道获取公钥文本, +// 否则可能被中间人替换导致加密内容泄露。 +func ParsePublicKeyPEM(data []byte) (*rsa.PublicKey, error) { + if len(data) == 0 { + return nil, fmt.Errorf("%w: 输入为空", ErrInvalidPEM) + } + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("%w: 无法解码 PEM(缺少 BEGIN/END 边界或格式非法)", ErrInvalidPEM) + } + if len(block.Bytes) == 0 { + return nil, fmt.Errorf("%w: PEM 块内容为空", ErrInvalidPEM) + } + + var ( + pub *rsa.PublicKey + ok bool + err error + ) + switch block.Type { + case pemTypePublicKey: + var key any + key, err = x509.ParsePKIXPublicKey(block.Bytes) + if err == nil { + pub, ok = key.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("%w: PKIX 公钥不是 RSA 类型(%T)", ErrInvalidPEM, key) + } + } + case pemTypePKCS1PublicKey: + pub, err = x509.ParsePKCS1PublicKey(block.Bytes) + default: + return nil, fmt.Errorf("%w: 不支持的公钥 PEM 标签 %q", ErrInvalidPEM, block.Type) + } + if err != nil { + return nil, fmt.Errorf("rsax: 解析公钥失败: %w", err) + } + if err := checkPublicKey(pub); err != nil { + return nil, err + } + return pub, nil +} + +// LoadPrivateKey 从文件读取并解析 RSA 私钥(PKCS#1 或 PKCS#8)。 +// +// path 为私钥 PEM 文件路径,不得为空;读取失败或内容不合法都返回 error。 +// 安全注意事项:调用方应保证该文件权限为 0600 且不被其他用户或进程读写。 +func LoadPrivateKey(path string) (*rsa.PrivateKey, error) { + if path == "" { + return nil, fmt.Errorf("%w: 私钥路径为空", ErrEmptyPath) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("rsax: 读取私钥文件失败: %w", err) + } + defer clear(data) + priv, err := ParsePrivateKeyPEM(data) + if err != nil { + return nil, fmt.Errorf("rsax: 解析私钥文件 %s 失败: %w", path, err) + } + return priv, nil +} + +// LoadPublicKey 从文件读取并解析 RSA 公钥(PKIX 或 PKCS#1)。 +// +// path 为公钥 PEM 文件路径,不得为空;读取失败或内容不合法都返回 error。 +// 安全注意事项:公钥文件可能被替换,加载后应结合证书链或指纹校验其真实性。 +func LoadPublicKey(path string) (*rsa.PublicKey, error) { + if path == "" { + return nil, fmt.Errorf("%w: 公钥路径为空", ErrEmptyPath) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("rsax: 读取公钥文件失败: %w", err) + } + pub, err := ParsePublicKeyPEM(data) + if err != nil { + return nil, fmt.Errorf("rsax: 解析公钥文件 %s 失败: %w", path, err) + } + return pub, nil +} + +// SaveKeyPair 生成一对 RSA 密钥并写入指定的 PEM 文件。 +// +// privatePath、publicPath 分别为私钥、公钥输出路径,均不得为空,且不能相同; +// bits 取值范围同 GenerateKey。私钥文件权限 0600,公钥文件权限 0644。 +// 安全注意事项:若私钥路径已存在会被覆盖,请先在应用层确认不会误删既有密钥; +// 在 Windows 上 0600/0644 仅作尽力而为的权限设置,实际访问控制依赖 ACL。 +func SaveKeyPair(privatePath, publicPath string, bits int) error { + if privatePath == "" || publicPath == "" { + return fmt.Errorf("%w: 私钥或公钥路径为空", ErrEmptyPath) + } + if privatePath == publicPath { + return fmt.Errorf("%w: 私钥与公钥路径相同 %s", ErrEmptyPath, privatePath) + } + privatePEM, publicPEM, err := GenerateKeyPairPEM(bits) + if err != nil { + return err + } + defer clear(privatePEM) + if err := os.WriteFile(privatePath, privatePEM, privateKeyFileMode); err != nil { + return fmt.Errorf("rsax: 写入私钥文件 %s 失败: %w", privatePath, err) + } + if err := os.WriteFile(publicPath, publicPEM, publicKeyFileMode); err != nil { + return fmt.Errorf("rsax: 写入公钥文件 %s 失败: %w", publicPath, err) + } + return nil +} + +// maxOAEPMessageLen 返回单块 OAEP 加密可容纳的最大明文长度。 +// +// k 为 RSA 模长(字节);哈希长度由 hashSize 决定。 +// 计算依据 PKCS#1 v2.2:k - 2*hLen - 2。 +func maxOAEPMessageLen(k int) int { + return k - 2*hashSize() - 2 +} + +// EncryptOAEP 使用 RSA-OAEP(SHA-256) 加密单块明文。 +// +// pub 为接收方公钥,plaintext 为明文,长度不得超过 len(pub)-2*32-2 字节。 +// 明文过长时返回明确错误,此时可改用 EncryptOAEPLong。 +// 安全注意事项:OAEP 只保证机密性,不提供完整性;密文被篡改后解密会失败, +// 但攻击者仍可观察失败与否,需要抵抗选择密文攻击时请配合 AEAD 或签名使用。 +func EncryptOAEP(pub *rsa.PublicKey, plaintext []byte) ([]byte, error) { + if err := checkPublicKey(pub); err != nil { + return nil, err + } + maxLen := maxOAEPMessageLen(pub.Size()) + if len(plaintext) > maxLen { + return nil, fmt.Errorf("rsax: 明文长度 %d 超过单块 OAEP 上限 %d 字节, 请改用 EncryptOAEPLong", len(plaintext), maxLen) + } + ciphertext, err := rsa.EncryptOAEP(rsaHash(), rand.Reader, pub, plaintext, nil) + if err != nil { + return nil, fmt.Errorf("rsax: OAEP 加密失败: %w", err) + } + return ciphertext, nil +} + +// DecryptOAEP 解密单块 RSA-OAEP(SHA-256) 密文。 +// +// priv 为接收方私钥,必须在长度上不小于 MinKeyBits; +// ciphertext 必须是恰好 len(priv)-字节的单块密文,否则返回错误。 +// 安全注意事项:解密失败的具体原因(长度错误、padding 非法)不会泄露明文信息, +// 但调用方不应把底层错误细节返回给远端攻击者。 +func DecryptOAEP(priv *rsa.PrivateKey, ciphertext []byte) ([]byte, error) { + if err := checkPrivateKey(priv); err != nil { + return nil, err + } + if len(ciphertext) == 0 { + return nil, fmt.Errorf("%w: 密文为空", ErrInvalidCiphertext) + } + if k := priv.Size(); len(ciphertext) != k { + return nil, fmt.Errorf("%w: 密文长度 %d 不是密钥长度 %d", ErrInvalidCiphertext, len(ciphertext), k) + } + plaintext, err := rsa.DecryptOAEP(rsaHash(), rand.Reader, priv, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("rsax: OAEP 解密失败: %w", err) + } + return plaintext, nil +} + +// EncryptOAEPLong 使用 RSA-OAEP(SHA-256) 分块加密任意长度的明文。 +// +// pub 为接收方公钥,plaintext 为明文,可为空(返回空密文与 nil); +// 每块明文长度上限为 k-2*hLen-2(k = pub.Size(),hLen = SHA-256 输出长度), +// 逐块调用 EncryptOAEP 后顺序拼接,密文总长度为 k 的整数倍。 +// 安全注意事项:分块格式不含块边界以外的元数据,也不提供跨块完整性, +// 攻击者可能重排或删除整块密文而不被检测;如需抗篡改请先做 AEAD/签名, +// 或仅对不超过单块上限的明文使用 EncryptOAEP。 +func EncryptOAEPLong(pub *rsa.PublicKey, plaintext []byte) ([]byte, error) { + if err := checkPublicKey(pub); err != nil { + return nil, err + } + if len(plaintext) == 0 { + return []byte{}, nil + } + k := pub.Size() + blockSize := maxOAEPMessageLen(k) + if blockSize <= 0 { + return nil, fmt.Errorf("rsax: 密钥长度 %d 字节过小, 无法进行 OAEP 分块", k) + } + blockCount := (len(plaintext) + blockSize - 1) / blockSize + ciphertext := make([]byte, 0, blockCount*k) + for offset := 0; offset < len(plaintext); offset += blockSize { + end := offset + blockSize + if end > len(plaintext) { + end = len(plaintext) + } + chunk, err := EncryptOAEP(pub, plaintext[offset:end]) + if err != nil { + return nil, fmt.Errorf("rsax: 分块加密第 %d 块失败: %w", offset/blockSize, err) + } + ciphertext = append(ciphertext, chunk...) + } + return ciphertext, nil +} + +// DecryptOAEPLong 解密由 EncryptOAEPLong 产生的分块密文。 +// +// priv 为接收方私钥,ciphertext 必须是 k 的整数倍(k = priv.Size())且非空, +// 否则返回错误;每块独立解密后按顺序拼接还原完整明文。 +// 安全注意事项:本函数不做跨块完整性校验,无法发现整块的删除、重排或替换; +// 若对端使用不同公钥加密相同明文,可能出现块替换攻击,因此请配合上层完整性保护。 +func DecryptOAEPLong(priv *rsa.PrivateKey, ciphertext []byte) ([]byte, error) { + if err := checkPrivateKey(priv); err != nil { + return nil, err + } + if len(ciphertext) == 0 { + return nil, fmt.Errorf("%w: 密文为空", ErrInvalidCiphertext) + } + k := priv.Size() + if len(ciphertext)%k != 0 { + return nil, fmt.Errorf("%w: 密文长度 %d 不是密钥长度 %d 的整数倍", ErrInvalidCiphertext, len(ciphertext), k) + } + blockSize := maxOAEPMessageLen(k) + blockCount := len(ciphertext) / k + plaintext := make([]byte, 0, blockCount*blockSize) + for offset := 0; offset < len(ciphertext); offset += k { + chunk, err := DecryptOAEP(priv, ciphertext[offset:offset+k]) + if err != nil { + return nil, fmt.Errorf("rsax: 分块解密第 %d 块失败: %w", offset/k, err) + } + plaintext = append(plaintext, chunk...) + } + return plaintext, nil +} + +// EncryptPKCS1v15 使用 RSAES-PKCS1-v1_5 加密单块明文。 +// +// pub 为接收方公钥,plaintext 长度不得超过 len(pub)-11 字节。 +// 安全注意事项:该方案是为兼容老旧系统保留的,存在 Bleichenbacher 类填充预言机风险, +// 新代码必须优先使用 EncryptOAEP/EncryptOAEPLong; +// 若对端只能识别 PKCS#1 v1.5,请确保解密失败信息不会暴露给攻击者。 +func EncryptPKCS1v15(pub *rsa.PublicKey, plaintext []byte) ([]byte, error) { + if err := checkPublicKey(pub); err != nil { + return nil, err + } + //lint:ignore SA1019 PKCS#1 v1.5 加密自 Go 1.26 起被标准库标记为危险;本函数仅为解密既有密文/与旧系统互操作而提供,函数注释已明确建议改用 OAEP。 + ciphertext, err := rsa.EncryptPKCS1v15(rand.Reader, pub, plaintext) + if err != nil { + return nil, fmt.Errorf("rsax: PKCS#1 v1.5 加密失败: %w", err) + } + return ciphertext, nil +} + +// DecryptPKCS1v15 解密单块 RSAES-PKCS1-v1_5 密文。 +// +// priv 为接收方私钥,ciphertext 必须恰好为 len(priv) 字节。 +// 安全注意事项:解密错误(包含填充校验失败)会被完整包装返回, +// 调用方绝不能把该错误直接回显给远端,否则会形成填充预言机导致明文泄露; +// 新代码请使用 DecryptOAEP。 +func DecryptPKCS1v15(priv *rsa.PrivateKey, ciphertext []byte) ([]byte, error) { + if err := checkPrivateKey(priv); err != nil { + return nil, err + } + if len(ciphertext) == 0 { + return nil, fmt.Errorf("%w: 密文为空", ErrInvalidCiphertext) + } + if k := priv.Size(); len(ciphertext) != k { + return nil, fmt.Errorf("%w: 密文长度 %d 不是密钥长度 %d", ErrInvalidCiphertext, len(ciphertext), k) + } + //lint:ignore SA1019 PKCS#1 v1.5 解密自 Go 1.26 起被标准库标记为危险(错误信息会泄漏信息、可能构成 padding oracle);本函数仅为解密既有密文而提供,函数注释已明确建议改用 OAEP。 + plaintext, err := rsa.DecryptPKCS1v15(rand.Reader, priv, ciphertext) + if err != nil { + return nil, fmt.Errorf("rsax: PKCS#1 v1.5 解密失败: %w", err) + } + return plaintext, nil +} + +// SignPSS 使用 RSASSA-PSS(SHA-256) 对消息签名。 +// +// priv 为签名私钥,message 为待签名消息原文(函数内部完成哈希)。 +// 盐长度固定为 rsa.PSSSaltLengthEqualsHash(等于哈希输出长度 32 字节), +// 这样签名结果长度确定,且符合 RFC 8017 推荐做法。 +// 安全注意事项:签名私钥泄露等同于身份泄露,请确保私钥以 0600 权限保存; +// 本函数对 message 做完整哈希,不要传入已经哈希过的摘要。 +func SignPSS(priv *rsa.PrivateKey, message []byte) ([]byte, error) { + if err := checkPrivateKey(priv); err != nil { + return nil, err + } + digest := sha256.Sum256(message) + signature, err := rsa.SignPSS(rand.Reader, priv, cryptoHash, digest[:], &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthEqualsHash, + Hash: cryptoHash, + }) + if err != nil { + return nil, fmt.Errorf("rsax: PSS 签名失败: %w", err) + } + return signature, nil +} + +// VerifyPSS 验证 RSASSA-PSS(SHA-256) 签名,成功返回 nil,失败返回非 nil 错误。 +// +// pub 为签名者公钥,message 为原始消息,signature 为 SignPSS 的输出。 +// 安全注意事项:验证失败(含长度不合法、填充错误、内容不匹配)统一包装 +// rsa.ErrVerification 返回,调用方可用 errors.Is(err, rsa.ErrVerification) 判断; +// 盐长度参数在验证时按自动探测处理,以兼容其他实现产生的合法签名。 +func VerifyPSS(pub *rsa.PublicKey, message, signature []byte) error { + if err := checkPublicKey(pub); err != nil { + return err + } + if len(signature) == 0 { + return fmt.Errorf("%w: 签名为空", ErrInvalidSignature) + } + if k := pub.Size(); len(signature) != k { + return fmt.Errorf("%w: 签名长度 %d 不是密钥长度 %d", ErrInvalidSignature, len(signature), k) + } + digest := sha256.Sum256(message) + if err := rsa.VerifyPSS(pub, cryptoHash, digest[:], signature, &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthAuto, + Hash: cryptoHash, + }); err != nil { + return wrapVerifyError("PSS", err) + } + return nil +} + +// SignPKCS1v15 使用 RSASSA-PKCS1-v1_5(SHA-256) 对消息签名。 +// +// priv 为签名私钥,message 为待签名消息原文(函数内部完成哈希)。 +// 安全注意事项:PKCS#1 v1.5 签名本身仍属可用方案,但缺乏 PSS 的可证明安全性; +// 新系统建议使用 SignPSS,本函数主要用于与既有系统互通。 +func SignPKCS1v15(priv *rsa.PrivateKey, message []byte) ([]byte, error) { + if err := checkPrivateKey(priv); err != nil { + return nil, err + } + digest := sha256.Sum256(message) + signature, err := rsa.SignPKCS1v15(rand.Reader, priv, cryptoHash, digest[:]) + if err != nil { + return nil, fmt.Errorf("rsax: PKCS#1 v1.5 签名失败: %w", err) + } + return signature, nil +} + +// VerifyPKCS1v15 验证 RSASSA-PKCS1-v1_5(SHA-256) 签名,成功返回 nil,失败返回非 nil 错误。 +// +// pub 为签名者公钥,message 为原始消息,signature 为 SignPKCS1v15 的输出。 +// 安全注意事项:验证失败统一包装 rsa.ErrVerification 返回, +// 调用方不应向未认证的对端区分“签名格式错误”与“签名内容不匹配”。 +func VerifyPKCS1v15(pub *rsa.PublicKey, message, signature []byte) error { + if err := checkPublicKey(pub); err != nil { + return err + } + if len(signature) == 0 { + return fmt.Errorf("%w: 签名为空", ErrInvalidSignature) + } + if k := pub.Size(); len(signature) != k { + return fmt.Errorf("%w: 签名长度 %d 不是密钥长度 %d", ErrInvalidSignature, len(signature), k) + } + digest := sha256.Sum256(message) + if err := rsa.VerifyPKCS1v15(pub, cryptoHash, digest[:], signature); err != nil { + return wrapVerifyError("PKCS#1 v1.5", err) + } + return nil +} + +// wrapVerifyError 统一包装验签失败原因。 +// +// scheme 为方案名(用于错误文本),err 为底层 verify 错误。 +// 安全注意事项:不把底层库的细节暴露给未认证对端,同时保留 rsa.ErrVerification +// 的错误链,便于调用方用 errors.Is(err, rsa.ErrVerification) 精确判断“签名不匹配”。 +// 这里刻意不回显签名或消息内容,避免敏感数据进入日志。 +func wrapVerifyError(scheme string, err error) error { + if errors.Is(err, rsa.ErrVerification) { + return fmt.Errorf("%w: %s 验签失败: %w", ErrInvalidSignature, scheme, rsa.ErrVerification) + } + return fmt.Errorf("%w: %s 验签失败: %w", ErrInvalidSignature, scheme, err) } diff --git a/rsax/rsax_test.go b/rsax/rsax_test.go index 285da54..471ec5b 100644 --- a/rsax/rsax_test.go +++ b/rsax/rsax_test.go @@ -1,19 +1,776 @@ package rsax_test import ( - "fmt" + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "errors" + "math/big" + "os" + "path/filepath" + "runtime" + "sync" "testing" "code.yun.ink/pkg/encryptx/rsax" ) -func TestRsa(t *testing.T) { - //加密 - data := []byte("hello world") - encrypt := rsax.RSA_Encrypt(data, "public.pem") - fmt.Println(string(encrypt)) +// testKeyBits 是测试用密钥长度,取安全下限以缩短密钥生成时间。 +const testKeyBits = rsax.MinKeyBits - // 解密 - decrypt := rsax.RSA_Decrypt(encrypt, "private.pem") - fmt.Println(string(decrypt)) +var ( + sharedKeyOnce sync.Once + sharedKey *rsa.PrivateKey + sharedKeyErr error + + otherKeyOnce sync.Once + otherKey *rsa.PrivateKey + otherKeyErr error +) + +// testKey 返回测试共享的 RSA 私钥(进程内只生成一次,避免重复的密钥生成开销)。 +// 测试辅助函数不会 panic:失败通过 t.Fatalf 报告。 +func testKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + sharedKeyOnce.Do(func() { + sharedKey, sharedKeyErr = rsax.GenerateKey(testKeyBits) + }) + if sharedKeyErr != nil { + t.Fatalf("生成测试密钥失败: %v", sharedKeyErr) + } + return sharedKey +} + +// otherTestKey 返回另一把与 testKey 无关的 RSA 私钥,用于“错误密钥”负向用例。 +func otherTestKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + otherKeyOnce.Do(func() { + otherKey, otherKeyErr = rsax.GenerateKey(testKeyBits) + }) + if otherKeyErr != nil { + t.Fatalf("生成第二把测试密钥失败: %v", otherKeyErr) + } + return otherKey +} + +// requireNoPanic 断言 fn 在失败路径上只返回 error,绝不 panic。 +func requireNoPanic(t *testing.T, what string, fn func()) { + t.Helper() + defer func() { + if r := recover(); r != nil { + t.Fatalf("%s 发生 panic: %v", what, r) + } + }() + fn() +} + +// randomBytes 生成指定长度的随机数据作为测试明文。 +func randomBytes(t *testing.T, n int) []byte { + t.Helper() + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + t.Fatalf("生成随机测试数据失败: %v", err) + } + return buf +} + +// ecdsaTestKey 生成一把 ECDSA 密钥,用于构造“非 RSA 密钥”的负向用例。 +func ecdsaTestKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("生成 ECDSA 测试密钥失败: %v", err) + } + return key +} + +// xorByteAt 翻转 data[pos] 的一个比特位,用于篡改密文/签名。 +func xorByteAt(t *testing.T, data []byte, pos int) []byte { + t.Helper() + if pos < 0 || pos >= len(data) { + t.Fatalf("篡改位置 %d 越界(长度 %d)", pos, len(data)) + } + out := make([]byte, len(data)) + copy(out, data) + out[pos] ^= 0x01 + return out +} + +// TestGenerateKeyAndPEMRoundTrip 覆盖密钥生成、PEM 往返与公私钥一致性。 +func TestGenerateKeyAndPEMRoundTrip(t *testing.T) { + priv, err := rsax.GenerateKey(rsax.DefaultKeyBits) + if err != nil { + t.Fatalf("GenerateKey(%d) 失败: %v", rsax.DefaultKeyBits, err) + } + if got := priv.N.BitLen(); got != rsax.DefaultKeyBits { + t.Fatalf("密钥位宽 = %d, 期望 %d", got, rsax.DefaultKeyBits) + } + + privatePEM, err := rsax.MarshalPrivateKeyPEM(priv) + if err != nil { + t.Fatalf("MarshalPrivateKeyPEM 失败: %v", err) + } + publicPEM, err := rsax.MarshalPublicKeyPEM(&priv.PublicKey) + if err != nil { + t.Fatalf("MarshalPublicKeyPEM 失败: %v", err) + } + if block, _ := pem.Decode(privatePEM); block == nil || block.Type != "PRIVATE KEY" { + t.Fatalf("私钥 PEM 标签不是 PRIVATE KEY: %#v", block) + } + if block, _ := pem.Decode(publicPEM); block == nil || block.Type != "PUBLIC KEY" { + t.Fatalf("公钥 PEM 标签不是 PUBLIC KEY: %#v", block) + } + + parsedPriv, err := rsax.ParsePrivateKeyPEM(privatePEM) + if err != nil { + t.Fatalf("ParsePrivateKeyPEM 失败: %v", err) + } + if parsedPriv.D.Cmp(priv.D) != 0 || parsedPriv.N.Cmp(priv.N) != 0 { + t.Fatal("私钥 PEM 往返后参数不一致") + } + parsedPub, err := rsax.ParsePublicKeyPEM(publicPEM) + if err != nil { + t.Fatalf("ParsePublicKeyPEM 失败: %v", err) + } + if !parsedPub.Equal(&priv.PublicKey) { + t.Fatal("公钥 PEM 往返后与私钥内公钥不一致") + } + if !parsedPub.Equal(&parsedPriv.PublicKey) { + t.Fatal("公钥 PEM 与私钥 PEM 导出的公钥不一致") + } + + // GenerateKeyPairPEM 直接产出的 PEM 也必须可解析且互相匹配。 + genPrivatePEM, genPublicPEM, err := rsax.GenerateKeyPairPEM(testKeyBits) + if err != nil { + t.Fatalf("GenerateKeyPairPEM(%d) 失败: %v", testKeyBits, err) + } + genPriv, err := rsax.ParsePrivateKeyPEM(genPrivatePEM) + if err != nil { + t.Fatalf("解析 GenerateKeyPairPEM 私钥失败: %v", err) + } + genPub, err := rsax.ParsePublicKeyPEM(genPublicPEM) + if err != nil { + t.Fatalf("解析 GenerateKeyPairPEM 公钥失败: %v", err) + } + if !genPub.Equal(&genPriv.PublicKey) { + t.Fatal("GenerateKeyPairPEM 的公私钥不匹配") + } +} + +// TestParsePrivateKeyPEMAcceptsPKCS1 覆盖 PKCS#1 私钥的兼容解析。 +func TestParsePrivateKeyPEMAcceptsPKCS1(t *testing.T) { + priv := testKey(t) + pkcs1PEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(priv), + }) + parsed, err := rsax.ParsePrivateKeyPEM(pkcs1PEM) + if err != nil { + t.Fatalf("解析 PKCS#1 私钥失败: %v", err) + } + if parsed.D.Cmp(priv.D) != 0 { + t.Fatal("PKCS#1 私钥往返后 D 不一致") + } +} + +// TestParsePublicKeyPEMAcceptsPKCS1 覆盖 PKCS#1 公钥的兼容解析。 +func TestParsePublicKeyPEMAcceptsPKCS1(t *testing.T) { + priv := testKey(t) + pkcs1PEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: x509.MarshalPKCS1PublicKey(&priv.PublicKey), + }) + parsed, err := rsax.ParsePublicKeyPEM(pkcs1PEM) + if err != nil { + t.Fatalf("解析 PKCS#1 公钥失败: %v", err) + } + if !parsed.Equal(&priv.PublicKey) { + t.Fatal("PKCS#1 公钥往返后不一致") + } +} + +// TestGenerateKeyRejectsInvalidBits 覆盖密钥长度下限/上限及非 RSA 尺寸的拒绝逻辑。 +func TestGenerateKeyRejectsInvalidBits(t *testing.T) { + for _, bits := range []int{-1024, -1, 0, 1, 512, 1024, 2047, rsax.MinKeyBits - 1, rsax.MaxKeyBits + 1, 16384} { + if _, err := rsax.GenerateKey(bits); err == nil { + t.Fatalf("GenerateKey(%d) 应当返回错误,实际返回 nil", bits) + } + } + // 弱密钥同样不能进入 PEM 生成路径。 + for _, bits := range []int{0, 1024} { + if _, _, err := rsax.GenerateKeyPairPEM(bits); err == nil { + t.Fatalf("GenerateKeyPairPEM(%d) 应当返回错误,实际返回 nil", bits) + } + } +} + +// TestOAEPRoundTripAndTamper 覆盖 OAEP 往返、错误私钥解密与密文篡改检测。 +func TestOAEPRoundTripAndTamper(t *testing.T) { + priv := testKey(t) + message := []byte("RSA-OAEP 往返测试 with ASCII + 中文 + 符号 !@#$%^&*()") + + ciphertext, err := rsax.EncryptOAEP(&priv.PublicKey, message) + if err != nil { + t.Fatalf("EncryptOAEP 失败: %v", err) + } + if len(ciphertext) != priv.Size() { + t.Fatalf("单块密文长度 = %d, 期望 %d", len(ciphertext), priv.Size()) + } + plaintext, err := rsax.DecryptOAEP(priv, ciphertext) + if err != nil { + t.Fatalf("DecryptOAEP 失败: %v", err) + } + if !bytes.Equal(plaintext, message) { + t.Fatalf("解密结果 = %q, 期望 %q", plaintext, message) + } + + // 用另一把密钥解密必须失败。 + if _, err := rsax.DecryptOAEP(otherTestKey(t), ciphertext); err == nil { + t.Fatal("使用错误私钥解密应当失败,实际成功") + } + + // OAEP 有严格的填充校验:逐字节篡改后解密必须全部失败。 + k := priv.Size() + for _, pos := range []int{0, k / 3, k / 2, k - 3, k - 2, k - 1} { + tampered := xorByteAt(t, ciphertext, pos) + if _, err := rsax.DecryptOAEP(priv, tampered); err == nil { + t.Fatalf("篡改密文第 %d 字节后解密应当失败,实际成功", pos) + } + } + + // 长度不合法的密文必须被拒绝。 + for _, bad := range [][]byte{{}, ciphertext[:k-1], append(append([]byte{}, ciphertext...), 0x00)} { + if _, err := rsax.DecryptOAEP(priv, bad); err == nil { + t.Fatalf("长度 %d 的密文应当被拒绝", len(bad)) + } + } +} + +// TestEncryptOAEPLong 覆盖远超单块上限的分块加解密往返与分块错误处理。 +func TestEncryptOAEPLong(t *testing.T) { + priv := testKey(t) + pub := &priv.PublicKey + k := priv.Size() + maxSingle := k - 2*sha256.Size - 2 + + // 分块路径:5000 字节远超单块上限,覆盖“满块 + 末块更短”的组合。 + message := randomBytes(t, 5000) + ciphertext, err := rsax.EncryptOAEPLong(pub, message) + if err != nil { + t.Fatalf("EncryptOAEPLong(5000) 失败: %v", err) + } + expectedBlocks := (len(message) + maxSingle - 1) / maxSingle + if len(ciphertext) != expectedBlocks*k { + t.Fatalf("分块密文长度 = %d, 期望 %d(%d 块 × %d)", len(ciphertext), expectedBlocks*k, expectedBlocks, k) + } + if len(ciphertext) <= k { + t.Fatal("5000 字节明文应当产生多块密文") + } + plaintext, err := rsax.DecryptOAEPLong(priv, ciphertext) + if err != nil { + t.Fatalf("DecryptOAEPLong 失败: %v", err) + } + if !bytes.Equal(plaintext, message) { + t.Fatalf("分块往返结果不一致: 长度 %d vs %d", len(plaintext), len(message)) + } + + // 单块路径:短明文同样走 EncryptOAEPLong 时只有一块。 + short := []byte("short") + shortCipher, err := rsax.EncryptOAEPLong(pub, short) + if err != nil { + t.Fatalf("EncryptOAEPLong(short) 失败: %v", err) + } + if len(shortCipher) != k { + t.Fatalf("短明文分块密文长度 = %d, 期望单块 %d", len(shortCipher), k) + } + shortPlain, err := rsax.DecryptOAEPLong(priv, shortCipher) + if err != nil { + t.Fatalf("DecryptOAEPLong(short) 失败: %v", err) + } + if !bytes.Equal(shortPlain, short) { + t.Fatalf("短明文分块往返结果 = %q, 期望 %q", shortPlain, short) + } + + // 单块上限边界:恰好等于上限可以加密,恰好超过上限必须报错并提示改用 Long 版本。 + exact := randomBytes(t, maxSingle) + if _, err := rsax.EncryptOAEP(pub, exact); err != nil { + t.Fatalf("恰好 %d 字节的明文应当可以单块加密: %v", maxSingle, err) + } + if _, err := rsax.EncryptOAEP(pub, randomBytes(t, maxSingle+1)); err == nil { + t.Fatal("超过单块上限的明文应当返回错误") + } + if _, err := rsax.EncryptOAEPLong(pub, exact); err != nil { + t.Fatalf("Long 版本加密边界长度失败: %v", err) + } + + // 密文长度不是 k 的整数倍时 DecryptOAEPLong 必须报错。 + if _, err := rsax.DecryptOAEPLong(priv, ciphertext[:len(ciphertext)-1]); err == nil { + t.Fatal("非 k 整数倍长度的密文应当返回错误") + } + if _, err := rsax.DecryptOAEPLong(priv, nil); err == nil { + t.Fatal("空密文应当返回错误") + } + // 篡改分块密文(最后一字节)后整串解密必须失败。 + if _, err := rsax.DecryptOAEPLong(priv, xorByteAt(t, ciphertext, len(ciphertext)-1)); err == nil { + t.Fatal("篡改分块密文后解密应当失败") + } + + // 空明文往返为空密文。 + emptyCipher, err := rsax.EncryptOAEPLong(pub, nil) + if err != nil { + t.Fatalf("EncryptOAEPLong(nil) 失败: %v", err) + } + if len(emptyCipher) != 0 { + t.Fatalf("空明文密文长度 = %d, 期望 0", len(emptyCipher)) + } +} + +// TestEncryptOAEPRejectsOversizedPlaintext 单独固化“单块超长必须报错”的行为。 +func TestEncryptOAEPRejectsOversizedPlaintext(t *testing.T) { + priv := testKey(t) + maxSingle := priv.Size() - 2*sha256.Size - 2 + _, err := rsax.EncryptOAEP(&priv.PublicKey, randomBytes(t, maxSingle+1)) + if err == nil { + t.Fatal("超过单块上限的明文应当返回错误") + } + if !bytes.Contains([]byte(err.Error()), []byte("EncryptOAEPLong")) { + t.Fatalf("错误信息应提示改用 EncryptOAEPLong, 实际: %v", err) + } +} + +// TestPKCS1v15EncryptRoundTrip 覆盖兼容用 PKCS#1 v1.5 加解密路径。 +func TestPKCS1v15EncryptRoundTrip(t *testing.T) { + priv := testKey(t) + message := []byte("PKCS#1 v1.5 legacy path") + + ciphertext, err := rsax.EncryptPKCS1v15(&priv.PublicKey, message) + if err != nil { + t.Fatalf("EncryptPKCS1v15 失败: %v", err) + } + plaintext, err := rsax.DecryptPKCS1v15(priv, ciphertext) + if err != nil { + t.Fatalf("DecryptPKCS1v15 失败: %v", err) + } + if !bytes.Equal(plaintext, message) { + t.Fatalf("解密结果 = %q, 期望 %q", plaintext, message) + } + if _, err := rsax.DecryptPKCS1v15(otherTestKey(t), ciphertext); err == nil { + t.Fatal("使用错误私钥解密 PKCS#1 v1.5 密文应当失败") + } + if _, err := rsax.DecryptPKCS1v15(priv, xorByteAt(t, ciphertext, priv.Size()-1)); err == nil { + t.Fatal("篡改 PKCS#1 v1.5 密文后解密应当失败") + } + // 明文超过 len(pub)-11 时底层实现应返回错误而非 panic。 + if _, err := rsax.EncryptPKCS1v15(&priv.PublicKey, randomBytes(t, priv.Size()-10)); err == nil { + t.Fatal("超过 PKCS#1 v1.5 长度上限的明文应当返回错误") + } +} + +// TestSignVerifyPSS 覆盖 PSS 签名/验签往返与篡改检测。 +func TestSignVerifyPSS(t *testing.T) { + priv := testKey(t) + message := []byte("需要签名的消息 with 中文") + + signature, err := rsax.SignPSS(priv, message) + if err != nil { + t.Fatalf("SignPSS 失败: %v", err) + } + if len(signature) != priv.Size() { + t.Fatalf("PSS 签名长度 = %d, 期望 %d", len(signature), priv.Size()) + } + if err := rsax.VerifyPSS(&priv.PublicKey, message, signature); err != nil { + t.Fatalf("VerifyPSS 对合法签名返回错误: %v", err) + } + if err := rsax.VerifyPSS(&priv.PublicKey, []byte("被篡改的消息"), signature); err == nil { + t.Fatal("消息被篡改后验签应当失败") + } + if err := rsax.VerifyPSS(&priv.PublicKey, message, xorByteAt(t, signature, len(signature)/2)); err == nil { + t.Fatal("签名被篡改后验签应当失败") + } + if err := rsax.VerifyPSS(&priv.PublicKey, message, signature[:len(signature)-1]); err == nil { + t.Fatal("签名长度不合法时应当返回错误") + } + if err := rsax.VerifyPSS(&otherTestKey(t).PublicKey, message, signature); err == nil { + t.Fatal("使用错误公钥验签应当失败") + } +} + +// TestSignVerifyPKCS1v15 覆盖 PKCS#1 v1.5 签名/验签往返与篡改检测。 +func TestSignVerifyPKCS1v15(t *testing.T) { + priv := testKey(t) + message := []byte("PKCS#1 v1.5 signed message") + + signature, err := rsax.SignPKCS1v15(priv, message) + if err != nil { + t.Fatalf("SignPKCS1v15 失败: %v", err) + } + if err := rsax.VerifyPKCS1v15(&priv.PublicKey, message, signature); err != nil { + t.Fatalf("VerifyPKCS1v15 对合法签名返回错误: %v", err) + } + if err := rsax.VerifyPKCS1v15(&priv.PublicKey, []byte("tampered message"), signature); err == nil { + t.Fatal("消息被篡改后验签应当失败") + } + if err := rsax.VerifyPKCS1v15(&priv.PublicKey, message, xorByteAt(t, signature, 0)); err == nil { + t.Fatal("签名被篡改后验签应当失败") + } + if err := rsax.VerifyPKCS1v15(&otherTestKey(t).PublicKey, message, signature); err == nil { + t.Fatal("使用错误公钥验签应当失败") + } +} + +// TestVerifyWrapsErrVerification 确认验签失败包装了 rsa.ErrVerification,便于 errors.Is 判断。 +func TestVerifyWrapsErrVerification(t *testing.T) { + priv := testKey(t) + message := []byte("errors.Is 检查") + + pssSig, err := rsax.SignPSS(priv, message) + if err != nil { + t.Fatalf("SignPSS 失败: %v", err) + } + if err := rsax.VerifyPSS(&priv.PublicKey, []byte("other"), pssSig); !errors.Is(err, rsa.ErrVerification) { + t.Fatalf("PSS 失败应包装 rsa.ErrVerification, 实际: %v", err) + } + // 长度非法的签名属于输入错误,不应伪装成 rsa.ErrVerification。 + if err := rsax.VerifyPSS(&priv.PublicKey, message, []byte("short")); errors.Is(err, rsa.ErrVerification) { + t.Fatalf("长度非法的签名不应返回 rsa.ErrVerification, 实际: %v", err) + } + + v15Sig, err := rsax.SignPKCS1v15(priv, message) + if err != nil { + t.Fatalf("SignPKCS1v15 失败: %v", err) + } + if err := rsax.VerifyPKCS1v15(&priv.PublicKey, []byte("other"), v15Sig); !errors.Is(err, rsa.ErrVerification) { + t.Fatalf("PKCS#1 v1.5 失败应包装 rsa.ErrVerification, 实际: %v", err) + } + if err := rsax.VerifyPKCS1v15(&priv.PublicKey, message, []byte("short")); errors.Is(err, rsa.ErrVerification) { + t.Fatalf("长度非法的签名不应返回 rsa.ErrVerification, 实际: %v", err) + } +} + +// TestParsePEMRejectsBadInput 覆盖垃圾输入、空输入、合法 PEM 但内容错误等场景。 +func TestParsePEMRejectsBadInput(t *testing.T) { + priv := testKey(t) + validPublicPEM, err := rsax.MarshalPublicKeyPEM(&priv.PublicKey) + if err != nil { + t.Fatalf("准备合法公钥 PEM 失败: %v", err) + } + + // 其他算法生成的 PKIX 公钥(ECDSA)必须被明确拒绝,而不是类型断言 panic。 + ecdsaDER, err := x509.MarshalPKIXPublicKey(&ecdsaTestKey(t).PublicKey) + if err != nil { + t.Fatalf("编码 ECDSA 公钥失败: %v", err) + } + + badInputs := []struct { + name string + data []byte + }{ + {"nil 输入", nil}, + {"空切片", []byte{}}, + {"纯文本垃圾", []byte("this is not a pem file at all")}, + {"只有 BEGIN 行", []byte("-----BEGIN RSA PRIVATE KEY-----\n")}, + {"PEM 边界不完整", []byte("-----BEGIN PUBLIC KEY-----\nQUJD\n")}, + {"私钥标签但内容是随机字节", pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: []byte("garbage bytes here")})}, + {"私钥标签但内容是公钥 DER", func() []byte { + block, _ := pem.Decode(validPublicPEM) + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: block.Bytes}) + }()}, + {"PKCS#8 标签但内容是 PKCS#1 私钥", pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})}, + {"EC PRIVATE KEY 标签", pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: []byte("junk")})}, + } + for _, tc := range badInputs { + tc := tc + t.Run("private/"+tc.name, func(t *testing.T) { + var err error + requireNoPanic(t, "ParsePrivateKeyPEM", func() { + _, err = rsax.ParsePrivateKeyPEM(tc.data) + }) + if err == nil { + t.Fatalf("ParsePrivateKeyPEM(%s) 应当返回错误", tc.name) + } + }) + t.Run("public/"+tc.name, func(t *testing.T) { + var err error + requireNoPanic(t, "ParsePublicKeyPEM", func() { + _, err = rsax.ParsePublicKeyPEM(tc.data) + }) + if err == nil { + t.Fatalf("ParsePublicKeyPEM(%s) 应当返回错误", tc.name) + } + }) + } + + // 公钥专属:合法 PUBLIC KEY PEM 但内容是私钥 DER、或非 RSA 的 PKIX 公钥。 + publicBadInputs := []struct { + name string + data []byte + }{ + {"公钥标签但内容是 PKCS#1 私钥", pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})}, + {"公钥标签但内容是 PKCS#8 私钥", func() []byte { + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatalf("编码 PKCS#8 私钥失败: %v", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) + }()}, + {"PKIX 标签但内容是 ECDSA 公钥", pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: ecdsaDER})}, + {"CERTIFICATE 标签", pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("junk")})}, + } + for _, tc := range publicBadInputs { + tc := tc + t.Run("public/"+tc.name, func(t *testing.T) { + var err error + requireNoPanic(t, "ParsePublicKeyPEM", func() { + _, err = rsax.ParsePublicKeyPEM(tc.data) + }) + if err == nil { + t.Fatalf("ParsePublicKeyPEM(%s) 应当返回错误", tc.name) + } + }) + } +} + +// TestWeakKeyPolicy 锁定弱密钥策略。 +// +// 该策略有意区分「生成」与「使用」两条路径: +// +// - 生成:一律要求 ≥ MinKeyBits(2048),绝不产生弱密钥; +// - 使用既有的 1024 位密钥:允许解析、导出、加解密与签名。旧系统里真实存在 +// 这类密钥,若连解析都拒绝,持有者将无法解密自己的历史数据——那是把安全 +// 策略变成了功能性故障; +// - 但调用方可以通过 IsWeakPrivateKey / IsWeakPublicKey 得知它已不达标; +// - 低于 MinUsableKeyBits(1024) 的密钥无论如何都拒绝。 +func TestWeakKeyPolicy(t *testing.T) { + // 1. 生成环节必须拒绝弱密钥 + for _, bits := range []int{512, 1024, 2047} { + if _, err := rsax.GenerateKey(bits); !errors.Is(err, rsax.ErrInvalidKeySize) { + t.Errorf("GenerateKey(%d) 应当被拒绝, 得到 %v", bits, err) + } + if _, _, err := rsax.GenerateKeyPairPEM(bits); !errors.Is(err, rsax.ErrInvalidKeySize) { + t.Errorf("GenerateKeyPairPEM(%d) 应当被拒绝, 得到 %v", bits, err) + } + } + + // 2. 既有的 1024 位密钥:允许导出、解析与使用 + legacy, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatalf("生成 1024 位测试密钥失败: %v", err) + } + if !rsax.IsWeakPrivateKey(legacy) { + t.Error("IsWeakPrivateKey(1024 位) = false, 期望 true") + } + if !rsax.IsWeakPublicKey(&legacy.PublicKey) { + t.Error("IsWeakPublicKey(1024 位) = false, 期望 true") + } + + privatePEM, err := rsax.MarshalPrivateKeyPEM(legacy) + if err != nil { + t.Fatalf("既有 1024 位私钥应当允许导出: %v", err) + } + publicPEM, err := rsax.MarshalPublicKeyPEM(&legacy.PublicKey) + if err != nil { + t.Fatalf("既有 1024 位公钥应当允许导出: %v", err) + } + parsed, err := rsax.ParsePrivateKeyPEM(privatePEM) + if err != nil { + t.Fatalf("既有 1024 位私钥应当允许解析: %v", err) + } + if _, err := rsax.ParsePublicKeyPEM(publicPEM); err != nil { + t.Fatalf("既有 1024 位公钥应当允许解析: %v", err) + } + + // 加解密与签名都要能跑通,否则"允许使用"只是空话 + plaintext := []byte("legacy data that must stay readable") + ciphertext, err := rsax.EncryptOAEPLong(&legacy.PublicKey, plaintext) + if err != nil { + t.Fatalf("1024 位密钥加密失败: %v", err) + } + decrypted, err := rsax.DecryptOAEPLong(parsed, ciphertext) + if err != nil { + t.Fatalf("1024 位密钥解密失败: %v", err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Error("1024 位密钥加解密往返不一致") + } + + signature, err := rsax.SignPSS(parsed, plaintext) + if err != nil { + t.Fatalf("1024 位密钥签名失败: %v", err) + } + if err := rsax.VerifyPSS(&legacy.PublicKey, plaintext, signature); err != nil { + t.Fatalf("1024 位密钥验签失败: %v", err) + } + + // 2048 位密钥不应被标记为弱 + strong, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("生成 2048 位密钥失败: %v", err) + } + if rsax.IsWeakPrivateKey(strong) { + t.Error("IsWeakPrivateKey(2048 位) = true, 期望 false") + } + if rsax.IsWeakPublicKey(&strong.PublicKey) { + t.Error("IsWeakPublicKey(2048 位) = true, 期望 false") + } + + // 3. 低于可使用下限:一律拒绝 + // + // 这里用合成密钥而不是 rsa.GenerateKey:Go 1.26 的标准库已拒绝生成低于 + // 1024 位的密钥,恰好与本库的 MinUsableKeyBits 一致,因此无法用标准库造出 + // 更弱的密钥来做测试。 + tooWeakPriv := &rsa.PrivateKey{ + PublicKey: rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 511), E: 65537}, + D: big.NewInt(3), + } + tooWeakPub := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 511), E: 65537} + if tooWeakPriv.N.BitLen() >= rsax.MinUsableKeyBits { + t.Fatalf("测试前提不成立: 合成密钥有 %d 位", tooWeakPriv.N.BitLen()) + } + + if _, err := rsax.MarshalPrivateKeyPEM(tooWeakPriv); !errors.Is(err, rsax.ErrInvalidKeySize) { + t.Errorf("512 位私钥应当拒绝导出, 得到 %v", err) + } + if _, err := rsax.MarshalPublicKeyPEM(tooWeakPub); !errors.Is(err, rsax.ErrInvalidKeySize) { + t.Errorf("512 位公钥应当拒绝导出, 得到 %v", err) + } + if _, err := rsax.EncryptOAEP(tooWeakPub, []byte("x")); !errors.Is(err, rsax.ErrInvalidKeySize) { + t.Errorf("512 位公钥应当拒绝加密, 得到 %v", err) + } + if _, err := rsax.SignPSS(tooWeakPriv, []byte("x")); !errors.Is(err, rsax.ErrInvalidKeySize) { + t.Errorf("512 位私钥应当拒绝签名, 得到 %v", err) + } + if _, err := rsax.DecryptOAEP(tooWeakPriv, []byte("x")); !errors.Is(err, rsax.ErrInvalidKeySize) { + t.Errorf("512 位私钥应当拒绝解密, 得到 %v", err) + } + if err := rsax.VerifyPSS(tooWeakPub, []byte("x"), []byte("y")); !errors.Is(err, rsax.ErrInvalidKeySize) { + t.Errorf("512 位公钥应当拒绝验签, 得到 %v", err) + } + + // 4. nil 一律视为弱密钥 + if !rsax.IsWeakPrivateKey(nil) || !rsax.IsWeakPublicKey(nil) { + t.Error("nil 密钥应当被视为弱密钥") + } +} + +// TestNilKeysReturnErrorRatherThanPanic 确认 nil 密钥在各入口返回错误而非 panic。 +func TestNilKeysReturnErrorRatherThanPanic(t *testing.T) { + // nil 密钥必须返回错误而不是 panic。 + requireNoPanic(t, "EncryptOAEP(nil)", func() { + if _, err := rsax.EncryptOAEP(nil, []byte("x")); err == nil { + t.Error("EncryptOAEP(nil) 应当返回错误") + } + }) + requireNoPanic(t, "DecryptOAEP(nil)", func() { + if _, err := rsax.DecryptOAEP(nil, []byte("x")); err == nil { + t.Error("DecryptOAEP(nil) 应当返回错误") + } + }) + requireNoPanic(t, "SignPSS(nil)", func() { + if _, err := rsax.SignPSS(nil, []byte("x")); err == nil { + t.Error("SignPSS(nil) 应当返回错误") + } + }) + requireNoPanic(t, "VerifyPSS(nil)", func() { + if err := rsax.VerifyPSS(nil, nil, nil); err == nil { + t.Error("VerifyPSS(nil) 应当返回错误") + } + }) + requireNoPanic(t, "MarshalPrivateKeyPEM(nil)", func() { + if _, err := rsax.MarshalPrivateKeyPEM(nil); err == nil { + t.Error("MarshalPrivateKeyPEM(nil) 应当返回错误") + } + }) + requireNoPanic(t, "MarshalPublicKeyPEM(nil)", func() { + if _, err := rsax.MarshalPublicKeyPEM(nil); err == nil { + t.Error("MarshalPublicKeyPEM(nil) 应当返回错误") + } + }) +} + +// TestSaveAndLoadKeyPair 覆盖文件落盘、加载往返与私钥文件权限。 +func TestSaveAndLoadKeyPair(t *testing.T) { + dir := t.TempDir() + privatePath := filepath.Join(dir, "private.pem") + publicPath := filepath.Join(dir, "public.pem") + + if err := rsax.SaveKeyPair(privatePath, publicPath, testKeyBits); err != nil { + t.Fatalf("SaveKeyPair 失败: %v", err) + } + + priv, err := rsax.LoadPrivateKey(privatePath) + if err != nil { + t.Fatalf("LoadPrivateKey 失败: %v", err) + } + pub, err := rsax.LoadPublicKey(publicPath) + if err != nil { + t.Fatalf("LoadPublicKey 失败: %v", err) + } + if !pub.Equal(&priv.PublicKey) { + t.Fatal("从文件加载的公私钥不匹配") + } + if priv.N.BitLen() != testKeyBits { + t.Fatalf("加载的私钥位宽 = %d, 期望 %d", priv.N.BitLen(), testKeyBits) + } + + // 加载出的密钥必须真的可用。 + message := []byte("落盘密钥可用性检查") + ciphertext, err := rsax.EncryptOAEP(pub, message) + if err != nil { + t.Fatalf("用加载的公钥加密失败: %v", err) + } + plaintext, err := rsax.DecryptOAEP(priv, ciphertext) + if err != nil { + t.Fatalf("用加载的私钥解密失败: %v", err) + } + if !bytes.Equal(plaintext, message) { + t.Fatalf("落盘密钥往返结果 = %q, 期望 %q", plaintext, message) + } + + // Windows 的权限位语义与 POSIX 不同(os.Stat().Perm() 通常恒为 0666),因此跳过断言。 + if runtime.GOOS == "windows" { + t.Log("Windows 平台跳过私钥文件权限位断言(POSIX 权限位语义不适用)") + } else { + info, err := os.Stat(privatePath) + if err != nil { + t.Fatalf("Stat 私钥文件失败: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("私钥文件权限 = %#o, 期望 0600", perm) + } + publicInfo, err := os.Stat(publicPath) + if err != nil { + t.Fatalf("Stat 公钥文件失败: %v", err) + } + if perm := publicInfo.Mode().Perm(); perm != 0o644 { + t.Fatalf("公钥文件权限 = %#o, 期望 0644", perm) + } + } + + // 路径非法时必须返回错误。 + if err := rsax.SaveKeyPair(filepath.Join(dir, "same.pem"), filepath.Join(dir, "same.pem"), testKeyBits); err == nil { + t.Fatal("私钥与公钥路径相同时应当返回错误") + } + if err := rsax.SaveKeyPair("", publicPath, testKeyBits); err == nil { + t.Fatal("私钥路径为空时应当返回错误") + } + if _, err := rsax.LoadPrivateKey(filepath.Join(dir, "not-exist.pem")); err == nil { + t.Fatal("加载不存在的私钥文件应当返回错误") + } + if _, err := rsax.LoadPublicKey(filepath.Join(dir, "not-exist.pem")); err == nil { + t.Fatal("加载不存在的公钥文件应当返回错误") + } + if _, err := rsax.LoadPrivateKey(""); err == nil { + t.Fatal("私钥路径为空时应当返回错误") + } + if _, err := rsax.LoadPublicKey(""); err == nil { + t.Fatal("公钥路径为空时应当返回错误") + } + if _, err := rsax.LoadPrivateKey(publicPath); err == nil { + t.Fatal("把公钥文件当私钥加载应当返回错误") + } } diff --git a/scryptx/scryptx.go b/scryptx/scryptx.go new file mode 100644 index 0000000..9ecca57 --- /dev/null +++ b/scryptx/scryptx.go @@ -0,0 +1,198 @@ +// Package scryptx 基于 scrypt(RFC 7914)提供内存硬的口令密钥派生与校验。 +// +// scrypt 通过同时消耗 CPU 与内存来提高离线暴力破解成本,适合口令存储。 +// +// 安全注意事项: +// - 选型顺序:口令哈希首选 argon2x(Argon2id);scrypt 是同等可用的备选, +// 二者都远优于纯 CPU 代价的 pbkdf2x。 +// - 内存开销约为 128*r*(n+p) 字节,且该内存必须真实分配才具备抗 ASIC 能力。 +// 默认 DefaultN=1<<15、DefaultR=8、DefaultP=1 时约 32 MiB/次,适合交互式 +// 登录;服务器内存充裕时应上调到 OWASP 建议的 N=1<<17、r=8、p=1(约 128 MiB)。 +// - N 必须是大于 1 的 2 的幂,且受 MaxN 限制;n/r/p 组合折算出的内存也不得超过 +// 上限,防止调用方误传超大参数造成 OOM(进程被杀死,无法恢复)。 +// - 盐必须是密码学安全随机数、每个口令独立,长度不低于 MinSaltLength 字节。 +// - 派生密钥与口令都是敏感数据,调用方负责及时清零,且不得写入日志或错误文本。 +// +// 本包任何情况下都不会 panic:所有非法参数都返回 error。 +package scryptx + +import ( + "crypto/rand" + "crypto/subtle" + "fmt" + "io" + + "golang.org/x/crypto/scrypt" +) + +const ( + // DefaultN 是默认的 CPU/内存代价参数,取 1<<15(32768)。调大 N 会同时 + // 提高 CPU 时间与内存占用,是抵御 GPU/ASIC 的主要旋钮。 + DefaultN = 1 << 15 + + // DefaultR 是默认的块大小参数,8 对应 1024 字节的块;r 越大,内存占用 + // 与内存访问延迟越高。 + DefaultR = 8 + + // DefaultP 是默认的并行化参数,1 表示单线程完成一次派生。 + DefaultP = 1 + + // MaxN 是允许的最大 N(1<<20)。N=1<<20、r=8 时单次派生需要约 1 GiB 内存, + // 该上限用于拦截调用方传入超大 N 导致的 OOM(进程被杀死,无法恢复)。 + MaxN = 1 << 20 + + // DefaultSaltLength 是推荐的盐长度(字节),16 字节足以避免彩虹表碰撞。 + DefaultSaltLength = 16 + + // MinSaltLength 是允许的最小盐长度(字节),更短的盐会显著提高碰撞概率。 + MinSaltLength = 8 + + // DefaultKeyLength 是推荐的派生密钥长度(字节),32 字节可直接作为 + // AES-256 或 HMAC-SHA256 的密钥。 + DefaultKeyLength = 32 + + // MaxKeyLength 是允许的最大派生密钥长度(字节),用于拦截把缓冲区大小 + // 误当输出长度传入的调用。 + MaxKeyLength = 1024 +) + +const ( + // maxSaltLength 是允许的最大盐长度(字节)。 + maxSaltLength = 1024 + + // maxMemoryBytes 是单次派生允许申请的 scrypt 工作内存上限。默认参数 + // (n=1<<15、r=8、p=1)约 32 MiB;把 n 调到 1<<20 且 r=8 时约 1 GiB,仍在 + // 允许范围内,但明显异常的组合(例如 n=1<<20、r=64,约 8 GiB)会被拒绝, + // 而不是让进程因 OOM 被杀死。 + maxMemoryBytes = 2 << 30 +) + +// GenerateSalt 返回 length 字节的密码学安全随机盐,供 DeriveKey 使用。 +// +// length 必须落在 [MinSaltLength, 1024] 内,推荐 DefaultSaltLength。每个口令 +// 必须使用独立的新盐,并与派生结果一起保存;盐是公开值,缺失它无法复现密钥。 +// 随机源不可用时返回 error,绝不退化为可预测的盐。 +func GenerateSalt(length int) ([]byte, error) { + if length < MinSaltLength { + return nil, fmt.Errorf("scryptx: 盐长度 %d 小于最小值 %d", length, MinSaltLength) + } + if length > maxSaltLength { + return nil, fmt.Errorf("scryptx: 盐长度 %d 超过最大值 %d", length, maxSaltLength) + } + + salt := make([]byte, length) + // 用 io.ReadFull(rand.Reader) 而非 rand.Read:后者在随机源故障时会直接 + // 终止进程,而本包要求把失败作为 error 返回。 + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, fmt.Errorf("scryptx: 读取随机盐失败: %w", err) + } + return salt, nil +} + +// DeriveKey 使用 scrypt 从 password 与 salt 派生 keyLength 字节密钥。 +// +// n 必须是大于 1 的 2 的幂且不超过 MaxN;r、p 必须大于 0;salt 长度必须在 +// [MinSaltLength, 1024];keyLength 必须在 [1, MaxKeyLength]。任一条件不满足 +// 都返回 error,绝不 panic。 +// +// 单次派生的峰值内存约为 128*r*(n+p+2) 字节,耗时随 n、r、p 线性增长。 +// 默认参数(DefaultN/DefaultR/DefaultP)约需 32 MiB 内存,适合交互式场景。 +func DeriveKey(password, salt []byte, n, r, p, keyLength int) ([]byte, error) { + if err := validateParams(salt, n, r, p, keyLength); err != nil { + return nil, err + } + + key, err := scrypt.Key(password, salt, n, r, p, keyLength) + if err != nil { + return nil, fmt.Errorf("scryptx: 派生密钥失败: %w", err) + } + return key, nil +} + +// DeriveKeyWithRandomSalt 生成 saltLength 字节随机盐并立即派生密钥,一次性返回 +// (key, salt, error)。这是推荐入口:调用方必须把 salt 与 key 一起保存,否则 +// 派生结果无法复现。 +// +// saltLength 通常传 DefaultSaltLength;n、r、p、keyLength 的含义同 DeriveKey。 +// 参数非法或随机源失败时返回 error,此时 key 与 salt 均为 nil。 +func DeriveKeyWithRandomSalt(password []byte, saltLength, n, r, p, keyLength int) (key, salt []byte, err error) { + salt, err = GenerateSalt(saltLength) + if err != nil { + return nil, nil, err + } + + key, err = DeriveKey(password, salt, n, r, p, keyLength) + if err != nil { + return nil, nil, err + } + return key, salt, nil +} + +// Verify 用相同参数重新派生并以常量时间比较 expectedKey,返回是否匹配。 +// +// 参数非法时返回 error;参数合法但密钥不匹配时返回 (false, nil)。expectedKey +// 长度与 keyLength 不一致时判定为不匹配。 +// +// 安全提示:必须用本函数而不是 bytes.Equal/== 比较密钥,后者会提前返回并泄漏 +// 匹配前缀长度,构成时序侧信道。 +func Verify(password, salt, expectedKey []byte, n, r, p, keyLength int) (bool, error) { + derived, err := DeriveKey(password, salt, n, r, p, keyLength) + if err != nil { + return false, err + } + // 比较后立即清零本地派生结果,缩短密钥在内存中的驻留时间。 + defer func() { + for i := range derived { + derived[i] = 0 + } + }() + + if len(expectedKey) != len(derived) { + return false, nil + } + return subtle.ConstantTimeCompare(derived, expectedKey) == 1, nil +} + +// validateParams 是所有派生入口共用的参数闸门,保证非法输入在进入 scrypt 之前 +// 就变成明确的 error,而不是 panic、OOM 或静默截断。 +func validateParams(salt []byte, n, r, p, keyLength int) error { + if len(salt) == 0 { + return fmt.Errorf("scryptx: 盐不能为空") + } + if len(salt) < MinSaltLength { + return fmt.Errorf("scryptx: 盐长度 %d 小于最小值 %d", len(salt), MinSaltLength) + } + if len(salt) > maxSaltLength { + return fmt.Errorf("scryptx: 盐长度 %d 超过最大值 %d", len(salt), maxSaltLength) + } + if n <= 1 { + return fmt.Errorf("scryptx: 代价参数 n=%d 必须大于 1", n) + } + if n&(n-1) != 0 { + return fmt.Errorf("scryptx: 代价参数 n=%d 必须是 2 的幂", n) + } + if n > MaxN { + return fmt.Errorf("scryptx: 代价参数 n=%d 超过上限 %d", n, MaxN) + } + if r <= 0 { + return fmt.Errorf("scryptx: 块大小参数 r=%d 必须大于 0", r) + } + if p <= 0 { + return fmt.Errorf("scryptx: 并行度参数 p=%d 必须大于 0", p) + } + if keyLength <= 0 { + return fmt.Errorf("scryptx: 密钥长度 %d 必须大于 0", keyLength) + } + if keyLength > MaxKeyLength { + return fmt.Errorf("scryptx: 密钥长度 %d 超过最大值 %d", keyLength, MaxKeyLength) + } + // scrypt 的峰值内存约为 128*r*(n+p+2) 字节:V 数组占 128*n*r,p 个并行单元 + // 共占 128*r*p,另有 256*r 的临时区。这里统一按 (n+p) 折算,且全程只做除法, + // 避免 r 或 p 取极大值时乘法回绕绕过检查(例如 r=1、p=2^29 时 scrypt 允许 + // 但实际需要约 64 GiB 内存,会直接把进程打死)。 + units := uint64(n) + uint64(p) // n <= 2^20 且 p <= MaxInt,相加不会溢出 uint64 + if uint64(r) > maxMemoryBytes/128/units { + return fmt.Errorf("scryptx: 参数组合 n=%d, r=%d, p=%d 需要的内存超过上限 %d 字节", n, r, p, maxMemoryBytes) + } + return nil +} diff --git a/scryptx/scryptx_test.go b/scryptx/scryptx_test.go new file mode 100644 index 0000000..b66f584 --- /dev/null +++ b/scryptx/scryptx_test.go @@ -0,0 +1,320 @@ +package scryptx_test + +import ( + "bytes" + "encoding/hex" + "math" + "testing" + + "code.yun.ink/pkg/encryptx/scryptx" +) + +// 测试统一使用远小于默认值的内存参数,避免测试机耗时过长。 +const ( + testN = 1 << 10 // 1 MiB 工作内存 + testR = 8 + testP = 1 +) + +// TestDeriveKeyKnownVectors 使用 RFC 7914 第 12 节的官方 scrypt 向量, +// 以及 Python 3.13 hashlib.scrypt 独立生成的小参数向量。 +func TestDeriveKeyKnownVectors(t *testing.T) { + cases := []struct { + name string + password []byte + salt []byte + n, r, p int + keyLength int + want string + }{ + { + // RFC 7914 §12: scrypt(P="pleaseletmein", S="SodiumChloride", + // N=16384, r=8, p=1, dkLen=64) + name: "RFC7914-N16384-r8-p1", + password: []byte("pleaseletmein"), + salt: []byte("SodiumChloride"), + n: 16384, + r: 8, + p: 1, + keyLength: 64, + want: "7023bdcb3afd7348461c06cd81fd38ebfda8fbba904f8e3ea9b543f6545da1f2d5432955613f0fcf62d49705242a9af9e61e85dc0d651e40dfcf017b45575887", + }, + { + // hashlib.scrypt(b'password', salt=b'saltsaltsalt', n=1024, r=8, p=1, dklen=32) + name: "python-n1024-r8-p1", + password: []byte("password"), + salt: []byte("saltsaltsalt"), + n: 1024, + r: 8, + p: 1, + keyLength: 32, + want: "3d33aa071ee47b3d50096e7e58fe5cf9918eba38c6a7218e961d0badce7a4deb", + }, + { + // hashlib.scrypt(b'password', salt=b'saltsaltsalt', n=16384, r=8, p=1, dklen=32) + name: "python-n16384-r8-p1", + password: []byte("password"), + salt: []byte("saltsaltsalt"), + n: 16384, + r: 8, + p: 1, + keyLength: 32, + want: "316fe485a31e8d960549f59c5f9e0a33dec4cc15bd1bf55fe22b1fa484da2c1d", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := scryptx.DeriveKey(tc.password, tc.salt, tc.n, tc.r, tc.p, tc.keyLength) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + if hex.EncodeToString(got) != tc.want { + t.Errorf("与已知向量不一致\n got: %s\nwant: %s", hex.EncodeToString(got), tc.want) + } + }) + } +} + +func TestDeriveKeyReproducible(t *testing.T) { + password := []byte("正确的马电池订书钉") + salt := []byte("0123456789abcdef") + + k1, err := scryptx.DeriveKey(password, salt, testN, testR, testP, scryptx.DefaultKeyLength) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + k2, err := scryptx.DeriveKey(password, salt, testN, testR, testP, scryptx.DefaultKeyLength) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + if !bytes.Equal(k1, k2) { + t.Fatal("相同输入两次派生结果不一致") + } + + other, err := scryptx.DeriveKey(password, []byte("fedcba9876543210"), testN, testR, testP, scryptx.DefaultKeyLength) + if err != nil { + t.Fatalf("换盐派生失败: %v", err) + } + if bytes.Equal(k1, other) { + t.Error("不同盐得到了相同密钥") + } + + otherN, err := scryptx.DeriveKey(password, salt, testN*2, testR, testP, scryptx.DefaultKeyLength) + if err != nil { + t.Fatalf("换 n 派生失败: %v", err) + } + if bytes.Equal(k1, otherN) { + t.Error("不同 n 得到了相同密钥") + } + + otherP, err := scryptx.DeriveKey(password, salt, testN, testR, testP+1, scryptx.DefaultKeyLength) + if err != nil { + t.Fatalf("换 p 派生失败: %v", err) + } + if bytes.Equal(k1, otherP) { + t.Error("不同 p 得到了相同密钥") + } +} + +// TestDeriveKeyWithRandomSalt 断言返回的盐确实能复现同一个密钥。 +func TestDeriveKeyWithRandomSalt(t *testing.T) { + password := []byte("hunter2") + + key, salt, err := scryptx.DeriveKeyWithRandomSalt(password, scryptx.DefaultSaltLength, testN, testR, testP, scryptx.DefaultKeyLength) + if err != nil { + t.Fatalf("DeriveKeyWithRandomSalt 返回错误: %v", err) + } + if len(salt) != scryptx.DefaultSaltLength { + t.Fatalf("返回的盐长度 = %d, 期望 %d", len(salt), scryptx.DefaultSaltLength) + } + if len(key) != scryptx.DefaultKeyLength { + t.Fatalf("返回的密钥长度 = %d, 期望 %d", len(key), scryptx.DefaultKeyLength) + } + + reproduced, err := scryptx.DeriveKey(password, salt, testN, testR, testP, scryptx.DefaultKeyLength) + if err != nil { + t.Fatalf("用返回的盐复现失败: %v", err) + } + if !bytes.Equal(key, reproduced) { + t.Fatal("用 DeriveKeyWithRandomSalt 返回的盐无法复现同一密钥") + } + + key2, salt2, err := scryptx.DeriveKeyWithRandomSalt(password, scryptx.DefaultSaltLength, testN, testR, testP, scryptx.DefaultKeyLength) + if err != nil { + t.Fatalf("第二次 DeriveKeyWithRandomSalt 失败: %v", err) + } + if bytes.Equal(salt, salt2) { + t.Error("两次调用返回了相同的盐") + } + if bytes.Equal(key, key2) { + t.Error("两次调用返回了相同的密钥") + } + + for _, bad := range []int{0, -1, scryptx.MinSaltLength - 1, 1025, math.MaxInt} { + if _, _, err := scryptx.DeriveKeyWithRandomSalt(password, bad, testN, testR, testP, scryptx.DefaultKeyLength); err == nil { + t.Errorf("saltLength=%d 时未返回错误", bad) + } + } +} + +func TestVerify(t *testing.T) { + password := []byte("s3cret-口令") + salt := []byte("0123456789abcdef") + key, err := scryptx.DeriveKey(password, salt, testN, testR, testP, scryptx.DefaultKeyLength) + if err != nil { + t.Fatalf("派生失败: %v", err) + } + + ok, err := scryptx.Verify(password, salt, key, testN, testR, testP, scryptx.DefaultKeyLength) + if err != nil { + t.Fatalf("Verify 返回错误: %v", err) + } + if !ok { + t.Error("正确口令未通过校验") + } + + cases := []struct { + name string + password []byte + salt []byte + expected []byte + }{ + {"错误口令", []byte("s3cret-口令x"), salt, key}, + {"错误盐", password, []byte("fedcba9876543210"), key}, + {"密钥被篡改", password, salt, append(append([]byte{}, key[:len(key)-1]...), key[len(key)-1]^0x01)}, + {"密钥被截断", password, salt, key[:len(key)-1]}, + {"密钥被加长", password, salt, append(append([]byte{}, key...), 0x00)}, + {"空密钥", password, salt, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ok, err := scryptx.Verify(tc.password, tc.salt, tc.expected, testN, testR, testP, scryptx.DefaultKeyLength) + if err != nil { + t.Fatalf("参数合法时 Verify 不应返回错误: %v", err) + } + if ok { + t.Error("不匹配的密钥被判为匹配") + } + }) + } +} + +// TestParameterValidation 覆盖非法参数:必须返回 error 且不 panic。 +func TestParameterValidation(t *testing.T) { + validSalt := bytes.Repeat([]byte{0xA5}, scryptx.MinSaltLength) + password := []byte("pw") + + cases := []struct { + name string + salt []byte + n, r, p int + keyLength int + saltInvalid bool + }{ + {"盐为 nil", nil, testN, testR, testP, 32, true}, + {"盐为空切片", []byte{}, testN, testR, testP, 32, true}, + {"盐过短", []byte("1234567"), testN, testR, testP, 32, true}, + {"盐过长", bytes.Repeat([]byte{0x01}, 1025), testN, testR, testP, 32, true}, + {"n 为 0", validSalt, 0, testR, testP, 32, false}, + {"n 为 1", validSalt, 1, testR, testP, 32, false}, + {"n 为负", validSalt, -1024, testR, testP, 32, false}, + {"n 为 3(非 2 的幂)", validSalt, 3, testR, testP, 32, false}, + {"n 为 1000(非 2 的幂)", validSalt, 1000, testR, testP, 32, false}, + {"n 超上限", validSalt, scryptx.MaxN * 2, testR, testP, 32, false}, + {"n 为 MaxInt", validSalt, math.MaxInt, testR, testP, 32, false}, + {"r 为 0", validSalt, testN, 0, testP, 32, false}, + {"r 为负", validSalt, testN, -8, testP, 32, false}, + {"p 为 0", validSalt, testN, testR, 0, 32, false}, + {"p 为负", validSalt, testN, testR, -1, 32, false}, + {"密钥长度为 0", validSalt, testN, testR, testP, 0, false}, + {"密钥长度为负", validSalt, testN, testR, testP, -32, false}, + {"密钥长度超上限", validSalt, testN, testR, testP, scryptx.MaxKeyLength + 1, false}, + {"内存代价超上限", validSalt, 1 << 20, 64, 1, 32, false}, + {"p 极大导致内存超上限", validSalt, 2, 1, 1 << 29, 32, false}, + {"参数溢出", validSalt, 1 << 20, math.MaxInt, 1, 32, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + key, err := scryptx.DeriveKey(password, tc.salt, tc.n, tc.r, tc.p, tc.keyLength) + if err == nil { + t.Fatalf("未返回错误,得到 %d 字节密钥", len(key)) + } + if key != nil { + t.Errorf("出错时密钥应为 nil,实际长度 %d", len(key)) + } + if _, err := scryptx.Verify(password, tc.salt, validSalt, tc.n, tc.r, tc.p, tc.keyLength); err == nil { + t.Error("Verify 对非法参数未返回错误") + } + // DeriveKeyWithRandomSalt 自行生成盐,因此仅在 n/r/p/长度非法时才应报错。 + if !tc.saltInvalid { + if _, _, err := scryptx.DeriveKeyWithRandomSalt(password, scryptx.DefaultSaltLength, tc.n, tc.r, tc.p, tc.keyLength); err == nil { + t.Error("DeriveKeyWithRandomSalt 对非法参数未返回错误") + } + } + }) + } +} + +// TestBoundaryValues 验证边界参数落在允许一侧时可用,且默认参数可正常完成。 +func TestBoundaryValues(t *testing.T) { + salt := bytes.Repeat([]byte{0x5A}, scryptx.MinSaltLength) + + // n=2 是最小的合法值(1<<1),r=p=1。 + key, err := scryptx.DeriveKey([]byte("pw"), salt, 2, 1, 1, 1) + if err != nil { + t.Fatalf("最小合法参数派生失败: %v", err) + } + if len(key) != 1 { + t.Errorf("密钥长度 = %d, 期望 1", len(key)) + } + + // 较大的 n 在内存上限内应被接受:n=1<<18、r=1 约 32 MiB,耗时可接受。 + key, err = scryptx.DeriveKey([]byte("pw"), salt, 1<<18, 1, 1, scryptx.MaxKeyLength) + if err != nil { + t.Fatalf("n=1<<18、r=1 应被接受: %v", err) + } + if len(key) != scryptx.MaxKeyLength { + t.Errorf("密钥长度 = %d, 期望 %d", len(key), scryptx.MaxKeyLength) + } + + // 默认参数必须可用(32 MiB,约 0.1s)。 + key, err = scryptx.DeriveKey([]byte("pw"), salt, scryptx.DefaultN, scryptx.DefaultR, scryptx.DefaultP, scryptx.DefaultKeyLength) + if err != nil { + t.Fatalf("默认参数派生失败: %v", err) + } + if len(key) != scryptx.DefaultKeyLength { + t.Errorf("默认参数派生长度 = %d, 期望 %d", len(key), scryptx.DefaultKeyLength) + } +} + +func TestGenerateSalt(t *testing.T) { + for _, length := range []int{scryptx.MinSaltLength, scryptx.DefaultSaltLength, 32} { + salt, err := scryptx.GenerateSalt(length) + if err != nil { + t.Fatalf("GenerateSalt(%d) 返回错误: %v", length, err) + } + if len(salt) != length { + t.Errorf("盐长度 = %d, 期望 %d", len(salt), length) + } + } + + a, err := scryptx.GenerateSalt(scryptx.DefaultSaltLength) + if err != nil { + t.Fatalf("GenerateSalt 返回错误: %v", err) + } + b, err := scryptx.GenerateSalt(scryptx.DefaultSaltLength) + if err != nil { + t.Fatalf("GenerateSalt 返回错误: %v", err) + } + if bytes.Equal(a, b) { + t.Error("两次生成的盐相同") + } + + for _, length := range []int{0, -1, scryptx.MinSaltLength - 1, 1025, math.MaxInt} { + if _, err := scryptx.GenerateSalt(length); err == nil { + t.Errorf("GenerateSalt(%d) 未返回错误", length) + } + } +} diff --git a/sha1x/sha1x.go b/sha1x/sha1x.go index f17cc86..c974a85 100644 --- a/sha1x/sha1x.go +++ b/sha1x/sha1x.go @@ -1,18 +1,99 @@ +// Package sha1x 提供 SHA-1 摘要计算,以及十六进制/Base64 编码与流式、文件接口。 +// +// # 安全警告 +// +// SHA-1 已被证明无法抵抗碰撞攻击(SHAttered 等实际攻击已能在可控成本下构造碰撞), +// 本包仅用于兼容旧系统或非安全用途(如 Git 对象名、旧版校验和), +// 不可用于口令存储、口令派生、消息认证或数字签名。 +// 新代码请使用 sha256x、sha512x、sha3x 或 blake2x。 package sha1x import ( "crypto/sha1" + "encoding/base64" "encoding/hex" + "fmt" + "hash" + "io" + "os" ) -func Sha1(s string) string { - h := sha1.New() - h.Write([]byte(s)) +const ( + // Size 是 SHA-1 摘要的字节数(20)。 + Size = sha1.Size + // BlockSize 是 SHA-1 分组的字节数(64)。 + BlockSize = sha1.BlockSize +) - bs := h.Sum(nil) - return hex.EncodeToString(bs) +// New 返回一个计算 SHA-1 摘要的 hash.Hash,可反复 Write 以流式处理数据。 +// +// 安全警告:SHA-1 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func New() hash.Hash { + return sha1.New() +} - // return fmt.Sprintf("%x", bs) +// Sum 返回 data 的 SHA-1 摘要(Size 字节原始数据)。 +// +// 安全警告:SHA-1 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func Sum(data []byte) []byte { + h := sha1.New() + _, _ = h.Write(data) + return h.Sum(nil) +} - // return res +// Hex 返回 data 的 SHA-1 摘要的小写十六进制字符串。 +// +// 安全警告:SHA-1 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func Hex(data []byte) string { + return hex.EncodeToString(Sum(data)) +} + +// Base64 返回 data 的 SHA-1 摘要的标准 Base64 字符串。 +// +// 安全警告:SHA-1 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func Base64(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum(data)) +} + +// SumReader 读取 r 中的全部数据并返回其 SHA-1 摘要;读取失败时返回包装后的 error。 +// +// 安全警告:SHA-1 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func SumReader(r io.Reader) ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("sha1x: 读取器为 nil") + } + h := sha1.New() + if _, err := io.Copy(h, r); err != nil { + return nil, fmt.Errorf("sha1x: 读取数据失败: %w", err) + } + return h.Sum(nil), nil +} + +// HexReader 读取 r 中的全部数据并返回其 SHA-1 摘要的小写十六进制字符串。 +// +// 安全警告:SHA-1 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func HexReader(r io.Reader) (string, error) { + sum, err := SumReader(r) + if err != nil { + return "", err + } + return hex.EncodeToString(sum), nil +} + +// HexFile 计算 path 所指向文件的 SHA-1 摘要并返回小写十六进制字符串; +// 文件不存在或读取失败时返回包装后的 error,不 panic。 +// +// 安全警告:SHA-1 不安全,仅用于兼容旧系统或非安全用途(如校验和),不可用于口令或签名。 +func HexFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("sha1x: 打开文件 %q 失败: %w", path, err) + } + defer f.Close() + + sum, err := SumReader(f) + if err != nil { + return "", fmt.Errorf("sha1x: 计算文件 %q 摘要失败: %w", path, err) + } + return hex.EncodeToString(sum), nil } diff --git a/sha1x/sha1x_test.go b/sha1x/sha1x_test.go index af88f07..8021e19 100644 --- a/sha1x/sha1x_test.go +++ b/sha1x/sha1x_test.go @@ -1,14 +1,178 @@ package sha1x_test import ( - "fmt" + "bytes" + "encoding/base64" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" "testing" "code.yun.ink/pkg/encryptx/sha1x" ) -func TestSha1(t *testing.T) { - s := "sha1 this string" - r := sha1x.Sha1(s) - fmt.Println(r) +// 测试向量来源:FIPS 180-1 / RFC 3174 附录 A,并经 Python 3 hashlib 独立复算确认。 +var vectors = []struct { + name string + input string + want string +}{ + {"空串", "", "da39a3ee5e6b4b0d3255bfef95601890afd80709"}, + {"abc", "abc", "a9993e364706816aba3e25717850c26c9cd0d89d"}, + {"message digest", "message digest", "c12252ceda8be8994d5fa0290a47231c1d16aae3"}, + {"The quick brown fox jumps over the lazy dog", "The quick brown fox jumps over the lazy dog", "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12"}, +} + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("模拟读取失败") } + +func TestConstants(t *testing.T) { + if sha1x.Size != 20 { + t.Fatalf("Size = %d, 期望 20", sha1x.Size) + } + if sha1x.BlockSize != 64 { + t.Fatalf("BlockSize = %d, 期望 64", sha1x.BlockSize) + } +} + +func TestVectors(t *testing.T) { + for _, v := range vectors { + t.Run(v.name, func(t *testing.T) { + data := []byte(v.input) + sum := sha1x.Sum(data) + if len(sum) != sha1x.Size { + t.Fatalf("Sum 长度 = %d, 期望 %d", len(sum), sha1x.Size) + } + if got := hex.EncodeToString(sum); got != v.want { + t.Errorf("Sum = %s, 期望 %s", got, v.want) + } + if got := sha1x.Hex(data); got != v.want { + t.Errorf("Hex = %s, 期望 %s", got, v.want) + } + if want := base64.StdEncoding.EncodeToString(sum); sha1x.Base64(data) != want { + t.Errorf("Base64 = %s, 期望 %s", sha1x.Base64(data), want) + } + h := sha1x.New() + if _, err := h.Write(data); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != v.want { + t.Errorf("New+Write = %s, 期望 %s", got, v.want) + } + }) + } +} + +func TestStreamingMatchesSum(t *testing.T) { + data := []byte(strings.Repeat("encryptx-sha1-", 100)) + h := sha1x.New() + for i := 0; i < len(data); i += 13 { + end := i + 13 + if end > len(data) { + end = len(data) + } + if _, err := h.Write(data[i:end]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + } + if got, want := h.Sum(nil), sha1x.Sum(data); !bytes.Equal(got, want) { + t.Errorf("分块写入 = %x, 期望 %x", got, want) + } +} + +func TestReaderConsistency(t *testing.T) { + const s = "encryptx 一致性检查" + sum, err := sha1x.SumReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("SumReader 失败: %v", err) + } + if want := sha1x.Sum([]byte(s)); !bytes.Equal(sum, want) { + t.Errorf("SumReader = %x, 期望 %x", sum, want) + } + hexStr, err := sha1x.HexReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if want := sha1x.Hex([]byte(s)); hexStr != want { + t.Errorf("HexReader = %s, 期望 %s", hexStr, want) + } +} + +func TestReaderErrors(t *testing.T) { + if _, err := sha1x.SumReader(nil); err == nil { + t.Fatal("nil 读取器应返回 error") + } + if _, err := sha1x.HexReader(nil); err == nil { + t.Fatal("nil 读取器应返回 error") + } + if _, err := sha1x.SumReader(failingReader{}); err == nil { + t.Fatal("读取失败时应返回 error") + } +} + +func TestHexFile(t *testing.T) { + dir := t.TempDir() + content := []byte("文件内容 file content\n") + path := filepath.Join(dir, "sample.bin") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("写入测试文件失败: %v", err) + } + got, err := sha1x.HexFile(path) + if err != nil { + t.Fatalf("HexFile 失败: %v", err) + } + if want := sha1x.Hex(content); got != want { + t.Errorf("HexFile = %s, 期望 %s", got, want) + } +} + +func TestHexFileErrors(t *testing.T) { + if _, err := sha1x.HexFile(filepath.Join(t.TempDir(), "不存在.bin")); err == nil { + t.Fatal("不存在的文件应返回 error") + } + if _, err := sha1x.HexFile(""); err == nil { + t.Fatal("空路径应返回 error") + } + if _, err := sha1x.HexFile(t.TempDir()); err == nil { + t.Fatal("目录路径应返回 error") + } +} + +func TestLargeInput(t *testing.T) { + data := largeData(1 << 20) + want := sha1x.Hex(data) + if got := sha1x.Hex(data); got != want { + t.Errorf("重复计算结果不一致: %s vs %s", got, want) + } + + got, err := sha1x.HexReader(bytes.NewReader(data)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if got != want { + t.Errorf("HexReader = %s, 期望 %s", got, want) + } + + h := sha1x.New() + if _, err := h.Write(data[:1<<19]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if _, err := h.Write(data[1<<19:]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + t.Errorf("1MiB 分块 = %s, 期望 %s", got, want) + } +} + +// largeData 生成确定性的 n 字节数据。 +func largeData(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i*31 + i/7) + } + return b } diff --git a/sha256x/sha256x.go b/sha256x/sha256x.go index fcfc0d8..5ebbf69 100644 --- a/sha256x/sha256x.go +++ b/sha256x/sha256x.go @@ -1,14 +1,137 @@ +// Package sha256x 提供 SHA-224 与 SHA-256 摘要计算,以及十六进制/Base64 编码与流式、文件接口。 +// +// 命名约定:带位数后缀的函数(Sum224、Hex224、New224 等)明确指定算法变体; +// 不带后缀的 Sum、Hex、Base64、New、SumReader、HexReader、HexFile 等价于 256 位变体, +// 因为 SHA-256 是本包最常用的变体。 package sha256x import ( "crypto/sha256" + "encoding/base64" "encoding/hex" + "fmt" + "hash" + "io" + "os" ) -//Sha256加密 -func Sha256(src string) string { - m := sha256.New() - m.Write([]byte(src)) - res := hex.EncodeToString(m.Sum(nil)) - return res +const ( + // Size224 是 SHA-224 摘要的字节数(28)。 + Size224 = sha256.Size224 + // Size256 是 SHA-256 摘要的字节数(32)。 + Size256 = sha256.Size + // BlockSize 是 SHA-224/SHA-256 分组的字节数(64)。 + BlockSize = sha256.BlockSize +) + +// New224 返回一个计算 SHA-224 摘要的 hash.Hash。 +func New224() hash.Hash { + return sha256.New224() +} + +// New256 返回一个计算 SHA-256 摘要的 hash.Hash。 +func New256() hash.Hash { + return sha256.New() +} + +// New 等价于 New256。 +func New() hash.Hash { + return New256() +} + +// Sum224 返回 data 的 SHA-224 摘要(Size224 字节原始数据)。 +func Sum224(data []byte) []byte { + return sum(sha256.New224(), data) +} + +// Sum256 返回 data 的 SHA-256 摘要(Size256 字节原始数据)。 +func Sum256(data []byte) []byte { + return sum(sha256.New(), data) +} + +// Sum 等价于 Sum256。 +func Sum(data []byte) []byte { + return Sum256(data) +} + +// Hex224 返回 data 的 SHA-224 摘要的小写十六进制字符串。 +func Hex224(data []byte) string { + return hex.EncodeToString(Sum224(data)) +} + +// Hex256 返回 data 的 SHA-256 摘要的小写十六进制字符串。 +func Hex256(data []byte) string { + return hex.EncodeToString(Sum256(data)) +} + +// Hex 等价于 Hex256。 +func Hex(data []byte) string { + return Hex256(data) +} + +// Base64Sum224 返回 data 的 SHA-224 摘要的标准 Base64 字符串。 +func Base64Sum224(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum224(data)) +} + +// Base64Sum256 返回 data 的 SHA-256 摘要的标准 Base64 字符串。 +func Base64Sum256(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum256(data)) +} + +// Base64 等价于 Base64Sum256。 +func Base64(data []byte) string { + return Base64Sum256(data) +} + +// SumReader 读取 r 中的全部数据并返回其 SHA-256 摘要;读取失败时返回包装后的 error。 +func SumReader(r io.Reader) ([]byte, error) { + return sumReader(sha256.New(), r) +} + +// HexReader 读取 r 中的全部数据并返回其 SHA-256 摘要的小写十六进制字符串。 +func HexReader(r io.Reader) (string, error) { + sum, err := SumReader(r) + if err != nil { + return "", err + } + return hex.EncodeToString(sum), nil +} + +// HexFile 计算 path 所指向文件的 SHA-256 摘要并返回小写十六进制字符串; +// 文件不存在或读取失败时返回包装后的 error,不 panic。 +func HexFile(path string) (string, error) { + return hexFile(path, sha256.New) +} + +// sum 把 data 一次性写入 h 并取出摘要。 +func sum(h hash.Hash, data []byte) []byte { + _, _ = h.Write(data) + return h.Sum(nil) +} + +// sumReader 把 r 的全部内容写入 h 并取出摘要。 +func sumReader(h hash.Hash, r io.Reader) ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("sha256x: 读取器为 nil") + } + if _, err := io.Copy(h, r); err != nil { + return nil, fmt.Errorf("sha256x: 读取数据失败: %w", err) + } + return h.Sum(nil), nil +} + +// hexFile 计算文件的小写十六进制摘要,newHash 提供具体算法实例。 +func hexFile(path string, newHash func() hash.Hash) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("sha256x: 打开文件 %q 失败: %w", path, err) + } + defer f.Close() + + sum, err := sumReader(newHash(), f) + if err != nil { + return "", fmt.Errorf("sha256x: 计算文件 %q 摘要失败: %w", path, err) + } + return hex.EncodeToString(sum), nil } diff --git a/sha256x/sha256x_test.go b/sha256x/sha256x_test.go new file mode 100644 index 0000000..1f8e329 --- /dev/null +++ b/sha256x/sha256x_test.go @@ -0,0 +1,254 @@ +package sha256x_test + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "code.yun.ink/pkg/encryptx/sha256x" +) + +// 测试向量来源:FIPS 180-4 示例与 NIST 标准测试向量,并经 Python 3 hashlib 独立复算确认。 +var vectors224 = []struct { + name string + input string + want string +}{ + {"空串", "", "d14a028c2a3a2bc9476102bb288234c415a2b01f828ea62ac5b3e42f"}, + {"abc", "abc", "23097d223405d8228642a477bda255b32aadbce4bda0b3f7e36c9da7"}, +} + +var vectors256 = []struct { + name string + input string + want string +}{ + {"空串", "", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}, + {"abc", "abc", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}, + {"The quick brown fox jumps over the lazy dog", "The quick brown fox jumps over the lazy dog", "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592"}, +} + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("模拟读取失败") } + +func TestConstants(t *testing.T) { + if sha256x.Size224 != 28 || sha256x.Size256 != 32 { + t.Fatalf("Size224 = %d, Size256 = %d, 期望 28 / 32", sha256x.Size224, sha256x.Size256) + } + if sha256x.BlockSize != 64 { + t.Fatalf("BlockSize = %d, 期望 64", sha256x.BlockSize) + } +} + +func TestVectors224(t *testing.T) { + for _, v := range vectors224 { + t.Run(v.name, func(t *testing.T) { + data := []byte(v.input) + sum := sha256x.Sum224(data) + if len(sum) != sha256x.Size224 { + t.Fatalf("Sum224 长度 = %d, 期望 %d", len(sum), sha256x.Size224) + } + if got := hex.EncodeToString(sum); got != v.want { + t.Errorf("Sum224 = %s, 期望 %s", got, v.want) + } + if got := sha256x.Hex224(data); got != v.want { + t.Errorf("Hex224 = %s, 期望 %s", got, v.want) + } + if want := base64.StdEncoding.EncodeToString(sum); sha256x.Base64Sum224(data) != want { + t.Errorf("Base64Sum224 = %s, 期望 %s", sha256x.Base64Sum224(data), want) + } + h := sha256x.New224() + if _, err := h.Write(data); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != v.want { + t.Errorf("New224+Write = %s, 期望 %s", got, v.want) + } + }) + } +} + +func TestVectors256(t *testing.T) { + for _, v := range vectors256 { + t.Run(v.name, func(t *testing.T) { + data := []byte(v.input) + sum := sha256x.Sum256(data) + if len(sum) != sha256x.Size256 { + t.Fatalf("Sum256 长度 = %d, 期望 %d", len(sum), sha256x.Size256) + } + if got := hex.EncodeToString(sum); got != v.want { + t.Errorf("Sum256 = %s, 期望 %s", got, v.want) + } + if got := sha256x.Hex256(data); got != v.want { + t.Errorf("Hex256 = %s, 期望 %s", got, v.want) + } + if want := base64.StdEncoding.EncodeToString(sum); sha256x.Base64Sum256(data) != want { + t.Errorf("Base64Sum256 = %s, 期望 %s", sha256x.Base64Sum256(data), want) + } + h := sha256x.New256() + if _, err := h.Write(data); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != v.want { + t.Errorf("New256+Write = %s, 期望 %s", got, v.want) + } + }) + } +} + +func TestDefaultAliasesMatch256(t *testing.T) { + data := []byte("encryptx alias check") + if !bytes.Equal(sha256x.Sum(data), sha256x.Sum256(data)) { + t.Error("Sum 应等价于 Sum256") + } + if sha256x.Hex(data) != sha256x.Hex256(data) { + t.Error("Hex 应等价于 Hex256") + } + if sha256x.Base64(data) != sha256x.Base64Sum256(data) { + t.Error("Base64 应等价于 Base64Sum256") + } + h := sha256x.New() + if h.Size() != sha256x.Size256 { + t.Errorf("New().Size() = %d, 期望 %d", h.Size(), sha256x.Size256) + } +} + +func TestVariantsDiffer(t *testing.T) { + data := []byte("abc") + if sha256x.Hex224(data) == sha256x.Hex256(data) { + t.Error("SHA-224 与 SHA-256 的摘要不应相同") + } +} + +func TestStreamingMatchesSum(t *testing.T) { + data := []byte(strings.Repeat("encryptx-sha256-", 100)) + for _, tc := range []struct { + name string + h hashLike + whole []byte + }{ + {"SHA-224", sha256x.New224(), sha256x.Sum224(data)}, + {"SHA-256", sha256x.New256(), sha256x.Sum256(data)}, + } { + t.Run(tc.name, func(t *testing.T) { + for i := 0; i < len(data); i += 9 { + end := i + 9 + if end > len(data) { + end = len(data) + } + if _, err := tc.h.Write(data[i:end]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + } + if sum := tc.h.Sum(nil); !bytes.Equal(sum, tc.whole) { + t.Errorf("分块写入 = %x, 期望 %x", sum, tc.whole) + } + }) + } +} + +// hashLike 是测试用的最小哈希接口。 +type hashLike interface { + Write([]byte) (int, error) + Sum([]byte) []byte +} + +func TestReaderConsistency(t *testing.T) { + const s = "encryptx 一致性检查" + sum, err := sha256x.SumReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("SumReader 失败: %v", err) + } + if want := sha256x.Sum256([]byte(s)); !bytes.Equal(sum, want) { + t.Errorf("SumReader = %x, 期望 %x", sum, want) + } + hexStr, err := sha256x.HexReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if want := sha256x.Hex256([]byte(s)); hexStr != want { + t.Errorf("HexReader = %s, 期望 %s", hexStr, want) + } +} + +func TestReaderErrors(t *testing.T) { + if _, err := sha256x.SumReader(nil); err == nil { + t.Fatal("nil 读取器应返回 error") + } + if _, err := sha256x.HexReader(nil); err == nil { + t.Fatal("nil 读取器应返回 error") + } + if _, err := sha256x.SumReader(failingReader{}); err == nil { + t.Fatal("读取失败时应返回 error") + } +} + +func TestHexFile(t *testing.T) { + dir := t.TempDir() + content := []byte("文件内容 file content\n") + path := filepath.Join(dir, "sample.bin") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("写入测试文件失败: %v", err) + } + got, err := sha256x.HexFile(path) + if err != nil { + t.Fatalf("HexFile 失败: %v", err) + } + if want := sha256x.Hex256(content); got != want { + t.Errorf("HexFile = %s, 期望 %s", got, want) + } +} + +func TestHexFileErrors(t *testing.T) { + if _, err := sha256x.HexFile(filepath.Join(t.TempDir(), "不存在.bin")); err == nil { + t.Fatal("不存在的文件应返回 error") + } + if _, err := sha256x.HexFile(""); err == nil { + t.Fatal("空路径应返回 error") + } + if _, err := sha256x.HexFile(t.TempDir()); err == nil { + t.Fatal("目录路径应返回 error") + } +} + +func TestLargeInput(t *testing.T) { + data := largeData(1 << 20) + want := sha256x.Hex256(data) + if got := sha256x.Hex256(data); got != want { + t.Errorf("重复计算结果不一致: %s vs %s", got, want) + } + + got, err := sha256x.HexReader(bytes.NewReader(data)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if got != want { + t.Errorf("HexReader = %s, 期望 %s", got, want) + } + + h := sha256x.New256() + if _, err := h.Write(data[:1<<19]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if _, err := h.Write(data[1<<19:]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + t.Errorf("1MiB 分块 = %s, 期望 %s", got, want) + } +} + +// largeData 生成确定性的 n 字节数据。 +func largeData(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i*31 + i/7) + } + return b +} diff --git a/sha3x/sha3x.go b/sha3x/sha3x.go index 884e34c..12b906f 100644 --- a/sha3x/sha3x.go +++ b/sha3x/sha3x.go @@ -1,45 +1,363 @@ +// Package sha3x 提供 SHA3-224/256/384/512 摘要计算与 SHAKE128/SHAKE256 可扩展输出函数(XOF), +// 并附带十六进制/Base64 编码与流式、文件接口。 +// +// SHAKE 是 XOF:输出长度由调用方指定,必须显式校验。本包要求 outputLength 在 +// [1, MaxShakeOutputLength] 之间,超出范围返回 error 而不是 panic 或分配巨大内存。 +// +// 命名约定:带位数后缀的函数(Sum224、Hex224、New224 等)明确指定算法变体; +// 不带后缀的 Sum、Hex、Base64、New、SumReader、HexReader、HexFile 等价于 256 位变体。 package sha3x import ( + "encoding/base64" "encoding/hex" + "fmt" + "hash" + "io" + "os" + "golang.org/x/crypto/sha3" ) -// SHA3-256 哈希 -func Sha3_256(src string) string { - h := sha3.New256() - h.Write([]byte(src)) - return hex.EncodeToString(h.Sum(nil)) +const ( + // Size224 是 SHA3-224 摘要的字节数(28)。 + Size224 = 28 + // Size256 是 SHA3-256 摘要的字节数(32)。 + Size256 = 32 + // Size384 是 SHA3-384 摘要的字节数(48)。 + Size384 = 48 + // Size512 是 SHA3-512 摘要的字节数(64)。 + Size512 = 64 + + // Rate224 是 SHA3-224 的海绵分组(rate)字节数(144)。 + Rate224 = 144 + // Rate256 是 SHA3-256 的海绵分组(rate)字节数(136)。 + Rate256 = 136 + // Rate384 是 SHA3-384 的海绵分组(rate)字节数(104)。 + Rate384 = 104 + // Rate512 是 SHA3-512 的海绵分组(rate)字节数(72)。 + Rate512 = 72 + + // MaxShakeOutputLength 是 SHAKE128/SHAKE256 单次允许请求的最大输出字节数(1 MiB)。 + MaxShakeOutputLength = 1 << 20 +) + +// New224 返回一个计算 SHA3-224 摘要的 hash.Hash。 +func New224() hash.Hash { + return sha3.New224() } -// SHA3-384 哈希 -func Sha3_384(src string) string { - h := sha3.New384() - h.Write([]byte(src)) - return hex.EncodeToString(h.Sum(nil)) +// New256 返回一个计算 SHA3-256 摘要的 hash.Hash。 +func New256() hash.Hash { + return sha3.New256() } -// SHA3-512 哈希 -func Sha3_512(src string) string { - h := sha3.New512() - h.Write([]byte(src)) - return hex.EncodeToString(h.Sum(nil)) +// New384 返回一个计算 SHA3-384 摘要的 hash.Hash。 +func New384() hash.Hash { + return sha3.New384() } -// SHAKE128 可变长度哈希 -func Shake128(src string, outputLength int) string { - h := sha3.NewShake128() - h.Write([]byte(src)) - output := make([]byte, outputLength) - h.Read(output) - return hex.EncodeToString(output) +// New512 返回一个计算 SHA3-512 摘要的 hash.Hash。 +func New512() hash.Hash { + return sha3.New512() } -// SHAKE256 可变长度哈希 -func Shake256(src string, outputLength int) string { - h := sha3.NewShake256() - h.Write([]byte(src)) - output := make([]byte, outputLength) - h.Read(output) - return hex.EncodeToString(output) +// New 等价于 New256。 +func New() hash.Hash { + return New256() +} + +// NewShake128 返回一个 SHAKE128 XOF 实例,可流式 Write 输入后按需 Read 任意长度输出。 +// 调用方需自行保证输出长度合理(参见 MaxShakeOutputLength)。 +func NewShake128() sha3.ShakeHash { + return sha3.NewShake128() +} + +// NewShake256 返回一个 SHAKE256 XOF 实例,可流式 Write 输入后按需 Read 任意长度输出。 +// 调用方需自行保证输出长度合理(参见 MaxShakeOutputLength)。 +func NewShake256() sha3.ShakeHash { + return sha3.NewShake256() +} + +// Sum224 返回 data 的 SHA3-224 摘要(Size224 字节原始数据)。 +func Sum224(data []byte) []byte { + return sum(sha3.New224(), data) +} + +// Sum256 返回 data 的 SHA3-256 摘要(Size256 字节原始数据)。 +func Sum256(data []byte) []byte { + return sum(sha3.New256(), data) +} + +// Sum384 返回 data 的 SHA3-384 摘要(Size384 字节原始数据)。 +func Sum384(data []byte) []byte { + return sum(sha3.New384(), data) +} + +// Sum512 返回 data 的 SHA3-512 摘要(Size512 字节原始数据)。 +func Sum512(data []byte) []byte { + return sum(sha3.New512(), data) +} + +// Sum 等价于 Sum256。 +func Sum(data []byte) []byte { + return Sum256(data) +} + +// Hex224 返回 data 的 SHA3-224 摘要的小写十六进制字符串。 +func Hex224(data []byte) string { + return hex.EncodeToString(Sum224(data)) +} + +// Hex256 返回 data 的 SHA3-256 摘要的小写十六进制字符串。 +func Hex256(data []byte) string { + return hex.EncodeToString(Sum256(data)) +} + +// Hex384 返回 data 的 SHA3-384 摘要的小写十六进制字符串。 +func Hex384(data []byte) string { + return hex.EncodeToString(Sum384(data)) +} + +// Hex512 返回 data 的 SHA3-512 摘要的小写十六进制字符串。 +func Hex512(data []byte) string { + return hex.EncodeToString(Sum512(data)) +} + +// Hex 等价于 Hex256。 +func Hex(data []byte) string { + return Hex256(data) +} + +// Base64Sum224 返回 data 的 SHA3-224 摘要的标准 Base64 字符串。 +func Base64Sum224(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum224(data)) +} + +// Base64Sum256 返回 data 的 SHA3-256 摘要的标准 Base64 字符串。 +func Base64Sum256(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum256(data)) +} + +// Base64Sum384 返回 data 的 SHA3-384 摘要的标准 Base64 字符串。 +func Base64Sum384(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum384(data)) +} + +// Base64Sum512 返回 data 的 SHA3-512 摘要的标准 Base64 字符串。 +func Base64Sum512(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum512(data)) +} + +// Base64 等价于 Base64Sum256。 +func Base64(data []byte) string { + return Base64Sum256(data) +} + +// Sum224Reader 读取 r 中的全部数据并返回其 SHA3-224 摘要。 +func Sum224Reader(r io.Reader) ([]byte, error) { + return sumReader(sha3.New224(), r) +} + +// Sum256Reader 读取 r 中的全部数据并返回其 SHA3-256 摘要。 +func Sum256Reader(r io.Reader) ([]byte, error) { + return sumReader(sha3.New256(), r) +} + +// Sum384Reader 读取 r 中的全部数据并返回其 SHA3-384 摘要。 +func Sum384Reader(r io.Reader) ([]byte, error) { + return sumReader(sha3.New384(), r) +} + +// Sum512Reader 读取 r 中的全部数据并返回其 SHA3-512 摘要。 +func Sum512Reader(r io.Reader) ([]byte, error) { + return sumReader(sha3.New512(), r) +} + +// SumReader 等价于 Sum256Reader。 +func SumReader(r io.Reader) ([]byte, error) { + return Sum256Reader(r) +} + +// Hex224Reader 读取 r 中的全部数据并返回其 SHA3-224 摘要的小写十六进制字符串。 +func Hex224Reader(r io.Reader) (string, error) { + return hexReader(sha3.New224(), r) +} + +// Hex256Reader 读取 r 中的全部数据并返回其 SHA3-256 摘要的小写十六进制字符串。 +func Hex256Reader(r io.Reader) (string, error) { + return hexReader(sha3.New256(), r) +} + +// Hex384Reader 读取 r 中的全部数据并返回其 SHA3-384 摘要的小写十六进制字符串。 +func Hex384Reader(r io.Reader) (string, error) { + return hexReader(sha3.New384(), r) +} + +// Hex512Reader 读取 r 中的全部数据并返回其 SHA3-512 摘要的小写十六进制字符串。 +func Hex512Reader(r io.Reader) (string, error) { + return hexReader(sha3.New512(), r) +} + +// HexReader 等价于 Hex256Reader。 +func HexReader(r io.Reader) (string, error) { + return Hex256Reader(r) +} + +// HexFile224 计算 path 所指向文件的 SHA3-224 摘要并返回小写十六进制字符串。 +func HexFile224(path string) (string, error) { + return hexFile(path, "SHA3-224", sha3.New224) +} + +// HexFile256 计算 path 所指向文件的 SHA3-256 摘要并返回小写十六进制字符串。 +func HexFile256(path string) (string, error) { + return hexFile(path, "SHA3-256", sha3.New256) +} + +// HexFile384 计算 path 所指向文件的 SHA3-384 摘要并返回小写十六进制字符串。 +func HexFile384(path string) (string, error) { + return hexFile(path, "SHA3-384", sha3.New384) +} + +// HexFile512 计算 path 所指向文件的 SHA3-512 摘要并返回小写十六进制字符串。 +func HexFile512(path string) (string, error) { + return hexFile(path, "SHA3-512", sha3.New512) +} + +// HexFile 等价于 HexFile256。 +func HexFile(path string) (string, error) { + return HexFile256(path) +} + +// Shake128 返回 data 经 SHAKE128 扩展后的 outputLength 字节输出。 +// outputLength 必须大于 0 且不超过 MaxShakeOutputLength,否则返回 error。 +func Shake128(data []byte, outputLength int) ([]byte, error) { + return shakeSum(sha3.NewShake128(), data, outputLength) +} + +// Shake256 返回 data 经 SHAKE256 扩展后的 outputLength 字节输出。 +// outputLength 必须大于 0 且不超过 MaxShakeOutputLength,否则返回 error。 +func Shake256(data []byte, outputLength int) ([]byte, error) { + return shakeSum(sha3.NewShake256(), data, outputLength) +} + +// Shake128Hex 返回 data 经 SHAKE128 扩展后的 outputLength 字节输出的小写十六进制字符串。 +// outputLength 必须大于 0 且不超过 MaxShakeOutputLength,否则返回 error。 +func Shake128Hex(data []byte, outputLength int) (string, error) { + out, err := Shake128(data, outputLength) + if err != nil { + return "", err + } + return hex.EncodeToString(out), nil +} + +// Shake256Hex 返回 data 经 SHAKE256 扩展后的 outputLength 字节输出的小写十六进制字符串。 +// outputLength 必须大于 0 且不超过 MaxShakeOutputLength,否则返回 error。 +func Shake256Hex(data []byte, outputLength int) (string, error) { + out, err := Shake256(data, outputLength) + if err != nil { + return "", err + } + return hex.EncodeToString(out), nil +} + +// Shake128Reader 读取 r 中的全部数据并返回其 SHAKE128 扩展的 outputLength 字节输出。 +// outputLength 必须大于 0 且不超过 MaxShakeOutputLength,否则返回 error。 +func Shake128Reader(r io.Reader, outputLength int) ([]byte, error) { + return shakeSumReader(sha3.NewShake128(), r, outputLength) +} + +// Shake256Reader 读取 r 中的全部数据并返回其 SHAKE256 扩展的 outputLength 字节输出。 +// outputLength 必须大于 0 且不超过 MaxShakeOutputLength,否则返回 error。 +func Shake256Reader(r io.Reader, outputLength int) ([]byte, error) { + return shakeSumReader(sha3.NewShake256(), r, outputLength) +} + +// sum 把 data 一次性写入 h 并取出摘要。 +func sum(h hash.Hash, data []byte) []byte { + _, _ = h.Write(data) + return h.Sum(nil) +} + +// sumReader 把 r 的全部内容写入 h 并取出摘要。 +func sumReader(h hash.Hash, r io.Reader) ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("sha3x: 读取器为 nil") + } + if _, err := io.Copy(h, r); err != nil { + return nil, fmt.Errorf("sha3x: 读取数据失败: %w", err) + } + return h.Sum(nil), nil +} + +// hexReader 读取 r 的全部内容并以小写十六进制返回摘要。 +func hexReader(h hash.Hash, r io.Reader) (string, error) { + sum, err := sumReader(h, r) + if err != nil { + return "", err + } + return hex.EncodeToString(sum), nil +} + +// hexFile 计算文件的小写十六进制摘要,name 仅用于错误信息。 +func hexFile(path, name string, newHash func() hash.Hash) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("sha3x: 打开文件 %q 失败: %w", path, err) + } + defer f.Close() + + sum, err := sumReader(newHash(), f) + if err != nil { + return "", fmt.Errorf("sha3x: 计算文件 %q 的 %s 摘要失败: %w", path, name, err) + } + return hex.EncodeToString(sum), nil +} + +// checkShakeLength 校验 XOF 输出长度是否在允许范围内。 +func checkShakeLength(outputLength int) error { + if outputLength <= 0 { + return fmt.Errorf("sha3x: SHAKE 输出长度必须大于 0,当前为 %d", outputLength) + } + if outputLength > MaxShakeOutputLength { + return fmt.Errorf("sha3x: SHAKE 输出长度 %d 超过上限 %d", outputLength, MaxShakeOutputLength) + } + return nil +} + +// readShake 从已写入输入的 XOF 中精确读取 outputLength 字节。 +func readShake(h sha3.ShakeHash, outputLength int) ([]byte, error) { + if err := checkShakeLength(outputLength); err != nil { + return nil, err + } + out := make([]byte, outputLength) + if _, err := io.ReadFull(h, out); err != nil { + return nil, fmt.Errorf("sha3x: 读取 SHAKE 输出失败: %w", err) + } + return out, nil +} + +// shakeSum 计算 data 的 XOF 输出。 +func shakeSum(h sha3.ShakeHash, data []byte, outputLength int) ([]byte, error) { + if err := checkShakeLength(outputLength); err != nil { + return nil, err + } + if _, err := h.Write(data); err != nil { + return nil, fmt.Errorf("sha3x: 写入 SHAKE 输入失败: %w", err) + } + return readShake(h, outputLength) +} + +// shakeSumReader 计算 r 全部内容的 XOF 输出。 +func shakeSumReader(h sha3.ShakeHash, r io.Reader, outputLength int) ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("sha3x: 读取器为 nil") + } + if err := checkShakeLength(outputLength); err != nil { + return nil, err + } + if _, err := io.Copy(h, r); err != nil { + return nil, fmt.Errorf("sha3x: 读取数据失败: %w", err) + } + return readShake(h, outputLength) } diff --git a/sha3x/sha3x_test.go b/sha3x/sha3x_test.go index 5bbc956..a01a222 100644 --- a/sha3x/sha3x_test.go +++ b/sha3x/sha3x_test.go @@ -1,219 +1,502 @@ -package sha3x +package sha3x_test import ( + "bytes" + "encoding/base64" + "encoding/hex" + "errors" + "hash" + "io" + "os" + "path/filepath" "strings" "testing" + + "code.yun.ink/pkg/encryptx/sha3x" + "golang.org/x/crypto/sha3" ) -func TestSha3_256(t *testing.T) { - // 测试基本功能 - input := "hello world" - hash := Sha3_256(input) +// 测试向量来源:NIST FIPS 202 / SHA-3 标准示例,并经 Python 3 hashlib 独立复算确认。 +var vectors = []struct { + name string + newHash func() hash.Hash + sum func([]byte) []byte + hex func([]byte) string + b64 func([]byte) string + sumReader func(io.Reader) ([]byte, error) + hexReader func(io.Reader) (string, error) + hexFile func(string) (string, error) + size int + empty string + abc string +}{ + { + name: "SHA3-224", + newHash: sha3x.New224, + sum: sha3x.Sum224, + hex: sha3x.Hex224, + b64: sha3x.Base64Sum224, + sumReader: sha3x.Sum224Reader, + hexReader: sha3x.Hex224Reader, + hexFile: sha3x.HexFile224, + size: sha3x.Size224, + empty: "6b4e03423667dbb73b6e15454f0eb1abd4597f9a1b078e3f5b5a6bc7", + abc: "e642824c3f8cf24ad09234ee7d3c766fc9a3a5168d0c94ad73b46fdf", + }, + { + name: "SHA3-256", + newHash: sha3x.New256, + sum: sha3x.Sum256, + hex: sha3x.Hex256, + b64: sha3x.Base64Sum256, + sumReader: sha3x.Sum256Reader, + hexReader: sha3x.Hex256Reader, + hexFile: sha3x.HexFile256, + size: sha3x.Size256, + empty: "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a", + abc: "3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532", + }, + { + name: "SHA3-384", + newHash: sha3x.New384, + sum: sha3x.Sum384, + hex: sha3x.Hex384, + b64: sha3x.Base64Sum384, + sumReader: sha3x.Sum384Reader, + hexReader: sha3x.Hex384Reader, + hexFile: sha3x.HexFile384, + size: sha3x.Size384, + empty: "0c63a75b845e4f7d01107d852e4c2485c51a50aaaa94fc61995e71bbee983a2ac3713831264adb47fb6bd1e058d5f004", + abc: "ec01498288516fc926459f58e2c6ad8df9b473cb0fc08c2596da7cf0e49be4b298d88cea927ac7f539f1edf228376d25", + }, + { + name: "SHA3-512", + newHash: sha3x.New512, + sum: sha3x.Sum512, + hex: sha3x.Hex512, + b64: sha3x.Base64Sum512, + sumReader: sha3x.Sum512Reader, + hexReader: sha3x.Hex512Reader, + hexFile: sha3x.HexFile512, + size: sha3x.Size512, + empty: "a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a615b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26", + abc: "b751850b1a57168a5693cd924b6b096e08f621827444f70d884f5d0240d2712e10e116e9192af3c91a7ec57647e3934057340b4cf408d5a56592f8274eec53f0", + }, +} - // SHA3-256应该产生64字符的十六进制字符串 - if len(hash) != 64 { - t.Errorf("SHA3-256哈希长度错误,期望: 64, 实际: %d", len(hash)) +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("模拟读取失败") } + +func TestConstants(t *testing.T) { + sizes := map[string][2]int{ + "SHA3-224": {sha3x.Size224, sha3x.Rate224}, + "SHA3-256": {sha3x.Size256, sha3x.Rate256}, + "SHA3-384": {sha3x.Size384, sha3x.Rate384}, + "SHA3-512": {sha3x.Size512, sha3x.Rate512}, } - - // 相同输入应该产生相同输出 - hash2 := Sha3_256(input) - if hash != hash2 { - t.Errorf("相同输入产生了不同的哈希: %s vs %s", hash, hash2) + want := map[string][2]int{ + "SHA3-224": {28, 144}, + "SHA3-256": {32, 136}, + "SHA3-384": {48, 104}, + "SHA3-512": {64, 72}, } - - // 不同输入应该产生不同输出 - hash3 := Sha3_256("different input") - if hash == hash3 { - t.Errorf("不同输入产生了相同的哈希") + for name, got := range sizes { + if got != want[name] { + t.Errorf("%s: Size/Rate = %v, 期望 %v", name, got, want[name]) + } + } + if sha3x.MaxShakeOutputLength != 1<<20 { + t.Errorf("MaxShakeOutputLength = %d, 期望 %d", sha3x.MaxShakeOutputLength, 1<<20) } } -func TestSha3_384(t *testing.T) { - // 测试基本功能 - input := "hello world" - hash := Sha3_384(input) - - // SHA3-384应该产生96字符的十六进制字符串 - if len(hash) != 96 { - t.Errorf("SHA3-384哈希长度错误,期望: 96, 实际: %d", len(hash)) - } - - // 相同输入应该产生相同输出 - hash2 := Sha3_384(input) - if hash != hash2 { - t.Errorf("相同输入产生了不同的哈希: %s vs %s", hash, hash2) - } - - // 不同输入应该产生不同输出 - hash3 := Sha3_384("different input") - if hash == hash3 { - t.Errorf("不同输入产生了相同的哈希") +func TestVectors(t *testing.T) { + for _, v := range vectors { + t.Run(v.name, func(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"空串", "", v.empty}, + {"abc", "abc", v.abc}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + data := []byte(tc.input) + sum := v.sum(data) + if len(sum) != v.size { + t.Fatalf("Sum 长度 = %d, 期望 %d", len(sum), v.size) + } + if got := hex.EncodeToString(sum); got != tc.want { + t.Errorf("Sum = %s, 期望 %s", got, tc.want) + } + if got := v.hex(data); got != tc.want { + t.Errorf("Hex = %s, 期望 %s", got, tc.want) + } + if want := base64.StdEncoding.EncodeToString(sum); v.b64(data) != want { + t.Errorf("Base64 = %s, 期望 %s", v.b64(data), want) + } + h := v.newHash() + if _, err := h.Write(data); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != tc.want { + t.Errorf("New+Write = %s, 期望 %s", got, tc.want) + } + }) + } + }) } } -func TestSha3_512(t *testing.T) { - // 测试基本功能 - input := "hello world" - hash := Sha3_512(input) - - // SHA3-512应该产生128字符的十六进制字符串 - if len(hash) != 128 { - t.Errorf("SHA3-512哈希长度错误,期望: 128, 实际: %d", len(hash)) +func TestDefaultAliasesMatch256(t *testing.T) { + data := []byte("encryptx alias check") + if !bytes.Equal(sha3x.Sum(data), sha3x.Sum256(data)) { + t.Error("Sum 应等价于 Sum256") } - - // 相同输入应该产生相同输出 - hash2 := Sha3_512(input) - if hash != hash2 { - t.Errorf("相同输入产生了不同的哈希: %s vs %s", hash, hash2) + if sha3x.Hex(data) != sha3x.Hex256(data) { + t.Error("Hex 应等价于 Hex256") } - - // 不同输入应该产生不同输出 - hash3 := Sha3_512("different input") - if hash == hash3 { - t.Errorf("不同输入产生了相同的哈希") + if sha3x.Base64(data) != sha3x.Base64Sum256(data) { + t.Error("Base64 应等价于 Base64Sum256") + } + if h := sha3x.New(); h.Size() != sha3x.Size256 { + t.Errorf("New().Size() = %d, 期望 %d", h.Size(), sha3x.Size256) + } + if got, err := sha3x.SumReader(strings.NewReader(string(data))); err != nil { + t.Fatalf("SumReader 失败: %v", err) + } else if !bytes.Equal(got, sha3x.Sum256(data)) { + t.Error("SumReader 应等价于 Sum256Reader") + } + if got, err := sha3x.HexReader(strings.NewReader(string(data))); err != nil { + t.Fatalf("HexReader 失败: %v", err) + } else if got != sha3x.Hex256(data) { + t.Error("HexReader 应等价于 Hex256Reader") } } -func TestShake128(t *testing.T) { - // 测试基本功能 - input := "hello world" - - // 测试不同输出长度 - hash16 := Shake128(input, 16) // 16字节 - if len(hash16) != 32 { // 16字节 = 32个十六进制字符 - t.Errorf("SHAKE128(16字节)哈希长度错误,期望: 32, 实际: %d", len(hash16)) - } - - hash32 := Shake128(input, 32) // 32字节 - if len(hash32) != 64 { // 32字节 = 64个十六进制字符 - t.Errorf("SHAKE128(32字节)哈希长度错误,期望: 64, 实际: %d", len(hash32)) - } - - // 相同输入和长度应该产生相同输出 - hash16_2 := Shake128(input, 16) - if hash16 != hash16_2 { - t.Errorf("相同输入和长度产生了不同的哈希: %s vs %s", hash16, hash16_2) - } - - // 不同长度应该产生不同输出 - if hash16 == hash32 { - t.Errorf("不同长度产生了相同的哈希") +func TestStreamingMatchesSum(t *testing.T) { + data := []byte(strings.Repeat("encryptx-sha3-", 200)) + for _, v := range vectors { + t.Run(v.name, func(t *testing.T) { + h := v.newHash() + for i := 0; i < len(data); i += 17 { + end := i + 17 + if end > len(data) { + end = len(data) + } + if _, err := h.Write(data[i:end]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + } + if got, want := h.Sum(nil), v.sum(data); !bytes.Equal(got, want) { + t.Errorf("分块写入 = %x, 期望 %x", got, want) + } + }) } } -func TestShake256(t *testing.T) { - // 测试基本功能 - input := "hello world" - - // 测试不同输出长度 - hash16 := Shake256(input, 16) // 16字节 - if len(hash16) != 32 { // 16字节 = 32个十六进制字符 - t.Errorf("SHAKE256(16字节)哈希长度错误,期望: 32, 实际: %d", len(hash16)) - } - - hash32 := Shake256(input, 32) // 32字节 - if len(hash32) != 64 { // 32字节 = 64个十六进制字符 - t.Errorf("SHAKE256(32字节)哈希长度错误,期望: 64, 实际: %d", len(hash32)) - } - - // 相同输入和长度应该产生相同输出 - hash16_2 := Shake256(input, 16) - if hash16 != hash16_2 { - t.Errorf("相同输入和长度产生了不同的哈希: %s vs %s", hash16, hash16_2) - } - - // 不同长度应该产生不同输出 - if hash16 == hash32 { - t.Errorf("不同长度产生了相同的哈希") +func TestReaderConsistency(t *testing.T) { + const s = "encryptx 一致性检查" + for _, v := range vectors { + t.Run(v.name, func(t *testing.T) { + sum, err := v.sumReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("SumReader 失败: %v", err) + } + if want := v.sum([]byte(s)); !bytes.Equal(sum, want) { + t.Errorf("SumReader = %x, 期望 %x", sum, want) + } + hexStr, err := v.hexReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if want := v.hex([]byte(s)); hexStr != want { + t.Errorf("HexReader = %s, 期望 %s", hexStr, want) + } + }) } } -func TestSha3Consistency(t *testing.T) { - // 测试一致性 - input := "test string" - - // 多次计算应该得到相同结果 - hash1 := Sha3_256(input) - hash2 := Sha3_256(input) - hash3 := Sha3_256(input) - - if hash1 != hash2 || hash2 != hash3 { - t.Errorf("多次计算应该得到相同的哈希") +func TestReaderErrors(t *testing.T) { + readers := []func(io.Reader) ([]byte, error){ + sha3x.Sum224Reader, sha3x.Sum256Reader, sha3x.Sum384Reader, sha3x.Sum512Reader, sha3x.SumReader, } - - // SHAKE也应该一致 - shake1 := Shake128(input, 32) - shake2 := Shake128(input, 32) - shake3 := Shake128(input, 32) - - if shake1 != shake2 || shake2 != shake3 { - t.Errorf("SHAKE多次计算应该得到相同的哈希") + for i, fn := range readers { + if _, err := fn(nil); err == nil { + t.Errorf("第 %d 个读取函数:nil 读取器应返回 error", i) + } + if _, err := fn(failingReader{}); err == nil { + t.Errorf("第 %d 个读取函数:读取失败时应返回 error", i) + } + } + hexReaders := []func(io.Reader) (string, error){ + sha3x.Hex224Reader, sha3x.Hex256Reader, sha3x.Hex384Reader, sha3x.Hex512Reader, sha3x.HexReader, + } + for i, fn := range hexReaders { + if _, err := fn(nil); err == nil { + t.Errorf("第 %d 个十六进制读取函数:nil 读取器应返回 error", i) + } + if _, err := fn(failingReader{}); err == nil { + t.Errorf("第 %d 个十六进制读取函数:读取失败时应返回 error", i) + } } } -func TestSha3EdgeCases(t *testing.T) { - // 空字符串 - emptyHash := Sha3_256("") - if len(emptyHash) != 64 { - t.Errorf("空字符串的SHA3-256哈希长度错误,期望: 64, 实际: %d", len(emptyHash)) +func TestHexFile(t *testing.T) { + dir := t.TempDir() + content := []byte("文件内容 file content\n") + path := filepath.Join(dir, "sample.bin") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("写入测试文件失败: %v", err) } - - // 长字符串 - longString := strings.Repeat("a", 10000) - longHash := Sha3_256(longString) - if len(longHash) != 64 { - t.Errorf("长字符串的SHA3-256哈希长度错误,期望: 64, 实际: %d", len(longHash)) + for _, v := range vectors { + t.Run(v.name, func(t *testing.T) { + got, err := v.hexFile(path) + if err != nil { + t.Fatalf("HexFile 失败: %v", err) + } + if want := v.hex(content); got != want { + t.Errorf("HexFile = %s, 期望 %s", got, want) + } + }) } - - // 特殊字符 - specialChars := Sha3_256("!@#$%^&*()_+-=[]{}|;'\",./<>?") - if len(specialChars) != 64 { - t.Errorf("特殊字符的SHA3-256哈希长度错误,期望: 64, 实际: %d", len(specialChars)) + if got, err := sha3x.HexFile(path); err != nil { + t.Fatalf("HexFile 失败: %v", err) + } else if want := sha3x.Hex256(content); got != want { + t.Errorf("HexFile = %s, 期望 %s", got, want) } } -func TestSha3VsSha2(t *testing.T) { - // SHA3和SHA2应该产生不同的哈希 - input := "test string" - - // sha2_256 := "test" // 这里应该调用sha256x.Sha256(input),但为了简化测试,我们直接比较 - sha3_256 := Sha3_256(input) - - // 由于SHA2和SHA3算法不同,它们应该产生不同的哈希 - // 这里我们只检查长度是否相同 - if len(sha3_256) != 64 { - t.Errorf("SHA3-256哈希长度错误,期望: 64, 实际: %d", len(sha3_256)) +func TestHexFileErrors(t *testing.T) { + missing := filepath.Join(t.TempDir(), "不存在.bin") + files := []func(string) (string, error){ + sha3x.HexFile224, sha3x.HexFile256, sha3x.HexFile384, sha3x.HexFile512, sha3x.HexFile, + } + for i, fn := range files { + if _, err := fn(missing); err == nil { + t.Errorf("第 %d 个文件函数:不存在的文件应返回 error", i) + } + if _, err := fn(""); err == nil { + t.Errorf("第 %d 个文件函数:空路径应返回 error", i) + } + if _, err := fn(t.TempDir()); err == nil { + t.Errorf("第 %d 个文件函数:目录路径应返回 error", i) + } } } -func BenchmarkSha3_256(b *testing.B) { - input := "benchmark test string" - b.ResetTimer() - for i := 0; i < b.N; i++ { - Sha3_256(input) +// 测试向量来源:NIST FIPS 202 SHAKE 示例,并经 Python 3 hashlib 独立复算确认。 +func TestShakeVectors(t *testing.T) { + cases := []struct { + name string + fn func([]byte, int) ([]byte, error) + fnHex func([]byte, int) (string, error) + input string + n int + wantHex string + }{ + {"SHAKE128 空串 16 字节", sha3x.Shake128, sha3x.Shake128Hex, "", 16, "7f9c2ba4e88f827d616045507605853e"}, + {"SHAKE128 abc 16 字节", sha3x.Shake128, sha3x.Shake128Hex, "abc", 16, "5881092dd818bf5cf8a3ddb793fbcba7"}, + {"SHAKE256 空串 32 字节", sha3x.Shake256, sha3x.Shake256Hex, "", 32, "46b9dd2b0ba88d13233b3feb743eeb243fcd52ea62b81b82b50c27646ed5762f"}, + {"SHAKE256 abc 32 字节", sha3x.Shake256, sha3x.Shake256Hex, "abc", 32, "483366601360a8771c6863080cc4114d8db44530f8f1e1ee4f94ea37e78b5739"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := tc.fn([]byte(tc.input), tc.n) + if err != nil { + t.Fatalf("计算失败: %v", err) + } + if len(out) != tc.n { + t.Fatalf("输出长度 = %d, 期望 %d", len(out), tc.n) + } + if got := hex.EncodeToString(out); got != tc.wantHex { + t.Errorf("输出 = %s, 期望 %s", got, tc.wantHex) + } + gotHex, err := tc.fnHex([]byte(tc.input), tc.n) + if err != nil { + t.Fatalf("计算十六进制失败: %v", err) + } + if gotHex != tc.wantHex { + t.Errorf("十六进制输出 = %s, 期望 %s", gotHex, tc.wantHex) + } + }) } } -func BenchmarkSha3_512(b *testing.B) { - input := "benchmark test string" - b.ResetTimer() - for i := 0; i < b.N; i++ { - Sha3_512(input) +func TestShakeLengthValidation(t *testing.T) { + bad := []int{0, -1, -1024, sha3x.MaxShakeOutputLength + 1, 1 << 30} + for _, n := range bad { + if _, err := sha3x.Shake128([]byte("x"), n); err == nil { + t.Errorf("Shake128 长度 %d 应返回 error", n) + } + if _, err := sha3x.Shake256([]byte("x"), n); err == nil { + t.Errorf("Shake256 长度 %d 应返回 error", n) + } + if _, err := sha3x.Shake128Hex([]byte("x"), n); err == nil { + t.Errorf("Shake128Hex 长度 %d 应返回 error", n) + } + if _, err := sha3x.Shake256Hex([]byte("x"), n); err == nil { + t.Errorf("Shake256Hex 长度 %d 应返回 error", n) + } + if _, err := sha3x.Shake128Reader(strings.NewReader("x"), n); err == nil { + t.Errorf("Shake128Reader 长度 %d 应返回 error", n) + } + if _, err := sha3x.Shake256Reader(strings.NewReader("x"), n); err == nil { + t.Errorf("Shake256Reader 长度 %d 应返回 error", n) + } + } + if _, err := sha3x.Shake128Reader(nil, 16); err == nil { + t.Error("nil 读取器应返回 error") + } + if _, err := sha3x.Shake256Reader(nil, 16); err == nil { + t.Error("nil 读取器应返回 error") + } + if _, err := sha3x.Shake128Reader(failingReader{}, 16); err == nil { + t.Error("读取失败时应返回 error") } } -func BenchmarkShake128(b *testing.B) { - input := "benchmark test string" - b.ResetTimer() - for i := 0; i < b.N; i++ { - Shake128(input, 32) +func TestShakeOutputProperties(t *testing.T) { + full, err := sha3x.Shake128([]byte("xyz"), 64) + if err != nil { + t.Fatalf("计算失败: %v", err) + } + short, err := sha3x.Shake128([]byte("xyz"), 32) + if err != nil { + t.Fatalf("计算失败: %v", err) + } + if !bytes.Equal(short, full[:32]) { + t.Error("XOF 前缀性质不成立:短输出应是长输出的前缀") + } + other, err := sha3x.Shake128([]byte("xy"), 64) + if err != nil { + t.Fatalf("计算失败: %v", err) + } + if bytes.Equal(full, other) { + t.Error("不同输入的 SHAKE 输出不应相同") + } + + max, err := sha3x.Shake256([]byte("x"), sha3x.MaxShakeOutputLength) + if err != nil { + t.Fatalf("上限长度计算失败: %v", err) + } + if len(max) != sha3x.MaxShakeOutputLength { + t.Errorf("上限长度输出 = %d, 期望 %d", len(max), sha3x.MaxShakeOutputLength) } } -func BenchmarkShake256(b *testing.B) { - input := "benchmark test string" - b.ResetTimer() - for i := 0; i < b.N; i++ { - Shake256(input, 32) +func TestShakeReaderConsistency(t *testing.T) { + const s = "encryptx shake reader" + data := []byte(s) + got, err := sha3x.Shake128Reader(strings.NewReader(s), 48) + if err != nil { + t.Fatalf("Shake128Reader 失败: %v", err) + } + if want, err := sha3x.Shake128(data, 48); err != nil { + t.Fatalf("Shake128 失败: %v", err) + } else if !bytes.Equal(got, want) { + t.Errorf("Shake128Reader = %x, 期望 %x", got, want) + } + + got256, err := sha3x.Shake256Reader(strings.NewReader(s), 48) + if err != nil { + t.Fatalf("Shake256Reader 失败: %v", err) + } + if want, err := sha3x.Shake256(data, 48); err != nil { + t.Fatalf("Shake256 失败: %v", err) + } else if !bytes.Equal(got256, want) { + t.Errorf("Shake256Reader = %x, 期望 %x", got256, want) } } + +func TestShakeStreaming(t *testing.T) { + data := []byte(strings.Repeat("encryptx-xof-", 64)) + cases := []struct { + name string + newXOF func() sha3.ShakeHash + wantOut func([]byte, int) ([]byte, error) + }{ + {"SHAKE128", sha3x.NewShake128, sha3x.Shake128}, + {"SHAKE256", sha3x.NewShake256, sha3x.Shake256}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := tc.newXOF() + for i := 0; i < len(data); i += 5 { + end := i + 5 + if end > len(data) { + end = len(data) + } + if _, err := h.Write(data[i:end]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + } + + out := make([]byte, 40) + if _, err := io.ReadFull(h, out); err != nil { + t.Fatalf("读取 XOF 输出失败: %v", err) + } + want, err := tc.wantOut(data, 40) + if err != nil { + t.Fatalf("计算期望值失败: %v", err) + } + if !bytes.Equal(out, want) { + t.Errorf("流式 XOF = %x, 期望 %x", out, want) + } + + // 同一 XOF 实例续读时应保持输出连续。 + rest := make([]byte, 24) + if _, err := io.ReadFull(h, rest); err != nil { + t.Fatalf("续读 XOF 输出失败: %v", err) + } + full, err := tc.wantOut(data, 64) + if err != nil { + t.Fatalf("计算期望值失败: %v", err) + } + if !bytes.Equal(rest, full[40:]) { + t.Errorf("续读输出 = %x, 期望 %x", rest, full[40:]) + } + }) + } +} + +func TestLargeInput(t *testing.T) { + data := largeData(1 << 20) + for _, v := range vectors { + t.Run(v.name, func(t *testing.T) { + want := v.hex(data) + if got := v.hex(data); got != want { + t.Errorf("重复计算结果不一致: %s vs %s", got, want) + } + got, err := v.hexReader(bytes.NewReader(data)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if got != want { + t.Errorf("HexReader = %s, 期望 %s", got, want) + } + h := v.newHash() + if _, err := h.Write(data[:1<<19]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if _, err := h.Write(data[1<<19:]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + t.Errorf("1MiB 分块 = %s, 期望 %s", got, want) + } + }) + } +} + +// largeData 生成确定性的 n 字节数据。 +func largeData(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i*31 + i/7) + } + return b +} diff --git a/sha512x/sha512x.go b/sha512x/sha512x.go index ecd83a1..9c35244 100644 --- a/sha512x/sha512x.go +++ b/sha512x/sha512x.go @@ -1,14 +1,132 @@ +// Package sha512x 提供 SHA-384 与 SHA-512 摘要计算,以及十六进制/Base64 编码与流式、文件接口。 +// +// 命名约定:带位数后缀的函数(Sum384、Hex384、New384 等)明确指定算法变体; +// 不带后缀的 Sum、Hex、Base64、New、SumReader、HexReader、HexFile 等价于 512 位变体, +// 因为 SHA-512 是本包最常用的变体。 package sha512x import ( "crypto/sha512" + "encoding/base64" "encoding/hex" + "fmt" + "hash" + "io" + "os" ) -// Sha512加密 -func Sha512(src string) string { - m := sha512.New() - m.Write([]byte(src)) - res := hex.EncodeToString(m.Sum(nil)) - return res +const ( + // Size384 是 SHA-384 摘要的字节数(48)。 + Size384 = sha512.Size384 + // Size512 是 SHA-512 摘要的字节数(64)。 + Size512 = sha512.Size + // BlockSize 是 SHA-384/SHA-512 分组的字节数(128)。 + BlockSize = sha512.BlockSize +) + +// New384 返回一个计算 SHA-384 摘要的 hash.Hash。 +func New384() hash.Hash { + return sha512.New384() +} + +// New512 返回一个计算 SHA-512 摘要的 hash.Hash。 +func New512() hash.Hash { + return sha512.New() +} + +// New 等价于 New512。 +func New() hash.Hash { + return New512() +} + +// Sum384 返回 data 的 SHA-384 摘要(Size384 字节原始数据)。 +func Sum384(data []byte) []byte { + return sum(sha512.New384(), data) +} + +// Sum512 返回 data 的 SHA-512 摘要(Size512 字节原始数据)。 +func Sum512(data []byte) []byte { + return sum(sha512.New(), data) +} + +// Sum 等价于 Sum512。 +func Sum(data []byte) []byte { + return Sum512(data) +} + +// Hex384 返回 data 的 SHA-384 摘要的小写十六进制字符串。 +func Hex384(data []byte) string { + return hex.EncodeToString(Sum384(data)) +} + +// Hex512 返回 data 的 SHA-512 摘要的小写十六进制字符串。 +func Hex512(data []byte) string { + return hex.EncodeToString(Sum512(data)) +} + +// Hex 等价于 Hex512。 +func Hex(data []byte) string { + return Hex512(data) +} + +// Base64Sum384 返回 data 的 SHA-384 摘要的标准 Base64 字符串。 +func Base64Sum384(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum384(data)) +} + +// Base64Sum512 返回 data 的 SHA-512 摘要的标准 Base64 字符串。 +func Base64Sum512(data []byte) string { + return base64.StdEncoding.EncodeToString(Sum512(data)) +} + +// Base64 等价于 Base64Sum512。 +func Base64(data []byte) string { + return Base64Sum512(data) +} + +// SumReader 读取 r 中的全部数据并返回其 SHA-512 摘要;读取失败时返回包装后的 error。 +func SumReader(r io.Reader) ([]byte, error) { + return sumReader(sha512.New(), r) +} + +// HexReader 读取 r 中的全部数据并返回其 SHA-512 摘要的小写十六进制字符串。 +func HexReader(r io.Reader) (string, error) { + sum, err := SumReader(r) + if err != nil { + return "", err + } + return hex.EncodeToString(sum), nil +} + +// HexFile 计算 path 所指向文件的 SHA-512 摘要并返回小写十六进制字符串; +// 文件不存在或读取失败时返回包装后的 error,不 panic。 +func HexFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("sha512x: 打开文件 %q 失败: %w", path, err) + } + defer f.Close() + + sum, err := sumReader(sha512.New(), f) + if err != nil { + return "", fmt.Errorf("sha512x: 计算文件 %q 摘要失败: %w", path, err) + } + return hex.EncodeToString(sum), nil +} + +// sum 把 data 一次性写入 h 并取出摘要。 +func sum(h hash.Hash, data []byte) []byte { + _, _ = h.Write(data) + return h.Sum(nil) +} + +// sumReader 把 r 的全部内容写入 h 并取出摘要。 +func sumReader(h hash.Hash, r io.Reader) ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("sha512x: 读取器为 nil") + } + if _, err := io.Copy(h, r); err != nil { + return nil, fmt.Errorf("sha512x: 读取数据失败: %w", err) + } + return h.Sum(nil), nil } diff --git a/sha512x/sha512x_test.go b/sha512x/sha512x_test.go index 3fbb4e8..d45d71b 100644 --- a/sha512x/sha512x_test.go +++ b/sha512x/sha512x_test.go @@ -1,80 +1,252 @@ -package sha512x +package sha512x_test import ( + "bytes" + "encoding/base64" + "encoding/hex" + "errors" + "os" + "path/filepath" "strings" "testing" + + "code.yun.ink/pkg/encryptx/sha512x" ) -func TestSha512(t *testing.T) { - // 测试基本功能 - input := "hello world" - hash := Sha512(input) +// 测试向量来源:FIPS 180-4 示例与 NIST 标准测试向量,并经 Python 3 hashlib 独立复算确认。 +var vectors384 = []struct { + name string + input string + want string +}{ + {"空串", "", "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b"}, + {"abc", "abc", "cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7"}, +} - // SHA512应该产生128字符的十六进制字符串 - if len(hash) != 128 { - t.Errorf("SHA512哈希长度错误,期望: 128, 实际: %d", len(hash)) +var vectors512 = []struct { + name string + input string + want string +}{ + {"空串", "", "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"}, + {"abc", "abc", "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"}, +} + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("模拟读取失败") } + +func TestConstants(t *testing.T) { + if sha512x.Size384 != 48 || sha512x.Size512 != 64 { + t.Fatalf("Size384 = %d, Size512 = %d, 期望 48 / 64", sha512x.Size384, sha512x.Size512) } - - // 相同输入应该产生相同输出 - hash2 := Sha512(input) - if hash != hash2 { - t.Errorf("相同输入产生了不同的哈希: %s vs %s", hash, hash2) - } - - // 不同输入应该产生不同输出 - hash3 := Sha512("different input") - if hash == hash3 { - t.Errorf("不同输入产生了相同的哈希") + if sha512x.BlockSize != 128 { + t.Fatalf("BlockSize = %d, 期望 128", sha512x.BlockSize) } } -func TestSha512Consistency(t *testing.T) { - // 测试一致性 - input := "test string" - - // 多次计算应该得到相同结果 - hash1 := Sha512(input) - hash2 := Sha512(input) - hash3 := Sha512(input) - - if hash1 != hash2 || hash2 != hash3 { - t.Errorf("多次计算应该得到相同的哈希") +func TestVectors384(t *testing.T) { + for _, v := range vectors384 { + t.Run(v.name, func(t *testing.T) { + data := []byte(v.input) + sum := sha512x.Sum384(data) + if len(sum) != sha512x.Size384 { + t.Fatalf("Sum384 长度 = %d, 期望 %d", len(sum), sha512x.Size384) + } + if got := hex.EncodeToString(sum); got != v.want { + t.Errorf("Sum384 = %s, 期望 %s", got, v.want) + } + if got := sha512x.Hex384(data); got != v.want { + t.Errorf("Hex384 = %s, 期望 %s", got, v.want) + } + if want := base64.StdEncoding.EncodeToString(sum); sha512x.Base64Sum384(data) != want { + t.Errorf("Base64Sum384 = %s, 期望 %s", sha512x.Base64Sum384(data), want) + } + h := sha512x.New384() + if _, err := h.Write(data); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != v.want { + t.Errorf("New384+Write = %s, 期望 %s", got, v.want) + } + }) } } -func TestSha512EdgeCases(t *testing.T) { - // 空字符串 - emptyHash := Sha512("") - if len(emptyHash) != 128 { - t.Errorf("空字符串的SHA512哈希长度错误,期望: 128, 实际: %d", len(emptyHash)) - } - - // 长字符串 - longString := strings.Repeat("a", 10000) - longHash := Sha512(longString) - if len(longHash) != 128 { - t.Errorf("长字符串的SHA512哈希长度错误,期望: 128, 实际: %d", len(longHash)) - } - - // 特殊字符 - specialChars := Sha512("!@#$%^&*()_+-=[]{}|;':\",./<>?") - if len(specialChars) != 128 { - t.Errorf("特殊字符的SHA512哈希长度错误,期望: 128, 实际: %d", len(specialChars)) +func TestVectors512(t *testing.T) { + for _, v := range vectors512 { + t.Run(v.name, func(t *testing.T) { + data := []byte(v.input) + sum := sha512x.Sum512(data) + if len(sum) != sha512x.Size512 { + t.Fatalf("Sum512 长度 = %d, 期望 %d", len(sum), sha512x.Size512) + } + if got := hex.EncodeToString(sum); got != v.want { + t.Errorf("Sum512 = %s, 期望 %s", got, v.want) + } + if got := sha512x.Hex512(data); got != v.want { + t.Errorf("Hex512 = %s, 期望 %s", got, v.want) + } + if want := base64.StdEncoding.EncodeToString(sum); sha512x.Base64Sum512(data) != want { + t.Errorf("Base64Sum512 = %s, 期望 %s", sha512x.Base64Sum512(data), want) + } + h := sha512x.New512() + if _, err := h.Write(data); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != v.want { + t.Errorf("New512+Write = %s, 期望 %s", got, v.want) + } + }) } } -func BenchmarkSha512(b *testing.B) { - input := "benchmark test string" - b.ResetTimer() - for i := 0; i < b.N; i++ { - Sha512(input) +func TestDefaultAliasesMatch512(t *testing.T) { + data := []byte("encryptx alias check") + if !bytes.Equal(sha512x.Sum(data), sha512x.Sum512(data)) { + t.Error("Sum 应等价于 Sum512") + } + if sha512x.Hex(data) != sha512x.Hex512(data) { + t.Error("Hex 应等价于 Hex512") + } + if sha512x.Base64(data) != sha512x.Base64Sum512(data) { + t.Error("Base64 应等价于 Base64Sum512") + } + if h := sha512x.New(); h.Size() != sha512x.Size512 { + t.Errorf("New().Size() = %d, 期望 %d", h.Size(), sha512x.Size512) } } -func BenchmarkSha512Long(b *testing.B) { - input := strings.Repeat("benchmark test string ", 100) // 约2.5KB - b.ResetTimer() - for i := 0; i < b.N; i++ { - Sha512(input) +func TestVariantsDiffer(t *testing.T) { + data := []byte("abc") + if sha512x.Hex384(data) == sha512x.Hex512(data) { + t.Error("SHA-384 与 SHA-512 的摘要不应相同") } } + +func TestStreamingMatchesSum(t *testing.T) { + data := []byte(strings.Repeat("encryptx-sha512-", 100)) + for _, tc := range []struct { + name string + h hashLike + whole []byte + }{ + {"SHA-384", sha512x.New384(), sha512x.Sum384(data)}, + {"SHA-512", sha512x.New512(), sha512x.Sum512(data)}, + } { + t.Run(tc.name, func(t *testing.T) { + for i := 0; i < len(data); i += 11 { + end := i + 11 + if end > len(data) { + end = len(data) + } + if _, err := tc.h.Write(data[i:end]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + } + if sum := tc.h.Sum(nil); !bytes.Equal(sum, tc.whole) { + t.Errorf("分块写入 = %x, 期望 %x", sum, tc.whole) + } + }) + } +} + +// hashLike 是测试用的最小哈希接口。 +type hashLike interface { + Write([]byte) (int, error) + Sum([]byte) []byte +} + +func TestReaderConsistency(t *testing.T) { + const s = "encryptx 一致性检查" + sum, err := sha512x.SumReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("SumReader 失败: %v", err) + } + if want := sha512x.Sum512([]byte(s)); !bytes.Equal(sum, want) { + t.Errorf("SumReader = %x, 期望 %x", sum, want) + } + hexStr, err := sha512x.HexReader(strings.NewReader(s)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if want := sha512x.Hex512([]byte(s)); hexStr != want { + t.Errorf("HexReader = %s, 期望 %s", hexStr, want) + } +} + +func TestReaderErrors(t *testing.T) { + if _, err := sha512x.SumReader(nil); err == nil { + t.Fatal("nil 读取器应返回 error") + } + if _, err := sha512x.HexReader(nil); err == nil { + t.Fatal("nil 读取器应返回 error") + } + if _, err := sha512x.SumReader(failingReader{}); err == nil { + t.Fatal("读取失败时应返回 error") + } +} + +func TestHexFile(t *testing.T) { + dir := t.TempDir() + content := []byte("文件内容 file content\n") + path := filepath.Join(dir, "sample.bin") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("写入测试文件失败: %v", err) + } + got, err := sha512x.HexFile(path) + if err != nil { + t.Fatalf("HexFile 失败: %v", err) + } + if want := sha512x.Hex512(content); got != want { + t.Errorf("HexFile = %s, 期望 %s", got, want) + } +} + +func TestHexFileErrors(t *testing.T) { + if _, err := sha512x.HexFile(filepath.Join(t.TempDir(), "不存在.bin")); err == nil { + t.Fatal("不存在的文件应返回 error") + } + if _, err := sha512x.HexFile(""); err == nil { + t.Fatal("空路径应返回 error") + } + if _, err := sha512x.HexFile(t.TempDir()); err == nil { + t.Fatal("目录路径应返回 error") + } +} + +func TestLargeInput(t *testing.T) { + data := largeData(1 << 20) + want := sha512x.Hex512(data) + if got := sha512x.Hex512(data); got != want { + t.Errorf("重复计算结果不一致: %s vs %s", got, want) + } + + got, err := sha512x.HexReader(bytes.NewReader(data)) + if err != nil { + t.Fatalf("HexReader 失败: %v", err) + } + if got != want { + t.Errorf("HexReader = %s, 期望 %s", got, want) + } + + h := sha512x.New512() + if _, err := h.Write(data[:1<<19]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if _, err := h.Write(data[1<<19:]); err != nil { + t.Fatalf("Write 失败: %v", err) + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + t.Errorf("1MiB 分块 = %s, 期望 %s", got, want) + } +} + +// largeData 生成确定性的 n 字节数据。 +func largeData(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i*31 + i/7) + } + return b +} diff --git a/tdesx/tdesx.go b/tdesx/tdesx.go index c383770..019846e 100644 --- a/tdesx/tdesx.go +++ b/tdesx/tdesx.go @@ -1,203 +1,213 @@ +// Package tdesx 提供三重 DES(3DES / TDEA)的加解密封装,支持 ECB/CBC/CFB/OFB/CTR。 +// +// # 安全警告 +// +// 3DES 的分组长度仍然只有 64 位,在加密超过约 32 GB 数据时会遭遇 Sweet32 +// 生日攻击,因此 NIST 已于 2023 年正式弃用 3DES。它比单 DES 强得多,但 +// **新系统不应再选用**:AES 更快也更安全。本包仅为兼容无法改动的既有系统 +// (老支付网关、老终端协议)而保留。 +// +// 另外,本包所有模式都**不提供完整性保护**,密文可被篡改而不被发现。若必须 +// 使用 3DES,请额外配合 HMAC 做「先加密后认证」。 package tdesx import ( - "bytes" - "crypto/cipher" "crypto/des" "crypto/rand" "encoding/hex" - "errors" "fmt" "io" - "strings" + + "code.yun.ink/pkg/encryptx/cipherx" ) -// 3DES-ECB 加密,返回大写的十六进制字符串 -func EncryptECBHex(input, key string) (string, error) { - // 创建3DES加密器 - block, err := des.NewTripleDESCipher([]byte(key)) +const ( + // KeySize 是 3DES 的密钥长度(字节),即 3 个独立的 DES 密钥。 + KeySize = 24 + // BlockSize 是 3DES 的分组长度(字节)。与单 DES 相同,仍为 64 位。 + BlockSize = des.BlockSize +) + +// Suite 绑定 3DES 的分组构造方式与合法密钥长度,模式实现由 cipherx 提供。 +// +// 导出它是为了让调用方能够以统一方式驱动任意模式,而无需在本包与 cipherx +// 之间写 switch。 +var Suite = cipherx.NewSuite("3DES", des.NewTripleDESCipher, BlockSize, KeySize) + +// GenerateKey 用 crypto/rand 生成 24 字节 3DES 密钥。 +// +// 注意:若三个 8 字节子密钥中存在相等的情况(尤其是 K1==K2==K3),3DES 会 +// 退化为单 DES。本函数生成的是三个独立随机子密钥,不会退化。 +func GenerateKey() ([]byte, error) { + key := make([]byte, KeySize) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return nil, fmt.Errorf("tdesx: 生成密钥失败: %w", err) + } + return key, nil +} + +// GenerateKeyHex 生成 3DES 密钥并以十六进制字符串返回。 +// +// 注意:返回值是**编码后的文本**,不能直接当密钥使用。读回后必须先解码 +// (encoding/hex 的 DecodeString,或本库的 hexx.Decode),再传给 Encrypt/Decrypt。 +func GenerateKeyHex() (string, error) { + key, err := GenerateKey() if err != nil { return "", err } - - // 对输入数据进行PKCS7填充 - paddedInput := PKCS7Padding([]byte(input), block.BlockSize()) - - // ECB模式加密 - encrypted := make([]byte, len(paddedInput)) - for i := 0; i < len(paddedInput); i += des.BlockSize { - block.Encrypt(encrypted[i:i+des.BlockSize], paddedInput[i:i+des.BlockSize]) - } - - // 转换为十六进制并大写 - result := hex.EncodeToString(encrypted) - return strings.ToUpper(result), nil + return hex.EncodeToString(key), nil } -// 3DES-ECB 解密,输入大写的十六进制字符串 -func DecryptECBHex(encryptedHex, key string) (string, error) { - encrypted, err := hex.DecodeString(encryptedHex) - if err != nil { - return "", fmt.Errorf("hex解码失败: %v", err) - } +// ---- ECB ---- +// +// 警告:ECB 不具备语义安全性,相同的明文分组会产生相同的密文分组。 - block, err := des.NewTripleDESCipher([]byte(key)) - if err != nil { - return "", fmt.Errorf("创建3DES解密器失败: %v", err) - } - - // ECB模式解密 - decrypted := make([]byte, len(encrypted)) - for i := 0; i < len(encrypted); i += des.BlockSize { - block.Decrypt(decrypted[i:i+des.BlockSize], encrypted[i:i+des.BlockSize]) - } - - // 去除PKCS7填充 - unpaddedData, err := PKCS7UnPadding(decrypted) - if err != nil { - return "", fmt.Errorf("去除填充失败: %v", err) - } - return string(unpaddedData), nil +// EncryptECB 使用 3DES-ECB 加密并做 PKCS#7 填充。 +func EncryptECB(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.ECB, plaintext, key) } -// 3DES-CBC 加密 -func EncryptCBCHex(plaintext, key string) (string, error) { - block, err := des.NewTripleDESCipher([]byte(key)) - if err != nil { - return "", err - } - blockSize := block.BlockSize() - plainBytes := PKCS7Padding([]byte(plaintext), blockSize) - - // 创建密文字节切片,包含IV空间 - cipherbyte := make([]byte, des.BlockSize+len(plainBytes)) - - // 生成随机IV - iv := cipherbyte[:des.BlockSize] - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return "", err - } - - // 创建CBC模式加密器 - blockMode := cipher.NewCBCEncrypter(block, iv) - - // 加密数据 - blockMode.CryptBlocks(cipherbyte[des.BlockSize:], plainBytes) - - // 转换为十六进制并大写 - result := hex.EncodeToString(cipherbyte) - return strings.ToUpper(result), nil +// DecryptECB 解密 3DES-ECB 密文。 +func DecryptECB(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.ECB, ciphertext, key) } -// 3DES-CBC 解密 -func DecryptCBCHex(encryptedHex, key string) (string, error) { - cipherbyte, err := hex.DecodeString(encryptedHex) - if err != nil { - return "", fmt.Errorf("hex解码失败: %v", err) - } - - if len(cipherbyte) < des.BlockSize { - return "", errors.New("ciphertext too short") - } - - block, err := des.NewTripleDESCipher([]byte(key)) - if err != nil { - return "", err - } - - iv := cipherbyte[:des.BlockSize] - cipherbyte = cipherbyte[des.BlockSize:] - if len(cipherbyte)%des.BlockSize != 0 { - return "", errors.New("ciphertext is not a multiple of the block size") - } - - blockMode := cipher.NewCBCDecrypter(block, iv) - origData := make([]byte, len(cipherbyte)) - blockMode.CryptBlocks(origData, cipherbyte) - origBy, err := PKCS7UnPadding(origData) - if err != nil { - return "", err - } - return string(origBy), nil +// EncryptECBHex 与 EncryptECB 相同,返回大写十六进制字符串。 +func EncryptECBHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.ECB, plaintext, key) } -// 3DES-CFB 加密 -func EncryptCFBHex(plaintext, key string) (string, error) { - block, err := des.NewTripleDESCipher([]byte(key)) - if err != nil { - return "", err - } - - // 创建密文字节切片,包含IV空间 - cipherbyte := make([]byte, des.BlockSize+len(plaintext)) - - // 生成随机IV - iv := cipherbyte[:des.BlockSize] - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return "", err - } - - // 创建CFB模式加密器 - stream := cipher.NewCFBEncrypter(block, iv) - stream.XORKeyStream(cipherbyte[des.BlockSize:], []byte(plaintext)) - - // 转换为十六进制并大写 - result := hex.EncodeToString(cipherbyte) - return strings.ToUpper(result), nil +// DecryptECBHex 解密十六进制 3DES-ECB 密文,大小写均可。 +func DecryptECBHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.ECB, ciphertextHex, key) } -// 3DES-CFB 解密 -func DecryptCFBHex(encryptedHex, key string) (string, error) { - cipherbyte, err := hex.DecodeString(encryptedHex) - if err != nil { - return "", fmt.Errorf("hex解码失败: %v", err) - } +// ---- CBC ---- - if len(cipherbyte) < des.BlockSize { - return "", errors.New("ciphertext too short") - } - - block, err := des.NewTripleDESCipher([]byte(key)) - if err != nil { - return "", err - } - - // 获取IV - iv := cipherbyte[:des.BlockSize] - cipherbyte = cipherbyte[des.BlockSize:] - - // 创建CFB模式解密器 - stream := cipher.NewCFBDecrypter(block, iv) - stream.XORKeyStream(cipherbyte, cipherbyte) - - return string(cipherbyte), nil +// EncryptCBC 使用 3DES-CBC 加密,随机 IV 前置在密文之前。 +func EncryptCBC(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.CBC, plaintext, key) } -// PKCS7补码 -func PKCS7Padding(ciphertext []byte, blockSize int) []byte { - padding := blockSize - len(ciphertext)%blockSize - padtext := bytes.Repeat([]byte{byte(padding)}, padding) - return append(ciphertext, padtext...) +// DecryptCBC 解密 3DES-CBC 密文(IV 前置格式)。 +func DecryptCBC(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.CBC, ciphertext, key) } -// 去除PKCS7补码 -func PKCS7UnPadding(data []byte) ([]byte, error) { - length := len(data) - if length == 0 { - return nil, errors.New("ciphertext is empty") - } - - unpadding := int(data[length-1]) - if unpadding > length || unpadding == 0 { - return nil, errors.New("unpadding size is invalid") - } - - // 检查填充是否正确 - for i := length - unpadding; i < length; i++ { - if data[i] != byte(unpadding) { - return nil, errors.New("invalid padding") - } - } - - return data[:(length - unpadding)], nil +// EncryptCBCHex 与 EncryptCBC 相同,返回大写十六进制字符串。 +func EncryptCBCHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.CBC, plaintext, key) +} + +// DecryptCBCHex 解密十六进制 3DES-CBC 密文。 +func DecryptCBCHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.CBC, ciphertextHex, key) +} + +// EncryptCBCWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +// +// 仅用于与规定了固定 IV 的既有系统互操作。 +func EncryptCBCWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.CBC, plaintext, key, iv) +} + +// DecryptCBCWithIV 使用调用方指定的 IV 解密。 +func DecryptCBCWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.CBC, ciphertext, key, iv) +} + +// ---- CFB ---- + +// EncryptCFB 使用 3DES-CFB 加密,随机 IV 前置在密文之前。 +func EncryptCFB(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.CFB, plaintext, key) +} + +// DecryptCFB 解密 3DES-CFB 密文(IV 前置格式)。 +func DecryptCFB(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.CFB, ciphertext, key) +} + +// EncryptCFBHex 与 EncryptCFB 相同,返回大写十六进制字符串。 +func EncryptCFBHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.CFB, plaintext, key) +} + +// DecryptCFBHex 解密十六进制 3DES-CFB 密文。 +func DecryptCFBHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.CFB, ciphertextHex, key) +} + +// EncryptCFBWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +func EncryptCFBWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.CFB, plaintext, key, iv) +} + +// DecryptCFBWithIV 使用调用方指定的 IV 解密。 +func DecryptCFBWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.CFB, ciphertext, key, iv) +} + +// ---- OFB ---- + +// EncryptOFB 使用 3DES-OFB 加密,随机 IV 前置在密文之前。 +func EncryptOFB(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.OFB, plaintext, key) +} + +// DecryptOFB 解密 3DES-OFB 密文(IV 前置格式)。 +func DecryptOFB(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.OFB, ciphertext, key) +} + +// EncryptOFBHex 与 EncryptOFB 相同,返回大写十六进制字符串。 +func EncryptOFBHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.OFB, plaintext, key) +} + +// DecryptOFBHex 解密十六进制 3DES-OFB 密文。 +func DecryptOFBHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.OFB, ciphertextHex, key) +} + +// EncryptOFBWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +func EncryptOFBWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.OFB, plaintext, key, iv) +} + +// DecryptOFBWithIV 使用调用方指定的 IV 解密。 +func DecryptOFBWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.OFB, ciphertext, key, iv) +} + +// ---- CTR ---- + +// EncryptCTR 使用 3DES-CTR 加密,随机 IV 前置在密文之前。 +func EncryptCTR(plaintext, key []byte) ([]byte, error) { + return Suite.Encrypt(cipherx.CTR, plaintext, key) +} + +// DecryptCTR 解密 3DES-CTR 密文(IV 前置格式)。 +func DecryptCTR(ciphertext, key []byte) ([]byte, error) { + return Suite.Decrypt(cipherx.CTR, ciphertext, key) +} + +// EncryptCTRHex 与 EncryptCTR 相同,返回大写十六进制字符串。 +func EncryptCTRHex(plaintext, key []byte) (string, error) { + return Suite.EncryptHex(cipherx.CTR, plaintext, key) +} + +// DecryptCTRHex 解密十六进制 3DES-CTR 密文。 +func DecryptCTRHex(ciphertextHex string, key []byte) ([]byte, error) { + return Suite.DecryptHex(cipherx.CTR, ciphertextHex, key) +} + +// EncryptCTRWithIV 使用调用方指定的 IV 加密,返回值不含 IV。 +func EncryptCTRWithIV(plaintext, key, iv []byte) ([]byte, error) { + return Suite.EncryptWithIV(cipherx.CTR, plaintext, key, iv) +} + +// DecryptCTRWithIV 使用调用方指定的 IV 解密。 +func DecryptCTRWithIV(ciphertext, key, iv []byte) ([]byte, error) { + return Suite.DecryptWithIV(cipherx.CTR, ciphertext, key, iv) } diff --git a/tdesx/tdesx_test.go b/tdesx/tdesx_test.go index 7a75c1f..74d19c5 100644 --- a/tdesx/tdesx_test.go +++ b/tdesx/tdesx_test.go @@ -1,94 +1,181 @@ -package tdesx +package tdesx_test import ( + "bytes" + "errors" "testing" + + "code.yun.ink/pkg/encryptx/cipherx" + "code.yun.ink/pkg/encryptx/tdesx" ) -func TestEncryptECBHex(t *testing.T) { - // 3DES密钥需要24字节 - key := "123456789012345678901234" - input := "hello world" - - encrypted, err := EncryptECBHex(input, key) +func TestGenerateKey(t *testing.T) { + key, err := tdesx.GenerateKey() if err != nil { - t.Errorf("加密失败: %v", err) - return + t.Fatalf("GenerateKey 失败: %v", err) + } + if len(key) != tdesx.KeySize { + t.Fatalf("密钥长度 = %d, 期望 %d", len(key), tdesx.KeySize) } - decrypted, err := DecryptECBHex(encrypted, key) - if err != nil { - t.Errorf("解密失败: %v", err) - return + other, _ := tdesx.GenerateKey() + if bytes.Equal(key, other) { + t.Error("两次 GenerateKey 产生了相同密钥") } - if decrypted != input { - t.Errorf("解密结果不匹配,期望: %s, 实际: %s", input, decrypted) + hexKey, err := tdesx.GenerateKeyHex() + if err != nil { + t.Fatalf("GenerateKeyHex 失败: %v", err) + } + if len(hexKey) != tdesx.KeySize*2 { + t.Errorf("GenerateKeyHex 长度 = %d, 期望 %d", len(hexKey), tdesx.KeySize*2) } } -func TestEncryptCBCHex(t *testing.T) { - // 3DES密钥需要24字节 - key := "123456789012345678901234" - input := "hello world" - - encrypted, err := EncryptCBCHex(input, key) - if err != nil { - t.Errorf("CBC加密失败: %v", err) - return - } - - decrypted, err := DecryptCBCHex(encrypted, key) - if err != nil { - t.Errorf("CBC解密失败: %v", err) - return - } - - if decrypted != input { - t.Errorf("CBC解密结果不匹配,期望: %s, 实际: %s", input, decrypted) +func TestKeySizeValidation(t *testing.T) { + plaintext := []byte("hello") + for _, wrongLen := range []int{0, 8, 16, 23, 25, 32} { + key := bytes.Repeat([]byte{0x01}, wrongLen) + if _, err := tdesx.EncryptCBC(plaintext, key); !errors.Is(err, cipherx.ErrInvalidKeySize) { + t.Errorf("密钥长度 %d: 期望 ErrInvalidKeySize, 得到 %v", wrongLen, err) + } } } -func TestEncryptCFBHex(t *testing.T) { - // 3DES密钥需要24字节 - key := "123456789012345678901234" - input := "hello world" - - encrypted, err := EncryptCFBHex(input, key) +func TestAllModesRoundTrip(t *testing.T) { + key, err := tdesx.GenerateKey() if err != nil { - t.Errorf("CFB加密失败: %v", err) - return + t.Fatalf("GenerateKey 失败: %v", err) } - decrypted, err := DecryptCFBHex(encrypted, key) - if err != nil { - t.Errorf("CFB解密失败: %v", err) - return + encryptors := map[string]func([]byte, []byte) ([]byte, error){ + "ECB": tdesx.EncryptECB, "CBC": tdesx.EncryptCBC, "CFB": tdesx.EncryptCFB, + "OFB": tdesx.EncryptOFB, "CTR": tdesx.EncryptCTR, + } + decryptors := map[string]func([]byte, []byte) ([]byte, error){ + "ECB": tdesx.DecryptECB, "CBC": tdesx.DecryptCBC, "CFB": tdesx.DecryptCFB, + "OFB": tdesx.DecryptOFB, "CTR": tdesx.DecryptCTR, + } + plaintexts := [][]byte{ + {}, []byte("a"), []byte("1234567"), []byte("12345678"), []byte("123456789"), + bytes.Repeat([]byte("3DES"), 200), } - if decrypted != input { - t.Errorf("CFB解密结果不匹配,期望: %s, 实际: %s", input, decrypted) + for name, encrypt := range encryptors { + for i, plaintext := range plaintexts { + ciphertext, err := encrypt(plaintext, key) + if err != nil { + t.Fatalf("%s 明文#%d 加密失败: %v", name, i, err) + } + decrypted, err := decryptors[name](ciphertext, key) + if err != nil { + t.Fatalf("%s 明文#%d 解密失败: %v", name, i, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s 明文#%d 往返不一致", name, i) + } + } } } -func TestPKCS7Padding(t *testing.T) { - // 测试PKCS7填充和去除 - data := []byte("hello") - blockSize := 8 +func TestHexAPIRoundTrip(t *testing.T) { + key, _ := tdesx.GenerateKey() + plaintext := []byte("十六进制往返") - // 填充 - padded := PKCS7Padding(data, blockSize) - if len(padded) != blockSize { - t.Errorf("填充长度错误,期望: %d, 实际: %d", blockSize, len(padded)) + cases := []struct { + name string + encrypt func([]byte, []byte) (string, error) + decrypt func(string, []byte) ([]byte, error) + }{ + {"ECB", tdesx.EncryptECBHex, tdesx.DecryptECBHex}, + {"CBC", tdesx.EncryptCBCHex, tdesx.DecryptCBCHex}, + {"CFB", tdesx.EncryptCFBHex, tdesx.DecryptCFBHex}, + {"OFB", tdesx.EncryptOFBHex, tdesx.DecryptOFBHex}, + {"CTR", tdesx.EncryptCTRHex, tdesx.DecryptCTRHex}, } + for _, c := range cases { + hexStr, err := c.encrypt(plaintext, key) + if err != nil { + t.Fatalf("%s Hex 加密失败: %v", c.name, err) + } + if bytes.ContainsAny([]byte(hexStr), "\n\r \t") { + t.Errorf("%s Hex 输出含空白字符", c.name) + } + decrypted, err := c.decrypt(hexStr, key) + if err != nil { + t.Fatalf("%s Hex 解密失败: %v", c.name, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s Hex 往返不一致", c.name) + } + } +} + +func TestWithIVRoundTrip(t *testing.T) { + key, _ := tdesx.GenerateKey() + iv := bytes.Repeat([]byte{0x5A}, tdesx.BlockSize) + plaintext := []byte("fixed iv") + + cases := []struct { + name string + encrypt func([]byte, []byte, []byte) ([]byte, error) + decrypt func([]byte, []byte, []byte) ([]byte, error) + }{ + {"CBC", tdesx.EncryptCBCWithIV, tdesx.DecryptCBCWithIV}, + {"CFB", tdesx.EncryptCFBWithIV, tdesx.DecryptCFBWithIV}, + {"OFB", tdesx.EncryptOFBWithIV, tdesx.DecryptOFBWithIV}, + {"CTR", tdesx.EncryptCTRWithIV, tdesx.DecryptCTRWithIV}, + } + for _, c := range cases { + ciphertext, err := c.encrypt(plaintext, key, iv) + if err != nil { + t.Fatalf("%s WithIV 加密失败: %v", c.name, err) + } + decrypted, err := c.decrypt(ciphertext, key, iv) + if err != nil { + t.Fatalf("%s WithIV 解密失败: %v", c.name, err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("%s WithIV 往返不一致", c.name) + } + } +} + +// TestMalformedCiphertextNeverPanics 是 P0-3 在 3DES 上的回归测试。 +func TestMalformedCiphertextNeverPanics(t *testing.T) { + key, _ := tdesx.GenerateKey() + decryptors := map[string]func([]byte, []byte) ([]byte, error){ + "ECB": tdesx.DecryptECB, "CBC": tdesx.DecryptCBC, "CFB": tdesx.DecryptCFB, + "OFB": tdesx.DecryptOFB, "CTR": tdesx.DecryptCTR, + } + for _, decrypt := range decryptors { + for n := 0; n <= 24; n++ { + _, _ = decrypt(bytes.Repeat([]byte{0x7F}, n), key) + } + } + + nonAligned := bytes.Repeat([]byte{0xAB}, 12) + if _, err := tdesx.DecryptECB(nonAligned, key); !errors.Is(err, cipherx.ErrNotBlockAligned) { + t.Errorf("ECB 非对齐: 期望 ErrNotBlockAligned, 得到 %v", err) + } + if _, err := tdesx.DecryptCBC(nonAligned, key); !errors.Is(err, cipherx.ErrNotBlockAligned) { + t.Errorf("CBC 非对齐: 期望 ErrNotBlockAligned, 得到 %v", err) + } +} + +func TestECBIsDeterministic(t *testing.T) { + key, _ := tdesx.GenerateKey() + plaintext := bytes.Repeat([]byte("12345678"), 3) - // 去除填充 - unpadded, err := PKCS7UnPadding(padded) - if err != nil { - t.Errorf("去除填充失败: %v", err) - return + a, _ := tdesx.EncryptECB(plaintext, key) + b, _ := tdesx.EncryptECB(plaintext, key) + if !bytes.Equal(a, b) { + t.Error("ECB 应当是确定性的") } - if string(unpadded) != string(data) { - t.Errorf("去除填充后数据不匹配,期望: %s, 实际: %s", string(data), string(unpadded)) + c, _ := tdesx.EncryptCBC(plaintext, key) + d, _ := tdesx.EncryptCBC(plaintext, key) + if bytes.Equal(c, d) { + t.Error("CBC 使用了随机 IV,两次结果不应相同") } } diff --git a/utilsx/utilsx.go b/utilsx/utilsx.go index 3642749..8f3e9f8 100644 --- a/utilsx/utilsx.go +++ b/utilsx/utilsx.go @@ -1,184 +1,422 @@ +// Package utilsx 提供基于 crypto/rand 的随机数据与随机标识符生成工具。 +// +// # 本包只使用密码学安全随机源 +// +// 所有随机数都来自 crypto/rand(操作系统 CSPRNG),**绝不使用 math/rand**, +// 因此返回值可用于密钥、令牌、盐值、初始向量等安全用途。 +// 若某个函数需要确定性随机(例如可复现测试),请自行注入随机源,不要改用 math/rand。 +// +// # 已删除的危险 API(破坏性变更说明) +// +// 本次重写删除了以下函数,原因是它们存在真实的安全缺陷: +// +// - HashPassword(password, salt string) string:使用**单次 SHA-1** 计算口令哈希, +// 且没有迭代、没有内存硬化,GPU 每秒可尝试数十亿次,口令一旦泄露即可被离线爆破。 +// - VerifyPassword(password, salt, hash string) bool:内部用 == 比较哈希, +// 属于非常量时间比较,存在时序侧信道。 +// - CalculateHash(input string) string:同样是 SHA-1 的重复实现, +// 与 sha1x 包职责重叠且容易诱导误用为"口令哈希"。 +// +// 口令哈希请改用 bcryptx(bcrypt)或 argon2x(Argon2id); +// 需要常量时间比较请使用本包的 SecureCompare / SecureCompareString。 +// +// 旧名字 GenerateRandomString、GenerateRandomBytes、GenerateRandomHexString、 +// GenerateRandomBase64String、GenerateAPIKey、GenerateToken、GeneratePassword、 +// GenerateUUID 也一并删除,分别由 RandomString、RandomBytes、RandomHex、 +// RandomBase64、APIKey、RandomToken、RandomPassword、UUID 取代。 +// +// 另外,GenerateToken 的旧实现使用带填充的 base64.URLEncoding 再手工 TrimRight("="), +// 且未校验最小长度;新实现 RandomToken 直接使用 base64.RawURLEncoding, +// 并强制 n >= MinTokenLength。 package utilsx import ( "crypto/rand" - "crypto/sha1" + "crypto/subtle" "encoding/base64" "encoding/hex" + "errors" "fmt" "io" "math/big" "strings" ) -// GenerateRandomString 生成指定长度的随机字符串 -func GenerateRandomString(length int) (string, error) { - if length <= 0 { - return "", fmt.Errorf("长度必须大于0") +const ( + // MinTokenLength 是随机令牌建议的最小字符长度。 + // 低于该长度的令牌(例如 8 字符)在离线暴力枚举下已不安全, + // 不应作为会话令牌、重置令牌或 API 密钥使用。 + MinTokenLength = 16 + + // DefaultTokenLength 是推荐的随机令牌默认字符长度(32 字符), + // 对应约 192 位熵(URL 安全 Base64 每字符 6 位)。 + DefaultTokenLength = 32 + + // maxRandomBytes 是单次随机字节请求的上限(1 MiB)。 + // 设置上限是为了在参数被外部输入控制时避免巨额内存分配导致的拒绝服务, + // 也用于发现把"比特数/字符数"误当作"字节数"传入的调用错误。 + maxRandomBytes = 1 << 20 + + // upperCharset 是大写字母字符集。 + upperCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + // lowerCharset 是小写字母字符集。 + lowerCharset = "abcdefghijklmnopqrstuvwxyz" + + // digitCharset 是数字字符集。 + digitCharset = "0123456789" + + // symbolCharset 是常用可打印符号字符集。 + symbolCharset = "!@#$%^&*()-_=+[]{}|;:,.<>?" +) + +var ( + // errNonPositiveLength 表示请求了非正数的长度。 + errNonPositiveLength = errors.New("长度必须大于 0") + + // errTooLarge 表示请求的长度超过允许上限。 + errTooLarge = errors.New("长度超过允许的最大值") +) + +// RandomBytes 生成 n 个密码学安全随机字节。 +// +// 参数: +// - n: 需要的字节数,必须满足 0 < n <= 1<<20。 +// +// 返回值:长度为 n 的随机字节;参数非法或随机源失败时返回 nil 与包装后的 error。 +// +// 安全注意: +// - 返回值可直接用于密钥、盐值、IV、nonce;其中的内容属于敏感材料, +// 禁止打印到 stdout/stderr 或写入日志、错误信息。 +// - 上限 1<<20 字节用于防止参数受外部控制时造成内存放大(拒绝服务)。 +func RandomBytes(n int) ([]byte, error) { + if n <= 0 { + return nil, fmt.Errorf("生成随机字节: %w", errNonPositiveLength) } + if n > maxRandomBytes { + return nil, fmt.Errorf("生成随机字节: %w(最大 %d,请求 %d)", errTooLarge, maxRandomBytes, n) + } + buf := make([]byte, n) + if _, err := io.ReadFull(rand.Reader, buf); err != nil { + return nil, fmt.Errorf("生成随机字节: %w", err) + } + return buf, nil +} - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - result := make([]byte, length) +// RandomString 生成由大小写字母与数字组成的随机字符串。 +// +// 参数: +// - n: 目标字符数,必须满足 0 < n <= 1<<20。 +// +// 返回值:长度为 n 的随机字符串;参数非法或随机源失败时返回空串与包装后的 error。 +// +// 安全注意: +// - 每个字符都通过 crypto/rand.Int 在字符集范围内均匀选取, +// 内部使用拒绝采样,**不存在模偏差**(modulo bias),熵为 n*log2(62) 位。 +// - 逐字符调用 rand.Int 比批量取字节慢,但避免了偏差; +// 需要高性能且可接受字符集限制时请使用 RandomToken 或 RandomBase64。 +func RandomString(n int) (string, error) { + const charset = lowerCharset + upperCharset + digitCharset + return RandomStringWithCharset(n, charset) +} - for i := range result { - num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) +// RandomStringWithCharset 使用指定字符集生成随机字符串。 +// +// 参数: +// - n: 目标字符数,必须满足 0 < n <= 1<<20。 +// - charset: 候选字符集,不能为空;字符是否重复会改变各类字符的出现概率, +// 传入重复字符等同于加权,请确保这是有意为之。 +// +// 返回值:长度为 n 的随机字符串;参数非法或随机源失败时返回空串与包装后的 error。 +// +// 安全注意: +// - 使用 crypto/rand.Int 做拒绝采样,无模偏差;请勿传入可能被攻击者影响的字符集。 +// - 若要生成口令,请使用 RandomPassword,它会保证每类字符至少出现一次。 +func RandomStringWithCharset(n int, charset string) (string, error) { + if n <= 0 { + return "", fmt.Errorf("生成随机字符串: %w", errNonPositiveLength) + } + if n > maxRandomBytes { + return "", fmt.Errorf("生成随机字符串: %w(最大 %d,请求 %d)", errTooLarge, maxRandomBytes, n) + } + runes := []rune(charset) + if len(runes) == 0 { + return "", fmt.Errorf("生成随机字符串: 字符集不能为空") + } + max := big.NewInt(int64(len(runes))) + out := make([]rune, n) + for i := range out { + idx, err := rand.Int(rand.Reader, max) if err != nil { - return "", fmt.Errorf("生成随机数失败: %v", err) + return "", fmt.Errorf("生成随机字符串: %w", err) } - result[i] = charset[num.Int64()] + out[i] = runes[idx.Int64()] } - - return string(result), nil + return string(out), nil } -// GenerateRandomBytes 生成指定长度的随机字节 -func GenerateRandomBytes(length int) ([]byte, error) { - if length <= 0 { - return nil, fmt.Errorf("长度必须大于0") - } - - bytes := make([]byte, length) - _, err := rand.Read(bytes) +// RandomHex 生成 n 字节随机数据的十六进制字符串(2n 个字符,小写)。 +// +// 参数: +// - n: 随机字节数,必须满足 0 < n <= 1<<20。 +// +// 返回值:长度为 2n 的小写十六进制字符串;参数非法或随机源失败时返回空串与包装后的 error。 +// +// 安全注意:这是"n 字节 → 2n 字符"的编码,不要与需要 n 个字符的场景混用; +// 编码后的字符串仍是敏感明文,不要写入日志。 +func RandomHex(n int) (string, error) { + buf, err := RandomBytes(n) if err != nil { - return nil, fmt.Errorf("生成随机字节失败: %v", err) + return "", fmt.Errorf("生成随机十六进制字符串: %w", err) } - - return bytes, nil + return hex.EncodeToString(buf), nil } -// GenerateRandomHexString 生成指定长度的随机十六进制字符串 -func GenerateRandomHexString(length int) (string, error) { - bytes, err := GenerateRandomBytes(length) +// RandomBase64 生成 n 字节随机数据的 URL 安全、无填充 Base64 字符串。 +// +// 参数: +// - n: 随机字节数,必须满足 0 < n <= 1<<20。 +// +// 返回值:由 base64.RawURLEncoding 编码的字符串,长度为 ceil(4n/3); +// 参数非法或随机源失败时返回空串与包装后的 error。 +// +// 安全注意:输出只含 [A-Za-z0-9-_],可安全放进 URL、文件名与 JSON; +// 但仍不得写入日志。若需要**精确 n 个字符**,请使用 RandomToken。 +func RandomBase64(n int) (string, error) { + buf, err := RandomBytes(n) if err != nil { - return "", err + return "", fmt.Errorf("生成随机 Base64 字符串: %w", err) } - return hex.EncodeToString(bytes), nil + return base64.RawURLEncoding.EncodeToString(buf), nil } -// GenerateRandomBase64String 生成指定长度的随机Base64字符串 -func GenerateRandomBase64String(length int) (string, error) { - bytes, err := GenerateRandomBytes(length) +// RandomToken 生成 n 个字符的 URL 安全随机令牌(无填充)。 +// +// 参数: +// - n: 目标字符数,必须满足 n >= MinTokenLength 且 n <= 1<<20。 +// +// 返回值:恰好 n 个字符、只含 [A-Za-z0-9-_] 的随机令牌; +// 参数非法或随机源失败时返回空串与包装后的 error。 +// +// 实现说明:按 ceil(3n/4) 计算所需随机字节数,用 base64.RawURLEncoding 编码, +// 若结果长度超过 n 则截断到 n。截断**不引入偏差**:Base64 每个字符独立且均匀分布, +// 截断只是丢弃尾部字符,保留部分的分布不变。 +// +// 重要限制(已穷举验证):无填充 Base64 的**合法长度不可能满足 n ≡ 1 (mod 4)** +// (1 个残留字符无法还原成整字节),因此当 n ≡ 1 (mod 4)(如 17、21、33)时, +// 返回值虽然确实是 n 个 URL 安全字符,但**不能**再用 RawURLEncoding 直接解回字节。 +// 这是"恰好 n 个字符"与"无填充 Base64 可解码"两个要求本身的冲突; +// 本函数优先保证长度恰为 n。若需要"任何长度都能解回字节",请改用 +// RandomBase64(字节数入参)或自行补 '=' 后再解码,或把长度约束改为 4 的倍数。 +// +// 安全注意: +// - 长度下限 MinTokenLength 是硬性校验,目的是避免生成会话令牌、重置令牌、 +// API 密钥等短令牌;确实需要短随机串(例如展示用 ID) +// 请改用 RandomString 并自行评估风险。 +// - 令牌等同于口令,禁止打印、记录或放进错误信息。 +func RandomToken(n int) (string, error) { + if n < MinTokenLength { + return "", fmt.Errorf("生成随机令牌: 长度至少为 %d,请求 %d", MinTokenLength, n) + } + if n > maxRandomBytes { + return "", fmt.Errorf("生成随机令牌: %w(最大 %d,请求 %d)", errTooLarge, maxRandomBytes, n) + } + // 使用 int64 计算避免 n 极大时乘法溢出;ceil(3n/4) 用 (3n+3)/4 求得。 + bytesNeeded := int((int64(n)*3 + 3) / 4) + buf, err := RandomBytes(bytesNeeded) if err != nil { - return "", err + return "", fmt.Errorf("生成随机令牌: %w", err) } - return base64.StdEncoding.EncodeToString(bytes), nil -} - -// CalculateHash 计算字符串的哈希值 -func CalculateHash(input string) string { - h := sha1.New() - h.Write([]byte(input)) - return hex.EncodeToString(h.Sum(nil)) -} - -// GenerateUUID 生成UUID v4 -func GenerateUUID() (string, error) { - bytes := make([]byte, 16) - _, err := rand.Read(bytes) - if err != nil { - return "", fmt.Errorf("生成UUID失败: %v", err) + token := base64.RawURLEncoding.EncodeToString(buf) + if len(token) > n { + token = token[:n] } - - // 设置版本号和变体 - bytes[6] = (bytes[6] & 0x0f) | 0x40 // Version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80 // Variant 10 - - return fmt.Sprintf("%x-%x-%x-%x-%x", bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:16]), nil -} - -// GenerateAPIKey 生成API密钥 -func GenerateAPIKey(prefix string) (string, error) { - // 生成24字节的随机数据 - randomBytes, err := GenerateRandomBytes(24) - if err != nil { - return "", fmt.Errorf("生成API密钥失败: %v", err) - } - - // 转换为Base64 - apiKey := base64.StdEncoding.EncodeToString(randomBytes) - - // 添加前缀(如果有) - if prefix != "" { - apiKey = prefix + "." + apiKey - } - - return apiKey, nil -} - -// GenerateToken 生成安全令牌 -func GenerateToken(length int) (string, error) { - if length <= 0 { - return "", fmt.Errorf("长度必须大于0") - } - - // 生成足够的随机字节,确保Base64编码后至少有指定长度 - bytesNeeded := (length*3 + 3) / 4 // Base64编码会使长度增加约33% - bytes, err := GenerateRandomBytes(bytesNeeded) - if err != nil { - return "", fmt.Errorf("生成令牌失败: %v", err) - } - - // 转换为Base64 URL安全编码(无填充,URL和文件名安全) - token := base64.URLEncoding.EncodeToString(bytes) - - // 移除填充字符 - token = strings.TrimRight(token, "=") - - // 确保令牌长度符合要求 - if len(token) > length { - token = token[:length] - } - return token, nil } -// GeneratePassword 生成密码 -func GeneratePassword(length int, useUppercase, useLowercase, useDigits, useSymbols bool) (string, error) { - if length <= 0 { - return "", fmt.Errorf("密码长度必须大于0") +// RandomPassword 生成同时包含所有被选中字符类型的随机口令。 +// +// 参数: +// - length: 口令长度,必须 >= 被选中类型数量且 <= 1<<20。 +// - upper: 是否要求包含大写字母(A-Z)。 +// - lower: 是否要求包含小写字母(a-z)。 +// - digits: 是否要求包含数字(0-9)。 +// - symbols: 是否要求包含符号(!@#$%^&*()-_=+[]{}|;:,.<>?)。 +// +// 返回值:长度恰为 length 的口令;参数非法或随机源失败时返回空串与包装后的 error。 +// +// 实现说明:先从每一类中取 1 个字符放入结果,剩余位置用合并字符集填充, +// 最后用 crypto/rand 驱动的 Fisher-Yates 洗牌打乱。**洗牌是必需的**: +// 若不打乱,前几位的字符类别就固定为"大写、小写、数字、符号", +// 攻击者可据此缩小搜索空间,这是真实存在的弱点。 +// +// 安全注意: +// - 口令强度约为 length*log2(len(charset)) 位,12 位(四类全选)≈ 78 位, +// 仅适合作为初始口令;高价值账户建议 length >= 16。 +// - 返回的口令是明文秘密,禁止打印或记录; +// 存储时必须使用 bcryptx 或 argon2x 做慢哈希,切勿用 SHA 单次哈希。 +// - 拒绝被选中类型数量大于 length 的请求,因为那样无法满足"每类至少一个"。 +func RandomPassword(length int, upper, lower, digits, symbols bool) (string, error) { + var ( + sets []string + charset strings.Builder + ) + if upper { + sets = append(sets, upperCharset) + charset.WriteString(upperCharset) + } + if lower { + sets = append(sets, lowerCharset) + charset.WriteString(lowerCharset) + } + if digits { + sets = append(sets, digitCharset) + charset.WriteString(digitCharset) + } + if symbols { + sets = append(sets, symbolCharset) + charset.WriteString(symbolCharset) + } + if len(sets) == 0 { + return "", fmt.Errorf("生成随机口令: 至少需要选择一种字符类型") + } + if length < len(sets) { + return "", fmt.Errorf("生成随机口令: 长度 %d 不足以容纳 %d 种字符类型", length, len(sets)) + } + if length > maxRandomBytes { + return "", fmt.Errorf("生成随机口令: %w(最大 %d,请求 %d)", errTooLarge, maxRandomBytes, length) } - var charset string - if useUppercase { - charset += "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - } - if useLowercase { - charset += "abcdefghijklmnopqrstuvwxyz" - } - if useDigits { - charset += "0123456789" - } - if useSymbols { - charset += "!@#$%^&*()-_=+[]{}|;:,.<>?" - } - - if charset == "" { - return "", fmt.Errorf("至少选择一种字符类型") - } - - // 确保密码包含所有选择的字符类型 - password := make([]byte, length) - charsetLength := big.NewInt(int64(len(charset))) - - for i := 0; i < length; i++ { - num, err := rand.Int(rand.Reader, charsetLength) + out := make([]byte, 0, length) + // 第一步:每类各保证 1 个字符。 + for _, set := range sets { + c, err := randomByteFrom(set) if err != nil { - return "", fmt.Errorf("生成密码失败: %v", err) + return "", fmt.Errorf("生成随机口令: %w", err) } - password[i] = charset[num.Int64()] + out = append(out, c) } - - return string(password), nil + // 第二步:剩余位置从合并字符集中填充。 + all := charset.String() + for len(out) < length { + c, err := randomByteFrom(all) + if err != nil { + return "", fmt.Errorf("生成随机口令: %w", err) + } + out = append(out, c) + } + // 第三步:Fisher-Yates 洗牌,避免前几位字符类别固定。 + if err := shuffleBytes(out); err != nil { + return "", fmt.Errorf("生成随机口令: %w", err) + } + return string(out), nil } -// HashPassword 哈希密码(简单示例,实际应用中应使用更安全的算法如bcrypt) -func HashPassword(password, salt string) string { - h := sha1.New() - io.WriteString(h, password+salt) - return hex.EncodeToString(h.Sum(nil)) +// UUID 生成 RFC 4122 版本 4(随机)UUID 字符串。 +// +// 返回值:形如 "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" 的 36 字符小写字符串; +// 随机源失败时返回空串与包装后的 error。 +// +// 实现说明:取 16 字节随机数据,设置版本位(第 7 字节高 4 位为 0100,即版本 4) +// 与变体位(第 9 字节高 2 位为 10,即 RFC 4122 变体),共 122 位随机性。 +// +// 安全注意:UUID v4 不可预测,可用于会话标识;但它**不是**能力令牌, +// 不要用它代替 RandomToken 作为 API 密钥(缺少显式的长度与格式约束)。 +func UUID() (string, error) { + buf, err := RandomBytes(16) + if err != nil { + return "", fmt.Errorf("生成 UUID: %w", err) + } + buf[6] = (buf[6] & 0x0f) | 0x40 // version 4 + buf[8] = (buf[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%x-%x-%x-%x-%x", buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]), nil } -// VerifyPassword 验证密码(简单示例,实际应用中应使用更安全的算法如bcrypt) -func VerifyPassword(password, salt, hash string) bool { - return HashPassword(password, salt) == hash +// APIKey 生成带前缀的 API 密钥。 +// +// 参数: +// - prefix: 前缀(例如 "sk."),会**原样**拼接在令牌之前;为空则不加前缀。 +// - n: 令牌部分的字符数,必须满足 n >= MinTokenLength 且 n <= 1<<20。 +// +// 返回值:prefix + RandomToken(n) 形式的字符串; +// 参数非法或随机源失败时返回空串与包装后的 error。 +// +// 安全注意: +// - 返回值是完整的凭据,等同于口令,禁止打印或记录; +// 服务端只应保存其哈希(例如 SHA-256 摘要或 bcryptx 哈希)用于比对。 +// - 前缀仅用于人类可读的区分(便于密钥扫描与轮换),不提供任何安全性。 +func APIKey(prefix string, n int) (string, error) { + token, err := RandomToken(n) + if err != nil { + return "", fmt.Errorf("生成 API 密钥: %w", err) + } + return prefix + token, nil +} + +// SecureCompare 以常量时间比较两个字节串是否相等。 +// +// 参数: +// - a, b: 待比较的字节串,长度不同时判定为不相等。 +// +// 返回值:相等返回 true,否则返回 false。 +// +// 安全注意:底层为 crypto/subtle.ConstantTimeCompare,比较耗时与内容无关, +// 可抵御时序侧信道攻击;用于比较令牌、MAC、口令哈希等秘密时**必须**使用本函数, +// 绝不要使用 == 或 bytes.Equal。 +func SecureCompare(a, b []byte) bool { + return subtle.ConstantTimeCompare(a, b) == 1 +} + +// SecureCompareString 以常量时间比较两个字符串是否相等。 +// +// 参数: +// - a, b: 待比较的字符串,长度不同时判定为不相等。 +// +// 返回值:相等返回 true,否则返回 false。 +// +// 安全注意:比较秘密字符串(令牌、口令哈希)时必须使用本函数而非 ==; +// 长度差异会体现在返回结果与耗时上,因此被比较的值应具有固定长度。 +func SecureCompareString(a, b string) bool { + return SecureCompare([]byte(a), []byte(b)) +} + +// randomByteFrom 从字符集中均匀随机取一个字节。 +// +// 参数: +// - charset: 候选字符集,按字节切分,不能为空。 +// +// 返回值:随机选中的字节;字符集为空或随机源失败时返回 0 与包装后的 error。 +// +// 安全注意:使用 crypto/rand.Int 做拒绝采样,不存在模偏差; +// charset 应为固定常量,不要来自不可信输入。 +func randomByteFrom(charset string) (byte, error) { + if len(charset) == 0 { + return 0, fmt.Errorf("字符集不能为空") + } + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return 0, fmt.Errorf("生成随机索引: %w", err) + } + return charset[idx.Int64()], nil +} + +// shuffleBytes 用 crypto/rand 驱动的 Fisher-Yates 算法就地洗牌。 +// +// 参数: +// - buf: 待洗牌的字节切片,可以为 nil 或空(无操作)。 +// +// 返回值:随机源失败时返回包装后的 error,此时 buf 可能已被部分打乱。 +// +// 安全注意:必须使用密码学安全随机源,否则攻击者可以预测排列; +// 洗牌失败时调用方应丢弃 buf,不要继续使用。 +func shuffleBytes(buf []byte) error { + for i := len(buf) - 1; i > 0; i-- { + idx, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + if err != nil { + return fmt.Errorf("洗牌: %w", err) + } + j := int(idx.Int64()) + buf[i], buf[j] = buf[j], buf[i] + } + return nil } diff --git a/utilsx/utilsx_test.go b/utilsx/utilsx_test.go index bed00a9..e9462d4 100644 --- a/utilsx/utilsx_test.go +++ b/utilsx/utilsx_test.go @@ -1,444 +1,727 @@ -package utilsx +package utilsx_test import ( + "bytes" + "crypto/rand" "encoding/base64" "encoding/hex" "strings" "testing" + + "code.yun.ink/pkg/encryptx/utilsx" ) -func TestGenerateRandomString(t *testing.T) { - // 测试生成随机字符串 - str, err := GenerateRandomString(10) - if err != nil { - t.Errorf("生成随机字符串失败: %v", err) - return - } +// urlSafeAlphabet 是 URL 安全 Base64(无填充)允许出现的全部字符。 +const urlSafeAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" - if len(str) != 10 { - t.Errorf("生成的字符串长度不正确,期望: 10, 实际: %d", len(str)) - } - - // 测试长度为0 - _, err = GenerateRandomString(0) - if err == nil { - t.Errorf("长度为0应该返回错误") - } - - // 测试多次生成,应该不同 - str2, err := GenerateRandomString(10) - if err != nil { - t.Errorf("生成随机字符串失败: %v", err) - return - } - - if str == str2 { - t.Errorf("多次生成的随机字符串应该不同") - } -} - -func TestGenerateRandomBytes(t *testing.T) { - // 测试生成随机字节 - bytes, err := GenerateRandomBytes(10) - if err != nil { - t.Errorf("生成随机字节失败: %v", err) - return - } - - if len(bytes) != 10 { - t.Errorf("生成的字节长度不正确,期望: 10, 实际: %d", len(bytes)) - } - - // 测试长度为0 - _, err = GenerateRandomBytes(0) - if err == nil { - t.Errorf("长度为0应该返回错误") - } - - // 测试多次生成,应该不同 - bytes2, err := GenerateRandomBytes(10) - if err != nil { - t.Errorf("生成随机字节失败: %v", err) - return - } - - // 比较字节切片 - equal := true - for i := range bytes { - if bytes[i] != bytes2[i] { - equal = false - break +// TestRandomBytes 校验长度、随机性与错误路径。 +func TestRandomBytes(t *testing.T) { + for _, n := range []int{1, 2, 16, 32, 1024} { + buf, err := utilsx.RandomBytes(n) + if err != nil { + t.Fatalf("RandomBytes(%d) 返回错误: %v", n, err) + } + if len(buf) != n { + t.Errorf("RandomBytes(%d) 长度 = %d", n, len(buf)) } } - if equal { - t.Errorf("多次生成的随机字节应该不同") + // 错误路径:0、负数、超大值都必须返回 error 与 nil,且不 panic。 + bad := []int{0, -1, -1024, (1 << 20) + 1, 1 << 30} + for _, n := range bad { + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("RandomBytes(%d) 发生 panic: %v", n, r) + } + }() + buf, err := utilsx.RandomBytes(n) + if err == nil { + t.Errorf("RandomBytes(%d) 应返回错误", n) + } + if buf != nil { + t.Errorf("RandomBytes(%d) 出错时应返回 nil", n) + } + }() + } + + // 边界值 1<<20 必须被接受。 + if buf, err := utilsx.RandomBytes(1 << 20); err != nil || len(buf) != 1<<20 { + t.Errorf("RandomBytes(1<<20) 应成功,err=%v len=%d", err, len(buf)) } } -func TestGenerateRandomHexString(t *testing.T) { - // 测试生成随机十六进制字符串 - hexStr, err := GenerateRandomHexString(10) - if err != nil { - t.Errorf("生成随机十六进制字符串失败: %v", err) - return - } - - // 十六进制字符串长度应该是字节长度的两倍 - if len(hexStr) != 20 { - t.Errorf("生成的十六进制字符串长度不正确,期望: 20, 实际: %d", len(hexStr)) - } - - // 验证是否为有效的十六进制字符串 - _, err = hex.DecodeString(hexStr) - if err != nil { - t.Errorf("生成的字符串不是有效的十六进制: %v", err) - } - - // 测试多次生成,应该不同 - hexStr2, err := GenerateRandomHexString(10) - if err != nil { - t.Errorf("生成随机十六进制字符串失败: %v", err) - return - } - - if hexStr == hexStr2 { - t.Errorf("多次生成的随机十六进制字符串应该不同") - } +// TestRandomBytesUnique 校验重复调用产生不同结果。 +func TestRandomBytesUnique(t *testing.T) { + assertUnique(t, 64, func() string { + buf, err := utilsx.RandomBytes(32) + if err != nil { + t.Fatalf("RandomBytes 返回错误: %v", err) + } + return string(buf) + }) } -func TestGenerateRandomBase64String(t *testing.T) { - // 测试生成随机Base64字符串 - base64Str, err := GenerateRandomBase64String(10) +// TestRandomString 校验字符集、长度、随机性与错误路径。 +func TestRandomString(t *testing.T) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + + s, err := utilsx.RandomString(32) if err != nil { - t.Errorf("生成随机Base64字符串失败: %v", err) - return + t.Fatalf("RandomString 返回错误: %v", err) } - - // 验证是否为有效的Base64字符串 - _, err = base64.StdEncoding.DecodeString(base64Str) - if err != nil { - t.Errorf("生成的字符串不是有效的Base64: %v", err) + if len(s) != 32 { + t.Errorf("RandomString(32) 长度 = %d", len(s)) } - - // 测试多次生成,应该不同 - base64Str2, err := GenerateRandomBase64String(10) - if err != nil { - t.Errorf("生成随机Base64字符串失败: %v", err) - return - } - - if base64Str == base64Str2 { - t.Errorf("多次生成的随机Base64字符串应该不同") - } -} - -func TestCalculateHash(t *testing.T) { - // 测试计算哈希 - input := "test input" - hash := CalculateHash(input) - - // SHA1哈希应该是40个字符 - if len(hash) != 40 { - t.Errorf("哈希长度不正确,期望: 40, 实际: %d", len(hash)) - } - - // 相同输入应该产生相同哈希 - hash2 := CalculateHash(input) - if hash != hash2 { - t.Errorf("相同输入产生了不同的哈希") - } - - // 不同输入应该产生不同哈希 - hash3 := CalculateHash("different input") - if hash == hash3 { - t.Errorf("不同输入产生了相同的哈希") - } -} - -func TestGenerateUUID(t *testing.T) { - // 测试生成UUID - uuid, err := GenerateUUID() - if err != nil { - t.Errorf("生成UUID失败: %v", err) - return - } - - // UUID应该是36个字符(包括4个连字符) - if len(uuid) != 36 { - t.Errorf("UUID长度不正确,期望: 36, 实际: %d", len(uuid)) - } - - // UUID应该包含4个连字符 - if strings.Count(uuid, "-") != 4 { - t.Errorf("UUID应该包含4个连字符") - } - - // 测试多次生成,应该不同 - uuid2, err := GenerateUUID() - if err != nil { - t.Errorf("生成UUID失败: %v", err) - return - } - - if uuid == uuid2 { - t.Errorf("多次生成的UUID应该不同") - } -} - -func TestGenerateAPIKey(t *testing.T) { - // 测试生成API密钥(无前缀) - apiKey, err := GenerateAPIKey("") - if err != nil { - t.Errorf("生成API密钥失败: %v", err) - return - } - - // 验证是否为有效的Base64 - _, err = base64.StdEncoding.DecodeString(apiKey) - if err != nil { - t.Errorf("生成的API密钥不是有效的Base64: %v", err) - } - - // 测试生成API密钥(有前缀) - apiKeyWithPrefix, err := GenerateAPIKey("test") - if err != nil { - t.Errorf("生成API密钥失败: %v", err) - return - } - - // 应该包含前缀 - if !strings.HasPrefix(apiKeyWithPrefix, "test.") { - t.Errorf("API密钥应该包含前缀") - } - - // 去掉前缀后应该是有效的Base64 - parts := strings.Split(apiKeyWithPrefix, ".") - if len(parts) != 2 { - t.Errorf("API密钥格式不正确") - return - } - - _, err = base64.StdEncoding.DecodeString(parts[1]) - if err != nil { - t.Errorf("生成的API密钥不是有效的Base64: %v", err) - } - - // 测试多次生成,应该不同 - apiKey2, err := GenerateAPIKey("") - if err != nil { - t.Errorf("生成API密钥失败: %v", err) - return - } - - if apiKey == apiKey2 { - t.Errorf("多次生成的API密钥应该不同") - } -} - -func TestGenerateToken(t *testing.T) { - // 测试生成令牌 - token, err := GenerateToken(20) - if err != nil { - t.Errorf("生成令牌失败: %v", err) - return - } - - if len(token) != 20 { - t.Errorf("令牌长度不正确,期望: 20, 实际: %d", len(token)) - } - - // 令牌应该不包含填充字符 - if strings.Contains(token, "=") { - t.Errorf("令牌不应该包含填充字符") - } - - // 测试长度为0 - _, err = GenerateToken(0) - if err == nil { - t.Errorf("长度为0应该返回错误") - } - - // 测试多次生成,应该不同 - token2, err := GenerateToken(20) - if err != nil { - t.Errorf("生成令牌失败: %v", err) - return - } - - if token == token2 { - t.Errorf("多次生成的令牌应该不同") - } -} - -func TestGeneratePassword(t *testing.T) { - // 测试生成密码(所有字符类型) - password, err := GeneratePassword(12, true, true, true, true) - if err != nil { - t.Errorf("生成密码失败: %v", err) - return - } - - if len(password) != 12 { - t.Errorf("密码长度不正确,期望: 12, 实际: %d", len(password)) - } - - // 测试只使用大写字母 - passwordUpper, err := GeneratePassword(10, true, false, false, false) - if err != nil { - t.Errorf("生成密码失败: %v", err) - return - } - - // 验证只包含大写字母 - for _, c := range passwordUpper { - if !(c >= 'A' && c <= 'Z') { - t.Errorf("密码包含非大写字母字符: %c", c) + for i := 0; i < len(s); i++ { + if strings.IndexByte(charset, s[i]) < 0 { + t.Errorf("RandomString 含非法字符 %q", s[i]) } } - // 测试只使用小写字母 - passwordLower, err := GeneratePassword(10, false, true, false, false) - if err != nil { - t.Errorf("生成密码失败: %v", err) - return - } + assertUnique(t, 64, func() string { + v, err := utilsx.RandomString(24) + if err != nil { + t.Fatalf("RandomString 返回错误: %v", err) + } + return v + }) - // 验证只包含小写字母 - for _, c := range passwordLower { - if !(c >= 'a' && c <= 'z') { - t.Errorf("密码包含非小写字母字符: %c", c) + for _, n := range []int{0, -5, (1 << 20) + 1} { + v, err := utilsx.RandomString(n) + if err == nil { + t.Errorf("RandomString(%d) 应返回错误", n) + } + if v != "" { + t.Errorf("RandomString(%d) 出错时应返回空串,实际 %q", n, v) + } + } +} + +// TestRandomStringWithCharset 校验自定义字符集与错误路径。 +func TestRandomStringWithCharset(t *testing.T) { + s, err := utilsx.RandomStringWithCharset(64, "ab") + if err != nil { + t.Fatalf("RandomStringWithCharset 返回错误: %v", err) + } + if len(s) != 64 { + t.Errorf("长度 = %d, 期望 64", len(s)) + } + for i := 0; i < len(s); i++ { + if s[i] != 'a' && s[i] != 'b' { + t.Errorf("字符集受限却出现了 %q", s[i]) } } - // 测试只使用数字 - passwordDigits, err := GeneratePassword(10, false, false, true, false) + // 单字符字符集应始终返回同内容但长度正确的串。 + single, err := utilsx.RandomStringWithCharset(8, "x") if err != nil { - t.Errorf("生成密码失败: %v", err) - return + t.Fatalf("单字符字符集出错: %v", err) + } + if single != "xxxxxxxx" { + t.Errorf("单字符字符集结果 = %q, 期望 xxxxxxxx", single) } - // 验证只包含数字 - for _, c := range passwordDigits { - if !(c >= '0' && c <= '9') { - t.Errorf("密码包含非数字字符: %c", c) + // 空字符集必须报错。 + v, err := utilsx.RandomStringWithCharset(16, "") + if err == nil { + t.Error("空字符集应返回错误") + } + if v != "" { + t.Errorf("空字符集出错时应返回空串,实际 %q", v) + } + + // 非 ASCII 字符集:长度按 rune 计。 + uni, err := utilsx.RandomStringWithCharset(20, "中文🔐") + if err != nil { + t.Fatalf("非 ASCII 字符集出错: %v", err) + } + if len([]rune(uni)) != 20 { + t.Errorf("非 ASCII 字符集长度 = %d rune, 期望 20", len([]rune(uni))) + } + + // 长度非法。 + if _, err := utilsx.RandomStringWithCharset(0, "abc"); err == nil { + t.Error("长度为 0 应返回错误") + } + if _, err := utilsx.RandomStringWithCharset(-1, "abc"); err == nil { + t.Error("长度为负应返回错误") + } + if _, err := utilsx.RandomStringWithCharset((1<<20)+1, "abc"); err == nil { + t.Error("长度超上限应返回错误") + } +} + +// TestRandomHex 校验十六进制输出的长度、字符集与随机性。 +func TestRandomHex(t *testing.T) { + got, err := utilsx.RandomHex(16) + if err != nil { + t.Fatalf("RandomHex 返回错误: %v", err) + } + if len(got) != 32 { + t.Errorf("RandomHex(16) 长度 = %d, 期望 32", len(got)) + } + if _, err := hex.DecodeString(got); err != nil { + t.Errorf("RandomHex 结果不是合法十六进制: %v", err) + } + if got != strings.ToLower(got) { + t.Errorf("RandomHex 应为小写: %q", got) + } + + assertUnique(t, 64, func() string { + v, err := utilsx.RandomHex(16) + if err != nil { + t.Fatalf("RandomHex 返回错误: %v", err) + } + return v + }) + + for _, n := range []int{0, -1, (1 << 20) + 1} { + v, err := utilsx.RandomHex(n) + if err == nil { + t.Errorf("RandomHex(%d) 应返回错误", n) + } + if v != "" { + t.Errorf("RandomHex(%d) 出错时应返回空串", n) + } + } +} + +// TestRandomBase64 校验 URL 安全无填充 Base64 输出的长度与字符集。 +func TestRandomBase64(t *testing.T) { + cases := []struct { + n int + want int // 期望的编码后长度(ceil(4n/3)) + }{ + {1, 2}, {2, 3}, {3, 4}, {4, 6}, {16, 22}, {32, 43}, {48, 64}, + } + for _, c := range cases { + got, err := utilsx.RandomBase64(c.n) + if err != nil { + t.Fatalf("RandomBase64(%d) 返回错误: %v", c.n, err) + } + if len(got) != c.want { + t.Errorf("RandomBase64(%d) 长度 = %d, 期望 %d", c.n, len(got), c.want) + } + if strings.Contains(got, "=") { + t.Errorf("RandomBase64 不应含填充: %q", got) + } + assertOnlyCharset(t, got, urlSafeAlphabet) + if _, err := base64.RawURLEncoding.DecodeString(got); err != nil { + t.Errorf("RandomBase64(%d) 结果不是合法 RawURLEncoding: %v", c.n, err) } } - // 测试长度为0 - _, err = GeneratePassword(0, true, true, true, true) - if err == nil { - t.Errorf("长度为0应该返回错误") - } + assertUnique(t, 64, func() string { + v, err := utilsx.RandomBase64(24) + if err != nil { + t.Fatalf("RandomBase64 返回错误: %v", err) + } + return v + }) - // 测试不选择任何字符类型 - _, err = GeneratePassword(10, false, false, false, false) - if err == nil { - t.Errorf("不选择任何字符类型应该返回错误") + for _, n := range []int{0, -3, (1 << 20) + 1} { + v, err := utilsx.RandomBase64(n) + if err == nil { + t.Errorf("RandomBase64(%d) 应返回错误", n) + } + if v != "" { + t.Errorf("RandomBase64(%d) 出错时应返回空串", n) + } } } -func TestHashPassword(t *testing.T) { - // 测试哈希密码 - password := "test_password" - salt := "test_salt" +// TestRandomToken 校验长度恰为 n、只含 URL 安全字符、无填充, +// 以及"当 n 是合法无填充 Base64 长度时可解回字节"。 +func TestRandomToken(t *testing.T) { + // 覆盖全部 n mod 4 情形。 + lengths := []int{utilsx.MinTokenLength, 17, 18, 19, 20, 21, 24, 25, 32, 33, 64, utilsx.DefaultTokenLength, 100, 128, 129} + for _, n := range lengths { + got, err := utilsx.RandomToken(n) + if err != nil { + t.Fatalf("RandomToken(%d) 返回错误: %v", n, err) + } + if len(got) != n { + t.Errorf("RandomToken(%d) 长度 = %d", n, len(got)) + } + if strings.Contains(got, "=") { + t.Errorf("RandomToken(%d) 不应含填充: %q", n, got) + } + assertOnlyCharset(t, got, urlSafeAlphabet) - hash := HashPassword(password, salt) - - // SHA1哈希应该是40个字符 - if len(hash) != 40 { - t.Errorf("哈希长度不正确,期望: 40, 实际: %d", len(hash)) + // n ≡ 1 (mod 4) 不是合法的无填充 Base64 长度(残留 1 字符无法还原成整字节), + // 因此这类长度下只断言字符集与长度;其余长度必须能原样解回字节。 + if n%4 == 1 { + if _, err := base64.RawURLEncoding.DecodeString(got); err == nil { + t.Logf("RandomToken(%d) 意外可被 RawURLEncoding 解码(标准库行为可能已放宽)", n) + } + continue + } + raw, err := base64.RawURLEncoding.DecodeString(got) + if err != nil { + t.Errorf("RandomToken(%d)(n%%4=%d)结果不是合法 RawURLEncoding: %v", n, n%4, err) + continue + } + if wantBytes := (n * 3) / 4; len(raw) != wantBytes { + t.Errorf("RandomToken(%d) 可还原 %d 字节, 期望 %d", n, len(raw), wantBytes) + } } - // 相同密码和盐应该产生相同哈希 - hash2 := HashPassword(password, salt) - if hash != hash2 { - t.Errorf("相同密码和盐产生了不同的哈希") + // 低于最小长度必须报错(且返回空串、不 panic)。 + for _, n := range []int{0, 1, 8, utilsx.MinTokenLength - 1, -1} { + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("RandomToken(%d) 发生 panic: %v", n, r) + } + }() + v, err := utilsx.RandomToken(n) + if err == nil { + t.Errorf("RandomToken(%d) 应返回错误(低于 MinTokenLength=%d)", n, utilsx.MinTokenLength) + } + if v != "" { + t.Errorf("RandomToken(%d) 出错时应返回空串", n) + } + }() } - // 不同盐应该产生不同哈希 - hash3 := HashPassword(password, "different_salt") - if hash == hash3 { - t.Errorf("不同盐产生了相同的哈希") + // 超过上限必须报错。 + if _, err := utilsx.RandomToken((1 << 20) + 1); err == nil { + t.Error("RandomToken 超上限应返回错误") } - // 不同密码应该产生不同哈希 - hash4 := HashPassword("different_password", salt) - if hash == hash4 { - t.Errorf("不同密码产生了相同的哈希") + assertUnique(t, 128, func() string { + v, err := utilsx.RandomToken(32) + if err != nil { + t.Fatalf("RandomToken 返回错误: %v", err) + } + return v + }) +} + +// TestRandomPasswordCategories 断言每一个被选中的字符类别至少出现一次。 +// 这是本次重写要修复的核心缺陷,因此循环 50 次以确认稳定通过。 +func TestRandomPasswordCategories(t *testing.T) { + const iterations = 50 + + combos := []struct { + upper, lower, digits, symbols bool + }{ + {true, false, false, false}, + {false, true, false, false}, + {false, false, true, false}, + {false, false, false, true}, + {true, true, false, false}, + {true, false, true, false}, + {true, false, false, true}, + {false, true, true, false}, + {false, true, false, true}, + {false, false, true, true}, + {true, true, true, false}, + {true, true, false, true}, + {true, false, true, true}, + {false, true, true, true}, + {true, true, true, true}, + } + + for _, combo := range combos { + classes := 0 + for _, on := range []bool{combo.upper, combo.lower, combo.digits, combo.symbols} { + if on { + classes++ + } + } + length := classes + 8 // 保证有填充位置 + + for iter := 0; iter < iterations; iter++ { + pw, err := utilsx.RandomPassword(length, combo.upper, combo.lower, combo.digits, combo.symbols) + if err != nil { + t.Fatalf("组合 %+v 第 %d 次: RandomPassword 返回错误: %v", combo, iter, err) + } + if len(pw) != length { + t.Fatalf("组合 %+v 第 %d 次: 长度 = %d, 期望 %d", combo, iter, len(pw), length) + } + + var hasUpper, hasLower, hasDigit, hasSymbol bool + for i := 0; i < len(pw); i++ { + c := pw[i] + switch { + case c >= 'A' && c <= 'Z': + hasUpper = true + case c >= 'a' && c <= 'z': + hasLower = true + case c >= '0' && c <= '9': + hasDigit = true + case strings.IndexByte("!@#$%^&*()-_=+[]{}|;:,.<>?", c) >= 0: + hasSymbol = true + default: + t.Fatalf("组合 %+v 第 %d 次: 出现未知字符 %q(口令已隐去)", combo, iter, c) + } + } + + if combo.upper && !hasUpper { + t.Fatalf("组合 %+v 第 %d 次: 缺少大写字母", combo, iter) + } + if combo.lower && !hasLower { + t.Fatalf("组合 %+v 第 %d 次: 缺少小写字母", combo, iter) + } + if combo.digits && !hasDigit { + t.Fatalf("组合 %+v 第 %d 次: 缺少数字", combo, iter) + } + if combo.symbols && !hasSymbol { + t.Fatalf("组合 %+v 第 %d 次: 缺少符号", combo, iter) + } + // 未选中的类别不应出现(因为字符集只由选中类别构成)。 + if !combo.upper && hasUpper { + t.Fatalf("组合 %+v 第 %d 次: 出现了未选中的大写字母", combo, iter) + } + if !combo.lower && hasLower { + t.Fatalf("组合 %+v 第 %d 次: 出现了未选中的小写字母", combo, iter) + } + if !combo.digits && hasDigit { + t.Fatalf("组合 %+v 第 %d 次: 出现了未选中的数字", combo, iter) + } + if !combo.symbols && hasSymbol { + t.Fatalf("组合 %+v 第 %d 次: 出现了未选中的符号", combo, iter) + } + } } } -func TestVerifyPassword(t *testing.T) { - // 测试验证密码 - password := "test_password" - salt := "test_salt" - - // 正确的密码应该验证通过 - hash := HashPassword(password, salt) - if !VerifyPassword(password, salt, hash) { - t.Errorf("正确的密码验证失败") +// TestRandomPasswordShuffle 校验洗牌生效:首个字符的类别不应固定。 +// 若实现只做"每类先取一个再顺序拼接",则首位必然是大写字母,本测试会失败。 +func TestRandomPasswordShuffle(t *testing.T) { + firstIsUpper := 0 + const iterations = 200 + for i := 0; i < iterations; i++ { + pw, err := utilsx.RandomPassword(16, true, true, true, true) + if err != nil { + t.Fatalf("RandomPassword 返回错误: %v", err) + } + if pw[0] >= 'A' && pw[0] <= 'Z' { + firstIsUpper++ + } } - - // 错误的密码应该验证失败 - if VerifyPassword("wrong_password", salt, hash) { - t.Errorf("错误的密码验证通过") - } - - // 错误的盐应该验证失败 - if VerifyPassword(password, "wrong_salt", hash) { - t.Errorf("错误的盐验证通过") + // 若未洗牌则 firstIsUpper == iterations;4 类均匀时期望约 1/4。 + if firstIsUpper == iterations { + t.Error("首位始终是大写字母,说明 Fisher-Yates 洗牌未生效") } } -// 基准测试 -func BenchmarkGenerateRandomString(b *testing.B) { - for i := 0; i < b.N; i++ { - GenerateRandomString(16) +// TestRandomTokenExhaustiveLengths 穷举 n 从 MinTokenLength 到 1024, +// 断言返回值长度恰为 n、字符集合法,并且在 n 为合法无填充 Base64 长度 +// (n%4 != 1)时一定可以解回字节。 +func TestRandomTokenExhaustiveLengths(t *testing.T) { + const maxLen = 1024 + for n := utilsx.MinTokenLength; n <= maxLen; n++ { + got, err := utilsx.RandomToken(n) + if err != nil { + t.Fatalf("RandomToken(%d) 返回错误: %v", n, err) + } + if len(got) != n { + t.Fatalf("RandomToken(%d) 长度 = %d", n, len(got)) + } + assertOnlyCharset(t, got, urlSafeAlphabet) + if n%4 == 1 { + continue // 该长度在无填充 Base64 中不合法,见 RandomToken 文档 + } + if _, err := base64.RawURLEncoding.DecodeString(got); err != nil { + t.Fatalf("RandomToken(%d)(n%%4=%d)结果不是合法 RawURLEncoding: %v", n, n%4, err) + } } } -func BenchmarkGenerateRandomBytes(b *testing.B) { - for i := 0; i < b.N; i++ { - GenerateRandomBytes(16) +// TestRawBase64LengthConstraint 固定住"n ≡ 1 (mod 4) 不是合法无填充 Base64 长度" +// 这一事实,避免以后有人误以为 RandomToken 在该长度下可解码。 +func TestRawBase64LengthConstraint(t *testing.T) { + for _, n := range []int{13, 17, 21, 25, 33, 97} { + s := strings.Repeat("A", n) + if _, err := base64.RawURLEncoding.DecodeString(s); err == nil { + t.Errorf("长度为 %d (n%%4=%d) 的字符串不应可被 RawURLEncoding 解码", n, n%4) + } + } + // 合法长度(n%4 ∈ {0,2,3})可以解码。 + for _, n := range []int{16, 18, 19, 20, 22, 23, 32} { + s := strings.Repeat("A", n) + if _, err := base64.RawURLEncoding.DecodeString(s); err != nil { + t.Errorf("长度为 %d (n%%4=%d) 的字符串应可被 RawURLEncoding 解码: %v", n, n%4, err) + } } } -func BenchmarkCalculateHash(b *testing.B) { - input := "benchmark test string" - for i := 0; i < b.N; i++ { - CalculateHash(input) +// TestRandomPasswordErrors 校验类别数与长度矛盾时返回 error。 +func TestRandomPasswordErrors(t *testing.T) { + cases := []struct { + name string + length int + upper, lower, digits, symbols bool + }{ + {"长度 0", 0, true, true, true, true}, + {"长度为负", -1, true, true, true, true}, + {"长度 3 但需 4 类", 3, true, true, true, true}, + {"长度 1 但需 4 类", 1, true, true, true, true}, + {"长度 1 但需 2 类", 1, true, false, true, false}, + {"未选择任何类别", 16, false, false, false, false}, + {"未选择任何类别且长度 0", 0, false, false, false, false}, + {"长度足够但无类别", 64, false, false, false, false}, + {"超上限", (1 << 20) + 1, true, true, true, true}, + } + for _, c := range cases { + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("%s: RandomPassword 发生 panic: %v", c.name, r) + } + }() + pw, err := utilsx.RandomPassword(c.length, c.upper, c.lower, c.digits, c.symbols) + if err == nil { + t.Errorf("%s: 应返回错误,实际得到长度 %d 的口令", c.name, len(pw)) + } + if pw != "" { + t.Errorf("%s: 出错时应返回空串", c.name) + } + }() + } + + // 恰好等于类别数的长度必须成功。 + pw, err := utilsx.RandomPassword(4, true, true, true, true) + if err != nil { + t.Fatalf("长度等于类别数应成功: %v", err) + } + if len(pw) != 4 { + t.Errorf("长度 = %d, 期望 4", len(pw)) } } -func BenchmarkGenerateUUID(b *testing.B) { - for i := 0; i < b.N; i++ { - GenerateUUID() +// TestUUID 校验 RFC 4122 v4 的格式、版本位、变体位与随机性。 +func TestUUID(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 500; i++ { + id, err := utilsx.UUID() + if err != nil { + t.Fatalf("UUID 返回错误: %v", err) + } + if len(id) != 36 { + t.Fatalf("UUID 长度 = %d, 期望 36(%q)", len(id), id) + } + if id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' { + t.Fatalf("UUID 连字符位置错误: %q", id) + } + if strings.Count(id, "-") != 4 { + t.Fatalf("UUID 连字符数量错误: %q", id) + } + // 第 15 个字符(索引 14)必须是版本号 4。 + if id[14] != '4' { + t.Fatalf("UUID 版本位错误: 索引 14 = %q, 期望 '4'(%q)", id[14], id) + } + // 第 20 个字符(索引 19)必须属于 8/9/a/b(RFC 4122 变体 10xx)。 + if !strings.ContainsRune("89ab", rune(id[19])) { + t.Fatalf("UUID 变体位错误: 索引 19 = %q, 期望 8/9/a/b(%q)", id[19], id) + } + if id != strings.ToLower(id) { + t.Fatalf("UUID 应为小写: %q", id) + } + // 去掉连字符后必须是 32 位十六进制。 + if _, err := hex.DecodeString(strings.ReplaceAll(id, "-", "")); err != nil { + t.Fatalf("UUID 主体不是合法十六进制: %v(%q)", err, id) + } + if seen[id] { + t.Fatalf("UUID 出现重复: %q", id) + } + seen[id] = true } } -func BenchmarkGenerateAPIKey(b *testing.B) { - for i := 0; i < b.N; i++ { - GenerateAPIKey("") +// TestAPIKey 校验前缀处理与令牌长度。 +func TestAPIKey(t *testing.T) { + const n = 32 + + key, err := utilsx.APIKey("sk.", n) + if err != nil { + t.Fatalf("APIKey 返回错误: %v", err) + } + if !strings.HasPrefix(key, "sk.") { + t.Errorf("APIKey 未包含前缀: %q", key) + } + if len(key) != len("sk.")+n { + t.Errorf("APIKey 长度 = %d, 期望 %d", len(key), len("sk.")+n) + } + assertOnlyCharset(t, strings.TrimPrefix(key, "sk."), urlSafeAlphabet) + if strings.Contains(key, "=") { + t.Errorf("APIKey 不应含填充: %q", key) + } + + // 空前缀:直接返回令牌。 + plain, err := utilsx.APIKey("", n) + if err != nil { + t.Fatalf("APIKey(空前缀) 返回错误: %v", err) + } + if len(plain) != n { + t.Errorf("APIKey(空前缀) 长度 = %d, 期望 %d", len(plain), n) + } + + // 前缀仅用于人类可读的区分,长度校验只针对令牌部分。 + if _, err := utilsx.APIKey("sk.", 0); err == nil { + t.Error("APIKey 令牌长度为 0 应返回错误") + } + if _, err := utilsx.APIKey("sk.", utilsx.MinTokenLength-1); err == nil { + t.Error("APIKey 令牌长度低于 MinTokenLength 应返回错误") + } + if v, err := utilsx.APIKey("sk.", -1); err == nil || v != "" { + t.Errorf("APIKey 负数长度应返回错误与空串,得到 %q, %v", v, err) + } + + assertUnique(t, 64, func() string { + v, err := utilsx.APIKey("sk.", 32) + if err != nil { + t.Fatalf("APIKey 返回错误: %v", err) + } + return v + }) +} + +// TestSecureCompare 校验常量时间比较函数的行为。 +func TestSecureCompare(t *testing.T) { + a := []byte("a-secret-value-to-compare") + b := append([]byte(nil), a...) + + if !utilsx.SecureCompare(a, b) { + t.Error("SecureCompare 对相同内容返回 false") + } + if utilsx.SecureCompare(a, []byte("a-secret-value-to-compare-x")) { + t.Error("SecureCompare 对更长内容返回 true") + } + diff := append([]byte(nil), a...) + diff[len(diff)-1] ^= 0x01 + if utilsx.SecureCompare(a, diff) { + t.Error("SecureCompare 对仅末字节不同的内容返回 true") + } + if !utilsx.SecureCompare(nil, nil) { + t.Error("SecureCompare(nil, nil) 应为 true") + } + if utilsx.SecureCompare(nil, []byte{0x00}) { + t.Error("SecureCompare(nil, 非空) 应为 false") + } + + if !utilsx.SecureCompareString("token-value", "token-value") { + t.Error("SecureCompareString 对相同内容返回 false") + } + if utilsx.SecureCompareString("token-value", "token-valuE") { + t.Error("SecureCompareString 对大小写不同的内容返回 true") + } + if utilsx.SecureCompareString("", "") != true { + t.Error("SecureCompareString(\"\", \"\") 应为 true") + } + + // 与 RandomToken 的实际使用场景一致:比较相等的令牌应为 true。 + token, err := utilsx.RandomToken(32) + if err != nil { + t.Fatalf("RandomToken 返回错误: %v", err) + } + if !utilsx.SecureCompareString(token, token) { + t.Error("SecureCompareString 对相同令牌返回 false") } } -func BenchmarkGenerateToken(b *testing.B) { - for i := 0; i < b.N; i++ { - GenerateToken(32) +// TestConstants 校验导出的长度常量为文档约定的值。 +func TestConstants(t *testing.T) { + if utilsx.MinTokenLength != 16 { + t.Errorf("MinTokenLength = %d, 期望 16", utilsx.MinTokenLength) + } + if utilsx.DefaultTokenLength != 32 { + t.Errorf("DefaultTokenLength = %d, 期望 32", utilsx.DefaultTokenLength) + } + if utilsx.DefaultTokenLength < utilsx.MinTokenLength { + t.Error("DefaultTokenLength 不应小于 MinTokenLength") } } -func BenchmarkGeneratePassword(b *testing.B) { - for i := 0; i < b.N; i++ { - GeneratePassword(16, true, true, true, true) +// TestOutputsAreNotAllSame 用固定长度检查各随机函数确实在变化(弱随机实现的兜底检查)。 +func TestOutputsAreNotAllSame(t *testing.T) { + generators := map[string]func() (string, error){ + "RandomString": func() (string, error) { return utilsx.RandomString(16) }, + "RandomHex": func() (string, error) { return utilsx.RandomHex(16) }, + "RandomBase64": func() (string, error) { return utilsx.RandomBase64(16) }, + "RandomToken": func() (string, error) { return utilsx.RandomToken(24) }, + "APIKey": func() (string, error) { return utilsx.APIKey("sk.", 24) }, + "UUID": utilsx.UUID, + } + for name, gen := range generators { + first, err := gen() + if err != nil { + t.Fatalf("%s 返回错误: %v", name, err) + } + allSame := true + for i := 0; i < 32; i++ { + v, err := gen() + if err != nil { + t.Fatalf("%s 返回错误: %v", name, err) + } + if v != first { + allSame = false + break + } + } + if allSame { + t.Errorf("%s 32 次调用返回了完全相同的结果,随机源可疑", name) + } } } -func BenchmarkHashPassword(b *testing.B) { - password := "benchmark_password" - salt := "benchmark_salt" - for i := 0; i < b.N; i++ { - HashPassword(password, salt) +// TestRandomBytesUsesCryptoRand 交叉验证:用 crypto/rand 自身生成的字节 +// 与 RandomBytes 的输出空间应当一致(都覆盖全字节值,排除 math/rand 之外的弱源)。 +func TestRandomBytesStatisticalSmoke(t *testing.T) { + buf, err := utilsx.RandomBytes(4096) + if err != nil { + t.Fatalf("RandomBytes 返回错误: %v", err) + } + var counts [256]int + for _, b := range buf { + counts[b]++ + } + distinct := 0 + for _, c := range counts { + if c > 0 { + distinct++ + } + } + // 4096 字节取样下几乎必然覆盖 256 个值中的绝大多数;此处用宽松阈值避免偶发失败。 + if distinct < 200 { + t.Errorf("4096 字节中只出现 %d 种字节值,随机性可疑", distinct) + } + if bytes.Equal(buf, make([]byte, len(buf))) { + t.Error("RandomBytes 返回了全零内容") + } + + // 与 crypto/rand 直接读取的结果长度一致(仅作 API 对照,不比较内容)。 + ref := make([]byte, 8) + if _, err := rand.Read(ref); err != nil { + t.Fatalf("crypto/rand 读取失败: %v", err) + } + if len(ref) != 8 { + t.Error("crypto/rand 对照读取长度异常") + } +} + +// assertUnique 校验 gen 连续调用 count 次都不产生重复值。 +func assertUnique(t *testing.T, count int, gen func() string) { + t.Helper() + seen := make(map[string]struct{}, count) + for i := 0; i < count; i++ { + v := gen() + if _, ok := seen[v]; ok { + t.Fatalf("第 %d 次调用产生了重复值(长度 %d)", i, len(v)) + } + seen[v] = struct{}{} + } +} + +// assertOnlyCharset 校验字符串只包含给定字符集中的字符。 +func assertOnlyCharset(t *testing.T, s, charset string) { + t.Helper() + for i := 0; i < len(s); i++ { + if strings.IndexByte(charset, s[i]) < 0 { + t.Fatalf("字符串含字符集外的字符 %q", s[i]) + } } }