From 5cff1041a97f2a92cc29fb55f4dd7c60ba343ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=AA=E6=B3=A2?= Date: Mon, 12 Oct 2026 22:24:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20=E8=A1=A5=E9=BD=90=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E5=8A=A0=E5=AF=86=E6=8E=A5=E5=85=A5=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=92=8C=E5=AF=86=E9=92=A5=E5=A4=87=E4=BB=BD=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首次安装默认生成独立加密密钥,更新旧部署保留原配置,通过显式开关进入迁移窗口。增加生产迁移包装入口和完整密钥环备份恢复工具,并说明旧镜像升级、严格迁移、轮换及还原流程。 已通过 Bash 安全环境及密钥备份回归;对应 PowerShell 5.1 和 7 脚本已在 Windows 原生环境各通过 49 项验收。宁波服务器验收尚未执行。 --- .env.sample | 8 ++ .gitignore | 12 ++ README.md | 5 + docs/API_KEY_ENCRYPTION_DEPLOYMENT.md | 135 +++++++++++++++++++++++ docs/Windows一键部署方案.md | 2 + scripts/Initialize-SecurityEnv.ps1 | 50 +++++++++ scripts/Manage-CredentialKeys.ps1 | 68 ++++++++++++ scripts/Migrate-CredentialEncryption.ps1 | 28 +++++ scripts/Test-CredentialKeys.ps1 | 50 +++++++++ scripts/Test-SecurityEnv.ps1 | 20 ++++ scripts/credential-keys.sh | 50 +++++++++ scripts/init-security-env.sh | 56 ++++++++++ scripts/migrate-credential-encryption.sh | 21 ++++ scripts/test-credential-keys.sh | 39 +++++++ scripts/test-security-env.sh | 19 ++++ 15 files changed, 563 insertions(+) create mode 100644 .gitignore create mode 100644 docs/API_KEY_ENCRYPTION_DEPLOYMENT.md create mode 100644 scripts/Manage-CredentialKeys.ps1 create mode 100644 scripts/Migrate-CredentialEncryption.ps1 create mode 100644 scripts/Test-CredentialKeys.ps1 create mode 100755 scripts/credential-keys.sh create mode 100644 scripts/migrate-credential-encryption.sh create mode 100644 scripts/test-credential-keys.sh diff --git a/.env.sample b/.env.sample index 7403d4f..b697694 100644 --- a/.env.sample +++ b/.env.sample @@ -128,6 +128,14 @@ CONFIG_JWT_SECRET= # 启动/升级脚本会为新部署生成;数据库中已有的 JWT 轮转配置优先,不会被覆盖。 CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY= +# 独立凭据加密密钥。新安装由脚本生成,重复部署保留;不得复用 JWT 密钥。 +# 存量部署首次接入:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true ./update.sh +# 未接入的旧部署保持兼容;已有密文时必须恢复原密钥,禁止重新生成。 +CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID= +CONFIG_CREDENTIAL_ENCRYPTION_KEYRING= +# 新安装 false;存量接入 true,迁移 verify --finalize=true 后关闭。 +CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT= + # Canvas CLI / Agent WebSocket 短时票据专用签名密钥。 # 启动和升级脚本会在缺失、过短时自动生成并持久化,无需手动填写。 WS_AUTH_WS_TICKET_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..575ce26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env + +# 私有部署密钥、恢复副本与数据库/队列归档 +.env.* +*.env +!.env*.sample +.env.pre-credential-restore.* +*.credential-keys.* +data/credential-migration/ +data/credential-recovery/ +data/backups/ +data/restores/ diff --git a/README.md b/README.md index 9a6bb33..03bd133 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ Linux IP 模式会自动安装并配置 Nginx,浏览器统一访问 `http://IP 生成结果仅保存在本机 `.env`。除首次安装完成页会明确显示登录用的初始管理员密码外,脚本日志对其他安全密钥只显示变量名,不输出密钥内容。不要把 `.env` 提交到 Git;集群部署时,各 `easyai-server` / `ws-gateway` 实例必须使用同一份持久化配置。 +#### API Key 加密与旧部署升级 + +新安装脚本自动生成独立凭据加密密钥;存量部署默认保留兼容模式,未升级脚本的用户只更新兼容镜像也可继续使用原 API Key 和平台凭据。首次接入使用 `DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true` 执行新版更新脚本,完成历史迁移后关闭明文读取;已有密文时丢失密钥会拒绝启动。数据库归档与完整原 keyring、配置主密钥/JWT 分开备份;新版提供密钥导出/还原和覆盖前预检。操作步骤、Windows 入口、备份还原顺序和回滚条件见 [API Key 加密部署与旧用户兼容](docs/API_KEY_ENCRYPTION_DEPLOYMENT.md)。 + #### 初始管理员密码生命周期 首次安装时,部署脚本会生成 20 位随机密码,写入 `.env` 的 `CONFIG_INITIAL_ADMIN_PASSWORD`,并在部署完成页直接显示: @@ -234,6 +238,7 @@ chmod +x update.sh - 执行 `./update.sh` 后会**命令行内选择**更新方式: - `[1]` 更新并拉取仓库(git pull)+ 更新镜像并重启(**默认**,回车即选) - `[2]` 仅更新镜像并重启(跳过 git pull,适用于有本地修改不想被覆盖的场景) +- API Key 加密首次接入需要新版脚本及历史迁移,请按 [加密升级说明](docs/API_KEY_ENCRYPTION_DEPLOYMENT.md) 执行;只有尚未接入加密的旧部署可以继续仅更新兼容镜像。 - 如果本次**不涉及配置文件更新**(如 `.env*`、`docker-compose.yml`、`easyai-proxy.conf*` 无变更),可直接选择 `[2]`,仅更新后台服务镜像并重启即可。 - 自动化更新可通过变量跳过选择: diff --git a/docs/API_KEY_ENCRYPTION_DEPLOYMENT.md b/docs/API_KEY_ENCRYPTION_DEPLOYMENT.md new file mode 100644 index 0000000..73eadd2 --- /dev/null +++ b/docs/API_KEY_ENCRYPTION_DEPLOYMENT.md @@ -0,0 +1,135 @@ +# API Key 加密部署与旧用户兼容 + +用户 API Key、平台认证、云凭据和存储通道支持分阶段加密升级。未接入加密配置且数据库从未启用加密的旧部署,可以保持旧脚本和 Compose 不变,只更新兼容镜像;现有用户 Key 和平台 Key 的值均保留,不需要重新生成或重新填写。旧模式暂时沿用原存储方式,不能把它描述为已完成加密。 + +## 部署行为 + +| 场景 | 行为 | +| --- | --- | +| 使用新版启动脚本新安装(原来无 `.env`) | Bash/PowerShell 脚本生成独立随机 32 字节 keyring,持久保存;默认关闭旧明文读取 | +| 旧部署仅更新镜像 | 无新增密钥配置时保持旧读写与鉴权;数据库中有密文或加密启用标记时拒绝缺少密钥的启动 | +| 旧部署执行新版脚本 | 默认保留原模式;显式首次接入后新写入加密、历史明文临时可读,重复执行保留 keyring | +| 完成迁移并重启 | 原用户 Key 按哈希鉴权,平台按原凭据执行;永久关闭旧明文读取 | + +数据库保存加密启用标记和密钥验证值。一旦启用,加密密钥丢失、部分配置或错误配置都会报错,不会静默降级或用新的随机密钥替换已有密钥。`verify --finalize=true` 将数据库状态置为严格模式,后续重启即使旧环境仍写着兼容开关 `true`,也不会恢复明文读取。 + +## 私有配置 + +| 变量 | 要求 | +| --- | --- | +| `CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID` | 当前写入密钥 ID,字母、数字、下划线或连字符,长度 1 至 64,大小写精确匹配 | +| `CONFIG_CREDENTIAL_ENCRYPTION_KEYRING` | 单行 JSON 对象;值为独立随机 32 字节密钥的标准 Base64;脚本保留已有条目 | +| `CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT` | 新安装 `false`;存量接入阶段 `true`;迁移完成后 `false` | + +这组密钥与 `CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY`、JWT 和数据库密码独立,不得复用或派生。密钥只存私有 `.env` 或安全配置中心;Linux/macOS 文件为 0600,Windows 使用当前用户私有 DACL。数据库备份和密钥备份分开保管。更换目录、重新生成配置或恢复备份时,应带回对应的原 keyring。 + +Compose 会把 `.env` 注入新建容器,修改文件不会改变已经运行的进程环境,因此接入、关闭兼容和轮换后都要重建全部相关实例。网关独立使用 `AI_GATEWAY_CREDENTIAL_*` 配置;其 API、Worker 和迁移进程共享同一组网关密钥,不能复用 server-main 的密钥。 + +## 存量部署接入与迁移 + +先将所有读取凭据的应用和 Worker 升级到兼容版本,再允许加密写入。只理解明文的旧进程无法读取密文,混部时不能直接启用加密。以下命令要求镜像包含本次配套实现和 `/app/dist/credential-migrate.js`,不适用于此前的旧镜像。 + +1. 备份 MongoDB、相关 Redis 队列及私有配置,记录当前应用版本;暂停凭据修改并排空或暂停消费者。 +2. 显式接入独立密钥,并重建相关服务。Linux/macOS: + + ```bash + DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true ./update.sh + ``` + + Windows PowerShell: + + ```powershell + $env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION = 'true' + .\update.ps1 + Remove-Item Env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION + ``` + +3. 核对容器内 MongoDB 主机及数据库,按次序执行正式迁移入口。默认 Compose 示例为 `172.21.0.3/aidraw`;自定义连接必须使用实际值。 + + ```bash + bash scripts/migrate-credential-encryption.sh inventory 172.21.0.3/aidraw + bash scripts/migrate-credential-encryption.sh dry-run 172.21.0.3/aidraw + bash scripts/migrate-credential-encryption.sh apply 172.21.0.3/aidraw + bash scripts/migrate-credential-encryption.sh apply 172.21.0.3/aidraw + bash scripts/migrate-credential-encryption.sh verify 172.21.0.3/aidraw + bash scripts/migrate-credential-encryption.sh finalize 172.21.0.3/aidraw + ``` + + PowerShell 使用 `scripts/Migrate-CredentialEncryption.ps1 -Mode inventory -ExpectedTarget '172.21.0.3/aidraw'`,依次替换 Mode 为 `dry-run`、`apply`、`apply`、`verify`、`finalize`。 + +4. 确认 verify 零待更改、零明文、零冲突;把 `.env` 的 `CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT` 改为 `false`,重建全部相关进程,恢复消费,并用升级前的原用户 Key 验证业务调用。 + +入口只运行迁移进程,不启动新的应用消费者。正式目标须使用 `--production=true --expected-target=主机/数据库` 精确匹配;工具从服务环境继承认证、数据库名和 Redis 队列配置,不在命令或日志中输出密钥。队列使用配置中的 Redis 主机和 DB,显式覆盖 Redis URL 时还需匹配 `--expected-redis=主机/DB`。 + +checkpoint 保存在私有 `data/credential-migration` 目录,绑定目标、密钥和轮换设置。旧验收工具的无版本 checkpoint 不能直接复用;换目标或轮换时使用新的 checkpoint。遇到并发冲突会停止,保留 checkpoint、处理冲突后重跑;verify 从头扫描,不会因 checkpoint 跳过残留明文。工具保持 MongoDB 4.4 兼容。 + +迁移保留用户 Key 的原值、所有权、权限、撤销状态和有效期,增加哈希、密文及摘要。只有哈希的历史 Key 继续鉴权,页面显示无法查看。普通列表不返回完整值;复制或查看由登录会话按需请求。平台凭据按原记录身份加密,编辑摘要会保留原值,复制平台会按新记录身份重新加密。 + +## 备份、还原与换机 + +数据库归档只包含数据库中的密文、API Key 哈希、记录身份和 `credential_encryption_state` 验证值,**不会包含部署环境中的解密密钥**。后台的手动/每日自动备份仍可使用,但单独下载数据库文件不足以完成换机恢复。禁止把私有密钥放入上传 OSS 的数据库归档。 + +需要配套保管: + +- 对应时间点的完整 MongoDB 备份,包括主库中的加密状态集合;多库归档包含配置中列出的业务库。 +- 完整原 keyring 和 active ID,包括所有仍被历史备份使用的旧密钥。密钥轮换完成不代表旧备份已变为新密钥;保留旧备份期间仍需保留对应旧密钥。 +- 原 `CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY` 和 `CONFIG_JWT_SECRET`:已有 MCP、插件、OIDC/JWT 配置使用另一套加密或兼容派生,不能只备份新 API Key 的 keyring。另需私有保存完整 `.env`、相关 `.env.*` 与安全配置中心配置,保留审计、OTP 等原有安全配置和连接参数。 +- 需要恢复未完成任务时,同一维护窗口的 Redis 队列备份及相应消费者版本;数据库备份不包含 Redis、PostgreSQL、对象存储或模型文件。 + +### 独立密钥备份入口 + +Linux/macOS(输出目录应位于部署目录之外的私有位置): + +```bash +mkdir -p "$HOME/.easyai-recovery" +chmod 700 "$HOME/.easyai-recovery" +bash scripts/credential-keys.sh export "$HOME/.easyai-recovery/credential-keys-20261012.env" +``` + +Windows PowerShell: + +```powershell +.\scripts\Manage-CredentialKeys.ps1 -Mode export -Path 'D:\PrivateRecovery\credential-keys-20261012.env' +``` + +先创建私有输出目录。导出文件保留完整 keyring、兼容开关、配置主密钥和 JWT 原值;文件为 Linux/macOS 0600 或 Windows 当前用户独占 DACL。已有备份不会被覆盖,非法/部分配置和无 keyring 的旧模式不会生成替代密钥。这是**含完整密钥的私有配置文件,不是密码加密的归档**;离机副本放入加密保险库,与数据库归档分开保存。文件及其路径不应提交 Git、发聊天或上传公开存储。 + +首次接入后立即导出;每次新增或切换密钥后使用新文件再导出。数据库与密钥备份按日期、应用版本和归档指纹配对,并保留旧版本,不能仅留最近一次 active 密钥。旧模式还未生成加密 keyring 时,先保存旧 `.env` 和数据库原备份,接入后补充密钥备份。 + +### 还原顺序 + +1. 进入维护窗口,暂停所有写入及消费者,备份当前目标库、队列和私有配置。全库还原使用 `--drop`,会覆盖相应集合,无法与业务写入保证原子性。 +2. 在目标目录准备 `.env`,修正地址等连接参数,恢复对应原密钥: + + ```bash + bash scripts/credential-keys.sh restore "$HOME/.easyai-recovery/credential-keys-20261012.env" + ``` + + ```powershell + .\scripts\Manage-CredentialKeys.ps1 -Mode restore -Path 'D:\PrivateRecovery\credential-keys-20261012.env' + ``` + + 还原前校验配置,其他环境变量保留;原 `.env` 会保存私有回退副本。恢复入口明确用备份的完整 keyring 替换目标加密配置,适用于目标已误生成另一组随机密钥的情形;其他运行中实例须同步恢复对应配置。旧密钥备份若缺少配置主密钥/JWT 两项,额外恢复原私有 `.env`,不能以新生成值替代。 +3. 先重建承接还原的兼容版本服务,让它读取已恢复的环境配置;其他实例和消费者保持暂停,再通过原后台备份记录或上传备份还原。正在运行的进程不会自动读取 `.env` 更改;目标原数据采用另一套密钥而导致服务无法启动时,使用下述离线隔离流程。 +4. 后台会先还原至随机命名的临时数据库,检查归档数据库名是否属于已配置业务库,验证全部 API/平台密文、旧 key ID,以及 MCP、插件、OIDC/JWT 加密配置。缺钥、错钥、数据损坏、不安全压缩包或预检失败会拒绝,业务库尚未被覆盖。临时库会保留原索引;加密环境恢复旧明文归档时,先在临时库迁移历史凭据并保持原用户 Key,再导出校验后的归档覆盖业务集合,恢复严格状态。 +5. 还原后重建全部相关进程,清理业务缓存;验证原用户 Key 的权限/过期/撤销状态、原平台实际调用、配置查看与复制,再恢复消费。若同时恢复旧 Redis 任务,先运行正式迁移 `apply → verify` 清除历史任务载荷凭据;`verify/finalize` 通过后再重建和消费。 + +预检需要 Mongo 账户在临时数据库中创建、读写和删除数据的权限,以及临时磁盘空间;权限或空间不足会失败,不会绕过预检。临时库位于同一 Mongo 实例,需约一份数据库工作空间和一份归档空间。macOS 归档附带的 `._*` / `__MACOSX` 资源元数据在解压时忽略。正常完成/失败会清理临时库与校验归档;进程被强制终止时,可能留下 `easyai_credential_restore_*` 临时库,运维确认归属后清理。线上还原仅支持归档内原库名与当前配置匹配;换库名、目标无法启动或跨环境恢复优先走离线隔离流程。 + +### 无法启动或离线换库恢复 + +保持业务和消费者停止。在隔离 Mongo 实例/临时目标中用原密钥还原归档,完整保留 `_id`、API Key 哈希和加密状态集合;换库使用 MongoDB Tools 的 `--nsInclude`、`--nsFrom` / `--nsTo` 映射,先核对目标再执行,语法参照 [MongoDB Tools 官方说明](https://www.mongodb.com/docs/database-tools/mongorestore/mongorestore-examples/)。新 API/平台凭据的 AAD 绑定集合、记录身份和字段,未绑定数据库名称;同库结构的换库恢复可保留原值,不能重生成记录 ID 后直接搬密文。 + +如果归档早于加密接入,在隔离库使用相同原 keyring 和临时兼容开关 `true` 运行正式迁移,再 `verify/finalize`,制作新的已加密备份后恢复目标。`mongorestore --drop` 只删除归档包含的集合,旧归档没有加密状态集合时,直接覆盖已严格化的目标会留下旧标记而混入明文;不要跳过隔离迁移直接手工覆盖。 + +配置、密钥、数据库和队列确认对应后启动兼容版本,校验正常再切换业务。遗失任何仍在使用的密钥时,不能通过重置 active ID、设兼容开关或生成随机密钥恢复密文,应找回对应私有备份。 + +## 轮换与回滚 + +轮换先加入新密钥并保留旧密钥,再切换 active ID;使用新的 checkpoint 执行 `--rotate=true`,验证业务数据与数据库验证值均已迁移后,运行环境才可移除旧密钥;历史备份仍需保留原密钥。工具会随 `apply --rotate=true` 迁移验证值,并保持严格状态。只切换 active ID 不代表可以删除旧密钥。 + +首次加密写入后,只能回滚到支持当前密文格式的应用。镜像回滚不会撤销数据库变更,也不能恢复遗失的密钥;更新清单不得声称可无条件回滚到只理解明文的旧应用。恢复数据库和队列备份时,要协调应用版本并处理备份后产生的数据。 + +## 本地验收 + +2026-10-12 使用真实 MongoDB 4.4、Redis、PostgreSQL 18、旧发布镜像及本地构建的新镜像验证升级和迁移。用户 Key 与平台凭据在仅更新镜像、接入、迁移、关闭兼容和重启阶段保持原值;文本调用连接真实供应商。Windows PowerShell 5.1 和 pwsh 7.6.5 各 33 项原生扩展检查通过,覆盖密钥独立生成、幂等、非法配置拒绝、私有 DACL 和日志泄密。生产环境未执行迁移或部署。 diff --git a/docs/Windows一键部署方案.md b/docs/Windows一键部署方案.md index c31c393..f289918 100644 --- a/docs/Windows一键部署方案.md +++ b/docs/Windows一键部署方案.md @@ -1,5 +1,7 @@ # EasyAI Windows 一键部署方案 +API Key 加密和未升级脚本用户的兼容路径统一见 [API Key 加密部署与旧用户兼容](API_KEY_ENCRYPTION_DEPLOYMENT.md)。新安装自动生成独立密钥;存量部署默认保持兼容,首次接入后迁移验证并重启,原用户 Key 和平台 Key 保持不变。密钥备份/还原入口为 `scripts/Manage-CredentialKeys.ps1`,数据库归档与原密钥分开保管,按上述说明先还原密钥、重建服务再还原数据。 + ## 1. 背景与目标 `start.sh` 是 Linux 下的一键部署脚本,本方案为 Windows 平台提供等效的 `start.ps1` PowerShell 脚本。针对 Windows 典型场景做如下**精简**: diff --git a/scripts/Initialize-SecurityEnv.ps1 b/scripts/Initialize-SecurityEnv.ps1 index c26fad0..830c143 100644 --- a/scripts/Initialize-SecurityEnv.ps1 +++ b/scripts/Initialize-SecurityEnv.ps1 @@ -55,6 +55,48 @@ function Initialize-SecurityEnv { $content = Get-Content $Path -Raw -Encoding UTF8 if ($null -eq $content) { $content = "" } + $active = Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID' + $ringText = Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' + $legacy = Get-EnvValue $content 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT' + if ($legacy -and $legacy -notin @('true','false')) { throw 'Credential legacy flag must be true/false' } + if (-not $active -and -not $ringText) { + if ($Mode -ne 'new' -and $legacy -eq 'false') { throw 'Credential encryption key missing; restore the original configuration' } + if ($Mode -eq 'new' -or $env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION -eq 'true') { + $bytes = New-Object byte[] 32 + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } + $active = 'v1' + $ringText = '{"v1":"' + [Convert]::ToBase64String($bytes) + '"}' + $content = Set-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID' $active + $content = Set-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' ("'" + $ringText + "'") + Write-Host ' [OK] Initialized independent credential encryption key (value omitted)' + } elseif ($legacy -eq 'false') { + throw 'Credential encryption key missing; restore the original configuration' + } else { + Write-Host ' [INFO] Existing deployment retains legacy compatibility; opt in using DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true' + } + } + if ($active -or $ringText) { + try { + if ($active -notmatch '^[A-Za-z0-9_-]{1,64}$') { throw 'invalid' } + $normalized = $ringText -replace '\s', '' + if ($ringText -notmatch '^\{\s*("[A-Za-z0-9_-]{1,64}"\s*:\s*"[A-Za-z0-9+/]{43}="\s*,\s*)*"[A-Za-z0-9_-]{1,64}"\s*:\s*"[A-Za-z0-9+/]{43}="\s*\}$') { throw 'invalid' } + $ids = New-Object 'System.Collections.Generic.Dictionary[string,bool]' ([System.StringComparer]::Ordinal) + foreach ($match in [regex]::Matches($normalized, '"([A-Za-z0-9_-]+)":"([A-Za-z0-9+/]+=)"')) { + $id = $match.Groups[1].Value; $encoded = $match.Groups[2].Value + if ($ids.ContainsKey($id)) { throw 'duplicate' } + $key = [Convert]::FromBase64String($encoded) + if ($key.Length -ne 32 -or [Convert]::ToBase64String($key) -cne $encoded) { throw 'invalid' } + $ids[$id] = $true + } + if (-not $ids.ContainsKey($active)) { throw 'invalid' } + } catch { throw 'Credential keyring invalid or incomplete; restore original keys (values omitted)' } + if (-not $legacy) { + if ($Mode -eq 'new') { $legacy = 'false' } else { $legacy = 'true' } + $content = Set-EnvValue $content 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT' $legacy + } + if ($legacy -notin @('true', 'false')) { throw 'Credential legacy flag must be true/false' } + } $definitions = @( @{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 }, @{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 }, @@ -102,4 +144,12 @@ function Initialize-SecurityEnv { $content, [System.Text.UTF8Encoding]::new($false) ) + if ($env:OS -eq 'Windows_NT') { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = New-Object System.Security.AccessControl.FileSecurity + $acl.SetAccessRuleProtection($true, $false) + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'Allow') + $acl.AddAccessRule($rule) + Set-Acl -LiteralPath $Path -AclObject $acl + } } diff --git a/scripts/Manage-CredentialKeys.ps1 b/scripts/Manage-CredentialKeys.ps1 new file mode 100644 index 0000000..d8fc110 --- /dev/null +++ b/scripts/Manage-CredentialKeys.ps1 @@ -0,0 +1,68 @@ +#Requires -Version 5.1 +param( + [Parameter(Mandatory=$true)][ValidateSet('export','restore')][string]$Mode, + [Parameter(Mandatory=$true)][string]$Path, + [string]$EnvPath = (Join-Path (Split-Path -Parent $PSScriptRoot) '.env') +) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Initialize-SecurityEnv.ps1') +function Protect-CredentialFile([string]$FilePath) { + if ($env:OS -eq 'Windows_NT') { + $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = New-Object System.Security.AccessControl.FileSecurity + $acl.SetAccessRuleProtection($true, $false) + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'Allow'))) + Set-Acl -LiteralPath $FilePath -AclObject $acl + } else { & chmod 600 $FilePath; if ($LASTEXITCODE) { throw 'Cannot protect credential file' } } +} +if (-not (Test-Path -LiteralPath $EnvPath -PathType Leaf)) { throw 'Prepare the deployment .env first' } +$EnvPath = [System.IO.Path]::GetFullPath($EnvPath) +$Path = [System.IO.Path]::GetFullPath($Path) +if ($EnvPath -eq $Path) { throw 'Keep the key backup separate from deployment configuration' } +$source = $EnvPath +if ($Mode -eq 'restore') { $source = $Path } +$content = Get-Content -LiteralPath $source -Raw -Encoding UTF8 +$active = Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID' +$ring = Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' +$legacy = Get-EnvValue $content 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT' +if (-not $active -or -not $ring) { throw 'Incomplete original keyring; no replacement key will be generated' } +if (-not $legacy) { $legacy = 'false' } +$keyContent = "CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=$active`nCONFIG_CREDENTIAL_ENCRYPTION_KEYRING='$ring'`nCONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT=$legacy`n" +foreach ($name in @('CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY','CONFIG_JWT_SECRET')) { + $match = [regex]::Match($content, "(?m)^$name=(.*)$") + if ($match.Success) { $keyContent += $match.Value.TrimEnd("`r") + "`n" } + elseif ($Mode -eq 'export') { $keyContent += "$name=`n" } +} +$tmp = $EnvPath + '.credential-keys.' + [Guid]::NewGuid().ToString('N') +try { + [System.IO.File]::WriteAllText($tmp, '', (New-Object System.Text.UTF8Encoding($false))) + Protect-CredentialFile $tmp + [System.IO.File]::WriteAllText($tmp, $keyContent, (New-Object System.Text.UTF8Encoding($false))) + Initialize-SecurityEnv -Path $tmp -Mode upgrade *> $null + if ($Mode -eq 'export') { + $reservation = [System.IO.File]::Open($Path, 'CreateNew', 'Write', 'None'); $reservation.Dispose() + Protect-CredentialFile $Path + [System.IO.File]::WriteAllText($Path, $keyContent, (New-Object System.Text.UTF8Encoding($false))) + Write-Host 'Exported full keyring, including old keys. Store privately and separately from database backups.' + } else { + $original = Get-Content -LiteralPath $EnvPath -Raw -Encoding UTF8 + $previous = $EnvPath + '.pre-credential-restore.' + [Guid]::NewGuid().ToString('N') + [System.IO.File]::WriteAllText($previous, '', (New-Object System.Text.UTF8Encoding($false))) + Protect-CredentialFile $previous + [System.IO.File]::WriteAllText($previous, $original, (New-Object System.Text.UTF8Encoding($false))) + foreach ($name in @('CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID','CONFIG_CREDENTIAL_ENCRYPTION_KEYRING','CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT','CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY','CONFIG_JWT_SECRET')) { + $saved = [regex]::Match($keyContent, "(?m)^$name=(.*)$") + if (-not $saved.Success) { continue } + $literalLine = $saved.Value.TrimEnd("`r") + "`r`n" + $pattern = "(?m)^$name=.*(?:\r?\n)?" + if ([regex]::IsMatch($original, $pattern)) { + $evaluator = [System.Text.RegularExpressions.MatchEvaluator]{ param($match) return $literalLine }.GetNewClosure() + $original = [regex]::Replace($original, $pattern, $evaluator) + } else { $original = $original.TrimEnd("`r","`n") + "`r`n" + $literalLine } + } + [System.IO.File]::WriteAllText($tmp, $original, (New-Object System.Text.UTF8Encoding($false))) + Move-Item -LiteralPath $tmp -Destination $EnvPath -Force + Protect-CredentialFile $EnvPath + Write-Host 'Restored original keyring; other configuration preserved. Recreate all related containers before database restore.' + } +} finally { if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force } } diff --git a/scripts/Migrate-CredentialEncryption.ps1 b/scripts/Migrate-CredentialEncryption.ps1 new file mode 100644 index 0000000..8ad84b4 --- /dev/null +++ b/scripts/Migrate-CredentialEncryption.ps1 @@ -0,0 +1,28 @@ +#Requires -Version 5.1 +param( + [ValidateSet('inventory','dry-run','apply','verify','finalize')][string]$Mode = 'inventory', + [Parameter(Mandatory=$true)][string]$ExpectedTarget +) +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +Set-Location $root +. (Join-Path $root 'scripts/Initialize-SecurityEnv.ps1') +Initialize-SecurityEnv -Path (Join-Path $root '.env') -Mode upgrade +$content = Get-Content (Join-Path $root '.env') -Raw +if (-not (Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING')) { throw 'Configure independent encryption keys and prepare database/queue backup before migrating' } +$checkpointDir = Join-Path $root 'data/credential-migration' +[System.IO.Directory]::CreateDirectory($checkpointDir) | Out-Null +if ($env:OS -eq 'Windows_NT') { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = New-Object System.Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true, $false) + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $acl.AddAccessRule($rule) + Set-Acl -LiteralPath $checkpointDir -AclObject $acl +} +$toolArgs = @("--mode=$Mode", '--production=true', "--expected-target=$ExpectedTarget", '--batch-size=100', '--checkpoint=/credential-migration/checkpoint.json') +if ($Mode -eq 'finalize') { $toolArgs = @('--mode=verify', '--finalize=true', '--production=true', "--expected-target=$ExpectedTarget", '--batch-size=100') } +docker compose version *> $null +if ($LASTEXITCODE -eq 0) { docker compose run --rm --no-deps -v "${checkpointDir}:/credential-migration" easyai-server node /app/dist/credential-migrate.js @toolArgs } +else { docker-compose run --rm --no-deps -v "${checkpointDir}:/credential-migration" easyai-server node /app/dist/credential-migrate.js @toolArgs } +if ($LASTEXITCODE -ne 0) { throw 'Credential migration failed; preserve checkpoint and resolve configuration or concurrency conflicts' } diff --git a/scripts/Test-CredentialKeys.ps1 b/scripts/Test-CredentialKeys.ps1 new file mode 100644 index 0000000..7c9023f --- /dev/null +++ b/scripts/Test-CredentialKeys.ps1 @@ -0,0 +1,50 @@ +#Requires -Version 5.1 +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Initialize-SecurityEnv.ps1') +$temp = Join-Path ([System.IO.Path]::GetTempPath()) ('easyai-key-recovery-' + [Guid]::NewGuid().ToString('N')) +[System.IO.Directory]::CreateDirectory($temp) | Out-Null +$envFile = Join-Path $temp '.env'; $keyFile = Join-Path $temp 'keys.env'; $logFile = Join-Path $temp 'result.log' +function Assert-KeyTest([bool]$Value, [string]$Label) { if (-not $Value) { throw "FAILED: $Label" } } +try { + [System.IO.File]::WriteAllText($envFile, '') + Initialize-SecurityEnv -Path $envFile -Mode new *> $logFile + $original = Get-Content $envFile -Raw + $ring = Get-EnvValue $original 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' + $second = [Convert]::FromBase64String((New-SecuritySecret)) + $second = [Convert]::ToBase64String($second[0..31]) + $multi = $ring.Substring(0, $ring.Length - 1) + ',"v2":"' + $second + '"}' + $content = Set-EnvValue $original 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' ("'" + $multi + "'") + $content = Set-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID' 'v2' + $content = Set-EnvValue $content 'UNRELATED_CONFIG' 'keep-me' + [System.IO.File]::WriteAllText($envFile, $content) + & (Join-Path $PSScriptRoot 'Manage-CredentialKeys.ps1') -Mode export -Path $keyFile -EnvPath $envFile *>> $logFile + Assert-KeyTest ((Get-EnvValue (Get-Content $keyFile -Raw) 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING') -ceq $multi) 'full ring exported' + $refused = $false + try { & (Join-Path $PSScriptRoot 'Manage-CredentialKeys.ps1') -Mode export -Path $keyFile -EnvPath $envFile *>> $logFile } catch { $refused = $true } + Assert-KeyTest $refused 'existing backup never overwritten' + [System.IO.File]::WriteAllText($envFile, (Set-EnvValue $original 'UNRELATED_CONFIG' 'keep-me')) + & (Join-Path $PSScriptRoot 'Manage-CredentialKeys.ps1') -Mode restore -Path $keyFile -EnvPath $envFile *>> $logFile + $restored = Get-Content $envFile -Raw + foreach ($name in @('CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY','CONFIG_JWT_SECRET')) { Assert-KeyTest ((Get-EnvValue $restored $name) -ceq (Get-EnvValue $original $name)) 'original config master and JWT retained' } + Assert-KeyTest ((Get-EnvValue $restored 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING') -ceq $multi) 'both original keys restored' + Assert-KeyTest ((Get-EnvValue $restored 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID') -ceq 'v2') 'active key restored' + Assert-KeyTest ((Get-EnvValue $restored 'UNRELATED_CONFIG') -ceq 'keep-me') 'unrelated config preserved' + $backups = @(Get-ChildItem $temp -Force -Filter '.env.pre-credential-restore.*') + Assert-KeyTest ($backups.Count -eq 1) 'previous env saved' + $bad = Join-Path $temp 'bad.env' + [System.IO.File]::WriteAllText($bad, "CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=v2`nCONFIG_CREDENTIAL_ENCRYPTION_KEYRING='{}'`n") + $refused = $false + try { & (Join-Path $PSScriptRoot 'Manage-CredentialKeys.ps1') -Mode restore -Path $bad -EnvPath $envFile *>> $logFile } catch { $refused = $true } + Assert-KeyTest $refused 'invalid recovery refused' + Assert-KeyTest ((Get-Content $envFile -Raw) -ceq $restored) 'invalid recovery never mutates env' + Assert-KeyTest (-not (Get-Content $logFile -Raw).Contains($second)) 'no key leaked to log' + if ($env:OS -eq 'Windows_NT') { + $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + foreach ($file in @($envFile, $keyFile, $backups[0].FullName)) { + $acl = Get-Acl -LiteralPath $file + Assert-KeyTest $acl.AreAccessRulesProtected 'private DACL' + foreach ($rule in $acl.Access) { Assert-KeyTest ($rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $sid) 'only current user access' } + } + } + Write-Host 'Credential key backup/restore self-test passed.' +} finally { Remove-Item -LiteralPath $temp -Recurse -Force } diff --git a/scripts/Test-SecurityEnv.ps1 b/scripts/Test-SecurityEnv.ps1 index 4585a31..a9b5833 100644 --- a/scripts/Test-SecurityEnv.ps1 +++ b/scripts/Test-SecurityEnv.ps1 @@ -80,6 +80,26 @@ try { Assert-MinLength $freshContent "CONFIG_INITIAL_ADMIN_PASSWORD" 12 Assert-Value $freshContent "WS_AUTH_METHODS" "none,bearer,ws_ticket" + $freshRing = Get-EnvValue $freshContent 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' + $freshId = Get-EnvValue $freshContent 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID' + $parsedRing = $freshRing | ConvertFrom-Json + if ([Convert]::FromBase64String($parsedRing.$freshId).Length -ne 32) { throw 'Invalid credential key length' } + Assert-Value $freshContent 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT' 'false' + Initialize-SecurityEnv -Path $fresh -Mode upgrade + if ((Get-EnvValue (Get-Content $fresh -Raw) 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING') -cne $freshRing) { throw 'Credential key must survive repeat upgrade' } + if (Get-EnvValue $upgradeContent 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING') { throw 'Existing deployment must not silently opt in' } + $env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION = 'true' + Initialize-SecurityEnv -Path $upgrade -Mode upgrade + $env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION = $null + $opted = Get-Content $upgrade -Raw + Assert-Value $opted 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT' 'true' + $before = Get-EnvValue $opted 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' + Initialize-SecurityEnv -Path $upgrade -Mode upgrade + Assert-Value (Get-Content $upgrade -Raw) 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' $before + $bad = Write-TestEnv 'bad.env' @('CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=v1', 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING=') + $rejected = $false + try { Initialize-SecurityEnv -Path $bad -Mode upgrade } catch { $rejected = $true } + if (-not $rejected) { throw 'Partial key configuration must fail' } Write-Host "Security environment PowerShell tests passed" -ForegroundColor Green } finally { Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue diff --git a/scripts/credential-keys.sh b/scripts/credential-keys.sh new file mode 100755 index 0000000..e4171ce --- /dev/null +++ b/scripts/credential-keys.sh @@ -0,0 +1,50 @@ +#!/bin/bash +set -euo pipefail +umask 077 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/init-security-env.sh +. "$SCRIPT_DIR/init-security-env.sh" +mode="${1:-}"; keyfile="${2:-}"; envfile="${3:-$SCRIPT_DIR/../.env}" +case "$mode" in export|restore) ;; *) echo '用法: credential-keys.sh export|restore 私有密钥备份文件 [部署.env]' >&2; exit 1 ;; esac +[ -n "$keyfile" ] && [ -f "$envfile" ] || { echo '请指定密钥备份文件,并先准备部署 .env' >&2; exit 1; } +[ "$keyfile" != "$envfile" ] || { echo '密钥备份必须与部署配置分开' >&2; exit 1; } +sourcefile="$envfile" +if [ "$mode" = restore ]; then sourcefile="$keyfile"; fi +[ -f "$sourcefile" ] || { echo '密钥备份不存在' >&2; exit 1; } +active="$(read_env_value "$sourcefile" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID)" +ring="$(read_env_value "$sourcefile" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" +legacy="$(read_env_value "$sourcefile" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT)" +[ -n "$active" ] && [ -n "$ring" ] || { echo '没有完整加密密钥,不能导出或恢复;不会生成替代密钥' >&2; exit 1; } +tmp="$(mktemp "${envfile}.credential-keys.XXXXXX")" +trap 'rm -f "$tmp"' EXIT +printf 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=%s\nCONFIG_CREDENTIAL_ENCRYPTION_KEYRING=%s\nCONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT=%s\n' "$active" "'$ring'" "${legacy:-false}" > "$tmp" +init_credential_encryption_env "$tmp" upgrade >/dev/null +# Preserve the independent master and JWT fallback used by earlier encrypted settings. +for name in CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY CONFIG_JWT_SECRET; do + if [ "$mode" = export ]; then + awk -v key="$name" 'index($0, key "=") == 1 { print; found=1; exit } END { if (!found) print key "=" }' "$sourcefile" >> "$tmp" + else + awk -v key="$name" 'index($0, key "=") == 1 { print; exit }' "$sourcefile" >> "$tmp" + fi +done +if [ "$mode" = export ]; then + # noclobber reserves an immutable backup instead of truncating an earlier keyring. + ( set -o noclobber; cat "$tmp" > "$keyfile" ) || { echo '密钥备份已存在或路径不可写,请使用新文件' >&2; exit 1; } + chmod 600 "$keyfile" + echo '已导出完整 keyring(含旧密钥);请与数据库备份分开私有保管。' +else + previous="${envfile}.pre-credential-restore.$(date +%Y%m%dT%H%M%S).$$" + cp "$envfile" "$previous"; chmod 600 "$previous" + key_content="$(cat "$tmp")" + awk 'index($0,"CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=")==1 || index($0,"CONFIG_CREDENTIAL_ENCRYPTION_KEYRING=")==1 || index($0,"CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT=")==1 { next } { print }' "$envfile" > "$tmp" + for name in CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY CONFIG_JWT_SECRET; do + if printf '%s\n' "$key_content" | awk -v key="$name" 'index($0,key "=")==1 { found=1 } END { exit !found }'; then + filtered="$(mktemp "${envfile}.filtered.XXXXXX")" + awk -v key="$name" 'index($0,key "=")!=1 { print }' "$tmp" > "$filtered" + mv "$filtered" "$tmp" + fi + done + printf '%s\n' "$key_content" >> "$tmp" + chmod 600 "$tmp"; mv "$tmp" "$envfile" + echo '已恢复原 keyring,其他配置保留;重建全部相关容器后再恢复数据库。' +fi diff --git a/scripts/init-security-env.sh b/scripts/init-security-env.sh index 6014e2c..a8872ab 100644 --- a/scripts/init-security-env.sh +++ b/scripts/init-security-env.sh @@ -115,6 +115,61 @@ ensure_initial_admin_password() { echo " ✓ 已初始化管理员密码: CONFIG_INITIAL_ADMIN_PASSWORD" } +init_credential_encryption_env() { + local file="$1" mode="$2" active ring normalized entry id encoded decoded reencoded ids='' legacy + active="$(read_env_value "$file" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID)" + ring="$(read_env_value "$file" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" + legacy="$(read_env_value "$file" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT)" + case "$legacy" in ''|true|false) ;; *) echo '❌ 旧凭据兼容开关只能为 true/false' >&2; return 1 ;; esac + if [ -z "$active" ] && [ -z "$ring" ]; then + if [ "$mode" != new ] && [ "$legacy" = false ]; then + echo '❌ 已关闭旧凭据兼容但缺少加密密钥,请恢复原配置' >&2 + return 1 + fi + # Existing sites opt in explicitly: never invent a replacement after key loss. + if [ "$mode" != new ] && [ "${DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION:-false}" != true ]; then + if [ "$legacy" = false ]; then + echo '❌ 已关闭旧凭据兼容但缺少加密密钥,请恢复原配置' >&2 + return 1 + fi + echo ' ℹ 凭据加密尚未接入,保留旧部署兼容;首次接入使用 DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true' + return 0 + fi + command -v openssl >/dev/null 2>&1 || { echo '❌ 凭据加密初始化需要 openssl' >&2; return 1; } + active=v1 + encoded="$(openssl rand -base64 32 | tr -d '\r\n')" || return 1 + ring="{\"${active}\":\"${encoded}\"}" + write_env_value "$file" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID "$active" + write_env_value "$file" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "'$ring'" + echo ' ✓ 已初始化独立凭据加密密钥(未输出密钥)' + fi + # Values have a restricted JSON grammar; no eval/source or secret command arguments. + normalized="$(printf '%s' "$ring" | tr -d ' \t\r\n')" + if ! [[ "$active" =~ ^[A-Za-z0-9_-]{1,64}$ ]] || + ! printf '%s' "$ring" | LC_ALL=C grep -Eq '^\{[[:space:]]*("[A-Za-z0-9_-]{1,64}"[[:space:]]*:[[:space:]]*"[A-Za-z0-9+/]{43}="[[:space:]]*,[[:space:]]*)*"[A-Za-z0-9_-]{1,64}"[[:space:]]*:[[:space:]]*"[A-Za-z0-9+/]{43}="[[:space:]]*\}$'; then + echo '❌ 凭据加密配置不完整或无效,请恢复原密钥;不会自动替换' >&2 + return 1 + fi + command -v openssl >/dev/null 2>&1 || { echo '❌ 凭据密钥校验需要 openssl' >&2; return 1; } + while IFS= read -r entry; do + id="${entry%%:*}"; id="${id#\"}"; id="${id%\"}" + encoded="${entry#*:}"; encoded="${encoded#\"}"; encoded="${encoded%\"}" + case ",$ids," in *",$id,"*) echo '❌ 凭据密钥 ID 重复' >&2; return 1 ;; esac + ids="${ids:+$ids,}$id" + decoded="$(printf '%s' "$encoded" | openssl base64 -d -A | wc -c | tr -d ' ')" + reencoded="$(printf '%s' "$encoded" | openssl base64 -d -A | openssl base64 -A)" + if [ "$decoded" != 32 ] || [ "$reencoded" != "$encoded" ]; then + echo '❌ 凭据密钥必须为标准 Base64 编码的 32 字节随机值' >&2; return 1 + fi + done < <(printf '%s\n' "$normalized" | sed 's/^{//;s/}$//' | tr ',' '\n') + case ",$ids," in *",$active,"*) ;; *) echo '❌ 当前凭据密钥 ID 不在 keyring 中' >&2; return 1 ;; esac + if [ -z "$legacy" ]; then + if [ "$mode" = new ]; then legacy=false; else legacy=true; fi + write_env_value "$file" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT "$legacy" + fi + case "$legacy" in true|false) ;; *) echo '❌ 旧凭据兼容开关只能为 true/false' >&2; return 1 ;; esac +} + init_security_env() { local file="${1:-.env}" local mode="${2:-upgrade}" @@ -127,6 +182,7 @@ init_security_env() { return 1 fi + init_credential_encryption_env "$file" "$mode" || return 1 ensure_security_secret "$file" "CONFIG_JWT_SECRET" "this is a very secret secret" ensure_security_secret "$file" "WS_AUTH_WS_TICKET_SECRET" ensure_security_secret "$file" "SYSTEM_UPDATE_INTERNAL_TOKEN" diff --git a/scripts/migrate-credential-encryption.sh b/scripts/migrate-credential-encryption.sh new file mode 100644 index 0000000..b1449d1 --- /dev/null +++ b/scripts/migrate-credential-encryption.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}/.." +mode="${1:-inventory}" +expected="${2:-}" +case "$mode" in inventory|dry-run|apply|verify|finalize) ;; *) echo '用法: scripts/migrate-credential-encryption.sh inventory|dry-run|apply|verify|finalize 主机/数据库' >&2; exit 1 ;; esac +[ -n "$expected" ] || { echo '需要明确指定容器内 MongoDB 主机/数据库,例如 172.21.0.3/aidraw' >&2; exit 1; } +[ -f .env ] || { echo '未找到部署 .env' >&2; exit 1; } +# shellcheck source=scripts/init-security-env.sh +. "$SCRIPT_DIR/init-security-env.sh" +init_credential_encryption_env .env upgrade +[ -n "$(read_env_value .env CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" ] || { echo '请先接入独立加密密钥,完成备份并暂停相关写入/消费者' >&2; exit 1; } +mkdir -p data/credential-migration +chmod 700 data/credential-migration +compose=(docker compose) +if ! docker compose version >/dev/null 2>&1; then compose=(docker-compose); fi +args=("--mode=$mode" --production=true "--expected-target=$expected" --batch-size=100 --checkpoint=/credential-migration/checkpoint.json) +if [ "$mode" = finalize ]; then args=(--mode=verify --finalize=true --production=true "--expected-target=$expected" --batch-size=100); fi +# Credentials come exclusively from the existing service environment. +"${compose[@]}" run --rm --no-deps -v "$PWD/data/credential-migration:/credential-migration" easyai-server node /app/dist/credential-migrate.js "${args[@]}" diff --git a/scripts/test-credential-keys.sh b/scripts/test-credential-keys.sh new file mode 100644 index 0000000..cbd310a --- /dev/null +++ b/scripts/test-credential-keys.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/init-security-env.sh +. "$SCRIPT_DIR/init-security-env.sh" +tmp_dir="$(mktemp -d)"; trap 'rm -rf "$tmp_dir"' EXIT +touch "$tmp_dir/.env" +write_env_value "$tmp_dir/.env" CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY "original-master-with-dollar-\$1-and-backslash-\\" +write_env_value "$tmp_dir/.env" CONFIG_JWT_SECRET original-jwt-fallback +init_credential_encryption_env "$tmp_dir/.env" new > "$tmp_dir/log" +old_ring="$(read_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" +second="$(openssl rand -base64 32 | tr -d '\r\n')" +multi="${old_ring%\}},\"v2\":\"$second\"}" +write_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "'$multi'" +write_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID v2 +write_env_value "$tmp_dir/.env" UNRELATED_CONFIG keep-me +bash "$SCRIPT_DIR/credential-keys.sh" export "$tmp_dir/keys.env" "$tmp_dir/.env" >> "$tmp_dir/log" +[ "$(read_env_value "$tmp_dir/keys.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" = "$multi" ] +if bash "$SCRIPT_DIR/credential-keys.sh" export "$tmp_dir/keys.env" "$tmp_dir/.env" >> "$tmp_dir/log" 2>&1; then exit 1; fi +write_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "'$old_ring'" +write_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID v1 +bash "$SCRIPT_DIR/credential-keys.sh" restore "$tmp_dir/keys.env" "$tmp_dir/.env" >> "$tmp_dir/log" +[ "$(read_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" = "$multi" ] +[ "$(read_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID)" = v2 ] +[ "$(read_env_value "$tmp_dir/.env" UNRELATED_CONFIG)" = keep-me ] +[ "$(read_env_value "$tmp_dir/.env" CONFIG_JWT_SECRET)" = original-jwt-fallback ] +[ "$(read_env_value "$tmp_dir/.env" CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY)" = "$(read_env_value "$tmp_dir/keys.env" CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY)" ] +cp "$tmp_dir/.env" "$tmp_dir/before" +cp "$tmp_dir/keys.env" "$tmp_dir/bad.env" +write_env_value "$tmp_dir/bad.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING '{}' +if bash "$SCRIPT_DIR/credential-keys.sh" restore "$tmp_dir/bad.env" "$tmp_dir/.env" >> "$tmp_dir/log" 2>&1; then exit 1; fi +cmp -s "$tmp_dir/.env" "$tmp_dir/before" +touch "$tmp_dir/legacy.env" +if bash "$SCRIPT_DIR/credential-keys.sh" export "$tmp_dir/missing.env" "$tmp_dir/legacy.env" >> "$tmp_dir/log" 2>&1; then exit 1; fi +[ ! -e "$tmp_dir/missing.env" ] +if grep -Fq "$second" "$tmp_dir/log"; then exit 1; fi +if stat -f '%Lp' "$tmp_dir/keys.env" > /dev/null 2>&1; then permission="$(stat -f '%Lp' "$tmp_dir/keys.env")"; else permission="$(stat -c '%a' "$tmp_dir/keys.env")"; fi +[ "$permission" = 600 ] +echo 'Credential key backup/restore self-test passed (rotation, preservation, refusal, permissions, logs).' diff --git a/scripts/test-security-env.sh b/scripts/test-security-env.sh index 001ab6d..50f3316 100755 --- a/scripts/test-security-env.sh +++ b/scripts/test-security-env.sh @@ -122,4 +122,23 @@ assert_value "$new_env" "WS_AUTH_METHODS" "none,bearer,ws_ticket" mode="$(stat -c '%a' "$new_env" 2>/dev/null || stat -f '%Lp' "$new_env")" [ "$mode" = "600" ] || fail "generated environment file mode must be 600, got $mode" +assert_value "$upgrade_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING '' +assert_value "$new_env" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT false +new_ring="$(read_env_value "$new_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" +new_key="${new_ring#*:\"}"; new_key="${new_key%\"\}}" +[ "$(printf '%s' "$new_key" | openssl base64 -d -A | wc -c | tr -d ' ')" = 32 ] || fail 'credential key length must be 32 bytes' +if grep -Fq "$new_key" "$TMP_DIR/new.log"; then fail 'credential key must not appear in logs'; fi +DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true init_security_env "$upgrade_env" upgrade > "$TMP_DIR/opt-in.log" +assert_value "$upgrade_env" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT true +opted_ring="$(read_env_value "$upgrade_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" +init_security_env "$upgrade_env" upgrade > "$TMP_DIR/opt-repeat.log" +assert_value "$upgrade_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "$opted_ring" +write_env "$TMP_DIR/partial.env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=v1 CONFIG_CREDENTIAL_ENCRYPTION_KEYRING= +if init_security_env "$TMP_DIR/partial.env" upgrade > "$TMP_DIR/partial.log" 2>&1; then fail 'partial keyring must fail'; fi +rotate_key="$(openssl rand -base64 32 | tr -d '\r\n')" +rotated="${new_ring%\}},\"next\":\"${rotate_key}\"}" +write_env_value "$new_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "'$rotated'" +write_env_value "$new_env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID next +init_security_env "$new_env" upgrade > "$TMP_DIR/rotation.log" +assert_value "$new_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "$rotated" echo "Security environment shell tests passed"