Enhanced OpenSSH configuration with certificate auth, service management, and security features

Features added:
- Certificate authentication with Windows CA trust bridge
- CA filtering via regex inclusion/exclusion expressions
- Password authentication disable option
- Service auto-start and start management
- Config backup before modification (timestamped)
- Config validation (sshd -t) with automatic rollback on failure
- SFTP subsystem configured by default
- Public key management with deduplication
- Key pair generation (ed25519, rsa, ecdsa)

Toolkit enhancements:
- ConvertFrom-CertificateToPEM: Added -IncludePrivateKey, -ThumbprintList, -CreateCABundle
- Updated coding standards in PowershellScripts.md

Documentation:
- Comprehensive README with examples and troubleshooting
- Added .augment folder to .gitignore
This commit is contained in:
GraceSolutions
2026-05-01 15:17:43 -04:00
parent cd982938d3
commit 825dfdab79
5 changed files with 1337 additions and 84 deletions
+129 -1
View File
@@ -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<T>` 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`
+3
View File
@@ -52,3 +52,6 @@ CodeCoverage/
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Augment
.augment/
+542 -11
View File
@@ -46,13 +46,55 @@
(
[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()]
@@ -110,7 +152,7 @@ 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)}
{
}
@@ -230,10 +272,18 @@ 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)`"")
@@ -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
+280 -1
View File
@@ -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
@@ -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
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-----)
.PARAMETER ParameterName
Your parameter description
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
.PARAMETER ParameterName
Your parameter description
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.
.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.
[String]$RootCAFriendlyName = 'Self Signed Root CA Certificate'
$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'
$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,19 +142,92 @@ 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\<SerialNumber>\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')]
@@ -90,6 +238,11 @@ Function ConvertFrom-CertificateToPEM
[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,
@@ -102,6 +255,16 @@ Function ConvertFrom-CertificateToPEM
[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,10 +370,18 @@ 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'))}
@@ -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)
@@ -246,6 +417,55 @@ Function ConvertFrom-CertificateToPEM
}
}
}
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
{
@@ -282,6 +505,13 @@ Function ConvertFrom-CertificateToPEM
$OutputObjectProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$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)
}
}
}
}
@@ -572,6 +831,59 @@ Function ConvertFrom-CertificateToPEM
{
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)