← 中文资源

简体中文版本

Argon2 口令哈希

使用 Argon2id 与随机盐哈希口令,把参数编码进 PHC 字符串,并以常量时间校验。

阅读英文原文

功能说明

口令存储不能使用普通哈希,因为攻击者可以用显卡高速穷举。Argon2id 是口令哈希竞赛的推荐算法,通过可调的内存、时间和并行度抬高单次尝试成本,同时抵抗侧信道和时间内存权衡攻击。每次哈希使用独立的随机盐,使相同口令得到不同结果。

输出采用 PHC 字符串格式,包含算法、版本、参数、盐和哈希,全部信息自描述,校验时不需要额外查询数据库字段。示例哈希一段口令并进行正确与错误两次校验。

关键代码

HashPassword 用 crypto/rand 生成十六字节盐,调用 argon2.IDKey 得到三十二字节派生密钥,再用 RawStdEncoding 编码,避免填充字符出现在格式串中。VerifyPassword 按美元符号切分,校验算法标识,解析内存、时间与并行参数,然后使用同样的参数和盐重新派生,最后用 subtle.ConstantTimeCompare 比较,防止通过比较耗时推测哈希内容。

使用注意

示例参数为六十四兆内存、一次迭代、四线程,需要按服务器规格调整,目标是单次哈希耗时约一百到五百毫秒。参数会写入哈希串,升级参数后旧哈希仍可校验,登录成功时可重新哈希并更新。校验失败与格式错误要区分日志,但对外统一返回失败。口令长度应设上限,避免超长输入消耗内存。盐和哈希都不能记录到日志。

验证方式

哈希串应以 argon2id 标识开头,打印前缀可以看到格式。正确口令校验返回 true,错误口令返回 false,两次调用 err 均为 nil。由于盐随机,同一个口令连续哈希两次得到的字符串不同,但都能通过校验;把哈希串中的参数或哈希字符改掉,校验应返回 false 或格式错误。

代码示例

package main

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

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

const (
	argonTime    = 1
	argonMemory  = 64 * 1024
	argonThreads = 4
	argonKeyLen  = 32
	argonSaltLen = 16
)

func HashPassword(password string) (string, error) {
	salt := make([]byte, argonSaltLen)
	if _, err := rand.Read(salt); err != nil {
		return "", fmt.Errorf("read salt: %w", err)
	}
	key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
	return fmt.Sprintf(
		"$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
		argonMemory,
		argonTime,
		argonThreads,
		base64.RawStdEncoding.EncodeToString(salt),
		base64.RawStdEncoding.EncodeToString(key),
	), nil
}

func VerifyPassword(password, encoded string) (bool, error) {
	parts := strings.Split(encoded, "$")
	if len(parts) != 6 || parts[1] != "argon2id" {
		return false, fmt.Errorf("unsupported hash format")
	}
	var memory uint32
	var timeCost uint32
	var threads uint8
	if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
		return false, fmt.Errorf("parse parameters: %w", err)
	}
	salt, err := base64.RawStdEncoding.DecodeString(parts[4])
	if err != nil {
		return false, fmt.Errorf("decode salt: %w", err)
	}
	want, err := base64.RawStdEncoding.DecodeString(parts[5])
	if err != nil {
		return false, fmt.Errorf("decode hash: %w", err)
	}
	got := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(want)))
	return subtle.ConstantTimeCompare(got, want) == 1, nil
}

func main() {
	encoded, err := HashPassword("correct horse battery staple")
	if err != nil {
		panic(err)
	}
	fmt.Println("encoded prefix:", encoded[:10])
	match, err := VerifyPassword("correct horse battery staple", encoded)
	if err != nil {
		panic(err)
	}
	reject, err := VerifyPassword("wrong password", encoded)
	if err != nil {
		panic(err)
	}
	fmt.Println("match:", match, "reject:", reject)
}