Skip to content

feat(security): make session encryption a first-class deployment control - #28

Merged
Xander-Xai merged 1 commit into
mainfrom
security/session-encryption-deployment-controls
Oct 21, 2026
Merged

Xander-Xai merged 1 commit into
mainfrom
security/session-encryption-deployment-controls

Conversation

@Xander-Xai

Copy link
Copy Markdown
Owner

会话数据加密此前只有代码侧能力(envelope + fail-closed),生产配置侧几乎是空白。本 PR 补齐配置与可观测层,全部改动非破坏性:不删除、不迁移、不改写任何既有会话记录,不轮换任何既有密钥,不切换任何真实部署的 reject 模式。

1. 部署契约 —— SESSION_ENCRYPTION_KEY 进入生产配置

compose 的 app / worker / canary / scale / prod 现在都注入
SESSION_ENCRYPTION_KEY / SESSION_ENCRYPTION_REQUIRED / SESSION_ENCRYPTION_PLAINTEXT_COMPAT / SESSION_ENCRYPTION_KEY_PREVIOUS。
.env.prod 模板带占位符且 SESSION_ENCRYPTION_REQUIRED=true;generate_prod_env.py 生成 32 字节随机密钥。

app 与 worker 必须用同一把密钥(worker 也读写会话记录,且与 app 共用同一会话存储);canary / scale 同理。

compose 层刻意不用 ${SESSION_ENCRYPTION_KEY:?} —— 那会让 docker compose config 在配置阶段失败,既有生产环境连诊断都进不去。策略放在应用启动校验里,错误信息指名变量、可操作。

2. 生产模式的明确安全策略

新增 SESSION_ENCRYPTION_REQUIRED(默认 false = 保持既有部署行为):置 true 后,生产缺少密钥、或密钥仍是占位符/弱值,一律拒绝启动(fail closed),而不是像此前那样只打一条 warning 就继续以明文运行。

收紧不破坏既有真实数据:legacy 模式下历史明文仍可读,被改写的会话自动升级为密文。「配密钥」与「迁移历史明文」是两件独立的事。

3. 无破坏密钥轮换

新增 SESSION_ENCRYPTION_KEY_PREVIOUS(逗号分隔历史密钥)。没有 key-id 时直接换密钥 = 一次性作废全部既有密文。现在:旧密钥进 previous,旧密文仍可解密,新写入用当前密钥,旧会话在被改写时自动升级为密文。

scripts/rotate_secrets.py 已接入该密钥,并在轮换时自动把旧值写入 previous。session_encryption_key_fallback_total 让运维能判断轮换是否排空。

4. 错误契约与可观测性

  • SessionEncryptionError 增加 API 异常处理器:HTTP 500 + 稳定错误码 SESSION_ENCRYPTION_ERROR。响应体只含错误码,不含密钥、密文、记录内容、session_id。
  • 三个新指标(label 只能是 kind/reason,绝不携带会话标识):session_encryption_error_total{reason}、session_legacy_plaintext_read_total{kind}、session_encryption_key_fallback_total。
  • session_legacy_plaintext_read_total 是迁移前的盘点信号:切 reject 之前必须先知道历史明文还有多少,现在不再只能靠猜。

5. 存储访问控制(部署要求)

scripts/pre_deploy_check.sh 新增:强制加密下密钥缺失/占位符 → 阻断;Redis 默认密码 changeme → 告警;REDIS_URL 未用 rediss:// → 告警;明文兼容策略取值非法 → 阻断。

没有改造 Redis 容器(加 ACL / 拆凭据会破坏既有部署),因此存储侧访问控制仍是 NOT_VERIFIED。

边界(不夸大)

验证(基线 SHA a362fe1)

项 结果
pytest tests/unit/ 3545 passed, 5 skipped(3550 collected,新增 24 条)
pytest tests/integration/runtime/(真实 PG 15.18 + Redis 7.4.9 + Celery 5.6.3) 199 passed
pytest tests/unit/test_session_encryption_deployment.py 24 passed(新增)
scripts/audit_doc_consistency.py OK(57 active documents)
scripts/check_public_repository_boundary.py OK
ruff check . + ruff format --check .(canonical 0.4.0) All checks passed / 413 files formatted
make openapi-check OK(63 paths)
docker compose config(base / +prod / +canary / +scale) 全部 OK,四个变量正确落到 app 与 worker

安全验收对照

验收项 覆盖
配置加密时不会静默回退明文 TestProductionEncryptionPolicy / TestReadPathFailureModes::test_fernet_unavailable_never_falls_back_to_plaintext
生产缺少必要加密配置时有明确安全策略 TestProductionEncryptionPolicy::test_required_without_key_is_an_error / test_required_with_placeholder_key_is_an_error
错误密钥、损坏密文、历史明文均有测试 TestReadPathFailureModes(wrong key / corrupt / legacy plaintext / reject)
不泄露密钥、令牌或会话内容 TestNoSecretLeak(错误信息 + API 响应体)
不把存储加密误认为能防住存储写权限的攻击者 TestStorageWriteAccessIsNotDefended(钉住不可区分性)
不把合成测试宣称为真实生产迁移验证 本 PR 描述与 docs/limitations.md §9 均如实标注

会话数据加密此前只有**代码侧**能力(envelope + fail-closed),生产配置侧几乎
是空白:`SESSION_ENCRYPTION_KEY` 不在任何 compose 环境块里,`SESSION_ENCRYPTION_REQUIRED`
不存在,密钥轮换没有受支持的路径,`SessionEncryptionError` 没有 API 契约,
"还剩多少历史明文"只能靠猜。

