Private/CAFortiGateHandoff.ps1
|
<#
.SYNOPSIS CA Manager - FortiGate certificate hand-off (menu option F). Requires Modules\CACore.ps1, Modules\CAInteractive.ps1, Modules\CAOcsp.ps1 (for Get-CAOcspCACertBytes), Modules\CATameMyCerts.ps1 (for ConvertTo-CATameMyCertsToken - see Get-CAFortiGatePeerPlan below) dot-sourced, plus Request-VPNCertCore.ps1 (bundled next to CA-Manager.ps1 by Build-CAManagerZip.ps1; dev-time fallback to the repo copy under IPSEC AIO\MiscTools\Tools\). CA-Manager.ps1's own dot-source order loads CATameMyCerts.ps1 AFTER this file, but every module finishes loading before the dashboard shows its first menu, so the dependency is real (needed at menu-13 RUN time) without needing the load order itself changed. .DESCRIPTION The roll-out philosophy (the maintainer, 2026-09-08): a Certificate-auth client is NOT deployed until the CA side is fully built. The MasterOrchestrator stages this tool, HALTS the client, and waits for a <Company>_CAResponse.json to be copied back. This module produces: <Company>_CAResponse.json - DATA only (Schema 4, 2026-09-30): the issuing CA cert + parent chain, resolved object names, peer plan, revocation URLs, and the FortiGate identity cert (PFX embedded as base64, or the CSR-signed cert as PEM). No CLI: CLIBuilder renders the CA/peer block from these fields, and the Orchestrator writes the identity-cert import note (00_APPLY_FIRST_FortiGate_CertSetup.txt) itself. <Company>_FortiGate.pfx - the FortiGate's identity cert + key (password-protected), when CA-Manager generated the key; also embedded in the JSON. The FortiGate identity key is generated here in PowerShell (the maintainer's call - no CSR round-trip, no FortiGate API creds on the CA box) against the CA_TemplateFortiGate server-auth template, or signed from a FortiGate CSR. Re-running menu 13 offers to REUSE the identity cert already in the output folder's hand-back (Get-CAHandoffIdentity), so everything else can be rebuilt without a new request + approval. #> # --- locate + dot-source the shared request engine (same pattern as CATestSuite.ps1) --- $script:__caHandoffCoreLoaded = $false # NSP.PKI: the module ships it as (only) Private\Engine\Request-VPNCertCore.ps1. foreach ($candidate in @( (Join-Path $PSScriptRoot "Engine\Request-VPNCertCore.ps1") )) { if (Test-Path $candidate) { . $candidate; $script:__caHandoffCoreLoaded = $true; break } } # Schema 2 (2026-09-11) - additive only: FortiGateCertSetupText + FortiGatePfxBase64 embed the # sibling .txt/.pfx files' own content directly in the JSON, so a tech can copy back just the ONE # _CAResponse.json file instead of all 3 - IPSec-MasterOrchestrator.ps1's Restore-CAHandoffArtifacts # reconstitutes the sibling files on the receiving end if they weren't also copied back. A Schema-1 # response (missing these two fields) still works exactly as before - nothing here is removed. # # Schema 3 (2026-09-15) - additive only again: CACertChain (Get-CAFortiGateParentCertChain) carries # the parent CA cert(s) above the issuing CA, for a subordinate-CA client - empty array (unchanged # rendered output) for the common root/standalone-CA case. A Schema-1/2 response (missing this field) # still works exactly as before. # # Schema 4 (2026-09-30, per the maintainer - CLI generation moved to CLIBuilder): FortiGateCertSetupText is # gone and no _FortiGate_CertSetup.txt is written. The Orchestrator accepts a Schema 4 hand-back as # complete with just the JSON (+ PFX), and still ingests Schema 1-3 files. $script:CAHandoffResponseSchema = 4 # --------------------------------------------------------------------------- function ConvertTo-CAPem { <# .SYNOPSIS DER byte[] -> a PEM block (64-char-wrapped base64 between -----BEGIN/END <Label>-----). PURE. #> param( [Parameter(Mandatory)][byte[]]$DerBytes, [string]$Label = 'CERTIFICATE' ) $b64 = [Convert]::ToBase64String($DerBytes) $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine("-----BEGIN $Label-----") for ($i = 0; $i -lt $b64.Length; $i += 64) { [void]$sb.AppendLine($b64.Substring($i, [Math]::Min(64, $b64.Length - $i))) } [void]$sb.Append("-----END $Label-----") $sb.ToString() } # --------------------------------------------------------------------------- function Get-CAFortiGateParentCertChain { <# .SYNOPSIS PARENT CA certificate(s) only - NOT the issuing CA's own cert (the caller already has that via Get-CAOcspCACertBytes/CACertPem). Walks up from the issuing CA's cert to the self-signed root, one entry per tier in between, in leaf-to-root order. Empty array for a root/standalone issuing CA (the common case) - purely additive, changes nothing for a client that doesn't need it. 2026-09-15, per the maintainer: "We need to capture the whole chain (public certs) for the issuing CA (so if it's subordinate, get the parent CA cert too, make sure it's the latest cert)." The "latest cert" selection itself is Get-CAOcspCACertBytes's job (Sort-Object NotAfter -Descending against the issuing CA's own CN, already established for the OCSP responder) - this function takes that ALREADY-RESOLVED cert as input rather than re-deriving "latest" again here, so there's exactly one place that decision is made, not two that could drift. .PARAMETER IssuingCert The issuing CA's own X509Certificate2 - build this from the SAME bytes Get-CAOcspCACertBytes -CACommonName <Status.CACommonName> already selected. .OUTPUTS Array of pscustomobject { Name; Pem }. Name is "CA_<sanitized subject CN>", falling back to "CA_Parent<N>" if a tier's CN can't be extracted (still unique per tier, still safe to use as a FortiGate object name). Pem is that tier's public cert. .NOTES Read-only: X509Chain.Build() over the LOCAL machine's cert stores only - RevocationMode is explicitly NoCheck (this captures the STRUCTURAL chain of public certs to import, it is not a revocation check) and VerificationFlags allows an unknown/untrusted root so an internal PKI's own root (never in the Windows trusted-root store) still resolves. Fails soft to an empty array (never throws) if the parent isn't installed locally / the chain can't be built at all - the hand-off is then no worse than before this function existed (issuing CA cert alone). #> param([Parameter(Mandatory)][System.Security.Cryptography.X509Certificates.X509Certificate2]$IssuingCert) if ($IssuingCert.Subject -eq $IssuingCert.Issuer) { return @() } # self-signed - it IS the root, nothing above it try { $chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new() $chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck $chain.ChainPolicy.VerificationFlags = [System.Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority [void]$chain.Build($IssuingCert) } catch { return @() } $parents = New-Object System.Collections.Generic.List[object] # ChainElements[0] is the issuing cert itself (what Build() was called with) - the caller already # has that one (CACertPem/CACertNameOnGate); only tiers ABOVE it are new here. for ($i = 1; $i -lt $chain.ChainElements.Count; $i++) { $cert = $chain.ChainElements[$i].Certificate $cn = try { $cert.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) } catch { $null } $token = if (-not [string]::IsNullOrWhiteSpace($cn)) { ($cn -replace '[^A-Za-z0-9]', '') } else { '' } $name = if ($token) { "CA_$token" } else { "CA_Parent$i" } $parents.Add([pscustomobject]@{ Name = $name Pem = ConvertTo-CAPem -DerBytes ([byte[]]$cert.RawData) -Label 'CERTIFICATE' }) } return $parents.ToArray() } # --------------------------------------------------------------------------- function Get-CAResponseObject { <# .SYNOPSIS PURE. Builds the <Company>_CAResponse.json content object from the resolved inputs. No I/O. .PARAMETER CAAnswers The CAAnswers.json object (Company_Name, Cert_CertificateName, Cert_PeerName, CA_* fields). .PARAMETER CACommonName / CAConfigString / CASanitizedName Resolved CA identity. .PARAMETER CACertPem The CA public certificate as a PEM string. .PARAMETER CACertChain 2026-09-15 addition (Schema 3, additive) - array of { Name; Pem } for every PARENT CA certificate above the issuing CA (see Get-CAFortiGateParentCertChain) - empty/omitted for a root/standalone issuing CA, the common case. NOT the issuing CA's own cert, which stays in CACertPem/CACertNameOnGate exactly as before this addition. .PARAMETER FgCert The { PfxPath; Password; Thumbprint; SerialNumber } from New-CAFortiGateIdentityCert (or $null in a dry run). .PARAMETER PeerSubjectFilter The 'set subject' substring, or '' / $null for none. .PARAMETER FortiGatePfxBase64 2026-09-11 addition (Schema 2) - the PFX file's own bytes, base64-encoded, when New-CAFortiGateIdentityCert actually generated one (the "blank -> CA-Manager generates a keypair + PFX" path only; the CSR/import paths have no PFX, just FortiGateCertPem, already inline). This function stays PURE (no I/O) - the CALLER reads the PFX bytes and passes the base64 string in; Get-CAResponseObject never touches the filesystem itself. Lets the whole hand-back travel as ONE file (this JSON) instead of needing the sibling .pfx copied back too - see Write-CAHandoffFiles' own note and IPSec-MasterOrchestrator.ps1's Restore-CAHandoffArtifacts for the other end of this. #> param( $CAAnswers, [Parameter(Mandatory)][string]$CACommonName, [string]$CAConfigString, [Parameter(Mandatory)][string]$CASanitizedName, [string]$CACertPem, $CACertChain, $FgCert, [string]$PeerSubjectFilter, [string]$CAManagerVersion, [string]$FortiGatePfxBase64 ) $issuingModel = if ($CAAnswers.CA_IssuingModel) { "$($CAAnswers.CA_IssuingModel)" } else { 'SharedCAWithSubjectFilter' } $company = if ($CAAnswers.Company_Name) { "$($CAAnswers.Company_Name)" } else { $CACommonName } $fgCertName = if ($CAAnswers.Cert_CertificateName) { "$($CAAnswers.Cert_CertificateName)" } else { "$CASanitizedName-FGT" } $peerName = if ($CAAnswers.Cert_PeerName) { "$($CAAnswers.Cert_PeerName)" } else { "$CASanitizedName-peer" } $caCertName = "CA_$CASanitizedName" $crlUrl = if ($CAAnswers.CA_AppProxyCrlFqdn -and "$($CAAnswers.CA_AppProxyCrlFqdn)" -match '\.') { "http://$($CAAnswers.CA_AppProxyCrlFqdn)/CertEnroll/$CASanitizedName.crl" } else { $null } $ocspUrl = if ($CAAnswers.CA_AppProxyOcspFqdn -and "$($CAAnswers.CA_AppProxyOcspFqdn)" -match '\.') { "http://$($CAAnswers.CA_AppProxyOcspFqdn)/ocsp" } else { $null } # 2026-09-15, per the maintainer (live client troubleshooting - a hand-typed 'IKEv2_CorpLAN' peer had two # real bugs found only via live FortiGate debug logs): for a TameMyCerts + RadiusGroupPairs client, # generate one config user peer per pair instead of leaving this entirely manual. $peerPlan = Get-CAFortiGatePeerPlan -CAAnswers $CAAnswers $out = [ordered]@{ Schema = $script:CAHandoffResponseSchema GeneratedUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') CAManagerVersion = $CAManagerVersion Company = $company IssuingModel = $issuingModel CACommonName = $CACommonName CAConfigString = $CAConfigString CASanitizedName = $CASanitizedName CACertNameOnGate = $caCertName CACertPem = $CACertPem # NOT a bare @($CACertChain) - wrapping a $null scalar in @() produces a 1-element array # holding $null (Count 1), not an empty array. The caller omits -CACertChain entirely for the # common root/standalone-CA case, so this must explicitly collapse a null/absent value to a # real empty array or CLIBuilder's "any parents?" check would misfire. CACertChain = if ($CACertChain) { @($CACertChain) } else { @() } FortiGateCertName = $fgCertName FortiGatePeerName = $peerName FortiGatePfxFile = if ($FgCert -and $FgCert.PfxPath) { Split-Path $FgCert.PfxPath -Leaf } else { $null } FortiGatePfxPassword = if ($FgCert) { $FgCert.Password } else { $null } FortiGatePfxBase64 = if (-not [string]::IsNullOrWhiteSpace($FortiGatePfxBase64)) { $FortiGatePfxBase64 } else { $null } FortiGateCertPem = if ($FgCert -and $FgCert.PSObject.Properties['CertPem']) { $FgCert.CertPem } else { $null } FortiGateCertThumbprint = if ($FgCert) { $FgCert.Thumbprint } else { $null } PeerSubjectFilter = if ([string]::IsNullOrWhiteSpace($PeerSubjectFilter)) { '' } else { $PeerSubjectFilter.Trim() } PeerPlan = $peerPlan AppProxyCrlUrl = $crlUrl AppProxyOcspUrl = $ocspUrl AutoEnrollGpo = if ($CAAnswers.CA_AutoEnrollGPOName) { "$($CAAnswers.CA_AutoEnrollGPOName)" } else { $null } TemplatesPublished = @( $CAAnswers.CA_TemplateAuto, $CAAnswers.CA_TemplateManual, $CAAnswers.CA_TemplateFortiGate ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } } # Schema 4 (2026-09-30): no rendered CLI any more (Schema 2-3's FortiGateCertSetupText). CLIBuilder # renders the CA cert / chain / peer block from these fields once the Orchestrator folds them into # the client's answers, and the Orchestrator writes the identity-cert import note itself. return $out } # --------------------------------------------------------------------------- function Get-CAFortiGatePeerPlan { <# .SYNOPSIS PURE. One 'config user peer' object per RadiusGroupPair (TameMyCerts model), bundled into one 'config user peergrp', instead of the single hand-typed peer a real client's own live setup had - which shipped with two real bugs (a bare 'set subject' value with no RDN attribute name, and an mfa-mode that triggered a RADIUS auth method NPS's policy didn't allow) neither of which any tooling would have caught, since nothing generated this block at all before now. EXCLUDES any pair with IncludeInNPSImport explicitly $false - the SAME signal OrchestratorImport.ps1's own NPS-Manager import already uses to skip these (CLIBuilder's Custom App-Access Rules flow defaults new VPNFW/firewall-scoping-purpose pairs to this) - The maintainer, 2026-09-15: "I want a peer for each of the groups, but NOT the VPNFW groups." These aren't standalone tunnel identities, they ride along via firewall rules once a user's PRIMARY group already has a peer/cert - giving them their own peer object would be noise, not signal. Each peer's OU token is derived via the EXACT SAME ConvertTo-CATameMyCertsToken function TameMyCerts itself uses (CATameMyCerts.ps1) for its own policy XML - the peer's 'set subject' can never drift from what's actually stamped into the cert, unlike a hand-typed value. Deliberately never sets 'mfa-mode' - confirmed live against a real client's FortiGate that 'subject-identity' MFA mode sends a RADIUS request NPS-Manager's own generated Network Policies don't allow (PEAP-only) - a peer this function builds authenticates on the certificate + subject match alone. .PARAMETER CAAnswers The CAAnswers.json object (CA_SubjectStampMode, RadiusGroupPairs). .PARAMETER PeerPrefix Default 'P_IKEv2_DIv2_' - 'P_' (the maintainer, 2026-09-15) marks it as a 'config user peer' object at a glance on the gate, same spirit as the 'CA_' prefix on CA-certificate objects; DIv2 = Dialup-IKEv2, the tunnel these peers gate. A pair's own Label occasionally already carries a 'DIv2-'/'DIv2_' marker of its own (a RadiusGroupPairs labeling convention that predates this feature, e.g. 'DIv2-RedStone') - stripped from the LABEL before appending it here, so the result is 'P_IKEv2_DIv2_RedStone', not the redundant 'P_IKEv2_DIv2_DIv2-RedStone'. .PARAMETER PeerGroupName Default 'IKEv2_DIv2_AllowedPeers' (the maintainer's own naming, 2026-09-15) - a 'config user peergrp' object, not an individual peer, so it deliberately does NOT get the 'P_' prefix. .OUTPUTS pscustomobject: Applicable (bool - TameMyCerts mode AND at least one qualifying pair); PeerGroupName; Peers (array of { GroupLabel; UserGroupName; PeerName; OuValue }). #> param( $CAAnswers, [string]$PeerPrefix = 'P_IKEv2_DIv2_', [string]$PeerGroupName = 'IKEv2_DIv2_AllowedPeers' ) $mode = if ($CAAnswers -and -not [string]::IsNullOrWhiteSpace($CAAnswers.CA_SubjectStampMode)) { "$($CAAnswers.CA_SubjectStampMode)".Trim() } else { 'None' } $pairs = @() if ($CAAnswers -and $CAAnswers.PSObject.Properties['RadiusGroupPairs'] -and $CAAnswers.RadiusGroupPairs) { $pairs = @($CAAnswers.RadiusGroupPairs) } $peers = New-Object System.Collections.Generic.List[object] if ($mode -eq 'TameMyCerts' -and $pairs.Count) { foreach ($p in $pairs) { # Same exclusion signal OrchestratorImport.ps1's own NPS import already uses - a pair from # CLIBuilder's Custom App-Access Rules flow (VPNFW/firewall-scoping groups) defaults this # to $false; a pair saved before this field existed, or explicitly $true, is included. if ($p.PSObject.Properties['IncludeInNPSImport'] -and $null -ne $p.IncludeInNPSImport -and -not [bool]$p.IncludeInNPSImport) { continue } $label = if ($p.PSObject.Properties['Label']) { "$($p.Label)" } else { '' } # Same UserGroupValue-over-UserGroupName preference as Get-CAPerGroupTemplateSpecs/ # Get-CATameMyCertsPlan - UserGroupValue is the real AD group; UserGroupName is a friendlier # label not reliably a real AD group (2026-09-10 live bug, see those functions' own notes). $grp = if ($p.PSObject.Properties['UserGroupValue'] -and -not [string]::IsNullOrWhiteSpace($p.UserGroupValue)) { "$($p.UserGroupValue)" } elseif ($p.PSObject.Properties['UserGroupName']) { "$($p.UserGroupName)" } else { '' } if (-not $label -and -not $grp) { continue } $ou = if ($p.PSObject.Properties['CertSubjectOu'] -and -not [string]::IsNullOrWhiteSpace($p.CertSubjectOu)) { ConvertTo-CATameMyCertsToken $p.CertSubjectOu } else { ConvertTo-CATameMyCertsToken $(if ($grp) { $grp } else { $label }) } $labelToken = ConvertTo-CATameMyCertsToken $(if ($label) { $label } else { $grp }) $labelToken = $labelToken -replace '^DIv2[-_]', '' $peerName = "$PeerPrefix$labelToken" $peers.Add([pscustomobject]@{ GroupLabel = $label UserGroupName = $grp PeerName = $peerName OuValue = $ou }) } } [pscustomobject]@{ Applicable = [bool]$peers.Count PeerGroupName = $PeerGroupName Peers = $peers.ToArray() } } # --------------------------------------------------------------------------- function Get-CAHandoffIdentity { <# .SYNOPSIS The FortiGate identity certificate carried by an earlier <Company>_CAResponse.json in -OutputDir, so menu 13 can re-run without issuing a new one; $null when there is none. .DESCRIPTION 2026-09-30, per the maintainer: "not being able to hit the other items without re-requesting a FortiGate cert is my main concern." Everything else in the hand-back (CA cert + chain, names, peer plan, URLs) is rebuilt from the CA and the answers on every run; only the identity cert costs a request + approval. Returns the same shape New-CAFortiGateIdentityCert / New-CAFortiGateCertFromCsr do (PfxPath; Password; CertPem; Thumbprint; SerialNumber; Status), plus Subject / NotAfter / Kind ('PFX' or 'Cert') / PfxBase64 for the reuse prompt and checks. A PFX embedded only as base64 is written back out next to the JSON so PfxPath is real. #> param( [Parameter(Mandatory)][string]$OutputDir, [Parameter(Mandatory)][string]$CompanyStem ) # NSP.PKI: the hand-off file (<Company>_PKI_Response.json, payload unwrapped) first, then the # zip-era <stem>_CAResponse.json. $old = $null # Matched on the header's Company (the hand-off file name keeps '-' and '_'; -CompanyStem doesn't). if (Get-Command Import-NSPHandoff -ErrorAction SilentlyContinue) { foreach ($candidate in @(Get-ChildItem -Path $OutputDir -Filter '*_PKI_Response.json' -File -ErrorAction SilentlyContinue)) { try { $h = Import-NSPHandoff -Path $candidate.FullName -Tool PKI -Kind Response } catch { continue } if (("$($h.Company)" -replace '[^A-Za-z0-9]', '') -ne $CompanyStem) { continue } $old = $h.Payload; $jsonPath = $candidate.FullName; break } } if (-not $old) { $jsonPath = Join-Path $OutputDir "${CompanyStem}_CAResponse.json" if (-not (Test-Path $jsonPath)) { return $null } try { $old = Get-Content -Path $jsonPath -Raw | ConvertFrom-Json } catch { return $null } } $cert = $null $out = [pscustomobject]@{ Kind = $null; PfxPath = $null; Password = $null; PfxBase64 = $null; CertPem = $null Thumbprint = $old.FortiGateCertThumbprint; SerialNumber = $null; Subject = $null; NotAfter = $null; Status = 'Issued' } try { if ($old.FortiGatePfxBase64 -or $old.FortiGatePfxFile) { $pfxPath = if ($old.FortiGatePfxFile) { Join-Path $OutputDir $old.FortiGatePfxFile } else { Join-Path $OutputDir "${CompanyStem}_FortiGate.pfx" } # [byte[]]-typed: an if-expression unrolls a byte array into object[], and X509Certificate2 then # binds its (fileName, password) overload instead - 'path too long'. [byte[]]$bytes = if (Test-Path $pfxPath) { [System.IO.File]::ReadAllBytes($pfxPath) } elseif ($old.FortiGatePfxBase64) { [Convert]::FromBase64String($old.FortiGatePfxBase64) } else { $null } if (-not $bytes) { return $null } if (-not (Test-Path $pfxPath)) { [System.IO.File]::WriteAllBytes($pfxPath, $bytes) } $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes, [string]$old.FortiGatePfxPassword) $out.Kind = 'PFX'; $out.PfxPath = $pfxPath; $out.Password = $old.FortiGatePfxPassword; $out.PfxBase64 = [Convert]::ToBase64String($bytes) } elseif ($old.FortiGateCertPem) { $b64 = ("$($old.FortiGateCertPem)" -replace '-----(BEGIN|END) CERTIFICATE-----', '') -replace '\s', '' $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new([Convert]::FromBase64String($b64)) $out.Kind = 'Cert'; $out.CertPem = $old.FortiGateCertPem } else { return $null } } catch { Write-Host " WARNING: the FortiGate cert in $jsonPath could not be read ($($_.Exception.Message)) - a new one is needed." -ForegroundColor Yellow return $null } $out.Thumbprint = $cert.Thumbprint; $out.SerialNumber = $cert.SerialNumber; $out.Subject = $cert.Subject; $out.NotAfter = $cert.NotAfter return $out } function Get-CAHandoffIdentityWarnings { <# .SYNOPSIS PURE. Reasons to think twice before reusing -Identity (Get-CAHandoffIdentity): its CN is not the FortiGate cert name phase1 references, it has expired, or it expires within 30 days. -Revoked ($true from Test-CAHandoffCertRevoked) adds a revocation warning. Warn-and-continue (per the maintainer) - the caller shows these, it never refuses the reuse. #> param( [Parameter(Mandatory)]$Identity, [string]$CertName, [datetime]$Now = (Get-Date), [bool]$Revoked = $false ) $warnings = @() if ($CertName -and $Identity.Subject -and $Identity.Subject -notmatch ('(^|,\s*)CN=' + [regex]::Escape($CertName) + '(,|$)')) { $warnings += "Its subject ($($Identity.Subject)) is not CN=$CertName, the FortiGate certificate name phase1 uses." } if ($Identity.NotAfter) { if ($Identity.NotAfter -lt $Now) { $warnings += "It expired on $($Identity.NotAfter.ToString('yyyy-MM-dd'))." } elseif ($Identity.NotAfter -lt $Now.AddDays(30)) { $warnings += "It expires on $($Identity.NotAfter.ToString('yyyy-MM-dd')), within 30 days." } } if ($Revoked) { $warnings += "This CA lists it as REVOKED." } return $warnings } function Test-CAHandoffCertRevoked { <# .SYNOPSIS $true when this CA's database shows -SerialNumber revoked (disposition 21), $false when it's there and not revoked, $null when unknown (not issued by this CA, or certutil unavailable). #> param([Parameter(Mandatory)][string]$SerialNumber) $text = Invoke-CertUtilText -Arguments @('-view', '-restrict', "SerialNumber=$SerialNumber", '-out', 'Request.Disposition', 'csv') if (-not $text) { return $null } $rows = @($text -split "`r?`n" | Where-Object { $_ -match '^"' } | Select-Object -Skip 1) if (-not $rows.Count) { return $null } return [bool](@($rows | Where-Object { $_ -match '\b21\b|Revoked' }).Count) } # --------------------------------------------------------------------------- function New-CAFortiGateIdentityCert { <# .SYNOPSIS ENGINE. Generates the FortiGate identity keypair + submits a request against the server-auth template, waits for manual approval, retrieves, and exports a password-protected PFX. Returns [pscustomobject]@{ PfxPath; Password; Thumbprint; SerialNumber; Status }. Every mutation goes through Invoke-CAStep. #> param( [Parameter(Mandatory)]$CAAnswers, [Parameter(Mandatory)][string]$OutputDir, [Parameter(Mandatory)][string]$CASanitizedName, [string]$FortiGateFqdn ) if (-not $script:__caHandoffCoreLoaded) { throw "Request-VPNCertCore.ps1 not found (expected next to CA-Manager.ps1 in a built zip, or under IPSEC AIO\MiscTools\Tools\)." } $template = if (-not [string]::IsNullOrWhiteSpace($CAAnswers.CA_TemplateFortiGate)) { "$($CAAnswers.CA_TemplateFortiGate)" } else { 'FortiGate' } # Get-Certificate wants the template CN, not the display name (see Resolve-VPNCertTemplateName). if (Get-Command Resolve-VPNCertTemplateName -ErrorAction SilentlyContinue) { $template = Resolve-VPNCertTemplateName -Name $template } $certName = if (-not [string]::IsNullOrWhiteSpace($CAAnswers.Cert_CertificateName)) { "$($CAAnswers.Cert_CertificateName)" } else { "$CASanitizedName-FGT" } $subject = "CN=$certName" $dnsNames = @($FortiGateFqdn, $certName) | Where-Object { $_ } | Select-Object -Unique $company = if ($CAAnswers.Company_Name) { ($CAAnswers.Company_Name -replace '[^A-Za-z0-9]', '') } else { $CASanitizedName } $password = New-CAHandoffPassword $securePw = ConvertTo-SecureString $password -AsPlainText -Force $out = [pscustomobject]@{ PfxPath = $null; Password = $password; Thumbprint = $null; SerialNumber = $null; Status = 'NotRun' } if (Get-CADryRun) { Write-Host " [DRY RUN] would request '$template' (subject '$subject', SAN $($dnsNames -join ', ')), wait for approval, export a PFX to $OutputDir." -ForegroundColor DarkGray $out.Status = 'DryRun' return $out } # Try user context first; an existing FortiGate template with CT_FLAG_MACHINE_TYPE ("issued only # to a computer") needs machine context - retry from Cert:\LocalMachine\My (menu 13 is elevated). $req = $null $store = 'Cert:\CurrentUser\My' foreach ($try in @('Cert:\CurrentUser\My', 'Cert:\LocalMachine\My')) { $script:__caFgReq = $null try { Invoke-CAStep -Description "Request the FortiGate identity certificate ('$template', subject '$subject')$(if ($try -match 'LocalMachine') { ' [machine context]' })" ` -Commands @("Get-Certificate -Template '$template' -SubjectName '$subject' -DnsName $($dnsNames -join ',') -CertStoreLocation $try") ` -Action { $p = @{ Template = $template; SubjectName = $subject; CertStoreLocation = $try } if ($dnsNames) { $p.DnsName = [string[]]$dnsNames } $script:__caFgReq = Get-Certificate @p } | Out-Null $store = $try break } catch { if ($try -eq 'Cert:\CurrentUser\My' -and "$($_.Exception.Message)" -match 'issued only to a computer|CONTEXT_E_ROLENOTFOUND|0x8004e00c') { Write-Host " '$template' is a machine template - retrying from the computer account..." -ForegroundColor DarkYellow continue } throw } } $req = $script:__caFgReq if (-not $req -or -not $req.Request) { Write-Host " Could not submit the request against '$template' (user or machine context)." -ForegroundColor Red Write-Host " If this CA is already deployed, set CA_Deployed = Yes and skip menu 13. Otherwise" -ForegroundColor Gray Write-Host " check that '$template' grants Enroll to $(if ($store -match 'LocalMachine') { "$env:COMPUTERNAME`$" } else { $env:USERNAME })." -ForegroundColor Gray $out.Status = 'RequestFailed' return $out } Write-Host "" Write-Host " Request submitted (thumbprint $($req.Request.Thumbprint), $(if ($store -match 'LocalMachine') { 'machine' } else { 'user' }) context)." -ForegroundColor Cyan Write-Host " Approve it on the CA: certsrv.msc -> Pending Requests -> right-click -> All Tasks -> Issue" -ForegroundColor Yellow Read-Host " Press Enter once it is issued" | Out-Null $stem = "${company}_FortiGate" $res = Complete-VPNCertRequest -RequestObject $req -OutputDir $OutputDir -PfxPassword $securePw -FileNameStem $stem -CertStoreLocation $store $out.Status = $res.Status $out.Thumbprint = $res.Thumbprint $out.SerialNumber = $res.SerialNumber $out.PfxPath = $res.PfxPath if ($res.Status -ne 'Issued') { Write-Host " Retrieval reported '$($res.Status)' - re-run menu 13 once the request is issued." -ForegroundColor Yellow } return $out } # --------------------------------------------------------------------------- function Read-CAX509 { <# .SYNOPSIS Loads an X509Certificate2 from a DER .cer OR a base64 PEM file. WinPS 5.1's X509Certificate2(string) constructor only handles DER, so PEM is decoded by hand. Returns $null if the file isn't a certificate (e.g. it's a CSR). #> param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path $Path)) { return $null } try { return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($Path) } catch { } try { $raw = Get-Content -Path $Path -Raw if ($raw -match '(?s)-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----') { $b64 = ($Matches[1] -replace '\s', '') return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new([Convert]::FromBase64String($b64)) } } catch { } return $null } function Test-CAIsCsr { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path $Path)) { return $false } try { return ((Get-Content -Path $Path -Raw) -match 'BEGIN (NEW )?CERTIFICATE REQUEST') } catch { return $false } } # --------------------------------------------------------------------------- function Import-CAFortiGateIssuedCert { <# .SYNOPSIS Wraps an ALREADY-ISSUED FortiGate cert (.cer / .crt / base64 .pem) into the same shape New-CAFortiGateCertFromCsr returns - for re-building the hand-off around a cert issued by hand (e.g. re-processing an existing client). The gate keeps its own key -> no PFX. #> param( [Parameter(Mandatory)][string]$CertPath, [Parameter(Mandatory)][string]$OutputDir, [string]$FileStem = 'FortiGate' ) $out = [pscustomobject]@{ CertPem = $null; CertPath = $null; Thumbprint = $null; SerialNumber = $null; Status = 'NotRun'; PfxPath = $null; Password = $null } if (-not (Test-Path $CertPath)) { Write-Host " Cert not found: $CertPath" -ForegroundColor Red; $out.Status = 'NoCert'; return $out } $c = Read-CAX509 -Path $CertPath if (-not $c) { Write-Host " Not a readable certificate (DER .cer or base64 PEM): $CertPath" -ForegroundColor Red; $out.Status = 'BadCert'; return $out } try { if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null } $dest = Join-Path $OutputDir "$FileStem.cer" [System.IO.File]::WriteAllBytes($dest, $c.RawData) $out.CertPem = ConvertTo-CAPem -DerBytes $c.RawData -Label 'CERTIFICATE' $out.CertPath = $dest $out.Thumbprint = $c.Thumbprint $out.SerialNumber = $c.SerialNumber $out.Status = 'Issued' Write-Host " Using issued cert: CN $($c.Subject), thumbprint $($c.Thumbprint), NotAfter $($c.NotAfter)" -ForegroundColor Green } catch { Write-Host " Not a readable certificate: $($_.Exception.Message)" -ForegroundColor Red $out.Status = 'BadCert' } return $out } # --------------------------------------------------------------------------- function New-CAFortiGateCertFromCsr { <# .SYNOPSIS ENGINE. Submits a CSR generated ON the FortiGate against the FortiGate template (via certreq, so it runs under the admin's creds - works for a machine-type / admin-only template that Get-Certificate can't touch), waits for approval, retrieves the signed cert, and returns it as PEM. The private key never leaves the gate - so no PFX, no password. Returns [pscustomobject]@{ CertPem; CertPath; Thumbprint; SerialNumber; Status; PfxPath=$null; Password=$null }. #> param( [Parameter(Mandatory)][string]$CsrPath, [Parameter(Mandatory)][string]$Template, [Parameter(Mandatory)][string]$OutputDir, [string]$FileStem = 'FortiGate', # "<CA server FQDN>\<CA common name>" - passed as certreq -config so it submits DIRECTLY # (no CA-picker GUI, which drops the -attrib -> CERTSRV_E_NO_CERT_TYPE). [string]$CAConfig ) $out = [pscustomobject]@{ CertPem = $null; CertPath = $null; Thumbprint = $null; SerialNumber = $null; Status = 'NotRun'; PfxPath = $null; Password = $null } if (-not (Test-Path $CsrPath)) { Write-Host " CSR not found: $CsrPath" -ForegroundColor Red; $out.Status = 'NoCsr'; return $out } $cfgArgs = if (-not [string]::IsNullOrWhiteSpace($CAConfig)) { @('-config', $CAConfig) } else { @() } if (Get-CADryRun) { Write-Host " [DRY RUN] would: certreq -submit $($cfgArgs -join ' ') -attrib 'CertificateTemplate:$Template' '$CsrPath' -> approve -> certreq -retrieve <id> -> PEM." -ForegroundColor DarkGray $out.Status = 'DryRun'; return $out } if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null } $cerPath = Join-Path $OutputDir "$FileStem.cer" $reqId = $null Invoke-CAStep -Description "Submit the FortiGate CSR against '$Template' (certreq$(if ($cfgArgs) { " -config $CAConfig" }))" ` -Commands @("certreq -submit $($cfgArgs -join ' ') -attrib `"CertificateTemplate:$Template`" `"$CsrPath`" `"$cerPath`"") ` -Action { $o = & certreq.exe -submit @cfgArgs -attrib "CertificateTemplate:$Template" $CsrPath $cerPath 2>&1 | Out-String Write-Host $o -ForegroundColor DarkGray if ($o -match 'RequestId:\s*"?(\d+)"?') { $script:__caFgCsrReqId = $Matches[1] } } | Out-Null $reqId = $script:__caFgCsrReqId if ((Test-Path $cerPath) -and (Get-Item $cerPath).Length -gt 0) { # auto-issued (no pending) - certreq wrote the cert straight out } elseif ($reqId) { Write-Host "" Write-Host " Request $reqId is PENDING. Approve it: certsrv.msc -> Pending Requests -> Issue" -ForegroundColor Yellow Read-Host " Press Enter once it is issued" | Out-Null Invoke-CAStep -Description "Retrieve issued request $reqId" ` -Commands @("certreq -retrieve $($cfgArgs -join ' ') $reqId `"$cerPath`"") ` -Action { & certreq.exe -retrieve @cfgArgs $reqId $cerPath 2>&1 | Out-String | Write-Host -ForegroundColor DarkGray } | Out-Null } else { Write-Host " certreq -submit did not return a RequestId and wrote no cert - check the output above." -ForegroundColor Red $out.Status = 'SubmitFailed'; return $out } if (-not (Test-Path $cerPath) -or (Get-Item $cerPath).Length -eq 0) { Write-Host " No issued certificate at $cerPath - approve the request and re-run menu 13 with the same CSR." -ForegroundColor Yellow $out.Status = 'Pending'; return $out } try { $c = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cerPath) $out.CertPem = ConvertTo-CAPem -DerBytes $c.RawData -Label 'CERTIFICATE' $out.CertPath = $cerPath $out.Thumbprint = $c.Thumbprint $out.SerialNumber = $c.SerialNumber $out.Status = 'Issued' Write-Host " Issued: $cerPath (CN $($c.Subject), thumbprint $($c.Thumbprint))" -ForegroundColor Green } catch { Write-Host " Could not read the issued cert: $($_.Exception.Message)" -ForegroundColor Red $out.Status = 'BadCert' } return $out } # --------------------------------------------------------------------------- function New-CAHandoffPassword { <# .SYNOPSIS A 20-char random password for the FortiGate PFX. Letters+digits+a few safe symbols only (kept shell/CLI-paste friendly). PURE (uses RNGCryptoServiceProvider). #> $alphabet = (([char[]](48..57)) + ([char[]](65..90)) + ([char[]](97..122)) + [char[]]'!@#%^*-_=+') $bytes = New-Object 'byte[]' 20 (New-Object System.Security.Cryptography.RNGCryptoServiceProvider).GetBytes($bytes) -join ($bytes | ForEach-Object { $alphabet[$_ % $alphabet.Length] }) } # --------------------------------------------------------------------------- function Write-CAHandoffFiles { <# .SYNOPSIS ENGINE. Writes <Company>_CAResponse.json into $OutputDir from a Get-CAResponseObject. Routes through Invoke-CAStep. Returns @{ JsonPath }. Schema 4 (2026-09-30) writes no CLI file, and removes a <Company>_FortiGate_CertSetup.txt an earlier run left in the folder so it can't be copied back and applied by mistake - CLIBuilder's output is the CLI now. #> param( [Parameter(Mandatory)]$Response, [Parameter(Mandatory)][string]$OutputDir ) $companyStem = ($Response.Company -replace '[^A-Za-z0-9]', '') $jsonPath = Join-Path $OutputDir "${companyStem}_CAResponse.json" $staleTxt = Join-Path $OutputDir "${companyStem}_FortiGate_CertSetup.txt" # NSP.PKI: written as a PKI Response hand-off (NSP.Toolkit shared header around the same Schema 4 # payload, PFX and its password still embedded) - <Company>_PKI_Response.json, for the # Orchestrator's Staging\<Abbrev>\Inbox\. The zip-era <stem>_CAResponse.json is only written when # NSP.Toolkit is unavailable. if (Get-Command New-NSPHandoff -ErrorAction SilentlyContinue) { $handoffDir = $OutputDir $handoff = New-NSPHandoff -Kind Response -Tool PKI -Company "$($Response.Company)" -Payload ([pscustomobject]$Response) ` -PayloadSchema ([int]$Response.Schema) -ToolVersion "$($Response.CAManagerVersion)" -GeneratedBy "NSP.PKI $($Response.CAManagerVersion)" $handoffStem = [regex]::Replace("$($Response.Company)", '[^A-Za-z0-9_-]', ''); if (-not $handoffStem) { $handoffStem = 'Unknown' } $jsonPath = Join-Path $OutputDir "${handoffStem}_PKI_Response.json" # Export-NSPHandoff's file name Invoke-CAStep -Description "Write $jsonPath" ` -Commands @("Export-NSPHandoff -Handoff `$handoff -Directory '$OutputDir'") ` -Action { $null = Export-NSPHandoff -Handoff $handoff -Directory $handoffDir } | Out-Null if (Test-Path $staleTxt) { Invoke-CAStep -Description "Remove $staleTxt (old CLI file - CLIBuilder renders the CA/peer CLI now)" ` -Commands @("Remove-Item '$staleTxt'") ` -Action { Remove-Item -Path $staleTxt -Force } | Out-Null } return [pscustomobject]@{ JsonPath = $jsonPath } } Invoke-CAStep -Description "Write $jsonPath" ` -Commands @("`$Response | ConvertTo-Json -Depth 6 | Set-Content '$jsonPath'") ` -Action { if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null } $json = [pscustomobject]$Response | ConvertTo-Json -Depth 6 $json = ($json -replace "`r`n", "`n") -replace "`n", "`r`n" Set-Content -Path $jsonPath -Value $json -Encoding UTF8 } | Out-Null if (Test-Path $staleTxt) { Invoke-CAStep -Description "Remove $staleTxt (old CLI file - CLIBuilder renders the CA/peer CLI now)" ` -Commands @("Remove-Item '$staleTxt'") ` -Action { Remove-Item -Path $staleTxt -Force } | Out-Null } [pscustomobject]@{ JsonPath = $jsonPath } } # --------------------------------------------------------------------------- function Invoke-CAMenuHandoff { <# .SYNOPSIS Menu 13. Builds the <Company>_CAResponse.json hand-back the MasterOrchestrator ingests: the CA cert + chain, names, peer plan, URLs, and the FortiGate identity cert - reused from the last hand-back in the output folder when the tech accepts, otherwise generated / CSR-signed / imported. Run it after menus 1-9 are green; re-run it freely (2026-09-30). #> param( $CAAnswers, [Parameter(Mandatory)]$Status, [string]$CAManagerVersion ) Write-CAHeader "Hand back to the Orchestrator (CA data + FortiGate cert)" if (-not $CAAnswers) { $CAAnswers = [pscustomobject]@{} } if (-not $Status.CACommonName) { Write-Host " No local CA detected - run menu 13 on the CA box." -ForegroundColor Yellow Read-Host "`nPress Enter to return to the menu" | Out-Null; return } if (-not $Status.IssuanceReady) { Write-Host " Issuance readiness is NOT READY - the FortiGate cert would bake in the wrong" -ForegroundColor Yellow Write-Host " AIA/CDP/OCSP URLs. Finish menus 1-7 first." -ForegroundColor Yellow if ((Read-Host " Type YES to continue anyway") -cne 'YES') { Read-Host "`nPress Enter" | Out-Null; return } } $sanitized = try { Get-CAActiveConfigName } catch { $Status.CACommonName } $company = $CAAnswers.Company_Name if ([string]::IsNullOrWhiteSpace($company)) { # 2026-09-15, per the maintainer: "can we add a 'back'/'main menu' option here? and anywhere else # we've missed it" - this is the wizard's own very first prompt (only reached when # Company_Name isn't already known/staged), so there's nothing earlier to step back to # WITHIN the wizard - -AllowBack here means "cancel out of menu 13 entirely." $company = Read-CANonEmpty -Prompt "Company name (for the file names)" -AllowBack if (Test-CABackSignal $company) { Write-Host " Cancelled." -ForegroundColor Gray; return } } # write it back so Get-CAResponseObject / the file names use it, not the CA common name if ("$($CAAnswers.Company_Name)" -ne "$company") { $CAAnswers | Add-Member -NotePropertyName Company_Name -NotePropertyValue $company -Force } $companyStem = ($company -replace '[^A-Za-z0-9]', '') $issuingModel = if ($CAAnswers.CA_IssuingModel) { "$($CAAnswers.CA_IssuingModel)" } else { 'SharedCAWithSubjectFilter' } # 2026-09-15: resolved early (same reasoning as $issuingModel itself) so the PeerName step below # can suggest the peer GROUP name, not a single peer, when TameMyCerts + RadiusGroupPairs are # both in play - see Get-CAFortiGatePeerPlan's own header for the full reasoning. $peerPlanPreview = Get-CAFortiGatePeerPlan -CAAnswers $CAAnswers if ($peerPlanPreview.Applicable) { Write-Host " TameMyCerts per-group mode - will generate $($peerPlanPreview.Peers.Count) 'config user peer' object(s)" -ForegroundColor Gray Write-Host " bundled into peer group '$($peerPlanPreview.PeerGroupName)' (one per qualifying RADIUS group pair)." -ForegroundColor Gray } elseif ($issuingModel -ne 'SharedCAWithSubjectFilter') { Write-Host " Dedicated issuing CA - no 'set subject' filter (every cert from this CA is a VPN cert)." -ForegroundColor Gray } # Back-navigation (2026-09-10, Part A item 3 - see Invoke-CAWizardSteps, CAInteractive.ps1): this # run of prompts is a clean, undo-able SEQUENCE with no mutating action interleaved between them - # nothing touches the FortiGate identity cert until after every answer below is collected - so # it's one of the two wizards the plan named for the first back-nav rollout. Company (above) is # resolved eagerly, outside this array, purely so $companyStem is a fixed value the later steps' # prompt text can safely reference (a plain scriptblock literal already closes over THIS # function's own local variables lazily by reference - no need for anything more than that). # # 2026-09-15, real live bug at a client: each step below used to end in `.GetNewClosure()`. # GetNewClosure() detaches a scriptblock into its OWN private session state that only chains up # to GLOBAL scope (plus its captured variable snapshot) - it does NOT chain up through an # intermediate SCRIPT scope the way a plain scriptblock does. That's invisible when CA-Manager.ps1 # is dot-sourced or run directly, but the real shim (CA-Manager-Shim.ps1) launches the staged # dashboard via the CALL operator (`& $RealDashboard`, MiscTools\CA-Manager-Shim.ps1) - a genuine # extra scope boundary - and under that real invocation path, every GetNewClosure()'d step here # threw "Get-CAAnswerOrPrompt is not recognized" (a function from a DIFFERENT dot-sourced module, # CAInteractive.ps1, called from within the closure) even though Invoke-CAWizardSteps itself # (defined in that SAME file, called normally - no GetNewClosure()) resolved fine. Reproduced and # confirmed under real Windows PowerShell 5.1 with a 3-file repro matching this exact shape # (dot-sourced modules + `&`-wrapped entry script); removing GetNewClosure() fixed it in the same # repro. Every step below is a plain scriptblock now - none of them needed the "snapshot a # variable at definition time, immune to later mutation" behavior GetNewClosure() actually exists # for; they just needed to read THIS function's stable local variables at invocation time, which a # plain scriptblock already does correctly regardless of how the dashboard was launched. $steps = @( @{ Name = 'CertName'; Run = { param($CanGoBack) Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'Cert_CertificateName' -Prompt "FortiGate local certificate name (must match the CLIBuilder output)" -Remember -AllowBack:$CanGoBack } } @{ Name = 'PeerName'; Run = { param($CanGoBack) if ($peerPlanPreview.Applicable) { Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'Cert_PeerName' ` -Prompt "FortiGate PEER GROUP name (bundles the $($peerPlanPreview.Peers.Count) per-group peers below - must match the CLIBuilder output) [$($peerPlanPreview.PeerGroupName)]" ` -Default $peerPlanPreview.PeerGroupName -Remember -AllowBack:$CanGoBack } else { Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'Cert_PeerName' -Prompt "FortiGate user peer object name (must match the CLIBuilder output)" -Remember -AllowBack:$CanGoBack } } } @{ Name = 'PeerFilter'; Run = { param($CanGoBack) if ($issuingModel -ne 'SharedCAWithSubjectFilter') { return '' } # 2026-09-30: the staged answers already carry the filter (blank is a real answer - "accept # any cert from this CA"), so it's only asked when the field isn't there at all. if ($CAAnswers.PSObject.Properties['Cert_PeerSubjectFilter']) { return "$($CAAnswers.Cert_PeerSubjectFilter)" } Read-CAOptional -Prompt " FortiGate 'set subject' filter (shared CA) - blank to accept any cert from this CA" -AllowBack:$CanGoBack } } @{ Name = 'OutDir'; Run = { param($CanGoBack) # NSP.PKI: defaults to the PKI work folder's Responses\ (Administrators and SYSTEM only). $defaultOut = if (Get-Command Get-NSPToolWorkPath -ErrorAction SilentlyContinue) { Get-NSPToolWorkPath -Tool PKI -Kind Responses } else { "C:\Admin\Handoff\$companyStem" } $r = Read-CAOptional -Prompt " Output directory for the hand-back [$defaultOut]" -AllowBack:$CanGoBack if ((Test-CABackSignal $r) -or -not [string]::IsNullOrWhiteSpace($r)) { $r } else { $defaultOut } } } ) $wiz = Invoke-CAWizardSteps -Steps $steps $peerFilter = $wiz.PeerFilter $outDir = $wiz.OutDir $fgTemplate = if (-not [string]::IsNullOrWhiteSpace($CAAnswers.CA_TemplateFortiGate)) { "$($CAAnswers.CA_TemplateFortiGate)" } else { 'FortiGate' } if (Get-Command Resolve-VPNCertTemplateName -ErrorAction SilentlyContinue) { $fgTemplate = Resolve-VPNCertTemplateName -Name $fgTemplate } # --- FortiGate identity cert: reuse the one from the last hand-back, or make a new one ---------- # 2026-09-30, per the maintainer: re-running 13 must not force a new FortiGate cert. Warn-and-continue on # a CN mismatch / expiry / revocation - the tech decides. $fg = $null $fgPfxBase64 = $null $previous = Get-CAHandoffIdentity -OutputDir $outDir -CompanyStem $companyStem if ($previous) { $revoked = if ($previous.SerialNumber) { Test-CAHandoffCertRevoked -SerialNumber $previous.SerialNumber } else { $null } $warnings = @(Get-CAHandoffIdentityWarnings -Identity $previous -CertName "$($CAAnswers.Cert_CertificateName)" -Revoked ([bool]$revoked)) Write-Host "" Write-Host " FortiGate identity certificate from the last hand-back:" -ForegroundColor Cyan Write-Host (" {0} thumbprint {1} expires {2:yyyy-MM-dd} ({3})" -f $previous.Subject, $previous.Thumbprint, $previous.NotAfter, $(if ($previous.Kind -eq 'PFX') { 'PFX' } else { 'signed from a FortiGate CSR' })) foreach ($w in $warnings) { Write-Host " WARNING: $w" -ForegroundColor Yellow } $choice = ([string](Read-Host " [Enter] Reuse it [N] New certificate (CSR / issued cert / generate)")).Trim() if ($choice -notmatch '^(?i)n(ew)?$') { $fg = $previous $fgPfxBase64 = $previous.PfxBase64 Write-Host " Reusing it - no request or approval needed." -ForegroundColor Green } } if (-not $fg) { Write-Host "" Write-Host " FortiGate identity cert - give ONE of:" -ForegroundColor Cyan Write-Host " - a CSR generated on the FortiGate (.csr / .req) -> submitted here, key stays on" -ForegroundColor Gray Write-Host " the gate; required for a machine-type / admin-only template." -ForegroundColor Gray Write-Host " FortiGate: config vpn certificate local / edit <name> / (generate) -> download the .csr" -ForegroundColor DarkGray Write-Host " - an already-issued cert (.cer / .crt / .pem) -> wrapped into the hand-back as-is" -ForegroundColor Gray Write-Host " - blank -> CA-Manager generates a keypair + PFX" -ForegroundColor Gray $inPath = ([string](Read-CAOptional -Prompt " Path (blank = generate a PFX)")).Trim('"', ' ') if ([string]::IsNullOrWhiteSpace($inPath)) { # The SAN FQDN only matters for a cert generated here, so it's only asked on this path. $fgFqdn = Read-CAOptional -Prompt " FortiGate FQDN for the cert SAN (optional, e.g. vpn.$companyStem.com)" $fg = New-CAFortiGateIdentityCert -CAAnswers $CAAnswers -OutputDir $outDir -CASanitizedName $sanitized -FortiGateFqdn $fgFqdn } elseif (Test-Path $inPath) { # CSR markers win; otherwise anything that parses as a cert (DER or PEM) is an issued cert. if (-not (Test-CAIsCsr -Path $inPath) -and (Read-CAX509 -Path $inPath)) { $fg = Import-CAFortiGateIssuedCert -CertPath $inPath -OutputDir $outDir -FileStem "${companyStem}_FortiGate" } else { $caFqdnLocal = try { [System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName } catch { $env:COMPUTERNAME } $caConfigDefault = "$caFqdnLocal\$($Status.CACommonName)" $caConfigForCsr = Read-Host " CA config to submit to (`"<server-fqdn>\<CA name>`") [$caConfigDefault]" if ([string]::IsNullOrWhiteSpace($caConfigForCsr)) { $caConfigForCsr = $caConfigDefault } $fg = New-CAFortiGateCertFromCsr -CsrPath $inPath -Template $fgTemplate -OutputDir $outDir -FileStem "${companyStem}_FortiGate" -CAConfig $caConfigForCsr } } else { Write-Host " Path not found: $inPath" -ForegroundColor Red Read-Host "`nPress Enter to return to the menu" | Out-Null; return } # Embed the PFX bytes (base64) in the JSON when one was generated, so the JSON alone travels. if ($fg -and $fg.PSObject.Properties['PfxPath'] -and $fg.PfxPath -and (Test-Path $fg.PfxPath)) { try { $fgPfxBase64 = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($fg.PfxPath)) } catch { Write-Host " WARNING: could not read the PFX to embed it in the hand-back JSON ($($_.Exception.Message)) - the loose .pfx file is still required." -ForegroundColor Yellow } } } $caCertPem = $null $caCertChain = @() $der = Get-CAOcspCACertBytes -CACommonName $Status.CACommonName if ($der) { $caCertPem = ConvertTo-CAPem -DerBytes $der -Label 'CERTIFICATE' # 2026-09-15, per the maintainer: "capture the whole chain... if it's subordinate, get the parent CA # cert too, make sure it's the latest cert." "Latest" is Get-CAOcspCACertBytes's own job # (already Sort-Object NotAfter -Descending against this CN) - reuse THAT resolved cert as the # chain-walk's starting point rather than re-deriving "latest" a second time. try { $issuingCert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new([byte[]]$der) $caCertChain = @(Get-CAFortiGateParentCertChain -IssuingCert $issuingCert) if ($caCertChain.Count) { Write-Host " Subordinate CA - capturing $($caCertChain.Count) parent cert(s) too: $(($caCertChain | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Gray } } catch { Write-Host " WARNING: could not walk the CA chain ($($_.Exception.Message)) - only the issuing CA's own cert will be in the hand-back." -ForegroundColor Yellow } } else { Write-Host " WARNING: could not read the CA cert - CACertPem left empty in the hand-back." -ForegroundColor Yellow } $caConfig = try { "$((Get-CAActiveConfigName))" } catch { $null } $resp = Get-CAResponseObject -CAAnswers $CAAnswers -CACommonName $Status.CACommonName ` -CAConfigString $caConfig -CASanitizedName $sanitized -CACertPem $caCertPem -CACertChain $caCertChain ` -FgCert $fg -PeerSubjectFilter $peerFilter -CAManagerVersion $CAManagerVersion -FortiGatePfxBase64 $fgPfxBase64 $written = Write-CAHandoffFiles -Response $resp -OutputDir $outDir Write-Host "" Write-Host " Hand-back:" -ForegroundColor Green Write-Host " $($written.JsonPath) <- copy this ONE file back (the PFX, if any, is embedded in it)" -ForegroundColor Green if ($fg -and $fg.PfxPath) { Write-Host " $($fg.PfxPath) (same PFX, loose copy; its password is in the JSON)" -ForegroundColor Gray } elseif ($fg -and $fg.PSObject.Properties['CertPath'] -and $fg.CertPath) { Write-Host " $($fg.CertPath) (signed cert; the gate keeps its own key)" -ForegroundColor Gray } Write-Host "" Write-Host " Next: copy the JSON into the client's Staging\<Abbrev>\Inbox\ folder on the build box, then re-run" -ForegroundColor Cyan # NSP.PKI: Inbox Write-Host " IPSec-MasterOrchestrator.ps1 Step 5. CLIBuilder renders the FortiGate CA/peer CLI from it." -ForegroundColor Cyan Write-Host " Re-run this menu any time: it offers to reuse the FortiGate cert above." -ForegroundColor Cyan Read-Host "`nPress Enter to return to the menu" | Out-Null } |