- Increase minimum iterations from 2 to 3 (OWASP minimum) - Increase minimum memory from 16MB to 19MB (19456KB, OWASP minimum) - Update comments to document the OWASP rationale Fixes: SEC-ARGON2
239 lines
7.1 KiB
Go
239 lines
7.1 KiB
Go
package auth
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/subtle"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"golang.org/x/crypto/argon2"
|
||
"golang.org/x/crypto/bcrypt"
|
||
)
|
||
|
||
var defaultPasswordManager = NewPassword()
|
||
|
||
// Password 密码管理器(Argon2id)
|
||
type Password struct {
|
||
memory uint32
|
||
iterations uint32
|
||
parallelism uint8
|
||
saltLength uint32
|
||
keyLength uint32
|
||
}
|
||
|
||
// NewPassword 创建密码管理器
|
||
func NewPassword() *Password {
|
||
return &Password{
|
||
memory: 64 * 1024, // 64MB(符合 OWASP 建议)
|
||
iterations: 5, // 5 次迭代(保守值,高于 OWASP 建议的 3)
|
||
parallelism: 4, // 4 并行(符合 OWASP 建议,防御 GPU 破解)
|
||
saltLength: 16, // 16 字节盐(符合 OWASP 最低要求)
|
||
keyLength: 32, // 32 字节密钥
|
||
}
|
||
}
|
||
|
||
// CalibrateArgon2id 在当前机器上自动校准 Argon2id 参数,确保单次哈希时间不超过 budget。
|
||
//
|
||
// 校准策略(优先保留 memory,其次降低 iterations):
|
||
// 1. 用默认参数(64MB/5iter)测量一次哈希耗时。
|
||
// 2. 若耗时 ≤ budget,直接返回:默认参数已安全。
|
||
// 3. 若耗时 > budget,先尝试降低 iterations(最低 3,OWASP 最低要求)。
|
||
// 4. 若仍超预算,再二分降低 memory(最低 19MB = 19456KB,OWASP 最低要求)。
|
||
// 5. 若仍超预算,打印 warn 但不更改参数(避免参数过弱)。
|
||
//
|
||
// 建议在 main() 启动阶段调用一次,结果会更新全局 defaultPasswordManager。
|
||
// budget 推荐值:500ms(登录接口 P99 目标 < 1000ms,留出网络/DB 余量)。
|
||
func CalibrateArgon2id(budget time.Duration) {
|
||
if budget <= 0 {
|
||
budget = 500 * time.Millisecond
|
||
}
|
||
|
||
probe := func(mem uint32, iter uint32, par uint8) time.Duration {
|
||
salt := make([]byte, 16)
|
||
_, _ = rand.Read(salt)
|
||
start := time.Now()
|
||
_ = argon2.IDKey([]byte("calibration-probe"), salt, iter, mem, par, 32)
|
||
return time.Since(start)
|
||
}
|
||
|
||
mem := defaultPasswordManager.memory
|
||
iter := defaultPasswordManager.iterations
|
||
par := defaultPasswordManager.parallelism
|
||
|
||
elapsed := probe(mem, iter, par)
|
||
log.Printf("argon2id calibration: default params (m=%dKB, t=%d, p=%d) → %v", mem, iter, par, elapsed)
|
||
|
||
if elapsed <= budget {
|
||
log.Printf("argon2id calibration: default params are within budget (%v ≤ %v), no adjustment needed", elapsed, budget)
|
||
return
|
||
}
|
||
|
||
// Step 1:尝试降低 iterations(最低 3,OWASP 最低要求)
|
||
for iter > 3 {
|
||
iter--
|
||
elapsed = probe(mem, iter, par)
|
||
log.Printf("argon2id calibration: trying m=%dKB t=%d p=%d → %v", mem, iter, par, elapsed)
|
||
if elapsed <= budget {
|
||
break
|
||
}
|
||
}
|
||
|
||
// Step 2:若仍超预算,二分降低 memory(最低 19MB = 19456 KiB,OWASP 最低要求)
|
||
if elapsed > budget {
|
||
const minMem = 19 * 1024
|
||
for mem > minMem && elapsed > budget {
|
||
mem /= 2
|
||
if mem < minMem {
|
||
mem = minMem
|
||
}
|
||
elapsed = probe(mem, iter, par)
|
||
log.Printf("argon2id calibration: trying m=%dKB t=%d p=%d → %v", mem, iter, par, elapsed)
|
||
}
|
||
}
|
||
|
||
if elapsed > budget {
|
||
log.Printf("argon2id calibration: WARN — even minimum params (m=%dKB, t=%d) take %v > %v; check server load", mem, iter, elapsed, budget)
|
||
// 不降低到不安全参数,保持当前已尝试的最低值
|
||
} else {
|
||
log.Printf("argon2id calibration: adjusted params m=%dKB t=%d p=%d → %v (budget: %v)", mem, iter, par, elapsed, budget)
|
||
}
|
||
|
||
// 更新全局默认管理器(仅在此阶段修改,后续不再变更)
|
||
defaultPasswordManager.memory = mem
|
||
defaultPasswordManager.iterations = iter
|
||
}
|
||
|
||
|
||
// Hash 哈希密码(使用Argon2id + 随机盐)
|
||
func (p *Password) Hash(password string) (string, error) {
|
||
// 使用 crypto/rand 生成真正随机的盐
|
||
salt := make([]byte, p.saltLength)
|
||
if _, err := rand.Read(salt); err != nil {
|
||
return "", fmt.Errorf("生成随机盐失败: %w", err)
|
||
}
|
||
|
||
// 使用Argon2id哈希密码
|
||
hash := argon2.IDKey(
|
||
[]byte(password),
|
||
salt,
|
||
p.iterations,
|
||
p.memory,
|
||
p.parallelism,
|
||
p.keyLength,
|
||
)
|
||
|
||
// 格式: $argon2id$v=<version>$m=<memory>,t=<iterations>,p=<parallelism>$<salt_hex>$<hash_hex>
|
||
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||
argon2.Version,
|
||
p.memory,
|
||
p.iterations,
|
||
p.parallelism,
|
||
hex.EncodeToString(salt),
|
||
hex.EncodeToString(hash),
|
||
)
|
||
|
||
return encoded, nil
|
||
}
|
||
|
||
// Verify 验证密码
|
||
func (p *Password) Verify(hashedPassword, password string) bool {
|
||
// 支持 bcrypt 格式(兼容旧数据)
|
||
if strings.HasPrefix(hashedPassword, "$2a$") || strings.HasPrefix(hashedPassword, "$2b$") {
|
||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||
return err == nil
|
||
}
|
||
|
||
// 解析 Argon2id 格式
|
||
parts := strings.Split(hashedPassword, "$")
|
||
// 格式: ["", "argon2id", "v=<version>", "m=<mem>,t=<iter>,p=<par>", "<salt_hex>", "<hash_hex>"]
|
||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||
return false
|
||
}
|
||
|
||
// 解析参数
|
||
var memory, iterations uint32
|
||
var parallelism uint8
|
||
params := strings.Split(parts[3], ",")
|
||
if len(params) != 3 {
|
||
return false
|
||
}
|
||
for _, param := range params {
|
||
kv := strings.SplitN(param, "=", 2)
|
||
if len(kv) != 2 {
|
||
return false
|
||
}
|
||
val, err := strconv.ParseUint(kv[1], 10, 64)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
switch kv[0] {
|
||
case "m":
|
||
// #nosec G115 - argon2 memory param is constrained by spec to reasonable values
|
||
memory = uint32(val) // #nosec G115
|
||
case "t":
|
||
// #nosec G115 - argon2 iterations param is constrained by spec to reasonable values
|
||
iterations = uint32(val) // #nosec G115
|
||
case "p":
|
||
// #nosec G115 - argon2 parallelism param is constrained by spec to reasonable values
|
||
parallelism = uint8(val) // #nosec G115
|
||
}
|
||
}
|
||
|
||
// 解码盐和存储的哈希
|
||
salt, err := hex.DecodeString(parts[4])
|
||
if err != nil {
|
||
return false
|
||
}
|
||
storedHash, err := hex.DecodeString(parts[5])
|
||
if err != nil {
|
||
return false
|
||
}
|
||
|
||
// 用相同参数重新计算哈希
|
||
// #nosec G115 - bcrypt hash is typically 60 chars, fits in uint32
|
||
computedHash := argon2.IDKey(
|
||
[]byte(password),
|
||
salt,
|
||
iterations,
|
||
memory,
|
||
parallelism,
|
||
uint32(len(storedHash)), // #nosec G115
|
||
)
|
||
|
||
// 常数时间比较,防止时序攻击
|
||
return subtle.ConstantTimeCompare(storedHash, computedHash) == 1
|
||
}
|
||
|
||
// HashPassword hashes passwords with Argon2id for new credentials.
|
||
func HashPassword(password string) (string, error) {
|
||
return defaultPasswordManager.Hash(password)
|
||
}
|
||
|
||
// VerifyPassword verifies both Argon2id and legacy bcrypt password hashes.
|
||
func VerifyPassword(hashedPassword, password string) bool {
|
||
return defaultPasswordManager.Verify(hashedPassword, password)
|
||
}
|
||
|
||
// ErrInvalidPassword 密码无效错误
|
||
var ErrInvalidPassword = errors.New("密码无效")
|
||
|
||
// BcryptHash 使用bcrypt哈希密码(兼容性支持)
|
||
func BcryptHash(password string) (string, error) {
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
return "", fmt.Errorf("bcrypt加密失败: %w", err)
|
||
}
|
||
return string(hash), nil
|
||
}
|
||
|
||
// BcryptVerify 使用bcrypt验证密码
|
||
func BcryptVerify(hashedPassword, password string) bool {
|
||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||
return err == nil
|
||
}
|