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