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 项验收。宁波服务器验收尚未执行。
This commit is contained in:
2026-10-12 22:24:16 +08:00
parent f59dd33097
commit 5cff1041a9
15 changed files with 563 additions and 0 deletions
+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"