发布于
密码强度是少数有确切答案的安全问题之一。它不是一种感觉,不是彩色强度条,也不是特殊字符的个数——它是一个比特数,而你只要知道两件事就能算出来:生成器可以从多少个符号里选,以及它选了几个。本文其余内容都是从这个算术里推出来的。
熵是唯一有意义的度量
如果一个密码是从 N 个符号的字母表中均匀随机抽取、长度为 L 个符号,那么它的熵是 L × log2(N) 比特。公式就这一条。从 95 个可打印 ASCII 符号里取 16 个字符,是 16 × 6.57 ≈ 105 比特;从 62 个字母数字里取 12 个字符,是 12 × 5.95 ≈ 71 比特。
关键词是「均匀随机」。这个公式描述的是:当攻击者已经知道你的生成方案、必须穷举搜索时所面对的工作量——而你永远应该做这个假设,因为生成器是公开的,方案是会泄露的。它完全不描述人脑想出来的密码。Tr0ub4dor&3 有 14 个来自 95 符号字母表的字符,但它并不是从那个字母表均匀抽取的;它抽自「带可预测替换的单词」这个小得多的集合,而破解规则集会直接搜索那个集合。按字符类别计分的强度估算器,会系统性地高估人选密码、低估口令短语。
凡是可能被离线攻击的场景,80 比特是合理下限;128 比特则意味着在任何可预见的硬件下,密码都不再是最薄弱的一环。长度已向上取整。
| 字母表 | 符号数 | 每符号比特 | 达到 80 比特所需长度 | 达到 128 比特所需长度 |
|---|---|---|---|---|
| 纯数字(PIN) | 10 | 3.32 | 25 | 39 |
| 小写字母 | 26 | 4.70 | 18 | 28 |
| 小写字母加数字 | 36 | 5.17 | 16 | 25 |
| 大小写字母加数字 | 62 | 5.95 | 14 | 22 |
| 全部可打印 ASCII | 95 | 6.57 | 13 | 20 |
| EFF 长词表(每个词) | 7776 | 12.92 | 7 个词 | 10 个词 |
长度胜过复杂度,标准也是这么说的
回头再看那张表,注意扩大字母表换来的收益有多小:从 62 个符号扩到全部 95 个可打印 ASCII,在 80 比特这一档只省一个字符,128 比特这一档省两个;而给一个纯小写密码加一个字符就值 4.7 比特。长度就是更划算的杠杆,也是人类能忍受的那一个。
组合规则——「至少一个大写、一个数字、一个符号」——的效果与它的承诺相反。对随机生成的密码,它们完全不增加熵;严格说来,强制特定类别还会排除掉一些合法组合,让空间略微变小。它们真正的作用是把人逼进可预测的模式:大写放开头,数字和感叹号放结尾,替换一律是 a 变 @。破解工具把这些模式编码成规则,并且优先尝试。
这已经不是什么非主流观点。NIST SP 800-63B 在 2025 年定稿的第 4 修订版中要求验证方不得施加组合规则、必须接受至少 64 个字符、必须接受全部可打印 ASCII 加空格及 Unicode,并且要拿候选密码去比对已泄露口令语料库。它保留了 8 个字符的绝对下限,并新增了「建议验证方要求至少 15 个字符」。截断密码、静默剥离字符、拒绝空格,都被视为缺陷。
随机性从哪里来,决定了一切
Math.random 是典型错误。规范只要求它返回 [0, 1) 区间内近似均匀分布的数,也仅此而已——ECMAScript 标准明确不要求不可预测性。V8 用 xorshift128+ 实现它,那是一个内部状态 128 比特的快速非密码学生成器。只要观察到不多的若干次输出,就能还原它的状态,此后所有未来值和历史值都是确定的。基于 Math.random 的密码生成器,产出在强度条看来是随机的,而在能从同一页面观察到几个取值的人手里是可复原的。
自己动手实现的人常会踩到一个细节:用普通取模把一个随机字节缩到字母表下标会引入偏差,因为 256 不是 62 的整数倍。把落在末尾不完整区块里的取值丢弃重抽即可,如下所示。
正确的随机源是平台的密码学安全随机数生成器,在所有现代平台上它都是操作系统熵池之上的一层薄封装。浏览器和 Node 里是 crypto.getRandomValues,Python 里是 secrets 模块,Go 里是 crypto/rand,Java 里是 SecureRandom。在生成密码这个量级上,没有任何性能理由回避它们。
const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
function randomPassword(length) {
const n = ALPHABET.length; // 62
// 单字节内 n 的最大整数倍;取值达到或超过它就丢弃,
// 这样每个符号出现的概率才严格相等。
const limit = 256 - (256 % n); // 248
const out = [];
const buf = new Uint8Array(1);
while (out.length < length) {
crypto.getRandomValues(buf);
if (buf[0] < limit) out.push(ALPHABET[buf[0] % n]);
}
return out.join("");
}
// 从 62 符号字母表中取 20 个符号
// = 20 * log2(62) = 119.1 比特口令短语:同一套算术,换一种字母表
口令短语不是「披着亲和外衣的弱密码」。它是同一个计算,只不过把词当作符号。EFF 长词表有 7776 个条目,数量刚好让每个词可以用五次掷骰选出,于是每个词是 log2(7776) = 12.92 比特。六个词是 77.5 比特,七个词是 90.5 比特,十个词是 129 比特。
熵来自「选择是随机的」,而不是「词很生僻」。自己想出来的短语,价值远低于词数暗示的水平,所以请接受掷出来的词,哪怕它们很荒唐——荒唐本身就是熵。
在人必须凭记忆复现、或者必须在没有密码管理器的设备上手敲的场景里,口令短语多出来的几个字符是值得的:磁盘加密口令、密码管理器主密码、SSH 私钥口令、恢复短语。至于那几百个你根本不会手敲的站点密码,用管理器生成的 20 位随机串严格更优,因为根本没人需要记住它。
强制定期换密码为什么让情况变糟
九十天过期曾经是长达二十年的安全正统,如今 NIST、英国 NCSC 以及微软自家的基线指南都明确建议不要这么做。这条政策听上去审慎,所以值得把理由弄明白,而不只是照做。
轮换的初衷是缩短被盗密码可用的时间窗。它做不到,理由很简单:窃取凭证的攻击者会在几分钟到几小时内使用它,而不是在你周期剩下的八十九天里慢慢用。与此同时,这条政策每个季度都向每一位用户征收真实成本,而用户会做出理性反应:Autumn2026! 变成 Winter2027!,一个好记的基串后面挂一个计数器,或者干脆把密码写下来。工单系统的重置量随之上升,而重置流程本身就是一个攻击面。
替代做法是按证据换密码,而不是按日历。用已泄露语料库筛查新设密码,让已知被泄露的取值根本设不上去。只在出现真实信号时强制更换:收到泄露通报、检测到入侵、共享凭证的持有人离职。把省下来的精力投到真正降低凭证风险的控制上——多因素认证、认证端点限速,以及一个慢速的密码哈希。
密码管理器才是真正的答案
上面所有建议最终都收敛到一套实际安排:用密码管理器为每个站点生成一串长随机值,整体由一个强口令短语加第二因素保护。这不是一项便利功能,而是让这些建议真正可执行的前提,因为它消除了产生坏密码的两个约束——必须记住,以及必须手敲。
它还把「撞库」整类攻击一并消灭。密码复用正是那条让某个早已遗忘的论坛的泄露演变成你邮箱和银行账户安全事件的链条,而每站唯一的密码把它彻底切断。按来源匹配填充的管理器还会拒绝把你的银行密码自动填进仿冒域名,这种反钓鱼能力是人眼无法稳定提供的。
关于在网页里生成密码这件事
本站的密码生成器在你的浏览器里运行,取值来自 crypto.getRandomValues,和原生应用会用的是同一个随机源。值在本地产生,不上传,也不记录。这些是真实性质,也是我们刻意实现的。
即便如此,我们仍然更希望你用密码管理器内置的生成器;与其为页面停留时长做优化,我们更愿意把这句话说出来。管理器在同一步里生成并存储密码,于是这个秘密从来不曾以「可选中的文本」形式出现在浏览器标签页里,不会进入剪贴板,也不会出现在操作系统和各类同步服务保留的剪贴板历史中。网页生成器则必然包含一次复制动作,而剪贴板是其他应用可以读取的共享资源。
浏览器生成器真正合适的场景,是你需要一个不属于个人凭证的随机值:马上要粘进密钥管理系统的服务账号密钥、测试夹具的种子、脚本用的一次性令牌、演示用的临时值。在没有管理器的机器上,它也是合理的兜底。但它不适合用来创建那个保护其余一切的主密码——那一个,请在它将要长期驻留的地方生成。
要点回顾
- 强度按「长度 × log2(字母表大小)」计算;凡是可能被离线攻击的,至少 80 比特,能到 128 比特而不增加成本时就到 128。
- 增加字符数而不是字符类别——组合规则几乎不增加熵,却把人推向破解工具最先尝试的模式。
- 基于 Math.random 的生成器毫无安全价值,无论输出看起来如何;只有 crypto.getRandomValues 这类平台 CSPRNG 才算数。
- 按入侵证据换密码,不要按日程表换;日历式过期会实实在在地拉低人们所选密码的质量。
- 站点密码交给密码管理器;必须凭记忆手敲的少数几个秘密,用七个词及以上的生成式口令短语。