大于AI 博客

CMS 安全层重构指令

项目背景与约束

这是一个全新 / 可重构的 PHP CMS 项目,运行环境明确为 PHP 8.1+,不需要兼容老旧主机或旧 PHP 版本。本次重构目标:统一安全层设计,覆盖密码存储、内容加密、Token 体系、Session、CSRF、备份加密,在保证安全性的同时兼顾性能和代码可维护性。

硬性前提(不要添加任何兼容降级分支):

  • 必须要求 ext-sodium 和 PHP 原生 Argon2id 支持(PASSWORD_ARGON2ID)
  • 不写 PBKDF2 / bcrypt 降级逻辑
  • 不做 extension_loaded() 运行时判断,改为启动期 fail-fast 检测
  • 所有加密格式必须带版本号前缀,为未来演进预留空间,即使当前只有一个版本

一、用户密码存储

要求:

  • 使用 password_hash($password, PASSWORD_ARGON2ID, [...]),参数:memory_cost => 65536(64MB)、time_cost => 4、threads => 2
  • 不自己实现任何密码哈希逻辑,不用 md5/sha1/裸 hash()
  • 登录验证时用 password_verify(),验证成功后调用 password_needs_rehash() 检查是否需要用新参数重新哈希并更新存储
  • 数据库密码字段用 VARCHAR(255),足够容纳 Argon2id 编码字符串
  • 创建 PasswordService 类封装 hash() / verify() / needsRehash() 三个方法,业务代码不直接调用 password_* 函数

验收标准:

  • 任意位置的密码存储/校验代码都必须经过 PasswordService
  • 搜索整个代码库确认没有残留的 md5($password) 或类似明文可逆存储

二、内容字段加密(信封加密架构)

技术选型: sodium_crypto_aead_xchacha20poly1305_ietf_*

密钥分层:

  • KEK(Key Encryption Key):从环境变量 APP_KEK(base64 编码,32 字节)或独立密钥文件(权限 600,路径不在 web 根目录)加载,绝不入库、绝不进版本控制
  • DEK(Data Encryption Key):按表/按用途派生或生成,用 KEK 加密后存储在配置表或密钥表中(不是明文存储 DEK)

存储格式(必须严格遵守):

v1:<base64(nonce(24B) || ciphertext || tag(16B))>

AAD(关联数据)绑定规则:

  • 每条记录加密时,AAD 必须包含能唯一标识该记录上下文的信息(如 表名:主键ID),防止密文被搬运到其他行后仍能解密成功
  • 示例:$aad = "posts:{$postId}"

实现要求:

  1. 创建统一的 CryptoService 类,提供: - encryptField(string $plaintext, string $aad): string — 返回带版本前缀的存储字符串 - decryptField(string $stored, string $aad): string|null — 解密失败返回 null 并记录审计日志,不抛出未捕获异常导致页面报错 - 内部按 v1: 前缀分发到对应版本的解密逻辑,为未来 v2: 预留分支结构(哪怕现在只有一个 case)
  2. 所有业务模型(Model/Entity)中标记为"加密字段"的属性,统一通过 CryptoService 读写,不允许任何 Controller/Service 直接调用 sodium_crypto_aead_*
  3. 明确哪些字段加密、哪些不加密,写入项目文档 docs/encrypted-fields.md: - 加密:用户 PII(手机号/身份证/地址等业务涉及的)、第三方 API 密钥/OAuth token、用户私有内容正文 - 不加密:文章标题、分类、标签、浏览量、创建时间等公开/低敏感字段
  4. DEK 的加解密操作必须缓存(登录态下存 session 或请求级内存缓存),不能在每次字段读写时重新从 KEK 派生,避免性能热路径上出现密钥派生开销

搜索需求处理:

  • 如果某个加密字段需要支持精确匹配搜索(如手机号查重),额外维护一个确定性索引列:hash_hmac('sha256', $plaintext, $searchKey),查询时对输入做同样哈希后 WHERE search_index = ?
  • 不要尝试对加密字段做模糊匹配,产品需求上要规避这一点

