private/functions/Submit-AxisCsrToCommand.ps1
|
function Submit-AxisCsrToCommand { <# .SYNOPSIS Signs a CSR by running an arbitrary command (OpenSSL CA or any CLI), locally or over SSH. .DESCRIPTION Issuer backend for Type='Command' certificate authorities. The CSR (and only the CSR, never a key) is written to a temporary file, the tokens in $Authority.SignCommand are expanded and the command is run through the platform shell (cmd.exe on Windows, /bin/sh elsewhere) with Start-Process so that it works on PowerShell 5.1, PowerShell 7 and inside runspace pools. When $Authority.SshHost is set, the CSR is copied to the remote host with scp, the command runs through ssh and the certificate is copied back. The command must write a PEM file to {CertFile}; the first CERTIFICATE block is the leaf, any further blocks are returned as the chain. Temporary files (local and remote) are removed in Finally. Authority keys: Type='Command' SignCommand Template, required. Tokens: {CsrFile} {CertFile} {Serial} {Reason} {Device} {Subject} WorkingDirectory Optional. Local: working directory of the shell. SSH: directory to cd into first. SshHost/SshUser/SshKeyFile Optional. When SshHost is set the command runs remotely via ssh/scp (OpenSSH client; BatchMode=yes so the key must be passphrase-less or in an agent). TimeoutSec Optional, default 120. Applies to every spawned process. Example SignCommand for an OpenSSL CA (openssl ca man page: https://docs.openssl.org/master/man1/openssl-ca/): openssl ca -batch -notext -config /etc/pki/ca/openssl.cnf -extensions server_cert -in {CsrFile} -out {CertFile} .PARAMETER Authority The authority hashtable from $Config.CertificateAuthorities. .PARAMETER Csr The PEM encoded certificate signing request. .PARAMETER Device The device the request is for. Only used for the {Device} token and verbose output. .PARAMETER Subject Optional subject string for the {Subject} token (the CSR itself is not parsed, PS 5.1 has no CSR parser). .PARAMETER TimeoutSec Overrides $Authority.TimeoutSec. #> [cmdletbinding()] Param( [Parameter(Mandatory)] [Hashtable]$Authority, [Parameter(Mandatory)] [String]$Csr, [Parameter()] [String]$Device="", [Parameter()] [String]$Subject="", [Parameter()] [Int]$TimeoutSec ) if(!$Authority.SignCommand) { Throw "Authority has no SignCommand" } if($Csr -notmatch "-----BEGIN (NEW )?CERTIFICATE REQUEST-----") { Throw "Csr is not a PEM encoded certificate signing request" } if(!$TimeoutSec) { $TimeoutSec = 120 if($Authority.TimeoutSec) { $TimeoutSec = [Int]$Authority.TimeoutSec } } $IsWin = ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows $UseSsh = [bool]$Authority.SshHost #Local scratch directory; only the CSR, the returned certificate and the wrapper script ever land here $Id = [guid]::NewGuid().ToString("N") $TempDir = Join-Path ([System.IO.Path]::GetTempPath()) "AxisPowershell-$Id" $LocalCsrFile = Join-Path $TempDir "request.csr" $LocalCertFile = Join-Path $TempDir "certificate.pem" $ScriptFile = Join-Path $TempDir $(if($IsWin -and !$UseSsh) { "run.cmd" } else { "run.sh" }) $StdOutFile = Join-Path $TempDir "stdout.txt" $StdErrFile = Join-Path $TempDir "stderr.txt" #Remote scratch names (POSIX remote assumed; see worker notes) $RemoteCsrFile = "/tmp/axisps-$Id.csr" $RemoteCertFile = "/tmp/axisps-$Id.crt" $RemoteCreated = $false #Quotes a path for a shell command line when needed $Quote = { Param($Path) if($Path -match "\s") { return "`"$Path`"" } return $Path } #Runs an executable with Start-Process, honours the timeout and throws with stderr on a non-zero exit. #Start-Process is used (not the call operator) so that it behaves the same on 5.1/7 and in runspace pools. $Run = { Param([String]$FilePath, [String[]]$ArgumentList, [String]$StdIn, [String]$WorkDir, [String]$What) if(Test-Path $StdOutFile) { Remove-Item $StdOutFile -Force } if(Test-Path $StdErrFile) { Remove-Item $StdErrFile -Force } $Param = @{ FilePath = $FilePath PassThru = $true RedirectStandardOutput = $StdOutFile RedirectStandardError = $StdErrFile } if($ArgumentList) { $Param.Add('ArgumentList',$ArgumentList) } if($StdIn) { $Param.Add('RedirectStandardInput',$StdIn) } if($WorkDir) { $Param.Add('WorkingDirectory',$WorkDir) } if($IsWin) { $Param.Add('WindowStyle','Hidden') } else { $Param.Add('NoNewWindow',$true) } Write-Verbose "Running: $FilePath $($ArgumentList -join ' ')" $Process = Start-Process @Param #Touching Handle makes ExitCode available on 5.1 even when the process exits before WaitForExit $null = $Process.Handle if(!$Process.WaitForExit($TimeoutSec * 1000)) { Try { $Process.Kill() } Catch { } Throw "$What timed out after $TimeoutSec seconds" } $StdOut = "" $StdErr = "" if(Test-Path $StdOutFile) { $StdOut = "$(Get-Content $StdOutFile -Raw)" } if(Test-Path $StdErrFile) { $StdErr = "$(Get-Content $StdErrFile -Raw)" } if($Process.ExitCode -ne 0) { $Detail = $StdErr.Trim() if(!$Detail) { $Detail = $StdOut.Trim() } Throw "$What failed with exit code $($Process.ExitCode): $Detail" } if($StdErr.Trim()) { Write-Verbose $StdErr.Trim() } return $StdOut } #Common ssh/scp options $SshTarget = $Authority.SshHost if($Authority.SshUser) { $SshTarget = "$($Authority.SshUser)@$($Authority.SshHost)" } $SshOptions = @("-o","BatchMode=yes","-o","ConnectTimeout=15") if($Authority.SshKeyFile) { $SshOptions += @("-i",$Authority.SshKeyFile) } Try { $null = New-Item -ItemType Directory -Path $TempDir -Force #Write only the CSR. ASCII without BOM so OpenSSL and friends can read it. [System.IO.File]::WriteAllText($LocalCsrFile, $Csr.Trim() + "`n", [System.Text.Encoding]::ASCII) if($UseSsh) { $CsrToken = $RemoteCsrFile $CertToken = $RemoteCertFile } else { $CsrToken = & $Quote $LocalCsrFile $CertToken = & $Quote $LocalCertFile } $Command = $Authority.SignCommand $Command = $Command.Replace("{CsrFile}",$CsrToken).Replace("{CertFile}",$CertToken) $Command = $Command.Replace("{Device}",$Device).Replace("{Subject}",$Subject) $Command = $Command.Replace("{Serial}","").Replace("{Reason}","") if($UseSsh) { #1. Upload the CSR $RemoteCreated = $true $null = & $Run -FilePath "scp" -ArgumentList ($SshOptions + @($LocalCsrFile,"$($SshTarget):$RemoteCsrFile")) -What "scp upload of CSR to $($Authority.SshHost)" #2. Run the sign command through the remote shell. The script is fed on stdin ("sh -s") so that # quoting in the template survives Start-Process argument parsing and the ssh re-join. $Script = "set -e`n" if($Authority.WorkingDirectory) { $Script += "cd '$($Authority.WorkingDirectory)'`n" } $Script += "$Command`n" [System.IO.File]::WriteAllText($ScriptFile, $Script, [System.Text.Encoding]::ASCII) $null = & $Run -FilePath "ssh" -ArgumentList ($SshOptions + @("-T",$SshTarget,"sh","-s")) -StdIn $ScriptFile -What "Sign command on $($Authority.SshHost)" #3. Fetch the certificate $null = & $Run -FilePath "scp" -ArgumentList ($SshOptions + @("$($SshTarget):$RemoteCertFile",$LocalCertFile)) -What "scp download of certificate from $($Authority.SshHost)" } else { if($IsWin) { [System.IO.File]::WriteAllText($ScriptFile, "@echo off`r`n$Command`r`n", [System.Text.Encoding]::ASCII) $Shell = "$env:ComSpec" if(!$Shell) { $Shell = "cmd.exe" } $Arguments = @("/d","/c",(& $Quote $ScriptFile)) } else { [System.IO.File]::WriteAllText($ScriptFile, "set -e`n$Command`n", [System.Text.Encoding]::ASCII) $Shell = "/bin/sh" $Arguments = @($ScriptFile) } $null = & $Run -FilePath $Shell -ArgumentList $Arguments -WorkDir $Authority.WorkingDirectory -What "Sign command" } if(!(Test-Path $LocalCertFile)) { Throw "Sign command finished but did not write a certificate to {CertFile}" } $Content = "$(Get-Content $LocalCertFile -Raw)" #openssl ca writes a text dump before the PEM unless -notext is used; ConvertFrom-PemString only looks at PEM blocks $Certificates = @(ConvertFrom-PemString -Pem $Content -AsCertificate) if($Certificates.Count -eq 0) { Throw "No CERTIFICATE block found in the output of the sign command" } $Leaf = $Certificates[0] $Chain = @() if($Certificates.Count -gt 1) { $Chain = @($Certificates[1..($Certificates.Count-1)] | ForEach-Object { ConvertTo-PemString -Certificate $_ }) } Write-Verbose "Issued $($Leaf.Subject) serial $($Leaf.SerialNumber)" return [pscustomobject]@{ Certificate = ConvertTo-PemString -Certificate $Leaf Chain = $Chain Serial = $Leaf.SerialNumber.ToUpper() RequestId = $null Status = 'Issued' } } Catch { Throw "Command authority sign failed: $_" } Finally { if($UseSsh -and $RemoteCreated) { Try { $null = & $Run -FilePath "ssh" -ArgumentList ($SshOptions + @("-T",$SshTarget,"rm","-f",$RemoteCsrFile,$RemoteCertFile)) -What "Remote cleanup" } Catch { Write-Verbose "Remote cleanup failed: $_" } } if(Test-Path $TempDir) { Remove-Item $TempDir -Recurse -Force -ErrorAction SilentlyContinue } } } |