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}"
实现要求:
- 创建统一的
CryptoService类,提供: -encryptField(string $plaintext, string $aad): string— 返回带版本前缀的存储字符串 -decryptField(string $stored, string $aad): string|null— 解密失败返回 null 并记录审计日志,不抛出未捕获异常导致页面报错 - 内部按v1:前缀分发到对应版本的解密逻辑,为未来v2:预留分支结构(哪怕现在只有一个case) - 所有业务模型(Model/Entity)中标记为"加密字段"的属性,统一通过
CryptoService读写,不允许任何 Controller/Service 直接调用sodium_crypto_aead_* - 明确哪些字段加密、哪些不加密,写入项目文档
docs/encrypted-fields.md: - 加密:用户 PII(手机号/身份证/地址等业务涉及的)、第三方 API 密钥/OAuth token、用户私有内容正文 - 不加密:文章标题、分类、标签、浏览量、创建时间等公开/低敏感字段 - 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 — 密钥生成、存储、轮换说明
九、执行步骤(请按顺序进行,每步完成后输出变更摘要)
- 扫描现有代码库,列出所有涉及密码存储、敏感字段读写、token 生成、session 处理、备份导出的位置
- 创建上述
src/Security/下的服务类骨架 - 实现
PasswordService,替换现有密码哈希逻辑,并提供迁移脚本(如老数据是明文或其他哈希算法,需要在下次登录时自动升级,不要批量强制重置密码) - 实现
CryptoService,设计并迁移敏感字段到信封加密结构,编写数据迁移脚本(读取旧明文 → 加密 → 写回,必须支持幂等重跑和失败回滚) - 实现
TokenService,替换现有 token 逻辑为 selector+validator 模式 - 调整 Session 配置参数,分离 CSRF token 逻辑
- 实现
BackupCryptoService,接入现有备份导出/恢复流程 - 加入启动期环境检测
- 补充单元测试:至少覆盖加密/解密往返正确性、AAD 不匹配时解密失败、token 过期/吊销后验证失败、
hash_equals使用是否正确 - 输出一份变更总结,列出:新增文件、修改文件、需要人工配置的环境变量/密钥、需要运行的数据迁移脚本及顺序
十、明确不要做的事情
- 不要自己实现任何对称加密/哈希算法(不写手工 XOR、不写自定义 PRNG)
- 不要对 CSRF token 使用数据库持久化
- 不要在备份之外再叠加文件系统/磁盘层加密
- 不要添加任何"兼容旧 PHP 版本"或"sodium 不可用时降级"的分支逻辑
- 不要把所有字段都无差别加密,先确认字段敏感度分级后再决定
- 不要在每次请求的热路径上重新执行 Argon2id/KEK 派生运算
请严格按照以上规范执行重构,如遇到本文档未覆盖的具体场景(如特定字段的加密边界判断),先列出问题和你的建议方案,等待确认后再动手,不要自行假设。
评论(0)