PSWSMan.psm1
using namespace System.Security.Cryptography.X509Certificates using namespace System.Management.Automation $Script:LibPath = Join-Path -Path $PSScriptRoot -ChildPath lib class X509CertificateChainAttribute : ArgumentTransformationAttribute { [object] Transform([EngineIntrinsics]$EngineIntrinsics, [object]$InputData) { # X509Certificate2Collection is an IEnumerable so we cannot use it in a switch statement or else an empty # collection becomes $null which we don't want. if ($InputData -is [X509Certificate2Collection]) { return $InputData } $outputData = switch($InputData) { { ($_ -is [X509Certificate2]) } { [X509Certificate2Collection]::new($_) } default { throw [ArgumentTransformationMetadataException]::new( "Could not convert input '$_' to a valid X509Certificate2Collection object." ) } } return $outputData } } Add-Type -Namespace PSWSMan -Name Native -MemberDefinition @' [StructLayout(LayoutKind.Sequential)] public class PWSH_Version { public Int32 Major; public Int32 Minor; public Int32 Build; public Int32 Revision; public static explicit operator Version(PWSH_Version v) { return new Version(v.Major, v.Minor, v.Build, v.Revision); } } [DllImport("libc")] public static extern void setenv(string name, string value); [DllImport("libc")] public static extern void unsetenv(string name); [DllImport("libmi")] public static extern void MI_Version_Info(PWSH_Version version); [DllImport("libpsrpclient")] public static extern void PSRP_Version_Info(PWSH_Version version); '@ Function exec { <# .SYNOPSIS Wraps a native exec call in a function so it can be set with '-ErrorAction SilentlyContinue'. #> [CmdletBinding()] param ( [Parameter(Mandatory=$true, Position=0)] [String] $FilePath, [Parameter(Position=1, ValueFromRemainingArguments=$true)] [String[]] $Arguments ) (&$FilePath @Arguments | Set-Variable output) 2>&1 | Set-Variable err $output if ($err) { $err | Write-Error } } Function setenv { <# .SYNOPSIS Wrapper calling setenv PInvoke method to set the process environment variables. #> [CmdletBinding()] param ( [Parameter(Mandatory=$true, Position=0)] [String] $Name, [Parameter(Position=1)] [AllowEmptyString()] $Value ) # We need to use the native setenv call as .NET keeps it's own register of env vars that are separate from the # process block that native libraries like libmi sees. We still set the .NET env var to keep things in sync. [PSWSMan.Native]::setenv($Name, $Value) Set-Item -LiteralPath env:$Name -Value $Value } Function unsetenv { <# .SYNOPSIS Wrapper calling unsetenv PInvoke method to unset the process environment variables. #> [CmdletBinding()] param ( [Parameter(Mandatory=$true, Position=0)] [String] $Name ) # We need to use the native unsetenv call as .NET keeps it's own register of env vars that are separate from the # process block that native libraries like libmi sees. We still unset the .NET env var to keep things in sync. [PSWSMan.Native]::unsetenv($Name) if (Test-Path -LiteralPath env:$Name) { Remove-Item -LiteralPath env:$Name -Force } } Function Get-Distribution { <# .SYNOPSIS Gets the host distribution name as understood by PSWSMan. #> [CmdletBinding()] param () $distribution = switch -Wildcard ($PSVersionTable.OS) { *Darwin* { 'macOS' } *Linux* { if (Test-Path -LiteralPath /etc/os-release -PathType Leaf) { $osRelease = @{} Get-Content -LiteralPath /etc/os-release | ForEach-Object -Process { if (-not $_.Trim() -or -not $_.Contains('=')) { return } $key, $value = $_.Split('=', 2) if ($value.StartsWith('"')) { $value = $value.Substring(1) } if ($value.EndsWith('"')) { $value = $value.Substring(0, $value.Length - 1) } $osRelease.$key = $value } $name = '' foreach ($key in @('ID', 'NAME')) { if ($osRelease.Contains($key) -and $osRelease.$key) { $name = $osRelease.$key break } } switch ($name) { 'alpine' { $version = ([Version]$osRelease.VERSION_ID).Major "alpine$($version)" } 'arch' { 'archlinux' } 'centos' { "centos$($osRelease.VERSION_ID)" } 'debian' { "debian$($osRelease.VERSION_ID)" } 'fedora' { "fedora$($osRelease.VERSION_ID)" } 'ubuntu' { "ubuntu$($osRelease.VERSION_ID)" } } } } } $distribution } Function Get-ValidDistributions { <# .SYNOPSIS Outputs a list of valid distributions available to PSWSMan #> [CmdletBinding()] param () Get-ChildItem -LiteralPath $Script:LibPath -Directory | ForEach-Object -Process { $libExtension = if ($_.Name -eq 'macOS') { 'dylib' } else { 'so' } $libraries = Get-ChildItem -LiteralPath $_.FullName -File -Filter "*.$libExtension" if ($libraries) { $_.name } } } Function Disable-WSManCertVerification { <# .SYNOPSIS Disables certificate verification globally. .DESCRIPTION Disables certificate verification for any WSMan requests globally. This can be disabled for just the CA or CN checks or for all checks. The absence of a switch does not enable those checks, it only disables the specific check requested if it was not disabled already. .PARAMETER CACheck Disables the certificate authority (CA) checks, i.e. the certificate authority chain does not need to be trusted. .PARAMETER CNCheck Disables the common name (CN) checks, i.e. the hostname does not need to match the CN or SAN on the endpoint certificate. .PARAMETER All Disables both the CA and CN checks. .EXAMPLE Disable all cert verification checks Disable-WSManCertVerification -All .EXAMPLE Disable just the CA verification checks Disable-WSManCertVerification -CACheck .NOTES These checks are set through environment vars which are scoped to a process and are not set to a specific connection. Unless you've set the specific env vars yourself then cert verification is enabled by default. #> [CmdletBinding(DefaultParameterSetName='Individual')] param ( [Parameter(ParameterSetName='Individual')] [Switch] $CACheck, [Parameter(ParameterSetName='Individual')] [Switch] $CNCheck, [Parameter(ParameterSetName='All')] [Switch] $All ) if ($All) { $CACheck = $true $CNCheck = $true } if ($CACheck) { setenv 'OMI_SKIP_CA_CHECK' '1' } if ($CNCheck) { setenv 'OMI_SKIP_CN_CHECK' '1' } } Function Enable-WSManCertVerification { <# .SYNOPSIS Enables cert verification globally. .DESCRIPTION Enables certificate verification for any WSMan requests globally. This can be enabled for just the CA or CN checks or for all checks. The absence of a switch does not disable those checks, it only enables the specific check requested if it was not enabled already. .PARAMETER CACheck Enable the certificate authority (CA) checks, i.e. the certificate authority chain is checked for the endpoint certificate. .PARAMETER CNCheck Enable the common name (CN) checks, i.e. the hostname matches the CN or SAN on the endpoint certificate. .PARAMETER All Enables both the CA and CN checks. .EXAMPLE Enable all cert verification checks Enable-WSManCertVerification -All .EXAMPLE Enable just the CA verification checks Enable-WSManCertVerification -CACheck .NOTES These checks are set through environment vars which are scoped to a process and are not set to a specific connection. Unless you've set the specific env vars yourself then cert verification is enabled by default. #> [CmdletBinding(DefaultParameterSetName='Individual')] param ( [Parameter(ParameterSetName='Individual')] [Switch] $CACheck, [Parameter(ParameterSetName='Individual')] [Switch] $CNCheck, [Parameter(ParameterSetName='All')] [Switch] $All ) if ($All) { $CACheck = $true $CNCheck = $true } if ($CACheck) { unsetenv 'OMI_SKIP_CA_CHECK' } if ($CNCheck) { unsetenv 'OMI_SKIP_CN_CHECK' } } Function Get-WSManVersion { <# .SYNOPSIS Gets the versions of the installed WSMan libraries. .DESCRIPTION Gets the versions of the libmi and libpsrpclient libraries that were specified at build time. This will only output a valid version if the installed libraries are ones built and installed by PSWSMan. .EXAMPLE Get-WSManVersion .OUTPUTS PSWSMan.Version [PSCustomObject]@{ MI = [Version] The version of libmi PSRP = [Version] The version of libpsrpclient } #> [CmdletBinding()] param () $nameMap = [Ordered]@{ MI = 'mi' PSRP = 'psrpclient' } $versions = [Ordered]@{ PSTypeName = 'PSWSMan.Version' } foreach ($map in $nameMap.GetEnumerator()) { $version = [PSWSMan.Native+PWSH_Version]::new() try { [PSWSMan.Native]::"$($map.Key)_Version_Info"($version) } catch [ArgumentNullException] { # .NET raises ArgumentNullException if the library or it's deps could not be found. $msg = "lib$($map.Value) could not be loaded, make sure it and its dependencies are available" Write-Error -Message $msg -Category NotInstalled $version = $null } catch [EntryPointNotFoundException] { # The function isn't exported which means the loaded version isn't from our custom build $msg = "Custom lib$($map.Value) has not been installed, have you restarted PowerShell after installing it?" Write-Error -Message $msg -Category NotInstalled $version = $null } $versions.$($map.Key) = [Version]$version } [PSCustomObject]$versions } Function Install-WSMan { <# .SYNOPSIS Install the patched WSMan libs. .DESCRIPTION Install the patched WSMan libs for the current distribution. .PARAMETER Distribution Specify the distribution to install the libraries for. If not set then the current distribution will calculated. .EXAMPLE # Need to run as root sudo pwsh -Command 'Install-WSMan' .NOTES Once updated, PowerShell must be restarted for the library to be usable. This is a limitation of how the libraries are loaded in a process. The function will warn if one of the libraries has been changed and a restart is required. #> [CmdletBinding(SupportsShouldProcess=$true)] param ( [String] $Distribution ) if (-not $Distribution) { $Distribution = Get-Distribution if (-not $Distribution) { Write-Error -Message "Failed to find distribution for current host" -Category InvalidOperation return } } Write-Verbose -Message "Installing WSMan libs for '$Distribution'" $validDistributions = Get-ValidDistributions if ($Distribution -notin $validDistributions) { $distroList = "'$($validDistributions -join "', '")'" $msg = "Unsupported distribution '$Distribution'. Supported distributions: $distroList" Write-Error -Message $msg -Category InvalidArgument return } $pwshDir = Split-Path -Path ([PSObject].Assembly.Location) -Parent $distributionLib = Join-Path $Script:LibPath -ChildPath $Distribution $libExtension = if ($distribution -eq 'macOS') { 'dylib' } else { 'so' } $notify = $false Get-ChildItem -LiteralPath $distributionLib -File -Filter "*.$libExtension" | ForEach-Object -Process { Write-Verbose -Message "Checking to see if $($_.Name) is installed" $destPath = Join-Path -Path $pwshDir -ChildPath $_.Name $change = $true if (Test-Path -LiteralPath $destPath) { $srcHash = (Get-FileHash -LiteralPath $_.Fullname -Algorithm SHA256).Hash $destHash = (Get-FileHash -LiteralPath $destPath -Algorithm SHA256).Hash $change = $srcHash -ne $destHash } if ($change) { Write-Verbose -Message "Installing $($_.Name) to '$pwshDir'" Copy-Item -LiteralPath $_.Fullname -Destination $destPath $notify = $true } } if ($notify) { $msg = 'WSMan libs have been installed, please restart your PowerShell session to enable it in PowerShell' Write-Warning -Message $msg } } Register-ArgumentCompleter -CommandName Install-WSMan -ParameterName Distribution -ScriptBlock { Get-ValidDistributions } Function Register-TrustedCertificate { <# .SYNOPSIS Registers a certificate into the system's trusted store. .DESCRIPTION Registers a certificate, or a chain or certificates, into the trusted store for the current Linux distribution. .PARAMETER Name The name of the certificate file to use when placing it into the trusted store directory. If not set then a random filename with the prefix 'PSWSMan-' will be used. .PARAMETER Path Specifies the path of a certificate to register. Wildcard characters are permitted. .PARAMETER LiteralPath Specifies a path to one or more locations of certificates to register. The value of 'LiteralPath' is used exactly as it is typed. No characters are interpreted as wildcards. .PARAMETER Certificate The raw X509Certificate2 or X509Certificate2Collection object to register. .EXAMPLE Register multiple PEMs using a wildcard Register-TrustedCertificate -Path /tmp/*.pem .EXAMPLE Register 'my*host.pem' using a literal path Register-TrustedCertificate -LiteralPath 'my*host.pem' .EXAMPLE Load your own certificate chain and register as one chain $certs = [Security.Cryptography.X509Certificates.X509Certificate2Collection]::new() $certs.Add([Security.Cryptography.X509Certificates.X509Certificate2]::new('/tmp/ca1.pem')) $certs.Add([Security.Cryptography.X509Certificates.X509Certificate2]::new('/tmp/ca2.pem')) Register-TrustedCertificate -Name MyDomainChains -Certificate $certs .EXAMPLE Register a certificate from a PEM encoded file as a normal user sudo pwsh -Command { Register-TrustedCertificate -Path /tmp/my_chain.pem } .NOTES This function needs to place files into trusted directories which typically require root access. This function needs to be running as root for it to succeed. #> [CmdletBinding(SupportsShouldProcess=$true, DefaultParameterSetName='Path')] param ( [String] $Name, [Switch] $Sudo, [Parameter(Mandatory=$true, ParameterSetName='Path', ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [SupportsWildcards()] [ValidateNotNullOrEmpty()] [String[]] $Path, [Parameter(Mandatory=$true, ParameterSetName='LiteralPath', ValueFromPipelineByPropertyName=$true)] [Alias('PSPath')] [ValidateNotNullOrEmpty()] [String[]] $LiteralPath, [Parameter(Mandatory=$true, ParameterSetName='Certificate', ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [X509CertificateChainAttribute()] [X509Certificate2Collection] $Certificate ) begin { $failed = $false $distribution = Get-Distribution if (-not $distribution) { Write-Error -Message "Failed to find distribution for current host" -Category InvalidOperation $failed = $true return } Write-Verbose -Message "Begin certificate registration for '$distribution'" # Determine the target path and refresh command based on the current distribution $certExtension = 'pem' $certPath, $refreshCommand = switch ($distribution) { archlinux { '/etc/ca-certificates/trust-source/anchors', 'update-ca-trust extract' } macOS { # macOS is special, we don't use the builtin LibreSSL setup and rely on brew to provide OpenSSL. This # means the path to the cert dir could change at any point in the future and we can't rely on default # system locations. Instead we use otool to figure out the linked location of openssl and use that. If # that fails then fallback to what should be the default '/user/local/etc/openssl@1.1/certs'. $libmiPath = Join-Path -Path $Script:Libpath -ChildPath 'macOS' -AdditionalChildPath 'libmi.dylib' $opensslPath = $null if (Test-Path -LiteralPath $libmiPath) { $opensslPath = exec otool -L $libmiPath -ErrorAction SilentlyContinue | Select-String -Pattern '\s+(\/.*libssl\..*\.dylib)\s+\(.*\)' | ForEach-Object -Process { Split-Path -Path (Split-Path -Path $_.Matches[0].Groups[1]) } | Select-Object -First 1 } elseif (Get-Command -Name brew -CommandType Application -ErrorAction SilentlyContinue) { $opensslPath = exec brew --prefix openssl -ErrorAction SilentlyContinue } if ($opensslPath) { $openssl = Join-Path $opensslPath -ChildPath bin -AdditionalChildPath openssl $certDirectory = exec $openssl @('version', '-d') -ErrorAction SilentlyContinue | Select-String -Pattern 'OPENSSLDIR:\s+[\"|''](.*)[\"|'']$' | ForEach-Object -Process { $_.Matches[0].Groups[1].Value } | Select-Object -First 1 } if (-not $certDirectory) { $certDirectory = '/usr/local/etc/openssl@1.1/certs' } $cRehash = Join-Path -Path $opensslPath -ChildPath bin -AdditionalChildPath c_rehash $certDirectory, $cRehash } { $_ -like 'centos*' -or $_ -like 'fedora*' } { '/etc/pki/ca-trust/source/anchors', 'update-ca-trust extract' } { $_ -like 'alpine*' -or $_ -like 'debian*' -or $_ -like 'ubuntu*' } { # While the format of the file is the same, these distributions expect the files to have a .crt extension. $certExtension = 'crt' '/usr/local/share/ca-certificates', 'update-ca-certificates' } } Write-Verbose "Trust directory '$certPath' - Refresh command '$refreshCommand'" if (-not (Test-Path -LiteralPath $certPath)) { $msg = "Failed to find the expected cert trust path at '$certPath' for distribution '$distribution'" Write-Error -Message $msg -Category ObjectNotFound $failed = $true return } # Store the pem files $chainPems = [Collections.Generic.List[String]]@() } process { # Safeguard in case the begin block failed if ($failed) { return } $header = '-----BEGIN CERTIFICATE-----' $footer = '-----END CERTIFICATE-----' if ($PSCmdlet.ParameterSetName -in @('Path', 'LiteralPath')) { $Certificate = [X509Certificate2Collection]::new() $filePaths = [Collections.Generic.List[String]]@() if ($PSCmdlet.ParameterSetName -eq 'Path') { $provider = $null foreach ($rawPath in $Path) { $filePaths.AddRange($PSCmdlet.GetResolvedProviderPathFromPSPath($rawPath, [ref]$provider)) } } elseif ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { $filePaths.Add($PSCmdlet.GetUnresolvedProviderPathFromPSPath($LiteralPath)) } foreach ($filePath in $filePaths) { Write-Verbose -Message "Processing input certificate at '$filePath'" if (-not (Test-Path -LiteralPath $filePath)) { Write-Error -Message "Certificate at '$filePath' does not exist." -Category ObjectNotFound continue } # X509Certificate2Collection.Import() can be temperamental when trying to load multi-pem files. # Instead detect if it's a PEM file and load all the certs manually. $rawCertContent = Get-Content -LiteralPath $filePath if ($header -in $rawCertContent -and $footer -in $rawCertContent) { foreach ($line in $rawCertContent) { if (-not $line -or $line -eq $header) { $currentCert = [Text.StringBuilder]::new() } elseif ($line -eq $footer) { $certBytes = [Convert]::FromBase64String($currentCert.ToString()) $cert = [X509Certificate2]::new($certBytes) $null = $Certificate.Add($cert) } else { $null = $currentCert.Append($line) } } } else { $Certificate.Import($filePath) } Write-Verbose -Message "Found $($Certificate.Count) cert(s) at '$filePath'" } } foreach ($cert in $Certificate) { Write-Verbose -Message "Processing certificate Subject: '$($cert.Subject)', Thumbprint: $($cert.Thumbprint)" $certBytes = $cert.Export([X509ContentType]::Cert) $certB64 = [Convert]::ToBase64String($certBytes, [Base64FormattingOptions]::InsertLineBreaks) $certB64 = $certB64 -replace "`r`n", "`n" $chainPems.Add("$header`n$certB64`n$footer") } } end { # Safeguard in case the begin block failed if ($failed) { return } if (-not $chainPems) { Write-Verbose -Message "No certificates found to import" return } $tempFile = [IO.Path]::GetTempFileName() try { foreach ($pem in $chainPems) { Add-Content -LiteralPath $tempFile -Value $pem } if (-not $Name) { $Name = "PSWSMan-$([IO.Path]::GetRandomFileName())" } $destCertPath = Join-Path -Path $certPath -ChildPath "$Name.$certExtension" if ($PSCmdlet.ShouldProcess($destCertPath, 'Register')) { Write-Verbose -Message "Creating trust cert file at '$destCertPath'" Copy-Item -LiteralPath $tempFile -Destination $destCertPath -Force # The command to run may contain argument, just use Invoke-Expression as the input is statically defined. Write-Verbose -Message "Refreshing the trusted certificate directory with '$refreshCommand'" Invoke-Expression -Command $refreshCommand } } finally { Remove-Item -LiteralPath $tempFile -Force } } } $export = @{ Function = @( 'Disable-WSManCertVerification', 'Enable-WSManCertVerification', 'Get-WSManVersion', 'Install-WSMan', 'Register-TrustedCertificate' ) } Export-ModuleMember @export |