85 lines
5.6 KiB
PowerShell
85 lines
5.6 KiB
PowerShell
#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
|
|
}
|