完善不同加密方法的使用

This commit is contained in:
yun
2026-09-30 15:49:12 +08:00
parent 034315ddbd
commit 7db722f8b9
69 changed files with 21362 additions and 2563 deletions
+136 -11
View File
@@ -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
}
+183 -8
View File
@@ -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("非法哈希应当需要重算")
}
}