PHP 遗留密文迁移到 AEAD 的完整指南:AES-GCM、XChaCha20-Poly1305、封装协议与安全回滚

在 PHP 遗留系统中引入 AEAD(Authenticated Encryption with Associated Data,带关联数据的认证加密),绝不是简单地把 openssl_encrypt() 的算法名称改成 aes-256-gcm。历史系统中的密文可能混用 AES-CBC、AES-CTR、固定 IV、独立 HMAC,甚至无法准确描述的自定义格式;不同线上节点的 PHP 版本、OpenSSL 版本和 ext/sodium 能力也可能存在差异。

一次可控的 PHP 密文迁移,应同时解决加密算法升级、旧数据兼容、密文格式设计、密钥轮换、运行时能力检查、数据重加密和应用回滚等问题。目标可以明确为:新写入数据使用成熟的 AEAD 算法;旧数据在受控窗口内继续可读;密文能够识别格式版本、算法和密钥版本;应用回滚后仍能读取已经产生的新格式数据。

其中,回滚边界尤其重要。回滚并不意味着可以任意退回到尚不认识新密文的旧版本。团队应先发布具备新格式读取能力的兼容基线,再切换新格式写入。

一、先盘点 PHP 遗留密文的完整生命周期

迁移前的盘点不能只停留在搜索 openssl_encrypt() 或 openssl_decrypt() 调用。应以密文生命周期为单位,确认数据由谁加密、写入何处、何时读取、是否跨服务传输、是否长期归档,以及客户端或第三方是否会保存密文。

  • 短期会话与缓存值:通常可以随过期自然淘汰,但仍需确认缓存失效和回滚期间的读取行为。
  • 数据库长期字段:通常采用“读旧写新”,并通过后台任务逐步完成存量数据重加密。
  • 令牌、回调参数与队列消息:必须协调生产者、消费者和重试机制的发布顺序。
  • 外部协议字段:密文格式属于兼容性契约,不能由单个服务单方面修改。
  • 归档与备份数据:需要同步规划历史密钥保留时间和恢复演练。

同时记录旧加密方案使用的字符编码、Base64 变体、填充规则、IV 来源、MAC 覆盖范围、密钥派生方式和错误处理行为。迁移代码不能根据猜测兼容旧格式。

如果旧格式没有可靠的完整性校验,成功解密只代表程序产生了某些字节,并不能证明数据未被篡改。对于这类旧密文,应用还必须执行严格的业务结构校验、数据归属校验和字段范围校验,并将无法确认完整性的结果纳入风险处理流程。

二、根据部署约束选择 AEAD 算法

1. 优先评估 XChaCha20-Poly1305

对于不受既有协议限制的新格式,可以优先评估 ext/sodium 提供的 sodium_crypto_aead_xchacha20poly1305_ietf_encrypt()。XChaCha20-Poly1305 使用 24 字节 nonce,适合多个 PHP-FPM worker、容器实例或批处理节点并行生成随机 nonce。

每次加密都应使用 random_bytes() 生成新的 nonce,并将 nonce 与 AEAD 输出一起保存。随机 nonce 必须来自可靠的系统随机源,不能替换为时间戳、短随机数、数据库自增值或其他可预测标识。

2. 在互操作场景使用 AES-256-GCM

当跨语言互操作、合规配置或既有基础设施要求 AES-GCM 时,可以使用 OpenSSL 的 aes-256-gcm。ext/sodium 也提供 AES-256-GCM 接口,但调用前必须使用 sodium_crypto_aead_aes256gcm_is_available() 检查当前平台是否支持,不能假设所有部署节点都具备 AES 硬件或对应运行时能力。

AES-GCM 的核心安全约束是:同一密钥下不得重复 nonce。12 字节 nonce 是常见的互操作选择。系统可以使用经过容量评估的随机 nonce,也可以使用具备持久化、并发协调和故障恢复能力的计数方案,但不能依赖在进程或容器重启后归零的内存计数器。

如果采用随机 nonce,应根据单把密钥的最大加密次数评估碰撞概率,并在达到工程上限前轮换密钥。密钥轮换策略应与密文存量、业务保留周期和灾备恢复要求同时设计。

遗留 CBC 或自定义“加密加签”代码只能保留在受限读取路径,并设定明确的退役条件。不要为了迁移继续创建新的遗留格式,也不要自行实现 AEAD 原语、nonce 扩展算法或认证标签拼接逻辑。

三、把密文封装定义成稳定协议

生产密文应具备自描述能力,但不能包含密钥材料或敏感业务名称。一个基本的封装记录可以包含格式版本、算法标识、密钥标识、nonce 和 AEAD 输出:

format_version | algorithm_id | key_id | nonce | aead_output

