先记结论: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,生成后把整串当作金丝雀。下面三步可以在当前标签页做完,不需要账号。
- 打开开发者工具的 Sources,或对页面使用「查看网页源代码」。分别搜索
Math.random和getRandomValues。记下命中文件和函数名,先不要下结论。 - 从「生成」按钮的点击处理函数往下读,直到字符被拼进结果字符串。这一条路径上应出现
crypto.getRandomValues;若只有Math.random()、Date.now()或固定种子,这一次生成不能当成 CSPRNG。 - 切到 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 的井号后面为什么适合放密钥,以及什么时候会失效。