Math.random 生成的密码看起来够乱,为什么它不是密码学安全随机数

页面上刚吐出一串大小写、数字和符号混在一起的字符,观感上很「强」。这只能说明字符种类可能够多,证明不了下一串不可被预测。核对方法不是相信「随机」「本地」两个字,而是打开开发者工具:生成路径应走 crypto.getRandomValues,刚生成的口令也不该作为业务数据出现在 Network 里。

先记结论:Math.random() 是普通伪随机数,MDN 写明它不能用于安全相关场景。登录口令、一次性令牌和密钥必须改用 Web Crypto 的 crypto.getRandomValues()。看起来够乱、断网还能点、地址栏有锁,这三件事各自证明别的边界,都替代不了「生成函数读的是哪一个 API」。

一句话先回答搜索意图

MDN 对 Math.random 的说明写得很硬:它返回 [0, 1) 上近似均匀的伪随机浮点数,不能提供密码学安全的随机数,不要用它处理与安全有关的事情;应改用 Crypto.getRandomValues()。密码「看起来够乱」只描述输出的字符形态,不描述生成器的内部状态能不能被复原。

这篇文章给要评审「在线密码生成器」的开发者和运维,以及被同事丢过来一个生成页、想自己看一眼再决定用不用的人。它不代替 密码生成器 的操作说明,也不重复「明文有没有离开标签页」那一套 Network 总论。你要带走的是:普通伪随机和 CSPRNG 差在哪一层、取模为什么会让好接口变偏、以及从点击「生成」往下读源码的具体步骤。

看起来够乱,只能说明字符可能混杂

人眼判断「乱」非常便宜,也非常不准。一串 aB3$kP9x 和一串 Tr0ub4dor&3 看起来都像密码;前者可能来自足够大的字符集,后者可能只是一句口令做了常见替换。观感回答的是「像不像密码」,不是「下一比特能不能被算出来」。

即便字符集真的包含大小写、数字和符号,只要下标来自可预测的序列,攻击者就不需要穷举整个空间。他可以复现同一套生成器,或在观察到若干输出后还原内部状态,然后枚举「还没被用掉的那些候选」。对登录口令来说,不可预测比「看起来有特殊符号」更先要满足。

长度仍然重要。同一套安全随机源,6 位和 16 位的穷举成本差几个数量级;低于 8 位时,即使用对了 CSPRNG,空间也偏小。FastPwd 随机模式允许 6–128 位、默认 16 位,低于 8 位会提示安全性较低——那是长度警告,不是「随机数算法变弱了」。两件事要分开看:算法解决不可预测,长度解决搜索空间。

Math.random 是哪一类随机数

ECMAScript 把 Math.random() 写成:返回一个正号、大于等于 0 且小于 1 的 Number,用实现定义的算法或策略近似均匀地抽取。规范没有要求它抗预测,也没有要求种子来自操作系统的熵池。引擎可以选统计性质很好、但状态可逆的伪随机数生成器(PRNG)。

Chrome 所用的 V8 引擎在 2015 年底之前用 MWC1616。V8 自己的说明写过:该算法内部状态只有 64 位,质量偏差,在 TestU01 套件里会失败。从 V8 4.9.41(随 Chrome 49 发布)起,改成 xorshift128+:128 位状态,周期 2^128 - 1,并通过 TestU01。Firefox 与 Safari 后来也切到同一类算法。细节见 V8 对 Math.random 的说明。

同一篇文章把边界写死了:xorshift128+ 比旧实现好很多,仍然不是密码学安全的。哈希、签名、加解密这类用途,普通 PRNG 不适用;应使用 Web Cryptography API 的 window.crypto.getRandomValues。统计上「够乱」和「看见若干输出之后无法反推后续输出」,是两套标准。口令需要的是后者。

因此,一个生成器如果只调用 Math.random() 再映射到字符表,它可能通过「目测很乱」和简单的频率检查,却过不了「假设有人读到源码或截获若干输出」这一关。评审时不要把「用了 Math.random,但字符集很宽」写成已经合格。

getRandomValues 具体换了哪一层

