99 lines
5.6 KiB
PowerShell
99 lines
5.6 KiB
PowerShell
$ErrorActionPreference = 'Stop'
|
|||
|
|
$PSNativeCommandUseErrorActionPreference = $true
|
||
|
|
|
||
|
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||
|
|
Set-Location -LiteralPath $projectRoot
|
||
|
|
|
||
|
|
$apiBaseUrl = 'http://localhost:58080'
|
||
|
|
$deviceId = '10000000-0000-0000-0000-000000000511'
|
||
|
|
$oldSecret = 'local-simulated-device-secret-32-bytes-minimum'
|
||
|
|
$drillContainer = 'voice-key-rotation-drill'
|
||
|
|
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('kms-rotation-conformance-' + [Guid]::NewGuid().ToString('N'))
|
||
|
|
$capturePath = Join-Path $temporaryRoot 'rotation-capture.json'
|
||
|
|
$reportPath = Join-Path $temporaryRoot 'rotation-report.json'
|
||
|
|
$newSecret = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).ToLowerInvariant()
|
||
|
|
$originalImage = ((docker compose images -q api | Select-Object -First 1).Trim() -replace '^sha256:', '')
|
||
|
|
$rotationStartedAt = [DateTime]::UtcNow
|
||
|
|
|
||
|
|
function Wait-Ready([int]$Seconds = 60) {
|
||
|
|
$deadline = [DateTime]::UtcNow.AddSeconds($Seconds)
|
||
|
|
do {
|
||
|
|
try {
|
||
|
|
$response = Invoke-WebRequest -UseBasicParsing -Uri "$apiBaseUrl/health/ready" -TimeoutSec 2
|
||
|
|
if ($response.StatusCode -eq 200) { return }
|
||
|
|
} catch {}
|
||
|
|
Start-Sleep -Milliseconds 500
|
||
|
|
} while ([DateTime]::UtcNow -lt $deadline)
|
||
|
|
throw 'API未在限定时间内恢复就绪。'
|
||
|
|
}
|
||
|
|
|
||
|
|
function Invoke-SignedConfig([string]$Secret) {
|
||
|
|
$path = '/device/v2/config'
|
||
|
|
$timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||
|
|
$nonce = [Guid]::NewGuid().ToString('N')
|
||
|
|
$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([byte[]]::new(0))).ToLowerInvariant()
|
||
|
|
$canonical = "GET`n$path`n$timestamp`n$nonce`n$digest"
|
||
|
|
$hmac = [Security.Cryptography.HMACSHA256]::new([Text.Encoding]::UTF8.GetBytes($Secret))
|
||
|
|
try {
|
||
|
|
$signature = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||
|
|
} finally {
|
||
|
|
$hmac.Dispose()
|
||
|
|
}
|
||
|
|
return Invoke-WebRequest -UseBasicParsing -Uri ($apiBaseUrl + $path) -Headers @{
|
||
|
|
'X-Device-Id' = $deviceId
|
||
|
|
'X-Device-Timestamp' = $timestamp
|
||
|
|
'X-Device-Nonce' = $nonce
|
||
|
|
'X-Device-Content-SHA256' = $digest
|
||
|
|
'X-Device-Signature' = $signature
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
|
||
|
|
Wait-Ready
|
||
|
|
if ((Invoke-SignedConfig $oldSecret).StatusCode -ne 200) { throw '轮换前基线签名验证失败。' }
|
||
|
|
|
||
|
|
docker compose stop api | Out-Null
|
||
|
|
$env:DEVICE_LOCAL_SECRET = $newSecret
|
||
|
|
docker compose run -d --name $drillContainer -p 58080:8080 -e DEVICE_LOCAL_SECRET api | Out-Null
|
||
|
|
Wait-Ready
|
||
|
|
|
||
|
|
if ((Invoke-SignedConfig $newSecret).StatusCode -ne 200) { throw '新密钥注入后签名验证失败。' }
|
||
|
|
try {
|
||
|
|
Invoke-SignedConfig $oldSecret | Out-Null
|
||
|
|
throw '旧密钥在轮换后不应继续通过。'
|
||
|
|
} catch {
|
||
|
|
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 401) { throw }
|
||
|
|
}
|
||
|
|
$drillImage = ((docker inspect --format '{{.Image}}' $drillContainer).Trim() -replace '^sha256:', '')
|
||
|
|
if ($drillImage -ne $originalImage) { throw '轮换演练意外改变了容器镜像。' }
|
||
|
|
$now = [DateTime]::UtcNow
|
||
|
|
$capture = @{
|
||
|
|
schema_version = 'kms-rotation-capture-v1'; evidence_source = 'LOCAL'; candidate_version = '0.63.0-rc.local'
|
||
|
|
build_digest = ('sha256:' + $originalImage); environment = 'LOCAL'; provider_ref = 'docker-compose:runtime-environment'
|
||
|
|
workload_identity_ref = ''; provider_audit_ref = ''; attested_by_ref = 'local-smoke:automated'
|
||
|
|
attested_at = $now.ToString('o'); collected_at = $now.ToString('o')
|
||
|
|
rotations = @(@{
|
||
|
|
secret_ref = 'device-local-signing-secret'; consumer_ref = 'api'; consumption_mode = 'ROLLING_RESTART'
|
||
|
|
old_version_ref = 'local-version:baseline'; new_version_ref = 'local-version:drill'
|
||
|
|
started_at = $rotationStartedAt.ToString('o'); completed_at = $now.ToString('o')
|
||
|
|
new_credential_verified = $true; old_credential_rejected = $true; business_continuity_verified = $true; secret_value_not_exported = $true
|
||
|
|
pre_rotation_probe_ref = 'http-probe:baseline-200'; new_credential_probe_ref = 'http-probe:new-key-200'
|
||
|
|
old_rejection_probe_ref = 'http-probe:old-key-401'; continuity_probe_ref = 'health-ready:200'; access_audit_ref = 'local-container-log:masked'
|
||
|
|
})
|
||
|
|
}
|
||
|
|
[System.IO.File]::WriteAllText($capturePath, ($capture | ConvertTo-Json -Depth 10), [System.Text.UTF8Encoding]::new($false))
|
||
|
|
go run ./cmd/kms-rotation-conformance -capture $capturePath -candidate-version '0.63.0-rc.local' -build-digest ('sha256:' + $originalImage) -output $reportPath
|
||
|
|
$report = Get-Content -Raw -LiteralPath $reportPath | ConvertFrom-Json
|
||
|
|
if ($report.result -ne 'PASS' -or $report.evidence_scope -ne 'LOCAL_SECRET' -or -not $report.external_validation_required -or $report.rotation_count -ne 1) { throw '本地轮换报告错误地改变了云KMS/HSM验收边界。' }
|
||
|
|
Write-Host '长期密钥本地演练通过:新密钥生效、旧密钥失效、镜像未变化;报告范围为LOCAL_SECRET,真实KMS/HSM仍待验。'
|
||
|
|
} finally {
|
||
|
|
Remove-Item Env:DEVICE_LOCAL_SECRET -ErrorAction SilentlyContinue
|
||
|
|
docker rm -f $drillContainer 2>$null | Out-Null
|
||
|
|
docker compose up -d api | Out-Null
|
||
|
|
Wait-Ready
|
||
|
|
if ((Invoke-SignedConfig $oldSecret).StatusCode -ne 200) { throw '演练回退后原本地密钥未恢复。' }
|
||
|
|
if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force }
|
||
|
|
Write-Host '演练容器已删除,Compose API已恢复并通过原密钥验证。'
|
||
|
|
}
|