本 PR 补齐**配置与可观测**层,全部改动**非破坏性**:不删除、不迁移、不改写任何
既有会话记录,不轮换任何既有密钥,不切换任何真实部署的 reject 模式。

## 1. 部署契约(SESSION_ENCRYPTION_KEY 进入生产配置)

compose 的 app / worker / canary / scale / prod 现在都注入
`SESSION_ENCRYPTION_KEY` / `SESSION_ENCRYPTION_REQUIRED` /
`SESSION_ENCRYPTION_PLAINTEXT_COMPAT` / `SESSION_ENCRYPTION_KEY_PREVIOUS`。
`.env.prod` 模板带占位符且 `SESSION_ENCRYPTION_REQUIRED=true`;
`generate_prod_env.py` 会生成 32 字节随机密钥。

app 与 worker 必须用**同一把**密钥(worker 也读写会话记录,且与 app 共用同一
会话存储);canary / scale 同理。

compose 层刻意**不用** `${SESSION_ENCRYPTION_KEY:?}`:那会让 `docker compose
config` 在配置阶段失败,既有生产环境连诊断都进不去。策略放在**应用启动校验**
里,错误信息指名变量、可操作。

## 2. 生产模式的明确安全策略

新增 `SESSION_ENCRYPTION_REQUIRED`(默认 false = 保持既有部署行为):
置 true 后,生产缺少密钥、或密钥仍是占位符/弱值,一律**拒绝启动**(fail closed),
而不是像此前那样只打一条 warning 就继续以明文运行。

为什么是开关而不是无条件强制:无条件强制会让「刚升级 compose、还没配密钥」的
既有生产环境直接起不来。开关把收紧变成一次显式、可审计的部署决策。

**收紧不破坏既有真实数据**:legacy 模式下历史明文仍可读,被改写的会话自动升级
为密文。「配密钥」与「迁移历史明文」是两件独立的事。

## 3. 无破坏密钥轮换

新增 `SESSION_ENCRYPTION_KEY_PREVIOUS`(逗号分隔的历史密钥)。没有 key-id 时,
直接换密钥 = 一次性作废全部既有密文。现在:旧密钥进 previous,旧密文仍可解密,
新写入用当前密钥,旧会话在被改写时自动升级为密文。

`scripts/rotate_secrets.py` 已接入该密钥,并在轮换时**自动**把旧值写入 previous
(而不是直接替换)。`session_encryption_key_fallback_total` 计数让运维能判断
轮换是否排空。

## 4. 错误契约与可观测性

- `SessionEncryptionError` 增加 API 异常处理器:HTTP 500 + 稳定错误码
  `SESSION_ENCRYPTION_ERROR`。响应体**只含**错误码,不含密钥、密文、记录内容、
  session_id。
- 新增三个指标(label 只能是 kind/reason,绝不携带会话标识):
  `session_encryption_error_total{reason}`、`session_legacy_plaintext_read_total{kind}`、
  `session_encryption_key_fallback_total`。
- `session_legacy_plaintext_read_total` 是**迁移前的盘点信号**:切 reject 之前
  必须先知道历史明文还有多少,现在不再只能靠猜。

## 5. 存储访问控制(部署要求)

`scripts/pre_deploy_check.sh` 新增:强制加密下密钥缺失/占位符 -> 阻断;
Redis 默认密码 `changeme` -> 告警;`REDIS_URL` 未用 `rediss://` -> 告警;
明文兼容策略取值非法 -> 阻断。

**没有**改造 Redis 容器(加 ACL / 拆凭据会破坏既有部署),因此存储侧访问控制
仍是 **NOT_VERIFIED**。

## 边界(不夸大)

- 会话加密保护的是**记录内容**,**不是会话存储**。拥有 Redis / 会话文件写权限的
  攻击者仍能替换记录 —— 真正的控制是存储侧访问控制(Issue #25)。
- 本 PR 全部测试使用**合成数据**(tmp_path / 内存替身),验证的是**治理机制**,
  **不是**真实生产会话迁移的验证。
- 仍**没有**明文->密文的批量回填脚本、dry-run 盘点器、备份/回滚步骤。
- **Issue #25 保持 OPEN**:关闭条件(真实数据 artifact + 来源可验证性结论 +
  回填完成 + reject 启用证据)一项都未满足。
- 未对生产 Redis 做任何操作、未执行真实会话迁移、未轮换既有密钥、未切换真实
  部署的 reject 模式。

## 验证(基线 SHA a362fe1)

| 项 | 结果 |
|---|---|
| `pytest tests/unit/` | **3545 passed, 5 skipped**(3550 collected,新增 24 条) |
| `pytest tests/integration/runtime/`(真实 PG 15.18 + Redis 7.4.9 + Celery 5.6.3) | **199 passed** |
| `pytest tests/unit/test_session_encryption_deployment.py` | 24 passed(新增) |
| `scripts/audit_doc_consistency.py` | OK(57 active documents) |
| `scripts/check_public_repository_boundary.py` | OK |
| `ruff check .` + `ruff format --check .`(canonical 0.4.0) | All checks passed / 413 files formatted |
| `make openapi-check` | OK(63 paths) |
| `docker compose config`(base / +prod / +canary / +scale) | 全部 OK,四个变量正确落到 app 与 worker |
@Xander-Xai
Xander-Xai merged commit e18751e into main Oct 21, 2026
11 checks passed
@Xander-Xai
Xander-Xai deleted the security/session-encryption-deployment-controls branch October 21, 2026 07:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant