Go 密码哈希完整安全指南:bcrypt、Argon2、盐值与时序攻击防范

深入对比 bcrypt、argon2、scrypt 和 pbkdf2 四种密码哈希算法,讲解 Go 中各算法的使用方法、盐值与 pepper 原理、密码验证流程、时序攻击防范技术、密码策略与会话管理,以及 OAuth2/OIDC 替代方案。

密码不是普通字符串

做用户系统时,最危险的错误之一是把密码当普通字符串存进数据库。一旦数据库泄漏,所有用户密码直接暴露。更糟的是,很多用户会在多个网站重复使用同一个密码,你的安全事件可能波及他们在其他服务上的账号。

正确做法不是加密后保存,而是保存密码哈希。哈希是单向的:注册时把用户输入的密码变成哈希值存储,登录时用用户输入的密码重新计算哈希并与存储值比较。即使数据库泄漏,攻击者拿到的只是哈希值,无法直接得知原始密码。

密码哈希不能用普通的快速哈希函数,比如 MD5、SHA1、SHA256。这些函数设计目标是计算速度越快越好,而密码哈希恰恰相反:它应该足够慢,让暴力破解不可行。 bcrypt、scrypt、Argon2 这类专门为密码设计的算法才是正确选择。Go 生态中,golang.org/x/crypto/bcrypt 是最常用的密码哈希库,Argon2 和 scrypt 同样在 x/crypto 扩展库中提供。

四种密码哈希算法对比

算法主要特点推荐场景时间成本可调
bcrypt基于 Blowfish 密码,最成熟通用 Web 应用通过 cost 参数
scrypt内存困难型,抗 GPU/ASIC加密货币钱包通过 N/r/p 参数
Argon2密码哈希竞赛冠军,现代首选新系统首选通过 time/memory/parallelism
pbkdf2NIST 推荐,简单广泛需要标准合规通过迭代次数

对于 2024 年的新项目,Argon2 是密码哈希算法委员会推荐的首选。bcrypt 仍然完全可用,生态成熟稳定。scrypt 适合需要额外内存困难性的场景。pbkdf2 主要在需要 NIST/FIPS 合规时使用。

bcrypt 实战详解

安装依赖

go get golang.org/x/crypto/bcrypt

生成密码哈希

package auth

import (
    "fmt"
    "strings"

    "golang.org/x/crypto/bcrypt"
)

func HashPassword(password string) (string, error) {
    password = strings.TrimSpace(password)
    if len(password) < 8 {
        return "", fmt.Errorf("password must be at least 8 characters")
    }
    if len(password) > 72 {
        return "", fmt.Errorf("password exceeds maximum length")
    }

    hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return "", fmt.Errorf("hash password: %w", err)
    }
    return string(hash), nil
}

bcrypt.DefaultCost 当前值为 10,表示进行 2^10 = 1024 轮哈希计算。成本参数可以调整:数值越高,计算越慢,安全性越高,但服务器登录响应也越慢。bcrypt 的设计将成本参数直接编码在哈希输出中,因此即使后期调整默认值,旧哈希仍然可以正常校验。

校验密码

func CheckPassword(hash string, password string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    return err == nil
}

完整注册与登录流程:

type AuthService struct {
    store UserStore
}

func (s *AuthService) Register(ctx context.Context, req RegisterRequest) error {
    hash, err := HashPassword(req.Password)
    if err != nil {
        return fmt.Errorf("invalid password: %w", err)
    }

    user := User{
        Email:        req.Email,
        PasswordHash: hash,
        CreatedAt:    time.Now(),
    }

    return s.store.Create(ctx, user)
}

func (s *AuthService) Login(ctx context.Context, req LoginRequest) (string, error) {
    user, err := s.store.FindByEmail(ctx, req.Email)
    if err != nil {
        return "", fmt.Errorf("invalid email or password")
    }

    if !CheckPassword(user.PasswordHash, req.Password) {
        return "", fmt.Errorf("invalid email or password")
    }

    return s.createSession(ctx, user)
}

