// 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 }