不同密码库对 AEAD 输出的表达方式并不相同。ext/sodium 的 XChaCha20-Poly1305 接口返回密文与认证标签的组合结果;OpenSSL 的 GCM 接口通常通过独立参数返回 tag。因此,封装层必须针对每个 algorithm_id 固定 nonce 长度、tag 长度、字段顺序和编码规则,解析器不得根据剩余长度猜测算法。

format_version 表示整个封装协议的语义版本。即使底层算法不变,只要 AAD 编码、字段布局、长度表达或外层编码发生变化,也应升级格式版本。

key_id 仅用于定位密钥,应采用无敏感含义、长度受限且字符集固定的标识,例如轮换代号。不能把原始密钥、租户名称、用户标识或其他敏感元数据写入密文头。

AAD 如何绑定业务上下文

关联数据(AAD)用于绑定不需要保密、但不允许被替换的上下文,例如格式版本、记录类型、字段标识、不可变租户 ID 和业务对象 ID。不要把可变显示名、请求时间或未经规范化的 PHP 数组直接作为 AAD。

AAD 编码应集中实现,并固定字段顺序、字符编码、长度表达和缺失值语义。长度前缀二进制编码或经过明确规范的确定性编码,通常比临时拼接分隔符更可靠。加密和解密两侧必须使用完全一致的 AAD,否则认证将失败。

ext/sodium XChaCha20-Poly1305 写入示例

function encryptV2(
    string $plaintext,
    string $key,
    string $aad,
    string $keyId
): string {
    if (strlen($key) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
        throw new InvalidArgumentException('Invalid AEAD key length.');
    }

    if (!preg_match('/A[a-zA-Z0-9_-]{1,32}z/D', $keyId)) {
        throw new InvalidArgumentException('Invalid key identifier.');
    }

    $nonce = random_bytes(
        SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES
    );

    $sealed = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
        $plaintext,
        $aad,
        $nonce,
        $key
    );

    $payload = rtrim(
        strtr(base64_encode($nonce . $sealed), '+/', '-_'),
        '='
    );

    return 'v2.xc20p.' . $keyId . '.' . $payload;
}

以上代码用于说明封装思路,并不是完整的生产协议实现。读取端还应限制总长度和字段数量,严格解码 Base64URL,拒绝未知版本、未知算法、非法 key_id 以及长度不符合协议的 nonce 或密文。

解密认证失败时,只能返回统一的失败结果,不能把未经认证的明文交给业务层。密钥查找也应留在加密函数之外,由受控的密钥解析器根据 key_id 获取对应密钥。

不要让底层加密函数自行遍历环境变量、配置文件和密钥服务。否则密钥轮换、访问权限和审计行为会隐藏在难以测试的分支中,增加故障排查和安全审计成本。

四、通过兼容基线设计安全回滚

迁移期间,解密入口应先严格解析格式前缀,再分派到对应的解密器。不要采用“先尝试新算法,失败后再尝试旧算法”的探测逻辑。认证失败、安全策略拒绝和格式错误应被明确分类;模糊回退可能掩盖数据损坏,也可能形成不受控的降级路径。

  1. 先发布兼容版本,使其能够读取旧格式和新格式,但仍只写入旧格式。
  2. 确认所有读取节点、异步消费者、定时任务和灾备环境都已达到兼容基线。
  3. 在各类运行环境中检查 PHP 扩展、算法可用性、密钥权限和配置一致性。
  4. 按租户、数据表或业务场景逐步启用新格式写入,并保留快速停止开关。
  5. 使用可重试的后台任务重加密存量数据,记录旧格式数量、失败分类、处理进度和检查点。
  6. 经过业务保留期、备份恢复验证和监控观察后,再删除旧写入逻辑、旧读取逻辑和旧密钥。

回滚开关应停止新格式写入,但必须保留新格式读取。应用只能回滚到已经具备新格式读取能力的兼容基线。如果部署系统允许退回更早版本,就必须通过发布策略阻止该操作,或者先为旧版本补充兼容读取能力。

功能开关只能控制业务行为,不能让不认识新密文的代码突然获得解密能力。因此,代码发布顺序和功能开关顺序必须分离管理。

后台重加密如何避免并发覆盖

后台重加密需要防止并发请求覆盖更新。任务读取旧密文后,应通过版本列、条件更新或事务确认记录未被其他请求修改,再写入新密文。

重加密任务必须可以重复执行,但不能因为重试而对已经是新格式的数据再次层层加密。任务应识别当前格式版本,并为每条记录设计明确的幂等条件。

五、设计 AEAD 密钥轮换与灾备恢复

AEAD 密钥应由 KMS、HSM、受控密钥服务或权限隔离的注入机制提供。PHP 进程通常只应获得特定用途和特定环境的数据加密密钥(DEK),而不是长期根密钥。

