Files
story/scripts/verify-local-secret-rotation.ps1
T

99 lines
5.6 KiB
PowerShell
Raw Permalink Normal View History

2026-08-26 06:41:07 +08:00
$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已恢复并通过原密钥验证。'
}