PSWSMan.psm1
using namespace System.Security.Cryptography.X509Certificates using namespace System.Management.Automation $importModule = Get-Command -Name Import-Module -Module Microsoft.PowerShell.Core if ('PSWSMan.OnModuleImportAndRemove' -as [type]) { &$importModule -Force -Assembly ([PSWSMan.OnModuleImportAndRemove].Assembly) } else { &$importModule ([IO.Path]::Combine($PSScriptRoot, 'bin', 'PSWSMan.dll')) -ErrorAction Stop } $Script:LibPath = Join-Path -Path $PSScriptRoot -ChildPath bin 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 } } Function exec { <# .SYNOPSIS Wraps a native exec call and output as separate streams for manual handling #> [CmdletBinding()] param ( [Parameter(Mandatory=$true, Position=0)] [String] $FilePath, [Parameter(Position=1, ValueFromRemainingArguments=$true)] [String[]] $Arguments ) [PSWSMan.Process]::Exec($FIlePath, $Arguments) } Function Get-OpenSSLInfo { <# .SYNOPSIS Gets the OpenSSL version and SSL dir that is currently installed. #> [CmdletBinding()] param ( [String[]] $LibSSL ) $sslPaths = if ($LibSSL) { $LibSSL } elseif ($IsMacOS) { @( 'libssl', 'libssl.dylib', 'libssl.1.1.dylib', 'libssl.10.dylib', 'libssl.1.0.0.dylib', 'libssl.3.dylib' ) } else { @( 'libssl', 'libssl.so', 'libssl.so.1.1', 'libssl.so.10', 'libssl.so.1.0.0', 'libssl.so.3' ) } Write-Verbose -Message "Getting OpenSSL version for '$($sslPaths -join "', '")'" $versionNum = [PSWSMan.Native]::OpenSSL_version_num($sslPaths) # MNNFFPPS: major minor fix patch status # For major=1 patch refers to the letter as a number, e.g. 1 == 'a', 2 == 'b', etc # We don't care about status $major = ($versionNum -band 0xF0000000) -shr 28 $minor = ($versionNum -band 0x0FF00000) -shr 20 $fix = ($versionNum -band 0x000FF000) -shr 12 $patch = ($versionNum -band 0x00000FF0) -shr 4 $version = [Version]::new($major, $minor, $fix, $patch) $sslDir = [PSWSMan.Native]::OpenSSL_version($sslPaths, 4) # OPENSSL_DIR $sslDir = if ($sslDir) { $sslDir | Select-String -Pattern 'OPENSSLDIR:\s+[\"|''](.*)[\"|'']$' | ForEach-Object -Process { $_.Matches[0].Groups[1].Value } | Select-Object -First 1 } [PSCustomObject]@{ Version = $version SSLDir = $sslDir } } Function Test-MacOSArchitecture { <# .SYNOPSIS Validates the binary specified is valid with the architecture desired. #> [CmdletBinding()] param ( [String] $DesiredArch, [String] $Path ) if (-not (Test-Path -LiteralPath $Path)) { Write-Verbose -Message "Binary at '$Path' does not exist - cannot test architecture" $false return } $archs = (lipo -archs $Path) -split " " Write-Verbose -Message "Checking if '$DesiredArch' for '$Path' is one of '$($archs -join "', '")'" $DesiredArch -in $archs } Function Get-MacOSOpenSSL { <# .SYNOPSIS Gets the libcrypto and libssl paths to use on macOS. It gets the path from the brew install openssl package and falls back to the port install package. We cannot use the LibreSSL version distributed by Apple as that is old and isn't compatible with libmi. #> [CmdletBinding()] param () $desiredArch = uname -m $portLocations = @('port', '/opt/local/bin/port') $brewLocations = @('brew', ($desiredArch -eq 'x86_64' ? '/usr/local/bin/brew' : '/opt/homebrew/bin/brew')) # Try a few locations just in case it's not in the PATH or the wrong architecture it foreach ($brewPath in $brewLocations) { $app = Get-Command -Name $brewPath -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $app) { Write-Verbose -Message "Failed to find brew at '$brewPath'" continue } $brewPath = $app.Path # OpenSSL can be installed under a few different names foreach ($package in @('openssl', 'openssl@3', 'openssl@1.1')) { $brewInfo = exec $brewPath --prefix $package $msg = "Attempting to get OpenSSL info with $brewPath --prefix $package`nSTDOUT: {0}`nSTDERR: {1}`nRC: {2}" -f ( $brewInfo.Stdout, $brewInfo.Stderr, $brewInfo.ExitCode) Write-Verbose -Message $msg if ($brewInfo.ExitCode -ne 0) { continue } $brewLibCrypto = Join-Path -Path $brewInfo.Stdout.Trim() lib libcrypto.dylib $brewLibSSL = Join-Path -Path $brewInfo.Stdout.Trim() lib libssl.dylib Write-Verbose -Message "Checking arch information for '$brewLibCrypto' and '$brewLibSSL'" if ( (Test-MacOSArchitecture -DesiredArch $desiredArch -Path $brewLibCrypto) -and (Test-MacOSArchitecture -DesiredArch $desiredArch -Path $brewLibSSL) ) { Write-Verbose "Brew $package libcrypto|ssl exists and is valid at '$brewLibCrypto' and '$brewLibSSL'" [PSCustomObject]@{ LibCrypto = $brewLibCrypto LibSSL = $brewLibSSL } return } } } Write-Verbose -Message "Failed to find OpenSSL with homebrew, falling back to port" foreach ($portPath in $portLocations) { $app = Get-Command -Name $portPath -Commandtype Application -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $app) { Write-Verbose -Message "Failed to find port at '$portPath'" continue } $portPath = $app.Path foreach ($package in @('openssl', 'openssl3', 'openssl11')) { $portInfo = exec $portPath contents $package $msg = "Attempting to get OpenSSL info $portPath contents $package`nSTDERR: {0}`nRC: {1}" -f ( $portInfo.Stderr, $portInfo.ExitCode) Write-Verbose -Message $msg if ($portInfo.ExitCode -ne 0) { continue } $portLibCrypto = $portLibSSL = $null $portInfo.Stdout -split '\r?\n' | ForEach-Object -Process { $line = $_.Trim() if (-not $line.StartsWith('/') -or ($portLibCrypto -and $portLibSSL)) { return } if ($line -like '*/libcrypto.dylib') { $portLibCrypto = $line } elseif ($line -like '*/libssl.dylib') { $portLibSSL = $line } } if (-not ($portLibCrypto -and $portLibSSL)) { Write-Verbose -Message "Failed to find libs for port $package" continue } Write-Verbose -Message "Checking arch information for '$portLibCrypto' and '$portLibSSL'" if ( (Test-MacOSArchitecture -DesiredArch $desiredArch -Path $portLibCrypto) -and (Test-MacOSArchitecture -DesiredArch $desiredArch -Path $portLibSSL) ) { Write-Verbose "Port $package libcrypto|ssl exists and is valid at '$portLibCrypto' and $portLibSSL'" [PSCustomObject]@{ LibCrypto = $portLibCrypto LibSSL = $portLibSSL } return } } } } Function Get-HostInfo { <# .SYNOPSIS Gets the host info that selects the native libraries to install. .NOTES Currently we support the following C Standard Libraries: macOS glibc musl Each support OpenSSL 1.1.x and 3.x and glibc also supports 1.0.x. #> [CmdletBinding()] param () $info = if ($IsMacOS) { $libDetails = Get-MacOSOpenSSL if ($libDetails) { $opensslVersion = (Get-OpenSSLInfo -LibSSL $libDetails.LibSSL).Version Write-Verbose -Message ("OpenSSL Version: Major {0} Minor {1} Patch {2}" -f ( $opensslVersion.Major, $opensslVersion.Minor, $opensslVersion.Build)) $openssl, $cryptoSource, $sslSource = switch ($opensslVersion) { { $_.Major -eq 1 -and $_.Minor -eq 1 } { '1.1', 'libcrypto.1.1.dylib', 'libssl.1.1.dylib' } { $_.Major -eq 3 } { '3', 'libcrypto.3.dylib', 'libssl.3.dylib' } # Just default to 1.1 in case something catastrophic went wrong default { '1.1', 'libcrypto.1.1.dylib', 'libssl.1.1.dylib' } } [PSCustomObject]@{ Distribution = 'macOS' StandardLib = 'macOS' OpenSSL = $openssl LibCrypto = @{ Source = $cryptoSource Target = $libDetails.LibCrypto } LibSSL = @{ Source = $sslSource Target = $libDetails.LibSSL } } } } else { $opensslVersion = (Get-OpenSSLInfo).Version Write-Verbose -Message ("OpenSSL Version: Major {0} Minor {1} Patch {2}" -f ( $opensslVersion.Major, $opensslVersion.Minor, $opensslVersion.Build)) $openssl = switch ($opensslVersion) { { $_.Major -eq 1 -and $_.Minor -eq 0 } { '1.0' } { $_.Major -eq 1 -and $_.Minor -eq 1 } { '1.1' } { $_.Major -eq 3 } { '3' } } $cStd = $null try { [void][PSWSMan.Native]::gnu_get_libc_version() $cStd = 'glibc' } catch [EntryPointNotFoundException] { # gnu_get_libc_version() is GLIBC, we fallback on a check to musl through ldd --version. $libcInfo = exec ldd --version $libcVerbose = "Not glibc, checking musl with ldd --version:`nSTDOUT: {0}`nSTDERR: {1}`nRC: {2}" -f ( $libcInfo.Stdout, $libcInfo.Stderr, $libcInfo.ExitCode) Write-Verbose -Message $libcVerbose # ldd --version can output on either STDOUT/STDERR so we check both if (($libcInfo.Stdout + $libcInfo.Stderr).Contains('musl', 'CurrentCultureIgnoreCase')) { $cStd = 'musl' } } # We don't need to modify the symlinks as the linked SSL libs should already match what's in the PATH. # Only exception is CentOS 7 which has libcrypto.so.10 and libssl.so.10. # | OpenSSL Version | crypto name | ssl name | # | 1.0.x | libcrypto.so.1.0.0 | libssl.so.1.0.0 | # | 1.1.x | libcrypto.so.1.1 | libssl.so.1.1 | # | 3.x | libcrypto.so.3 | libssl.so.3 | $distro = Get-DistributionInfo if ($distro.Name -eq 'centos' -and $distro.Info.VERSION_ID -eq '7') { $libCrypto = @{ Source = 'libcrypto.so.1.0.0' Target = '/lib64/libcrypto.so.10' } $libSSL = @{ Source = 'libssl.so.1.0.0' Target = '/lib64/libssl.so.10' } } else { $libCrypto = $null $libSSL = $null } [PSCustomObject]@{ StandardLib = $cStd OpenSSL = $openssl LibCrypto = $libCrypto LibSSL = $libSSL } } Write-Verbose -Message "Host Info:`n$($info | ConvertTo-Json)" $info } Function Get-DistributionInfo { <# .SYNOPSIS Gets the host distribution name as understood by PSWSMan. #> [CmdletBinding()] param () $info = [Ordered]@{ Platform = $PSVersionTable.Platform OS = $PSVersionTable.OS Name = '' Info = [Ordered]@{} } if (Test-Path -LiteralPath /etc/os-release -PathType Leaf) { 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) } $info.Info.$key = $value } foreach ($key in @('ID', 'NAME')) { if ($info.Info.Contains($key) -and $info.Info.$key) { $info.Name = $info.Info.$key break } } } [PSCustomObject]$info } Function Install-WSMan { [CmdletBinding(SupportsShouldProcess=$true)] param ( [String] $Distribution ) if ($Distribution) { Write-Warning -Message "-Distribution is deprecated and will be removed in a future version" } $hostInfo = Get-HostInfo if (-not $hostInfo.StandardLib -or -not $hostInfo.OpenSSL) { $msg = "Failed to select the necessary library, the host isn't macOS, Linux based on GLIBC or musl, or OpenSSL isn't installed" Write-Error -Message $msg -Category InvalidOperation return } $library = '{0}-{1}' -f ($hostInfo.StandardLib, $hostInfo.OpenSSL) Write-Verbose -Message "Installing WSMan libs for '$library'" $pwshDir = Split-Path -Path ([PSObject].Assembly.Location) -Parent $distributionLib = Join-Path $Script:LibPath -ChildPath $library $libExtension = if ($hostInfo.StandardLib -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'" if (Test-Path -LiteralPath $destPath) { Write-Verbose -Message "Creating backup of $($_.Name) to $($_.Name).bak" Copy-Item -LiteralPath $destPath -Destination "$($destPath).bak" -Force } Copy-Item -LiteralPath $_.Fullname -Destination $destPath $notify = $true } } # These symlinks are either no longer needed or we set them to our own path. Get-Item -Path (Join-Path -Path $pwshDir -ChildPath 'lib*.so*') | Where-Object { $_.Name -match 'lib(ssl|crypto)\.so.*' } | ForEach-Object -Process { Write-Verbose -Message "Removing existing symlink '$($_.FullName)'" $_ | Remove-Item -Force } $hostInfo.LibCrypto, $hostInfo.LibSSL | ForEach-Object -Process { if (-not $_) { return } $srcPath = Join-Path -Path $pwshDir -ChildPath $_.Source $create = $true $srcLink = Get-Item -LiteralPath $srcPath -ErrorAction SilentlyContinue if ($srcLink) { if ($srcLink.Target -ne $_.Target) { $srcLink | Remove-Item -Force } else { $create = $false } } if ($create) { Write-Verbose -Message "Creating symbolic link '$srcPath' -> '$($_.Target)'" New-Item -Path $srcPath -ItemType SymbolicLink -Value $_.Target | Out-Null } } if ($notify) { $msg = 'WSMan libs have been installed, please restart your PowerShell session to enable it in PowerShell' Write-Warning -Message $msg } } Function Register-TrustedCertificate { [CmdletBinding(SupportsShouldProcess=$true, DefaultParameterSetName='Path')] param ( [String] $Name, [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 $certExtension = 'pem' $certPath, $refreshCommand = if ($IsMacOS) { Write-Verbose -Message "Begin certificate registration for 'macOS'" # macOS is special, we don't use the builtin LibreSSL setup and rely on brew or port 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 determine the path to the libssl.dylib library and use # that to PInvoke the OPENSSLDIR value that it has registered. If that fails then fallback to what # should be the brew default '/user/local/etc/openssl@1.1/certs'. $openSSLInfo = Get-MacOSOpenSSL if (-not $openSSLInfo) { $msg = "Failed to find valid macOS OpenSSL package usable by PSWSMan" Write-Error -Message $msg -Category ObjectNotFound $failed = $true return } $libSSL = $openSSLInfo.LibSSL $opensslPath = Split-Path -Path (Split-Path $libSSL -Parent) -Parent $opensslBin = Join-Path -Path $opensslPath -ChildPath bin $cRehash = Join-Path -Path $opensslBin -ChildPath c_rehash $certDirectory = (Get-OpenSSLInfo -LibSSL $libSSL).SSLDir if (-not $certDirectory) { $certDirectory = '/usr/local/etc/openssl@1.1' } (Join-Path -Path $certDirectory -ChildPath certs), $cRehash } else { $distribution = Get-DistributionInfo Write-Verbose -Message "Begin certificate registration for '$($distribution.Name)'" $distroIds = [System.CollEctions.Generic.HashSet[String]]::new() if ($distribution.Info.ID) { [void]$distroIds.Add($distribution.Info.ID) } if ($distribution.Info.ID_LIKE) { $distribution.Info.ID_LIKE -split " " | ForEach-Object { [void]$distroIds.Add($_) } } Write-Verbose -Message "Checking for known ids in '$($distroIds -join "', '")'" if ('centos' -in $distroIds -or 'fedora' -in $distroIds -or 'rhel' -in $distroIds) { '/etc/pki/ca-trust/source/anchors', 'update-ca-trust extract' } elseif ('arch' -in $distroIds) { '/etc/ca-certificates/trust-source/anchors', 'update-ca-trust extract' } elseif ('alpine' -in $distroIds -or 'debian' -in $distroIds -or 'ubuntu' -in $distroIds) { # 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' } else { Write-Error -Message "Failed to determine cert setup information for current host" -Category InvalidOperation $failed = $true return } } Write-Verbose "Trust directory '$certPath' - Refresh command '$refreshCommand'" # We create the child dir if it doesn't exist but we want the parent to at least exist $parentDir = Split-Path $certPath -Parent if (-not (Test-Path -LiteralPath $parentDir)) { $msg = "Failed to find the expected cert trust parent dir at '$parentDir'" 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) { $hashStr = (Get-FileHash -LiteralPath $tempFile -Algorithm SHA256).Hash $Name = "PSWSMan-$hashStr" } if (-not (Test-Path $certPath)) { if ($PSCmdlet.ShouldProcess($certPath, 'Create')) { Write-Verbose -Message "Creating trust cert dir at '$certPath'" New-Item -Path $certPath -ItemType Directory | Out-Null } } $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 file must be executable exec chmod 755 $destCertPath | Out-Null # 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 = @( 'Install-WSMan', 'Register-TrustedCertificate' ) Cmdlet = ( 'Disable-WSManCertVerification', 'Enable-WSManCertVerification', 'Get-WSManVersion' ) } Export-ModuleMember @export |