crypto.getRandomValues(typedArray) 会用密码学意义上足够强的随机值填满你传入的整型数组,例如 Uint8Array、Uint32Array。MDN 写明:为了性能,实现通常不是「真随机硬件每次直接吐比特」,而是把操作系统熵源(如 Unix 的 /dev/urandom、Windows 的系统随机接口)种进适合密码学用途的 PRNG。算法可随浏览器变化,但设计目标是:不能从已公开的输出廉价还原内部状态。

它和 Math.random 不在同一份语言规范里。后者属于 ECMA-262;前者属于 Web Cryptography。一次请求超过 65536 字节会抛 QuotaExceededError,生成一条 16 位密码远远到不了这个上限。另外,getRandomValues 是 Crypto 接口里少数能在非安全上下文调用的方法;密钥派生和 subtle.encrypt 仍要求 HTTPS 安全上下文。生产环境的生成页本来就应该是 HTTPS,不要用「HTTP 里也能调用 getRandomValues」当成可以关掉 TLS 的理由。

还要把「本机抽随机数」和「服务器代抽再发回来」分开。后者即使服务器用了高质量 CSPRNG,口令也已经以明文或可逆形式经过对方的内存、日志和这次 HTTP。地址栏小锁只说明浏览器到 TLS 终点之间的传输被加密,不说明终点看不到正文。若页面声称「本地生成」,生成这一下就不该出现把口令 POST 出去再回显的接口。

你看见的现象 它能支持的结论 它支持不了的结论
输出看起来很乱 字符种类可能够多 生成过程不可预测
源码里有 Math.random 页面用了普通伪随机 一定拿它来生成口令
源码里有 getRandomValues 页面引入了 CSPRNG 接口 你点的这个按钮一定走了它
断网后仍能生成 不依赖实时代抽接口 用的不是 Math.random
Network 搜不到口令 这次没当业务数据上传 随机数算法合格

取模偏差:CSPRNG 用错了也会偏

常见的第二处失误发生在「已经调用了 getRandomValues」之后。脚本拿到一个字节 0–255,再写 charset[byte % charset.length]。若字符集长度不能整除 256,靠前的那些字符会多占一截余数,出现频率略高。对 16 位口令,偏差通常很小,但它说明实现没有把「均匀」做到位;字符集更怪、或用 8 位去映射很大的词表时,偏差会更明显。

拒绝采样的做法是:先算出不超过 256(或 2^32)且能被字符集大小整除的上限,抽出的整数大于等于该上限就丢掉重抽,再用取模。FastPwd 的随机字符和可读密码都走 crypto.getRandomValues,用 Uint32Array(1) 抽 32 位整数,上限写成 Math.floor(0x100000000 / max) * max,超限则循环再抽。缺少 getRandomValues 时直接拒绝生成,而不是退回 Math.random。你在别的站点评审时,也应沿着「抽整数 → 是否丢弃尾部 → 再映射字符」往下读,而不是搜到函数名就停。

不要用真实登录口令、生产环境 API Key 或未脱敏工单做实验。生成一串可丢弃的测试口令,用完即弃。核对的是生成路径和流量,不是把新口令再暴露一遍。

当场核对:源码、控制台、Network

口号不能当证据。准备一枚不会出现在真实业务里的标记,例如把生成长度调到 16,生成后把整串当作金丝雀。下面三步可以在当前标签页做完,不需要账号。

  1. 打开开发者工具的 Sources,或对页面使用「查看网页源代码」。分别搜索 Math.random 和 getRandomValues。记下命中文件和函数名,先不要下结论。
  2. 从「生成」按钮的点击处理函数往下读,直到字符被拼进结果字符串。这一条路径上应出现 crypto.getRandomValues;若只有 Math.random()、Date.now() 或固定种子,这一次生成不能当成 CSPRNG。
  3. 切到 Network,勾选 Preserve log,过滤先用 All。再点一次生成,把刚得到的整串粘进搜索框。请求行、查询串、请求体和分析上报里都不该出现这串口令。

控制台只证明接口在不在

在 Console 里执行 typeof crypto.getRandomValues === "function",得到 true 只说明当前文档的浏览器用得了这个接口。它证明不了页面脚本调用了它。有人会在控制台自己跑一行 getRandomValues,然后把页面生成的口令也当成「已经核过」——那是两段不相干的代码。

断网复测只证明没有代抽

先让页面完整加载,再在 Network 里打开 Offline,或关掉系统网络,然后生成。若结果仍然出现,说明这一次不依赖实时接口。这是「本地生成」的强信号,但 Math.random 同样不需要网络。所以断网要通过,源码路径和金丝雀搜索仍要做。

