哈希、HMAC 与加密怎么选:先定原语再定算法的判断顺序
这篇不逐个介绍算法,而是给一条可以照着走的判断顺序:先确定要回答的问题,是内容是否一致、是否由持密钥者生成、口令如何变成密钥,还是如何隐藏内容,再在那一类里选算法与参数。七节各给判据、代价与不可逆点,覆盖摘要比较的位宽、HMAC 的构造、口令存储参数与 nonce 重用。
先选原语,再选算法:哈希、HMAC、KDF 与加密的分工
起点不是挑算法,而是确定要回答哪个问题。哈希回答「这段字节与那段字节是否相同」,单向、无密钥,任何人都能算出同一个值,所以它只证明内容一致,不证明来源。HMAC 回答「这段字节是否由持有密钥的一方生成」,单向、带密钥。KDF 回答「如何把口令变成密钥」,同样单向,但故意把每次计算变慢或变宽,用成本换掉暴力破解空间。加密回答「如何让没有密钥的人读不到内容」,是四类里唯一可逆的一种,也是唯一必须额外附加认证的一种。
判据按顺序落地:要拿回明文就加密,双方共享密钥用 AES-GCM 这类认证加密,只有对方公钥就用 RSA-OAEP 再套一层对称密钥;要判断内容是否被改动且不涉及秘密,用哈希;要证明来源而双方共享密钥,用 HMAC;要把口令变成可存储的校验值,用 Argon2id 或 PBKDF2。代价差得很远:哈希几乎免费,HMAC 约等于两次压缩函数,KDF 每次几十到几百毫秒并占用内存,加密还要再加密钥管理与 nonce 管理。
不可逆点有两处。哈希与 KDF 都丢弃原像,一旦写进持久层,除重新采集原始输入没有回退路径;加密虽然可逆,密钥丢失时密文与随机字节没有区别,这一点常被「文件还在」的直觉掩盖。误用也集中在两处:拿 Base64 当加密,编码一步就还原;拿快速哈希当口令保护。默认值最坑:很多语言的 sha256 函数只有一个参数,签名上看不出「无密钥」这个前提。
| 原语 | 回答的问题 | 可逆 | 典型选择 |
|---|---|---|---|
| 哈希 | 内容是否一致 | 否 | SHA-256、SHA-512 |
| HMAC | 是否由持密钥者生成 | 否 | HMAC-SHA-256(RFC 2104) |
| KDF | 口令如何变成密钥 | 否 | Argon2id(RFC 9106)、PBKDF2(RFC 8018) |
| 加密 | 如何隐藏内容 | 是 | AES-256-GCM、RSA-OAEP(RFC 8017) |
四类原语对应四个问题,混用不会报错,只会让安全性在某处静默归零
完整性校验:摘要比较的正确做法
第一个错误是比较得太少。SHA-256 输出 256 位,即 32 字节,写成十六进制是 64 个字符。截到 8 个十六进制字符只剩 32 位,随机内容在约 2 的 16 次方、也就六万多次尝试内就会撞上一次生日碰撞;截到 16 个字符是 64 位,门槛抬到约 43 亿次。判据很明确:两端都由你控制时截断只是省事,只要有一端是外部下载或第三方清单,就必须比全量。
第二个错误是比较的方式。前缀相等会把「前几位对上」变成通过条件;字符串相等在多数语言里逐字节短路,长度不同立即返回,对 MAC、token、口令重置链接这类秘密值,泄露的是可被统计利用的时序信息。判据是:比较秘密值用常数时间比较函数,比较公开文件的摘要用普通相等就够,因为摘要本身不是秘密,攻击者拿到它没有额外收益。
第三个错误是比较的通道。摘要必须来自与被校验内容不同的通道,否则攻击者同时改文件和清单,两者永远一致。代价是维护一条可信路径:HTTPS 传输、签名清单或者包管理器的锁文件。CRC32 只有 32 位且是线性的,只适合发现传输损坏,对抗不了故意构造;MD5 早就能构造选择前缀碰撞,用来做下载校验等于没有校验。截断是不可逆的,短摘要一旦发布出去,被丢掉的位无法从发布物里恢复。
| 比较方式 | 有效位宽 | 随机碰撞门槛 | 适用 |
|---|---|---|---|
| 全量 SHA-256 | 256 位 | 约 2 的 128 次方 | 下载校验、签名清单 |
| 截到 16 个 Hex | 64 位 | 约 2 的 32 次方 | 内部缓存键 |
| 截到 8 个 Hex | 32 位 | 约 2 的 16 次方 | 仅作人眼比对提示 |
| CRC32 | 32 位 | 线性,无碰撞界 | 传输损坏检测 |
| MD5 | 128 位 | 可构造碰撞 | 不作安全用途 |
十六进制长度等于字节数乘二;位宽砍一半,碰撞门槛砍到平方根
HMAC 与「固定前缀 + 密钥」的区别
「固定前缀加密钥」通常写成把密钥拼在消息前面再做哈希。在 MD5、SHA-1、SHA-256 这类 Merkle–Damgård 结构上,它会被长度扩展攻击击穿:只要知道摘要和密钥长度,不必知道密钥内容,就能算出「原消息 + 填充 + 追加内容」的合法值,因为压缩函数的状态可以接着喂。密钥长度往往能猜出来,16 或 32 字节,猜错也只是多试几次。这条攻击早已写进教科书,却仍以「把盐放前面」的形态出现在代码里。
RFC 2104 定义的 HMAC 用两层密钥消掉这个性质:内层把密钥与重复的 0x36 字节异或后先吸收消息,外层再与重复的 0x5c 字节异或后吸收内层结果。密钥先补零或先摘要到分组长度 B,SHA-256 的 B 是 64 字节,SHA-512 是 128 字节。两层压缩让内部状态无法被外部续接,安全性可以归约回底层哈希的抗碰撞性。代价是每次多一次压缩函数,在长消息上几乎测不出来。
有两处「看起来不同、实际等价」:短于 B 的密钥会被补零,所以一个密钥和它后面接零字节是同一个密钥;长于 B 的密钥会先被摘要,所以任意长密钥与它的 SHA-256 摘要等价。这两点说明密钥长度不是熵的度量,随机性才是。另外 MAC 的比较必须常数时间,逐字节短路比较会把「前缀匹配到第几位」泄露出去,等于给了伪造者一个逐位试错的接口。
- 密钥拼在前面的哈希:受长度扩展,等于把伪造能力交给了任何看到摘要的人
- 密钥拼在后面的哈希:没有长度扩展,但底层哈希一旦被撞破就能伪造,也没有安全归约
- HMAC:两层密钥,可归约到哈希本身的性质,共享密钥场景的默认答案
口令存储:为什么快速哈希是错的原语
口令存储的目标函数和登录速度正好相反:合法服务器每次登录只算一次,而攻击者拿到的是一份可以离线无限重复计算的列表。快速哈希在这里是错的原语,因为它的设计目标就是「每秒算得尽量多」。单次 SHA-256 在消费级显卡上每秒可跑百亿量级,8 位随机口令只有 47.6 位熵、约 2.1 乘 10 的 14 次方种,按这个速度不到六小时就能穷尽。把迭代提到 60 万次,同一份列表的成本就乘上六十万倍,这正是 KDF 存在的理由。
参数要落到具体数字才可执行。PBKDF2 由 RFC 8018 定义,OWASP 目前建议 HMAC-SHA256 用 600000 次迭代,HMAC-SHA1 用 1300000 次,HMAC-SHA512 用 210000 次;规范里那个 1000 次的下限是 2000 年的建议,早已失效。Argon2 由 RFC 9106 定义,推荐 Argon2id 配 t=3、m=64 MiB、p=4,或 t=1、m=2 GiB、p=4。bcrypt 的输入在 72 字节处被截断,这是硬边界,超出部分完全无效;scrypt(RFC 7914)按 N 为 2 的 17 次方、r=8、p=1 配置。
盐不是可选项:每个用户至少 16 字节随机值,与摘要一起存储,作用是让相同口令不产生相同记录、让预计算表失效;它对抗不了单个账号的在线爆破,那要靠限速与失败次数上限。不可逆点在迁移,格式一经写入,旧记录无法在登录之外重算,只能用「登录成功后惰性重算」的方式换代。误用最典型的是全局盐,以及把口令加密后存储,密钥一泄全泄,还失去了比对能力。
| 方案 | 内存成本 | 推荐参数 | 何时可用 |
|---|---|---|---|
| Argon2id | 按 MiB 计,可调 | t=3、m=64 MiB、p=4 | 首选,新系统默认 |
| scrypt | 可调 | N 为 2 的 17 次方、r=8、p=1 | Argon2 不可用时 |
| PBKDF2-HMAC-SHA256 | 几乎为零 | 600000 次迭代 | 合规要求指定时 |
| bcrypt | 小 | cost 12,输入上限 72 字节 | 存量系统迁移期 |
| SHA-256 单次 | 零 | 无 | 不应使用 |
内存这一列决定显卡还能不能拉平成本,为零就意味着只能靠迭代硬撑
口令强度估计:熵估算能告诉你什么、不能告诉你什么
熵的定义只有一句:候选空间取以 2 为底的对数,也就是长度乘每单位位数。62 个字符的字母表每位约 5.95 位,8 位就是 47.6 位;94 个字符的字母表每位 6.55 位,10 位是 65.5 位;Diceware 的 7776 个词每位 12.9 位,6 个词就是 77.6 位。这些数字能回答一个具体问题:两种生成策略在同一个攻击者模型下相差多少倍。位数每多 10 位,尝试次数就乘以约一千。
它成立的前提是「从那个字母表里均匀随机抽取」,而人编的口令不是。一串大小写字母加数字加感叹号的 10 位口令,按字符类公式能算出 65 位以上,实际用字典加变形规则几十次尝试就命中。所以现代工具改用猜测数估算,把词典、键盘滑动、年份、替换规则都计进去,输出的是「离线每秒一百亿次时需要多久」,并且明确假设攻击者已经知道这套估算方法。
熵不能告诉你四件事:这段口令是否已经出现在泄露库里、是否在别处复用、这个站点是否限速、用户会不会在钓鱼页面上自己交出去。前两件靠比对泄露语料与检测复用,第三件靠服务端策略。NIST SP 800-63B 因此取消了组合规则和定期更换,改为长度下限 8 个字符、上限至少允许 64 个字符、与已知泄露口令比对、限制连续失败次数。代价是长度取代复杂度,用户记的是一串长词组,而不是一堆符号。
| 生成方式 | 候选空间 | 每单位位数 | 总位数 |
|---|---|---|---|
| 8 位,62 字符集 | 62 的 8 次方 | 5.95 | 47.6 |
| 12 位,62 字符集 | 62 的 12 次方 | 5.95 | 71.5 |
| 10 位,94 字符集 | 94 的 10 次方 | 6.55 | 65.5 |
| Diceware 6 词 | 7776 的 6 次方 | 12.9 | 77.5 |
| Diceware 4 词 | 7776 的 4 次方 | 12.9 | 51.7 |
位数 = 长度 × 每单位位数;单位换成词时,位数涨得快,记忆负担反而更低
加密:认证、工作模式与 nonce 重用
加密要先分认证与非认证。AES-GCM 与 ChaCha20-Poly1305(RFC 8439)是 AEAD,密文末尾带 16 字节认证标签,校验失败就整体拒绝,代价是必须拿到完整标签才能验,流式处理要按块切开、每块独立随机数。CBC 与 CTR 都不带认证:CTR 的位翻转会原样映射到明文,CBC 的填充错误会变成填充预言,把明文一点点问出来。判据只有一条:没有认证的加密不要单独使用,要么换 AEAD,要么严格按先加密再 MAC 的顺序,并且验证失败时不区分失败原因。
nonce 是这类方案最容易出事的地方。GCM 的 nonce 推荐 96 位,同一密钥下随机 nonce 的调用次数上限是 2 的 32 次方(NIST SP 800-38D),确定性计数器可以放宽到 2 的 64 次方。重用一次不是「弱一点」:两段密文异或直接得到两段明文的异或,同时 GHASH 的认证密钥可被恢复,此后攻击者能为任意消息造出合法标签。计数器在进程重启后归零而密钥不变,就是最常见的重用来源。
密钥长度与派生路径也要固定。AES-128 用 16 字节密钥,AES-256 用 32 字节;从口令派生必须走 Argon2 或 PBKDF2,HKDF(RFC 5869)只适用于输入本来就是均匀随机的情况,它不增加任何攻击成本。RSA 只用来加密短数据,OAEP 在 2048 位密钥配 SHA-256 时的上限是 256 减 64 再减 2,即 190 字节,批量数据一律混合加密。误用清单很短但都很致命:用 Math.random 生成 IV、把 nonce 写进日志、解密失败时把填充错误和认证错误分开上报。
| 模式 | 是否认证 | nonce 或 IV | 重用的后果 |
|---|---|---|---|
| AES-256-GCM | 是,128 位标签 | 96 位,每密钥至多 2 的 32 次方次 | 明文异或泄露,认证密钥可恢复 |
| ChaCha20-Poly1305 | 是,128 位标签 | 96 位 | 同上(RFC 8439) |
| AES-256-CBC | 否 | 128 位随机 IV | 相同明文块出相同密文块,可配填充预言 |
| AES-256-CTR | 否 | 128 位计数器块 | 两段密文异或直接得明文异或 |
前三列决定能不能用,最后一列决定用错的代价是「弱」还是「全丢」
一套先排除错选项的判断顺序
把前面六节压成一个排除顺序,每一步只做排除,不在这一步挑精。第一步问要不要拿回明文:要,就只剩加密,共享密钥用 AEAD,只有对方公钥用 RSA-OAEP 加对称密钥,其余全部出局。第二步问输入是不是人类口令:是,就只剩慢 KDF,快速哈希与 HMAC 同时出局,因为它们的设计目标就是快。第三步问要不要证明来源:要且共享密钥,只剩 HMAC;要公开可验证,只剩非对称签名。第四步问是不是只需发现意外损坏:是,CRC32 就够,上 MAC 是浪费。
顺序颠倒就会选出能跑却不该用的组合。先挑算法再想用途,最常见的产物是「用 AES 加密口令」和「用 SHA-256 当 MAC」,两者都能跑通,都在关键处失效。每一步的代价也要一起算:AEAD 多 16 字节并要求 nonce 管理,KDF 每次登录多几十到几百毫秒,HMAC 多一次压缩函数,签名要管密钥生命周期和吊销。参数必须存档,算法、迭代次数、盐长度、密钥长度、nonce 来源,连格式版本一起写进记录。
最后一条留给未来:所有丢弃信息的操作都不可逆。截断摘要、换掉盐、把密钥写死在配置里,事后都无法从数据本身补救。凡是能加版本前缀的地方就加,bcrypt 的 2b 与 cost 前缀就是为此存在的;凡是能保留原始输入的环节就保留。这样下一次迁移只需要改代码,不需要改数据,也就不会出现「旧用户全部无法登录」这种只能靠发信重置来收场的局面。
| 问题 | 是 → 选 | 否 → 排除 |
|---|---|---|
| 要拿回明文? | AEAD:AES-GCM 或 ChaCha20-Poly1305 | 一切加密方案 |
| 输入是人类口令? | Argon2id 或 PBKDF2 慢派生 | 裸哈希、HMAC |
| 要证明来源且共享密钥? | HMAC-SHA-256 | 无密钥哈希 |
| 只需发现意外损坏? | CRC32 或 SHA-256 | MAC 与签名 |
| 要公开可验证? | 非对称签名 | 共享密钥方案 |
每行只排除一类,走完五行剩下的组合通常不超过两个
常见问题
- SHA-256 和 SHA-1 现在还能用吗?
- SHA-256 可用于完整性校验与 HMAC。SHA-1 已能构造碰撞,只能留在兼容旧协议的位置,不能用于签名或新设计。MD5 同理,连下载校验都不该用。
- HMAC 和「哈希加盐」到底差在哪?
- 盐是公开的、不参与密钥计算,只防预计算表;HMAC 的密钥必须保密,防的是伪造。给哈希加盐后发布,任何人都能算出同样的值,不能当认证码使用。
- 口令哈希该选 Argon2id 还是 PBKDF2?
- 新系统选 Argon2id,它按内存定价,显卡和专用芯片的优势小得多。只有合规要求或运行环境没有实现时才退回 PBKDF2,并把迭代提到 60 万次以上。
- AES-GCM 的 nonce 可以随机生成吗?
- 可以,96 位随机,但同一密钥下的总调用次数要控制在 2 的 32 次方以内。接近这条线就必须换密钥,或者改用持久化计数器并保证重启后不复位。
- 密码强度条给出的「熵」可信吗?
- 它是按生成模型算出的上界,用来比较两种生成策略有意义;但它判断不了这段口令是否已泄露、是否在别处复用,所以不能单独当作安全结论。