#Requires -Version 7.2 <# The owner signs from a trusted operator terminal, with workers stopped. Same-user unattended use additionally requires native signing-key denial probes. Dedicated-runner installations may keep the key under a separate identity. #> [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 -ne 'Verify' -and $env:JOBBJAKT_ORCHESTRATED) { throw 'Autonomous workers cannot invoke owner signing actions' } 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 }