Private/CARenewalTest.ps1
|
<#
.SYNOPSIS CA Manager - menu option T. TameMyCerts renewal-idempotency test harness (TameMyCerts-PoC.md section 4). Folded into the shipped toolset (previously a standalone, zip-excluded script) so it travels with CAManager.zip like every other menu. Requires Modules\CACore.ps1, CAInteractive.ps1, and CATameMyCerts.ps1 (for Get-CATameMyCertsPlan) dot-sourced first. .DESCRIPTION Walks the "set validity short, walk every renewal path, certutil-dump + diff the Subject" checklist: backs up + shortens the chosen template's pKIExpirationPeriod/pKIOverlapPeriod, snapshots the test cert before/after each path, asserts the resulting Subject carries EXACTLY ONE correct `OU=<token>` RDN (TameMyCerts' Force=true guarantee - no duplicate, no drift), drives the AD-group safety-net pair, and can show recent TameMyCerts event-log hits. Restores the template when done. Deliberately does NOT guess `certreq -Renew`'s exact CLI flags (they vary by Windows build) - that step runs `certreq -Renew -?` for you to read and leaves the actual command to you; it still does the before/after cert diff. SAFETY: mutates a REAL template object's validity/overlap/minor-version and REAL AD group membership. Refuses to run while the dashboard is in DRY RUN (menu D) - that toggle doesn't gate anything in here, so pretending it does would be worse than just refusing outright. Every step is wrapped in try/catch so one failing action returns you to this menu instead of killing the whole dashboard session (found live 2026-09-10: an unhandled exception with no per-step trap took the entire session down over a single bad step). #> $script:CARenewalTestBackupDir = Join-Path $env:ProgramData 'NSP\CAManager' # -------------------------------------------------------------------------------------------------- # Template validity/overlap (pKIExpirationPeriod / pKIOverlapPeriod are little-endian 8-byte negative # FILETIME durations in 100ns units - the same encoding certtmpl.msc's Validity/Renewal period fields # write; standard, widely-published ADSI technique, unrelated to the certreq CLI syntax this module # deliberately does not guess elsewhere). # -------------------------------------------------------------------------------------------------- function ConvertTo-CARenewalPeriodBytes { <# .NOTES The leading comma is REQUIRED, not decorative - the same class of bug as the `(,$mod)` fix elsewhere in this file, via a different mechanism: PowerShell enumerates/flattens an array left as a function's unreturned last expression onto the output pipeline, and a caller capturing that back into a variable gets a re-boxed generic `System.Object[]`, NOT the original `System.Byte[]`. Found live 2026-09-10 on a client's CA server the hard way: the 2-hour test validity got silently stringified ("0 48 119 60 239 255 255 255", 27 ASCII bytes) instead of written as 8 raw bytes, because Set-CARenewalTemplateAttribute's `$Value -is [byte[]]` check saw an Object[] and took the wrong (string) write branch - the VALUE was correct the whole time, only its .NET type after passing through this function wasn't. `,(...)` forces a true single-element array so the one element (our real byte[]) survives pipeline unrolling intact instead of being re-boxed. #> param([Parameter(Mandatory)][double]$Hours) $ticks = [int64](-1 * $Hours * 3600 * 1e7) ,([BitConverter]::GetBytes($ticks)) } function ConvertFrom-CARenewalPeriodBytes { param([Parameter(Mandatory)][byte[]]$Bytes) $ticks = [BitConverter]::ToInt64($Bytes, 0) [math]::Round((-1 * $ticks) / 1e7 / 3600, 3) } function Get-CARenewalConfigNC { ([ADSI]'LDAP://RootDSE').Properties['configurationNamingContext'][0] } function Get-CARenewalTemplateAdsi { param([Parameter(Mandatory)][string]$TemplateCn) $configNC = Get-CARenewalConfigNC $obj = [ADSI]"LDAP://CN=$TemplateCn,CN=Certificate Templates,CN=Public Key Services,CN=Services,$configNC" if (-not $obj.distinguishedName) { throw "Template CN=$TemplateCn not found under Certificate Templates - check the exact CN (certutil -CATemplates), not the display name." } $obj } function Get-CARenewalTemplateOid { <# .SYNOPSIS The template's own msPKI-Cert-Template-OID - a unique, unambiguous string that appears VERBATIM in an issued cert's Template Information extension text (see Get-CARenewalTestCert's .NOTES for why matching on this instead of the CN/display name is the correct fix). #> param([Parameter(Mandatory)][string]$TemplateCn) $t = Get-CARenewalTemplateAdsi -TemplateCn $TemplateCn "$($t.Properties['msPKI-Cert-Template-OID'][0])" } function Get-CARenewalTemplateValidity { param([Parameter(Mandatory)][string]$TemplateCn) $t = Get-CARenewalTemplateAdsi -TemplateCn $TemplateCn [pscustomobject]@{ TemplateCn = $TemplateCn ValidityHours = ConvertFrom-CARenewalPeriodBytes ([byte[]]$t.Properties['pKIExpirationPeriod'][0]) OverlapHours = ConvertFrom-CARenewalPeriodBytes ([byte[]]$t.Properties['pKIOverlapPeriod'][0]) Revision = [int]$t.Properties['revision'][0] MinorRevision = [int]$t.Properties['msPKI-Template-Minor-Revision'][0] } } function Set-CARenewalTemplateAttribute { <# .SYNOPSIS Writes ONE attribute on a template object. Tries a raw LDAP modify via System.DirectoryServices.Protocols FIRST, falling back to the classic ADSI .Put()/.SetInfo() and then .Properties[name].Value/.CommitChanges() paths only if that throws. .NOTES Live-diagnosed on a client's CA server 2026-09-10 through a long process of elimination - object existence, AD delegation/ACL (confirmed via a real ObjectSecurity.Access dump: the account running this, a Domain Admins + Enterprise Admins member, has GenericAll), and overlap/validity write-order semantics were each independently ruled OUT with hard evidence, yet BOTH ADSI write mechanisms (.Put()/.SetInfo() and .Properties[name].Value/.CommitChanges()) kept failing identically with a bare "Unspecified error (0x80004005 E_FAIL)" - no useful detail, on every attribute, every write order, both APIs. The clincher: a RAW LDAP modify of the exact same attribute/value via System.DirectoryServices.Protocols.LdapConnection/ModifyRequest SUCCEEDED on the first try. So this is a genuine defect/quirk in the ADSI COM layer on that box (or an environment-specific ADSI bind-mode issue) - not AD, not permissions, not value semantics - and the fix is to bypass ADSI for the write entirely rather than keep guessing why it fails. The two ADSI paths stay as fallbacks (harmless, and might still work in some other environment) but are no longer trusted as primary. DirectoryAttributeModification.Add() only has byte[]/string/Uri overloads - a plain [int] (e.g. msPKI-Template-Minor-Revision) must be sent as its string form, matching how LDAP represents Integer-syntax attributes on the wire. `(,$mod)` is deliberate, not decorative: DirectoryAttributeModification is itself enumerable (CollectionBase), so a bare `@($mod)` FLATTENS it into its own held values instead of wrapping it as one array element - hit live while building this fix (ModifyRequest's constructor then tries to convert those raw values into DirectoryAttributeModification and throws). The leading comma forces a true one-element array without enumerating $mod. CORRECTION to the paragraph above: once pKIExpirationPeriod/pKIOverlapPeriod both started writing fine via the LDAP path, msPKI-Template-Minor-Revision KEPT failing (all 3 paths) with a DIFFERENT, more specific error - ERROR_DS_NO_ATTRIBUTE_OR_VALUE. Root cause: the attribute name used everywhere in this file used to be "msPKI-Template-Minor-Revision-Number" - THAT NAME DOES NOT EXIST. The real schema attribute is "msPKI-Template-Minor-Revision" (no "-Number" suffix) - confirmed by dumping $t.Properties.PropertyNames on the live object. The earlier "successful read" of this (wrong) name returning 0 was NEVER real data - ADSI's forgiving indexer let `.Properties['<nonexistent-name>'][0]` silently coerce to `[int]$null -> 0` instead of throwing, which is why the bug hid behind 3 separate write-path failures and multiple wrong theories (permissions, USN rollback, write ordering) before anyone actually checked whether the attribute NAME itself was real. Lesson: when ADSI write failures don't match documented AD semantics, verify exact attribute names against the live object's own PropertyNames FIRST, before reasoning about permissions/replication/ordering. #> param([Parameter(Mandatory)]$Adsi, [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)]$Value) $errs = New-Object System.Collections.Generic.List[string] try { Add-Type -AssemblyName System.DirectoryServices.Protocols -ErrorAction Stop # LdapDirectoryIdentifier turned out to be a dead end on this runtime - neither New-Object # ...($null) (ambiguous overload) nor the zero-arg ::new() (this runtime's DirectoryServices. # Protocols build has no such overload at all - "argument count: 0", found live 2026-09-10) # actually work here. LdapConnection also just accepts a plain domain-name STRING directly # (exactly what the manually-verified working diagnostic script used) - derive it from the DN # we already have (its trailing DC=x,DC=y components) instead of hardcoding a domain name. $domainFqdn = ([regex]::Matches("$($Adsi.distinguishedName)", 'DC=([^,]+)') | ForEach-Object { $_.Groups[1].Value }) -join '.' if ([string]::IsNullOrWhiteSpace($domainFqdn)) { throw "could not derive a domain FQDN from '$($Adsi.distinguishedName)' - no DC= components found" } $ldap = New-Object System.DirectoryServices.Protocols.LdapConnection($domainFqdn) $ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Negotiate $ldap.SessionOptions.ProtocolVersion = 3 $mod = New-Object System.DirectoryServices.Protocols.DirectoryAttributeModification $mod.Name = $Name $mod.Operation = [System.DirectoryServices.Protocols.DirectoryAttributeOperation]::Replace if ($Value -is [byte[]]) { $mod.Add([byte[]]$Value) | Out-Null } else { $mod.Add([string]$Value) | Out-Null } $req = New-Object System.DirectoryServices.Protocols.ModifyRequest("$($Adsi.distinguishedName)", (,$mod)) $ldap.SendRequest($req) | Out-Null return } catch { $detail = if ($_.Exception.Response) { "$($_.Exception.Response.ResultCode): $($_.Exception.Response.ErrorMessage)" } else { $_.Exception.Message } $errs.Add("LdapConnection/ModifyRequest: $detail") } try { $Adsi.Put($Name, $Value) $Adsi.SetInfo() Write-Host " (wrote '$Name' via the classic .Put()/.SetInfo() path - the LDAP path failed first, see above)" -ForegroundColor DarkYellow return } catch { $hr = if ($_.Exception.InnerException) { $_.Exception.InnerException.HResult } else { $_.Exception.HResult } $errs.Add((".Put()/.SetInfo(): 0x{0:X8} - {1}" -f $hr, $_.Exception.Message)) } try { $Adsi.Properties[$Name].Value = $Value $Adsi.CommitChanges() Write-Host " (wrote '$Name' via the .Properties[]/.CommitChanges() fallback - both other paths failed first, see above)" -ForegroundColor DarkYellow return } catch { $hr = if ($_.Exception.InnerException) { $_.Exception.InnerException.HResult } else { $_.Exception.HResult } $errs.Add((".Properties[]/.CommitChanges(): 0x{0:X8} - {1}" -f $hr, $_.Exception.Message)) } throw ("could not write '$Name' on '$($Adsi.distinguishedName)' by any of 3 write paths (LDAP + 2 ADSI):`n" + (($errs | ForEach-Object { " $_" }) -join "`n") + "`n Object existence, ACL/permissions, and value semantics have all been independently ruled out" + "`n as causes for this specific template before (see this function's .NOTES) - if you're hitting" + "`n this fresh on a DIFFERENT template/box, don't assume the same root cause; start over with the" + "`n actual LDAP-side error text above rather than re-deriving conclusions from the ADSI ones.") } function Set-CARenewalTemplateValidity { <# .NOTES pKIOverlapPeriod must stay STRICTLY LESS than pKIExpirationPeriod at all times, INCLUDING at every intermediate step while getting from the old pair to the new one - AD appears to reject a write that would (even momentarily) leave overlap >= validity (found live 2026-09-10 on a client's CA server: writing pKIExpirationPeriod down to a small test value FIRST, while the old large pKIOverlapPeriod was still in place, threw a bare E_FAIL from BOTH ADSI write paths - the ACL was confirmed fine, so this is the real cause, not permissions). Refuses ValidityHours <= OverlapHours outright (that target state can never be valid) and picks whichever write ORDER keeps the pair valid at every step: if the OLD validity is big enough to still exceed the NEW overlap, shrink overlap first, then validity; otherwise (growing back to bigger values, e.g. Restore) grow validity first, then overlap. #> param([Parameter(Mandatory)][string]$TemplateCn, [Parameter(Mandatory)][double]$ValidityHours, [Parameter(Mandatory)][double]$OverlapHours) if ($OverlapHours -ge $ValidityHours) { throw "OverlapHours ($OverlapHours) must be LESS than ValidityHours ($ValidityHours) - AD rejects an overlap >= validity combination outright, it's not a valid template state." } $t = Get-CARenewalTemplateAdsi -TemplateCn $TemplateCn $current = Get-CARenewalTemplateValidity -TemplateCn $TemplateCn $expBytes = ConvertTo-CARenewalPeriodBytes $ValidityHours $ovlBytes = ConvertTo-CARenewalPeriodBytes $OverlapHours if ($current.ValidityHours -gt $OverlapHours) { # old validity already exceeds the new (smaller) overlap - safe to shrink overlap first Set-CARenewalTemplateAttribute -Adsi $t -Name 'pKIOverlapPeriod' -Value $ovlBytes Set-CARenewalTemplateAttribute -Adsi $t -Name 'pKIExpirationPeriod' -Value $expBytes } elseif ($ValidityHours -gt $current.OverlapHours) { # new validity already exceeds the old overlap - safe to grow validity first Set-CARenewalTemplateAttribute -Adsi $t -Name 'pKIExpirationPeriod' -Value $expBytes Set-CARenewalTemplateAttribute -Adsi $t -Name 'pKIOverlapPeriod' -Value $ovlBytes } else { throw "No safe write order found: old (validity=$($current.ValidityHours)h, overlap=$($current.OverlapHours)h) -> new (validity=${ValidityHours}h, overlap=${OverlapHours}h) - both intermediate states would leave overlap >= validity. Pick a new-value pair closer to the current one, or apply the change in two smaller steps." } # Non-fatal: the validity/overlap change above (the actual point of this function) already # succeeded by the time we get here - a separate problem writing the minor-revision bump (found # live 2026-09-10 on a client's CA server: ERROR_DS_NO_ATTRIBUTE_OR_VALUE on this one specific attribute, # still under investigation) should warn, not undo/block that already-applied change. $minor = [int]$t.Properties['msPKI-Template-Minor-Revision'][0] try { Set-CARenewalTemplateAttribute -Adsi $t -Name 'msPKI-Template-Minor-Revision' -Value ($minor + 1) Write-Host " Template '$TemplateCn': validity -> ${ValidityHours}h, overlap -> ${OverlapHours}h, minor rev -> $($minor + 1)." -ForegroundColor Gray } catch { Write-Host " Template '$TemplateCn': validity -> ${ValidityHours}h, overlap -> ${OverlapHours}h (APPLIED)." -ForegroundColor Gray Write-Host " WARNING: minor-revision bump did NOT apply - $($_.Exception.Message)" -ForegroundColor Yellow Write-Host " Validity/overlap themselves are still live and testable; the version bump is a separate, secondary concern used by menu items 2/4 later." -ForegroundColor DarkYellow } } function Backup-CARenewalTemplateValidity { param([Parameter(Mandatory)][string]$TemplateCn) $backupPath = Join-Path $script:CARenewalTestBackupDir "TmcRenewalTest_$TemplateCn.json" if (Test-Path $backupPath) { Write-Host " Backup already exists at $backupPath - not overwriting (assuming a prior run's ORIGINAL values). Delete it by hand if this is wrong." -ForegroundColor DarkYellow return } New-Item -ItemType Directory -Path $script:CARenewalTestBackupDir -Force -ErrorAction SilentlyContinue | Out-Null $orig = Get-CARenewalTemplateValidity -TemplateCn $TemplateCn $orig | ConvertTo-Json | Set-Content -Path $backupPath -Encoding UTF8 Write-Host " Backed up original validity/overlap/minor-rev to $backupPath" -ForegroundColor Gray $orig } function Restore-CARenewalTemplateValidity { <# .NOTES Set-CARenewalTemplateValidity's own minor-revision bump is a side-effect (always current+1, marking that ITS OWN edit happened) - it's not a restore to the ORIGINAL captured value, and it never touches 'revision' (Major Version Number) at all. Since Step-CARenewalBumpTemplateVersion now also mutates 'revision' (found live 2026-09-10 that only a MAJOR version bump reliably forces reenrollment - see its .NOTES), Restore must explicitly force both back to their exact backed-up values, not just call Set-CARenewalTemplateValidity and assume that's sufficient. #> param([Parameter(Mandatory)][string]$TemplateCn) $backupPath = Join-Path $script:CARenewalTestBackupDir "TmcRenewalTest_$TemplateCn.json" if (-not (Test-Path $backupPath)) { Write-Host " No backup found at $backupPath - nothing to restore." -ForegroundColor Yellow; return } $orig = Get-Content $backupPath -Raw | ConvertFrom-Json Set-CARenewalTemplateValidity -TemplateCn $TemplateCn -ValidityHours $orig.ValidityHours -OverlapHours $orig.OverlapHours $t = Get-CARenewalTemplateAdsi -TemplateCn $TemplateCn # A backup file written before 'Revision' was added to this schema has no captured value - [int]$null # would be 0, a genuinely bad thing to write to a template's major version. Skip it, warn, rather # than silently corrupt 'revision' from an incomplete old backup. if ($orig.PSObject.Properties['Revision'] -and $null -ne $orig.Revision) { Set-CARenewalTemplateAttribute -Adsi $t -Name 'revision' -Value ([int]$orig.Revision) } else { Write-Host " WARNING: this backup predates 'Revision' (major version) tracking - leaving 'revision' as-is. Check/restore it by hand if menu 2/4 bumped it this session." -ForegroundColor Yellow } Set-CARenewalTemplateAttribute -Adsi $t -Name 'msPKI-Template-Minor-Revision' -Value ([int]$orig.MinorRevision) Remove-Item $backupPath -Force Write-Host " Restored '$TemplateCn' to its original validity ($($orig.ValidityHours)h) / overlap ($($orig.OverlapHours)h)$(if ($orig.PSObject.Properties['Revision']) { " / version ($($orig.Revision).$($orig.MinorRevision))" })." -ForegroundColor Green } function Step-CARenewalBumpTemplateVersion { <# .NOTES Bumps BOTH `revision` (the "Major Version Number" shown in a cert's Template Information extension) and `msPKI-Template-Minor-Revision` ("Minor Version Number") - NOT just the minor field alone, which is what this originally did. Found live 2026-09-10 on a client's CA server: bumping ONLY msPKI-Template-Minor-Revision (5 -> 7, real, confirmed on AD, CertSvc restart ruled out as a factor) did NOT trigger autoenrollment to reenroll an existing, still-valid cert on the next gpupdate+pulse - nothing happened, no error, no event log entry, repeatedly. Bumping `revision` (Major Version Number) INSTEAD did trigger it immediately. This matches Microsoft's own major/minor semantics for certificate templates: a minor bump signals a change that does NOT require existing holders to reenroll (e.g. ACL/ permission edits); only a major bump is treated as "this cert is now out of date, reenroll regardless of the renewal/overlap window." Bump both here so this function reliably forces reenrollment for both menu items that use it (4.2 "new key via version bump", 4.4 "superseded template") - both are testing "force reenrollment independent of the window", which needs `revision`, not just the minor field. #> param([Parameter(Mandatory)][string]$TemplateCn) $t = Get-CARenewalTemplateAdsi -TemplateCn $TemplateCn $major = [int]$t.Properties['revision'][0] $minor = [int]$t.Properties['msPKI-Template-Minor-Revision'][0] Set-CARenewalTemplateAttribute -Adsi $t -Name 'revision' -Value ($major + 1) Set-CARenewalTemplateAttribute -Adsi $t -Name 'msPKI-Template-Minor-Revision' -Value ($minor + 1) Write-Host " '$TemplateCn' version bumped to $($major + 1).$($minor + 1) (major.minor) - re-run CA-Manager menu 4 so the CA republishes it, then pulse the client." -ForegroundColor Gray } # -------------------------------------------------------------------------------------------------- # Cert lookup / subject verification - the actual pass/fail engine for every renewal path # -------------------------------------------------------------------------------------------------- $script:CARenewalCertLookupScript = { param([string]$TemplateOid, [string]$StoreLocation) Get-ChildItem "Cert:\$StoreLocation\My" -ErrorAction SilentlyContinue | Where-Object { $tplExt = $_.Extensions | Where-Object { $_.Oid.Value -in '1.3.6.1.4.1.311.21.7', '1.3.6.1.4.1.311.20.2' } $tplExt | ForEach-Object { $_.Format($true) } | Where-Object { $_ -match [regex]::Escape($TemplateOid) } } | Sort-Object NotBefore -Descending | ForEach-Object { # Only plain properties, deliberately - a remote Invoke-Command call deserializes the return # value, which strips live .NET methods (.Extensions, .Format(), etc.) from an X509Certificate2; # Test-CARenewalCertOuSubject only ever needs .Subject/.Thumbprint/.NotBefore, so returning a # flat pscustomobject keeps local and remote callers identical instead of needing 2 code paths. [pscustomobject]@{ Subject = $_.Subject; Thumbprint = $_.Thumbprint; NotBefore = $_.NotBefore } } } function Get-CARenewalTestCert { <# .SYNOPSIS Matches the Certificate Template Information (1.3.6.1.4.1.311.21.7) or the older Certificate Template Name (1.3.6.1.4.1.311.20.2) extension's formatted text against the template's OWN OID (msPKI-Cert-Template-OID, from Get-CARenewalTemplateOid) - NOT its CN or display name. .NOTES Originally matched against the template CN (e.g. "NSPIKEv2VPN") - WRONG. Found live 2026-09-10 on a client's CA server: `.Format($true)` embeds the template's DISPLAY NAME ("NSP-IKEv2-VPN", with hyphens), never the CN, so a CN match against that text can never succeed (the hyphens break substring continuity) - every cert lookup silently returned nothing, making every PASS/FAIL meaningless, even once the remoting fix (below) was in place and genuinely working. The template's own OID is unique and appears VERBATIM in that formatted text regardless of CN/display-name conventions, so it's the only reliable match key. .PARAMETER ComputerName When supplied, queries that machine's cert store via PowerShell remoting instead of the LOCAL one. Required for the realistic topology (CA-Manager runs on the CA; the actual test cert lives on a separate VPN client) - found live 2026-09-10 on a client's CA server: every "Cert before/after" check was silently checking the CA server's OWN cert store, which never has the test cert, making every PASS/FAIL in this harness meaningless until this was added. Needs WinRM reachable/trusted between this box and the target (domain-joined defaults just work via Kerberos). #> param([Parameter(Mandatory)][string]$TemplateOid, [ValidateSet('CurrentUser', 'LocalMachine')][string]$StoreLocation = 'CurrentUser', [string]$ComputerName) if ([string]::IsNullOrWhiteSpace($ComputerName)) { & $script:CARenewalCertLookupScript $TemplateOid $StoreLocation } else { try { Invoke-Command -ComputerName $ComputerName -ScriptBlock $script:CARenewalCertLookupScript -ArgumentList $TemplateOid, $StoreLocation -ErrorAction Stop } catch { throw "Could not query the cert store on '$ComputerName' via PowerShell remoting - $($_.Exception.Message)`n Confirm WinRM is enabled there (Enable-PSRemoting -Force, run on '$ComputerName') and reachable from this box (Test-WSMan -ComputerName '$ComputerName')." } } } function Test-CARenewalCertOuSubject { param([Parameter(Mandatory)]$Cert, [Parameter(Mandatory)][string]$ExpectedOu) $subject = $Cert.Subject $ouMatches = [regex]::Matches($subject, 'OU=([^,]+)') $pass = ($ouMatches.Count -eq 1) -and ($ouMatches[0].Groups[1].Value -eq $ExpectedOu) [pscustomobject]@{ Subject = $subject OuCount = $ouMatches.Count OuValues = @($ouMatches | ForEach-Object { $_.Groups[1].Value }) Pass = $pass Thumbprint = $Cert.Thumbprint NotBefore = $Cert.NotBefore } } function Add-CARenewalResult { param([System.Collections.Generic.List[object]]$Results, [string]$Step, [bool]$Pass, [string]$Detail) $Results.Add([pscustomobject]@{ Step = $Step; Pass = $Pass; Detail = $Detail; At = Get-Date }) } function Wait-CARenewalClientEvent { <# .SYNOPSIS Polls the VPN client's certificate lifecycle event log (over the same remoting used everywhere else) for up to -TimeoutSeconds, looking for anything timestamped after -Since, instead of blindly asking the operator to press Enter and hope the async enrollment finished. .NOTES The commands that trigger enrollment (gpupdate, certutil -pulse, certreq) are synchronous and return well before the AutoEnrollment client-side extension actually finishes acting on them - this closes that gap by watching for the real completion signal (an actual lifecycle event) instead of guessing at a fixed sleep. Falls back to nothing (caller does its own Read-Host) when no -ComputerName is given - there's no log to poll for a check against the local machine. #> param( [Parameter(Mandatory)][string]$ComputerName, [Parameter(Mandatory)][datetime]$Since, [int]$TimeoutSeconds = 45, [int]$PollIntervalSeconds = 3 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) Write-Host " Waiting up to ${TimeoutSeconds}s for a cert-lifecycle event on '$ComputerName'..." -ForegroundColor Gray -NoNewline while ((Get-Date) -lt $deadline) { try { $found = Invoke-Command -ComputerName $ComputerName -ScriptBlock { param($Since) Get-WinEvent -LogName 'Microsoft-Windows-CertificateServicesClient-Lifecycle-User/Operational' -MaxEvents 20 -ErrorAction SilentlyContinue | Where-Object { $_.TimeCreated -gt $Since } | Sort-Object TimeCreated | ForEach-Object { $action = try { ([xml]$_.ToXml()).Event.UserData.CertNotificationData.Action } catch { $null } [pscustomobject]@{ TimeCreated = $_.TimeCreated; Id = $_.Id; Action = $action } } } -ArgumentList $Since -ErrorAction Stop } catch { Write-Host "" Write-Host " (could not poll the event log remotely - $($_.Exception.Message))" -ForegroundColor DarkYellow return @() } if ($found) { Write-Host " done." -ForegroundColor Gray foreach ($f in $found) { Write-Host " Event: $($f.TimeCreated) Id=$($f.Id) Action=$($f.Action)" -ForegroundColor DarkGray } return @($found) } Write-Host "." -ForegroundColor Gray -NoNewline Start-Sleep -Seconds $PollIntervalSeconds } Write-Host "" Write-Host " No cert-lifecycle event seen within ${TimeoutSeconds}s - may still be pending, or genuinely nothing changed." -ForegroundColor Yellow return @() } function Invoke-CARenewalStep { <# Snapshot -> trigger the real action -> wait for the client to actually finish (event-log poll when remoting, a manual Read-Host gate otherwise) -> diff + verify. Shared engine behind every "walk this path, certutil -dump after" checklist bullet. #> param( # AllowEmptyCollection is required, not decorative - a Mandatory collection-typed parameter # otherwise rejects an empty List[object] outright ("Cannot bind argument... because it is an # empty collection"), and $results legitimately starts empty until the first step completes - # found live 2026-09-10 on a client's CA server on the very first Invoke-CARenewalStep call of a session. [Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.List[object]]$Results, [Parameter(Mandatory)][string]$StepName, [Parameter(Mandatory)][scriptblock]$Instructions, [Parameter(Mandatory)][string]$TemplateCn, [Parameter(Mandatory)][string]$TemplateOid, # the actual cert-store match key - see Get-CARenewalTestCert's .NOTES (matching on the CN/display name doesn't work) [Parameter(Mandatory)][string]$ExpectedOu, [string]$CertStore = 'CurrentUser', [string]$ClientComputerName, # blank = check the LOCAL machine's store (only correct if CA-Manager itself runs where the cert lands) [bool]$ExpectChange = $true # $false for the "removed from group -> should NOT get a fresh cert" safety-net check ) Write-Host "`n=== $StepName ===" -ForegroundColor Cyan $before = Get-CARenewalTestCert -TemplateOid $TemplateOid -StoreLocation $CertStore -ComputerName $ClientComputerName | Select-Object -First 1 Write-Host (" Cert before: {0}" -f $(if ($before) { "$($before.Thumbprint) (NotBefore $($before.NotBefore))" } else { '(none found)' })) -ForegroundColor Gray $stepStart = Get-Date & $Instructions if ([string]::IsNullOrWhiteSpace($ClientComputerName)) { Read-Host " Press Enter once that's done (and the client has actually attempted the enrollment/renewal)" | Out-Null } else { Wait-CARenewalClientEvent -ComputerName $ClientComputerName -Since $stepStart -TimeoutSeconds 45 | Out-Null } $after = Get-CARenewalTestCert -TemplateOid $TemplateOid -StoreLocation $CertStore -ComputerName $ClientComputerName | Select-Object -First 1 $changed = ($before.Thumbprint -ne $after.Thumbprint) if (-not $ExpectChange) { $pass = -not $changed $detail = if ($pass) { 'no new cert issued - consistent with a denied enrollment (confirm you also saw a denial, not silence, on the client)' } else { "a NEW cert was issued anyway ($($after.Thumbprint)) - the ACL removal did NOT block enrollment" } Write-Host (" {0}" -f $detail) -ForegroundColor $(if ($pass) { 'Green' } else { 'Red' }) Add-CARenewalResult -Results $Results -Step $StepName -Pass $pass -Detail $detail return } if (-not $after) { Write-Host " FAIL - no certificate found for template '$TemplateCn' after the step." -ForegroundColor Red Add-CARenewalResult -Results $Results -Step $StepName -Pass $false -Detail 'no cert found after the step' return } $subjTest = Test-CARenewalCertOuSubject -Cert $after -ExpectedOu $ExpectedOu $pass = $subjTest.Pass -and $changed $color = if ($pass) { 'Green' } else { 'Red' } Write-Host (" Cert after : {0} (changed: {1})" -f $after.Thumbprint, $changed) -ForegroundColor Gray Write-Host (" Subject : {0}" -f $subjTest.Subject) -ForegroundColor Gray Write-Host (" OU count : {0} (values: {1}; expected: {2})" -f $subjTest.OuCount, ($subjTest.OuValues -join ', '), $ExpectedOu) -ForegroundColor $color if (-not $changed) { Write-Host " NOTE: thumbprint didn't change - was this actually a renewal, or the same cert re-read?" -ForegroundColor DarkYellow } Write-Host (" => {0}" -f $(if ($pass) { 'PASS - single, correct OU=, and a new cert was actually issued' } else { 'FAIL - check for duplicate/drifted/missing OU or a no-op' })) -ForegroundColor $color Add-CARenewalResult -Results $Results -Step $StepName -Pass $pass -Detail $subjTest.Subject } # -------------------------------------------------------------------------------------------------- # Client-side pulse - now that -ClientComputerName remoting is proven live, drive certutil -pulse # itself instead of leaving it as a manual "go run this on the client" instruction. # -------------------------------------------------------------------------------------------------- function Step-CARenewalCertReqOnClient { <# .SYNOPSIS Runs `certreq -enroll -cert <thumbprint> Renew [ReuseKeys]` on the VPN client (the CONFIRMED, not guessed, positional syntax - certreq.exe has no top-level "-Renew" switch at all; see menu item 3's own comments for how this was determined live 2026-09-10) via a TIME-BOUNDED remote job, falling back to a manual instruction+prompt if no -ComputerName is set, if it doesn't finish within the timeout, or if the remote call fails outright. .NOTES Found live 2026-09-10 on a client's test VPN client machine that this SAME command intermittently pops an interactive prompt (signing cert/CSP-related) with nowhere to go over a non-interactive WinRM session - one pass hung and needed the operator to kill an orphaned certreq.exe by hand; the very next pass completed cleanly in seconds. Since it's not reliably safe OR reliably unsafe, this runs it as an -AsJob with a bounded Wait-Job timeout instead of either a blocking Invoke-Command (which can hang this whole dashboard session indefinitely, not just the client) or going fully manual (which throws away the common case where it just works). On timeout, the job is left running server-side (Invoke-Command jobs don't reliably kill a stuck remote process on stop) and control hands back to the operator to check/kill it on the client themselves. #> param([Parameter(Mandatory)][string]$Thumbprint, [switch]$ReuseKeys, [string]$StoreLocation = 'CurrentUser', [string]$ComputerName, [int]$TimeoutSeconds = 25) $keyArgs = if ($ReuseKeys) { @('Renew', 'ReuseKeys') } else { @('Renew') } $cmdText = "certreq -enroll -cert $Thumbprint $($keyArgs -join ' ')" + $(if ($StoreLocation -eq 'LocalMachine') { ' -machine' } else { '' }) if ([string]::IsNullOrWhiteSpace($ComputerName)) { Write-Host " Run this on the CLIENT: $cmdText" -ForegroundColor Yellow Read-Host " Press Enter once that's done" | Out-Null return } Write-Host " Running remotely on '$ComputerName' (up to ${TimeoutSeconds}s): $cmdText" -ForegroundColor Gray $job = $null try { $job = Invoke-Command -ComputerName $ComputerName -ScriptBlock { param($Thumbprint, $KeyArgs, $Machine) $argList = @('-enroll', '-cert', $Thumbprint) + $KeyArgs if ($Machine) { $argList += '-machine' } & certreq.exe @argList 2>&1 | Out-String } -ArgumentList $Thumbprint, $keyArgs, ($StoreLocation -eq 'LocalMachine') -AsJob -ErrorAction Stop if (Wait-Job -Job $job -Timeout $TimeoutSeconds) { $out = Receive-Job -Job $job -ErrorAction SilentlyContinue if ($out) { Write-Host " $(($out | Out-String).Trim())" -ForegroundColor DarkGray } } else { Write-Host " Still running after ${TimeoutSeconds}s - it may be stuck on an interactive prompt (signing cert/CSP selection) that has nowhere to go over remoting." -ForegroundColor Red Write-Host " Go check the client (Task Manager -> certreq.exe - answer the prompt or kill it), or just run it yourself: $cmdText" -ForegroundColor Yellow Read-Host " Press Enter once you've handled it on the client" | Out-Null } } catch { Write-Host " Could not run certreq remotely on '$ComputerName' - $($_.Exception.Message)" -ForegroundColor Red Write-Host " Run it manually on the client instead: $cmdText" -ForegroundColor Yellow Read-Host " Press Enter once that's done" | Out-Null } finally { if ($job) { Remove-Job -Job $job -Force -ErrorAction SilentlyContinue } } } function Step-CARenewalDeleteClientCert { <# .SYNOPSIS Deletes one cert (by thumbprint) from the VPN client's store via the same PS remoting already used for cert-store checks and pulsing, so item 4.5 (delete + re-issue fresh) doesn't require a manual certmgr.msc trip either. Falls back to a manual prompt when no -ComputerName was given. #> param([Parameter(Mandatory)][string]$Thumbprint, [string]$StoreLocation = 'CurrentUser', [string]$ComputerName) if ([string]::IsNullOrWhiteSpace($ComputerName)) { Write-Host " Delete it now on the CLIENT: Remove-Item Cert:\$StoreLocation\My\$Thumbprint (or via certmgr.msc)." -ForegroundColor Yellow Read-Host " Press Enter once you've deleted it" | Out-Null return } Write-Host " Deleting $Thumbprint remotely on '$ComputerName'..." -ForegroundColor Gray try { Invoke-Command -ComputerName $ComputerName -ScriptBlock { param($Thumbprint, $StoreLocation) $path = "Cert:\$StoreLocation\My\$Thumbprint" if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force; "deleted" } else { "not found - already gone?" } } -ArgumentList $Thumbprint, $StoreLocation -ErrorAction Stop | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } } catch { Write-Host " Could not delete $Thumbprint remotely on '$ComputerName' - $($_.Exception.Message)" -ForegroundColor Red Write-Host " Delete it manually instead: Remove-Item Cert:\$StoreLocation\My\$Thumbprint" -ForegroundColor Yellow Read-Host " Press Enter once you've deleted it" | Out-Null } } function Step-CARenewalPulseClient { <# .SYNOPSIS Runs `gpupdate /force` then `certutil -pulse` on the VPN client via the same PS remoting already used for cert-store checks, so the operator doesn't have to alt-tab to the client for every single step. Falls back to printing the manual instructions when no -ClientComputerName was given (local-only sessions). .NOTES gpupdate /force first is required, not just belt-and-suspenders - found live 2026-09-10 on a client's CA server: a bare certutil -pulse alone did not pick up a template's minor-version bump (item 4.2's whole point), because the autoenrollment client's view of which template version applies is refreshed via the Group Policy CSE, not by certutil -pulse itself. Running gpupdate first makes every step correct, including the plain-renewal ones (1, 4.5, 4.6a/b) where it's a no-op. #> param([string]$ComputerName) if ([string]::IsNullOrWhiteSpace($ComputerName)) { Write-Host " On the CLIENT: gpupdate /force, then certutil -pulse" -ForegroundColor Yellow return } Write-Host " Refreshing policy + pulsing autoenrollment remotely on '$ComputerName' (gpupdate /force, then certutil -pulse)..." -ForegroundColor Gray try { $out = Invoke-Command -ComputerName $ComputerName -ScriptBlock { gpupdate /force 2>&1 | Out-String; certutil -pulse 2>&1 | Out-String } -ErrorAction Stop if ($out) { Write-Host " $(($out | Out-String).Trim())" -ForegroundColor DarkGray } } catch { Write-Host " Could not run gpupdate/certutil -pulse remotely on '$ComputerName' - $($_.Exception.Message)" -ForegroundColor Red Write-Host " Run it manually on the client instead: gpupdate /force, then certutil -pulse" -ForegroundColor Yellow } } # -------------------------------------------------------------------------------------------------- # Event log (did TameMyCerts even see the request?) # -------------------------------------------------------------------------------------------------- function Get-CARenewalTameMyCertsEvents { param([datetime]$Since = (Get-Date).AddHours(-6)) Get-WinEvent -FilterHashtable @{ LogName = 'Application'; StartTime = $Since } -ErrorAction SilentlyContinue | Where-Object { $_.Message -match 'TameMyCerts' -or $_.ProviderName -match 'TameMyCerts' } | Select-Object TimeCreated, Id, ProviderName, LevelDisplayName, Message | Sort-Object TimeCreated } # -------------------------------------------------------------------------------------------------- # Group-change safety net # -------------------------------------------------------------------------------------------------- function Test-CARenewalUserHasPrivilegedGroup { <# .SYNOPSIS Read-only. Checks whether $TestUser is a DIRECT member of Domain Admins or Enterprise Admins - both typically carry GenericAll (and therefore implicit Enroll/AutoEnroll) on every certificate template by default AD CS delegation, entirely independent of any RADIUS/VPN group's own ACL. .NOTES Found live 2026-09-10 on a client's CA server: menu item 6 (group-removal safety net) kept showing "enrollment succeeded anyway" no matter how many times the test user was removed from PrimaryGroup - because that user (nspadmin) is ALSO a Domain Admins/Enterprise Admins member, and those groups' rights on the template have nothing to do with PrimaryGroup membership. Not a bug in the removal/ACL logic - the chosen test user just can't validly be denied this way. Only checks these two specific, extremely common groups by name - a pragmatic catch for the exact case hit live, not a full cross-reference of every group the user belongs to against the template's actual ACL. #> param([Parameter(Mandatory)][string]$TestUser) try { Import-Module ActiveDirectory -ErrorAction Stop $groups = @((Get-ADUser -Identity $TestUser -Properties MemberOf -ErrorAction Stop).MemberOf) $hits = @($groups | Where-Object { $_ -match '^CN=(Domain Admins|Enterprise Admins),' } | ForEach-Object { ($_ -split ',')[0] -replace '^CN=' }) [pscustomobject]@{ IsPrivileged = [bool]$hits.Count; MatchedGroups = $hits } } catch { [pscustomobject]@{ IsPrivileged = $false; MatchedGroups = @() } } } function Step-CARenewalRemoveFromGroup { param([Parameter(Mandatory)][string]$TestUser, [Parameter(Mandatory)][string]$Group) if (-not (Read-CAConfirm -Prompt "Remove '$TestUser' from '$Group' now?")) { return } Import-Module ActiveDirectory -ErrorAction Stop Remove-ADGroupMember -Identity $Group -Members $TestUser -Confirm:$false Write-Host " Removed. AD group membership is cached in the user's Kerberos ticket - on the CLIENT, run 'klist purge' (or log off/on) before the next enrollment attempt, or the old membership may still apply." -ForegroundColor Yellow } function Step-CARenewalAddToGroup { param([Parameter(Mandatory)][string]$TestUser, [Parameter(Mandatory)][string]$Group, [switch]$Confirm) if ($Confirm -and -not (Read-CAConfirm -Prompt "Add '$TestUser' to '$Group' now?")) { return } Import-Module ActiveDirectory -ErrorAction Stop Add-ADGroupMember -Identity $Group -Members $TestUser Write-Host " Added '$TestUser' to '$Group'. Same Kerberos-ticket caveat as removal applies." -ForegroundColor Yellow } # -------------------------------------------------------------------------------------------------- # session menu (the checklist walkthrough) # -------------------------------------------------------------------------------------------------- function Show-CARenewalResultsTable { param([Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.List[object]]$Results) if (-not $Results.Count) { Write-Host " (no steps run yet)" -ForegroundColor DarkGray; return } Write-Host "" Write-Host (" {0,-55} {1,-6} {2}" -f 'Step', 'Pass', 'Detail') -ForegroundColor DarkGray Write-Host (" " + ('-' * 100)) -ForegroundColor DarkGray foreach ($r in $Results) { Write-Host (" {0,-55} {1,-6} {2}" -f $r.Step, $(if ($r.Pass) { 'PASS' } else { 'FAIL' }), $r.Detail) -ForegroundColor $(if ($r.Pass) { 'Green' } else { 'Red' }) } } function Invoke-CARenewalTestSession { param( [Parameter(Mandatory)][string]$TemplateCn, [Parameter(Mandatory)][string]$ExpectedOu, [Parameter(Mandatory)][string]$TestUser, [string]$PrimaryGroup, [string]$AlternateGroup, [string]$AlternateOu, [string]$CertStore = 'CurrentUser', # Blank = check the LOCAL machine's cert store (only correct if CA-Manager itself runs on the # box holding the test cert). Set this to the VPN client's hostname for the realistic topology # (CA-Manager on the CA, test cert on a separate client) - found live 2026-09-10 on a client's CA server: # every before/after check was silently looking at the CA's OWN store, so every PASS/FAIL was # meaningless until this was wired up. Needs WinRM reachable/trusted to that client. [string]$ClientComputerName, [double]$TestValidityHours = 2, [double]$TestOverlapHours = 1 # must stay < TestValidityHours - see Set-CARenewalTemplateValidity's .NOTES ) $results = New-Object System.Collections.Generic.List[object] # Resolved ONCE - the template doesn't change mid-session, and every cert lookup below needs this # exact OID (not the CN/display name - see Get-CARenewalTestCert's .NOTES for why that never matched). $templateOid = Get-CARenewalTemplateOid -TemplateCn $TemplateCn Write-Host "`nTameMyCerts renewal-idempotency session - template '$TemplateCn' (OID $templateOid), expected OU '$ExpectedOu', user '$TestUser'" -ForegroundColor Cyan if ($ClientComputerName) { Write-Host "Checking the cert store on '$ClientComputerName' via PowerShell remoting (WinRM must be reachable/trusted there)." -ForegroundColor DarkGray } else { Write-Host "No -ClientComputerName set - checking the LOCAL machine's cert store. If the test cert actually lands on a separate client, every PASS/FAIL below will be meaningless." -ForegroundColor Yellow } while ($true) { Write-Host "" Write-Host " 0) Backup + shorten validity/overlap ($TestValidityHours h / $TestOverlapHours h) - do this FIRST" -ForegroundColor White Write-Host " 1) 4.1 Autoenrollment renewal - SAME key (pulse the client, no version bump)" Write-Host " 2) 4.2 Autoenrollment renewal - NEW key (bump minor version, then pulse the client)" Write-Host " 3) 4.3 certreq -Renew (manual) - shows 'certreq -Renew ?' help, you run the actual command" Write-Host " 4) 4.4 Superseded template - bump version again, re-publish via CA-Manager menu 4, pulse" Write-Host " 5) 4.5 Delete cert -> autoenroll re-issues fresh" Write-Host " 6) 4.6a Group safety net - remove from PrimaryGroup, expect enrollment DENIED" Write-Host " 7) 4.6b Group safety net - add to AlternateGroup, expect a clean enroll with a DIFFERENT OU" Write-Host " 8) 4.7 Show TameMyCerts event-log entries since a given time" Write-Host " T) Show results table so far" Write-Host " R) Restore template validity/overlap + re-add to PrimaryGroup, then exit" Write-Host " Q) Quit (leaves the template SHORTENED - remember to come back and Restore)" $choice = Read-Host "Choice" try { switch -Regex ($choice) { '^0$' { Backup-CARenewalTemplateValidity -TemplateCn $TemplateCn | Out-Null Set-CARenewalTemplateValidity -TemplateCn $TemplateCn -ValidityHours $TestValidityHours -OverlapHours $TestOverlapHours } '^1$' { Invoke-CARenewalStep -Results $results -StepName '4.1 Autoenroll renewal (same key)' -TemplateCn $TemplateCn -TemplateOid $templateOid -ExpectedOu $ExpectedOu -CertStore $CertStore -ClientComputerName $ClientComputerName -Instructions { Write-Host " (validity/overlap were shortened in step 0, so the renewal window should already be open)" -ForegroundColor DarkGray Step-CARenewalPulseClient -ComputerName $ClientComputerName } } '^2$' { if (Read-CAConfirm -Prompt "Bump '$TemplateCn' minor version now (forces a new-key reenrollment)?") { Step-CARenewalBumpTemplateVersion -TemplateCn $TemplateCn } Invoke-CARenewalStep -Results $results -StepName '4.2 Autoenroll renewal (new key / version bump)' -TemplateCn $TemplateCn -TemplateOid $templateOid -ExpectedOu $ExpectedOu -CertStore $CertStore -ClientComputerName $ClientComputerName -Instructions { Step-CARenewalPulseClient -ComputerName $ClientComputerName } } '^3$' { # certreq.exe has NO top-level "-Renew" switch - confirmed live 2026-09-10 (running # "-Renew -?" just dumps the full general help then errors "Unknown argument: # -Renew"). The real form, from that same help dump, is positional: # CertReq -Enroll -cert CertId [Options] Renew [ReuseKeys] # i.e. "-cert <thumbprint>", then the literal keyword "Renew", then optionally the # literal keyword "ReuseKeys" for a same-key renewal (its absence = new key). $curForRenew = Get-CARenewalTestCert -TemplateOid $templateOid -StoreLocation $CertStore -ComputerName $ClientComputerName | Select-Object -First 1 if (-not $curForRenew) { Write-Host " No current cert found for this template - run menu item 1 or 5 first to get one to renew." -ForegroundColor Yellow } else { $sameKeyPass = Read-CAConfirm -Prompt "Test the SAME-key pass now? (No = new-key pass)" -DefaultYes Invoke-CARenewalStep -Results $results -StepName "4.3 certreq -Renew (confirmed syntax, $(if ($sameKeyPass) { 'same key' } else { 'new key' }))" -TemplateCn $TemplateCn -TemplateOid $templateOid -ExpectedOu $ExpectedOu -CertStore $CertStore -ClientComputerName $ClientComputerName -Instructions { Step-CARenewalCertReqOnClient -Thumbprint $curForRenew.Thumbprint -ReuseKeys:$sameKeyPass -StoreLocation $CertStore -ComputerName $ClientComputerName } Write-Host " Repeat this menu item for the $(if ($sameKeyPass) { 'new' } else { 'same' })-key pass too." -ForegroundColor DarkGray } } '^4$' { if (Read-CAConfirm -Prompt "Bump '$TemplateCn' minor version again (supersede)?") { Step-CARenewalBumpTemplateVersion -TemplateCn $TemplateCn } Invoke-CARenewalStep -Results $results -StepName '4.4 Superseded template' -TemplateCn $TemplateCn -TemplateOid $templateOid -ExpectedOu $ExpectedOu -CertStore $CertStore -ClientComputerName $ClientComputerName -Instructions { Write-Host " Re-run CA-Manager menu 4 to republish the bumped template first." -ForegroundColor Yellow Read-Host " Press Enter once menu 1 has republished it" | Out-Null Step-CARenewalPulseClient -ComputerName $ClientComputerName } } '^5$' { $cur = Get-CARenewalTestCert -TemplateOid $templateOid -StoreLocation $CertStore -ComputerName $ClientComputerName | Select-Object -First 1 if (-not $cur) { Write-Host " No current cert found for this template - nothing to delete. Pulsing to issue a fresh one anyway." -ForegroundColor Yellow } Invoke-CARenewalStep -Results $results -StepName '4.5 Delete + re-issue fresh' -TemplateCn $TemplateCn -TemplateOid $templateOid -ExpectedOu $ExpectedOu -CertStore $CertStore -ClientComputerName $ClientComputerName -Instructions { if ($cur) { Step-CARenewalDeleteClientCert -Thumbprint $cur.Thumbprint -StoreLocation $CertStore -ComputerName $ClientComputerName } Step-CARenewalPulseClient -ComputerName $ClientComputerName } } '^6$' { if (-not $PrimaryGroup) { Write-Host " No PrimaryGroup set for this session - skipping." -ForegroundColor Yellow } else { $priv = Test-CARenewalUserHasPrivilegedGroup -TestUser $TestUser $okToRun = $true if ($priv.IsPrivileged) { Write-Host " WARNING: '$TestUser' is ALSO a member of $($priv.MatchedGroups -join ', ') - those typically carry" -ForegroundColor Red Write-Host " independent Enroll/AutoEnroll rights on EVERY template by default, regardless of PrimaryGroup" -ForegroundColor Red Write-Host " membership. Removing them from PrimaryGroup will very likely NOT deny enrollment - this test needs" -ForegroundColor Red Write-Host " a test user with NO other privileged group memberships to be meaningful (found live 2026-09-10)." -ForegroundColor Red $okToRun = Read-CAConfirm -Prompt "Proceed anyway?" } if ($okToRun) { Step-CARenewalRemoveFromGroup -TestUser $TestUser -Group $PrimaryGroup Invoke-CARenewalStep -Results $results -StepName '4.6a Removed from group - expect DENIAL' -TemplateCn $TemplateCn -TemplateOid $templateOid -ExpectedOu $ExpectedOu -CertStore $CertStore -ClientComputerName $ClientComputerName -ExpectChange $false -Instructions { Step-CARenewalPulseClient -ComputerName $ClientComputerName Write-Host " ALSO check the client's Applications and Services Logs > Microsoft > Windows >" -ForegroundColor Yellow Write-Host " CertificateServicesClient-CertEnroll log for an explicit denial (not just silence)." -ForegroundColor Yellow } } } } '^7$' { if (-not $AlternateGroup -or -not $AlternateOu) { Write-Host " No AlternateGroup/AlternateOu set for this session - nothing to verify." -ForegroundColor Yellow } else { Step-CARenewalAddToGroup -TestUser $TestUser -Group $AlternateGroup -Confirm Invoke-CARenewalStep -Results $results -StepName '4.6b Added to AlternateGroup - expect clean enroll, different OU' -TemplateCn $TemplateCn -TemplateOid $templateOid -ExpectedOu $AlternateOu -CertStore $CertStore -ClientComputerName $ClientComputerName -Instructions { Write-Host " (enrolling whichever template maps to $AlternateGroup)" -ForegroundColor DarkGray Step-CARenewalPulseClient -ComputerName $ClientComputerName } } } '^8$' { $hrs = Read-Host " Look back how many hours? [6]" if ([string]::IsNullOrWhiteSpace($hrs)) { $hrs = 6 } $ev = Get-CARenewalTameMyCertsEvents -Since (Get-Date).AddHours(-[double]$hrs) if ($ev) { $ev | Format-Table -AutoSize -Wrap | Out-Host } else { Write-Host " No TameMyCerts entries found in that window." -ForegroundColor Yellow } } '^[Tt]$' { Show-CARenewalResultsTable -Results $results } '^[Rr]$' { Restore-CARenewalTemplateValidity -TemplateCn $TemplateCn if ($PrimaryGroup) { Step-CARenewalAddToGroup -TestUser $TestUser -Group $PrimaryGroup } Show-CARenewalResultsTable -Results $results return } '^[Qq]$' { Write-Host " Exiting WITHOUT restoring - the template is still on the shortened test validity/overlap. Come back and pick R when you're done." -ForegroundColor DarkYellow Show-CARenewalResultsTable -Results $results return } default { Write-Host " Unrecognized choice." -ForegroundColor Red } } } catch { # A step throwing should never kill the whole dashboard session - print the real error # (incl. any COM/ADSI inner exception, which carries the actual HRESULT reason) and loop. Write-Host "`n ERROR in that step: $($_.Exception.Message)" -ForegroundColor Red if ($_.Exception.InnerException) { Write-Host " inner: $($_.Exception.InnerException.Message)" -ForegroundColor DarkRed } Write-Host " at: $($_.InvocationInfo.PositionMessage)" -ForegroundColor DarkGray } } } # -------------------------------------------------------------------------------------------------- # menu wrapper (dashboard entry point) # -------------------------------------------------------------------------------------------------- function Invoke-CAMenuRenewalTest { param($CAAnswers, $Status) Write-CAHeader "TameMyCerts renewal-idempotency test (PoC)" if (Get-CADryRun) { Write-Host " This is a live-testing tool - it mutates a REAL template's validity/overlap and REAL AD" -ForegroundColor Yellow Write-Host " group membership regardless of this dashboard's DRY RUN toggle, so it refuses to run" -ForegroundColor Yellow Write-Host " while DRY RUN is on (pretending that toggle protects you here would be worse than just" -ForegroundColor Yellow Write-Host " refusing). Press D on the main menu to switch to APPLY, then come back here." -ForegroundColor Yellow Read-Host "Press Enter to return to the menu" | Out-Null return } $plan = Get-CATameMyCertsPlan -CAAnswers $CAAnswers if (-not $plan.TemplatePolicies -or -not $plan.TemplatePolicies.Count) { Write-Host " No per-template TameMyCerts policies in the current plan - run menu 3 first." -ForegroundColor Yellow Read-Host "Press Enter to return to the menu" | Out-Null return } Write-Host "" Write-Host " Templates currently under TameMyCerts:" -ForegroundColor Gray for ($i = 0; $i -lt $plan.TemplatePolicies.Count; $i++) { $tp = $plan.TemplatePolicies[$i] Write-Host (" [{0}] {1,-24} OU={2,-24} group={3}" -f $i, $tp.TemplateCn, $tp.OuValue, $tp.UserGroupName) } $idxRaw = Read-Host "Pick the PRIMARY template to test [0]" $idx = if ([string]::IsNullOrWhiteSpace($idxRaw)) { 0 } else { [int]$idxRaw } if ($idx -lt 0 -or $idx -ge $plan.TemplatePolicies.Count) { Write-Host " Out of range." -ForegroundColor Red; Read-Host "Press Enter to return" | Out-Null; return } $primary = $plan.TemplatePolicies[$idx] $alt = $null if ($plan.TemplatePolicies.Count -gt 1) { $altIdxRaw = Read-Host "Pick an ALTERNATE template for the group-change safety net (blank to skip)" if (-not [string]::IsNullOrWhiteSpace($altIdxRaw)) { $altIdx = [int]$altIdxRaw if ($altIdx -ge 0 -and $altIdx -lt $plan.TemplatePolicies.Count -and $altIdx -ne $idx) { $alt = $plan.TemplatePolicies[$altIdx] } else { Write-Host " Invalid/duplicate alternate index - skipping the group safety-net step." -ForegroundColor Yellow } } } $testUser = Read-Host "Pilot test user (sAMAccountName)" if ([string]::IsNullOrWhiteSpace($testUser)) { Write-Host " A test user is required." -ForegroundColor Red; Read-Host "Press Enter to return" | Out-Null; return } $certStoreChoice = Read-Host "Cert store to inspect - CurrentUser or LocalMachine [CurrentUser]" $certStore = if ($certStoreChoice -match '(?i)^LocalMachine$') { 'LocalMachine' } else { 'CurrentUser' } # Blank = check THIS box's (the CA's) own cert store, which only has the test cert if CA-Manager is # somehow running on the same machine the user enrolls from - not the realistic topology. Give the # VPN client's hostname to check IT instead, over PowerShell remoting. $clientComputerName = Read-Host "VPN client's hostname to check for the test cert over PS remoting (blank = check THIS machine instead)" if (-not [string]::IsNullOrWhiteSpace($clientComputerName)) { try { Test-WSMan -ComputerName $clientComputerName -ErrorAction Stop | Out-Null Write-Host " '$clientComputerName' answered WinRM - remoting should work." -ForegroundColor Gray } catch { Write-Host " WARNING: could not reach '$clientComputerName' over WinRM right now ($($_.Exception.Message))." -ForegroundColor Yellow Write-Host " Enable-PSRemoting -Force on that machine if it hasn't been done, then re-check. Proceeding anyway - every cert check below will fail clearly until this is fixed." -ForegroundColor Yellow } } # AD rejects overlap >= validity outright (see Set-CARenewalTemplateValidity's .NOTES - found live # 2026-09-10 as a bare E_FAIL from ADSI, NOT a permissions issue) - validate here, up front, rather # than let a bad pair fail deep inside the write with a cryptic COM error. Defaults are 2h/1h (not # 2h/2h) for the same reason - overlap must stay strictly less, even at the default. $validityRaw = Read-Host "Test validity hours [2]" $validityHours = if ([string]::IsNullOrWhiteSpace($validityRaw)) { 2 } else { [double]$validityRaw } while ($true) { $overlapRaw = Read-Host "Test overlap hours - must be LESS than validity [1]" $overlapHours = if ([string]::IsNullOrWhiteSpace($overlapRaw)) { 1 } else { [double]$overlapRaw } if ($overlapHours -lt $validityHours) { break } Write-Host " Overlap ($overlapHours h) must be less than validity ($validityHours h) - AD won't accept that combination. Try again." -ForegroundColor Red } Invoke-CARenewalTestSession -TemplateCn $primary.TemplateCn -ExpectedOu $primary.OuValue -TestUser $testUser ` -PrimaryGroup $primary.UserGroupName ` -AlternateGroup $(if ($alt) { $alt.UserGroupName } else { $null }) ` -AlternateOu $(if ($alt) { $alt.OuValue } else { $null }) ` -CertStore $certStore -ClientComputerName $clientComputerName -TestValidityHours $validityHours -TestOverlapHours $overlapHours } |