打包后的脚本可能把名字压成单字母。搜不到 getRandomValues 时,再搜 crypto 和 Uint32Array,并对照点击「生成」之后有没有新的业务 POST。压缩不是免检声明,只是搜索要多绕一步。

三种把「随机」读过头的情况

第一,把地址栏小锁当成生成算法合格。HTTPS / TLS 保护的是浏览器到当前 TLS 终点的传输,见常见对「传输加密 ≠ 端到端、终点看不到正文」的区分。服务器用 HTTPS 接收你 POST 上去的口令,小锁仍然是亮的。小锁不回答 Math.random 还是 getRandomValues。

第二,把「源码里出现过 getRandomValues」当成按钮已走 CSPRNG。同一份打包文件可能给文件加密、焚链或演示用了 Web Crypto,密码生成却仍走 Math.random。反向同样成立:广告或动画里的 Math.random 不能用来否定口令路径。必须以点击生成的调用栈为准。

第三,把「没上传」当成「不可预测」。Network 干净只说明这次观察里,口令没有作为 HTTP 业务字段离开标签页。扩展仍可能读 DOM,操作系统剪贴板历史仍可能留下你复制的那一串。那是另一篇文章的边界;本文只要求你先分清:随机数质量、是否上传、复制之后还在不在,是三件不同的事。

常见问题

密码看起来很乱,是不是就可以当登录口令?

不能只凭观感。看起来混杂只说明字符种类可能够多,证明不了生成过程不可预测。MDN 写明 Math.random 不能用于安全场景。还要看长度是否足够、有没有在多个站复用,以及生成路径是不是 crypto.getRandomValues。

断网后还能生成,是不是随机数就安全?

只能证明这一次生成不依赖实时接口,服务器没有代抽随机数。Math.random 同样能在断网后运行。断网要通过,源码里仍要看到生成路径走 getRandomValues,并且 Network 里不应出现刚生成的口令。

源码里同时出现 Math.random 和 getRandomValues,还能用吗?

能用与否取决于生成按钮绑到哪一段。动画、打乱展示顺序可能用 Math.random;口令、令牌、密钥必须走 getRandomValues。打开 Sources,从点击「生成」的处理函数往下读,不要只看全站搜索的命中次数。

FastPwd 的密码生成器用哪种随机数?会不会上传?

随机字符和可读密码都走 crypto.getRandomValues,并把 32 位整数映射到字符集时做拒绝采样,避免取模偏差。缺少该接口时会拒绝生成。长度 6–128 位、默认 16 位;低于 8 位会提示安全性较低。生成结果默认不上传。打开即用,无需账号。

把同一串测试口令用打开即用的工具对照一次

若你希望用一个把随机数边界写清楚的页面来练习,打开 FastPwd 的 密码生成器 即可,无需注册。随机模式先用默认 16 位,生成一串可丢弃的口令。按第五节做完:Sources 里沿「生成」读到 getRandomValues 和拒绝采样循环;Network 里用整串搜索,请求行和 body 不应命中。

生成之后,若还要判断这串像不像已被广泛滥用的口令,把另一串可丢弃的测试字符拿到 密码检测 里对照公开高频名单。待测密码不上传。未命中不能证明全网没见过,命中只说明太常见;那是弱口令名单的边界,不是 CSPRNG 的边界。不要把刚要启用的真口令贴进任何在线检测框。

下次打开陌生生成页之前记住三件事。第一,看起来够乱证明不了不可预测。第二,Math.random 即使统计性质已经很好,仍不是密码学安全随机数。第三,getRandomValues 出现在源码里还不够,必须读到你点击的那条路径,并用金丝雀确认口令没有作为业务数据出站。能重复的观察,才值得写进你们自己的评审清单。

延伸阅读

「本地加密、不上传」不能当证据,要用金丝雀搜 Network,见 浏览器本地加密怎么核对明文没有离开本机。本地弱口令名单能抓住常见弱密码、不能当成全网撞库,见 本地弱口令名单能证明什么,为什么它不是全网撞库。若生成之后还要把口令一次性交给同事,井号后面为什么默认不进 HTTP,见 URL 的井号后面为什么适合放密钥,以及什么时候会失效。