三、KEK 派生(如涉及"用户口令解锁"场景)

如果项目有"用户输入密码来解锁/访问加密内容"的场景(而不仅是加密静态存储):

function deriveKEK(string $password, string $salt): string
{
    return sodium_crypto_pwhash(
        32, $password, $salt,
        SODIUM_CRYPTO_PWHASH_OPSLIMIT_MODERATE,
        SODIUM_CRYPTO_PWHASH_MEMLIMIT_MODERATE,
        SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13
    );
}
  • $salt 为每用户随机生成一次、存库(不需要保密,只需要唯一)
  • 派生结果只在当次会话期间缓存于 session,不落库
  • 不提供任何 PBKDF2 兜底分支

四、Token 体系(按类型分层,不要统一处理)

4.1 API Token / "记住登录" Token(长期有效、高价值)

格式:selector + validator 两段式

$selector = bin2hex(random_bytes(8));
$validator = bin2hex(random_bytes(32));
$validatorHash = hash('sha256', $validator);
$token = $selector . ':' . $validator; // 只在生成时返回给客户端一次,不再存储明文

// 数据库存储:selector(明文,加唯一索引)+ validatorHash + expires_at + revoked_at

验证逻辑:
[$selector, $validator] = explode(':', $incomingToken, 2);
$record = findBySelector($selector); // 走索引查询
if ($record && !$record->isExpired() && !$record->isRevoked()
    && hash_equals($record->validatorHash, hash('sha256', $validator))) {
    // 验证通过
}

要求:

  • 必须用 hash_equals() 比较,严禁 ===/==
  • 每条 token 记录必须有 expires_at 和 revoked_at 字段
  • 提供"登出时吊销当前 token"和"修改密码时吊销该用户所有 token"的接口
  • 创建 TokenService 封装生成/验证/吊销逻辑,不允许业务代码直接拼接 token 字符串

4.2 CSRF Token

  • 不落库,存储在 Session 中
  • 每个表单渲染时生成或复用 session 中已有的 token
  • 验证时 hash_equals($_SESSION['csrf_token'], $submittedToken)
  • 不要套用 4.1 的 selector+validator 数据库方案,这是过度设计

4.3 Session

  • 优先使用 PHP 原生 session,配置: ```php ini_set('session.cookie_httponly', '1'); ini_set('session.cookie_secure', '1'); // 生产环境强制 HTTPS ini_set('session.cookie_samesite', 'Strict'); // 或 Lax,按业务跨站需求决定 ```
  • 仅当需要"查看/管理所有登录设备"这类功能时才自建 session 表,表结构同样用 selector+validator 模式

五、备份文件加密

技术选型: 复用 sodium_crypto_aead_xchacha20poly1305_ietf,流式分块加密

格式:

文件头:[版本号 1B][nonce前缀 12B]
每个数据块:
  nonce = 前缀(12B) + 块序号(8B, 大端)
  AAD = 块序号(防止分块换序/截断拼接攻击)
  ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(block, AAD, nonce, backupKey)

要求:

  • backupKey 独立于内容加密的 DEK,单独管理(避免备份密钥泄露连带影响在线数据,反之亦然)
  • 不叠加操作系统/磁盘层加密(LUKS/BitLocker 等),避免双重加密导致的密钥管理复杂度上升和"一把密钥丢失即全部不可解"的风险
  • 如需传输到远程存储,用 HTTPS/SFTP 保证传输安全,不额外引入"传输层加密"概念
  • 创建 BackupCryptoService,提供 sealStream() / openStream(),内部处理分块逻辑,业务代码不直接操作 nonce/AAD

六、启动期环境检查(fail-fast)

在应用引导文件(如 bootstrap.php / index.php 入口)中加入:

function assertRequiredExtensions(): void
{
    $required = ['sodium', 'pdo', 'mbstring'];
    foreach ($required as $ext) {
        if (!extension_loaded($ext)) {
            throw new RuntimeException("缺少必需扩展: {$ext},请检查部署环境");
        }
    }
    if (!defined('PASSWORD_ARGON2ID')) {
        throw new RuntimeException('PHP 未编译 Argon2id 支持,请检查部署环境');
    }
}

同时在 composer.json 中声明:
"require": {
    "php": ">=8.1",
    "ext-sodium": "*"
}


七、密钥管理约定

  • KEK / backupKey 一律从环境变量或独立密钥文件加载,文件权限 600,路径在 web 根目录之外
  • .env / 密钥文件加入 .gitignore,并在 README.md / 部署文档中写明生成方式(例如 sodium_crypto_aead_xchacha20poly1305_ietf_keygen() 生成后 base64 存入环境变量)
  • 不同环境(开发/测试/生产)使用不同密钥,避免测试环境密钥泄露影响生产
  • 所有解密失败(tag 校验不通过)必须记录审计日志(时间、操作者、记录标识),不能静默忽略

八、代码组织要求

请按以下结构组织安全相关代码(如项目已有不同结构,按现有规范调整命名但保持职责划分):

src/Security/
  PasswordService.php      — 密码哈希/验证
  CryptoService.php         — 字段级内容加密/解密
  KekDerivationService.php  — 口令到 KEK 的派生(如有此场景)
  TokenService.php          — API/记住登录 token 生成与验证
  BackupCryptoService.php   — 备份文件流式加密
  Bootstrap/EnvironmentCheck.php — 启动期扩展检测
docs/
  encrypted-fields.md        — 哪些字段加密、哪些不加密,及理由
  key-management.md          — 密钥生成、存储、轮换说明

九、执行步骤(请按顺序进行,每步完成后输出变更摘要)

  1. 扫描现有代码库,列出所有涉及密码存储、敏感字段读写、token 生成、session 处理、备份导出的位置
  2. 创建上述 src/Security/ 下的服务类骨架
  3. 实现 PasswordService,替换现有密码哈希逻辑,并提供迁移脚本(如老数据是明文或其他哈希算法,需要在下次登录时自动升级,不要批量强制重置密码)
  4. 实现 CryptoService,设计并迁移敏感字段到信封加密结构,编写数据迁移脚本(读取旧明文 → 加密 → 写回,必须支持幂等重跑和失败回滚)
  5. 实现 TokenService,替换现有 token 逻辑为 selector+validator 模式
  6. 调整 Session 配置参数,分离 CSRF token 逻辑
  7. 实现 BackupCryptoService,接入现有备份导出/恢复流程
  8. 加入启动期环境检测
  9. 补充单元测试:至少覆盖加密/解密往返正确性、AAD 不匹配时解密失败、token 过期/吊销后验证失败、hash_equals 使用是否正确
  10. 输出一份变更总结,列出:新增文件、修改文件、需要人工配置的环境变量/密钥、需要运行的数据迁移脚本及顺序

十、明确不要做的事情

  • 不要自己实现任何对称加密/哈希算法(不写手工 XOR、不写自定义 PRNG)
  • 不要对 CSRF token 使用数据库持久化
  • 不要在备份之外再叠加文件系统/磁盘层加密
  • 不要添加任何"兼容旧 PHP 版本"或"sodium 不可用时降级"的分支逻辑
  • 不要把所有字段都无差别加密,先确认字段敏感度分级后再决定
  • 不要在每次请求的热路径上重新执行 Argon2id/KEK 派生运算

请严格按照以上规范执行重构,如遇到本文档未覆盖的具体场景(如特定字段的加密边界判断),先列出问题和你的建议方案,等待确认后再动手,不要自行假设。

评论(0)

验证码输入图中 4 个字符,不区分大小写;看不清就点图片换一张