一种常见架构是使用密钥加密密钥(KEK)包装 DEK,应用仅在获得授权的环境中解包并使用 DEK。具体实现仍需结合 KMS 的权限模型、密钥版本管理、审计能力和故障恢复机制。

服务端随机生成的 DEK 应直接使用 CSPRNG 产生符合算法要求的字节,不需要再经过口令 KDF。只有当密钥来源确实是口令等低熵输入时,才应使用 sodium_crypto_pwhash() 等口令派生接口,并为每条派生记录保存独立的随机 salt 和明确的参数版本。直接对口令执行一次 SHA-256 不能替代安全的口令 KDF。

轮换时应创建新的 key_id,新写入使用新 DEK,读取路径根据密文中的密钥标识选择当前密钥或历史密钥。不要在同一 key_id 下原地替换密钥材料,否则旧密文会立即失去可读性。

删除历史密钥前,必须同时检查在线数据、延迟消息、归档、备份以及恢复演练所需的保留周期。密钥删除的时间点应由安全、业务和合规负责人共同确认。

六、覆盖格式解析、认证失败与跨语言互操作测试

测试不能只断言“加密后可以解密”。至少应覆盖以下场景:

  • nonce 长度、密钥长度和 tag 长度校验;
  • 格式版本、算法标识和字段数量的严格解析;
  • 空明文、二进制明文和超长输入;
  • AAD 变化、错误密钥和未知 key_id;
  • 密文、nonce 或认证标签发生单字节修改后的认证失败;
  • 非法 Base64URL、截断数据和超出最大长度的数据;
  • 旧格式与新格式在不同 PHP、OpenSSL 和操作系统环境中的读取结果。

OpenSSL AES-GCM 封装应固定 tag 长度,例如协议明确规定为 16 字节,并在调用 openssl_decrypt() 前自行校验输入 tag 的精确长度。加解密时应显式使用 OPENSSL_RAW_DATA,由封装层自行负责外层编码,并严格区分有效的空明文字符串与失败值 false,不能使用宽松的真假判断。

算法和封装测试应采用来源可追溯的已知答案测试向量,并记录向量出处、适用版本、算法、密钥、nonce、AAD 和明文。互操作测试应在 PHP 与目标语言实现之间固定这些输入,比较完整的密文、tag 和解密结果。

生产环境的加密流程仍必须生成新的 nonce,不能复用测试向量中的固定 nonce。

发布演练应覆盖旧读、新读、旧写和新写的组合,并模拟以下故障:缺少新密钥、字段损坏、扩展不可用、节点版本落后以及重加密任务中断恢复。

认证失败日志只记录受控的错误分类、格式版本和算法标识,不要记录明文、密钥、完整密文或可能包含敏感标识的 AAD。

七、建立可观测的验收与旧格式退役条件

迁移完成不能只观察新格式写入比例。建议至少建立以下指标:

  • 各格式的读取量和剩余存量;
  • 认证失败的分类与趋势;
  • 未知密钥标识的数量;
  • 重加密任务的成功数、失败数和重试次数;
  • 各部署节点的 PHP 扩展和算法能力;
  • 消息队列、归档和备份中旧格式数据的数量。

所有指标都应避免高基数标签和敏感数据泄露。日志、指标和告警中的格式版本、算法标识和密钥标识也应经过长度和字符集限制。

只有当在线数据、消息积压、归档和恢复样本都不再依赖旧格式,并且旧密钥保留策略已经经过安全与业务负责人确认后,才能删除旧读取代码。

删除前应执行一次从备份恢复到隔离环境的演练,验证恢复后的应用版本和密钥集合仍然能够读取保留期内的数据。旧格式退役应是可验证的工程结论,而不是基于“看起来已经没有流量”的判断。

参考资料

结语:让 PHP 加密迁移可审查、可回滚、可恢复

PHP 遗留密文迁移到 AEAD,本质上是一项同时涉及协议兼容、运行时治理、数据重加密、密钥生命周期和灾备恢复的工程。

XChaCha20-Poly1305 有利于降低分布式随机 nonce 管理的操作难度;AES-GCM 适合跨语言互操作或既有标准约束场景。但无论选择哪种 AEAD 算法,都无法弥补含糊的密文封装、错误的 nonce 管理、不可追溯的密钥轮换和不清晰的回滚边界。

将格式版本、算法标识、密钥标识和 AAD 上下文定义为明确协议,先建立支持新旧格式读取的兼容基线,再逐步切换新格式写入,最后通过可恢复、可观测且具备幂等性的重加密流程清理存量数据,才能构建安全、可维护、可审计的 PHP 加密演进路径。

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容