Files
encryptx/argon2x/argon2x.go
T
2026-09-30 15:49:12 +08:00

366 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}