mirror of
https://github.com/Grace-Solutions/Invoke-OpenSSHConfiguration.git
synced 2026-07-26 11:38:14 +00:00
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:
@@ -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`
|
||||
|
||||
+4
-1
@@ -51,4 +51,7 @@ CodeCoverage/
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Augment
|
||||
.augment/
|
||||
+547
-16
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
.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\<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')]
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user