关键安全实践:错误消息不要区分邮箱不存在和密码错误,否则攻击者可以通过接口枚举已注册邮箱。

Argon2 实战详解

Argon2 是 2015 年密码哈希竞赛(Password Hashing Competition)的冠军,在 CPU 和内存资源消耗上都有良好的可配置性。Go 的 x/crypto/argon2 包提供了三种变体:Argon2d(优化抵抗 GPU 破解)、Argon2i(优化抵抗侧信道攻击)、Argon2id(混合推荐)。

Argon2id 注册实现

package auth

import (
    "crypto/rand"
    "crypto/subtle"
    "encoding/base64"
    "fmt"
    "strings"

    "golang.org/x/crypto/argon2"
)

type Argon2Params struct {
    Memory      uint32
    Iterations  uint32
    Parallelism uint8
    SaltLength  uint32
    KeyLength   uint32
}

var DefaultParams = Argon2Params{
    Memory:      64 * 1024, // 64 MB
    Iterations:  3,
    Parallelism: 4,
    SaltLength:  16,
    KeyLength:   32,
}

func HashPasswordArgon2(password string) (string, error) {
    password = strings.TrimSpace(password)
    if len(password) < 8 {
        return "", fmt.Errorf("password must be at least 8 characters")
    }

    salt := make([]byte, DefaultParams.SaltLength)
    if _, err := rand.Read(salt); err != nil {
        return "", fmt.Errorf("generate salt: %w", err)
    }

    hash := argon2.IDKey([]byte(password), salt,
        DefaultParams.Iterations,
        DefaultParams.Memory,
        DefaultParams.Parallelism,
        DefaultParams.KeyLength)

    // 编码为 modular crypt format
    b64Salt := base64.RawStdEncoding.EncodeToString(salt)
    b64Hash := base64.RawStdEncoding.EncodeToString(hash)

    encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
        argon2.Version,
        DefaultParams.Memory,
        DefaultParams.Iterations,
        DefaultParams.Parallelism,
        b64Salt,
        b64Hash)

    return encodedHash, nil
}

Argon2id 校验实现

func CheckPasswordArgon2(hash string, password string) (bool, error) {
    parts := strings.Split(hash, "$")
    if len(parts) != 6 {
        return false, fmt.Errorf("invalid hash format")
    }

    var version int
    _, err := fmt.Sscanf(parts[2], "v=%d", &version)
    if err != nil {
        return false, err
    }

    var memory uint32
    var iterations uint32
    var parallelism uint8
    _, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, &parallelism)
    if err != nil {
        return false, err
    }

    salt, err := base64.RawStdEncoding.DecodeString(parts[4])
    if err != nil {
        return false, err
    }

    expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
    if err != nil {
        return false, err
    }

    computedHash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(len(expectedHash)))

    if subtle.ConstantTimeCompare(computedHash, expectedHash) == 1 {
        return true, nil
    }
    return false, nil
}

scrypt 和 pbkdf2 的使用

scrypt

import "golang.org/x/crypto/scrypt"

func HashPasswordScrypt(password string) (string, error) {
    salt := make([]byte, 16)
    if _, err := rand.Read(salt); err != nil {
        return "", err
    }

    hash, err := scrypt.Key([]byte(password), salt, 32768, 8, 1, 32)
    if err != nil {
        return "", err
    }

    // 存储 salt 和 hash
    return fmt.Sprintf("%s:%s",
        base64.StdEncoding.EncodeToString(salt),
        base64.StdEncoding.EncodeToString(hash)), nil
}

pbkdf2

import (
    "golang.org/x/crypto/pbkdf2"
    "crypto/sha256"
)

