为什么"密码复杂度规则"是错误的(NIST SP 800-63B)
几十年来,关于密码的标准建议一直是:至少一个大写字母、一个小写字母、一个数字、一个特殊字符;每90天强制更换;禁止重复使用最近5个密码。大多数企业IT部门至今仍在强制执行这一套。NIST——美国国家标准与技术研究院,负责制定联邦网络安全指南的机构——于2017年发布了Special Publication 800-63B,并在2024年的重大修订中明确指出:把这些做法全部停掉。
其依据来自对真实世界结果的测量:复杂度规则和强制轮换让密码变得更糟,而不是更好。当用户被迫满足"必须包含大写+数字+特殊字符"时,占主导地位的结果是一种可预测的变换——Password1!、Password2!、Password3!——基于人类习惯的猜测工具可以轻而易举地枚举出来。当被迫每90天轮换一次时,用户只会在原密码后面附加一个计数器。这种"复杂度"看似能通过正则校验,但相对于没有约束的同等密码,熵的增益基本为零,而用户的摩擦却急剧上升,密码最终被重复使用或写在便利贴上。
NIST SP 800-63B第5.1.1.2节(memorized secrets)现在写道:"验证方不应(SHOULD NOT)对记忆型秘密施加其他组成规则(例如要求混合不同类型的字符)。验证方不应(SHOULD NOT)要求任意地(例如定期地)更换记忆型秘密。"只有在出现泄露证据时才需要更换。最小长度为8(随机生成的为6),最大长度必须(SHALL)至少为64,并且必须(MUST)允许所有可打印的ASCII和Unicode字符。字符类别限制已被取消。
取代复杂度规则的是拒绝列表(denylist)。NIST强制要求将密码与已知被泄露值的列表进行比对:出现在泄露语料库中的密码、字典单词、重复序列(aaaaaaaa、qwerty),以及与上下文相关的字符串(用户名、站点名)。免费服务Have I Been Pwned提供了一个可以通过k-匿名性集成的API——发送SHA-1哈希的前5个字符,取回所有具有该前缀的哈希,再在本地比对。2026年,已有数百万服务在使用它。
由此得出的实用用户建议是:使用长随机密码(由生成器生成16个字符以上),每个站点一个,存放在密码管理器中。或者使用便于记忆的口令短语(从词表中随机选取4个以上英文单词,约50比特以上的熵)。停止强制轮换。停止屏蔽表情符号或Unicode。停止强制要求特殊字符。只拦截明显糟糕的密码,在此之外,长度和形式让用户自己选择。
// HIBP k-anonymity check (no full hash leaves your server)
async function checkPwned(password) {
const hash = sha1Hex(password).toUpperCase();
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
const res = await fetch('https://api.pwnedpasswords.com/range/' + prefix);
const text = await res.text();
for (const line of text.split('\n')) {
const [s, count] = line.trim().split(':');
if (s === suffix) return parseInt(count, 10); // # of breaches
}
return 0;
}
// 2026 sign-up flow per NIST SP 800-63B
async function validateSignup(password) {
if (password.length < 8) return 'too short';
if (password.length > 64) return 'too long';
if (/^(.)\1{4,}$/.test(password)) return 'too repetitive';
const breached = await checkPwned(password);
if (breached > 0) return `appeared in ${breached} breaches`;
return null; // OK — no character-class checks
}熵的数学——密码应该多长
密码强度用Shannon熵来量化:H = log2(N^L),其中N是字母表大小,L是长度。单位是比特,每增加一比特,攻击者所需的平均猜测次数就翻一倍。从26个字母的字母表中均匀随机抽取的长度为10的密码有26^10 ≈ 1.4 × 10^14种可能取值,即log2(1.4 × 10^14) ≈ 47比特的熵。同样长度、基于95个可打印ASCII字符的字母表则是95^10 ≈ 6 × 10^19,约66比特。
关键词是"均匀随机"。人类选择的密码的熵远低于字母表所暗示的水平,因为人类只从整个空间中极小的一部分里挑选。对泄露密码语料库(RockYou、LinkedIn、Adobe)的研究一致估计,人类选择的8字符密码的有效熵为18~22比特——以50%的概率破解只需约400万到800万次猜测,单块GPU几秒钟就完全够用。同样的字符数由生成器生成则是50比特以上,约10^15次猜测——在专用硬件上要以年为单位计算。
作为对现代攻击者能力的一个校准:2026年,单块Nvidia RTX 4090每天可计算约2000亿次bcrypt cost-5哈希。由100块GPU组成的小型集群可达20万亿次。租用云端hashcat集群几个小时,就能达到高出若干数量级的规模。密码哈希算法的防护性work factor影响极其巨大——在bcrypt cost 12(当前OWASP推荐)下,同样的硬件每天只能计算约8万次哈希,因此进行暴力破解的攻击者所需时间比cost 5长约2.5亿倍。这就是为什么密码哈希的选择至少与密码长度同等重要。
2026年的运营指南(假设采用典型的Argon2id work factor):从95个符号的字母表中随机生成12个字符,约有79比特的熵,在未来十年内实际上无法破解。16个字符约为105比特,即使面对国家级行为者的预算,在可预见的未来也无法破解。对于口令短语,从7,776个单词的列表(EFF wordlist)中随机选取4个单词是log2(7776^4) ≈ 51比特——与12字符密码相当,但好记得多。5个单词是65比特,是人类可记忆用途的实用甜点区。
一个微妙的陷阱:熵假设的是均匀随机选择。如果你的生成器使用Math.random(),那么关于熵的说法就是谎言——V8中的Math.random()是xorshift128+,攻击者只要捕获几个输出就能预测后续输出。任何密码级别的输出都要始终使用crypto.getRandomValues(new Uint32Array(...))(浏览器)或crypto.randomBytes(...)(Node)。这两者的差别,就是79比特熵与0之间的差别。
// Entropy table (assumes uniform random)
//
// alphabet | length | bits | crack time at 1e9 guesses/sec
// --------- | ------ | ----- | -----------------------------
// 26 | 8 | 37.6 | 2 minutes
// 62 | 8 | 47.6 | 1 day
// 95 | 8 | 52.5 | ~50 days
// 95 | 12 | 78.7 | ~10 million years
// 95 | 16 | 105.0 | ~10^15 years (heat death of sun)
//
// — and remember: real cracking goes through Argon2id which is
// ~10^6 to ~10^9 times slower than raw hashing.
// CORRECT: cryptographic random
function generate(length, alphabet) {
const out = new Array(length);
const r = new Uint32Array(length);
crypto.getRandomValues(r);
for (let i = 0; i < length; i++) out[i] = alphabet[r[i] % alphabet.length];
return out.join('');
}
// WRONG: Math.random — predictable, NOT for passwords
function bad(length) {
let s = '';
for (let i = 0; i < length; i++) s += String.fromCharCode(97 + Math.floor(Math.random() * 26));
return s;
}
// EFF passphrase
import wordlist from './eff_large_wordlist.json'; // 7776 words
function passphrase(n = 5) {
const r = new Uint32Array(n);
crypto.getRandomValues(r);
return [...r].map(x => wordlist[x % 7776]).join('-');
}Passkey、FIDO2、WebAuthn——后密码时代的世界
密码有一个无论多长、无论怎样哈希都无法修复的结构性问题:它们是持有者秘密(bearer secret)。用户知道它,服务器存储它的哈希,网络传输它。这个秘密存在的每一个地方,都可能被钓鱼、被泄露、被截获或被重放。Passkey(通行密钥)是业界协调一致给出的答案。
Passkey是一对公私钥,由用户的设备生成,并存储在设备的安全隔区中(Apple Secure Enclave、Android StrongBox、Windows Hello TPM,以及像YubiKey这样的硬件FIDO2密钥)。注册时,设备生成密钥对,只把公钥发送给服务器。登录时,服务器发送一个随机challenge,设备用私钥对challenge签名,服务器再用公钥验证。私钥永远不会离开设备。challenge每次登录都会更换。不存在可被钓走的共享秘密。
相关标准包括:FIDO2(FIDO Alliance的总体框架)、WebAuthn(W3C浏览器API,自2019年起为Recommendation状态)以及CTAP2(浏览器与认证器硬件之间的协议)。Apple、Google、Microsoft和Mozilla在2022~2023年同步推出了passkey支持,Apple iCloud Keychain和Google Password Manager使用端到端加密在用户的各个设备之间复制passkey。截至2026年,所有主要站点(Google、Apple、Microsoft、GitHub、PayPal、Amazon、Shopify等)都支持仅用passkey登录。
抗钓鱼能力是结构性的。WebAuthn签名包含relying party identifier(域名),浏览器会拒绝在错误的origin上使用passkey。被诱骗在google.com.attacker.example输入Google凭据的用户,无法在那里使用他们的Google passkey——浏览器会检查origin,设备会拒绝签名。这就是为什么FIDO是面向消费者推出的、单项效果最强的反钓鱼技术。
实现模式(服务器端):注册时,接收WebAuthn registration response,验证attestation,为该用户存储公钥 + credential ID + counter。登录时,生成随机challenge发送给客户端,接收签名后的assertion,用存储的公钥验证签名,检查counter是否单调递增(抵御被克隆的认证器),并检查origin与你的域名一致。可用库:@simplewebauthn/server(Node)、webauthn-rs(Rust)、java-webauthn-server(Java)。
2026年大多数应用遵循的过渡模式是:为向后兼容保留密码登录,但在首次登录时提示注册passkey,之后的登录优先使用passkey。GitHub正是这样做的。领先站点报告称,在提供passkey后的12~18个月内,60~80%的活跃用户至少注册了一个passkey,密码的使用也相应下降。最终状态是"默认passkey,密码仅作为迁移场景的后备",并最终对新账户实现无密码认证。
// Browser: register a passkey
const opts = await fetch('/passkey/register/options').then(r => r.json());
const cred = await navigator.credentials.create({
publicKey: {
challenge: base64ToArrayBuffer(opts.challenge),
rp: { name: 'Acme', id: 'acme.example' },
user: { id: opts.userId, name: 'alice', displayName: 'Alice' },
pubKeyCredParams: [{ type: 'public-key', alg: -7 /* ES256 */ }],
authenticatorSelection: { userVerification: 'preferred' },
}
});
await fetch('/passkey/register', { method: 'POST', body: JSON.stringify(cred) });
// Browser: sign in with passkey
const opts2 = await fetch('/passkey/login/options').then(r => r.json());
const assertion = await navigator.credentials.get({
publicKey: {
challenge: base64ToArrayBuffer(opts2.challenge),
rpId: 'acme.example',
}
});
await fetch('/passkey/login', { method: 'POST', body: JSON.stringify(assertion) });
// Server: verify with @simplewebauthn/server (Node)
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
const verify = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: storedChallenge,
expectedOrigin: 'https://acme.example',
expectedRPID: 'acme.example',
authenticator: storedCred,
});
if (verify.verified) await db.users.updateCounter(userId, verify.authenticationInfo.newCounter);