From 02d4929dbe0b9d9b1a1a1fda7bc4f5d6c11974c9 Mon Sep 17 00:00:00 2001 From: Justin Johns Date: Fri, 8 Mar 2024 09:11:39 -0800 Subject: [PATCH] Add support for more subject alternative names (SAN) (#14) * Add support for more subject alternative names (SAN) * update comments fix shouldprocess fix SAN validation fix template creation defect update verbose output * fixed first SAN * renamed function to "New-CertificateSigningRequest" added alias "New-CSR" to preserve backwards compatibility * updated comments --- PS.SSL.psd1 | 10 +- PS.SSL.psm1 | 8 +- Public/New-CSR.ps1 | 193 --------------------- Public/New-CertificateSigningRequest.ps1 | 204 +++++++++++++++++++++++ examples/GenerateCSR.ps1 | 21 ++- 5 files changed, 223 insertions(+), 213 deletions(-) delete mode 100644 Public/New-CSR.ps1 create mode 100644 Public/New-CertificateSigningRequest.ps1 diff --git a/PS.SSL.psd1 b/PS.SSL.psd1 index 52487c8..6b3fa50 100644 --- a/PS.SSL.psd1 +++ b/PS.SSL.psd1 @@ -9,7 +9,7 @@ RootModule = 'PS.SSL.psm1' # Version number of this module. - ModuleVersion = '0.2.1' + ModuleVersion = '0.2.2' # Supported PSEditions # CompatiblePSEditions = @() @@ -85,10 +85,14 @@ CmdletsToExport = @() # Variables to export from this module - VariablesToExport = @() + VariablesToExport = @( + 'CSR_Template' + ) # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. - AliasesToExport = @() + AliasesToExport = @( + 'New-CSR' + ) # DSC resources to export from this module # DscResourcesToExport = @() diff --git a/PS.SSL.psm1 b/PS.SSL.psm1 index f6a8fcf..3c855f7 100644 --- a/PS.SSL.psm1 +++ b/PS.SSL.psm1 @@ -1,6 +1,6 @@ # ============================================================================== # Filename: PS.SSL.psm1 -# Version: 0.1.4 | Updated: 2022-10-21 +# Updated: 2024-03-08 # Author: Justin Johns # ============================================================================== @@ -50,13 +50,9 @@ New-Variable -Name 'CSR_Template' -Option ReadOnly -Value @( 'extendedKeyUsage = serverAuth' 'subjectAltName = @alt_names' '[alt_names]' - 'DNS.1 = #CN#' - 'DNS.2 = #SAN1#' - 'DNS.3 = #SAN2#' - 'DNS.4 = #SAN3#' ) # EXPORT MEMBERS # THESE ARE SPECIFIED IN THE MODULE MANIFEST AND THEREFORE DON'T NEED TO BE LISTED HERE #Export-ModuleMember -Function * -Export-ModuleMember -Variable 'CSR_Template' \ No newline at end of file +Export-ModuleMember -Variable 'CSR_Template' -Alias 'New-CSR' \ No newline at end of file diff --git a/Public/New-CSR.ps1 b/Public/New-CSR.ps1 deleted file mode 100644 index f22abc1..0000000 --- a/Public/New-CSR.ps1 +++ /dev/null @@ -1,193 +0,0 @@ -function New-CSR { - <# - .SYNOPSIS - Generate new CSR and Private key file - .DESCRIPTION - Generate new CSR and Private key file - .PARAMETER OutputDirectory - Output directory for CSR and key file - .PARAMETER Days - Validity period in days (default is 365) - .PARAMETER ConfigFile - Path to configuration template file - .PARAMETER CommonName - Common Name (CN) - .PARAMETER Country - Country Name (C) - .PARAMETER State - State or Province Name (ST) - .PARAMETER Locality - Locality Name (L) - .PARAMETER Organization - Organization Name (O) - .PARAMETER OrganizationalUnit - Organizational Unit Name (OU) - .PARAMETER Email - Email Address - .PARAMETER SAN1 - Subject Alternative Name (SAN) 1 - .PARAMETER SAN2 - Subject Alternative Name (SAN) 2 - .PARAMETER SAN3 - Subject Alternative Name (SAN) 3 - .INPUTS - None. - .OUTPUTS - System.Object. - .EXAMPLE - PS C:\> New-CSR -CommonName www.myDomain.com - Creates a new CSR and private key for www.myDomain.com - .NOTES - Name: New-CSR - Author: Justin Johns - Version: 0.1.1 | Last Edit: 2022-06-20 - - 0.1.0 - Initial versions - - 0.1.1 - Added SupportsShouldProcess - General notes - Example commands - openssl req -newkey rsa:2048 -sha256 -keyout PRIVATEKEY.key -out MYCSR.csr -subj "/C=US/ST=CA/L=Redlands/O=Esri/CN=myDomain.com" - openssl req -new -newkey rsa:2048 -nodes -sha256 -out company_san.csr -keyout company_san.key -config req.conf - #> - [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = '__conf')] - Param( - [Parameter(HelpMessage = 'Output directory for CSR and key file')] - [ValidateScript({ Test-Path -Path (Split-Path -Path $_) -PathType Container })] - [System.String] $OutputDirectory = "$HOME\Desktop", - - [Parameter(HelpMessage = 'Validity period in days (default is 365)')] - [ValidateRange(30, 3650)] - [System.String] $Days = 365, - - [Parameter(Mandatory, ParameterSetName = '__conf', HelpMessage = 'Path to configuration template')] - [ValidateScript({ Test-Path -Path $_ -PathType Leaf -Include '*.conf' })] - [System.String] $ConfigFile, - - [Parameter(Mandatory, ParameterSetName = '__input', HelpMessage = 'Common Name (CN)')] - [Alias('CN')] - [ValidatePattern('^[\w\.-]+\.(com|org|gov)$')] - [string] $CommonName, - - [Parameter(ParameterSetName = '__input', HelpMessage = 'Country Name (C)')] - [Alias('C')] - [ValidatePattern('^[A-Z]{2}$')] - [System.String] $Country, - - [Parameter(ParameterSetName = '__input', HelpMessage = 'State or Province Name (ST)')] - [Alias('ST')] - [ValidatePattern('^[\w\s-]+$')] - [System.String] $State, - - [Parameter(ParameterSetName = '__input', HelpMessage = 'Locality Name (L)')] - [Alias('L')] - [ValidatePattern('^[\w\s-]+$')] - [System.String] $Locality, - - [Parameter(ParameterSetName = '__input', HelpMessage = 'Organization Name (O)')] - [Alias('O')] - [ValidatePattern('^[\w\.\s-]+$')] - [System.String] $Organization, - - [Parameter(ParameterSetName = '__input', HelpMessage = 'Organizational Unit Name (OU)')] - [Alias('OU')] - [ValidatePattern('^[\w\.\s-]+$')] - [System.String] $OrganizationalUnit, - - [Parameter(ParameterSetName = '__input', HelpMessage = 'Email Address')] - [ValidatePattern('^[\w\.@-]+$')] - [System.String] $Email, - - [Parameter(ParameterSetName = '__input', HelpMessage = 'Subject Alternative Name (SAN) 1')] - [ValidatePattern('^[\w\.-]+\.(com|org|gov)$')] - [System.String] $SAN1, - - [Parameter(ParameterSetName = '__input', HelpMessage = 'Subject Alternative Name (SAN) 2')] - [ValidatePattern('^[\w\.-]+\.(com|org|gov)$')] - [System.String] $SAN2, - - [Parameter(ParameterSetName = '__input', HelpMessage = 'Subject Alternative Name (SAN) 3')] - [ValidatePattern('^[\w\.-]+\.(com|org|gov)$')] - [System.String] $SAN3 - ) - Begin { - Write-Verbose -Message "Starting $($MyInvocation.Mycommand)" - Write-Verbose -Message ('Parameter Set: {0}' -f $PSCmdlet.ParameterSetName) - - # SHOULD PROCESS - if ($PSCmdlet.ShouldProcess($OutputDirectory, "Create Files")) { - - # GET OUTPUT DIRECTORY - if (-not (Test-Path -Path $OutputDirectory)) { - Write-Verbose -Message ('Creating new folder named: {0}' -f (Split-Path -Path $OutputDirectory -Leaf)) - New-Item -Path $OutputDirectory -ItemType Directory | Out-Null - } - - # BUILD CSR BASED ON PARAMETER INPUT - if ($PSCmdlet.ParameterSetName -eq '__input') { - # GET TEMPLATE - $template = [System.Collections.ArrayList]::new($CSR_Template) - - # SET REPLACEMENT TOKENS - $tokenList = @{ CN = $CommonName } - if ($PSBoundParameters.ContainsKey('Country')) { $tokenList.Add('C', $Country) } else { $template.Remove('C = #C#') } - if ($PSBoundParameters.ContainsKey('State')) { $tokenList.Add('ST', $State) } else { $template.Remove('ST = #ST#') } - if ($PSBoundParameters.ContainsKey('Locality')) { $tokenList.Add('L', $Locality) } else { $template.Remove('L = #L#') } - if ($PSBoundParameters.ContainsKey('Organization')) { $tokenList.Add('O', $Organization) } else { $template.Remove('O = #O#') } - if ($PSBoundParameters.ContainsKey('OrganizationalUnit')) { $tokenList.Add('OU', $OrganizationalUnit) } else { $template.Remove('OU = #OU#') } - if ($PSBoundParameters.ContainsKey('Email')) { $tokenList.Add('E', $Email) } else { $template.Remove('emailAddress = "#E#"') } - if ($PSBoundParameters.ContainsKey('SAN1')) { $tokenList.Add('SAN1', $SAN1) } else { $template.Remove('DNS.2 = #SAN1#') } - if ($PSBoundParameters.ContainsKey('SAN2')) { $tokenList.Add('SAN2', $SAN2) } else { $template.Remove('DNS.3 = #SAN2#') } - if ($PSBoundParameters.ContainsKey('SAN3')) { $tokenList.Add('SAN3', $SAN3) } else { $template.Remove('DNS.4 = #SAN3#') } - - # REPLACE TOKENS - foreach ( $token in $tokenList.GetEnumerator() ) { - $pattern = '#{0}#' -f $token.key - $template = $template -replace $pattern, $token.Value - } - - # SHOW TEMPLATE - Write-Verbose -Message ($template -join "`n") - - # SET TEMPLATE FILE WITH NEW VALUES - $random = [System.IO.Path]::GetRandomFileName().Split('.')[0] - $configPath = Join-Path -Path $OutputDirectory -ChildPath ('csr_template_{0}.conf' -f $random) - - # CREATE TEMPLATE FILE - Set-Content -Path $configPath -Value $template -Confirm:$false - } - else { - $configPath = $ConfigFile - } - - # SET FILE NAME - $selectPattern = Get-Content -Path $configPath | Select-String -Pattern '^CN = (.+)$' - $fileName = $selectPattern.Matches.Groups[1].Value - - # THE CHARACTER "*" IS NOT VALID IN A WINDOWS FILENAME. REPLACE "*" WITH "STAR" - if ($fileName -match '\*') { $fileName = $fileName.Replace('*', 'star') } - Write-Verbose -Message ('New file name: {0}' -f $fileName) - - # SET OPENSSL PARAMETERS - # openssl req -new -newkey rsa:2048 -nodes -sha256 -out company_san.csr -keyout company_san.key -config req.conf - # USING THE "-legacy" PARAMETER WILL MAINTAIN COMPATABILITY WITH CERTAIN SERVERS THAT DO NOT YET SUPPORT - # THE LATEST CIPHERS OR PROTOCOLS - # EXAMPLE> openssl pkcs12 -export -legacy -out example.pfx -inkey example.key -in example.crt - $sslParams = @{ - FilePath = 'openssl' # .exe - ArgumentList = @( - 'req -new -nodes -days {0}' -f $Days - '-config {0}' -f $configPath - '-keyout {0}' -f (Join-Path -Path $OutputDirectory -ChildPath ('{0}_PRIVATE.key' -f $fileName)) - '-out {0}' -f (Join-Path -Path $OutputDirectory -ChildPath ('{0}.csr' -f $fileName)) - ) - Wait = $true - NoNewWindow = $true - PassThru = $true - } - $proc = Start-Process @sslParams - - if ($proc.ExitCode -NE 0) { - Write-Error -Message ('openssl failed with exit code: {0}' -f $proc.ExitCode) - } - } - } -} \ No newline at end of file diff --git a/Public/New-CertificateSigningRequest.ps1 b/Public/New-CertificateSigningRequest.ps1 new file mode 100644 index 0000000..8e05a5d --- /dev/null +++ b/Public/New-CertificateSigningRequest.ps1 @@ -0,0 +1,204 @@ +function New-CertificateSigningRequest { + <# + .SYNOPSIS + Generate new CSR and Private key file + .DESCRIPTION + Generate new CSR and Private key file + .PARAMETER OutputDirectory + Output directory for CSR and key file + .PARAMETER Days + Validity period in days (default is 365) + .PARAMETER ConfigFile + Path to configuration template file + .PARAMETER CommonName + Common Name (CN) + .PARAMETER Country + Country Name (C) + .PARAMETER State + State or Province Name (ST) + .PARAMETER Locality + Locality Name (L) + .PARAMETER Organization + Organization Name (O) + .PARAMETER OrganizationalUnit + Organizational Unit Name (OU) + .PARAMETER Email + Email Address + .PARAMETER SubjectAlternativeName + Subject Alternative Name (SAN) + .INPUTS + None. + .OUTPUTS + System.Object. + .EXAMPLE + PS C:\> New-CertificateSigningRequest -CommonName www.myDomain.com + Creates a new CSR and private key for www.myDomain.com + .NOTES + Name: New-CertificateSigningRequest + Author: Justin Johns + Version: 0.2.0 | Last Edit: 2024-03-08 + - 0.2.0 - (2024-03-08) Fixed SupportsShouldProcess, updated SAN input, renamed function + - 0.1.1 - (2022-06-20) Added SupportsShouldProcess + - 0.1.0 - Initial versions + General notes + Example commands + openssl req -newkey rsa:2048 -sha256 -keyout PRIVATEKEY.key -out MYCSR.csr -subj "/C=US/ST=CA/L=Redlands/O=Esri/CN=myDomain.com" + openssl req -new -newkey rsa:2048 -nodes -sha256 -out company_san.csr -keyout company_san.key -config req.conf + #> + [Alias('New-CSR')] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = '__conf')] + Param( + [Parameter(HelpMessage = 'Output directory for CSR and key file')] + [ValidateScript({ Test-Path -Path (Split-Path -Path $_) -PathType Container })] + [System.String] $OutputDirectory = "$HOME\Desktop", + + [Parameter(HelpMessage = 'Validity period in days (default is 365)')] + [ValidateRange(30, 3650)] + [System.String] $Days = 365, + + [Parameter(Mandatory, ParameterSetName = '__conf', HelpMessage = 'Path to configuration template')] + [ValidateScript({ Test-Path -Path $_ -PathType Leaf -Include '*.conf' })] + [System.String] $ConfigFile, + + [Parameter(Mandatory, ParameterSetName = '__input', HelpMessage = 'Common Name (CN)')] + [Alias('CN')] + [ValidatePattern('^[\w\.-]+\.(com|org|gov)$')] + [string] $CommonName, + + [Parameter(ParameterSetName = '__input', HelpMessage = 'Country Name (C)')] + [Alias('C')] + [ValidatePattern('^[A-Z]{2}$')] + [System.String] $Country, + + [Parameter(ParameterSetName = '__input', HelpMessage = 'State or Province Name (ST)')] + [Alias('ST')] + [ValidatePattern('^[\w\s-]+$')] + [System.String] $State, + + [Parameter(ParameterSetName = '__input', HelpMessage = 'Locality Name (L)')] + [Alias('L')] + [ValidatePattern('^[\w\s-]+$')] + [System.String] $Locality, + + [Parameter(ParameterSetName = '__input', HelpMessage = 'Organization Name (O)')] + [Alias('O')] + [ValidatePattern('^[\w\.\s-]+$')] + [System.String] $Organization, + + [Parameter(ParameterSetName = '__input', HelpMessage = 'Organizational Unit Name (OU)')] + [Alias('OU')] + [ValidatePattern('^[\w\.\s-]+$')] + [System.String] $OrganizationalUnit, + + [Parameter(ParameterSetName = '__input', HelpMessage = 'Email Address')] + [ValidatePattern('^[\w\.@-]+$')] + [System.String] $Email, + + [Parameter(ParameterSetName = '__input', HelpMessage = 'Subject Alternative Name (SAN)')] + [Alias('SAN')] + [ValidatePattern('^[\w\.-]+\.(com|org|gov|info)$')] + [System.String[]] $SubjectAlternativeName + ) + Begin { + Write-Verbose -Message "Starting $($MyInvocation.Mycommand)" + Write-Verbose -Message ('Parameter Set: {0}' -f $PSCmdlet.ParameterSetName) + + # GET OUTPUT DIRECTORY + if (-not (Test-Path -Path $OutputDirectory)) { + Write-Verbose -Message ('Creating new folder named: {0}' -f (Split-Path -Path $OutputDirectory -Leaf)) + New-Item -Path $OutputDirectory -ItemType Directory | Out-Null + } + + # BUILD CSR BASED ON PARAMETER INPUT + if ($PSCmdlet.ParameterSetName -eq '__input') { + # CREATE NEW LIST + $template = [System.Collections.ArrayList]::new() + + # ADD TEMPLATE TO LIST + $template.AddRange($CSR_Template) + + # ADD WWW TO COMMON NAME AND ADD TO LIST + if ($CommonName -notmatch '^www') { $template.Add(('DNS.1 = www.{0}' -f $CommonName)) | Out-Null; $start = 2 } + else { $start = 1 } + + # ADD SUBJECT ALTERNATIVE NAMES TO LIST + if ($PSBoundParameters.ContainsKey('SubjectAlternativeName')) { + # EVALUATE EACH SAN IN ARRAY + for ($i = $start; $i -lt ($SubjectAlternativeName.Count + $start); $i++) { + # ADD SAN TO END OF COLLECTION + $template.Add(('DNS.{0} = {1}' -f $i, $SubjectAlternativeName[$i - $start])) | Out-Null + } + } + + # SET REPLACEMENT TOKENS + $tokenList = @{ CN = $CommonName } + if ($PSBoundParameters.ContainsKey('Country')) { $tokenList.Add('C', $Country) } else { $template.Remove('C = #C#') } + if ($PSBoundParameters.ContainsKey('State')) { $tokenList.Add('ST', $State) } else { $template.Remove('ST = #ST#') } + if ($PSBoundParameters.ContainsKey('Locality')) { $tokenList.Add('L', $Locality) } else { $template.Remove('L = #L#') } + if ($PSBoundParameters.ContainsKey('Organization')) { $tokenList.Add('O', $Organization) } else { $template.Remove('O = #O#') } + if ($PSBoundParameters.ContainsKey('OrganizationalUnit')) { $tokenList.Add('OU', $OrganizationalUnit) } else { $template.Remove('OU = #OU#') } + if ($PSBoundParameters.ContainsKey('Email')) { $tokenList.Add('E', $Email) } else { $template.Remove('emailAddress = "#E#"') } + + # REPLACE TOKENS IN TEMPLATE + foreach ($token in $tokenList.GetEnumerator()) { + $pattern = '#{0}#' -f $token.key + $template = $template -replace $pattern, $token.Value + } + + # SHOW TEMPLATE + Write-Verbose -Message ("`n" + ($template -join "`n")) + + # SET TEMPLATE FILE WITH NEW VALUES + $random = [System.IO.Path]::GetRandomFileName().Split('.')[0] + $configPath = Join-Path -Path $OutputDirectory -ChildPath ('csr_template_{0}.conf' -f $random) + + # CREATE TEMPLATE FILE + Set-Content -Path $configPath -Value $template -Confirm:$false + + # OUTPUT TEMPLATE PATH + Write-Verbose -Message ('Template file path: [{0}]' -f $configPath) + } + else { + $configPath = $ConfigFile + } + + # SET FILE NAME + $selectPattern = Get-Content -Path $configPath | Select-String -Pattern '^CN = (.+)$' + $fileName = $selectPattern.Matches.Groups[1].Value + + # THE CHARACTER "*" IS NOT VALID IN A WINDOWS FILENAME. REPLACE "*" WITH "STAR" + if ($fileName -match '\*') { $fileName = $fileName.Replace('*', 'star') } + Write-Verbose -Message ('New file name: {0}' -f $fileName) + + # SET OPENSSL PARAMETERS + # openssl req -new -newkey rsa:2048 -nodes -sha256 -out company_san.csr -keyout company_san.key -config req.conf + # USING THE "-legacy" PARAMETER WILL MAINTAIN COMPATABILITY WITH CERTAIN SERVERS THAT DO NOT YET SUPPORT + # THE LATEST CIPHERS OR PROTOCOLS + # EXAMPLE> openssl pkcs12 -export -legacy -out example.pfx -inkey example.key -in example.crt + $sslParams = @{ + FilePath = 'openssl' # .exe + ArgumentList = @( + 'req -new -nodes -days {0}' -f $Days + '-config {0}' -f $configPath + '-keyout {0}' -f (Join-Path -Path $OutputDirectory -ChildPath ('{0}_PRIVATE.key' -f $fileName)) + '-out {0}' -f (Join-Path -Path $OutputDirectory -ChildPath ('{0}.csr' -f $fileName)) + ) + Wait = $true + NoNewWindow = $true + PassThru = $true + } + + # SHOULD PROCESS + if ($PSCmdlet.ShouldProcess($OutputDirectory, "Create Files")) { + + # INVOKE OPENSSL + $proc = Start-Process @sslParams + + # CHECK FOR ERRORS + if ($proc.ExitCode -NE 0) { + # OUTPUT ERROR + Write-Error -Message ('openssl failed with exit code: {0}' -f $proc.ExitCode) + } + } + } +} \ No newline at end of file diff --git a/examples/GenerateCSR.ps1 b/examples/GenerateCSR.ps1 index 21d19d9..aa46123 100644 --- a/examples/GenerateCSR.ps1 +++ b/examples/GenerateCSR.ps1 @@ -1,6 +1,6 @@ # ============================================================================== # Filename: GenerateCSR.ps1 -# Version: 0.0.6 | Updated: 2023-10-05 +# Version: 0.0.7 | Updated: 2024-03-08 # Author: Justin Johns # ============================================================================== @@ -11,6 +11,7 @@ Create, validate, and complete the CSR process .NOTES Version History: + - 0.0.7 - Update SANs - 0.0.6 - Added self-signed cert example - 0.0.5 - (2021-12-18) Previous version - 0.0.1 - Initial version @@ -23,16 +24,14 @@ Import-Module -Name 'PS.SSL' #region NEW CSR FROM INPUTS ==================================================== # USE THE FOLLOWING TO CREATE THE CSR $csrParams = @{ - OutputDirectory = "$HOME\Desktop\test\CSR" - #Country = 'US' - #State = 'California' - #Locality = 'Redlands' - #Organization = 'Esri' - #OU = 'PS' - CommonName = 'www.company.com' - SAN1 = 'company.com' - SAN2 = 'www.company.org' - #SAN3 = 'company.org' + OutputDirectory = "$HOME\Desktop\test\CSR" + Country = 'US' + State = 'California' + Locality = 'Redlands' + Organization = 'Esri' + OU = 'PS' + CommonName = 'company.com' + SubjectAlternativeName = 'new.company.com', 'www.company.org', 'company.org', 'company.info', 'www.company.info' } New-CSR @csrParams