Files
2026-09-30 15:49:12 +08:00

199 lines
8.1 KiB
Go
Raw Permalink 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 scryptx 基于 scrypt(RFC 7914)提供内存硬的口令密钥派生与校验。
//
// scrypt 通过同时消耗 CPU 与内存来提高离线暴力破解成本,适合口令存储。
//
// 安全注意事项:
// - 选型顺序:口令哈希首选 argon2x(Argon2id);scrypt 是同等可用的备选,
// 二者都远优于纯 CPU 代价的 pbkdf2x。
// - 内存开销约为 128*r*(n+p) 字节,且该内存必须真实分配才具备抗 ASIC 能力。
// 默认 DefaultN=1<<15、DefaultR=8、DefaultP=1 时约 32 MiB/次,适合交互式
// 登录;服务器内存充裕时应上调到 OWASP 建议的 N=1<<17、r=8、p=1(约 128 MiB)。
// - N 必须是大于 1 的 2 的幂,且受 MaxN 限制;n/r/p 组合折算出的内存也不得超过
// 上限,防止调用方误传超大参数造成 OOM(进程被杀死,无法恢复)。
// - 盐必须是密码学安全随机数、每个口令独立,长度不低于 MinSaltLength 字节。
// - 派生密钥与口令都是敏感数据,调用方负责及时清零,且不得写入日志或错误文本。
//
// 本包任何情况下都不会 panic:所有非法参数都返回 error。
package scryptx
import (
"crypto/rand"
"crypto/subtle"
"fmt"
"io"
"golang.org/x/crypto/scrypt"
)
const (
// DefaultN 是默认的 CPU/内存代价参数,取 1<<15(32768)。调大 N 会同时
// 提高 CPU 时间与内存占用,是抵御 GPU/ASIC 的主要旋钮。
DefaultN = 1 << 15
// DefaultR 是默认的块大小参数,8 对应 1024 字节的块;r 越大,内存占用
// 与内存访问延迟越高。
DefaultR = 8
// DefaultP 是默认的并行化参数,1 表示单线程完成一次派生。
DefaultP = 1
// MaxN 是允许的最大 N(1<<20)。N=1<<20、r=8 时单次派生需要约 1 GiB 内存,
// 该上限用于拦截调用方传入超大 N 导致的 OOM(进程被杀死,无法恢复)。
MaxN = 1 << 20
// DefaultSaltLength 是推荐的盐长度(字节),16 字节足以避免彩虹表碰撞。
DefaultSaltLength = 16
// MinSaltLength 是允许的最小盐长度(字节),更短的盐会显著提高碰撞概率。
MinSaltLength = 8
// DefaultKeyLength 是推荐的派生密钥长度(字节),32 字节可直接作为
// AES-256 或 HMAC-SHA256 的密钥。
DefaultKeyLength = 32
// MaxKeyLength 是允许的最大派生密钥长度(字节),用于拦截把缓冲区大小
// 误当输出长度传入的调用。
MaxKeyLength = 1024
)
const (
// maxSaltLength 是允许的最大盐长度(字节)。
maxSaltLength = 1024
// maxMemoryBytes 是单次派生允许申请的 scrypt 工作内存上限。默认参数
// (n=1<<15、r=8、p=1)约 32 MiB;把 n 调到 1<<20 且 r=8 时约 1 GiB,仍在
// 允许范围内,但明显异常的组合(例如 n=1<<20、r=64,约 8 GiB)会被拒绝,
// 而不是让进程因 OOM 被杀死。
maxMemoryBytes = 2 << 30
)
// GenerateSalt 返回 length 字节的密码学安全随机盐,供 DeriveKey 使用。
//
// length 必须落在 [MinSaltLength, 1024] 内,推荐 DefaultSaltLength。每个口令
// 必须使用独立的新盐,并与派生结果一起保存;盐是公开值,缺失它无法复现密钥。
// 随机源不可用时返回 error,绝不退化为可预测的盐。
func GenerateSalt(length int) ([]byte, error) {
if length < MinSaltLength {
return nil, fmt.Errorf("scryptx: 盐长度 %d 小于最小值 %d", length, MinSaltLength)
}
if length > maxSaltLength {
return nil, fmt.Errorf("scryptx: 盐长度 %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("scryptx: 读取随机盐失败: %w", err)
}
return salt, nil
}
// DeriveKey 使用 scrypt 从 password 与 salt 派生 keyLength 字节密钥。
//
// n 必须是大于 1 的 2 的幂且不超过 MaxN;r、p 必须大于 0;salt 长度必须在
// [MinSaltLength, 1024];keyLength 必须在 [1, MaxKeyLength]。任一条件不满足
// 都返回 error,绝不 panic。
//
// 单次派生的峰值内存约为 128*r*(n+p+2) 字节,耗时随 n、r、p 线性增长。
// 默认参数(DefaultN/DefaultR/DefaultP)约需 32 MiB 内存,适合交互式场景。
func DeriveKey(password, salt []byte, n, r, p, keyLength int) ([]byte, error) {
if err := validateParams(salt, n, r, p, keyLength); err != nil {
return nil, err
}
key, err := scrypt.Key(password, salt, n, r, p, keyLength)
if err != nil {
return nil, fmt.Errorf("scryptx: 派生密钥失败: %w", err)
}
return key, nil
}
// DeriveKeyWithRandomSalt 生成 saltLength 字节随机盐并立即派生密钥,一次性返回
// (key, salt, error)。这是推荐入口:调用方必须把 salt 与 key 一起保存,否则
// 派生结果无法复现。
//
// saltLength 通常传 DefaultSaltLength;n、r、p、keyLength 的含义同 DeriveKey。
// 参数非法或随机源失败时返回 error,此时 key 与 salt 均为 nil。
func DeriveKeyWithRandomSalt(password []byte, saltLength, n, r, p, keyLength int) (key, salt []byte, err error) {
salt, err = GenerateSalt(saltLength)
if err != nil {
return nil, nil, err
}
key, err = DeriveKey(password, salt, n, r, p, 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, n, r, p, keyLength int) (bool, error) {
derived, err := DeriveKey(password, salt, n, r, p, 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
}
// validateParams 是所有派生入口共用的参数闸门,保证非法输入在进入 scrypt 之前
// 就变成明确的 error,而不是 panic、OOM 或静默截断。
func validateParams(salt []byte, n, r, p, keyLength int) error {
if len(salt) == 0 {
return fmt.Errorf("scryptx: 盐不能为空")
}
if len(salt) < MinSaltLength {
return fmt.Errorf("scryptx: 盐长度 %d 小于最小值 %d", len(salt), MinSaltLength)
}
if len(salt) > maxSaltLength {
return fmt.Errorf("scryptx: 盐长度 %d 超过最大值 %d", len(salt), maxSaltLength)
}
if n <= 1 {
return fmt.Errorf("scryptx: 代价参数 n=%d 必须大于 1", n)
}
if n&(n-1) != 0 {
return fmt.Errorf("scryptx: 代价参数 n=%d 必须是 2 的幂", n)
}
if n > MaxN {
return fmt.Errorf("scryptx: 代价参数 n=%d 超过上限 %d", n, MaxN)
}
if r <= 0 {
return fmt.Errorf("scryptx: 块大小参数 r=%d 必须大于 0", r)
}
if p <= 0 {
return fmt.Errorf("scryptx: 并行度参数 p=%d 必须大于 0", p)
}
if keyLength <= 0 {
return fmt.Errorf("scryptx: 密钥长度 %d 必须大于 0", keyLength)
}
if keyLength > MaxKeyLength {
return fmt.Errorf("scryptx: 密钥长度 %d 超过最大值 %d", keyLength, MaxKeyLength)
}
// scrypt 的峰值内存约为 128*r*(n+p+2) 字节:V 数组占 128*n*r,p 个并行单元
// 共占 128*r*p,另有 256*r 的临时区。这里统一按 (n+p) 折算,且全程只做除法,
// 避免 r 或 p 取极大值时乘法回绕绕过检查(例如 r=1、p=2^29 时 scrypt 允许
// 但实际需要约 64 GiB 内存,会直接把进程打死)。
units := uint64(n) + uint64(p) // n <= 2^20 且 p <= MaxInt,相加不会溢出 uint64
if uint64(r) > maxMemoryBytes/128/units {
return fmt.Errorf("scryptx: 参数组合 n=%d, r=%d, p=%d 需要的内存超过上限 %d 字节", n, r, p, maxMemoryBytes)
}
return nil
}