---
title: "CMS 安全层重构指令"
slug: "cms-safe-bludit"
date: "2026-10-09 20:40"
updated: "2026-10-09T20:44:10+08:00"
category: "笔记"
description: "项目背景与约束 这是一个全新 / 可重构的 PHP CMS 项目，运行环境明确为 PHP 8.1+，不需要兼容老旧主机或旧 PHP 版本。本次重构目标：统一安全层设计，覆盖密码存储、内容加密、Token 体系、Session、CSRF、备份加密，在保证安全性的同时兼顾性能和代码可维护性。 硬性前提（不要添加任何兼容降级…"
url: https://blog.dayuai.com/cms-safe-bludit
---
## 项目背景与约束

这是一个全新 / 可重构的 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 派生（如涉及"用户口令解锁"场景）

如果项目有"用户输入密码来解锁/访问加密内容"的场景（而不仅是加密静态存储）：

```php
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 两段式**

```php
$selector = bin2hex(random_bytes(8));
$validator = bin2hex(random_bytes(32));
$validatorHash = hash('sha256', $validator);
$token = $selector . ':' . $validator; // 只在生成时返回给客户端一次，不再存储明文

// 数据库存储：selector（明文，加唯一索引）+ validatorHash + expires_at + revoked_at
```

**验证逻辑：**
```php
[$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` 入口）中加入：

```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` 中声明：
```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 派生运算

---

请严格按照以上规范执行重构，如遇到本文档未覆盖的具体场景（如特定字段的加密边界判断），先列出问题和你的建议方案，等待确认后再动手，不要自行假设。

