Files
encryptx/argon2x/argon2x.go
T

366 lines
15 KiB
Go
Raw Normal View History

2026-09-30 15:49:12 +08:00
// 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$<base64盐>$<base64哈希>
//
// 盐与哈希使用 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=<KiB>,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 解析 "<prefix><十进制整数>" 形式的字段,并拒绝前导零、正负号等
// 非规范写法,避免同一组参数存在多种字符串表示。
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
}