Private/CATameMyCerts.ps1
|
<#
.SYNOPSIS CA Manager - menu option S. Deploys the TameMyCerts policy module on the issuing CA and writes one per-template policy file that stamps a single static OU= RDN into every certificate the template issues, so a FortiGate `config user peer` can key on `set subject "OU=<group>"`. .DESCRIPTION TameMyCerts (https://github.com/Sleepw4lker/TameMyCerts, Apache-2.0) is an open-source ADCS policy module that *daisy-chains* the Windows Default policy module - templates without a policy XML behave exactly as before it was installed. For each RADIUS group pair we emit `<PolicyDirectory>\<TemplateCn>.xml` containing a permissive <Subject>/<SubjectAlternativeName> allow-list (see Get-CATameMyCertsPolicyXml's own .DESCRIPTION for why this is required - TameMyCerts denies every RDN/SAN type actually present in a request unless it's explicitly allow-listed, the moment ANY policy file matches the template) plus the actual rewrite rule: <OutboundSubject> <OutboundSubjectRule> <Field>organizationalUnitName</Field> <Value>SALES</Value> <Force>true</Force> </OutboundSubjectRule> </OutboundSubject> `Force=true` guarantees exactly one OU=<token> regardless of what the CA-built (or, for a MANUAL template, tech-supplied) subject already contained (renewal idempotency). Policy XMLs are hot-loaded - no certsvc restart to add/change one. Pinned to TameMyCerts community 1.8.1871.683, which requires the .NET 10 Desktop Runtime. The vendor install.ps1 registers the COM module, repoints PolicyModules\Active to TameMyCerts.Policy, copies the MS-default registry hive across, and restarts certsvc. Teardown (Remove-CATameMyCerts) re-runs it with -Uninstall so the hive is copied back and Active returns to the MS default. .NOTES Constants transcribed from the 1.8.1871.683 install.ps1 / user-guide - a wrong value is a one-line data fix. Pure Get-*Plan + read-only Test-* + Invoke-CAStep-routed engines, same shape as CAOcsp.ps1 / CACrlShare.ps1. #> $script:TmcVersion = '1.8.1871.683' $script:TmcZipUrl = "https://github.com/Sleepw4lker/TameMyCerts/releases/download/$($script:TmcVersion)/TameMyCerts_community_$($script:TmcVersion).zip" $script:TmcActiveProgId = 'TameMyCerts.Policy' $script:TmcDefaultProgId = 'CertificateAuthority_MicrosoftDefault.Policy' $script:TmcInstallDir = Join-Path $env:ProgramFiles 'TameMyCerts' $script:TmcCaRegRoot = 'HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration' $script:TmcDotNetMajor = 10 $script:TmcDotNetUrl = 'https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x64.exe' $script:TmcDotNetPage = 'https://dotnet.microsoft.com/download/dotnet/10.0' $script:TmcDefaultPolicyDir = 'C:\PolicyFiles' $script:TmcDefaultTemplatePrefix = 'NSP-IKEv2-' # -------------------------------------------------------------------------------------------------- # PURE helpers # -------------------------------------------------------------------------------------------------- function ConvertTo-CATameMyCertsToken { <# .SYNOPSIS Sanitizes an AD group name into an OU= token that is safe in a DN and predictable for a FortiOS substring match: keep [A-Za-z0-9._-], trim leading/trailing separators, cap at 64. #> param([AllowNull()][AllowEmptyString()][string]$Name) if ([string]::IsNullOrWhiteSpace($Name)) { return 'VPN' } $t = ($Name -replace '[^A-Za-z0-9._-]', '') $t = $t.Trim('._-') if ($t.Length -gt 64) { $t = $t.Substring(0, 64) } if ([string]::IsNullOrWhiteSpace($t)) { return 'VPN' } return $t } function Get-CATameMyCertsPolicyXml { <# .SYNOPSIS The full per-template policy XML that stamps exactly one static OU= RDN. .DESCRIPTION 2026-09-15, bug found in the field: every per-group MANUAL template (ENROLLEE_SUPPLIES_SUBJECT - the CSR carries a real, tech-supplied subject) was denied with CERT_E_INVALID_NAME the moment a policy file existed for it, even though the file only had an <OutboundSubject> rewrite and no <Subject> section. TameMyCerts's own Application-log entry named the exact cause: "The emailAddress/commonName/organizationalUnitName/domainComponent/dNSName field is not allowed." - this session's original assumption ("no <Subject> section => no inbound validation") was WRONG. TameMyCerts is deny-by-default on every RDN/SAN type PRESENT in the request: as soon as ANY policy file matches a template, every field the CSR actually carries must be explicitly allow-listed here or the request is denied outright - confirmed against the project's own examples/Sample_Offline_User_StaticSubject.xml at the EXACT pinned tag (1.8.1871.683). The per-group AUTO templates never hit this because CT_FLAG_SUBJECT_ALT_ REQUIRE_DIRECTORY_PATH means the CA builds their subject FROM AD, not from anything the enrollee submitted - nothing for TameMyCerts's inbound Subject/SAN validation to see. 2026-09-15 correction, same day: GitHub's examples/Sample_Offline_User_StaticSubject.xml at the "1.8.1871.683" TAG shows <Overwrite>, not <Force> - tried that live at a client's CA and the CA denied EVERY request with "Unknown XML element Overwrite" (a real strict-schema rejection, not a silent drop). So whatever build is actually installed on that CA does NOT match what's committed under that tag name in TameMyCerts's own repo (a docs/release drift, not something guessable from source alone) - <Force> is what that real, running module actually accepts, confirmed by two independent facts: it never once threw "Unknown XML element Force" across this whole debugging session, and the Auto per-group templates' OU stamp was already confirmed live-correct at a client site. Reverted to <Force>. Whether <Force> truly REPLACES a pre-existing OU RDN (vs. only inserting when absent) is still UNCONFIRMED either way for a MANUAL template's CSR, which does carry a real pre-existing OU chain (e.g. "OU=Test Main, OU=Corp, OU=Contoso, OU=DD TS Users") unlike the Auto templates - worth the maintainer actually inspecting one issued Manual-template cert's decoded subject to confirm only ONE OU RDN survived, next time he's testing this live. If it doesn't, this is where to look first. The allow-list rules below are intentionally permissive (Mandatory=false, "^.*$" patterns) - these are internal admin-issued/auto-enrolled VPN certs, not public-facing enrollment; the real security boundary is Windows's own ENROLL ACL on the template plus the OutboundSubject stamp itself, not a narrow per-value regex here. #> param([Parameter(Mandatory)][string]$OuValue) @" <?xml version="1.0" encoding="utf-8"?> <!-- Generated by CA-Manager (menu 3). Stamps OU=$OuValue into every certificate this template issues. --> <CertificateRequestPolicy xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <Subject> <SubjectRule> <Field>commonName</Field> <Mandatory>false</Mandatory> <MaxOccurrences>1</MaxOccurrences> <MaxLength>128</MaxLength> <Patterns><Pattern><Expression>^.*$</Expression></Pattern></Patterns> </SubjectRule> <SubjectRule> <Field>organizationalUnitName</Field> <Mandatory>false</Mandatory> <MaxOccurrences>10</MaxOccurrences> <MaxLength>64</MaxLength> <Patterns><Pattern><Expression>^.*$</Expression></Pattern></Patterns> </SubjectRule> <SubjectRule> <Field>domainComponent</Field> <Mandatory>false</Mandatory> <MaxOccurrences>10</MaxOccurrences> <MaxLength>64</MaxLength> <Patterns><Pattern><Expression>^.*$</Expression></Pattern></Patterns> </SubjectRule> <SubjectRule> <Field>emailAddress</Field> <Mandatory>false</Mandatory> <MaxOccurrences>1</MaxOccurrences> <MaxLength>128</MaxLength> <Patterns><Pattern><Expression>^.*$</Expression></Pattern></Patterns> </SubjectRule> </Subject> <SubjectAlternativeName> <SubjectRule> <Field>dNSName</Field> <Mandatory>false</Mandatory> <MaxOccurrences>10</MaxOccurrences> <MaxLength>256</MaxLength> <Patterns><Pattern><Expression>^.*$</Expression></Pattern></Patterns> </SubjectRule> </SubjectAlternativeName> <OutboundSubject> <OutboundSubjectRule> <Field>organizationalUnitName</Field> <Value>$OuValue</Value> <Force>true</Force> </OutboundSubjectRule> </OutboundSubject> </CertificateRequestPolicy> "@ } function Get-CATameMyCertsPlan { <# .SYNOPSIS PURE. Resolves the install parameters plus one policy-file spec per RADIUS group pair. .PARAMETER CAAnswers The CAAnswers.json object. Reads CA_SubjectStampMode (gate), RadiusGroupPairs (the 1:1 template<->group set), CA_TemplateAuto / CA_AutoEnrollGroup (single-template fallback). #> param( $CAAnswers, [string]$PolicyDirectory, [string]$TemplatePrefix ) $mode = if ($CAAnswers -and $CAAnswers.PSObject.Properties['CA_SubjectStampMode'] -and -not [string]::IsNullOrWhiteSpace($CAAnswers.CA_SubjectStampMode)) { "$($CAAnswers.CA_SubjectStampMode)".Trim() } else { 'None' } $polDir = if (-not [string]::IsNullOrWhiteSpace($PolicyDirectory)) { $PolicyDirectory.Trim() } elseif ($CAAnswers -and -not [string]::IsNullOrWhiteSpace($CAAnswers.CA_TameMyCertsPolicyDir)) { "$($CAAnswers.CA_TameMyCertsPolicyDir)".Trim() } else { $script:TmcDefaultPolicyDir } $prefix = if (-not [string]::IsNullOrWhiteSpace($TemplatePrefix)) { $TemplatePrefix } elseif ($CAAnswers -and -not [string]::IsNullOrWhiteSpace($CAAnswers.CA_TameMyCertsTemplatePrefix)) { "$($CAAnswers.CA_TameMyCertsTemplatePrefix)" } else { $script:TmcDefaultTemplatePrefix } $policies = New-Object System.Collections.Generic.List[object] $pairs = @() if ($CAAnswers -and $CAAnswers.PSObject.Properties['RadiusGroupPairs'] -and $CAAnswers.RadiusGroupPairs) { $pairs = @($CAAnswers.RadiusGroupPairs) } if ($pairs.Count) { foreach ($p in $pairs) { $label = if ($p.PSObject.Properties['Label']) { "$($p.Label)" } else { '' } # UserGroupValue is the actual AD security group's real name (matches how # Get-CAPerGroupTemplateSpecs / CATemplates.ps1 resolves it for the ACL grant); # UserGroupName is a friendlier label used elsewhere for firewall-rule naming and is NOT # reliably a real AD group. The .GroupName / .UserGroupName fields below are named after # this object's own shape, not the source JSON field of the same name. $grp = if ($p.PSObject.Properties['UserGroupValue'] -and -not [string]::IsNullOrWhiteSpace($p.UserGroupValue)) { "$($p.UserGroupValue)" } elseif ($p.PSObject.Properties['UserGroupName']) { "$($p.UserGroupName)" } else { '' } $ou = if ($p.PSObject.Properties['CertSubjectOu'] -and -not [string]::IsNullOrWhiteSpace($p.CertSubjectOu)) { ConvertTo-CATameMyCertsToken $p.CertSubjectOu } else { ConvertTo-CATameMyCertsToken $(if ($grp) { $grp } else { $label }) } # DisplayName '<prefix><token>'; CN = that with non-alphanumerics stripped - MUST match # New-CAVpnTemplate / Get-CAPerGroupTemplateSpecs (CATemplates.ps1), since the policy file # is named after the template CN. $display = "$prefix$(ConvertTo-CATameMyCertsToken $(if ($label) { $label } else { $grp }))" $tcn = ($display -replace '[^A-Za-z0-9]', '') $policies.Add([pscustomobject]@{ GroupLabel = $label UserGroupName = $grp TemplateDisplayName = $display TemplateCn = $tcn OuValue = $ou FileName = "$tcn.xml" FilePath = (Join-Path $polDir "$tcn.xml") Xml = (Get-CATameMyCertsPolicyXml -OuValue $ou) }) # 2026-09-15, per the maintainer: "for any 'template' we generate, we also need to allow a manual # admin request against it... wire this in to TameMyCerts. Same flow." - a manually-approved # cert issued from this pair's own manual counterpart (Get-CAPerGroupManualTemplateSpec, # CATemplates.ps1 - DisplayName = "<this Auto template's DisplayName>-MANUAL") needs the # IDENTICAL OU=<token> stamp the Auto template gets, or it can't actually be used to test/ # troubleshoot that group's FortiGate peer-subject-filter match. TameMyCerts's own # <OutboundSubject Force="true"> rewrite operates purely by matching the REQUESTING # TEMPLATE's CN, independent of how the request was submitted (auto vs admin-approved # manual) - so this needs nothing more than a second policy file, same OU value, named # after the manual template's own CN. The manual spec's own ENROLLEE_SUPPLIES_SUBJECT flag # is UNCHANGED (an admin can still supply whatever CN they need for testing) - TameMyCerts's # Force=true rule still overrides just the OU RDN on top of that, same as it does for the # Auto template. "Templates without a matching policy file are left completely untouched" - # see Show-CATameMyCertsPlan's own note - so this is harmless even before/unless the manual # template has actually been created. $manualDisplay = "$display-MANUAL" $manualTcn = ($manualDisplay -replace '[^A-Za-z0-9]', '') $policies.Add([pscustomobject]@{ GroupLabel = $label UserGroupName = $grp TemplateDisplayName = $manualDisplay TemplateCn = $manualTcn OuValue = $ou FileName = "$manualTcn.xml" FilePath = (Join-Path $polDir "$manualTcn.xml") Xml = (Get-CATameMyCertsPolicyXml -OuValue $ou) }) } } else { # single-template fallback: stamp the master auto-enroll group's token onto CA_TemplateAuto. # CN = the display name with non-alphanumerics stripped (New-CAVpnTemplate does the same). $disp = if ($CAAnswers -and -not [string]::IsNullOrWhiteSpace($CAAnswers.CA_TemplateAuto)) { "$($CAAnswers.CA_TemplateAuto)" } else { 'IKEv2VPN-InternalUsers' } $tcn = ($disp -replace '[^A-Za-z0-9]', '') $ou = ConvertTo-CATameMyCertsToken $(if ($CAAnswers -and $CAAnswers.CA_AutoEnrollGroup) { "$($CAAnswers.CA_AutoEnrollGroup)" } else { 'IKEv2_MasterGroup' }) $policies.Add([pscustomobject]@{ GroupLabel = '(fallback)'; UserGroupName = $(if ($CAAnswers) { "$($CAAnswers.CA_AutoEnrollGroup)" } else { '' }) TemplateDisplayName = $disp TemplateCn = $tcn; OuValue = $ou; FileName = "$tcn.xml" FilePath = (Join-Path $polDir "$tcn.xml"); Xml = (Get-CATameMyCertsPolicyXml -OuValue $ou) }) } [pscustomobject]@{ Mode = $mode Applicable = ($mode -ieq 'TameMyCerts') Version = $script:TmcVersion ZipUrl = $script:TmcZipUrl ZipName = "TameMyCerts_community_$($script:TmcVersion).zip" InstallDir = $script:TmcInstallDir PolicyDirectory = $polDir TemplatePrefix = $prefix ActiveProgId = $script:TmcActiveProgId DefaultProgId = $script:TmcDefaultProgId CaRegRoot = $script:TmcCaRegRoot DotNetMajor = $script:TmcDotNetMajor DotNetUrl = $script:TmcDotNetUrl DotNetPage = $script:TmcDotNetPage TemplatePolicies = $policies.ToArray() # NB: @($list) inside a literal that also has a "$(...)" trips the PS7 binder } } function Show-CATameMyCertsPlan { param([Parameter(Mandatory)]$Plan) Write-Host "" Write-Host " TameMyCerts policy module (subject-stamp)" -ForegroundColor Cyan Write-Host (" Mode : {0}{1}" -f $Plan.Mode, $(if (-not $Plan.Applicable) { ' (CA_SubjectStampMode is not TameMyCerts - pilot: proceed anyway to test)' } else { '' })) -ForegroundColor $(if ($Plan.Applicable) { 'Gray' } else { 'DarkYellow' }) Write-Host (" Version : {0} ({1})" -f $Plan.Version, $Plan.ZipUrl) -ForegroundColor Gray Write-Host (" .NET requirement : Desktop Runtime {0}.x ({1})" -f $Plan.DotNetMajor, $Plan.DotNetPage) -ForegroundColor Gray Write-Host (" Install dir : {0}" -f $Plan.InstallDir) -ForegroundColor Gray Write-Host (" Policy directory : {0}" -f $Plan.PolicyDirectory) -ForegroundColor Gray Write-Host (" Active module -> : {0} (chains {1})" -f $Plan.ActiveProgId, $Plan.DefaultProgId) -ForegroundColor Gray Write-Host "" Write-Host (" {0,-32} {1,-24} {2}" -f 'Policy file', 'stamps', 'from group') -ForegroundColor DarkGray Write-Host (" " + ('-' * 92)) -ForegroundColor DarkGray foreach ($tp in $Plan.TemplatePolicies) { Write-Host (" {0,-32} OU={1,-21} {2}" -f $tp.FileName, $tp.OuValue, $(if ($tp.UserGroupName) { $tp.UserGroupName } else { $tp.GroupLabel })) -ForegroundColor Gray } Write-Host "" Write-Host " Templates without a matching policy file are left completely untouched." -ForegroundColor DarkGray } # -------------------------------------------------------------------------------------------------- # read-only probes # -------------------------------------------------------------------------------------------------- function Get-CATameMyCertsDotNetStatus { <# Read-only: is a .NET >=10 (WindowsDesktop) runtime present? #> $installed = $false $detail = 'dotnet not found on PATH' try { $rt = & dotnet --list-runtimes 2>$null if ($LASTEXITCODE -eq 0 -and $rt) { $wd = $rt | Where-Object { $_ -match '^Microsoft\.WindowsDesktop\.App\s+(\d+)\.' -and [int]$Matches[1] -ge $script:TmcDotNetMajor } if ($wd) { $installed = $true; $detail = ($wd | Select-Object -First 1) } else { $detail = "no Microsoft.WindowsDesktop.App >= $($script:TmcDotNetMajor).x (have: " + (($rt | Where-Object { $_ -match 'WindowsDesktop' }) -join '; ') + ")" } } } catch { $detail = "dotnet query failed: $($_.Exception.Message)" } [pscustomobject]@{ Installed = $installed; Detail = $detail } } function Test-CATameMyCerts { <# Read-only. Never mutates. #> param($Plan) $polDir = if ($Plan) { $Plan.PolicyDirectory } else { $script:TmcDefaultPolicyDir } $notes = New-Object System.Collections.Generic.List[string] $dn = Get-CATameMyCertsDotNetStatus $dllPresent = Test-Path (Join-Path $script:TmcInstallDir 'TameMyCerts.comhost.dll') $comReg = $false try { $comReg = [bool](Test-Path 'Registry::HKEY_CLASSES_ROOT\TameMyCerts.Policy') } catch {} $caCfg = $null; $activeModule = $null; $polDirReg = $null; $tmcFlags = $null; $caType = $null try { $caCfg = (Get-ItemProperty $script:TmcCaRegRoot -Name Active -ErrorAction Stop).Active } catch { $notes.Add('CA configuration key not found - run menu 3 on the CA box') } if ($caCfg) { try { $activeModule = (Get-ItemProperty "$script:TmcCaRegRoot\$caCfg\PolicyModules" -Name Active -ErrorAction Stop).Active } catch {} try { $polDirReg = (Get-ItemProperty "$script:TmcCaRegRoot\$caCfg\PolicyModules\$($script:TmcActiveProgId)" -Name PolicyDirectory -ErrorAction Stop).PolicyDirectory } catch {} try { $tmcFlags = (Get-ItemProperty "$script:TmcCaRegRoot\$caCfg\PolicyModules\$($script:TmcActiveProgId)" -Name TmcFlags -ErrorAction Stop).TmcFlags } catch {} try { $caType = (Get-ItemProperty "$script:TmcCaRegRoot\$caCfg" -Name CaType -ErrorAction Stop).CaType } catch {} } $activeIsTmc = ($activeModule -eq $script:TmcActiveProgId) $svcRunning = $false try { $svcRunning = ((Get-Service -Name certsvc -ErrorAction Stop).Status -eq 'Running') } catch {} $files = [ordered]@{} if ($Plan) { foreach ($tp in $Plan.TemplatePolicies) { $files[$tp.FileName] = (Test-Path (Join-Path $polDir $tp.FileName)) } } if (-not $dn.Installed) { $notes.Add(".NET Desktop Runtime $($script:TmcDotNetMajor).x missing - $($dn.Detail)") } if ($dllPresent -and -not $activeIsTmc) { $notes.Add("module files present but PolicyModules\Active is '$activeModule' - install did not complete / was reverted") } if ($activeIsTmc -and -not $polDirReg) { $notes.Add("Active is TameMyCerts but PolicyDirectory registry value is unset") } if ($null -ne $caType -and $caType -notin 0, 1) { $notes.Add("CaType=$caType - TameMyCerts supports Enterprise Root/Sub only") } [pscustomobject]@{ DotNetOk = $dn.Installed DotNetDetail = $dn.Detail DllPresent = $dllPresent ComRegistered = $comReg CaConfigName = $caCfg CaType = $caType ActiveModule = $activeModule ActiveIsTmc = $activeIsTmc PolicyDirRegistered = [bool]$polDirReg PolicyDirRegValue = $polDirReg TmcFlags = $tmcFlags CertSvcRunning = $svcRunning PolicyFiles = $files Working = ($dn.Installed -and $dllPresent -and $activeIsTmc -and [bool]$polDirReg -and $svcRunning) Notes = $notes.ToArray() } } function Show-CATameMyCertsTest { param([Parameter(Mandatory)]$Result, $Plan) Write-Host "" Write-Host " Current state" -ForegroundColor Cyan Write-Host (" .NET Desktop Runtime {0}.x : {1}" -f $script:TmcDotNetMajor, $(if ($Result.DotNetOk) { 'present' } else { "MISSING - $($Result.DotNetDetail)" })) -ForegroundColor $(if ($Result.DotNetOk) { 'Green' } else { 'Yellow' }) Write-Host (" Module files : {0}" -f $(if ($Result.DllPresent) { 'present' } else { 'not installed' })) -ForegroundColor $(if ($Result.DllPresent) { 'Green' } else { 'Yellow' }) Write-Host (" PolicyModules\Active : {0}" -f $(if ($Result.ActiveModule) { $Result.ActiveModule } else { '(unknown)' })) -ForegroundColor $(if ($Result.ActiveIsTmc) { 'Green' } else { 'Yellow' }) Write-Host (" PolicyDirectory (reg) : {0}" -f $(if ($Result.PolicyDirRegValue) { $Result.PolicyDirRegValue } else { '(unset)' })) -ForegroundColor $(if ($Result.PolicyDirRegistered) { 'Green' } else { 'Yellow' }) Write-Host (" certsvc : {0}" -f $(if ($Result.CertSvcRunning) { 'Running' } else { 'NOT running' })) -ForegroundColor $(if ($Result.CertSvcRunning) { 'Green' } else { 'Red' }) if ($Plan -and $Result.PolicyFiles.Count) { Write-Host " Policy files:" -ForegroundColor Gray foreach ($k in $Result.PolicyFiles.Keys) { Write-Host (" {0,-34} {1}" -f $k, $(if ($Result.PolicyFiles[$k]) { 'present' } else { 'missing' })) -ForegroundColor $(if ($Result.PolicyFiles[$k]) { 'Green' } else { 'DarkYellow' }) } } foreach ($n in $Result.Notes) { Write-Host (" ! $n") -ForegroundColor DarkYellow } Write-Host (" => {0}" -f $(if ($Result.Working) { 'Working' } else { 'Not fully configured yet' })) -ForegroundColor $(if ($Result.Working) { 'Green' } else { 'Yellow' }) } # -------------------------------------------------------------------------------------------------- # engines (all mutations via Invoke-CAStep => dry-run aware) # -------------------------------------------------------------------------------------------------- function Install-CATameMyCertsDotNet { <# .SYNOPSIS Downloads + silently installs the .NET Desktop Runtime the module needs. .NOTES Checks a stable, well-known cache path first (C:\ProgramData\NSP\CAManager\ windowsdesktop-runtime-10-x64.exe) so a CA box with no outbound route to aka.ms can be side-loaded once - same pattern as the PowerShellGet Save-Module workaround. -StagingDir is a fresh per-run GUID folder (Install-CATameMyCerts), so it is NOT a place to hand-drop a file for reuse across runs; the ProgramData path is. 2026-09-11, per the maintainer: this cache is now SELF-POPULATING, not manual-drop-only - after a real download succeeds, the file is copied into the same ProgramData\NSP\CAManager\ cache path (creating the folder if needed) before installing, so the SECOND box (or a re-run after a failed install) never re-downloads at all. A copy failure is non-fatal (warns, keeps going with the already-downloaded staging copy) - caching is a nicety, not something worth failing an otherwise-successful download over. On download failure, every attempted method's real error is surfaced (found live 2026-09-10 on a client's CA server: both curl.exe and Invoke-WebRequest failed against the aka.ms redirector with no diagnosable reason under the old bare try/catch - could be a redirector-specific block, DNS, TLS, or a general egress restriction on the CA box; the real error tells you which). #> param([Parameter(Mandatory)]$Plan, [string]$StagingDir = $env:TEMP) # Defensive - this function is called by Install-CATameMyCerts (which already creates $StagingDir) # but is also independently callable, and writing to a non-existent directory fails with a # misleading "could not download" error that isn't actually a network problem. if (-not (Test-Path -LiteralPath $StagingDir)) { New-Item -ItemType Directory -Path $StagingDir -Force -ErrorAction SilentlyContinue | Out-Null } $cachedCopy = Join-Path $env:ProgramData 'NSP\CAManager\windowsdesktop-runtime-10-x64.exe' $exe = Join-Path $StagingDir 'windowsdesktop-runtime-10-x64.exe' Invoke-CAStep -Description ".NET Desktop Runtime $($Plan.DotNetMajor).x - use a cached installer if present, else download + silent install (caching it for next time)" ` -Commands @( "if exists '$cachedCopy' use it directly (no download)", "else: curl.exe -L -o `"$exe`" `"$($Plan.DotNetUrl)`" (falls back to Invoke-WebRequest)", " then copy the successful download to '$cachedCopy' for future runs", "<installer> /install /quiet /norestart" ) ` -Action { $useExe = $exe if (Test-Path -LiteralPath $cachedCopy) { $useExe = $cachedCopy } else { $ok = $false $errs = New-Object System.Collections.Generic.List[string] try { $curlOut = & curl.exe -sS -L -o $exe $Plan.DotNetUrl 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { $errs.Add("curl.exe exit $LASTEXITCODE : $($curlOut.Trim())") } elseif ((Test-Path $exe) -and (Get-Item $exe).Length -gt 1mb) { $ok = $true } else { $errs.Add("curl.exe reported success but the file is missing/too small ($(if (Test-Path $exe) { (Get-Item $exe).Length } else { 0 }) bytes)") } } catch { $errs.Add("curl.exe threw: $($_.Exception.Message)") } if (-not $ok) { try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 Invoke-WebRequest -UseBasicParsing -Uri $Plan.DotNetUrl -OutFile $exe -MaximumRedirection 8 -ErrorAction Stop if ((Get-Item $exe).Length -gt 1mb) { $ok = $true } else { $errs.Add("Invoke-WebRequest wrote a too-small file") } } catch { $errs.Add("Invoke-WebRequest: $($_.Exception.Message)") } } if (-not $ok) { throw ("could not download the .NET runtime from $($Plan.DotNetUrl):`n" + (($errs | ForEach-Object { " $_" }) -join "`n") + "`n Side-load instead: on a box WITH internet, download the .NET $($Plan.DotNetMajor).0 Desktop Runtime (x64) from $($Plan.DotNetPage)," + "`n copy the installer to THIS box as '$cachedCopy' (create the folder if needed), then re-run menu 3.") } # Cache the good download for next time - non-fatal if this fails (e.g. permissions); # the install itself still proceeds off the staging copy either way. try { $cacheDir = Split-Path -Parent $cachedCopy if (-not (Test-Path -LiteralPath $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } Copy-Item -LiteralPath $exe -Destination $cachedCopy -Force Write-Host " Cached the downloaded installer to $cachedCopy for future runs." -ForegroundColor DarkGray } catch { Write-Host " WARNING: could not cache the installer to $cachedCopy ($($_.Exception.Message)) - will re-download next time." -ForegroundColor Yellow } } $p = Start-Process -FilePath $useExe -ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru if ($p.ExitCode -notin 0, 3010) { throw "runtime installer ($useExe) exit $($p.ExitCode)" } # The installer just persisted 'dotnet' onto the MACHINE (and possibly per-user) PATH, but # THIS already-running elevated process took its $env:PATH snapshot at its own startup and # never sees that change on its own - found live 2026-09-10 on a client's CA server: the install step # reported success, then the vendor install.ps1's own `Get-Command dotnet` check (which # runs in this SAME process, not a fresh one) still failed with ".NET 10 Runtime is not # installed! Aborting." Re-pull PATH from the registry so it resolves immediately, for both # our own re-check below and install.ps1's later invocation in this same process. $env:PATH = @([Environment]::GetEnvironmentVariable('PATH', 'Machine'), [Environment]::GetEnvironmentVariable('PATH', 'User')) -join ';' if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { throw "the .NET runtime installer reported success (exit $($p.ExitCode)) but 'dotnet' still isn't resolvable even after refreshing PATH from the registry - if the exit code was 3010, a reboot is pending and required; otherwise check $env:ProgramFiles\dotnet exists and PATH was actually updated." } "installed from $useExe (exit $($p.ExitCode))" } | Out-Null } function Install-CATameMyCerts { <# .SYNOPSIS Downloads the pinned TameMyCerts community zip, unblocks it, ensures the policy directory, and runs the vendor install.ps1 (which registers the COM module, repoints PolicyModules\Active, copies the MS-default hive across, and restarts certsvc). #> param([Parameter(Mandatory)]$Plan, [string]$StagingDir) if (-not $StagingDir) { $StagingDir = Join-Path $env:TEMP ("TmcInstall_" + [guid]::NewGuid().ToString('N')) } # Create it NOW, unconditionally (a scratch temp dir isn't a mutation worth dry-run-gating) - it # used to only get created inside the zip-download step further down, AFTER # Install-CATameMyCertsDotNet already needed it to exist to write the runtime installer into # (found live 2026-09-10: "Could not find a part of the path ...\TmcInstall_<guid>\..." - masqueraded # as a download/network failure when it was really just a missing directory). New-Item -ItemType Directory -Path $StagingDir -Force -ErrorAction SilentlyContinue | Out-Null # --- guards ------------------------------------------------------------------------------- if (-not (Get-Command certutil.exe -ErrorAction SilentlyContinue) -or -not (Test-Path $Plan.CaRegRoot)) { Write-Host " No certification authority detected on this box - run menu 3 on the issuing CA." -ForegroundColor Red return } $t0 = Test-CATameMyCerts -Plan $Plan if ($null -ne $t0.CaType -and $t0.CaType -notin 0, 1) { Write-Host " CaType=$($t0.CaType): TameMyCerts supports Enterprise Root / Enterprise Sub CAs only. Aborting." -ForegroundColor Red return } # --- .NET prereq ----------------------------------------------------------------------------- if (-not $t0.DotNetOk) { Install-CATameMyCertsDotNet -Plan $Plan -StagingDir $StagingDir } # --- download + extract -------------------------------------------------------------------- $zip = Join-Path $StagingDir $Plan.ZipName $ext = Join-Path $StagingDir 'tmc' Invoke-CAStep -Description "Download + extract TameMyCerts $($Plan.Version)" ` -Commands @( "New-Item -ItemType Directory -Force '$StagingDir'", "curl.exe -L -o '$zip' '$($Plan.ZipUrl)'", "Expand-Archive '$zip' -DestinationPath '$ext'", "Get-ChildItem '$ext' -Recurse | Unblock-File" ) ` -Action { New-Item -ItemType Directory -Path $StagingDir -Force -ErrorAction SilentlyContinue | Out-Null $ok = $false try { & curl.exe -L -s -o $zip $Plan.ZipUrl; if ((Test-Path $zip) -and (Get-Item $zip).Length -gt 50kb) { $ok = $true } } catch {} if (-not $ok) { try { Invoke-WebRequest -UseBasicParsing -Uri $Plan.ZipUrl -OutFile $zip -MaximumRedirection 8 -ErrorAction Stop; if ((Get-Item $zip).Length -gt 50kb) { $ok = $true } } catch {} } if (-not $ok) { throw "could not download $($Plan.ZipUrl)" } if (Test-Path $ext) { Remove-Item $ext -Recurse -Force -ErrorAction SilentlyContinue } Expand-Archive -Path $zip -DestinationPath $ext -Force Get-ChildItem -Path $ext -Recurse | Unblock-File -ErrorAction SilentlyContinue if (-not (Test-Path (Join-Path $ext 'install.ps1'))) { throw "install.ps1 not found after extract - archive layout changed?" } "extracted to $ext" } | Out-Null # --- policy directory -------------------------------------------------------------------- Invoke-CAStep -Description "Ensure policy directory '$($Plan.PolicyDirectory)'" ` -Commands @("New-Item -ItemType Directory -Force '$($Plan.PolicyDirectory)'") ` -Action { New-Item -ItemType Directory -Path $Plan.PolicyDirectory -Force | Out-Null; "ok" } | Out-Null # --- vendor installer (restarts certsvc itself) ----------------------------------------- $installPs1 = Join-Path $ext 'install.ps1' Invoke-CAStep -Description "Run vendor install.ps1 (registers COM, repoints PolicyModules\Active -> $($Plan.ActiveProgId), restarts certsvc)" ` -Commands @("& '$installPs1' -PolicyDirectory '$($Plan.PolicyDirectory)'") ` -Action { $r = & $installPs1 -PolicyDirectory $Plan.PolicyDirectory if (-not ($r -and $r.Success)) { throw "install.ps1 did not report success: $($r | Out-String)" } "$($r.Message)" } | Out-Null # --- verify ------------------------------------------------------------------------------ $t1 = Test-CATameMyCerts -Plan $Plan Show-CATameMyCertsTest -Result $t1 -Plan $Plan if (-not (Get-CADryRun) -and -not $t1.ActiveIsTmc) { Write-Host " WARNING: PolicyModules\Active is still '$($t1.ActiveModule)'. Check the CA event log and certsrv.msc." -ForegroundColor Yellow } } function Write-CATameMyCertsPolicies { <# Writes one <TemplateCn>.xml per plan entry into the policy directory. Hot-loaded - no restart. #> param([Parameter(Mandatory)]$Plan) Invoke-CAStep -Description "Ensure policy directory '$($Plan.PolicyDirectory)'" ` -Commands @("New-Item -ItemType Directory -Force '$($Plan.PolicyDirectory)'") ` -Action { New-Item -ItemType Directory -Path $Plan.PolicyDirectory -Force | Out-Null; "ok" } | Out-Null foreach ($tp in $Plan.TemplatePolicies) { $path = Join-Path $Plan.PolicyDirectory $tp.FileName Invoke-CAStep -Description "Write policy '$($tp.FileName)' (stamps OU=$($tp.OuValue))" ` -Commands @("Set-Content '$path' (<OutboundSubject> organizationalUnitName = $($tp.OuValue), Force=true)") ` -Action { [System.IO.File]::WriteAllText($path, $tp.Xml, (New-Object System.Text.UTF8Encoding($false))) "wrote $path" } | Out-Null } Write-Host "" Write-Host " Policy files are hot-loaded on the next request for each template - no certsvc restart needed." -ForegroundColor DarkGray } function Remove-CATameMyCerts { <# .SYNOPSIS Teardown. Re-downloads the pinned zip for its install.ps1, runs it with -Uninstall (copies the registry hive back to the MS default, repoints Active, unregisters COM, restarts certsvc), then optionally deletes the generated policy XMLs. Does NOT remove the .NET runtime. #> param([Parameter(Mandatory)]$Plan, [switch]$KeepPolicyFiles, [string]$StagingDir) if (-not $StagingDir) { $StagingDir = Join-Path $env:TEMP ("TmcRemove_" + [guid]::NewGuid().ToString('N')) } $zip = Join-Path $StagingDir $Plan.ZipName $ext = Join-Path $StagingDir 'tmc' Invoke-CAStep -Description "Download TameMyCerts $($Plan.Version) (for its uninstall script)" ` -Commands @("curl.exe -L -o '$zip' '$($Plan.ZipUrl)'", "Expand-Archive '$zip' -DestinationPath '$ext'") ` -Action { New-Item -ItemType Directory -Path $StagingDir -Force -ErrorAction SilentlyContinue | Out-Null $ok = $false try { & curl.exe -L -s -o $zip $Plan.ZipUrl; if ((Test-Path $zip) -and (Get-Item $zip).Length -gt 50kb) { $ok = $true } } catch {} if (-not $ok) { try { Invoke-WebRequest -UseBasicParsing -Uri $Plan.ZipUrl -OutFile $zip -MaximumRedirection 8 -ErrorAction Stop; $ok = $true } catch {} } if (-not $ok) { throw "could not download $($Plan.ZipUrl) - to revert by hand: certutil -setreg CA\PolicyModules\Active `"$($Plan.DefaultProgId)`" ; net stop certsvc ; net start certsvc" } if (Test-Path $ext) { Remove-Item $ext -Recurse -Force -ErrorAction SilentlyContinue } Expand-Archive -Path $zip -DestinationPath $ext -Force "ok" } | Out-Null $installPs1 = Join-Path $ext 'install.ps1' Invoke-CAStep -Description "Run vendor install.ps1 -Uninstall (Active -> $($Plan.DefaultProgId), restarts certsvc)" ` -Commands @("& '$installPs1' -Uninstall") ` -Action { $r = & $installPs1 -Uninstall if (-not ($r -and $r.Success)) { throw "uninstall did not report success: $($r | Out-String)" } "$($r.Message)" } | Out-Null if (-not $KeepPolicyFiles) { foreach ($tp in $Plan.TemplatePolicies) { $path = Join-Path $Plan.PolicyDirectory $tp.FileName Invoke-CAStep -Description "Delete policy file '$($tp.FileName)'" -Commands @("Remove-Item '$path'") ` -Action { if (Test-Path $path) { Remove-Item $path -Force }; "ok" } -ContinueOnError | Out-Null } } } # -------------------------------------------------------------------------------------------------- # menu wrapper # -------------------------------------------------------------------------------------------------- function Invoke-CATameMyCertsMenu { param($CAAnswers, $Status) Write-CAHeader "Subject-stamp policy module (TameMyCerts)" $polDir = Get-CAAnswerOrPrompt -CAAnswers $CAAnswers -Field 'CA_TameMyCertsPolicyDir' ` -Prompt "Policy directory for per-template XML [$($script:TmcDefaultPolicyDir)]" -Default $script:TmcDefaultPolicyDir -Remember if ([string]::IsNullOrWhiteSpace($polDir)) { $polDir = $script:TmcDefaultPolicyDir } $plan = Get-CATameMyCertsPlan -CAAnswers $CAAnswers -PolicyDirectory $polDir Show-CATameMyCertsPlan -Plan $plan $t = Test-CATameMyCerts -Plan $plan Show-CATameMyCertsTest -Result $t -Plan $plan Write-Host "" if (-not (Read-CAConfirm -Prompt "Proceed?" -DefaultYes)) { Read-Host "Press Enter to return to the menu" | Out-Null return } if (-not $t.Working -or -not $t.ActiveIsTmc) { Install-CATameMyCerts -Plan $plan } else { Write-Host " Module already active - skipping install, just (re)writing policy files." -ForegroundColor Gray } Write-CATameMyCertsPolicies -Plan $plan $t2 = Test-CATameMyCerts -Plan $plan Show-CATameMyCertsTest -Result $t2 -Plan $plan Write-Host "" Write-Host " Next: publish/enroll the templates named above; a `certutil -dump` of an issued cert" -ForegroundColor DarkGray Write-Host " should now show the OU= RDN, and a FortiGate 'config user peer / set subject `"OU=<token>`"' will match." -ForegroundColor DarkGray Read-Host "Press Enter to return to the menu" | Out-Null } |