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

423 lines
17 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 utilsx 提供基于 crypto/rand 的随机数据与随机标识符生成工具。
//
// # 本包只使用密码学安全随机源
//
// 所有随机数都来自 crypto/rand(操作系统 CSPRNG),**绝不使用 math/rand**,
// 因此返回值可用于密钥、令牌、盐值、初始向量等安全用途。
// 若某个函数需要确定性随机(例如可复现测试),请自行注入随机源,不要改用 math/rand。
//
// # 已删除的危险 API(破坏性变更说明)
//
// 本次重写删除了以下函数,原因是它们存在真实的安全缺陷:
//
// - HashPassword(password, salt string) string:使用**单次 SHA-1** 计算口令哈希,
// 且没有迭代、没有内存硬化,GPU 每秒可尝试数十亿次,口令一旦泄露即可被离线爆破。
// - VerifyPassword(password, salt, hash string) bool:内部用 == 比较哈希,
// 属于非常量时间比较,存在时序侧信道。
// - CalculateHash(input string) string:同样是 SHA-1 的重复实现,
// 与 sha1x 包职责重叠且容易诱导误用为"口令哈希"。
//
// 口令哈希请改用 bcryptx(bcrypt)或 argon2x(Argon2id);
// 需要常量时间比较请使用本包的 SecureCompare / SecureCompareString。
//
// 旧名字 GenerateRandomString、GenerateRandomBytes、GenerateRandomHexString、
// GenerateRandomBase64String、GenerateAPIKey、GenerateToken、GeneratePassword、
// GenerateUUID 也一并删除,分别由 RandomString、RandomBytes、RandomHex、
// RandomBase64、APIKey、RandomToken、RandomPassword、UUID 取代。
//
// 另外,GenerateToken 的旧实现使用带填充的 base64.URLEncoding 再手工 TrimRight("="),
// 且未校验最小长度;新实现 RandomToken 直接使用 base64.RawURLEncoding,
// 并强制 n >= MinTokenLength。
package utilsx
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"math/big"
"strings"
)
const (
// MinTokenLength 是随机令牌建议的最小字符长度。
// 低于该长度的令牌(例如 8 字符)在离线暴力枚举下已不安全,
// 不应作为会话令牌、重置令牌或 API 密钥使用。
MinTokenLength = 16
// DefaultTokenLength 是推荐的随机令牌默认字符长度(32 字符),
// 对应约 192 位熵(URL 安全 Base64 每字符 6 位)。
DefaultTokenLength = 32
// maxRandomBytes 是单次随机字节请求的上限(1 MiB)。
// 设置上限是为了在参数被外部输入控制时避免巨额内存分配导致的拒绝服务,
// 也用于发现把"比特数/字符数"误当作"字节数"传入的调用错误。
maxRandomBytes = 1 << 20
// upperCharset 是大写字母字符集。
upperCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// lowerCharset 是小写字母字符集。
lowerCharset = "abcdefghijklmnopqrstuvwxyz"
// digitCharset 是数字字符集。
digitCharset = "0123456789"
// symbolCharset 是常用可打印符号字符集。
symbolCharset = "!@#$%^&*()-_=+[]{}|;:,.<>?"
)
var (
// errNonPositiveLength 表示请求了非正数的长度。
errNonPositiveLength = errors.New("长度必须大于 0")
// errTooLarge 表示请求的长度超过允许上限。
errTooLarge = errors.New("长度超过允许的最大值")
)
// RandomBytes 生成 n 个密码学安全随机字节。
//
// 参数:
// - n: 需要的字节数,必须满足 0 < n <= 1<<20。
//
// 返回值:长度为 n 的随机字节;参数非法或随机源失败时返回 nil 与包装后的 error。
//
// 安全注意:
// - 返回值可直接用于密钥、盐值、IV、nonce;其中的内容属于敏感材料,
// 禁止打印到 stdout/stderr 或写入日志、错误信息。
// - 上限 1<<20 字节用于防止参数受外部控制时造成内存放大(拒绝服务)。
func RandomBytes(n int) ([]byte, error) {
if n <= 0 {
return nil, fmt.Errorf("生成随机字节: %w", errNonPositiveLength)
}
if n > maxRandomBytes {
return nil, fmt.Errorf("生成随机字节: %w(最大 %d,请求 %d)", errTooLarge, maxRandomBytes, n)
}
buf := make([]byte, n)
if _, err := io.ReadFull(rand.Reader, buf); err != nil {
return nil, fmt.Errorf("生成随机字节: %w", err)
}
return buf, nil
}
// RandomString 生成由大小写字母与数字组成的随机字符串。
//
// 参数:
// - n: 目标字符数,必须满足 0 < n <= 1<<20。
//
// 返回值:长度为 n 的随机字符串;参数非法或随机源失败时返回空串与包装后的 error。
//
// 安全注意:
// - 每个字符都通过 crypto/rand.Int 在字符集范围内均匀选取,
// 内部使用拒绝采样,**不存在模偏差**(modulo bias),熵为 n*log2(62) 位。
// - 逐字符调用 rand.Int 比批量取字节慢,但避免了偏差;
// 需要高性能且可接受字符集限制时请使用 RandomToken 或 RandomBase64。
func RandomString(n int) (string, error) {
const charset = lowerCharset + upperCharset + digitCharset
return RandomStringWithCharset(n, charset)
}
// RandomStringWithCharset 使用指定字符集生成随机字符串。
//
// 参数:
// - n: 目标字符数,必须满足 0 < n <= 1<<20。
// - charset: 候选字符集,不能为空;字符是否重复会改变各类字符的出现概率,
// 传入重复字符等同于加权,请确保这是有意为之。
//
// 返回值:长度为 n 的随机字符串;参数非法或随机源失败时返回空串与包装后的 error。
//
// 安全注意:
// - 使用 crypto/rand.Int 做拒绝采样,无模偏差;请勿传入可能被攻击者影响的字符集。
// - 若要生成口令,请使用 RandomPassword,它会保证每类字符至少出现一次。
func RandomStringWithCharset(n int, charset string) (string, error) {
if n <= 0 {
return "", fmt.Errorf("生成随机字符串: %w", errNonPositiveLength)
}
if n > maxRandomBytes {
return "", fmt.Errorf("生成随机字符串: %w(最大 %d,请求 %d)", errTooLarge, maxRandomBytes, n)
}
runes := []rune(charset)
if len(runes) == 0 {
return "", fmt.Errorf("生成随机字符串: 字符集不能为空")
}
max := big.NewInt(int64(len(runes)))
out := make([]rune, n)
for i := range out {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
return "", fmt.Errorf("生成随机字符串: %w", err)
}
out[i] = runes[idx.Int64()]
}
return string(out), nil
}
// RandomHex 生成 n 字节随机数据的十六进制字符串(2n 个字符,小写)。
//
// 参数:
// - n: 随机字节数,必须满足 0 < n <= 1<<20。
//
// 返回值:长度为 2n 的小写十六进制字符串;参数非法或随机源失败时返回空串与包装后的 error。
//
// 安全注意:这是"n 字节 → 2n 字符"的编码,不要与需要 n 个字符的场景混用;
// 编码后的字符串仍是敏感明文,不要写入日志。
func RandomHex(n int) (string, error) {
buf, err := RandomBytes(n)
if err != nil {
return "", fmt.Errorf("生成随机十六进制字符串: %w", err)
}
return hex.EncodeToString(buf), nil
}
// RandomBase64 生成 n 字节随机数据的 URL 安全、无填充 Base64 字符串。
//
// 参数:
// - n: 随机字节数,必须满足 0 < n <= 1<<20。
//
// 返回值:由 base64.RawURLEncoding 编码的字符串,长度为 ceil(4n/3);
// 参数非法或随机源失败时返回空串与包装后的 error。
//
// 安全注意:输出只含 [A-Za-z0-9-_],可安全放进 URL、文件名与 JSON;
// 但仍不得写入日志。若需要**精确 n 个字符**,请使用 RandomToken。
func RandomBase64(n int) (string, error) {
buf, err := RandomBytes(n)
if err != nil {
return "", fmt.Errorf("生成随机 Base64 字符串: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// RandomToken 生成 n 个字符的 URL 安全随机令牌(无填充)。
//
// 参数:
// - n: 目标字符数,必须满足 n >= MinTokenLength 且 n <= 1<<20。
//
// 返回值:恰好 n 个字符、只含 [A-Za-z0-9-_] 的随机令牌;
// 参数非法或随机源失败时返回空串与包装后的 error。
//
// 实现说明:按 ceil(3n/4) 计算所需随机字节数,用 base64.RawURLEncoding 编码,
// 若结果长度超过 n 则截断到 n。截断**不引入偏差**:Base64 每个字符独立且均匀分布,
// 截断只是丢弃尾部字符,保留部分的分布不变。
//
// 重要限制(已穷举验证):无填充 Base64 的**合法长度不可能满足 n ≡ 1 (mod 4)**
// (1 个残留字符无法还原成整字节),因此当 n ≡ 1 (mod 4)(如 17、21、33)时,
// 返回值虽然确实是 n 个 URL 安全字符,但**不能**再用 RawURLEncoding 直接解回字节。
// 这是"恰好 n 个字符"与"无填充 Base64 可解码"两个要求本身的冲突;
// 本函数优先保证长度恰为 n。若需要"任何长度都能解回字节",请改用
// RandomBase64(字节数入参)或自行补 '=' 后再解码,或把长度约束改为 4 的倍数。
//
// 安全注意:
// - 长度下限 MinTokenLength 是硬性校验,目的是避免生成会话令牌、重置令牌、
// API 密钥等短令牌;确实需要短随机串(例如展示用 ID)
// 请改用 RandomString 并自行评估风险。
// - 令牌等同于口令,禁止打印、记录或放进错误信息。
func RandomToken(n int) (string, error) {
if n < MinTokenLength {
return "", fmt.Errorf("生成随机令牌: 长度至少为 %d,请求 %d", MinTokenLength, n)
}
if n > maxRandomBytes {
return "", fmt.Errorf("生成随机令牌: %w(最大 %d,请求 %d)", errTooLarge, maxRandomBytes, n)
}
// 使用 int64 计算避免 n 极大时乘法溢出;ceil(3n/4) 用 (3n+3)/4 求得。
bytesNeeded := int((int64(n)*3 + 3) / 4)
buf, err := RandomBytes(bytesNeeded)
if err != nil {
return "", fmt.Errorf("生成随机令牌: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(buf)
if len(token) > n {
token = token[:n]
}
return token, nil
}
// RandomPassword 生成同时包含所有被选中字符类型的随机口令。
//
// 参数:
// - length: 口令长度,必须 >= 被选中类型数量且 <= 1<<20。
// - upper: 是否要求包含大写字母(A-Z)。
// - lower: 是否要求包含小写字母(a-z)。
// - digits: 是否要求包含数字(0-9)。
// - symbols: 是否要求包含符号(!@#$%^&*()-_=+[]{}|;:,.<>?)。
//
// 返回值:长度恰为 length 的口令;参数非法或随机源失败时返回空串与包装后的 error。
//
// 实现说明:先从每一类中取 1 个字符放入结果,剩余位置用合并字符集填充,
// 最后用 crypto/rand 驱动的 Fisher-Yates 洗牌打乱。**洗牌是必需的**:
// 若不打乱,前几位的字符类别就固定为"大写、小写、数字、符号",
// 攻击者可据此缩小搜索空间,这是真实存在的弱点。
//
// 安全注意:
// - 口令强度约为 length*log2(len(charset)) 位,12 位(四类全选)≈ 78 位,
// 仅适合作为初始口令;高价值账户建议 length >= 16。
// - 返回的口令是明文秘密,禁止打印或记录;
// 存储时必须使用 bcryptx 或 argon2x 做慢哈希,切勿用 SHA 单次哈希。
// - 拒绝被选中类型数量大于 length 的请求,因为那样无法满足"每类至少一个"。
func RandomPassword(length int, upper, lower, digits, symbols bool) (string, error) {
var (
sets []string
charset strings.Builder
)
if upper {
sets = append(sets, upperCharset)
charset.WriteString(upperCharset)
}
if lower {
sets = append(sets, lowerCharset)
charset.WriteString(lowerCharset)
}
if digits {
sets = append(sets, digitCharset)
charset.WriteString(digitCharset)
}
if symbols {
sets = append(sets, symbolCharset)
charset.WriteString(symbolCharset)
}
if len(sets) == 0 {
return "", fmt.Errorf("生成随机口令: 至少需要选择一种字符类型")
}
if length < len(sets) {
return "", fmt.Errorf("生成随机口令: 长度 %d 不足以容纳 %d 种字符类型", length, len(sets))
}
if length > maxRandomBytes {
return "", fmt.Errorf("生成随机口令: %w(最大 %d,请求 %d)", errTooLarge, maxRandomBytes, length)
}
out := make([]byte, 0, length)
// 第一步:每类各保证 1 个字符。
for _, set := range sets {
c, err := randomByteFrom(set)
if err != nil {
return "", fmt.Errorf("生成随机口令: %w", err)
}
out = append(out, c)
}
// 第二步:剩余位置从合并字符集中填充。
all := charset.String()
for len(out) < length {
c, err := randomByteFrom(all)
if err != nil {
return "", fmt.Errorf("生成随机口令: %w", err)
}
out = append(out, c)
}
// 第三步:Fisher-Yates 洗牌,避免前几位字符类别固定。
if err := shuffleBytes(out); err != nil {
return "", fmt.Errorf("生成随机口令: %w", err)
}
return string(out), nil
}
// UUID 生成 RFC 4122 版本 4(随机)UUID 字符串。
//
// 返回值:形如 "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" 的 36 字符小写字符串;
// 随机源失败时返回空串与包装后的 error。
//
// 实现说明:取 16 字节随机数据,设置版本位(第 7 字节高 4 位为 0100,即版本 4)
// 与变体位(第 9 字节高 2 位为 10,即 RFC 4122 变体),共 122 位随机性。
//
// 安全注意:UUID v4 不可预测,可用于会话标识;但它**不是**能力令牌,
// 不要用它代替 RandomToken 作为 API 密钥(缺少显式的长度与格式约束)。
func UUID() (string, error) {
buf, err := RandomBytes(16)
if err != nil {
return "", fmt.Errorf("生成 UUID: %w", err)
}
buf[6] = (buf[6] & 0x0f) | 0x40 // version 4
buf[8] = (buf[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]), nil
}
// APIKey 生成带前缀的 API 密钥。
//
// 参数:
// - prefix: 前缀(例如 "sk."),会**原样**拼接在令牌之前;为空则不加前缀。
// - n: 令牌部分的字符数,必须满足 n >= MinTokenLength 且 n <= 1<<20。
//
// 返回值:prefix + RandomToken(n) 形式的字符串;
// 参数非法或随机源失败时返回空串与包装后的 error。
//
// 安全注意:
// - 返回值是完整的凭据,等同于口令,禁止打印或记录;
// 服务端只应保存其哈希(例如 SHA-256 摘要或 bcryptx 哈希)用于比对。
// - 前缀仅用于人类可读的区分(便于密钥扫描与轮换),不提供任何安全性。
func APIKey(prefix string, n int) (string, error) {
token, err := RandomToken(n)
if err != nil {
return "", fmt.Errorf("生成 API 密钥: %w", err)
}
return prefix + token, nil
}
// SecureCompare 以常量时间比较两个字节串是否相等。
//
// 参数:
// - a, b: 待比较的字节串,长度不同时判定为不相等。
//
// 返回值:相等返回 true,否则返回 false。
//
// 安全注意:底层为 crypto/subtle.ConstantTimeCompare,比较耗时与内容无关,
// 可抵御时序侧信道攻击;用于比较令牌、MAC、口令哈希等秘密时**必须**使用本函数,
// 绝不要使用 == 或 bytes.Equal。
func SecureCompare(a, b []byte) bool {
return subtle.ConstantTimeCompare(a, b) == 1
}
// SecureCompareString 以常量时间比较两个字符串是否相等。
//
// 参数:
// - a, b: 待比较的字符串,长度不同时判定为不相等。
//
// 返回值:相等返回 true,否则返回 false。
//
// 安全注意:比较秘密字符串(令牌、口令哈希)时必须使用本函数而非 ==;
// 长度差异会体现在返回结果与耗时上,因此被比较的值应具有固定长度。
func SecureCompareString(a, b string) bool {
return SecureCompare([]byte(a), []byte(b))
}
// randomByteFrom 从字符集中均匀随机取一个字节。
//
// 参数:
// - charset: 候选字符集,按字节切分,不能为空。
//
// 返回值:随机选中的字节;字符集为空或随机源失败时返回 0 与包装后的 error。
//
// 安全注意:使用 crypto/rand.Int 做拒绝采样,不存在模偏差;
// charset 应为固定常量,不要来自不可信输入。
func randomByteFrom(charset string) (byte, error) {
if len(charset) == 0 {
return 0, fmt.Errorf("字符集不能为空")
}
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
return 0, fmt.Errorf("生成随机索引: %w", err)
}
return charset[idx.Int64()], nil
}
// shuffleBytes 用 crypto/rand 驱动的 Fisher-Yates 算法就地洗牌。
//
// 参数:
// - buf: 待洗牌的字节切片,可以为 nil 或空(无操作)。
//
// 返回值:随机源失败时返回包装后的 error,此时 buf 可能已被部分打乱。
//
// 安全注意:必须使用密码学安全随机源,否则攻击者可以预测排列;
// 洗牌失败时调用方应丢弃 buf,不要继续使用。
func shuffleBytes(buf []byte) error {
for i := len(buf) - 1; i > 0; i-- {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
return fmt.Errorf("洗牌: %w", err)
}
j := int(idx.Int64())
buf[i], buf[j] = buf[j], buf[i]
}
return nil
}