feat(automation): add durable runner and signed approval recovery

This commit is contained in:
cesnimda
2026-10-29 14:33:50 +01:00
parent 4da3939547
commit 607ee1853e
29 changed files with 29694 additions and 7 deletions
+84
View File
@@ -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
}
+24
View File
@@ -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