Private/Engine/Request-VPNCertCore.ps1

#region Standalone
<#
.SYNOPSIS
    Shared engine for VPN user-certificate requests. Pure functions, no top-level side effects -
    safe to dot-source.
 
.DESCRIPTION
    Dot-sourced by:
      - IPSEC AIO\MiscTools\Tools\Request-VPNCert.ps1 (interactive, one cert at a time)
      - PushableTools\CAManager\Modules\CATestSuite.ps1 (batch - a whole valid+revoked test
        suite; the CAManager.zip build copies THIS file in next to CA-Manager.ps1)
 
    Both entry points share the AD-user search, the Get-Certificate request/retrieve/export path,
    and (test-suite only) the certutil revoke + CRL republish - so the request shape (DnsName SAN =
    mail + DN, SubjectName = "E=<mail>, <DN>") can never drift between the two.
 
.NOTES
    Get-Certificate uses the machine's normal AD CS enrollment discovery - no CA URL needed unless
    -Url is supplied. If the template requires manager approval the request is left PENDING;
    Complete-VPNCertRequest must be called AFTER an admin issues it on the CA.
#>


function Find-VPNCertADUser {
    <#
    .SYNOPSIS
        Wildcard-searches AD for the target user and returns one AD user object
        (SamAccountName / UserPrincipalName / mail / DistinguishedName).
 
    .PARAMETER SearchTerm
        Partial SamAccountName to seed the search. If omitted (and -NonInteractive is not set), the
        user is prompted for it.
 
    .PARAMETER NonInteractive
        Batch mode: the search MUST resolve to exactly one user or this throws. No prompts.
 
    .PARAMETER ReturnNullOnNoMatch
        2026-09-15, per the maintainer (CA-Manager menu 11): when -SearchTerm was supplied by a caller that
        has its own retry/skip loop (New-CATestCertSuite's per-template username prompt), a 0-match
        search should just return $null so THAT caller's own "blank to skip this template" prompt
        gets control back - not loop forever inside here re-prompting with this function's own
        generic message. Only changes the 0-MATCH path; a 2+ match disambiguation picker still
        prompts here either way. Default ($false, the original behavior) still applies to
        Request-VPNCert.ps1's standalone interactive use, where re-prompting in place is correct.
    #>

    param(
        [string]$SearchTerm,
        [switch]$NonInteractive,
        [switch]$ReturnNullOnNoMatch
    )

    # Column table for the "more than one match" picker.
    function Show-VPNCertUserTable {
        param($UserList)
        $props = @('UserPrincipalName', 'SamAccountName')
        $widths = @{}
        foreach ($p in $props) {
            $widths[$p] = (($UserList | ForEach-Object { "$($_.$p)".Length }) | Sort-Object -Descending | Select-Object -First 1)
            if (-not $widths[$p] -or $widths[$p] -lt $p.Length) { $widths[$p] = $p.Length }
        }
        $fmt = "{0,-6}" + (($props | ForEach-Object { "{$([array]::IndexOf($props, $_) + 1),-$($widths[$_] + 3)}" }) -join '')
        # NOTE 2026-09-15, real bug hit live at a client: '-f' binds TIGHTER than '+' in PowerShell, so
        # "$fmt -f @('idx') + $props" was actually "($fmt -f @('idx')) + $props" - -f ran with only ONE
        # value against a 3-placeholder format string ("Index...must be...less than the size of the
        # argument list"). The array concatenation must be grouped BEFORE -f sees it.
        Write-Host ($fmt -f (@('idx') + $props))
        for ($i = 0; $i -lt $UserList.Count; $i++) {
            $vals = @("$i`:") + ($props | ForEach-Object { "$($UserList[$i].$_)" })
            Write-Host ($fmt -f $vals)
        }
    }

    $term = $SearchTerm
    while ($true) {
        if ([string]::IsNullOrWhiteSpace($term)) {
            if ($NonInteractive) { throw "Find-VPNCertADUser: -SearchTerm is required in -NonInteractive mode." }
            $term = Read-Host "AD username to issue a certificate for (partial is fine - wildcard search)"
            if ([string]::IsNullOrWhiteSpace($term)) { continue }
        }

        # Server-side wildcard filter (not a client-side Where-Object over the whole directory).
        # Single quotes doubled so they can't break the -Filter string.
        $safe = $term.Replace("'", "''")
        $found = @(Get-ADUser -Filter "SamAccountName -like '*$safe*'" -Properties mail)

        if ($found.Count -eq 1) {
            return $found[0]
        }
        if ($found.Count -eq 0) {
            if ($NonInteractive) { throw "Find-VPNCertADUser: no AD user matched '*$term*'." }
            Write-Host "No users matched '*$term*'." -ForegroundColor Yellow
            if ($ReturnNullOnNoMatch) { return $null }
            $term = $null
            continue
        }
        # more than one
        if ($NonInteractive) { throw "Find-VPNCertADUser: '*$term*' matched $($found.Count) users - be more specific for batch mode." }
        Write-Host "Matched $($found.Count) users:" -ForegroundColor Cyan
        Show-VPNCertUserTable -UserList $found
        while ($true) {
            $pick = Read-Host "`nWhich one? (0-$($found.Count - 1), or S to search again)"
            if ($pick -match '^(?i)s$') { $term = $null; break }
            $idx = 0
            if ([int]::TryParse($pick, [ref]$idx) -and $idx -ge 0 -and $idx -lt $found.Count) {
                return $found[$idx]
            }
            Write-Host "Enter a number between 0 and $($found.Count - 1), or S." -ForegroundColor Yellow
        }
    }
}

function Resolve-VPNCertTemplateName {
    <#
    .SYNOPSIS
        Get-Certificate -Template (CX509Enrollment::InitializeFromTemplateName) wants the template's
        CN, NOT its display name. `certutil -CATemplates` prints "CN: DisplayName -- ...". Given
        either form, return the CN the CA will accept. Falls back to the input unchanged if certutil
        can't be read (e.g. not on the CA / no enterprise CA reachable).
    #>

    param([Parameter(Mandatory)][string]$Name)
    $out = try { & certutil.exe -CATemplates 2>&1 | Out-String } catch { '' }
    if ([string]::IsNullOrWhiteSpace($out)) { return $Name }
    $rows = foreach ($line in ($out -split "`r?`n")) {
        if ($line -match '^\s*([A-Za-z0-9][\w.-]*):\s*(.+?)\s*--') {
            [pscustomobject]@{ Cn = $Matches[1].Trim(); Display = $Matches[2].Trim() }
        }
    }
    $hit = $rows | Where-Object { $_.Cn -ieq $Name -or $_.Display -ieq $Name } | Select-Object -First 1
    if ($hit) { return $hit.Cn }
    # not published under that name - try Menu 1's CN convention (display name minus non-alphanumerics)
    $stripped = ($Name -replace '[^A-Za-z0-9]', '')
    if ($rows | Where-Object { $_.Cn -ieq $stripped }) { return $stripped }
    return $Name
}

function New-VPNCertRequest {
    <#
    .SYNOPSIS
        Submits a certificate request for a resolved AD user against $TemplateName via Get-Certificate.
        Returns Get-Certificate's result object (its .Request member carries Thumbprint/Subject). The
        request is left PENDING if the template requires manager approval.
    #>

    param(
        [Parameter(Mandatory)]$ADUser,
        [Parameter(Mandatory)][string]$TemplateName,
        [string]$Url,
        [string]$CertStoreLocation = 'Cert:\CurrentUser\My'
    )

    # Get-Certificate wants the template CN, not the display name (IKEv2VPN-CorpLAN-MANUAL ->
    # IKEv2VPNCorpLANMANUAL). Resolve it against what the CA actually publishes.
    $TemplateName = Resolve-VPNCertTemplateName -Name $TemplateName

    # SAN = mail + DN, blanks filtered (a user with no 'mail' attribute would otherwise pass $null).
    $dnsName = @($ADUser.mail, $ADUser.DistinguishedName) | Where-Object { $_ }
    # 2026-09-15, real bug found live at a client (TameMyCerts's own CA-log entry showed 'E="", CN=...' -
    # an emailAddress RDN with an EMPTY value): a test account with no 'mail' attribute set (several
    # of that client's per-group test users - IV2.Plant, IV2.PSGI, IV2.Auditor) used to always get an
    # "E=$mail, " prefix even when $mail was $null/blank, producing a malformed empty-valued RDN.
    # Omit the E= RDN entirely when there's no mail attribute to put in it.
    $subject = if ($ADUser.mail) { "E=$($ADUser.mail), $($ADUser.DistinguishedName)" } else { "$($ADUser.DistinguishedName)" }

    $params = @{
        Template          = $TemplateName
        DnsName           = $dnsName          # [string[]] - passed directly, not pre-joined
        SubjectName       = $subject
        CertStoreLocation = $CertStoreLocation
    }
    if ($Url) { $params.Url = $Url }

    try {
        $result = Get-Certificate @params
    } catch {
        Write-Host "ERROR: certificate request failed - $($_.Exception.Message)" -ForegroundColor Red
        return $null
    }
    Write-Host ("Requested: subject '{0}', thumbprint {1}" -f $result.Request.Subject, $result.Request.Thumbprint) -ForegroundColor Green
    return $result
}

function Complete-VPNCertRequest {
    <#
    .SYNOPSIS
        Retrieves an approved request and exports a password-protected PFX, plus the public-key-only
        .cer alongside it. Returns [pscustomobject]@{ Status; Thumbprint; SerialNumber; PfxPath;
        CerPath }. Status is 'Issued' on success, otherwise whatever Get-Certificate reported (e.g.
        'Pending') or 'Error'.
 
    .NOTES
        2026-09-15, per the maintainer (re: CA-Manager menu 11's test-cert suite): "why didn't I have you do
        it during generation time" - export the .cer (public certificate, no private key) at the same
        moment the PFX is exported, rather than as a separate later step. CerPath is $null (not an
        error) if the .cer export fails but the PFX already succeeded - the tech still has a usable
        PFX either way, so a .cer hiccup doesn't blank out an otherwise-successful issuance.
    #>

    param(
        [Parameter(Mandatory)]$RequestObject,
        [Parameter(Mandatory)][string]$OutputDir,
        [Parameter(Mandatory)][securestring]$PfxPassword,
        # File name stem (no extension). Default: sanitized SamAccountName + date.
        [string]$FileNameStem,
        # Store the request/issued cert lives in. Cert:\LocalMachine\My for a machine-context enroll.
        [string]$CertStoreLocation = 'Cert:\CurrentUser\My'
    )

    if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null }

    $out = [pscustomobject]@{ Status = 'Error'; Thumbprint = $null; SerialNumber = $null; PfxPath = $null; CerPath = $null }

    try {
        $retrieved = Get-Certificate -Request $RequestObject.Request
    } catch {
        Write-Host "ERROR: certificate retrieval failed - $($_.Exception.Message)" -ForegroundColor Red
        return $out
    }

    $out.Status = "$($retrieved.Status)"
    if ($retrieved.Status -ne 'Issued') {
        Write-Host "WARNING: request status is '$($retrieved.Status)', not 'Issued' - has it been approved on the CA yet?" -ForegroundColor Yellow
        return $out
    }

    $cert = $retrieved.Certificate
    $out.Thumbprint   = $cert.Thumbprint
    $out.SerialNumber = $cert.SerialNumber

    if (-not $FileNameStem) {
        $FileNameStem = ($RequestObject.Request.Subject -replace '[^A-Za-z0-9]', '') + "_" + (Get-Date -Format 'MM_dd_yyyy')
    }
    $pfxPath = Join-Path $OutputDir ($FileNameStem + ".pfx")
    $cerPath = Join-Path $OutputDir ($FileNameStem + ".cer")

    try {
        $store = $CertStoreLocation.TrimEnd('\')
        $certPath = if (Test-Path "$store\$($cert.Thumbprint)") { "$store\$($cert.Thumbprint)" }
                    elseif (Test-Path "Cert:\LocalMachine\My\$($cert.Thumbprint)") { "Cert:\LocalMachine\My\$($cert.Thumbprint)" }
                    else { "Cert:\CurrentUser\My\$($cert.Thumbprint)" }
        Export-PfxCertificate -Cert $certPath -FilePath $pfxPath -Password $PfxPassword | Out-Null
        $out.PfxPath = $pfxPath
        Write-Host "Exported: $pfxPath" -ForegroundColor Green

        # Public-key-only .cer alongside the PFX - no password, no private key, safe to hand to
        # anyone who just needs to import/trust/inspect the cert (e.g. pinning it into a FortiGate
        # peer, or a quick certutil -dump). A failure here doesn't blank out the PFX result above.
        try {
            Export-Certificate -Cert $certPath -FilePath $cerPath -Type CERT | Out-Null
            $out.CerPath = $cerPath
            Write-Host "Exported: $cerPath" -ForegroundColor Green
        } catch {
            Write-Host "WARNING: .cer export failed (PFX above is still good) - $($_.Exception.Message)" -ForegroundColor Yellow
        }
    } catch {
        Write-Host "ERROR: PFX export failed - $($_.Exception.Message)" -ForegroundColor Red
    }
    return $out
}

function Revoke-VPNCert {
    <#
    .SYNOPSIS
        Revokes an issued certificate by serial number via certutil, then (optionally) republishes
        the CRL so the revocation is picked up. Test-suite use only. Returns
        [pscustomobject]@{ Revoked; CrlRepublished; Detail }.
    #>

    param(
        [Parameter(Mandatory)][string]$SerialNumber,
        # certutil revoke reason: 0 = unspecified, 1 = key compromise, 3 = affiliation changed,
        # 4 = superseded, 5 = cessation of operation, 6 = certificate hold.
        [int]$ReasonCode = 0,
        [switch]$RepublishCrl
    )
    $out = [pscustomobject]@{ Revoked = $false; CrlRepublished = $false; Detail = $null }
    try {
        $r = & certutil.exe -revoke $SerialNumber $ReasonCode 2>&1 | Out-String
        $out.Detail = $r.Trim()
        $out.Revoked = ($r -match '(?i)completed successfully')
        if (-not $out.Revoked) { Write-Host "WARNING: certutil -revoke did not report success:`n$($out.Detail)" -ForegroundColor Yellow }
    } catch {
        $out.Detail = $_.Exception.Message
        Write-Host "ERROR: certutil -revoke failed - $($_.Exception.Message)" -ForegroundColor Red
        return $out
    }
    if ($RepublishCrl -and $out.Revoked) {
        try {
            $c = & certutil.exe -CRL 2>&1 | Out-String
            $out.CrlRepublished = ($c -match '(?i)completed successfully')
            if (-not $out.CrlRepublished) { Write-Host "WARNING: certutil -CRL did not report success:`n$($c.Trim())" -ForegroundColor Yellow }
        } catch {
            Write-Host "ERROR: certutil -CRL (CRL republish) failed - $($_.Exception.Message)" -ForegroundColor Red
        }
    }
    return $out
}
#endregion Standalone