"至少 8 位,含大小写、数字和特殊符号"这条规则已经过时了。真正决定密码强度的是信息熵,而熵由长度主导:H = L × log₂N,其中 L 是长度,N 是每一位可用的字符数。
看一组对照(组合数量级就是 2^H):
| 密码形式 | N | L | 熵(位) | 组合数量级 |
|---|---|---|---|---|
| 8 位小写字母 | 26 | 8 | 37.6 | 2×10¹¹ |
| 8 位大小写+数字+符号 | 72 | 8 | 49.4 | 7×10¹⁴ |
| 12 位小写字母 | 26 | 12 | 56.4 | 9×10¹⁶ |
| 4 词密码短语(7776 词表) | 7776 | 4 | 51.7 | 3.7×10¹⁵ |
| 6 词密码短语(7776 词表) | 7776 | 6 | 77.5 | 2×10²³ |
关键对比在第二行和第三行:把长度从 8 加到 12(+4 位),熵增加约 19 位;而在 8 位长度内堆满符号,只比纯小写多约 12 位。多一个字符带来的收益,远大于多一类字符。
而 P@ssw0rd 这类"看起来复杂"的密码熵极低——它就在所有破解字典的第一页,符号替换规则(a→@、o→0)早就是字典生成器的默认配置。
熵每多 1 位,穷举所需的尝试次数翻倍。所以:
这也是为什么密码短语是好方案:6 个常见词有 77.5 位熵,还比一串随机字符好记。
必须用密码学安全的随机源,不能用 Math.random()。
import secrets
WORDS = ["correct", "horse", "battery", "staple", "purple", "engine"]
def passphrase(n=6):
return "-".join(secrets.choice(WORDS) for _ in range(n))
print(passphrase()) # 例:battery-engine-staple-correct-purple-engine
上面的示例词表只有 6 个词,实际使用时要用 7776 词(EFF 长词表一类的)才能达到表中标注的熵值——每词的熵是 log₂(词表大小),词表越小熵越低。
浏览器里用 crypto.getRandomValues,并且注意 randomInt 不能简单取模,否则会有取模偏差:
function randomInt(max) {
const limit = 2 ** 32 - (2 ** 32 % max); // 拒绝采样,消除取模偏差
const buf = new Uint32Array(1);
let x;
do { crypto.getRandomValues(buf); x = buf[0]; } while (x >= limit);
return x % max;
}
const passphrase = Array.from({ length: 6 }, () => WORDS[randomInt(WORDS.length)]).join('-');
Python 的 secrets.choice 内部已经处理了偏差,直接用即可。
NIST SP 800-63B 给出的建议和很多老系统相反,这几条值得照着改:
| 老做法 | 现在的建议 | 原因 |
|---|---|---|
| 强制大小写数字符号 | 不强制组合规则 | 人只会用固定套路应付,熵反而低 |
| 每 90 天强制换密码 | 不要求定期更换 | 只会催生 password1 → password2 |
| 最短 8 位、上限 16 位 | 最短 8 位,允许至少 64 位 | 长密码短语要能存得下 |
| 密码提示问题 | 不要用 | "母亲的姓氏"是可以被社工查到的 |
| 无 | 注册和改密时检查是否在已泄露密码库中 | 撞库远比暴力破解有效 |
| 做法 | 是否可行 |
|---|---|
| 明文存 | 绝不可行 |
| 可逆加密(AES)存 | 不可行,密钥泄露等于全泄露 |
| MD5 / SHA-1 / SHA-256 | 不可行,这些是快哈希,每秒可算数百万次 |
| 快哈希 + 盐 | 不可行,盐只防彩虹表,防不住定向穷举 |
| bcrypt / scrypt | 可以 |
| Argon2id | 首选,可调内存、迭代和并行度 |
慢哈希的意义是把单次计算成本抬到几十到几百毫秒——对正常登录无感,对每秒要试几百万次的攻击者是灾难。
如果想知道手上这个密码大概有多强,可以用基于 zxcvbn 的估算工具——它不只是数字符,还会识别常见单词、键盘序列(qwerty)、重复模式(aaaa)和日期,估算结果比"含几种字符"这类规则靠谱得多。