做密码生成器才发现:Math.random 根本不安全
一、Math.random 为什么不安全
它返回的是 0 到 1 之间的伪随机数,看着挺随机:
Math.random() // 0.3748291038475
问题在于"伪随机"这三个字。它是由一个确定的算法算出来的,只要知道算法和初始状态(种子),就能推出后面所有的"随机数"。
浏览器里的 Math.random() 实现(现代浏览器用的是 xorshift128+ 算法)有个已知问题:它的内部状态可以被推算出来。也就是说,攻击者拿到你生成的一串随机数,理论上能反推出后续会生成什么。
对我们的密码生成器意味着什么?如果页面上有别的代码在跑(第三方脚本、浏览器插件),它有可能观测到 Math.random() 的输出序列,进而预测你生成的下一个密码。
结论:生成密码、token、验证码、会话 ID 这类东西,绝对不能用 Math.random()。
两者差在哪,一张表看清楚:
| 维度 | Math.random() | crypto.getRandomValues |
|---|---|---|
| 随机性来源 | 算法推算(伪随机) | 操作系统密码学随机源 |
| 能否被预测 | 能(已知序列可反推后续) | 不能 |
| 安全性 | 不安全,禁用于敏感场景 | 安全,专门用于密钥/token |
| 调用方式 | 直接返回值 0~1 |
填充一个 TypedArray |
| 可用环境 | 任意环境 | 需 HTTPS 或 localhost |
二、正确的做法:crypto.getRandomValues
浏览器提供了加密级别的随机源:
const arr = new Uint32Array(1)
crypto.getRandomValues(arr)
console.log(arr[0]) // 一个 0 ~ 4294967295 的随机数
crypto.getRandomValues 用的是操作系统提供的密码学安全随机源(Linux 上是 /dev/urandom,Windows 上是 CryptGenRandom 一类),不可预测、不可重现。
用法是填充一个 TypedArray,不是直接返回值:
// 一次取 10 个字节
const bytes = new Uint8Array(10)
crypto.getRandomValues(bytes)
// Uint8Array(10) [ 47, 199, 23, 88, ... ]
三、从随机数到随机字符
有了随机数字,映射成字符:
function randomString(length, charset) {
const bytes = new Uint32Array(length)
crypto.getRandomValues(bytes)
let result = ''
for (let i = 0; i < length; i++) {
result += charset[bytes[i] % charset.length]
}
return result
}
const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const LOWER = 'abcdefghijklmnopqrstuvwxyz'
const DIGIT = '0123456789'
const SYMBOL = '!@#$%^&*()-_=+[]{}'
randomString(16, LOWER + UPPER + DIGIT + SYMBOL)
// 类似:k8$mPq2!xR9#Lw4n
四、取模带来的偏差
上面那句 bytes[i] % charset.length 藏着一个小问题,叫取模偏差(modulo bias)。
原因是:随机数的范围(0 ~ 4294967295)通常不能被字符集长度整除,那么落在区间尾部的那些余数,被选中的概率会略低。
举例说:假设随机数只有 0~9 十个数,字符集长度是 3。
- 余数 0:来自 0、3、6、9 → 4 个
- 余数 1:来自 1、4、7 → 3 个
- 余数 2:来自 2、5、8 → 3 个
余数 0 的概率就是 40%,另外两个各 30%。不均匀。
对 16 位密码来说这点偏差微不足道,但要做严谨,可以用拒绝采样:碰到落在不完整区间的数就丢弃重取。
function randomIndex(max) {
const limit = Math.floor(0xFFFFFFFF / max) * max // 最大的完整区间
const arr = new Uint32Array(1)
let value
do {
crypto.getRandomValues(arr)
value = arr[0]
} while (value >= limit) // 落在尾巴上就重取
return value % max
}
function randomString(length, charset) {
let result = ''
for (let i = 0; i < length; i++) {
result += charset[randomIndex(charset.length)]
}
return result
}
五、完整密码生成器
加上选项控制:
function generatePassword({ length = 16, upper = true, lower = true, digit = true, symbol = true } = {}) {
let charset = ''
if (lower) charset += 'abcdefghijklmnopqrstuvwxyz'
if (upper) charset += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
if (digit) charset += '0123456789'
if (symbol) charset += '!@#$%^&*()-_=+[]{}'
if (!charset) throw new Error('至少选一种字符类型')
if (length < 8) throw new Error('长度至少 8 位')
return randomString(length, charset)
}
还可以加个"确保包含每类字符"的检查——随机生成的密码理论上可能刚好不含数字,重生成一次就行:
function generateStrict(options) {
for (let i = 0; i < 100; i++) { // 最多试 100 次
const pwd = generatePassword(options)
const checks = [
!options.lower || /[a-z]/.test(pwd),
!options.upper || /[A-Z]/.test(pwd),
!options.digit || /[0-9]/.test(pwd),
!options.symbol || /[^A-Za-z0-9]/.test(pwd),
]
if (checks.every(Boolean)) return pwd
}
return generatePassword(options) // 兜底
}
六、几个要注意的点
-
crypto.getRandomValues只在安全上下文可用。也就是 HTTPS 或者 localhost 才行;http://192.168.x.x这种局域网地址下crypto可能是undefined。判断一下没有的话给个友好提示,别让页面直接报错。好在个人使用时 localhost 就满足条件,本地跑着玩不受影响。
-
一次别取太多。
getRandomValues单次上限是 65536 字节,超了会抛异常。生成密码远远用不到,但如果要生成大数据量的随机内容要注意。 -
别自己造随机数算法。见过有人用
Date.now()的尾数当随机源,那更不靠谱。系统的随机源是专门设计过的,别重复发明轮子。 -
生成的密码别存本地。localStorage 里的数据,同源下的任何脚本都能读到,页面万一有个第三方脚本,生成的密码等于白生成。让用户复制走就行,别留痕迹。
-
前端生成的密码只对自己负责。如果是给别的系统生成初始密码,最好在服务端生成——前端生成的话,理论上页面上的任何脚本都能截获它。
-
长度比复杂度更重要。16 位纯小写字母的密码,比 8 位带各种符号的密码难破解得多(因为组合数是指数级的)。界面上可以引导用户优先加长度。
小结
做密码生成器最大的收获不是把功能实现出来,而是纠正了一个想当然:Math.random() 是伪随机,可被预测,绝对不能用在密码、token、验证码这些场景。正确姿势是 crypto.getRandomValues(),它用操作系统的密码学随机源。想做得严谨还可以处理一下取模偏差,虽然对十几位的密码影响微乎其微。另外提醒一句——生成完的密码别往 localStorage 里存,那地方不保险。
- 点赞
- 收藏
- 关注作者
评论(0)