func HashPasswordPBKDF2(password string) (string, error) {
    salt := make([]byte, 16)
    if _, err := rand.Read(salt); err != nil {
        return "", err
    }

    hash := pbkdf2.Key([]byte(password), salt, 100000, 32, sha256.New)

    return fmt.Sprintf("%s:%s",
        base64.StdEncoding.EncodeToString(salt),
        base64.StdEncoding.EncodeToString(hash)), nil
}

盐值(Salt)原理与生成

盐值是每次哈希时随机生成的数据,与密码混合后再计算哈希。它的核心作用是防御彩虹表攻击。

没有盐值的情况下,相同密码的哈希值完全相同。攻击者可以预先计算海量密码的哈希值(彩虹表),然后用哈希值直接反查。有了盐值后,即使两个用户使用完全相同的密码,它们的哈希值也不同。

Go 中生成盐值的标准做法:

func generateSalt(length int) ([]byte, error) {
    salt := make([]byte, length)
    if _, err := rand.Read(salt); err != nil {
        return nil, err
    }
    return salt, nil
}

注意:盐值不需要保密,通常与哈希值一起存储。但每个密码应该有独立的随机盐值。

Pepper 值的应用

Pepper 是存储在代码或配置中的全局密钥,与密码一起参与哈希计算。与盐值不同:

  • 盐值:每个用户不同,与哈希一起存储
  • Pepper:全系统统一,不存储在数据库中

Pepper 的作用是:即使数据库泄漏,没有 pepper 的攻击者也无法验证密码。

var pepper = os.Getenv("PASSWORD_PEPPER")

func HashPasswordWithPepper(password string) (string, error) {
    peppered := password + pepper
    return HashPassword(peppered)
}

func CheckPasswordWithPepper(hash string, password string) bool {
    peppered := password + pepper
    return CheckPassword(hash, peppered)
}

Pepper 丢失(如配置变更未同步)会导致所有密码无法验证。因此 pepper 应该与备份策略一起考虑。

时序攻击(Timing Attack)防范

时序攻击是密码验证中的一个微妙漏洞。如果校验函数在发现字符不匹配时提前返回,攻击者可以通过测量响应时间差异来逐字节推断密码。

bcrypt 的 CompareHashAndPassword 内部已经实现了常数时间比较。但如果你手动比较哈希值,务必使用 crypto/subtle 包:

import "crypto/subtle"

// 正确:常数时间比较
if subtle.ConstantTimeCompare(computedHash, storedHash) == 1 {
    // 匹配
}

// 错误:非常数时间,存在时序攻击风险
if string(computedHash) == string(storedHash) {
    // 匹配
}

对于早期返回的情况,可以强制固定响应时间:

func CheckPasswordWithDelay(hash string, password string) bool {
    start := time.Now()
    result := CheckPassword(hash, password)

    // 保证至少 100ms 的响应时间
    elapsed := time.Since(start)
    if elapsed < 100*time.Millisecond {
        time.Sleep(100*time.Millisecond - elapsed)
    }

    return result
}

密码策略与会话管理

密码复杂度策略

func ValidatePassword(password string) error {
    if len(password) < 12 {
        return fmt.Errorf("password must be at least 12 characters")
    }
    if len(password) > 128 {
        return fmt.Errorf("password is too long")
    }

    var hasUpper, hasLower, hasDigit, hasSpecial bool
    for _, c := range password {
        switch {
        case c >= 'A' && c <= 'Z':
            hasUpper = true
        case c >= 'a' && c <= 'z':
            hasLower = true
        case c >= '0' && c <= '9':
            hasDigit = true
        default:
            hasSpecial = true
        }
    }

    score := 0
    if hasUpper { score++ }
    if hasLower { score++ }
    if hasDigit { score++ }
    if hasSpecial { score++ }

    if score < 3 {
        return fmt.Errorf("password must contain at least 3 of: uppercase, lowercase, digits, special characters")
    }

    return nil
}

会话管理最佳实践

type Session struct {
    ID        string
    UserID    int64
    CreatedAt time.Time
    ExpiresAt time.Time
    IP        string
    UserAgent string
}

