做密码生成器才发现:Math.random 根本不安全

举报
柠檬-夏日清爽 发表于 2026/10/08 10:58:37 2026/10/08
【摘要】 做个随机密码生成器,第一反应是 Math.random() 取随机数。查了资料才后怕——它生成的东西是可预测的,用来生成密码等于没生成。这篇讲浏览器里正确的随机方式 crypto.getRandomValues:为什么它安全、为什么"取模取随机数"会有偏差、以及一个能直接用的密码生成函数。

一、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 里存,那地方不保险。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。