Files
easyai/scripts/Initialize-SecurityEnv.ps1
T
easyai03 5cff1041a9 feat(deploy): 补齐凭据加密接入迁移和密钥备份恢复
首次安装默认生成独立加密密钥,更新旧部署保留原配置,通过显式开关进入迁移窗口。增加生产迁移包装入口和完整密钥环备份恢复工具,并说明旧镜像升级、严格迁移、轮换及还原流程。

已通过 Bash 安全环境及密钥备份回归;对应 PowerShell 5.1 和 7 脚本已在 Windows 原生环境各通过 49 项验收。宁波服务器验收尚未执行。
2026-10-12 22:24:16 +08:00

156 lines
7.5 KiB
PowerShell

function New-SecuritySecret {
$bytes = New-Object byte[] 48
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$rng.GetBytes($bytes)
} finally {
$rng.Dispose()
}
return [Convert]::ToBase64String($bytes)
}
function New-InitialAdminPassword {
$bytes = New-Object byte[] 10
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$rng.GetBytes($bytes)
} finally {
$rng.Dispose()
}
return ([BitConverter]::ToString($bytes) -replace "-", "").ToLowerInvariant()
}
function Get-EnvValue {
param([string]$Content, [string]$Key)
$match = [regex]::Match($Content, "(?m)^$([regex]::Escape($Key))=(.*)$")
if (-not $match.Success) { return "" }
$value = $match.Groups[1].Value.Trim()
if ($value.Length -ge 2) {
$first = $value.Substring(0, 1)
$last = $value.Substring($value.Length - 1, 1)
if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
return $value.Substring(1, $value.Length - 2)
}
}
return $value
}
function Set-EnvValue {
param([string]$Content, [string]$Key, [string]$Value)
$pattern = "(?m)^$([regex]::Escape($Key))=.*(?:\r?\n)?"
$replacement = "$Key=$Value`r`n"
if ([regex]::IsMatch($Content, $pattern)) {
return [regex]::Replace($Content, $pattern, $replacement, 1)
}
if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`r`n" }
return $Content + $replacement
}
function Initialize-SecurityEnv {
param(
[Parameter(Mandatory = $true)][string]$Path,
[ValidateSet("new", "upgrade")][string]$Mode = "upgrade"
)
if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" }
$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 },
@{ Key = "SYSTEM_UPDATE_INTERNAL_TOKEN"; Legacy = ""; MinLength = 32 },
@{ Key = "WATCHTOWER_API_KEY"; Legacy = ""; MinLength = 32 },
@{ Key = "WATCHTOWER_HTTP_API_EVENTS_TOKEN"; Legacy = ""; MinLength = 32 },
@{ Key = "MODEL_CONVERSION_WORKER_TOKEN"; Legacy = ""; MinLength = 32 }
)
if ($Mode -eq "new") {
$definitions += @(
@{ Key = "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY"; Legacy = ""; MinLength = 32 },
@{ Key = "CONFIG_OTP_HASH_SECRET"; Legacy = ""; MinLength = 32 },
@{ Key = "CONFIG_AUDIT_HASH_PEPPER"; Legacy = ""; MinLength = 32 },
@{ Key = "CONFIG_AUDIT_INTEGRITY_KEY"; Legacy = ""; MinLength = 32 }
)
}
foreach ($definition in $definitions) {
$current = Get-EnvValue $content $definition.Key
$needsInitialization = $current.Length -lt $definition.MinLength -or
($definition.Legacy -and $current -eq $definition.Legacy)
if (-not $needsInitialization) { continue }
$content = Set-EnvValue $content $definition.Key (New-SecuritySecret)
Write-Host " [OK] Initialized security secret: $($definition.Key)" -ForegroundColor Green
}
$authMethods = Get-EnvValue $content "WS_AUTH_METHODS"
if ([string]::IsNullOrWhiteSpace($authMethods)) { $authMethods = "none,bearer" }
$hasWsTicket = @($authMethods.Split(",") | ForEach-Object { $_.Trim() }) -contains "ws_ticket"
if (-not $hasWsTicket) {
$content = Set-EnvValue $content "WS_AUTH_METHODS" ($authMethods + ",ws_ticket")
Write-Host " [OK] Added security config item: WS_AUTH_METHODS" -ForegroundColor Green
}
if ($Mode -eq "new") {
$adminPassword = Get-EnvValue $content "CONFIG_INITIAL_ADMIN_PASSWORD"
if ($adminPassword.Length -lt 12) {
$content = Set-EnvValue $content "CONFIG_INITIAL_ADMIN_PASSWORD" (New-InitialAdminPassword)
Write-Host " [OK] Initialized admin password: CONFIG_INITIAL_ADMIN_PASSWORD" -ForegroundColor Green
}
}
[System.IO.File]::WriteAllText(
(Resolve-Path $Path),
$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
}
}