Compare commits

..
2 Commits
Author SHA1 Message Date
easyai03 4cff2dcc58 feat(deploy): 合并凭据加密迁移及密钥备份恢复 (#2)
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
完成 API Key 加密、旧部署兼容及迁移支持。

本地验收通过;宁波服务器验收尚未执行。
任务:credential-ningbo-20261012
2026-10-13 00:37:53 +08:00
easyai03 5cff1041a9 feat(deploy): 补齐凭据加密接入迁移和密钥备份恢复
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
Test deployment public URL (Linux) / test-linux (pull_request) Canceled after 0s
Test start.ps1 (Windows) / test-windows (pull_request) Canceled after 0s
首次安装默认生成独立加密密钥,更新旧部署保留原配置,通过显式开关进入迁移窗口。增加生产迁移包装入口和完整密钥环备份恢复工具,并说明旧镜像升级、严格迁移、轮换及还原流程。

已通过 Bash 安全环境及密钥备份回归;对应 PowerShell 5.1 和 7 脚本已在 Windows 原生环境各通过 49 项验收。宁波服务器验收尚未执行。
2026-10-12 22:24:16 +08:00
15 changed files with 563 additions and 0 deletions
+8
View File
@@ -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=
+12
View File
@@ -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/
+5
View File
@@ -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]`,仅更新后台服务镜像并重启即可。
- 自动化更新可通过变量跳过选择:
+135
View File
@@ -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 和日志泄密。生产环境未执行迁移或部署。
+2
View File
@@ -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 典型场景做如下**精简**:
+50
View File
@@ -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
}
}
+68
View File
@@ -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 } }
+28
View File
@@ -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' }
+50
View File
@@ -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 }
+20
View File
@@ -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
+50
View File
@@ -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
+56
View File
@@ -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"
+21
View File
@@ -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[@]}"
+39
View File
@@ -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).'
+19
View File
@@ -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"