<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>hash on Chaney&#39;s MoonBook</title>
    <link>https://chaneyzorn.github.io/tags/hash/</link>
    <description>Recent content in hash on Chaney&#39;s MoonBook</description>
    <follow_challenge>
        <feedId>94402801171278848</feedId>
        <userId>60932538427591680</userId>
    </follow_challenge>
    <image>
      <title>Chaney&#39;s MoonBook</title>
      <url>https://chaneyzorn.github.io/cm/chaney-cover.jpg</url>
      <link>https://chaneyzorn.github.io/cm/chaney-cover.jpg</link>
    </image>
    <generator>Hugo(0.160.1) -- gohugo.io</generator>
    <language>zh</language>
    <managingEditor>chaneyzorn#gmail#com (ChaneyZorn)</managingEditor>
    <webMaster>chaneyzorn#gmail#com (ChaneyZorn)</webMaster>
    <copyright>Copyright © ChaneyZorn | CC BY-NC-ND 4.0 |</copyright>
    <lastBuildDate>Sun, 02 Aug 2026 10:33:05 +0800</lastBuildDate>
    <atom:link href="https://chaneyzorn.github.io/tags/hash/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>密码哈希的三层防御：盐、慢哈希与恒定时间比较</title>
      <link>https://chaneyzorn.github.io/codes/hash-salt-constant-time-slow-hash/</link>
      <pubDate>Sun, 02 Aug 2026 10:33:05 +0800</pubDate><author>chaneyzorn#gmail#com (ChaneyZorn)</author>
      <guid>https://chaneyzorn.github.io/codes/hash-salt-constant-time-slow-hash/</guid>
      <description><![CDATA[<p>在讨论如何安全存储密码时，经常会遇到三个概念：<strong>盐（salt）</strong>、<strong>慢哈希（slow hash）</strong> 与 <strong>恒定时间比较（constant-time comparison）</strong>。本文以“数据库泄露后攻击者能做什么”为线索，依次看看这三者各自解决什么问题：先用盐消除批量预计算的优势，再用慢哈希提高单次破解的成本，最后用恒定时间比较堵住在线验证环节的旁路。这些概念同样适用于令牌验证、密钥派生等场景，这里以密码存储为主线。</p>
<h2 id="1-背景哈希函数与密码存储面临的攻击">1. 背景：哈希函数与密码存储面临的攻击</h2>
<h3 id="11-密码学哈希在做什么">1.1 密码学哈希在做什么</h3>
<p>密码学哈希函数（如 SHA-256、BLAKE3）把任意长度的输入映射成固定长度的摘要，并满足三个基本性质：</p>
<ul>
<li><strong>单向性</strong>：由摘要反推原始输入在计算上不可行。</li>
<li><strong>抗碰撞</strong>：很难找到两个不同输入产生相同摘要。</li>
<li><strong>雪崩效应</strong>：输入哪怕只改一位，输出也面目全非。</li>
</ul>
<p>这些特性让哈希非常适合校验文件完整性、构造 Merkle 树（一种把大量数据逐层两两哈希、最后归并成一个根摘要的树形结构）、生成数据指纹等场景。并非所有带“哈希”之名的算法都仍满足这些性质：MD5 与 SHA-1 的抗碰撞性已被攻破，不再适合安全场景。</p>
<h3 id="12-密码存储为什么选择哈希">1.2 密码存储：为什么选择哈希</h3>
<p>密码保存常见有三种方式，它们在泄露后的后果各不相同：</p>
<ul>
<li><strong>明文存储</strong>：数据库里直接存 <code>password</code>。一旦泄露，攻击者立刻获得真实密码；由于用户常在多个网站复用密码，泄露的密码还会波及该用户在其他网站的账户。</li>
<li><strong>加密存储</strong>：数据库里存 <code>encrypt(password)</code>。只要解密密钥泄露，所有密码都可逆；而且拥有密钥的服务方内部人员也能看到明文。</li>
<li><strong>哈希存储</strong>：数据库里存 <code>hash(password)</code>。哈希是单向的，泄露后攻击者只能尝试暴力破解，而无法直接还原。</li>
</ul>
<p>三者对比之下，密码更适合用哈希存储，而不是加密或明文保存。接下来的问题是：哈希本身还不够，盐、慢哈希与恒定时间比较各自能补上什么缺口。</p>
<h3 id="13-在线攻击离线攻击与时序旁路">1.3 在线攻击、离线攻击与时序旁路</h3>
<p>三类攻击场景对应三层不同的防御：</p>
<ul>
<li><strong>在线攻击</strong>：攻击者通过正常登录接口不断尝试密码。防御重心在业务层，如速率限制、账户锁定、CAPTCHA、强制使用强密码、多因素认证（MFA）等。</li>
<li><strong>离线攻击</strong>：攻击者已经拿到数据库，在本地用硬件暴力破解。这是盐 + 慢哈希要解决的问题。</li>
<li><strong>时序旁路攻击</strong>：攻击者不直接暴力猜密码，而是通过测量系统响应时间来推断正确值，对应的是恒定时间比较这层防御。</li>
</ul>
<p>盐与慢哈希主要解决离线攻击；恒定时间比较主要加强在线验证环节的安全性。</p>
<h3 id="14-为什么单轮快哈希仍然不够安全">1.4 为什么单轮快哈希仍然不够安全</h3>
<p>假设网站把 <code>SHA256(password)</code> 存进数据库。一旦泄露，攻击者拿到一列哈希值，但这并不意味着“无法利用”：</p>
<ul>
<li>因为 SHA-256 <strong>算得极快</strong>，现代 GPU 或专用芯片每秒可以尝试数十亿个候选密码。</li>
<li>因为相同密码必然得到相同哈希，攻击者可以<strong>一次性预计算</strong>海量常见密码的哈希，然后批量反查。</li>
</ul>
<p>所以，安全存储密码的目标不是“让哈希不可逆”——这本来就由哈希函数保证——而是<strong>让攻击者即使拿到数据库，破解任意一个账户的成本也足够高</strong>。盐、慢哈希与恒定时间比较正是围绕这个目标的三层防御。</p>
<h2 id="2-盐让相同的密码不再相同">2. 盐：让相同的密码不再相同</h2>
<h3 id="21-什么是盐">2.1 什么是盐</h3>
<p><strong>盐是一段随机生成的、与密码一起输入哈希函数的额外数据</strong>。它不需要保密，通常与哈希结果一起存在数据库里。验证登录时，系统取出该用户的盐，用同样的算法重新计算，再与库存值比对。</p>
<p>用伪代码表示：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">salt</span> <span class="o">=</span> <span class="n">generate_random_bytes</span><span class="p">(</span><span class="mi">16</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">stored</span> <span class="o">=</span> <span class="n">slow_hash</span><span class="p">(</span><span class="n">password</span><span class="p">,</span> <span class="n">salt</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 库存：salt + stored</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>其中重要的是：<strong>每个用户的盐应当不同</strong>。即使两个用户都用了 <code>123456</code> 作为密码，因为盐不同，数据库里出现的哈希值也完全不同。</p>
<h3 id="22-为什么需要盐从预计算到彩虹表">2.2 为什么需要盐：从预计算到彩虹表</h3>
<p>如果没有盐，相同密码会产生相同哈希。攻击者拿到泄露的数据库后，可以发动两类攻击：</p>
<ol>
<li><strong>预计算攻击</strong>：提前把常见密码（如 Top 100 万弱口令）全部算一遍哈希，做成“密码 → 哈希”对照表。泄露发生后直接查表，就能快速反查出大量账户。</li>
<li><strong>彩虹表攻击</strong>：如果直接把全部常见密码的哈希存成一张表，空间开销会非常大。彩虹表是一种用时间换空间的折中方案。</li>
</ol>
<h4 id="彩虹表是怎么工作的">彩虹表是怎么工作的</h4>
<p>直接保存“密码 → 哈希”对照表的问题在于空间：想覆盖 1000 万个密码，就要存 1000 万条记录。彩虹表的思路是把哈希计算组织成“链”，只保存链的两头。</p>
<p><strong>建表</strong>：从一个起点密码出发，交替执行两种操作：</p>
<ol>
<li>哈希：把密码 <code>P</code> 算成哈希值 <code>H(P)</code>；</li>
<li>归约：用<strong>归约函数（reduction function）</strong> 把哈希值映射回密码空间，得到另一个候选密码。归约函数不是哈希的逆运算，它只是按确定的规则把哈希值换算成密码空间中的一员，例如把哈希值当作大整数，对空间大小取模后再换算成字符组合。实际建表时还可以把密码空间划分成若干子段，让每张表的归约结果只落在自己负责的子段内。</li>
</ol>
<p>交替执行下去就形成一条链：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">P0 → H(P0) → P1 → H(P1) → P2 → … → H(Pn)
</span></span></code></pre></td></tr></table>
</div>
</div><p>这条链是<strong>完全确定</strong>的：哈希与归约都是确定性函数，起点 <code>P0</code>、归约函数与链长一经确定，链上每个中间值就随之固定，仅凭起点即可把整条链重新推演出来。因此表中只需保存链的起点 <code>P0</code> 与终点 <code>H(Pn)</code>：终点充当查找时的“锚点”，起点留着供命中后重算。一条走过 1000 个密码的链，在表里只占 2 条记录；用大量这样的链覆盖密码空间，就得到一张彩虹表。</p>
<p><strong>查找</strong>：关键是确定目标哈希值 <code>h</code> 在链上的位置，但位置事先未知，只能逐个假设来试：假设 <code>h</code> 在链的倒数第 1 个位置，就对 <code>h</code> 做一次归约，看结果是否等于某条链的终点；不等，就假设它在倒数第 2 个位置，从 <code>h</code> 出发多走一段（归约、哈希、再归约），再比对终点；依此类推。一旦命中某条链的终点，说明 <code>h</code> 可能在这条链上，于是从该链起点重新计算整条链，找到 <code>h</code> 出现的位置，它前面的那个密码就是答案。终点命中也可能是链间重合造成的空命中：重算后没找到 <code>h</code>，就继续尝试其余假设。</p>
<p>实际彩虹表还会为链上每个位置使用不同的归约函数 <code>R1</code>、<code>R2</code>、…、<code>Rn</code>，“彩虹”即由此得名，这样可以减少链与链之间的重合。这不影响上述查找：每条链的终点都固定在第 n 个位置，因此“假设 <code>h</code> 在倒数第 k 个位置”就唯一确定了从哪个归约函数开始、依次使用哪几个；从起点重算时位置从头数起，函数选择同样确定。</p>
<p>彩虹表的本质可以从两个角度看：一是<strong>空间换时间</strong>——建表时投入大量离线计算存成表，换取破解时的快速反查；二是<strong>时间换空间</strong>——链上只存起点与终点，查找时靠逐步推演补回中间值，换取存储的压缩。链越长，省下的空间越多，推演所需的步数也越多。彩虹表对无盐哈希非常有效：一张表可以覆盖整个数据库。</p>
<p>盐的作用，对比两条链就能看清：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">无盐：  P0 → H(P0)     → P1 → H(P1)     → P2 → … → H(Pn)
</span></span><span class="line"><span class="cl">加盐：  P0 → H(P0 + S) → P1 → H(P1 + S) → P2 → … → H(Pn + S)
</span></span></code></pre></td></tr></table>
</div>
</div><p>无盐链的轨迹完全由密码空间决定，而密码空间（P0 … Pn）是攻击者可以预先圈定的，因此链可以提前离线算好、存成彩虹表反复使用，一张表覆盖所有用户。</p>
<p>加盐之后，轨迹还取决于盐。每个用户的盐都是注册时随机生成的，泄露之前攻击者一个都无法预知，自然无法提前离线建表。</p>
<p>即使泄露后拿到了全部盐，也只是确定了盐空间：哪个密码配哪个盐正是攻击者要破解的问题，事先无从知晓，覆盖范围无法收窄到实际出现的组合，只能按盐分别建表——为每个用户各建一张。此时链反解的是用户 i 的哈希 <code>H(P + S_i)</code>，一个确定的函数，因此整条链每一步都用同一个 <code>S_i</code> 计算。建一张表的成本与直接暴力破解该用户相当，对其他用户又毫无用处，预计算优势由此被消除。</p>
<p>从需要覆盖的空间来看，<strong>盐把攻击者的预计算对象从密码空间扩大到了“密码空间 × 盐空间”</strong>：盐取 128 bit 时，空间膨胀为原来的 2¹²⁸ 倍，离线预计算在可行性上被直接排除。</p>
<h3 id="23-密码哈希的存储格式mcf-与-phc-string-format">2.3 密码哈希的存储格式：MCF 与 PHC String Format</h3>
<p>现代密码哈希库通常把算法标识、参数、盐和哈希结果编码成一条字符串，方便直接存入数据库。这类格式主要有两种：MCF（Modular Crypt Format）与 PHC String Format。两种格式的字段分隔符都是 <code>$</code>；字符串里出现的 <code>/</code>、<code>+</code>、<code>.</code> 等只是 Base64 编码的字符，不是分隔符（下文的例子特意选了不含 <code>/</code> 和 <code>.</code> 的结果；实际生成的字符串中常常含有这些字符）。</p>
<h4 id="bcrypt-的-mcf-格式">bcrypt 的 MCF 格式</h4>
<p>MCF 源自 Unix <code>crypt(3)</code> 的密码字段格式，以 <code>$</code> 分隔算法标识、参数、盐与哈希。除 bcrypt 的 <code>$2a$</code>/<code>$2b$</code> 外，常见的标识还有 <code>$5$</code>（SHA-256-crypt）、<code>$6$</code>（SHA-512-crypt）、<code>$y$</code>（yescrypt）等。bcrypt 自身也有版本演进：<code>$2a$</code> 是早期版本，<code>$2b$</code> 修复了旧实现中的边界处理问题，现代库一般生成 <code>$2b$</code>。</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$2b$12$zOZm7DPI3xf8RBF3NINMrOH3RDXP45tx9QBvH7rUv4b7ehXbEBCLi
</span></span><span class="line"><span class="cl">\_____/\____________________/\_____________________________/
</span></span><span class="line"><span class="cl"> Alg+Cost      Salt                       Hash
</span></span></code></pre></td></tr></table>
</div>
</div><ul>
<li><code>$2b$</code>：算法标识与版本。</li>
<li><code>12</code>：cost 因子（work factor），表示迭代次数为 <code>2^12 = 4096</code> 轮。</li>
<li>接下来的 22 个字符：Base64 编码的 16 字节盐（bcrypt 使用自定义的 Base64 变体字符集）。</li>
<li>最后的 31 个字符：Base64 编码的 24 字节哈希结果。</li>
</ul>
<h4 id="phc-string-formatargon2scrypt-等">PHC String Format（Argon2、scrypt 等）</h4>
<p>PHC（Password Hashing Competition）格式更通用，结构为：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$&lt;id&gt;$&lt;param&gt;$&lt;salt&gt;$&lt;hash&gt;
</span></span></code></pre></td></tr></table>
</div>
</div><p>以 Argon2id 为例：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$argon2id$v=19$m=65536,t=3,p=4$MbG5aRrwDWQy888L+KXuWg$PJgn2gwI2umUFPnkVnC74i6pbG2sDSXapgJwCjTZhpg
</span></span><span class="line"><span class="cl">\________/\___/\______________/\____________________/\_______________________________________/
</span></span><span class="line"><span class="cl">   Alg      Version   Params            Salt                            Hash
</span></span></code></pre></td></tr></table>
</div>
</div><ul>
<li><code>$argon2id$</code>：算法标识。</li>
<li><code>v=19</code>：版本号。</li>
<li><code>m=65536,t=3,p=4</code>：内存、时间、并行度参数。</li>
<li><code>MbG5aRrwDWQy888L+KXuWg</code>：Base64 编码的盐。</li>
<li>最后一段：Base64 编码的哈希输出。</li>
</ul>
<p>这种“自描述”格式的好处是：验证时只需要这一条字符串，就能完整还原出当初使用的算法、参数和盐，无需在数据库的其他字段中另外记录这些信息。</p>
<h3 id="24-盐该怎么用">2.4 盐该怎么用</h3>
<ul>
<li><strong>每个用户、每次改密码都使用新的随机盐</strong>。盐的长度至少 128 bit（16 字节），应使用密码学安全伪随机数生成器（CSPRNG，例如操作系统提供的 <code>/dev/urandom</code> 或语言库里的 <code>crypto/rand</code>、<code>secrets.token_bytes</code> 等）生成。</li>
<li><strong>盐不需要保密</strong>，但要保证唯一性与不可预测性。把它和哈希值一起存即可。</li>
<li><strong>不建议自行设计哈希拼接格式</strong>，例如 <code>sha256(username + password + salt)</code>。专门设计的密码哈希算法已经内置了盐与慢哈希机制，并且能避免编码、长度、截断等细节错误。</li>
<li><strong>胡椒（pepper）</strong> 是与盐类似但全局统一、不存数据库（或存于 HSM/独立密钥管理服务）的密钥。它可以作为额外防御层，但一旦泄露所有用户都会受影响，且实现复杂度更高，通常不是必须。</li>
</ul>
<h2 id="3-慢哈希降低单次破解速度">3. 慢哈希：降低单次破解速度</h2>
<p>盐解决了“批量预计算”问题，但没有解决“暴力破解单个账户”的问题。如果哈希计算很快，攻击者仍然可以逐个尝试海量密码。</p>
<h3 id="31-为什么普通哈希太快了">3.1 为什么普通哈希太快了</h3>
<p>SHA-256 的设计目标之一是<strong>快</strong>。这对校验文件完整性是优点，对密码存储却是缺点：现代 GPU、FPGA（现场可编程门阵列）和 ASIC（专用集成电路）每秒可以计算数十亿次 SHA-256。如果密码哈希只是 <code>SHA256(password + salt)</code>，攻击者仍然可以在离线环境下快速遍历整个字典。</p>
<h3 id="32-慢哈希与密钥派生函数">3.2 慢哈希与密钥派生函数</h3>
<p>慢哈希（slow hash）属于密钥派生函数（KDF, Key Derivation Function）的一种，专门设计成计算成本高，以抵抗离线暴力破解。它的核心思想是：让合法用户登录时等待几十到几百毫秒，却让攻击者在离线尝试时付出远高于合法用户的计算代价。</p>
<p>KDF 并非都是慢的。另一类快速 KDF（如 HKDF）用于从高熵密钥材料（例如密钥交换的结果、主密钥）派生子密钥，追求计算效率。选择依据是输入的熵：输入本身随机性足够时用快速 KDF；输入是用户密码这类低熵数据时用慢哈希。</p>
<p>常见慢哈希算法包括：</p>
<table>
  <thead>
      <tr>
          <th>算法</th>
          <th>特点</th>
          <th>推荐场景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>bcrypt</strong></td>
          <td>基于 Blowfish，固定 72 字节输入，自带盐与 cost</td>
          <td>密码存储经典选择</td>
      </tr>
      <tr>
          <td><strong>scrypt</strong></td>
          <td>内存困难，除时间成本外还消耗大量内存</td>
          <td>抵抗 ASIC/FPGA 的能力较强</td>
      </tr>
      <tr>
          <td><strong>Argon2</strong></td>
          <td><a href="https://password-hashing.net/">2015 年密码哈希竞赛</a>冠军，参数可调（时间、内存、并行度）</td>
          <td><strong>当前首选</strong>，推荐 Argon2id</td>
      </tr>
      <tr>
          <td><strong>PBKDF2</strong></td>
          <td>迭代次数可调，<a href="https://csrc.nist.gov/pubs/sp/800/132/final">NIST SP 800-132</a> 推荐</td>
          <td>兼容要求高或合规场景</td>
      </tr>
  </tbody>
</table>
<p>这些算法的共同点是有一个可调的 <strong>cost 因子（work factor）</strong>，例如 bcrypt 的 cost、PBKDF2 的迭代次数、Argon2 的 <code>time_cost</code> 与 <code>memory_cost</code>。随着硬件性能提升，应定期提高这些参数。</p>
<p>PBKDF2 与 bcrypt 的“慢”主要来自<strong>密钥拉伸（key stretching）</strong>：把哈希运算迭代成千上万次，使单次计算成本线性放大。scrypt 与 Argon2 在迭代之外又增加了内存维度，见下节。</p>
<p>Argon2 有三个变体：Argon2i 使用数据无关的内存访问模式，侧重抵抗时序旁路；Argon2d 使用数据相关的内存访问模式，侧重抵抗 GPU 破解；Argon2id 是两者的混合。一般场景推荐 Argon2id，可同时获得两类防护。</p>
<h3 id="33-内存困难有什么用">3.3 内存困难有什么用</h3>
<p>GPU/ASIC 在并行计算 SHA-256 时效率极高，很大程度上是因为 SHA-256 只需要很少的内存。内存困难函数（如 scrypt、Argon2）要求计算过程中占用大量 RAM。大规模并行时，内存成本会急剧上升——攻击者需要为每个并行线程准备一份内存，而芯片上的高速缓存/内存非常昂贵。这就削弱了专用硬件相对普通 CPU 的优势。</p>
<h3 id="34-慢哈希的实际配置思路">3.4 慢哈希的实际配置思路</h3>
<p>以 Argon2id 为例（Go 语言 <code>golang.org/x/crypto/argon2</code> 或 Python <code>argon2-cffi</code>）：</p>
<ul>
<li><code>time</code>（迭代次数）：根据可接受的验证延迟设置，通常 1–3 次迭代。</li>
<li><code>memory</code>：尽可能大，常见为 64 MiB，安全敏感场景可达 512 MiB 或更高。</li>
<li><code>threads</code>（并行度）：与服务器 CPU 核心数匹配。</li>
</ul>
<p>目标是让正常用户的登录延迟仍在可接受范围内（例如几百毫秒），却让攻击者的离线破解成本大幅提高。</p>
<h3 id="35-成本因子需要随时间升级">3.5 成本因子需要随时间升级</h3>
<p>硬件性能在持续提升，三年前“足够慢”的参数今天可能已经不再安全。因此，<strong>cost 因子应当定期提高</strong>。</p>
<p>常见的平滑升级做法是：数据库里记录当前使用的 cost；用户登录时先用旧参数验证；一旦验证通过，立即用新的更高参数重新计算哈希并更新数据库。这样，活跃用户会逐步迁移到新参数，而无需强制所有用户立刻修改密码。</p>
<h2 id="4-恒定时间比较堵住在线验证的旁路">4. 恒定时间比较：堵住在线验证的旁路</h2>
<p>盐与慢哈希主要应对“数据库泄露后的离线破解”。攻击者即使无法直接拿到数据库，仍可能通过测量系统响应时间来推断密码——恒定时间比较针对的正是这类问题。</p>
<h3 id="41-普通比较有什么问题">4.1 普通比较有什么问题</h3>
<p>很多语言中 <code>==</code> 或 <code>strncmp</code> 在发现第一个不同字节时就会提前返回。这看起来是优化，却泄露了信息：</p>
<ul>
<li>比较时间越长，说明前缀匹配越多。</li>
<li>攻击者可以通过测量大量请求的响应时间，逐字节推断出正确值。</li>
</ul>
<p>这就是<strong>时序旁路攻击（timing side-channel attack）</strong>。它不需要攻击者能读取内存或数据库，只需要能精确测量响应时间。</p>
<p>时序只是旁路的一种。广义的旁路攻击还包括缓存、功耗、电磁泄露等信息泄露渠道，各自的防护方式不同；就 Web 密码验证而言，时序是最主要的风险来源。</p>
<h3 id="42-恒定时间比较怎么做">4.2 恒定时间比较怎么做</h3>
<p>恒定时间比较函数会：</p>
<ol>
<li>先检查两个字符串长度是否相等，不等则直接返回。这一步确实会泄露“长度是否一致”，但恒定时间比较要保护的是内容而非长度；在比较哈希、MAC、签名这类定长值时，长度由算法决定，本来就是公开信息。</li>
<li>按位遍历所有字节，无论是否相同，都做异或累加。</li>
<li>最后根据累加结果是否为零返回布尔值。</li>
</ol>
<p>这样，无论两个字符串在第几位不同，执行路径与耗时都基本一致。伪代码如下：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">constant_time_compare</span><span class="p">(</span><span class="n">a</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">,</span> <span class="n">b</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">a</span><span class="p">)</span> <span class="o">!=</span> <span class="nb">len</span><span class="p">(</span><span class="n">b</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">x</span><span class="p">,</span> <span class="n">y</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">result</span> <span class="o">|=</span> <span class="n">x</span> <span class="o">^</span> <span class="n">y</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">result</span> <span class="o">==</span> <span class="mi">0</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>各语言库对长度差异的处理与这里一致或更细致：Go 的 <code>crypto/subtle.ConstantTimeCompare</code> 在长度不等时立即返回（文档明确写明）；Python 的 <code>hmac.compare_digest</code> 尽量抹平长度差异带来的耗时差别，但文档同样注明长度信息理论上可能泄露、值不会泄露。如果长度本身需要保密，常见做法是先把两个值各自做一次哈希（或 HMAC）再比较，让被比较的值总是定长的。</p>
<p>实际工程中应直接使用语言/框架提供的安全比较函数，例如：</p>
<ul>
<li>Python: <code>hmac.compare_digest</code></li>
<li>Go: <code>crypto/subtle.ConstantTimeCompare</code></li>
<li>Rust: <code>subtle::ConstantTimeEq</code></li>
<li>Node.js: <code>crypto.timingSafeEqual</code></li>
</ul>
<h3 id="43-什么时候用什么时候没那么重要">4.3 什么时候用、什么时候没那么重要</h3>
<ul>
<li><strong>适合使用的场景</strong>：比较 API Key、Session Token、HMAC（基于哈希的消息认证码）、JWT 签名、TOTP（基于时间的一次性密码）等。</li>
<li><strong>也建议使用的场景</strong>：比较密码哈希结果，作为纵深防御的一环。</li>
<li><strong>作用有限的情况</strong>：如果攻击者已经拿到了数据库里的哈希值，时序旁路攻击的防护就失去了意义。真正的防线仍然是盐 + 慢哈希。</li>
</ul>
<h2 id="5-三者如何协同">5. 三者如何协同</h2>
<p>一个现代密码验证流程大致是：</p>
<ol>
<li>用户注册/改密码时，生成随机盐。</li>
<li>用 Argon2id/bcrypt/scrypt 把密码与盐转换成慢哈希值。</li>
<li>数据库中存储包含算法、参数、盐与哈希的完整字符串（如 PHC 格式）。</li>
<li>登录时，取出该用户的盐与参数，用同样算法重新计算。</li>
<li>用 <code>constant_time_compare</code> 比较计算结果与库存值，返回登录是否成功。</li>
</ol>
<p>这三者各司其职：</p>
<ul>
<li><strong>盐</strong>：消除相同密码的哈希一致性，挫败彩虹表与批量预计算。</li>
<li><strong>慢哈希</strong>：提高单次哈希计算成本，让暴力破解成本过高。</li>
<li><strong>恒定时间比较</strong>：堵住在线验证环节的时序旁路，避免攻击者通过响应时间逐字节猜测。</li>
</ul>
<h2 id="6-几个容易混淆的问题">6. 几个容易混淆的问题</h2>
<ul>
<li><strong>盐需要保密吗？</strong> 不需要。它的作用是打乱输入，不是加密密钥。</li>
<li><strong>sha256(password + salt) 够用吗？</strong> 不够。缺少慢哈希与恒定时间比较，且容易在格式、编码、截断上出错。</li>
<li><strong>用了 HTTPS 还需要恒定时间比较吗？</strong> 需要。HTTPS 防的是传输层窃听，时序攻击仍然可以通过请求响应时间进行。</li>
<li><strong>慢哈希会让网站变慢吗？</strong> 验证密码本就不是高频操作，几百毫秒的用户可接受延迟换取数据库泄露时的安全收益，是值得的。</li>
<li><strong>加盐后密码就一定安全吗？</strong> 盐只解决特定问题。弱密码仍可能被字典攻击（按常见口令列表逐个尝试）或针对性破解，只是成本被大幅提高。</li>
<li><strong>彩虹表和字典攻击是一回事吗？</strong> 不是，区别在存的内容：字典存的是密码本身，攻击时逐个现场算哈希比对；彩虹表存的是提前算好的“哈希 → 密码”对应关系（链条只是它的压缩形式），拿到哈希直接反查。预计算的对应关系会被盐打乱，所以盐能击败彩虹表；现场计算的字典攻击不受盐影响，那是慢哈希要应对的。</li>
<li><strong>哈希过的密码可以找回或解密吗？</strong> 不能。哈希是单向的，忘记密码时只能重置，无法把原密码发回给用户。</li>
<li><strong>网站数据库没泄露，是不是就不用担心？</strong> 即使本网站数据库安全，用户也可能在别的网站泄露过相同密码，攻击者会用这些密码来尝试登录你的站点，这叫<strong>凭据填充攻击（credential stuffing）</strong>。强密码策略、检测异常登录、MFA 是这方面的主要防御。</li>
</ul>
<h2 id="7-小结">7. 小结</h2>
<ul>
<li><strong>盐</strong>：随机、公开、唯一的数据，让相同密码产生不同哈希，抵御彩虹表与批量预计算。</li>
<li><strong>恒定时间比较</strong>：消除字符串比较的时间差异，防止时序旁路攻击。</li>
<li><strong>慢哈希</strong>：通过高计算成本与内存困难性，显著提高暴力破解的成本。</li>
</ul>
<p>在实际工程中，稳妥的做法是不自己实现密码哈希方案：选择经过审计的库与算法（Argon2id、bcrypt、scrypt），正确配置参数，并在比较敏感值时使用语言提供的恒定时间比较函数。</p>
<h2 id="参考链接">参考链接</h2>
<ul>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">OWASP Password Storage Cheat Sheet</a> 密码存储的工程实践清单</li>
<li><a href="https://password-hashing.net/">Password Hashing Competition</a> PHC 官网，Argon2 为 2015 年胜出算法</li>
<li><a href="https://www.rfc-editor.org/rfc/rfc9106.html">RFC 9106: Argon2 Memory-Hard Function</a> Argon2 的规范文档，含参数选择建议</li>
<li><a href="https://www.usenix.org/legacy/events/usenix99/provos.html">A Future-Adaptable Password Scheme</a> bcrypt 原始论文（USENIX 1999）</li>
<li><a href="https://www.tarsnap.com/scrypt/scrypt.pdf">Stronger Key Derivation via Sequential Memory-Hard Functions</a> scrypt 原始论文</li>
<li><a href="https://csrc.nist.gov/pubs/sp/800/132/final">NIST SP 800-132</a> PBKDF2 的正式标准</li>
<li><a href="https://github.com/C2SP/C2SP/blob/main/phc-strings.md">PHC string format 规范</a> <code>$id$param$salt$hash</code> 编码格式的定义</li>
<li><a href="https://doi.org/10.1007/978-3-540-45146-4_36">Making a Faster Cryptanalytic Time-Memory Trade-Off</a> 彩虹表原始论文（Oechslin, CRYPTO 2003）</li>
<li><a href="https://docs.python.org/3/library/hmac.html#hmac.compare_digest">Python hmac.compare_digest</a> 官方文档</li>
<li><a href="https://pkg.go.dev/crypto/subtle">Go crypto/subtle</a> 官方文档</li>
</ul>
]]></description>
    </item>
  </channel>
</rss>
