feat(automation): add durable runner and signed approval recovery
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
#Requires -Version 7.2
|
||||
<# The owner runs NewKey/Sign on a separate Windows identity or signing machine.
|
||||
Only the public certificate reaches the runner. Never export the private key. #>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][ValidateSet('NewKey','ExportPublicKey','Sign','Verify')][string]$Action,
|
||||
[string]$RequestPath,
|
||||
[string]$ApprovalPath,
|
||||
[string]$CertificatePath,
|
||||
[string]$Thumbprint,
|
||||
[string]$ExpectedOwnerSid,
|
||||
[string]$Decision = 'Approved as requested',
|
||||
[ValidateSet('Approve','Revise','Reject')][string]$Disposition = 'Approve',
|
||||
[ValidateRange(1,720)][int]$ValidHours = 72
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try {
|
||||
if ($Action -eq 'NewKey') {
|
||||
$certificate = New-SelfSignedCertificate -Subject 'CN=Jobjakt Roadmap Owner' -CertStoreLocation Cert:\CurrentUser\My `
|
||||
-KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 -KeyExportPolicy NonExportable `
|
||||
-KeyUsage DigitalSignature -NotAfter (Get-Date).AddYears(2)
|
||||
[pscustomobject]@{ Thumbprint=$certificate.Thumbprint; OwnerSid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value } | ConvertTo-Json
|
||||
exit 0
|
||||
}
|
||||
if ($Action -eq 'ExportPublicKey') {
|
||||
if (-not $Thumbprint -or -not $CertificatePath) { throw 'Thumbprint and CertificatePath required' }
|
||||
Export-Certificate -Cert (Get-Item -LiteralPath "Cert:\CurrentUser\My\$Thumbprint") -FilePath $CertificatePath | Out-Null
|
||||
Write-Output 'Public certificate exported. Keep its pinned hash in the protected runner configuration.'
|
||||
exit 0
|
||||
}
|
||||
if (-not $RequestPath -or -not $ApprovalPath) { throw 'RequestPath and ApprovalPath required' }
|
||||
$requestBytes = [IO.File]::ReadAllBytes([IO.Path]::GetFullPath($RequestPath))
|
||||
$requestHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($requestBytes)).ToLowerInvariant()
|
||||
$request = [Text.Encoding]::UTF8.GetString($requestBytes) | ConvertFrom-Json
|
||||
if ($request.schemaVersion -ne 1 -or -not $request.kind -or -not $request.manifestSha256 -or -not $request.policySha256) {
|
||||
throw 'Invalid controller approval request'
|
||||
}
|
||||
if ($Action -eq 'Sign') {
|
||||
if (-not $Thumbprint) { throw 'Owner certificate Thumbprint required' }
|
||||
$owner = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
if ($ExpectedOwnerSid -and $ExpectedOwnerSid -ne $owner) { throw 'Wrong signing identity' }
|
||||
# Display concrete scope and evidence before owner deliberately invokes Sign.
|
||||
Write-Host ([Text.Encoding]::UTF8.GetString($requestBytes))
|
||||
$payload = [ordered]@{ requestSha256=$requestHash; approved=$true; ownerSid=$owner; decision=$Decision; disposition=$Disposition;
|
||||
issuedUtc=[DateTimeOffset]::UtcNow.ToString('o'); expiresUtc=[DateTimeOffset]::UtcNow.AddHours($ValidHours).ToString('o') }
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes(($payload | ConvertTo-Json -Compress))
|
||||
$certificate = Get-Item -LiteralPath "Cert:\CurrentUser\My\$Thumbprint"
|
||||
$rsa = [Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($certificate)
|
||||
if (-not $rsa) { throw 'Owner private signing key unavailable' }
|
||||
try { $signature = $rsa.SignData($bytes, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) }
|
||||
finally { $rsa.Dispose() }
|
||||
$envelope = @{ payloadBase64=[Convert]::ToBase64String($bytes); signatureBase64=[Convert]::ToBase64String($signature) }
|
||||
$destination = [IO.Path]::GetFullPath($ApprovalPath)
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
||||
$temporary = "$destination.$([Guid]::NewGuid().ToString('N')).tmp"
|
||||
[IO.File]::WriteAllText($temporary, ($envelope | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false))
|
||||
[IO.File]::Move($temporary, $destination, $true)
|
||||
Write-Output "Signed request $requestHash. Transfer this envelope into the runner approvals folder, then resume."
|
||||
exit 0
|
||||
}
|
||||
if (-not $CertificatePath -or -not $ExpectedOwnerSid) { throw 'Public CertificatePath and ExpectedOwnerSid required' }
|
||||
$envelope = Get-Content -LiteralPath $ApprovalPath -Raw | ConvertFrom-Json
|
||||
$bytes = [Convert]::FromBase64String($envelope.payloadBase64)
|
||||
$signature = [Convert]::FromBase64String($envelope.signatureBase64)
|
||||
$certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new([IO.File]::ReadAllBytes($CertificatePath))
|
||||
if ($certificate.NotAfter.ToUniversalTime() -le [DateTime]::UtcNow -or $certificate.NotBefore.ToUniversalTime() -gt [DateTime]::UtcNow) {
|
||||
throw 'Owner certificate is outside its validity period'
|
||||
}
|
||||
$rsa = [Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($certificate)
|
||||
try { $valid = $rsa.VerifyData($bytes, $signature, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) }
|
||||
finally { $rsa.Dispose() }
|
||||
if (-not $valid) { throw 'Invalid approval signature' }
|
||||
$payload = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json
|
||||
$now = [DateTimeOffset]::UtcNow
|
||||
if ($payload.approved -ne $true -or $payload.requestSha256 -cne $requestHash -or $payload.ownerSid -cne $ExpectedOwnerSid `
|
||||
-or [DateTimeOffset]$payload.expiresUtc -le $now -or [DateTimeOffset]$payload.issuedUtc -gt $now.AddMinutes(5)) {
|
||||
throw 'Approval is expired, replayed, unapproved, future-dated or from another owner'
|
||||
}
|
||||
Write-Output 'Verified'
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error $_ -ErrorAction Continue
|
||||
exit 2
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#Requires -Version 7.2
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][ValidateSet('init','validate','dry-run','start','resume','status','pause','retry','current','logs','recover','configure')][string]$Command,
|
||||
[Parameter(Mandatory)][string]$ControlRoot,
|
||||
[string]$Repository,
|
||||
[string]$Stage,
|
||||
[string]$Manifest,
|
||||
[string]$Python = 'python'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = [IO.Path]::GetFullPath($ControlRoot)
|
||||
if ($Command -eq 'init') {
|
||||
$engine = Join-Path $PSScriptRoot '../automation/controller.py'
|
||||
} else {
|
||||
$engine = Join-Path $root 'controller.py'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $engine -PathType Leaf)) { throw "Controller missing: $engine" }
|
||||
$arguments = @($engine, $Command, '--control-root', $root)
|
||||
if ($Repository) { $arguments += @('--repository', $Repository) }
|
||||
if ($Stage) { $arguments += @('--stage', $Stage) }
|
||||
if ($Manifest) { $arguments += @('--manifest', $Manifest) }
|
||||
& $Python @arguments
|
||||
exit $LASTEXITCODE
|
||||
Reference in New Issue
Block a user