diff --git a/.augment/rules/PowershellScripts.md b/.augment/rules/PowershellScripts.md index 8baedd5..60d761c 100644 --- a/.augment/rules/PowershellScripts.md +++ b/.augment/rules/PowershellScripts.md @@ -1,4 +1,132 @@ --- -type: "manual" +type: "agent_requested" +description: "Use these for powershell scripts" --- +# PowerShell Coding Standards + +## Output and Logging +- No `Write-Host` +- No AI icons in log messages and strings +- Only `Write-Verbose`, `Write-Warning`, `Write-Error`, and `Write-Output` as needed +- When using toolkit's `$WriteLogMessage`: do NOT add timestamps (handled by scriptblock), do NOT check for null +- Centralized logging format: `[TimestampUTC] - [Level] - Message` +- Use `Start-Transcript` for script logging (no `-Append`, no custom log appending functions) +- Transcript log name should be based on the `BaseName` of the script + +## Object Construction +- Use `$Var = New-Object -TypeName 'Type'` instead of `[Type]::New()` +- Use `OrderedDictionary` for building object properties, then cast to PSObject +- Use `New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'` for splatting parameters +- Splatting dictionary properties should be indented one additional level from the variable assignment + +## Collections +- Use generic lists: `New-Object -TypeName 'System.Collections.Generic.List[System.String]'` +- Use `.Add()` and `.AddRange()` methods - never use `+=` for array concatenation +- Use `HashSet` when checking for existence/uniqueness + +## Paths and File System +- Use `[System.IO.FileInfo]` for file path parameters +- Use `[System.IO.DirectoryInfo]` for directory path parameters +- Build paths with: `[System.IO.FileInfo][System.IO.Path]::Combine(Path1, Path2, Leaf)` or `[System.IO.DirectoryInfo][System.IO.Path]::Combine(Path1, Path2)` +- Use `[System.IO.File]::Exists()` for file existence checks +- Use `[System.IO.Directory]::Exists()` for directory existence checks (not `Test-Path`) +- Use environment variable paths like `$Env:ProgramFiles` instead of hardcoded paths like `'C:\Program Files'` +- ScriptPath should be dynamically determined and used for logpath and name + +## File Encoding +- Use `WriteAllText` or `WriteAllLines` with non-BOM encoding +- Use ASCII encoding for config files to avoid BOM issues +- PowerShell's `[System.Text.Encoding]::UTF8` adds BOM - avoid for config files + +## URLs and Web Requests +- URLs should be `System.URI` with full scheme (e.g., `https://`) +- Use `System.Net.WebClient` with default credentials for downloads instead of `Invoke-WebRequest` + +## Conditions and Filtering + +### Where-Object Syntax +- Always wrap each condition in parentheses +- No spaces after `{` or before `}` + +```powershell +# Correct - Single condition +Where-Object {($_.Type -eq 'CABundle')} + +# Correct - Multiple conditions +Where-Object {($_.Type -eq 'CABundle') -and ($_.CertificateCount -gt 0)} + +# Incorrect +Where-Object { $_.Type -eq 'CABundle' } +``` + +### Switch Statements (Preferred over If Statements) +- Use `Switch ($VarName)` with `{($_ -eq $True)}` and `{($_ -eq $False)}` for single boolean +- Use `Switch ($True)` only for unrelated conditions +- Use `Default` for else cases +- Set conditional properties when splatting using switch statements + +```powershell +Switch ($SomeCondition) + { + {($_ -eq $True)} + { + # Do something + } + + Default + { + # Else case + } + } +``` + +### Combined Conditions +- Each sub-condition must be wrapped in parentheses +- Use `-eq $True` explicitly for switch parameters + +```powershell +# Correct +Switch (($EnableFeature.IsPresent -eq $True) -and ($ConfigExists -eq $True)) + +# Incorrect +Switch ($EnableFeature.IsPresent -and $ConfigExists) +``` + +## Code Formatting +- Do NOT put `{` on the same line - line break and indent one level +- Code inside block indented one more level +- Closing `}` at same indent as opening `{` +- Applies to all blocks: Try/Catch/Finally, Switch, For, Function, etc. +- Nested blocks continue the pattern + +## Loops +- Use long loop format: `For ($Counter Index etc) {}` +- Use Counter and Count variables for easy `Write-Progress` integration + +## Script Structure +- Main script should always have Try/Catch/Finally +- PS7 compatible for Linux/Mac portability (though not the main goal) +- Environment variables only for paths and non-sensitive config, never for secrets/API keys +- Here-strings should use `$($Var)` syntax to bake in values at generation time +- Service names should be generic to the product, not specific to single use case + +## String Checks +- Use `([System.String]::IsNullOrEmpty($var) -eq $False)` instead of implicit boolean +- Use `([System.String]::IsNullOrWhiteSpace($var) -eq $False)` when whitespace matters + +## PowerShell Modules +- No async/await +- No updating progress bars from background threads +- No workarounds without asking first +- Code quality over quick delivery +- Think before implementation - reusable code: BaseCmdlets, inherits, Classes, Models, Methods +- Add Write-Progress to cmdlets where appropriate +- Module folder should NOT be gitignored +- Build artifacts go into `Artifacts` folder +- All docs except README go into `docs` folder +- Scripts go into `scripts` folder +- Maintain a changelog +- Avoid excessive printing to threads +- Threads should track progress for resume capability +- Release format: `yyyy.mm.dd.hhmm` diff --git a/.gitignore b/.gitignore index 35063fc..6a94250 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,7 @@ CodeCoverage/ # NUnit *.VisualState.xml TestResult.xml -nunit-*.xml \ No newline at end of file +nunit-*.xml + +# Augment +.augment/ \ No newline at end of file diff --git a/Invoke-OpenSSHConfiguration.ps1 b/Invoke-OpenSSHConfiguration.ps1 index 2ede0fc..47c0f0b 100644 --- a/Invoke-OpenSSHConfiguration.ps1 +++ b/Invoke-OpenSSHConfiguration.ps1 @@ -43,22 +43,64 @@ [CmdletBinding(SupportsShouldProcess=$True)] Param - ( + ( [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] - [Alias('TSVars', 'TSVs')] - [String[]]$TaskSequenceVariables, + [Alias('M')] + [ValidateSet('Client', 'Server')] + [String]$Mode, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] - [Alias('TSVD', 'TSVDL')] - [String[]]$TSVariableDecodeList, - + [Alias('PKL')] + [String[]]$PublicKeyList, + + [Parameter(Mandatory=$False)] + [Switch]$GenerateKeyPair, + + [Parameter(Mandatory=$False)] + [ValidateSet('rsa', 'ecdsa', 'ed25519')] + [String]$KeyType = 'ed25519', + + [Parameter(Mandatory=$False)] + [ValidateSet(256, 384, 521, 2048, 3072, 4096)] + [Int]$KeyBits, + + [Parameter(Mandatory=$False)] + [Switch]$EnablePubkeyAuthentication, + + [Parameter(Mandatory=$False)] + [Switch]$DisablePasswordAuthentication, + + [Parameter(Mandatory=$False)] + [Switch]$EnableCertificateAuthentication, + + [Parameter(Mandatory=$False)] + [Switch]$TrustWindowsCertificateAuthorities, + + [Parameter(Mandatory=$False)] + [ValidateNotNullOrEmpty()] + [String]$CAInclusionExpression, + + [Parameter(Mandatory=$False)] + [ValidateNotNullOrEmpty()] + [String]$CAExclusionExpression, + + [Parameter(Mandatory=$False)] + [ValidateNotNullOrEmpty()] + [String]$SFTPSubsystemPath, + + [Parameter(Mandatory=$False)] + [Switch]$EnableService, + + [Parameter(Mandatory=$False)] + [Switch]$StartService, + [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [Alias('LogDir', 'LogPath')] [System.IO.DirectoryInfo]$LogDirectory, - + [Parameter(Mandatory=$False)] [Switch]$ContinueOnError ) @@ -110,9 +152,9 @@ Switch (Test-ProcessElevationStatus) #region Set default parameter values Switch ($True) { - {([System.String]::IsNullOrEmpty($ExampleVariable) -eq $True) -or ([System.String]::IsNullOrWhiteSpace($ExampleVariable) -eq $True)} + {([System.String]::IsNullOrEmpty($Mode) -eq $True) -or ([System.String]::IsNullOrWhiteSpace($Mode) -eq $True)} { - + } } #endregion @@ -230,13 +272,21 @@ Switch (Test-ProcessElevationStatus) $StartProcessWithOutputParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' $StartProcessWithOutputParameters.FilePath = 'msiexec.exe' - #$StartProcessWithOutputParameters.WorkingDirectory = "$($Env:SystemDrive)\Users\Public\Documents" $StartProcessWithOutputParameters.ArgumentList = New-Object -TypeName 'System.Collections.Generic.List[System.String]' $StartProcessWithOutputParameters.ArgumentList.Add('/i') $StartProcessWithOutputParameters.ArgumentList.Add("`"$($GitRepositoryReleaseAssetListItem.DestinationPath.FullName)`"") + + Switch ($True) + { + {([System.String]::IsNullOrEmpty($Mode) -eq $False) -and ([System.String]::IsNullOrWhiteSpace($Mode) -eq $False)} + { + $StartProcessWithOutputParameters.ArgumentList.Add("ADDLOCAL=$($Mode)") + } + } + $StartProcessWithOutputParameters.ArgumentList.Add('/qn') $StartProcessWithOutputParameters.ArgumentList.Add('/norestart') - $StartProcessWithOutputParameters.ArgumentList.Add("/l*v `"$($ExecutionLogPath.FullName)`"") + $StartProcessWithOutputParameters.ArgumentList.Add("/l*v `"$($ExecutionLogPath.FullName)`"") $StartProcessWithOutputParameters.AcceptableExitCodeList = New-Object -TypeName 'System.Collections.Generic.List[System.String]' $StartProcessWithOutputParameters.AcceptableExitCodeList.Add('0') $StartProcessWithOutputParameters.AcceptableExitCodeList.Add('3010') @@ -251,7 +301,7 @@ Switch (Test-ProcessElevationStatus) $MSIProductInfo = Get-MSIPropertyList -Path ($GitRepositoryReleaseAssetListItem.DestinationPath.FullName) - $WriteLogMessage.Invoke(0, @("Attempting to release binary $($GitRepositoryReleaseAssetListCounter) of $($GitRepositoryReleaseAssetListCount). Please Wait...", "Manufacturer: $($MSIProductInfo.Manufacturer)", "ProductName: $($MSIProductInfo.ProductName)", "ProductVersion: $($MSIProductInfo.ProductVersion)", "Product Language: $($MSIProductInfo.ProductLanguage)")) + $WriteLogMessage.Invoke(0, @("Attempting to install release binary $($GitRepositoryReleaseAssetListCounter) of $($GitRepositoryReleaseAssetListCount). Please Wait...", "Manufacturer: $($MSIProductInfo.Manufacturer)", "ProductName: $($MSIProductInfo.ProductName)", "ProductVersion: $($MSIProductInfo.ProductVersion)", "Product Language: $($MSIProductInfo.ProductLanguage)")) $StartProcessWithOutputResult = Start-ProcessWithOutput @StartProcessWithOutputParameters @@ -268,11 +318,492 @@ Switch (Test-ProcessElevationStatus) { {($_ -eq $True)} { - ###Do configuration stuff here - #PublicKeys - New and from list idepotent - #Certs - From windows store to PEM - #Options etc + #region Define OpenSSH Paths and Settings + [System.IO.DirectoryInfo]$OpenSSHProgramDataDirectory = [System.IO.Path]::Combine($Env:ProgramData, 'ssh') + [System.IO.FileInfo]$SSHDConfigPath = [System.IO.Path]::Combine($OpenSSHProgramDataDirectory.FullName, 'sshd_config') + [System.IO.FileInfo]$AdministratorsAuthorizedKeysPath = [System.IO.Path]::Combine($OpenSSHProgramDataDirectory.FullName, 'administrators_authorized_keys') + [System.Text.Encoding]$OpenSSHFileEncoding = [System.Text.Encoding]::ASCII + #endregion + #region Generate Key Pair + Switch ($GenerateKeyPair.IsPresent) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("Attempting to generate SSH key pair. Please Wait...")) + + [System.IO.DirectoryInfo]$SSHKeyDirectory = [System.IO.Path]::Combine($OpenSSHProgramDataDirectory.FullName, 'keys') + + Switch ($SSHKeyDirectory.Exists) + { + {($_ -eq $False)} + { + $WriteLogMessage.Invoke(0, @("Attempting to create directory `"$($SSHKeyDirectory.FullName)`". Please Wait...")) + + $Null = [System.IO.Directory]::CreateDirectory($SSHKeyDirectory.FullName) + } + } + + $KeyFileName = "ssh_host_$($KeyType)_key" + + [System.IO.FileInfo]$PrivateKeyPath = [System.IO.Path]::Combine($SSHKeyDirectory.FullName, $KeyFileName) + + [System.IO.FileInfo]$PublicKeyPath = [System.IO.Path]::Combine($SSHKeyDirectory.FullName, "$($KeyFileName).pub") + + Switch ($PrivateKeyPath.Exists) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("SSH key pair already exists at `"$($PrivateKeyPath.FullName)`". Skipping generation.")) + } + + Default + { + $SSHKeygenArgumentList = New-Object -TypeName 'System.Collections.Generic.List[System.String]' + $SSHKeygenArgumentList.Add('-t') + $SSHKeygenArgumentList.Add($KeyType) + + Switch (($Null -ine $KeyBits) -and ($KeyBits -gt 0)) + { + {($_ -eq $True)} + { + $SSHKeygenArgumentList.Add('-b') + $SSHKeygenArgumentList.Add($KeyBits.ToString()) + } + } + + $SSHKeygenArgumentList.Add('-f') + $SSHKeygenArgumentList.Add("`"$($PrivateKeyPath.FullName)`"") + $SSHKeygenArgumentList.Add('-N') + $SSHKeygenArgumentList.Add('""') + $SSHKeygenArgumentList.Add('-C') + $SSHKeygenArgumentList.Add("`"$($Env:COMPUTERNAME)`"") + + $StartProcessWithOutputParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' + $StartProcessWithOutputParameters.FilePath = 'ssh-keygen.exe' + $StartProcessWithOutputParameters.ArgumentList = $SSHKeygenArgumentList + $StartProcessWithOutputParameters.AcceptableExitCodeList = New-Object -TypeName 'System.Collections.Generic.List[System.String]' + $StartProcessWithOutputParameters.AcceptableExitCodeList.Add('0') + $StartProcessWithOutputParameters.CreateNoWindow = $True + $StartProcessWithOutputParameters.LogOutput = $True + $StartProcessWithOutputParameters.ContinueOnError = $False + $StartProcessWithOutputParameters.Verbose = $True + + $WriteLogMessage.Invoke(0, @("Generating $($KeyType) key pair at `"$($PrivateKeyPath.FullName)`". Please Wait...")) + + $StartProcessWithOutputResult = Start-ProcessWithOutput @StartProcessWithOutputParameters + + $WriteLogMessage.Invoke(0, @("SSH key pair generated successfully.")) + } + } + } + } + #endregion + + #region Add Public Keys + Switch (($Null -ine $PublicKeyList) -and ($PublicKeyList.Count -gt 0)) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("Attempting to add $($PublicKeyList.Count) public key(s) to authorized_keys. Please Wait...")) + + $ExistingAuthorizedKeys = New-Object -TypeName 'System.Collections.Generic.HashSet[System.String]' + + Switch ($AdministratorsAuthorizedKeysPath.Exists) + { + {($_ -eq $True)} + { + $ExistingContent = [System.IO.File]::ReadAllLines($AdministratorsAuthorizedKeysPath.FullName) + + ForEach ($Line In $ExistingContent) + { + Switch (([System.String]::IsNullOrEmpty($Line) -eq $False) -and ([System.String]::IsNullOrWhiteSpace($Line) -eq $False)) + { + {($_ -eq $True)} + { + $Null = $ExistingAuthorizedKeys.Add($Line.Trim()) + } + } + } + } + } + + $KeysToAdd = New-Object -TypeName 'System.Collections.Generic.List[System.String]' + + $PublicKeyListCounter = 1 + + ForEach ($PublicKey In $PublicKeyList) + { + $PublicKeyTrimmed = $PublicKey.Trim() + + Switch ($ExistingAuthorizedKeys.Contains($PublicKeyTrimmed)) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("Public key $($PublicKeyListCounter) of $($PublicKeyList.Count) already exists. Skipping.")) + } + + Default + { + $WriteLogMessage.Invoke(0, @("Adding public key $($PublicKeyListCounter) of $($PublicKeyList.Count).")) + $KeysToAdd.Add($PublicKeyTrimmed) + } + } + + $PublicKeyListCounter++ + } + + Switch ($KeysToAdd.Count -gt 0) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("Appending $($KeysToAdd.Count) new public key(s) to `"$($AdministratorsAuthorizedKeysPath.FullName)`". Please Wait...")) + + $Null = [System.IO.File]::AppendAllLines($AdministratorsAuthorizedKeysPath.FullName, $KeysToAdd, $OpenSSHFileEncoding) + + $WriteLogMessage.Invoke(0, @("Public keys added successfully.")) + } + } + } + } + #endregion + + #region Export Windows CA Certificates to PEM + Switch ($TrustWindowsCertificateAuthorities.IsPresent) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("Attempting to export Windows CA certificates to PEM format. Please Wait...")) + + $CertificateExpirationThreshold = Get-Date + + $CertificateStorePathList = New-Object -TypeName 'System.Collections.Generic.List[System.String]' + $CertificateStorePathList.Add('Cert:\LocalMachine\Root') + $CertificateStorePathList.Add('Cert:\LocalMachine\CA') + + $CACertificates = Get-ChildItem -Path $CertificateStorePathList | Where-Object {($_.NotAfter -gt $CertificateExpirationThreshold) -and ($_.Extensions | Where-Object {($_.Oid.FriendlyName -iin @('Basic Constraints')) -and ($_.CertificateAuthority -eq $True)})} + + #region Apply CA Inclusion Filter + Switch (([System.String]::IsNullOrEmpty($CAInclusionExpression) -eq $False) -and ([System.String]::IsNullOrWhiteSpace($CAInclusionExpression) -eq $False)) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("Applying CA inclusion filter: `"$($CAInclusionExpression)`"")) + $CACertificates = $CACertificates | Where-Object {($_.Subject -imatch $CAInclusionExpression) -or ($_.Thumbprint -imatch $CAInclusionExpression)} + } + } + #endregion + + #region Apply CA Exclusion Filter + Switch (([System.String]::IsNullOrEmpty($CAExclusionExpression) -eq $False) -and ([System.String]::IsNullOrWhiteSpace($CAExclusionExpression) -eq $False)) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("Applying CA exclusion filter: `"$($CAExclusionExpression)`"")) + $CACertificates = $CACertificates | Where-Object {($_.Subject -inotmatch $CAExclusionExpression) -and ($_.Thumbprint -inotmatch $CAExclusionExpression)} + } + } + #endregion + + $CACertificatesCount = ($CACertificates | Measure-Object).Count + + $WriteLogMessage.Invoke(0, @("Found $($CACertificatesCount) CA certificate(s) matching criteria.")) + + Switch ($CACertificatesCount -gt 0) + { + {($_ -eq $True)} + { + $ConvertFromCertificateToPEMParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' + $ConvertFromCertificateToPEMParameters.CertificateList = $CACertificates + $ConvertFromCertificateToPEMParameters.Export = $True + $ConvertFromCertificateToPEMParameters.ExportRootDirectory = $OpenSSHProgramDataDirectory.FullName + $ConvertFromCertificateToPEMParameters.CreateCABundle = $True + $ConvertFromCertificateToPEMParameters.CABundleFileName = 'OpenSSH-TrustedCertificateAuthorities-LocalMachine.pem' + $ConvertFromCertificateToPEMParameters.ContinueOnError = $False + $ConvertFromCertificateToPEMParameters.Verbose = $True + + $ConvertFromCertificateToPEMResult = ConvertFrom-CertificateToPEM @ConvertFromCertificateToPEMParameters + + $CABundleResult = $ConvertFromCertificateToPEMResult | Where-Object {($_.Type -ieq 'CABundle')} + + Switch ($Null -ine $CABundleResult) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("CA bundle exported successfully to `"$($CABundleResult.Path)`" with $($CABundleResult.CertificateCount) certificate(s).")) + } + } + } + } + } + } + #endregion + + #region Configure sshd_config + Switch ($SSHDConfigPath.Exists) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("Attempting to configure sshd_config at `"$($SSHDConfigPath.FullName)`". Please Wait...")) + + #region Backup Existing Configuration + $SSHDConfigBackupPath = [System.IO.FileInfo][System.IO.Path]::Combine($OpenSSHProgramDataDirectory.FullName, "sshd_config.backup.$($GetCurrentDateTimeFileFormat.Invoke())") + + $WriteLogMessage.Invoke(0, @("Creating backup of sshd_config to `"$($SSHDConfigBackupPath.FullName)`". Please Wait...")) + + $Null = [System.IO.File]::Copy($SSHDConfigPath.FullName, $SSHDConfigBackupPath.FullName, $True) + + $WriteLogMessage.Invoke(0, @("Backup created successfully.")) + #endregion + + $SSHDConfigContent = [System.IO.File]::ReadAllText($SSHDConfigPath.FullName) + $SSHDConfigModified = $False + + #region Enable PubkeyAuthentication + Switch ($EnablePubkeyAuthentication.IsPresent -eq $True) + { + {($_ -eq $True)} + { + $PubkeyAuthPattern = '(?m)^#?\s*PubkeyAuthentication\s+.*$' + $PubkeyAuthReplacement = 'PubkeyAuthentication yes' + + Switch ($SSHDConfigContent -imatch $PubkeyAuthPattern) + { + {($_ -eq $True)} + { + $SSHDConfigContent = [System.Text.RegularExpressions.Regex]::Replace($SSHDConfigContent, $PubkeyAuthPattern, $PubkeyAuthReplacement) + $SSHDConfigModified = $True + $WriteLogMessage.Invoke(0, @("Enabled PubkeyAuthentication.")) + } + + Default + { + $SSHDConfigContent = $SSHDConfigContent + "`r`n$($PubkeyAuthReplacement)" + $SSHDConfigModified = $True + $WriteLogMessage.Invoke(0, @("Added PubkeyAuthentication setting.")) + } + } + } + } + #endregion + + #region Disable PasswordAuthentication + Switch ($DisablePasswordAuthentication.IsPresent -eq $True) + { + {($_ -eq $True)} + { + $PasswordAuthPattern = '(?m)^#?\s*PasswordAuthentication\s+.*$' + $PasswordAuthReplacement = 'PasswordAuthentication no' + + Switch ($SSHDConfigContent -imatch $PasswordAuthPattern) + { + {($_ -eq $True)} + { + $SSHDConfigContent = [System.Text.RegularExpressions.Regex]::Replace($SSHDConfigContent, $PasswordAuthPattern, $PasswordAuthReplacement) + $SSHDConfigModified = $True + $WriteLogMessage.Invoke(0, @("Disabled PasswordAuthentication.")) + } + + Default + { + $SSHDConfigContent = $SSHDConfigContent + "`r`n$($PasswordAuthReplacement)" + $SSHDConfigModified = $True + $WriteLogMessage.Invoke(0, @("Added PasswordAuthentication setting (disabled).")) + } + } + } + } + #endregion + + #region Configure SFTP Subsystem (Default) + $SFTPPath = $SFTPSubsystemPath + + Switch (([System.String]::IsNullOrEmpty($SFTPPath) -eq $True) -or ([System.String]::IsNullOrWhiteSpace($SFTPPath) -eq $True)) + { + {($_ -eq $True)} + { + $SFTPPath = 'sftp-server.exe' + } + } + + $SubsystemPattern = '(?m)^#?\s*Subsystem\s+sftp\s+.*$' + $SubsystemReplacement = "Subsystem sftp $($SFTPPath)" + + Switch ($SSHDConfigContent -imatch $SubsystemPattern) + { + {($_ -eq $True)} + { + $CurrentSubsystemMatch = [System.Text.RegularExpressions.Regex]::Match($SSHDConfigContent, $SubsystemPattern) + + Switch ($CurrentSubsystemMatch.Value -ine $SubsystemReplacement) + { + {($_ -eq $True)} + { + $SSHDConfigContent = [System.Text.RegularExpressions.Regex]::Replace($SSHDConfigContent, $SubsystemPattern, $SubsystemReplacement) + $SSHDConfigModified = $True + $WriteLogMessage.Invoke(0, @("Configured SFTP subsystem: $($SFTPPath)")) + } + } + } + + Default + { + $SSHDConfigContent = $SSHDConfigContent + "`r`n$($SubsystemReplacement)" + $SSHDConfigModified = $True + $WriteLogMessage.Invoke(0, @("Added SFTP subsystem configuration.")) + } + } + #endregion + + #region Configure TrustedUserCAKeys for Certificate Authentication + Switch (($EnableCertificateAuthentication.IsPresent -eq $True) -and ($TrustWindowsCertificateAuthorities.IsPresent -eq $True)) + { + {($_ -eq $True)} + { + [System.IO.FileInfo]$CABundlePath = [System.IO.Path]::Combine($OpenSSHProgramDataDirectory.FullName, 'OpenSSH-TrustedCertificateAuthorities-LocalMachine.pem') + + Switch ($CABundlePath.Exists) + { + {($_ -eq $True)} + { + $TrustedCAKeysPattern = '(?m)^#?\s*TrustedUserCAKeys\s+.*$' + $TrustedCAKeysReplacement = "TrustedUserCAKeys $($CABundlePath.FullName)" + + Switch ($SSHDConfigContent -imatch $TrustedCAKeysPattern) + { + {($_ -eq $True)} + { + $SSHDConfigContent = [System.Text.RegularExpressions.Regex]::Replace($SSHDConfigContent, $TrustedCAKeysPattern, $TrustedCAKeysReplacement) + $SSHDConfigModified = $True + $WriteLogMessage.Invoke(0, @("Configured TrustedUserCAKeys to `"$($CABundlePath.FullName)`".")) + } + + Default + { + $SSHDConfigContent = $SSHDConfigContent + "`r`n$($TrustedCAKeysReplacement)" + $SSHDConfigModified = $True + $WriteLogMessage.Invoke(0, @("Added TrustedUserCAKeys setting.")) + } + } + } + } + } + } + #endregion + + #region Write sshd_config if Modified + Switch ($SSHDConfigModified -eq $True) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("Writing updated sshd_config. Please Wait...")) + [System.IO.File]::WriteAllText($SSHDConfigPath.FullName, $SSHDConfigContent, $OpenSSHFileEncoding) + $WriteLogMessage.Invoke(0, @("sshd_config updated successfully.")) + + #region Validate Configuration + $WriteLogMessage.Invoke(0, @("Validating sshd_config with sshd -t. Please Wait...")) + + $SSHDExePath = [System.IO.FileInfo][System.IO.Path]::Combine($Env:ProgramFiles, 'OpenSSH', 'sshd.exe') + + Switch ($SSHDExePath.Exists -eq $True) + { + {($_ -eq $True)} + { + $ValidateConfigParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' + $ValidateConfigParameters.FilePath = $SSHDExePath.FullName + $ValidateConfigParameters.ArgumentList = New-Object -TypeName 'System.Collections.Generic.List[System.String]' + $ValidateConfigParameters.ArgumentList.Add('-t') + $ValidateConfigParameters.AcceptableExitCodeList = New-Object -TypeName 'System.Collections.Generic.List[System.String]' + $ValidateConfigParameters.AcceptableExitCodeList.Add('0') + $ValidateConfigParameters.CreateNoWindow = $True + $ValidateConfigParameters.LogOutput = $True + $ValidateConfigParameters.ContinueOnError = $True + $ValidateConfigParameters.Verbose = $True + + $ValidateConfigResult = Start-ProcessWithOutput @ValidateConfigParameters + + Switch ($ValidateConfigResult.ExitCode -eq 0) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("sshd_config validation passed.")) + + $WriteLogMessage.Invoke(0, @("Attempting to restart sshd service to apply configuration changes. Please Wait...")) + $Null = Restart-Service -Name 'sshd' -Force -ErrorAction SilentlyContinue + $WriteLogMessage.Invoke(0, @("sshd service restarted.")) + } + + Default + { + $WriteLogMessage.Invoke(2, @("sshd_config validation failed. Restoring backup...")) + $Null = [System.IO.File]::Copy($SSHDConfigBackupPath.FullName, $SSHDConfigPath.FullName, $True) + $WriteLogMessage.Invoke(0, @("Backup restored. Configuration changes reverted.")) + } + } + } + + Default + { + $WriteLogMessage.Invoke(1, @("sshd.exe not found at `"$($SSHDExePath.FullName)`". Skipping validation.")) + + $WriteLogMessage.Invoke(0, @("Attempting to restart sshd service to apply configuration changes. Please Wait...")) + $Null = Restart-Service -Name 'sshd' -Force -ErrorAction SilentlyContinue + $WriteLogMessage.Invoke(0, @("sshd service restarted.")) + } + } + #endregion + } + + Default + { + $WriteLogMessage.Invoke(0, @("No changes required to sshd_config.")) + } + } + #endregion + } + } + #endregion + + #region Service Management + Switch ($EnableService.IsPresent -eq $True) + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("Configuring sshd service to start automatically. Please Wait...")) + $Null = Set-Service -Name 'sshd' -StartupType Automatic -ErrorAction SilentlyContinue + $WriteLogMessage.Invoke(0, @("sshd service configured for automatic startup.")) + } + } + + Switch ($StartService.IsPresent -eq $True) + { + {($_ -eq $True)} + { + $SSHDService = Get-Service -Name 'sshd' -ErrorAction SilentlyContinue + + Switch ($Null -ine $SSHDService) + { + {($_ -eq $True)} + { + Switch ($SSHDService.Status -ine 'Running') + { + {($_ -eq $True)} + { + $WriteLogMessage.Invoke(0, @("Starting sshd service. Please Wait...")) + $Null = Start-Service -Name 'sshd' -ErrorAction SilentlyContinue + $WriteLogMessage.Invoke(0, @("sshd service started.")) + } + + Default + { + $WriteLogMessage.Invoke(0, @("sshd service is already running.")) + } + } + } + } + } + } + #endregion } } #endregion diff --git a/README.md b/README.md index a7551cf..568b866 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,281 @@ -# Powershell-Script-Template +# Invoke-OpenSSHConfiguration +A PowerShell toolkit for automated installation and configuration of OpenSSH on Windows systems. Provides idempotent configuration of SSH server settings, public key authentication, certificate-based authentication using Windows certificate stores, and key pair generation. + +## Features + +- **Automated Installation**: Downloads and installs the latest Win32-OpenSSH MSI from GitHub, with dynamic OS architecture detection +- **Public Key Authentication**: Idempotently manages `administrators_authorized_keys` with deduplication +- **Certificate Authentication**: Exports Windows Root and Intermediate CA certificates to PEM format for OpenSSH trust +- **Key Pair Generation**: Creates SSH host keys with configurable algorithm and key size +- **Configuration Management**: Idempotently modifies `sshd_config` with proper encoding (ASCII, no BOM) +- **Elevation Handling**: Automatically re-launches with elevated privileges when required + +## Requirements + +- Windows 10/11 or Windows Server 2016+ +- PowerShell 5.1 or later +- Administrator privileges +- Internet connectivity (for initial OpenSSH download) + +## Installation + +Clone the repository and run the script directly: + +```powershell +git clone https://github.com/yourusername/Invoke-OpenSSHConfiguration.git +cd Invoke-OpenSSHConfiguration +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server +``` + +## Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `-Mode` | String | Installation mode: `Client` or `Server` | +| `-PublicKeyList` | String[] | Array of public keys to add to `administrators_authorized_keys` | +| `-GenerateKeyPair` | Switch | Generate SSH host key pair | +| `-KeyType` | String | Key algorithm: `rsa`, `ecdsa`, `ed25519` (default: `ed25519`) | +| `-KeyBits` | Int | Key size: 256, 384, 521 (ECDSA) or 2048, 3072, 4096 (RSA) | +| `-EnablePubkeyAuthentication` | Switch | Enable `PubkeyAuthentication yes` in sshd_config | +| `-DisablePasswordAuthentication` | Switch | Disable password authentication (`PasswordAuthentication no`) | +| `-EnableCertificateAuthentication` | Switch | Configure `TrustedUserCAKeys` for certificate auth | +| `-TrustWindowsCertificateAuthorities` | Switch | Export Windows CA certificates to PEM bundle | +| `-CAInclusionExpression` | String | Regex to filter CAs by Subject or Thumbprint (include matches) | +| `-CAExclusionExpression` | String | Regex to filter CAs by Subject or Thumbprint (exclude matches) | +| `-SFTPSubsystemPath` | String | Custom SFTP server path (default: `sftp-server.exe`, configured automatically) | +| `-EnableService` | Switch | Set sshd service to automatic startup | +| `-StartService` | Switch | Start sshd service if not running | +| `-LogDirectory` | DirectoryInfo | Custom log directory path | +| `-ContinueOnError` | Switch | Continue execution on non-fatal errors | + +## Usage Examples + +### Basic Server Installation + +```powershell +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server +``` + +### Server with Public Key Authentication + +```powershell +$PublicKeys = @( + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample1... user1@host', + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample2... user2@host' +) + +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server ` + -PublicKeyList $PublicKeys ` + -EnablePubkeyAuthentication +``` + +### Server with Certificate Authentication (Windows CA Trust) + +```powershell +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server ` + -TrustWindowsCertificateAuthorities ` + -EnableCertificateAuthentication ` + -EnablePubkeyAuthentication +``` + +This exports all unexpired Root and Intermediate CA certificates from the Windows certificate store to `C:\ProgramData\ssh\OpenSSH-TrustedCertificateAuthorities-LocalMachine.pem` and configures sshd to trust them. + +### Generate Host Keys + +```powershell +# Generate Ed25519 key (recommended) +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server -GenerateKeyPair + +# Generate RSA 4096-bit key +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server -GenerateKeyPair -KeyType rsa -KeyBits 4096 + +# Generate ECDSA 384-bit key +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server -GenerateKeyPair -KeyType ecdsa -KeyBits 384 +``` + +### Full Server Configuration + +```powershell +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server ` + -PublicKeyList @('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... admin@corp') ` + -GenerateKeyPair ` + -KeyType ed25519 ` + -TrustWindowsCertificateAuthorities ` + -EnableCertificateAuthentication ` + -EnablePubkeyAuthentication ` + -DisablePasswordAuthentication ` + -EnableService ` + -StartService ` + -LogDirectory 'C:\Logs\OpenSSH' +``` + +### Hardened Server (Keys Only, No Passwords) + +```powershell +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server ` + -EnablePubkeyAuthentication ` + -DisablePasswordAuthentication ` + -EnableService ` + -StartService +``` + +### Selective CA Trust (Regex Filtering) + +```powershell +# Only trust CAs with "Corp" or "Enterprise" in the subject +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server ` + -TrustWindowsCertificateAuthorities ` + -CAInclusionExpression '(Corp|Enterprise)' ` + -EnableCertificateAuthentication + +# Trust all CAs except Microsoft root CAs +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server ` + -TrustWindowsCertificateAuthorities ` + -CAExclusionExpression 'Microsoft' ` + -EnableCertificateAuthentication + +# Trust specific CA by thumbprint pattern +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server ` + -TrustWindowsCertificateAuthorities ` + -CAInclusionExpression '^A1B2C3' ` + -EnableCertificateAuthentication +``` + +### Custom SFTP Server Path + +SFTP subsystem is configured automatically. To use a custom path: + +```powershell +.\Invoke-OpenSSHConfiguration.ps1 -Mode Server ` + -SFTPSubsystemPath 'C:\Program Files\OpenSSH\sftp-server.exe' +``` + +## File Locations + +| File | Path | Description | +|------|------|-------------| +| sshd_config | `C:\ProgramData\ssh\sshd_config` | SSH server configuration | +| sshd_config.backup.* | `C:\ProgramData\ssh\sshd_config.backup.*` | Timestamped config backups | +| administrators_authorized_keys | `C:\ProgramData\ssh\administrators_authorized_keys` | Authorized keys for admin users | +| CA Bundle | `C:\ProgramData\ssh\OpenSSH-TrustedCertificateAuthorities-LocalMachine.pem` | Trusted CA certificates | +| Generated Keys | `C:\ProgramData\ssh\keys\` | Generated host key pairs | +| Logs | `%WINDIR%\Temp\Invoke-OpenSSHConfiguration\Logs\` | Script execution logs | + +## Certificate Authentication + +The script bridges Windows certificate trust to OpenSSH by exporting CA certificates: + +1. Reads unexpired CA certificates from `Cert:\LocalMachine\Root` and `Cert:\LocalMachine\CA` +2. Applies optional inclusion/exclusion regex filters on Subject and Thumbprint +3. Filters for certificates with Basic Constraints CA flag +4. Exports to PEM format bundle +5. Configures `TrustedUserCAKeys` in sshd_config + +**Important**: OpenSSH does NOT accept expired certificates. When CA certificates are renewed, re-run the script with `-TrustWindowsCertificateAuthorities` to update the PEM bundle. + +### Periodic CA Refresh + +For automated CA refresh, create a scheduled task: + +```powershell +$Action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-ExecutionPolicy Bypass -File "C:\Scripts\Invoke-OpenSSHConfiguration.ps1" -TrustWindowsCertificateAuthorities' +$Trigger = New-ScheduledTaskTrigger -Daily -At '3:00AM' +$Principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest +Register-ScheduledTask -TaskName 'OpenSSH-CA-Refresh' -Action $Action -Trigger $Trigger -Principal $Principal +``` + +## Toolkit Functions + +The toolkit includes reusable functions in `Toolkit\Functions\`: + +| Function | Description | +|----------|-------------| +| `ConvertFrom-CertificateToPEM` | Converts X.509 certificates to PEM format | +| `Get-GitRepositoryRelease` | Downloads releases from GitHub repositories | +| `Get-CurrentUser` | Retrieves current user session information | +| `Start-ProcessWithOutput` | Executes processes with output capture and logging | +| `Get-InstalledSoftware` | Queries installed software from registry | +| `Get-MSIPropertyList` | Reads MSI file properties | + +## Known Limitations + +### Current Gaps (Not Yet Implemented) + +**Server Configuration** +- Port configuration (change from default 22) +- User/group restrictions (AllowUsers, AllowGroups) +- SSH banner/MOTD configuration +- ListenAddress binding to specific interfaces + +**Security** +- No automatic ACL/permission setting on generated keys +- No Windows Firewall rule creation + +**Management** +- No uninstall/rollback capability +- No certificate expiry warnings + +### Features Implemented +- ✅ Password authentication control (`-DisablePasswordAuthentication`) +- ✅ Service auto-start (`-EnableService`, `-StartService`) +- ✅ Config backup before modification (automatic timestamped backups) +- ✅ Config validation (`sshd -t`) before restart with automatic rollback +- ✅ CA selection via regex (`-CAInclusionExpression`, `-CAExclusionExpression`) +- ✅ SFTP subsystem (configured automatically, custom path via `-SFTPSubsystemPath`) + +## Troubleshooting + +### Script Re-launches in New Window +The script automatically elevates to administrator. This is expected behavior. + +### Public Keys Not Working +1. Verify the key format is correct (starts with `ssh-rsa`, `ssh-ed25519`, etc.) +2. Check `C:\ProgramData\ssh\administrators_authorized_keys` file permissions +3. Ensure `PubkeyAuthentication yes` is in sshd_config + +### Certificate Authentication Not Working +1. Verify CA certificates are not expired +2. Check the PEM bundle exists at `C:\ProgramData\ssh\OpenSSH-TrustedCertificateAuthorities-LocalMachine.pem` +3. Verify `TrustedUserCAKeys` path in sshd_config +4. Run `sshd -t` to validate configuration + +### Service Won't Start +1. Check Windows Event Viewer for sshd errors +2. Validate config: `& 'C:\Program Files\OpenSSH\sshd.exe' -t` +3. Ensure no port conflicts on TCP 22 + +## Project Structure + +``` +Invoke-OpenSSHConfiguration/ +├── Invoke-OpenSSHConfiguration.ps1 # Main script +├── README.md # This file +├── Content/ # Additional content files +├── Toolkit/ +│ ├── Toolkit.ps1 # Core toolkit initialization +│ ├── Functions/ # Reusable PowerShell functions +│ │ ├── ConvertFrom-CertificateToPEM.ps1 +│ │ ├── Get-GitRepositoryRelease.ps1 +│ │ ├── Get-CurrentUser.ps1 +│ │ └── ... +│ ├── Modules/ # PowerShell modules +│ └── Tools/ # External tools +└── .augment/ + └── rules/ # Coding standards +``` + +## Contributing + +1. Follow the coding standards in `.augment/rules/PowershellScripts.md` +2. Use the established patterns (OrderedDictionary, Switch statements, etc.) +3. Test changes with `-Verbose` flag +4. Update documentation for new parameters + +## License + +[Add your license here] + +## Acknowledgments + +- [Win32-OpenSSH](https://github.com/PowerShell/Win32-OpenSSH) - Microsoft's OpenSSH port for Windows diff --git a/Toolkit/Functions/ConvertFrom-CertificateToPEM.ps1 b/Toolkit/Functions/ConvertFrom-CertificateToPEM.ps1 index 7d83dca..6a0ce89 100644 --- a/Toolkit/Functions/ConvertFrom-CertificateToPEM.ps1 +++ b/Toolkit/Functions/ConvertFrom-CertificateToPEM.ps1 @@ -5,21 +5,81 @@ Function ConvertFrom-CertificateToPEM { <# .SYNOPSIS - A brief overview of what your function does - + Convert X.509 certificates to PEM format with support for certificate, public key, and private key extraction. + .DESCRIPTION - Slightly more detailed description of what your function does - - .PARAMETER ParameterName - Your parameter description + Extracts and converts X.509 certificates from the Windows certificate store to Privacy-Enhanced Mail (PEM) format. + This function supports both RSA and ECC (ECDSA) key algorithms and can extract: - .PARAMETER ParameterName - Your parameter description + - Certificate data (-----BEGIN CERTIFICATE-----/-----END CERTIFICATE-----) + - Public keys (-----BEGIN PUBLIC KEY-----/-----END PUBLIC KEY-----) + - Private keys (-----BEGIN PRIVATE KEY-----/-----END PRIVATE KEY-----) + + The function is particularly useful for: + - Setting up certificate-based authentication for OpenSSH servers and clients + - Exporting certificates for use with Linux/Unix systems + - Creating CA trust chains for SSH host or user certificate authentication + - Converting Windows certificates for cross-platform applications + - Preparing certificates for web servers, APIs, or other TLS-enabled services + + Certificates can be specified by serial number, thumbprint, or passed directly as X509Certificate2 objects. + When exporting, individual PEM files are created for each component, and optionally a combined CA bundle file + can be generated containing all certificates for trust chain scenarios. + + .PARAMETER SerialNumberList + One or more certificate serial numbers to locate and convert. The function searches all certificate stores + (LocalMachine and CurrentUser) to find matching certificates. Serial numbers are case-insensitive. + This parameter belongs to the 'BySerialNumber' parameter set. + + .PARAMETER ThumbprintList + One or more certificate thumbprints (SHA-1 hash) to locate and convert. The function searches all certificate + stores to find matching certificates. Thumbprints are 40 hexadecimal characters and are case-insensitive. + This parameter belongs to the 'ByThumbprint' parameter set. + + .PARAMETER CertificateList + One or more X509Certificate2 objects to convert. Use this when you already have certificate objects in memory, + such as from Get-ChildItem Cert:\ or New-SelfSignedCertificate. + This parameter belongs to the 'ByCertificate' parameter set. + + .PARAMETER Export + When specified, exports the PEM data to files on disk. Without this switch, only the in-memory PEM blobs + are returned in the output object. Each certificate's components are exported to a subdirectory named + by serial number under the ExportRootDirectory. + + .PARAMETER ExportRootDirectory + The root directory where exported PEM files will be saved. Each certificate creates a subdirectory + named by its serial number containing Certificate.pem, PublicKey.pem, and PrivateKey.pem files. + Defaults to '$Env:Public\Documents\ConvertFrom-CertificateToPEM' if not specified. + + .PARAMETER CreateCABundle + When specified along with -Export, creates a combined CA bundle file (ca-bundle.pem) containing all + processed certificates. This is useful for creating trust chains for OpenSSH TrustedUserCAKeys or + TrustedHostCAKeys configuration, or for other applications that need a single file with multiple + CA certificates. + + .PARAMETER CABundleFileName + The filename for the combined CA bundle when -CreateCABundle is specified. + Defaults to 'ca-bundle.pem' if not specified. + + .PARAMETER IncludePrivateKey + When specified, attempts to extract and export the private key if the certificate has an exportable + private key. By default, private keys are NOT exported for security reasons. + + For OpenSSH certificate authentication, private keys are typically NOT needed on the server side - + only the CA's public certificate is required for TrustedUserCAKeys or TrustedHostCAKeys. + + Only use this switch when you specifically need the private key, such as: + - Backing up a CA certificate with its private key + - Migrating certificates to another system + - Setting up client-side identity keys + + .PARAMETER ContinueOnError + When specified, continues processing remaining certificates if an error occurs with one certificate. + Without this switch, the first error terminates the function. - .PARAMETER ParameterName - Your parameter description - .EXAMPLE + Export a certificate by serial number for use with OpenSSH user authentication. + $ConvertFromCertificateToPEMParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' $ConvertFromCertificateToPEMParameters.SerialNumberList = New-Object -TypeName 'System.Collections.Generic.List[System.String]' $ConvertFromCertificateToPEMParameters.SerialNumberList.Add('3d05e1f73beb5d3cd1be2b9f8d96f6c323260f78') @@ -33,15 +93,31 @@ Function ConvertFrom-CertificateToPEM Write-Output -InputObject ($ConvertFromCertificateToPEMResult) .EXAMPLE - [String]$RootCALocation = 'Cert:\LocalMachine\My' + Export a certificate by thumbprint. + + $ConvertFromCertificateToPEMParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' + $ConvertFromCertificateToPEMParameters.ThumbprintList = New-Object -TypeName 'System.Collections.Generic.List[System.String]' + $ConvertFromCertificateToPEMParameters.ThumbprintList.Add('A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2') + $ConvertFromCertificateToPEMParameters.Export = $True + $ConvertFromCertificateToPEMParameters.ExportRootDirectory = "$($Env:ProgramData)\OpenSSH\Certificates" + $ConvertFromCertificateToPEMParameters.ContinueOnError = $False + $ConvertFromCertificateToPEMParameters.Verbose = $True + + $ConvertFromCertificateToPEMResult = ConvertFrom-CertificateToPEM @ConvertFromCertificateToPEMParameters + + Write-Output -InputObject ($ConvertFromCertificateToPEMResult) + + .EXAMPLE + Create a self-signed CA certificate and export it for OpenSSH TrustedUserCAKeys configuration. + + [String]$RootCALocation = 'Cert:\LocalMachine\My' + [String]$RootCAFriendlyName = 'SSH User CA Certificate' - [String]$RootCAFriendlyName = 'Self Signed Root CA Certificate' - $DNSHostEntry = [System.Net.Dns]::GetHostEntry('LocalHost') $NewSelfSignedCertificateParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' $NewSelfSignedCertificateParameters.FriendlyName = $RootCAFriendlyName - $NewSelfSignedCertificateParameters.Subject = "$($Env:ComputerName.ToUpper())" + $NewSelfSignedCertificateParameters.Subject = "CN=SSH User CA" $NewSelfSignedCertificateParameters.DnsName = New-Object -TypeName 'System.Collections.Generic.List[System.String]' $NewSelfSignedCertificateParameters.DnsName.Add($DNSHostEntry.HostName) $NewSelfSignedCertificateParameters.NotBefore = (Get-Date).Date @@ -52,7 +128,6 @@ Function ConvertFrom-CertificateToPEM $NewSelfSignedCertificateParameters.Provider = 'Microsoft Enhanced RSA and AES Cryptographic Provider' $NewSelfSignedCertificateParameters.TextExtension = New-Object -TypeName 'System.Collections.Generic.List[System.String]' $NewSelfSignedCertificateParameters.TextExtension.Add('2.5.29.19={text}CA=1&pathlength=3') - $NewSelfSignedCertificateParameters.TextExtension.Add('2.5.29.37={text}1.3.6.1.5.5.7.3.3') $NewSelfSignedCertificateParameters.KeyUsageProperty = New-Object -TypeName 'System.Collections.Generic.List[Microsoft.Certificateservices.Commands.Keyusageproperty]' $NewSelfSignedCertificateParameters.KeyUsageProperty.Add('All') $NewSelfSignedCertificateParameters.KeyUsage = New-Object -TypeName 'System.Collections.Generic.List[Microsoft.Certificateservices.Commands.Keyusage]' @@ -67,41 +142,129 @@ Function ConvertFrom-CertificateToPEM $ConvertFromCertificateToPEMParameters.CertificateList = New-Object -TypeName 'System.Collections.Generic.List[System.Security.Cryptography.X509Certificates.X509Certificate2]' $ConvertFromCertificateToPEMParameters.CertificateList.Add($SelfSignedCertificate) $ConvertFromCertificateToPEMParameters.Export = $True - $ConvertFromCertificateToPEMParameters.ExportRootDirectory = "$($Env:Userprofile)\Downloads\Certificates" + $ConvertFromCertificateToPEMParameters.ExportRootDirectory = "$($Env:ProgramData)\ssh" $ConvertFromCertificateToPEMParameters.ContinueOnError = $False $ConvertFromCertificateToPEMParameters.Verbose = $True $ConvertFromCertificateToPEMResult = ConvertFrom-CertificateToPEM @ConvertFromCertificateToPEMParameters + # The Certificate.pem file can be used in sshd_config as: + # TrustedUserCAKeys C:\ProgramData\ssh\\Certificate.pem + Write-Output -InputObject ($ConvertFromCertificateToPEMResult) - + + .EXAMPLE + Export all unexpired Root CAs and Intermediate CAs (Sub-CAs) to the machine SSH trust location without private keys. + This is the recommended approach for configuring OpenSSH TrustedUserCAKeys or TrustedHostCAKeys. + + # Collect all unexpired CA certificates from both Root and Intermediate CA stores + $CACertificateList = New-Object -TypeName 'System.Collections.Generic.List[System.Security.Cryptography.X509Certificates.X509Certificate2]' + + # Get unexpired Root CAs (Cert:\LocalMachine\Root) + $RootCAs = Get-ChildItem -Path 'Cert:\LocalMachine\Root' | Where-Object { ($_.NotAfter -gt (Get-Date)) -and ($_.Extensions | Where-Object { $_.Oid.FriendlyName -eq 'Basic Constraints' -and $_.CertificateAuthority }) } + $CACertificateList.AddRange([System.Security.Cryptography.X509Certificates.X509Certificate2[]]$RootCAs) + + # Get unexpired Intermediate/Sub CAs (Cert:\LocalMachine\CA) + $IntermediateCAs = Get-ChildItem -Path 'Cert:\LocalMachine\CA' | Where-Object { ($_.NotAfter -gt (Get-Date)) -and ($_.Extensions | Where-Object { $_.Oid.FriendlyName -eq 'Basic Constraints' -and $_.CertificateAuthority }) } + $CACertificateList.AddRange([System.Security.Cryptography.X509Certificates.X509Certificate2[]]$IntermediateCAs) + + # Export to the standard OpenSSH machine configuration directory + # Note: -IncludePrivateKey is NOT specified, so only public certificates are exported (secure default) + $ConvertFromCertificateToPEMParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' + $ConvertFromCertificateToPEMParameters.CertificateList = $CACertificateList + $ConvertFromCertificateToPEMParameters.Export = $True + $ConvertFromCertificateToPEMParameters.ExportRootDirectory = "$($Env:ProgramData)\ssh" + $ConvertFromCertificateToPEMParameters.CreateCABundle = $True + $ConvertFromCertificateToPEMParameters.CABundleFileName = 'OpenSSH-TrustedCertificateAuthorities-LocalMachine.pem' + $ConvertFromCertificateToPEMParameters.ContinueOnError = $False + $ConvertFromCertificateToPEMParameters.Verbose = $True + + $ConvertFromCertificateToPEMResult = ConvertFrom-CertificateToPEM @ConvertFromCertificateToPEMParameters + + # The combined CA bundle can be referenced in sshd_config as: + # TrustedUserCAKeys C:\ProgramData\ssh\OpenSSH-TrustedCertificateAuthorities-LocalMachine.pem + # + # For host certificate verification: + # TrustedHostCAKeys C:\ProgramData\ssh\OpenSSH-TrustedCertificateAuthorities-LocalMachine.pem + + Write-Output -InputObject ($ConvertFromCertificateToPEMResult) + .NOTES - Any useful tidbits - + OpenSSH Certificate Authentication: + - For TrustedUserCAKeys: Export the CA certificate and reference the Certificate.pem file + - For TrustedHostCAKeys: Same approach for host certificate verification + - The CA's private key is NOT needed on the SSH server - only the public certificate + - The CA bundle option (-CreateCABundle) is useful when multiple CAs can sign user/host certificates + - Machine trust location on Windows: $Env:ProgramData\ssh (typically C:\ProgramData\ssh) + + Certificate Expiration and Validity: + - OpenSSH does NOT accept expired certificates for authentication + - Certificates must have valid NotBefore and NotAfter dates at the time of authentication + - If a CA certificate expires, all user/host certificates signed by that CA become invalid + - Plan certificate renewal before expiration to avoid authentication failures + - Use the output properties NotBefore and NotAfter to monitor certificate validity + + Private Key Security: + - By default, private keys are NOT exported for security reasons + - Use -IncludePrivateKey only when you specifically need to export private keys + - Private keys must be marked as exportable in the certificate store to be extracted + - Access to private keys may require administrative privileges + - Keep private keys secure and never expose them unnecessarily + + Key Algorithm Support: + - RSA: Fully supported for certificate, public key, and private key extraction + - ECC/ECDSA: Fully supported for certificate, public key, and private key extraction + - Other algorithms may have limited support + + PEM File Encoding: + - All PEM files are written with ASCII encoding for maximum compatibility + - Line endings use the platform default (CRLF on Windows) + .LINK - Place any useful link here where your function or cmdlet can be referenced + https://man.openbsd.org/sshd_config#TrustedUserCAKeys + + .LINK + https://docs.microsoft.com/en-us/windows-server/administration/openssh/openssh_keymanagement + + .LINK + https://www.rfc-editor.org/rfc/rfc7468 (Textual Encodings of PKIX, PKCS, and CMS Structures) #> [CmdletBinding(ConfirmImpact = 'Low', DefaultParameterSetName = 'BySerialNumber')] - + Param - ( + ( [Parameter(Mandatory=$True, ParameterSetName = 'BySerialNumber')] [ValidateNotNullOrEmpty()] [String[]]$SerialNumberList, - + + [Parameter(Mandatory=$True, ParameterSetName = 'ByThumbprint')] + [ValidateNotNullOrEmpty()] + [ValidatePattern('^[A-Fa-f0-9]{40}$')] + [String[]]$ThumbprintList, + [Parameter(Mandatory=$True, ParameterSetName = 'ByCertificate')] [ValidateNotNullOrEmpty()] [System.Security.Cryptography.X509Certificates.X509Certificate2[]]$CertificateList, [Parameter(Mandatory=$False)] [Switch]$Export, - + [Parameter(Mandatory=$False)] [AllowEmptyString()] [AllowNull()] [System.IO.DirectoryInfo]$ExportRootDirectory, - + + [Parameter(Mandatory=$False)] + [Switch]$CreateCABundle, + + [Parameter(Mandatory=$False)] + [ValidateNotNullOrEmpty()] + [String]$CABundleFileName, + + [Parameter(Mandatory=$False)] + [Switch]$IncludePrivateKey, + [Parameter(Mandatory=$False)] [Switch]$ContinueOnError ) @@ -207,16 +370,24 @@ Function ConvertFrom-CertificateToPEM { [System.IO.DirectoryInfo]$ExportRootDirectory = "$($Env:Public)\Documents\$($FunctionName)" } + + {([String]::IsNullOrEmpty($CABundleFileName) -eq $True) -or ([String]::IsNullOrWhiteSpace($CABundleFileName) -eq $True)} + { + [String]$CABundleFileName = 'ca-bundle.pem' + } } $X509Certificate2List = New-Object -TypeName 'System.Collections.Generic.List[System.Security.Cryptography.X509Certificates.X509Certificate2]' + #List to collect all certificate PEM blobs for CA bundle creation + $CABundleCertificateBlobs = New-Object -TypeName 'System.Collections.Generic.List[System.String]' + Switch ($PSCmdlet.ParameterSetName) { {($_ -iin @('BySerialNumber'))} { $ExistingCertificateList = Get-ChildItem -Path @('Microsoft.PowerShell.Security\Certificate::\') -Recurse -Force -ErrorAction SilentlyContinue | Where-Object {($Null -ine $_.SerialNumber)} - + ForEach ($SerialNumber In $SerialNumberList) { Switch ($ExistingCertificateList.SerialNumber -icontains $SerialNumber) @@ -227,7 +398,7 @@ Function ConvertFrom-CertificateToPEM $ExistingCertificateCount = ($ExistingCertificates | Measure-Object).Count - $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Located $($ExistingCertificateCount) certificate(s) for certificate serial number `"$($SerialNumber)`"." + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Located $($ExistingCertificateCount) certificate(s) for serial number `"$($SerialNumber)`"." Write-Verbose -Message ($LoggingDetails.LogMessage) Switch ($ExistingCertificateCount) @@ -238,13 +409,62 @@ Function ConvertFrom-CertificateToPEM } {($_ -gt 1)} - { + { ForEach ($ExistingCertificate In $ExistingCertificates) { $X509Certificate2List.Add($ExistingCertificate) } } - } + } + } + + Default + { + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - No certificate found with serial number `"$($SerialNumber)`"." + Write-Warning -Message ($LoggingDetails.LogMessage) + } + } + } + } + + {($_ -iin @('ByThumbprint'))} + { + $ExistingCertificateList = Get-ChildItem -Path @('Microsoft.PowerShell.Security\Certificate::\') -Recurse -Force -ErrorAction SilentlyContinue | Where-Object {($Null -ine $_.Thumbprint)} + + ForEach ($Thumbprint In $ThumbprintList) + { + Switch ($ExistingCertificateList.Thumbprint -icontains $Thumbprint) + { + {($_ -eq $True)} + { + $ExistingCertificates = $ExistingCertificateList | Where-Object {($_.Thumbprint -ieq $Thumbprint)} | Sort-Object -Property @('NotAfter') -Descending -Unique + + $ExistingCertificateCount = ($ExistingCertificates | Measure-Object).Count + + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Located $($ExistingCertificateCount) certificate(s) for thumbprint `"$($Thumbprint)`"." + Write-Verbose -Message ($LoggingDetails.LogMessage) + + Switch ($ExistingCertificateCount) + { + {($_ -eq 1)} + { + $X509Certificate2List.Add($ExistingCertificates) + } + + {($_ -gt 1)} + { + ForEach ($ExistingCertificate In $ExistingCertificates) + { + $X509Certificate2List.Add($ExistingCertificate) + } + } + } + } + + Default + { + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - No certificate found with thumbprint `"$($Thumbprint)`"." + Write-Warning -Message ($LoggingDetails.LogMessage) } } } @@ -255,6 +475,9 @@ Function ConvertFrom-CertificateToPEM $CertificateList | Sort-Object -Property @('NotAfter') -Descending -Unique | ForEach-Object {($X509Certificate2List.Add($_))} } } + + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Total certificates to process: $($X509Certificate2List.Count)" + Write-Verbose -Message ($LoggingDetails.LogMessage) } Catch { @@ -281,7 +504,14 @@ Function ConvertFrom-CertificateToPEM $X509Certificate2ListItem = $X509Certificate2List[$X509Certificate2ListIndex] $OutputObjectProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' - $OutputObjectProperties.SerialNumber = $X509Certificate2ListItem.SerialNumber + $OutputObjectProperties.SerialNumber = $X509Certificate2ListItem.SerialNumber + $OutputObjectProperties.Thumbprint = $X509Certificate2ListItem.Thumbprint + $OutputObjectProperties.Subject = $X509Certificate2ListItem.Subject + $OutputObjectProperties.Issuer = $X509Certificate2ListItem.Issuer + $OutputObjectProperties.NotBefore = $X509Certificate2ListItem.NotBefore + $OutputObjectProperties.NotAfter = $X509Certificate2ListItem.NotAfter + $OutputObjectProperties.KeyAlgorithm = $Null + $OutputObjectProperties.HasPrivateKey = $X509Certificate2ListItem.HasPrivateKey $OutputObjectProperties.Blobs = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' $OutputObjectProperties.Blobs.Certificate = $Null $OutputObjectProperties.Blobs.PublicKey = $Null @@ -345,8 +575,13 @@ Function ConvertFrom-CertificateToPEM $OutputObjectProperties.Blobs.Certificate = $StringBuilders.Certificate.ToString() + #Add to CA bundle collection for combined export + $CABundleCertificateBlobs.Add($OutputObjectProperties.Blobs.Certificate) + $X509Certificate2.OID = [System.Security.Cryptography.OID]::New($X509Certificate2.Object.GetKeyAlgorithm()) + $OutputObjectProperties.KeyAlgorithm = $X509Certificate2.OID.FriendlyName + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - A certificate key algorithm of `"$($X509Certificate2.OID.FriendlyName)`" [OID: $($X509Certificate2.OID.Value)] was detected." Write-Verbose -Message ($LoggingDetails.LogMessage) @@ -393,48 +628,74 @@ Function ConvertFrom-CertificateToPEM } } - Switch ($X509Certificate2.Object.HasPrivateKey) + #region Private Key Extraction (only if -IncludePrivateKey is specified) + Switch ($IncludePrivateKey.IsPresent) { {($_ -eq $True)} - { - Switch ($X509Certificate2.OID.FriendlyName) + { + Switch ($X509Certificate2.Object.HasPrivateKey) { - {($_ -iin @('RSA'))} + {($_ -eq $True)} { - $X509Certificate2.DSA = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($X509Certificate2.Object) - $X509Certificate2.CNG = [System.Security.Cryptography.RSACNG]($X509Certificate2.DSA) - } + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Attempting to extract private key (as requested by -IncludePrivateKey)..." + Write-Verbose -Message ($LoggingDetails.LogMessage) - {($_ -iin @('ECC'))} - { - $X509Certificate2.DSA = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDSAPrivateKey($X509Certificate2.Object) - $X509Certificate2.CNG = [System.Security.Cryptography.ECDSACNG]($X509Certificate2.DSA) + Switch ($X509Certificate2.OID.FriendlyName) + { + {($_ -iin @('RSA'))} + { + $X509Certificate2.DSA = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($X509Certificate2.Object) + $X509Certificate2.CNG = [System.Security.Cryptography.RSACNG]($X509Certificate2.DSA) + } + + {($_ -iin @('ECC'))} + { + $X509Certificate2.DSA = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDSAPrivateKey($X509Certificate2.Object) + $X509Certificate2.CNG = [System.Security.Cryptography.ECDSACNG]($X509Certificate2.DSA) + } + + Default + { + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - The certificate key algorithm of `"$($X509Certificate2.OID.FriendlyName)`" [OID: $($X509Certificate2.OID.Value)] is unsupported for private key extraction." + Write-Warning -Message ($LoggingDetails.LogMessage) + } + } + + Switch ($Null -ine $X509Certificate2.CNG) + { + {($_ -eq $True)} + { + $X509Certificate2.PrivateKey = $X509Certificate2.CNG.Key + + $X509Certificate2.PrivateKeyEncoded = [System.Convert]::ToBase64String(($X509Certificate2.PrivateKey.Export([System.Security.Cryptography.CngKeyBlobFormat]::PKCS8PrivateBlob)), [System.Base64FormattingOptions]::InsertLineBreaks) + + $Null = $StringBuilders.PrivateKey.Append('-----BEGIN PRIVATE KEY-----').AppendLine() + $Null = $StringBuilders.PrivateKey.Append($X509Certificate2.PrivateKeyEncoded).AppendLine() + $Null = $StringBuilders.PrivateKey.Append('-----END PRIVATE KEY-----') + + $OutputObjectProperties.Blobs.PrivateKey = $StringBuilders.PrivateKey.ToString() + + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Private key successfully extracted." + Write-Verbose -Message ($LoggingDetails.LogMessage) + } + } } Default { - $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - The certificate key algorithm of `"$($X509Certificate2.OID.FriendlyName)`" [OID: $($X509Certificate2.OID.Value)] is unsupported for private key extracted." + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - The certificate `"$($X509Certificate2ListItem.SerialNumber)`" [Thumbprint: $($X509Certificate2ListItem.Thumbprint)] does not contain a private key." Write-Warning -Message ($LoggingDetails.LogMessage) } } - - $X509Certificate2.PrivateKey = $X509Certificate2.CNG.Key - - $X509Certificate2.PrivateKeyEncoded = [System.Convert]::ToBase64String(($X509Certificate2.PrivateKey.Export([System.Security.Cryptography.CngKeyBlobFormat]::PKCS8PrivateBlob)), [System.Base64FormattingOptions]::InsertLineBreaks) - - $Null = $StringBuilders.PrivateKey.Append('-----BEGIN PRIVATE KEY-----').AppendLine() - $Null = $StringBuilders.PrivateKey.Append($X509Certificate2.PrivateKeyEncoded).AppendLine() - $Null = $StringBuilders.PrivateKey.Append('-----END PRIVATE KEY-----') - - $OutputObjectProperties.Blobs.PrivateKey = $StringBuilders.PrivateKey.ToString() } Default { - $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - The certificate `"$($X509Certificate2ListItem.SerialNumber)`" [Thumbprint: $($X509Certificate2ListItem.Thumbprint)] does not contain a private key. Please ensure that the private key is exportable." - Write-Warning -Message ($LoggingDetails.LogMessage) + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Skipping private key extraction (use -IncludePrivateKey to export private keys)." + Write-Verbose -Message ($LoggingDetails.LogMessage) } } + #endregion $WriteAllTextParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' $WriteAllTextParameters.Path = $Null @@ -514,7 +775,8 @@ Function ConvertFrom-CertificateToPEM } } - Switch (([String]::IsNullOrEmpty($OutputObjectProperties.Blobs.PrivateKey) -eq $False) -or ([String]::IsNullOrWhiteSpace($OutputObjectProperties.Blobs.PrivateKey) -eq $False)) + #Only export private key if -IncludePrivateKey was specified and blob was successfully extracted + Switch (($IncludePrivateKey.IsPresent) -and (([String]::IsNullOrEmpty($OutputObjectProperties.Blobs.PrivateKey) -eq $False) -or ([String]::IsNullOrWhiteSpace($OutputObjectProperties.Blobs.PrivateKey) -eq $False))) { {($_ -eq $True)} { @@ -531,15 +793,12 @@ Function ConvertFrom-CertificateToPEM {($_ -eq $True)} { $OutputObjectProperties.Exports.PrivateKey = $WriteAllTextParameters.Path + + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Private key exported successfully." + Write-Verbose -Message ($LoggingDetails.LogMessage) } } } - - Default - { - $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Unable to export the private key from certificate `"$($X509Certificate2ListItem.SerialNumber)`" [Thumbprint: $($X509Certificate2ListItem.Thumbprint)] to `"$($WriteAllTextParameters.Path)`"." - Write-Warning -Message ($LoggingDetails.LogMessage) - } } } } @@ -569,21 +828,74 @@ Function ConvertFrom-CertificateToPEM } End - { + { Try { + #region Create CA Bundle if requested + Switch (($Export.IsPresent) -and ($CreateCABundle.IsPresent) -and ($CABundleCertificateBlobs.Count -gt 0)) + { + {($_ -eq $True)} + { + $CABundlePath = "$($ExportRootDirectory.FullName)\$($CABundleFileName)" -As [System.IO.FileInfo] + + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Creating CA bundle with $($CABundleCertificateBlobs.Count) certificate(s)..." + Write-Verbose -Message ($LoggingDetails.LogMessage) + + #Ensure export directory exists + Switch ([System.IO.Directory]::Exists($ExportRootDirectory.FullName)) + { + {($_ -eq $False)} + { + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Creating directory `"$($ExportRootDirectory.FullName)`"..." + Write-Verbose -Message ($LoggingDetails.LogMessage) + + $Null = [System.IO.Directory]::CreateDirectory($ExportRootDirectory.FullName) + } + } + + #Combine all certificate blobs with line breaks between them + $CABundleContent = $CABundleCertificateBlobs -Join "`r`n`r`n" + + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Attempting to export CA bundle to `"$($CABundlePath.FullName)`"..." + Write-Verbose -Message ($LoggingDetails.LogMessage) + + $Null = [System.IO.File]::WriteAllText($CABundlePath.FullName, $CABundleContent, [System.Text.Encoding]::ASCII) + + Switch ($?) + { + {($_ -eq $True)} + { + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - CA bundle successfully exported to `"$($CABundlePath.FullName)`"." + Write-Verbose -Message ($LoggingDetails.LogMessage) + + #Add CA bundle info to output + $CABundleOutputProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' + $CABundleOutputProperties.Type = 'CABundle' + $CABundleOutputProperties.Path = $CABundlePath.FullName + $CABundleOutputProperties.CertificateCount = $CABundleCertificateBlobs.Count + $CABundleOutputProperties.Content = $CABundleContent + + $CABundleOutput = New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($CABundleOutputProperties) + + $OutputObjectList.Add($CABundleOutput) + } + } + } + } + #endregion + #Determine the date and time the function completed execution $FunctionEndTime = (Get-Date) $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Execution of $($FunctionName) ended on $($FunctionEndTime.ToString($DateTimeLogFormat))" Write-Verbose -Message ($LoggingDetails.LogMessage) -Verbose - #Log the total script execution time + #Log the total script execution time $FunctionExecutionTimespan = New-TimeSpan -Start ($FunctionStartTime) -End ($FunctionEndTime) $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Function execution took $($FunctionExecutionTimespan.Hours.ToString()) hour(s), $($FunctionExecutionTimespan.Minutes.ToString()) minute(s), $($FunctionExecutionTimespan.Seconds.ToString()) second(s), and $($FunctionExecutionTimespan.Milliseconds.ToString()) millisecond(s)" Write-Verbose -Message ($LoggingDetails.LogMessage) -Verbose - + $LoggingDetails.LogMessage = "$($GetCurrentDateTimeMessageFormat.Invoke()) - Function `'$($FunctionName)`' is completed." Write-Verbose -Message ($LoggingDetails.LogMessage) -Verbose }