题目与适用范围
一个拥有 10,000,000 个账户的 SaaS 正在改造密码认证。当前数据库中约 7,000,000 条记录是 cost 为 10 的 bcrypt,2,000,000 条是 210000 次迭代的 PBKDF2-HMAC-SHA256,另有 1,000,000 条是没有独立盐值的 SHA-256。现有记录格式不统一,部分行无法直接看出算法和参数。认证服务峰值需要处理每秒 1500 次密码验证,单个实例最多允许 24 个高成本哈希并发。
请设计迁移到 Argon2id 的完整方案。回答需要说明威胁模型、参数选择与容量测量、可演进的存储格式、混合算法验证、登录时升级、并发密码修改、长期不登录账户、pepper 管理、数据库或密钥泄露后的响应、账户枚举与资源耗尽防护,以及上线和完成标准。题目中的账户数、算法比例、旧参数和并发数只是面试约束,不是通用安全阈值。
这是一道后端安全与认证工程题。重点是验证器和迁移状态机,不要求设计完整的注册、找回密码、MFA 或会话系统;这些流程只在影响凭据迁移和泄露处置时讨论。
面试官在考什么
第一,候选人能否区分在线猜测与离线破解。限流能约束登录接口,却无法限制拿到哈希库的攻击者。密码是低熵的人类秘密,SHA-256 这类快速摘要会让攻击者高速枚举候选;专用密码哈希通过独立盐值、可调计算成本和内存成本提高每次猜测的代价。
第二,候选人是否理解参数必须在真实容量上测量。说“使用 Argon2id”只是算法选择。一个完整方案还要决定内存 m、迭代 t、并行度 p、盐值和输出长度,并计算并发验证对内存、CPU、延迟和拒绝服务面的影响。参数越高并不自动越安全;如果服务在攻击流量下耗尽内存,认证可用性会先失守。
第三,候选人能否迁移不可逆数据。系统拿不到用户明文密码,不能离线把 bcrypt 结果“解密后转换”为 Argon2id。常规路径是在一次旧算法验证成功后,用本次提交的明文重新计算当前格式;长期不登录账户则需要保留受控的旧验证器、做临时包裹,或要求重置,选择取决于旧算法风险和泄露历史。
最后,优秀回答会处理竞态与运维边界。登录升级不能覆盖同时发生的密码重置;pepper 不应和哈希库一起存放;算法版本、参数和迁移状态需要可观测,但日志不能包含密码、完整哈希或 pepper。完成标准也不能只写“新代码已发布”。
回答前先确认
- 现有每种格式能否可靠识别? 需要知道算法、参数、字符编码、盐值位置和历史库版本。无法识别的行不能猜测算法后尝试多个验证器。
- 旧 bcrypt 如何处理超过 72 字节的输入? 必须复现历史实现的编码和截断语义,避免迁移时静默改变用户实际凭据。
- 数据库、备份或旧哈希是否曾经泄露? 已泄露的无盐快速摘要不能靠给当前数据库再包一层就撤回,可能需要强制重置和会话处置。
- 认证实例的真实资源预算是多少? 需要基线内存、CPU 配额、哈希并发上限、自动扩缩容速度、目标 p95/p99 延迟和可接受失败率。
- 是否存在 FIPS 等合规约束? 这会影响可选算法和实现,但不能用合规标签代替参数测量与迁移设计。
- pepper 是否已经存在? 要确认保存位置、调用依赖、版本、轮换能力、审计边界和密钥服务不可用时的降级策略。
- 密码修改和会话签发的事务边界是什么? 迁移成功、并发重置和会话签发的顺序必须明确,否则旧密码可能在重置后仍换取新会话。
- 长期不活跃账户的业务价值和风险如何分层? 高权限账户、近期活跃账户和多年未登录账户可以采用不同截止日期和重验证要求。
30 秒回答框架
“我先把目标定义为抵抗哈希库泄露后的离线猜测,并另外保护在线认证容量。新密码使用带随机独立盐值的 Argon2id,记录中保存算法、版本、m/t/p、盐值和输出;pepper 若启用,则放在数据库之外的密钥系统中。参数以 OWASP 当前最低配置作为候选起点,再在生产同规格实例上按并发内存、CPU 和 p99 延迟压测,不能照抄一个数值。
登录时先根据记录版本选择唯一旧验证器。验证成功后计算当前 Argon2id 记录,用旧哈希作为条件做 compare-and-swap;若并发修改导致失败,就重读并验证最新记录,不能覆盖密码重置。新建和重置密码直接写当前格式。无盐 SHA-256 账户设置更短迁移期限;没有可靠安全包裹或已发生泄露时要求重置。
接口使用统一错误、虚拟当前哈希、账户与来源双维度限流和有界哈希并发,避免枚举及内存耗尽。上线后按算法版本观察迁移比例、延迟、内存、失败、CAS 冲突和重置完成率;只有旧高风险格式清零、竞态测试与峰值压测通过、密钥轮换演练完成,才宣布迁移结束。”
分步详解
第一步:先画清在线与离线攻击面
正常登录是在线路径。攻击者受到接口吞吐、账户限流、来源限流、风险控制和监控约束。数据库泄露后的猜测是离线路径:攻击者在自己的 GPU、ASIC 或云实例上运行验证算法,不受应用限流控制。密码存储主要要提高后一条路径中每次候选尝试的成本。
无盐 SHA-256 有两个问题。它计算很快,同一密码还会得到同一摘要,使攻击者能复用预计算结果并识别密码复用群组。每条记录独立的随机盐值使相同密码产生不同输出,迫使攻击者逐条计算;盐值可以和哈希一起保存,它不需要保密。盐值不能把快速 SHA-256 变成合格的密码哈希,真正的计算阻力来自专用、可调且最好具备内存成本的算法。
Pepper 是另一层控制。它是跨记录共享或按版本管理的服务端秘密,必须和密码数据库分离,通常放在密钥管理系统、HSM 或受保护执行环境中。它可以降低“只泄露数据库”时的风险,但无法代替独立盐值和慢速哈希。一旦数据库与 pepper 同时泄露,旧 pepper 对应的记录不能仅靠数据库批处理安全换钥,因为系统没有用户明文。
第二步:选择参数并计算认证容量
对新系统,选择维护良好的 Argon2id 实现。OWASP 当前给出的最低候选之一是 m=19456 KiB、t=2、p=1。RFC 9106 还给出更高内存的通用推荐,包括内存受限场景下 64 MiB、3 次迭代、4 路并行的方案。两份资料的目标和运行约束不同,不能把任意一组数字当成所有 Web 服务的固定答案。
更可靠的选择过程是:
- 在与生产相同的 CPU、内存限制和运行时上,从经过审查的候选配置开始;
- 测量单次验证的中位数、p95、p99、实际常驻内存和 CPU 时间;
- 用正常峰值、登录洪峰、错误密码和不存在账户的混合流量压测;
- 设置有界并发,观察排队时间、容器 OOM、CPU throttling 和上游超时;
- 在可用性预算内选择尽可能高的攻击成本,并记录硬件、库版本和测量日期。
以 19 MiB 为例,24 个同时运行的哈希仅工作内存理论值就是 456 MiB,还未包含进程基线、库开销、请求对象和安全余量。如果单次验证在目标实例上需要 250 毫秒,一个实例的粗略上限约为每秒 96 次完成;1500 次每秒至少需要约 16 个始终可用的同规格实例,实际还要为尾延迟、故障和扩容滞后留余量。这些计算只用于暴露容量级别,最终值必须来自压测。
盐值可采用每条记录独立生成的 128 位随机值,输出可采用 256 位。使用成熟库生成并解析标准编码,避免自己拼接密码学字段。密码输入在哈希前应遵循稳定、经过记录的字节编码;如果支持 Unicode,需要明确规范化策略并保持注册、登录和迁移一致。
第三步:让凭据记录能够自描述和演进
记录需要携带验证所需的非秘密信息。例如 Argon2 编码可以表达为:
$argon2id$v=19$m=19456,t=2,p=1$SALT_BASE64$TAG_BASE64旧格式也要建立确定性映射,例如 {bcrypt}、{pbkdf2-sha256}、{sha256-legacy}。标签属于解析协议,不代表安全背书。未知标签、缺失字段、非法 Base64 或超出允许范围的参数都应拒绝并进入受控修复队列,不能依次尝试多个算法直到某个结果匹配。
参数解析必须有上限。攻击者如果能篡改记录,可能把内存或迭代参数改成极大值,借登录请求消耗服务资源。验证器只接受部署允许的算法和参数范围;存储值超界时记录不含敏感数据的安全事件,并要求账户恢复。
凭据表至少要支持当前编码、凭据更新时间和安全处置状态。迁移报表可以从编码前缀聚合算法版本,或维护低基数的 scheme_id。不要把完整哈希、盐值、候选密码、pepper 版本秘密或验证中间值写入日志、指标标签和分析系统。
第四步:在成功登录时安全升级
迁移函数先解析记录,再调用唯一匹配的验证器。只有旧凭据验证成功,系统才拥有本次请求中的正确明文,可以计算当前 Argon2id 记录。昂贵计算放在数据库短事务之外,写入使用旧记录作条件:
record = loadCredential(userId)
ok = verifyByDeclaredScheme(record.hash, submittedPassword)
if !ok: rejectWithGenericError()
if needsRehash(record.hash):
upgraded = hashWithCurrentPolicy(submittedPassword)
changed = compareAndSwap(userId, expected=record.hash, replacement=upgraded)
if !changed:
latest = loadCredential(userId)
if !verifyByDeclaredScheme(latest.hash, submittedPassword):
rejectAndAskForFreshLogin()
issueSessionAfterCredentialStateIsConfirmed()Compare-and-swap 防止登录升级覆盖同时发生的密码重置。条件更新失败可能表示另一次登录已完成相同迁移,也可能表示用户刚换了不同密码。必须重读并用本次输入验证最新记录;不能因为旧记录曾经匹配就继续签发会话。新注册、主动改密和找回密码都直接写当前格式,不再产生旧记录。
升级写入失败时,登录是否允许继续要由风险边界决定。可重试的数据库错误可以暂时保留旧记录并记录迁移失败,但高风险旧格式可以规定“升级成功才允许签发会话”。无论采用哪种策略,都要限制重试,避免一次登录重复执行多个高成本哈希。
第五步:分别处理可接受旧格式与高风险旧格式
Bcrypt 和足够强的 PBKDF2 可以在受控期限内保留只读验证能力,并在成功登录后升级。旧验证器需要最小化暴露,只用于存量记录,不能继续创建新凭据。监控每种格式的剩余账户数、活跃程度和迁移速度,到达截止日期后删除旧验证能力前,必须先处理仍依赖它的账户。
无盐 SHA-256 风险更高。对未发生泄露且无法立刻要求全部用户重置的系统,可以把现有摘要作为输入,再用带新盐值的慢速算法做一次离线包裹,作为临时数据库加固。验证时先按历史规则计算 SHA-256,再验证外层;用户成功登录后仍要把真实提交密码改写为标准 Argon2id 记录。
这种包裹不等同于 Argon2id(password)。它无法撤回曾经泄露的内层摘要,也无法修复历史编码、截断或弱密码问题。如果旧哈希或备份已经外泄、账户权限较高,或无法确信格式语义,应把账户置为需要重置,撤销相关会话,并通过独立验证流程恢复。多年不登录的普通账户也可以在截止日期后冻结认证,等用户回来再走找回流程,而不是永久保留最弱验证器。
第六步:设计 pepper 版本和泄露响应
如果使用 pepper,可以在密码哈希前采用受审查的 keyed 预处理,或对哈希输出再做 HMAC;具体组合应由成熟库和安全评审确定。数据库记录只保存非秘密的 pepper 版本标识,实际密钥留在数据库与备份之外。认证服务通过最小权限访问,缓存时间和密钥服务故障策略需要明确。
常规轮换可以短期同时持有“当前”和“上一版”密钥。旧版验证成功后,用用户提交的明文和当前 pepper 重算完整记录。不能无限保留旧密钥;轮换计划必须统计剩余旧版本账户,并为未迁移账户安排重置或冻结。
泄露响应按证据分层:
- 只泄露数据库:立即保全证据、修复入口、提高监控,评估算法与参数后决定是否强制重置;pepper 仍保密可以提供额外时间,但不能保证弱密码安全。
- 只泄露 pepper:轮换密钥,调查调用日志,并评估攻击者是否同时可能取得哈希库。
- 数据库与对应 pepper 均泄露:按密码哈希暴露处理,强制受影响账户重置,撤销或缩短相关会话,并提醒用户处理复用密码。
轮换成功不代表历史泄露消失。处置记录要包含受影响版本、账户范围、备份、会话、通知、完成比例和残留例外。
第七步:同时防止枚举和资源耗尽
不存在账户、错误密码、停用账户和迁移失败对外返回统一的错误语义,状态码和响应形态也应保持一致。不存在账户仍运行一个受控的当前策略虚拟哈希,减少“直接返回”和“执行昂贵验证”之间的明显时间差。混合旧算法可能产生不同耗时,还需通过迁移、运行时补偿和统计测量减少可利用差异;不应承诺网络上的每次响应完全等时。
昂贵哈希本身会形成拒绝服务面。进入哈希队列前,应用要执行不泄露账户存在性的粗粒度来源限流和请求合法性检查;对允许进入的请求,再使用账户维度与来源维度的独立配额、全局有界队列和每实例 24 个并发许可。单纯按“IP 加用户名”组合键限流会让攻击者不断更换其中一项绕过总量约束。
队列满时快速返回统一的临时失败,不能无界堆积请求。扩容指标应包含排队时间、哈希并发、内存余量和 CPU throttling,而不只是请求数。日志只记录账户的不可逆内部标识、算法低基数版本、结果类别、限流维度和延迟桶;禁止记录提交密码、完整凭据记录或密钥材料。
第八步:分阶段发布并用证据验收
先做只读盘点,确认每条旧记录可解析,并在隔离环境用已知测试向量验证历史实现。随后发布“可读所有受支持旧格式、只写当前格式”的代码。先让新注册和改密流量写入 Argon2id,再小比例启用登录升级,观察 CAS 冲突、验证失败和资源曲线,最后逐步扩大。
上线前至少执行以下验证:
- 每种历史格式的正确密码、错误密码、边界长度、Unicode 和损坏记录测试;
- 登录升级与并发密码重置竞态,证明旧登录不能覆盖新密码或签发错误会话;
- 当前、上一版和未知 pepper 的轮换与故障演练;
- 1500 次每秒目标负载和攻击混合流量下的延迟、内存、CPU、队列和扩容测试;
- 不存在账户与各类失败响应的消息、状态、体积和时延分布检查;
- 数据库、备份、日志、指标和错误追踪中敏感字段的扫描。
迁移看板按算法和风险层统计总数、最近活跃数、每日成功升级数、失败原因、强制重置完成率和截止日期。技术完成标准包括:所有新写入都采用当前策略;无盐 SHA-256 和已泄露版本清零;不再需要的旧验证器已删除;剩余可接受旧格式符合书面例外;峰值压测与竞态测试通过;pepper 轮换和恢复演练有证据;认证 SLO 在约定范围内。
高质量示范回答
“我会先区分两个攻击面。在线攻击受接口限流约束,哈希库泄露后的离线攻击不受约束,所以密码必须用带独立随机盐值、可调成本且内存困难的专用哈希。新记录选择成熟实现的 Argon2id,并自描述算法版本、m/t/p、盐值和输出。Pepper 若启用,会按版本放在数据库和备份之外的密钥系统中。
参数不会从博客直接复制。我会把 OWASP 当前的 19 MiB、t=2、p=1 作为最低候选之一,在生产同规格实例上测量单次和并发的 p50/p95/p99、CPU 与真实内存,再用正常峰值和攻击流量压测。19 MiB 乘 24 个并发已经是 456 MiB 工作内存,所以服务要有有界队列、并发许可和足够实例余量。盐值使用成熟库为每条记录随机生成,记录参数以便以后升级。
登录时只按记录声明的格式选择验证器。旧密码验证成功后,在数据库事务外计算当前 Argon2id 记录,再以旧哈希为条件更新。若 CAS 失败,我会重读最新凭据并再次验证本次输入;这样并发登录可以收敛,而并发密码重置不会被旧登录覆盖。确认凭据仍有效后才签发会话。注册、主动改密和找回流程从第一天起只写新格式。
我会给 bcrypt 与仍可接受的 PBKDF2 一个明确迁移期,只保留验证能力。无盐 SHA-256 进入更高风险队列。未曾泄露时可以用带新盐值的慢速外层做短期包裹,但它不能替代用真实密码重哈希;已泄露、高权限和长期不活跃账户在截止日前重置或冻结。旧验证器不会永久保留。
接口使用统一错误和虚拟当前哈希减少账户枚举差异,并结合来源、账户两个独立限流维度、全局队列和内存并发上限防止哈希型拒绝服务。完成证据包括版本分布、高风险旧格式清零、迁移失败与 CAS 冲突、峰值压测、竞态测试、敏感日志扫描、pepper 轮换和泄露演练。只有这些指标通过且旧验证器按计划下线,我才宣布迁移结束。”
常见错误与改进
- 用 SHA-256 加盐保存密码 → 盐值阻止跨账户复用计算,却不降低单次高速猜测能力 → 使用专用、可调且内存困难的密码哈希。
- 宣称哈希无法破解 → 弱密码仍可通过候选猜测得到匹配 → 把目标表述为提高每次离线猜测的成本,并配合密码阻止列表和 MFA。
- 把 OWASP 参数当成永久最佳值 → 硬件、库、实例资源和流量会变化 → 记录基准环境,定期测量并按版本升级。
- 离线“转换”所有 bcrypt 为 Argon2id → 不知道明文时无法得到标准新哈希 → 在成功验证时重哈希,剩余账户按风险包裹、重置或冻结。
- 迁移更新不带旧值条件 → 登录可能覆盖刚完成的密码重置 → 使用 compare-and-swap,失败后重读并重新验证。
- 哈希时持有用户行锁 → 高成本计算放大锁时间和连接池占用 → 事务外计算,短事务条件写入。
- 把 pepper 存在同一数据库配置表 → 一次数据库泄露同时获得两层材料 → 把密钥放在独立受保护系统并按版本审计。
- 轮换 pepper 只改环境变量 → 旧记录无法使用新密钥直接验证 → 保留短期双版本验证,并在成功登录后完整重算或要求重置。
- 不存在账户立即返回 → 响应时间暴露账户是否存在 → 使用统一错误、虚拟哈希和实际时延分布测试。
- 无限并发执行 Argon2id → 攻击者能放大内存和 CPU 消耗 → 在双维度限流后使用有界队列和并发许可。
- 忽略 bcrypt 的输入边界 → 历史 72 字节语义可能在迁移后改变凭据 → 复现旧验证行为,并对超界账户要求显式改密。
- 只看新注册已用 Argon2id → 大量活跃或高风险旧记录仍暴露 → 按算法、风险和活跃度追踪存量,设置清零与例外标准。
延伸追问
追问一:为什么盐值可以明文保存,pepper 却要保密?
盐值的职责是让每条记录的计算输入不同,避免相同密码共享输出和预计算成果;攻击者知道盐值后仍必须逐条猜测。Pepper 的额外价值来自攻击者只拿到数据库时还缺少一个服务端秘密,因此它必须与哈希库分离。两者职责不同,pepper 不能代替每条记录的独立盐值。
追问二:能否在数据库里直接提高 Argon2id 的迭代次数?
不能从旧输出推导出使用更高参数的标准密码哈希,因为缺少用户明文。可以把旧输出再包一层作为临时加固,但记录语义会改变,也不能消除旧输出曾泄露的风险。标准升级仍应在正确密码验证成功时重算,或要求用户重置。
追问三:CAS 失败后为什么不能直接允许登录?
失败既可能是另一请求完成了同一迁移,也可能是用户同时通过找回流程设置了新密码。后一种情况下,本次旧密码已经失效。重读最新记录并验证本次输入,才能区分两种情况;验证失败就不能签发会话。
追问四:Argon2id 参数越高越好吗?
更高内存或时间成本会增加离线攻击成本,也会增加合法登录的资源消耗。过高参数会造成尾延迟、队列积压、实例 OOM,甚至让低成本请求变成拒绝服务放大器。合适参数是在实际硬件、并发、扩容和 SLO 约束下测得的最高可持续成本,并需要随硬件和威胁变化重新评估。
追问五:如何处理五年未登录但仍是 bcrypt 的账户?
先按账户权限、泄露历史和旧参数判断风险。普通低风险账户可以在迁移截止日后冻结密码登录,用户回来时通过受控恢复流程设置新密码;高权限或受影响账户应更早强制重置并撤销会话。永久保留旧验证器会让迁移永远无法结束。