func (s *AuthService) CreateSession(ctx context.Context, user User) (string, error) {
    token := generateSecureToken(32)
    session := Session{
        ID:        token,
        UserID:    user.ID,
        CreatedAt: time.Now(),
        ExpiresAt: time.Now().Add(24 * time.Hour),
        IP:        getClientIP(ctx),
        UserAgent: getUserAgent(ctx),
    }

    if err := s.store.SaveSession(ctx, session); err != nil {
        return "", err
    }

    return token, nil
}

func generateSecureToken(length int) string {
    b := make([]byte, length)
    if _, err := rand.Read(b); err != nil {
        panic(err)
    }
    return base64.URLEncoding.EncodeToString(b)
}

密码重置的安全流程:

type PasswordReset struct {
    UserID    int64
    TokenHash string
    ExpiresAt time.Time
    Used      bool
}

func (s *AuthService) CreatePasswordReset(ctx context.Context, userID int64) (string, error) {
    token := generateSecureToken(32)
    tokenHash := sha256.Sum256([]byte(token))

    reset := PasswordReset{
        UserID:    userID,
        TokenHash: hex.EncodeToString(tokenHash[:]),
        ExpiresAt: time.Now().Add(1 * time.Hour),
        Used:      false,
    }

    if err := s.store.SavePasswordReset(ctx, reset); err != nil {
        return "", err
    }

    return token, nil
}

OAuth2/OIDC 替代方案

自建密码系统始终存在风险。对于面向用户的应用,考虑使用 OAuth2/OIDC 委托认证:

func oauth2Callback(w http.ResponseWriter, r *http.Request) {
    code := r.URL.Query().Get("code")

    token, err := oauthConfig.Exchange(r.Context(), code)
    if err != nil {
        http.Error(w, "exchange failed", http.StatusBadRequest)
        return
    }

    client := oauthConfig.Client(r.Context(), token)
    resp, err := client.Get("https://openidconnect.googleapis.com/v1/userinfo")
    if err != nil {
        http.Error(w, "get userinfo failed", http.StatusInternalServerError)
        return
    }
    defer resp.Body.Close()

    var userInfo struct {
        Email string `json:"email"`
        Name  string `json:"name"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
        http.Error(w, "decode failed", http.StatusInternalServerError)
        return
    }

    // 查找或创建用户,创建会话
}

使用 OAuth2/OIDC 的好处包括:

  • 不存储用户密码
  • 利用身份提供商的安全基础设施
  • 支持多因素认证(MFA)
  • 简化密码重置流程

日志与错误处理

密码永远不应该出现在日志中:

// 正确:只记录安全信息
log.Printf("login failed email=%s ip=%s", req.Email, clientIP)

// 错误:将密码写入日志
log.Printf("login failed password=%s", req.Password)

密码只应该在请求处理的极短时间内存在于内存中,不应进入:

  • 日志系统
  • 错误消息(返回给客户端)
  • 事件队列
  • 分析系统
  • 内存 dump

不要自己设计密码算法

以下做法绝对不可取:

// 危险:快速哈希不适合密码
sum := sha256.Sum256([]byte(password))
hash := hex.EncodeToString(sum[:])

// 危险:自创新方案
hash := sha256(password + salt + "mysecret" + strconv.Itoa(iterations))

密码学的黄金法则是:不要自己发明密码算法,使用经过充分审查和维护的成熟库。

小结

密码处理是每个用户系统的安全底线。使用 bcrypt 或 Argon2 进行密码哈希,为每个密码生成独立的随机盐值,考虑使用 pepper 增加额外安全层,使用常数时间比较防止时序攻击,实施合理的密码复杂度策略和会话管理。

安全代码的第一步不是复杂,而是避免明显错误。把密码当作最敏感的数据处理,使用成熟的库而不是自创方案,不在日志中暴露密码,统一错误消息防止信息泄漏。这些基础实践做好了,你的用户系统的安全基础就建立起来了。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练