$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已恢复并通过原密钥验证。' }