Public/New-NSPSslRequest.ps1

function New-NSPSslRequest {
    <#
    .SYNOPSIS
        Generates a private key and a certificate signing request (CSR) for a public SSL certificate.
    .DESCRIPTION
        Creates a request folder <Path>\<domain> with:

            Inputs\ put the issuer's ZIP here when the certificate comes back
            Deliverables\<domain>.csr the CSR to paste into the issuer's order page
            Deliverables\<domain>_priv.key the private key (unencrypted PKCS#8 PEM)
            request.json the names and subject used

        The domain is the certificate's common name and first subject alternative name (SAN); add
        more names with -SubjectAlternativeName, and www.<domain> with -IncludeWww. Five names at
        most, the domain included, unless -NoNameLimit. Wildcards
        (*.contoso.com) work; their folder is named wildcard.contoso.com.

        A request folder that already has a key is never overwritten: the CSR may already be with
        the issuer. -Force starts over with a new key and moves the old Deliverables folder to
        Archive\<timestamp>.

        When the certificate is issued, download it in IIS format and run Complete-NSPSslRequest.

        Replaces 1_Gen_NewPrivKey_And_CSR.sh from NSP-SSLCertificates (no WSL or openssl needed).
    .PARAMETER Domain
        The certificate's main name (common name), for example vpn.contoso.com.
    .PARAMETER SubjectAlternativeName
        More names for the certificate. The domain is always included, and counts toward the
        five-name limit.
    .PARAMETER IncludeWww
        Also add www.<domain>, when one of the five names is still free (a warning says when it isn't).
    .PARAMETER NoNameLimit
        Allow more than five names, for a certificate product that covers more.
    .PARAMETER Organization
        Subject O. Domain-validated certificates ignore it; for organization-validated ones use the
        client's legal name.
    .PARAMETER City
        Subject L.
    .PARAMETER State
        Subject ST.
    .PARAMETER Country
        Subject C, two letters.
    .PARAMETER KeySize
        RSA key size: 2048 (default), 3072 or 4096.
    .PARAMETER Path
        The folder that holds request folders. Default: $env:NSP_SSL_PATH, else
        Documents\NSP-SSLCertificates.
    .PARAMETER CopyToClipboard
        Copy the CSR text to the clipboard.
    .PARAMETER Force
        Replace an existing request's key (the old files are archived, not deleted).
    .EXAMPLE
        New-NSPSslRequest -Domain vpn.contoso.com -CopyToClipboard
    .EXAMPLE
        New-NSPSslRequest -Domain contoso.com -IncludeWww -SubjectAlternativeName mail.contoso.com -Organization 'Contoso, Inc.' -City Omaha -State NE
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Domain,
        [string[]]$SubjectAlternativeName = @(),
        [switch]$IncludeWww,
        [switch]$NoNameLimit,
        [string]$Organization = 'Network Systems Plus, Inc.',
        [string]$City = 'Sioux City',
        [string]$State = 'IA',
        [ValidatePattern('^[A-Za-z]{2}$')][string]$Country = 'US',
        [ValidateSet(2048, 3072, 4096)][int]$KeySize = 2048,
        [string]$Path,
        [switch]$CopyToClipboard,
        [switch]$Force
    )

    $Domain = $Domain.Trim().ToLowerInvariant()
    $names = [Collections.Generic.List[string]]::new()
    $candidates = @($Domain) + @($SubjectAlternativeName | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ })
    foreach ($name in $candidates) {
        if (-not (Test-NSPSslDnsName -Name $name)) { throw "'$name' is not a valid certificate name (expected something like vpn.contoso.com)." }
        if (-not $names.Contains($name)) { $names.Add($name) }
    }
    $limit = 5
    if (-not $NoNameLimit -and $names.Count -gt $limit) {
        throw "$($names.Count) names requested; the certificate products we buy cover $limit (the domain included). Drop some, or use -NoNameLimit for a product that covers more."
    }
    # www.<domain> takes a spare slot only, as the WSL scripts did.
    $www = "www.$Domain"
    if ($IncludeWww -and $Domain -notmatch '^(\*|www)\.' -and -not $names.Contains($www)) {
        if ($NoNameLimit -or $names.Count -lt $limit) { $names.Add($www) }
        else { Write-Warning "$www was not added: all $limit names are in use." }
    }

    $root = Get-NSPSslWorkRoot -Path $Path
    $folderName = ConvertTo-NSPSslFolderName -Domain $Domain
    $folder = Join-Path $root $folderName
    $layout = Get-NSPSslRequestLayout -Folder $folder
    if (Test-Path -LiteralPath $layout.KeyPath) {
        if (-not $Force) {
            throw "$Domain already has a key ($($layout.KeyPath)). Its CSR may already be with the issuer: finish it with Complete-NSPSslRequest, or use -Force to start over with a new key."
        }
    }
    if (-not $PSCmdlet.ShouldProcess($folder, "Create a $KeySize-bit key and CSR for $($names -join ', ')")) { return }

    if (Test-Path -LiteralPath $layout.KeyPath) {
        $archive = Join-Path $folder ("Archive\{0:yyyyMMdd-HHmmss}" -f (Get-Date))
        New-Item -ItemType Directory -Path $archive -Force | Out-Null
        Move-Item -LiteralPath $layout.Deliverables -Destination $archive
        Write-Warning "The previous key and deliverables for $Domain were moved to $archive."
    }
    $deliverables = Join-Path $folder 'Deliverables'
    foreach ($directory in $layout.Inputs, $deliverables) { New-Item -ItemType Directory -Path $directory -Force | Out-Null }
    $layout = Get-NSPSslRequestLayout -Folder $folder

    # Subject in the order openssl's config wrote it (C, ST, L, O, CN). .NET lists names most
    # specific first and encodes them in reverse; values with commas ("Plus, Inc.") are quoted.
    $parts = foreach ($pair in @(@('CN', $Domain), @('O', $Organization), @('L', $City), @('S', $State), @('C', $Country.ToUpperInvariant()))) {
        $value = $pair[1]
        if (-not $value) { continue }
        if ($value -match '[,+=";<>#\\]|^\s|\s$') { $value = '"{0}"' -f ($value -replace '"', '""') }
        '{0}={1}' -f $pair[0], $value
    }
    $subject = New-Object System.Security.Cryptography.X509Certificates.X500DistinguishedName ($parts -join ', ')

    $rsa = New-NSPSslRsa -KeySize $KeySize
    try {
        $request = New-Object System.Security.Cryptography.X509Certificates.CertificateRequest($subject, $rsa, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
        $san = New-Object System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder
        foreach ($name in $names) { $san.AddDnsName($name) }
        $request.CertificateExtensions.Add($san.Build())
        $csr = ConvertTo-NSPSslPem -Label 'CERTIFICATE REQUEST' -Bytes $request.CreateSigningRequest()
        $key = ConvertTo-NSPSslPrivateKeyPem -Parameters $rsa.ExportParameters($true)
    } finally {
        $rsa.Dispose()
    }

    [IO.File]::WriteAllText($layout.KeyPath, $key)
    [IO.File]::WriteAllText($layout.CsrPath, $csr)
    $requestInfo = [ordered]@{
        Domain  = $Domain
        Names   = @($names)
        Subject = $subject.Name
        KeySize = $KeySize
        Created = (Get-Date).ToString('s')
    }
    [IO.File]::WriteAllText((Join-Path $folder 'request.json'), ($requestInfo | ConvertTo-Json))

    if ($CopyToClipboard) { Set-Clipboard -Value $csr }
    Write-Information "CSR for $Domain written to $($layout.CsrPath). When the certificate is issued, download it in IIS format, put the ZIP in $($layout.Inputs), and run: Complete-NSPSslRequest -Domain $Domain"

    [pscustomobject]@{
        Domain  = $Domain
        Names   = @($names)
        Subject = $subject.Name
        Folder  = $folder
        Inputs  = $layout.Inputs
        CsrPath = $layout.CsrPath
        KeyPath = $layout.KeyPath
        Csr     = $csr
    }
}