Private/CATemplates.ps1

<#
.SYNOPSIS
    CA Manager - certificate templates + auto-enrollment umbrella group (dashboard menu options 1
    and 2). Requires Modules\CACore.ps1 (and, for the umbrella wizard, the ActiveDirectory module).
 
.DESCRIPTION
    Get-CAVpnTemplateSpec is PURE DATA - the four custom templates transcribed attribute-for-attribute
    from a real client's live CA (see Examples_Sources\ for the captured inventory). Every msPKI-* flag value here is a
    captured fact, not an assumption.
 
    ConvertTo-CAPkiPeriodBytes is the pure validity/overlap -> FILETIME byte[8] helper.
 
    Show-CATemplatePlan renders what would be created/published.
 
    Resolve-CAUmbrellaGroup is the "prompt for an existing master group, else create it and nest the
    VPN user groups" wizard (menu 5). AD group ops only - safe and stable.
 
    New-CAVpnTemplate / Publish-CATemplateToCA / Grant-CATemplateEnrollment are the CREATE mechanism.
    They are DELIBERATELY not implemented against a live directory yet - the exact route (raw LDAP
    under CN=Certificate Templates,..., vs PSPKI, vs an ldifde/certutil approach, plus OID allocation)
    is the one piece worth pinning down against a real fresh CA before committing code to it. The
    spec they'd consume (Get-CAVpnTemplateSpec) is ready; only the plumbing is pending.
#>


# ---------------------------------------------------------------------------
function ConvertTo-CAPkiPeriodBytes {
    <#
    .SYNOPSIS
        Converts a validity/overlap duration to the pKIExpirationPeriod / pKIOverlapPeriod form:
        an 8-byte little-endian NEGATIVE 100-nanosecond FILETIME interval.
    .PARAMETER Days
        Duration in days (365 = "1 year", 42 = "6 weeks", 14, 2, ...).
    #>

    param([Parameter(Mandatory)][int]$Days)
    # 1 day = 24*3600 seconds * 1e7 (100-ns units) = 864000000000
    $ticks = [int64]([int64]$Days * -864000000000)
    return [byte[]][System.BitConverter]::GetBytes($ticks)
}

# ---------------------------------------------------------------------------
function Get-CAVpnTemplateSpec {
    <#
    .SYNOPSIS
        The four custom templates, exactly as captured from a real client. Returns an array of specs; each:
          Key, DisplayName, SchemaVersion, MinimalKeySize, KeySpec, EkuOids[], KeyUsageHex,
          CertificateNameFlagHex, EnrollmentFlagHex, PrivateKeyFlagHex, ValidityDays, OverlapDays,
          MachineType (bool -> CT_FLAG_MACHINE_TYPE in `flags`),
          RaApplicationPoliciesRaw (v3+ CNG-params string for msPKI-RA-Application-Policies, or $null),
          EnrollPrincipals[] ('Umbrella' / 'DomainAndEnterpriseAdmins' / 'OcspResponderHost'),
          AutoEnrollPrincipals[], Notes
        DisplayName defaults come from the CA_Template* answer fields; a client prefix (e.g. some clients'
        own short abbreviation) is the tech's choice at create time.
    #>

    param(
        # 2026-09-15, per the maintainer: the shared default used to be "IKEv2VPN-CorpLAN"/"-MANUAL" - lifted
        # straight from a real client's own setup, not a generic starting point. "InternalUsers" is this
        # codebase's own established generic name for the primary IKEv2 VPN AD group (AD-Manager's own
        # scaffold creates "IKEv2_InternalUsers" - project_ad_manager.md) - only the DISPLAY NAME
        # changed here; every captured flag/schema value below is still verbatim from that client's real
        # templates, unaffected by this rename.
        [string]$AutoName      = 'IKEv2VPN-InternalUsers',
        [string]$ManualName    = 'IKEv2VPN-InternalUsers-MANUAL',
        [string]$FortiGateName = 'FortiGate',
        # NOT "OCSPResponseSigning" - that collides with the built-in v3 template. A distinct name
        # lets us create one WITH the ocsp-nocheck + no-SID-extension enrollment flags.
        [string]$OcspName      = 'NSP-OCSPResponseSigning',
        # SharedCAWithSubjectFilter (default) | DedicatedIssuingCA. Only affects the Auto template's
        # subject-name flags - see $autoNameFlag below.
        [string]$IssuingModel  = 'SharedCAWithSubjectFilter'
    )

    # Auto template subject-name flags.
    # Dedicated CA: reference-client-verbatim 0xA6000000 = DIRECTORY_PATH + REQUIRE_EMAIL + ALT_REQUIRE_EMAIL
    # + ALT_REQUIRE_UPN. Every cert from a dedicated issuing CA is a VPN cert, so the
    # FortiGate peer needs no subject filter - shape doesn't matter for routing.
    # Shared CA: 0x82000000 = DIRECTORY_PATH (0x80000000) + ALT_REQUIRE_UPN (0x02000000). The
    # cert's Subject DN becomes the enrollee's full LDAP path, so if VPN users are
    # parked under one OU subtree the FortiGate `config user peer` can gate on
    # `set subject "OU=<that OU>"` (substring). REQUIRE_EMAIL is dropped - a mail-less
    # account would otherwise fail enrollment - and the UPN stays in the SAN for the
    # EAP identity. This is the "OU in the subject" trick (the maintainer, 2026-09-08).
    $autoNameFlag = if ($IssuingModel -eq 'DedicatedIssuingCA') { '0xA6000000' } else { '0x82000000' }

    # The user templates (Auto / Manual) and FortiGate are schema v2 - matches a real client's live
    # IKEv2VPN-CorpLAN / -MANUAL, which auto-enroll in production. DefaultCsps is left EMPTY so the
    # enrolling client picks a working provider itself (a v2 template pinned to a CNG *KSP* only ->
    # CRYPT_E_NO_PROVIDER on the legacy enrollment path). A v4 template is NOT raw-ADSI-authorable:
    # its msPKI-RA-Application-Policies blob must also carry co-sign policy OIDs, and a v4 template
    # missing that is rejected by the CA policy module (CERTSRV_E_UNSUPPORTED_CERT_TYPE).
    #
    # OcspSigning is the exception: it MUST be schema v3. The Online Responder's "add revocation
    # configuration" wizard filters candidate signing templates and rejects anything that (a) is
    # schema < 3 or (b) has msPKI-RA-Application-Policies unset - a v2 or a bare-v3 template fails
    # enrollment with 0x80070490 "a template ... could not be retrieved". On a v3 template that attr
    # holds only CNG key parameters (algorithm / hash / key-protection SD / key-usage), NOT co-sign
    # OIDs, so raw ADSI CAN write it - it's a plain unicode string. RaApplicationPoliciesRaw below is
    # transcribed verbatim from the box's built-in OCSPResponseSigning (v3) template; the SD grants
    # the Online Responder service read on the signing key. MachineType ORs CT_FLAG_MACHINE_TYPE
    # (0x40) into `flags` - OCSP signers enroll as the computer account, and the wizard checks it.
    @(
        [pscustomobject]@{
            Key = 'Auto'; DisplayName = $AutoName
            SchemaVersion = 2; MinimalKeySize = 2048; KeySpec = 1; DefaultCsps = @()
            RaApplicationPoliciesRaw = $null; MachineType = $false
            EkuOids = @('1.3.6.1.5.5.7.3.2')                        # Client Authentication
            KeyUsageHex = '0xA000'                                  # digitalSignature + keyEncipherment
            CertificateNameFlagHex = $autoNameFlag                  # issuing-model-aware - see $autoNameFlag note above
            EnrollmentFlagHex = '0x00000029'                        # INCLUDE_SYMMETRIC + PUBLISH_TO_DS + AUTO_ENROLLMENT
            PrivateKeyFlagHex = '0x06060000'                        # non-exportable (captured verbatim)
            ValidityDays = 365; OverlapDays = 42
            EnrollPrincipals = @('Umbrella'); AutoEnrollPrincipals = @('Umbrella')
            Notes = 'Auto-enrolled user VPN cert. Only the umbrella group gets Enroll+AutoEnroll.'
        }
        [pscustomobject]@{
            Key = 'Manual'; DisplayName = $ManualName
            SchemaVersion = 2; MinimalKeySize = 2048; KeySpec = 1; DefaultCsps = @()
            RaApplicationPoliciesRaw = $null; MachineType = $false
            EkuOids = @('1.3.6.1.5.5.7.3.2')
            KeyUsageHex = '0xA000'
            CertificateNameFlagHex = '0x00000001'                   # ENROLLEE_SUPPLIES_SUBJECT
            EnrollmentFlagHex = '0x0000000B'                        # INCLUDE_SYMMETRIC + PEND_ALL_REQUESTS + PUBLISH_TO_DS
            PrivateKeyFlagHex = '0x01010010'                        # EXPORTABLE_KEY (captured verbatim)
            ValidityDays = 365; OverlapDays = 42
            EnrollPrincipals = @('DomainAndEnterpriseAdmins'); AutoEnrollPrincipals = @()
            Notes = 'Admin-approved, exportable. Request-VPNCert.ps1 / the test-cert suite use this.'
        }
        [pscustomobject]@{
            Key = 'FortiGate'; DisplayName = $FortiGateName
            SchemaVersion = 2; MinimalKeySize = 2048; KeySpec = 1; DefaultCsps = @()
            RaApplicationPoliciesRaw = $null; MachineType = $false
            EkuOids = @('1.3.6.1.5.5.7.3.1')                        # Server Authentication
            KeyUsageHex = '0xA000'
            CertificateNameFlagHex = '0x00000001'                   # ENROLLEE_SUPPLIES_SUBJECT (menu 13 supplies CN=<Cert_CertificateName>)
            EnrollmentFlagHex = '0x0000000A'                        # PEND_ALL_REQUESTS + PUBLISH_TO_DS (admin-approved)
            PrivateKeyFlagHex = '0x06060110'                        # + EXPORTABLE_KEY (0x10) - menu 13 generates the key CA-side and hands the FortiGate a PFX
            ValidityDays = 730; OverlapDays = 42
            EnrollPrincipals = @('DomainAndEnterpriseAdmins'); AutoEnrollPrincipals = @()
            Notes = 'The FortiGate''s own identity cert (Cert_CertificateName). CA-Manager menu 13 generates the keypair, gets it issued (admin-approved), and exports a PFX for the gate.'
        }
        [pscustomobject]@{
            Key = 'OcspSigning'; DisplayName = $OcspName
            SchemaVersion = 3; MinimalKeySize = 2048; KeySpec = 2; DefaultCsps = @()   # v3 required - see the comment above; v2/bare-v3 -> wizard 0x80070490
            EkuOids = @('1.3.6.1.5.5.7.3.9')                        # OCSP Signing
            KeyUsageHex = '0x8000'                                  # digitalSignature only
            CertificateNameFlagHex = '0x18000000'                   # DNS name from AD
            EnrollmentFlagHex = '0x00005020'                        # AUTO_ENROLLMENT + ADD_OCSP_NOCHECK + NO_SECURITY_EXTENSION
            PrivateKeyFlagHex = '0x06060000'
            MachineType = $true                                     # -> CT_FLAG_MACHINE_TYPE (0x40) in `flags`; responder enrolls as the computer account
            # Verbatim from the built-in OCSPResponseSigning (v3). Backtick-delimited; the SD grants
            # the Online Responder service GENERIC_READ on the signing key. Without this the OCSP
            # "add revocation configuration" wizard reports 0x80070490 "template could not be retrieved".
            RaApplicationPoliciesRaw = 'msPKI-Asymmetric-Algorithm`PZPWSTR`RSA`msPKI-Hash-Algorithm`PZPWSTR`SHA1`msPKI-Key-Security-Descriptor`PZPWSTR`D:P(A;;FA;;;BA)(A;;FA;;;SY)(A;;GR;;;S-1-5-80-3804348527-3718992918-2141599610-3686422417-2726379419)`msPKI-Key-Usage`DWORD`2`'
            ValidityDays = 14; OverlapDays = 2
            EnrollPrincipals = @('OcspResponderHost'); AutoEnrollPrincipals = @('OcspResponderHost')
            Notes = 'Short-lived, auto-renewed by the Online Responder. Enroll goes to the responder box''s own group/account.'
        }
    )
}

# ---------------------------------------------------------------------------
function Get-CAAdHocTemplatePurposes {
    <#
    .SYNOPSIS
        PURE. The purpose/EKU catalog the ad-hoc template wizard (menu 16, Invoke-CAMenuAdHocTemplate)
        offers - a lookup TABLE, deliberately, not a hardcoded single path. The maintainer, 2026-09-10: "we
        don't need the moon at this point, but the moon should fit in this room" - only the VPN/
        Client-Authentication case is wired up today (the one CA-Manager actually needs), but adding a
        second purpose later (web server, code signing, whatever comes up) is just a new entry here,
        not a rewrite of the wizard itself.
    #>

    @(
        [pscustomobject]@{
            Key         = 'VpnClientAuth'
            Label       = 'VPN / Client Authentication'
            EkuOids     = @('1.3.6.1.5.5.7.3.2')   # Client Authentication
            KeyUsageHex = '0xA000'                  # digitalSignature + keyEncipherment
        }
    )
}

# ---------------------------------------------------------------------------
function Get-CAAdHocTemplateSpec {
    <#
    .SYNOPSIS
        PURE. Builds a single Get-CAVpnTemplateSpec-shaped object for the ad-hoc template wizard -
        outside the RadiusGroupPairs-driven flow entirely (New-CAVpnTemplate/Grant-CATemplateEnrollment
        don't care which caller built the spec, so this reuses both unchanged). Admin-approved
        (ENROLLEE_SUPPLIES_SUBJECT + PEND_ALL_REQUESTS), schema v2, same crypto baseline as the
        Auto/Manual/FortiGate templates - the exact captured PrivateKeyFlagHex values from those (not
        an invented bitwise combination) depending on -ExportableKey.
    #>

    param(
        [Parameter(Mandatory)][string]$DisplayName,
        [Parameter(Mandatory)][string]$PurposeKey,
        [int]$ValidityDays = 365,
        [int]$OverlapDays = 42,
        [switch]$ExportableKey
    )
    $purpose = @(Get-CAAdHocTemplatePurposes) | Where-Object { $_.Key -eq $PurposeKey }
    if (-not $purpose) {
        throw "Unknown ad-hoc template purpose '$PurposeKey' - see Get-CAAdHocTemplatePurposes for the valid keys."
    }

    [pscustomobject]@{
        Key = 'AdHoc'; DisplayName = $DisplayName
        SchemaVersion = 2; MinimalKeySize = 2048; KeySpec = 1; DefaultCsps = @()
        RaApplicationPoliciesRaw = $null; MachineType = $false
        EkuOids = $purpose.EkuOids
        KeyUsageHex = $purpose.KeyUsageHex
        CertificateNameFlagHex = '0x00000001'                   # ENROLLEE_SUPPLIES_SUBJECT
        EnrollmentFlagHex = '0x0000000B'                        # INCLUDE_SYMMETRIC + PEND_ALL_REQUESTS + PUBLISH_TO_DS (admin-approved, same as Manual)
        PrivateKeyFlagHex = if ($ExportableKey) { '0x01010010' } else { '0x06060000' }   # exact captured Manual/Auto values, not an invented combination
        ValidityDays = $ValidityDays; OverlapDays = $OverlapDays
        EnrollPrincipals = @(); AutoEnrollPrincipals = @()   # the wizard grants Enroll itself, directly by name - no token indirection needed for a single one-off template
        Notes = "Ad-hoc $($purpose.Label) template, created outside the RadiusGroupPairs flow (menu 16)."
    }
}

# ---------------------------------------------------------------------------
function Get-CATemplateSpecForAnswers {
    <#
    .SYNOPSIS
        Resolves the 4 template DisplayNames from a CAAnswers object (falling back to the
        Get-CAVpnTemplateSpec defaults) and returns the spec array. Guards the OCSP name against the
        built-in 'OCSPResponseSigning' collision - a blank OR literal-'OCSPResponseSigning' answer is
        forced to the safe default.
    #>

    param($CAAnswers)
    $ov = @{}
    if ($CAAnswers.CA_TemplateAuto)      { $ov.AutoName      = $CAAnswers.CA_TemplateAuto }
    if ($CAAnswers.CA_TemplateManual)    { $ov.ManualName    = $CAAnswers.CA_TemplateManual }
    if ($CAAnswers.CA_TemplateFortiGate) { $ov.FortiGateName = $CAAnswers.CA_TemplateFortiGate }
    if ($CAAnswers.CA_IssuingModel -in @('SharedCAWithSubjectFilter', 'DedicatedIssuingCA')) {
        $ov.IssuingModel = $CAAnswers.CA_IssuingModel
    }
    $ocsp = $CAAnswers.CA_TemplateOcspSigning
    if ($ocsp -and $ocsp -ne 'OCSPResponseSigning') {
        $ov.OcspName = $ocsp
    } elseif ($ocsp -eq 'OCSPResponseSigning') {
        Write-Host " note: CA_TemplateOcspSigning is 'OCSPResponseSigning', which collides with the built-in" -ForegroundColor DarkYellow
        Write-Host " template - using 'NSP-OCSPResponseSigning' instead. (Fix CAAnswers.json to silence this.)" -ForegroundColor DarkYellow
    }

    $base = @(Get-CAVpnTemplateSpec @ov)

    # TameMyCerts subject-stamp model: swap the single shared Auto template for one Auto template per
    # RADIUS group pair, each ACL'd (Enroll+AutoEnroll) to just that group and each with a CN-only
    # subject (TameMyCerts menu 3 stamps OU=<group> on top - the user's real AD OU path never goes on
    # the wire). The base Auto is dropped so a user doesn't auto-enroll two VPN certs.
    #
    # 2026-09-15, per the maintainer (live, looking at a client's own menu 4 pick-list - "can we drop the
    # 'manual' one from CLIBuilder?... since we're autocreating manual ones anyway"): the single
    # shared Manual template is now ALSO redundant in this mode, the same way Auto already was -
    # every per-group Auto template gets its own dedicated -MANUAL counterpart on request
    # (Get-CAPerGroupManualTemplateSpec, the "Also add manual version(s)" opt-in prompt), so having
    # the CN-only per-group Manual templates AND one extra shared, non-scoped Manual template was
    # just noise on the pick-list, not a real additional capability. Dropped alongside Auto - Manual
    # stays in the base spec for a NON-TameMyCerts (shared-CA-with-subject-filter/dedicated) client,
    # where there IS no per-group alternative.
    $perGroup = @(Get-CAPerGroupTemplateSpecs -CAAnswers $CAAnswers)
    if ($perGroup.Count) {
        return @(@($base | Where-Object { $_.Key -notin @('Auto', 'Manual') }) + $perGroup)
    }
    return $base
}

function Get-CAPerGroupTemplateSpecs {
    <#
    .SYNOPSIS
        PURE. When CA_SubjectStampMode = 'TameMyCerts' and RadiusGroupPairs is populated, returns one
        Auto-template spec per group pair: schema v2, same crypto as the base Auto spec, DisplayName
        '<CA_TameMyCertsTemplatePrefix><sanitised label>' (CN = that with non-alphanumerics stripped,
        matching New-CAVpnTemplate), CN-only subject-name flag (0x42000000 = SUBJECT_REQUIRE_COMMON_NAME
        + SUBJECT_ALT_REQUIRE_UPN), and Enroll+AutoEnroll granted to the pair's own AD group
        (EnrollPrincipals = @('GroupSpecific'), GroupName carrying the DOMAIN\group). Empty array when
        the mode isn't TameMyCerts or there are no pairs.
    .NOTES
        The CN this produces MUST equal what CATameMyCerts.ps1 Get-CATameMyCertsPlan derives for the
        policy-file name - both strip non-alphanumerics from '<prefix><token>'.
    #>

    param($CAAnswers)

    $mode = if ($CAAnswers -and -not [string]::IsNullOrWhiteSpace($CAAnswers.CA_SubjectStampMode)) { "$($CAAnswers.CA_SubjectStampMode)".Trim() } else { 'None' }
    if ($mode -ne 'TameMyCerts') { return @() }

    $pairs = @()
    if ($CAAnswers -and $CAAnswers.PSObject.Properties['RadiusGroupPairs'] -and $CAAnswers.RadiusGroupPairs) { $pairs = @($CAAnswers.RadiusGroupPairs) }
    if (-not $pairs.Count) { return @() }

    $prefix = if ($CAAnswers -and -not [string]::IsNullOrWhiteSpace($CAAnswers.CA_TameMyCertsTemplatePrefix)) { "$($CAAnswers.CA_TameMyCertsTemplatePrefix)" } else { 'NSP-IKEv2-' }

    $out = New-Object System.Collections.Generic.List[object]
    foreach ($p in $pairs) {
        $label = if ($p.PSObject.Properties['Label']) { "$($p.Label)" } else { '' }
        # UserGroupValue is the actual AD security group's real name (what NPS/RADIUS matches
        # membership against); UserGroupName is a friendlier label used elsewhere for firewall-rule
        # naming and is NOT reliably a real AD group - found live 2026-09-10 on a client's CA server,
        # Grant-CATemplateEnrollment failed to resolve 'IKEv2_UserGroup' (UserGroupName) to a SID
        # because the actual AD group is 'IKEv2_InternalUsers' (UserGroupValue).
        $grp = if ($p.PSObject.Properties['UserGroupValue'] -and -not [string]::IsNullOrWhiteSpace($p.UserGroupValue)) { "$($p.UserGroupValue)" }
               elseif ($p.PSObject.Properties['UserGroupName']) { "$($p.UserGroupName)" }
               else { '' }
        $token = (($(if ($label) { $label } else { $grp })) -replace '[^A-Za-z0-9._-]', '').Trim('._-')
        if ([string]::IsNullOrWhiteSpace($token)) { continue }
        $display = "$prefix$token"
        $out.Add([pscustomobject]@{
            Key = "Group_$token"; DisplayName = $display; GroupName = $grp
            SchemaVersion = 2; MinimalKeySize = 2048; KeySpec = 1; DefaultCsps = @()
            RaApplicationPoliciesRaw = $null; MachineType = $false
            EkuOids = @('1.3.6.1.5.5.7.3.2')                         # Client Authentication
            KeyUsageHex = '0xA000'                                   # digitalSignature + keyEncipherment
            CertificateNameFlagHex = '0x42000000'                    # SUBJECT_REQUIRE_COMMON_NAME + SUBJECT_ALT_REQUIRE_UPN - CN-only, TameMyCerts adds OU=<token>
            EnrollmentFlagHex = '0x00000029'                         # INCLUDE_SYMMETRIC + PUBLISH_TO_DS + AUTO_ENROLLMENT (same as base Auto)
            PrivateKeyFlagHex = '0x06060000'                         # non-exportable
            ValidityDays = 365; OverlapDays = 42
            EnrollPrincipals = @('GroupSpecific'); AutoEnrollPrincipals = @('GroupSpecific')
            Notes = "Per-group auto-enroll VPN cert. Enroll -> '$grp' (AutoEnroll is separate now - menu 15, the enrollment gate); TameMyCerts menu 3 stamps OU=$token."
        })
    }
    return $out.ToArray()   # NB: @($list) trips the PS7 binder ("Argument types do not match")
}

# ---------------------------------------------------------------------------
function Get-CAPerGroupManualTemplateSpec {
    <#
    .SYNOPSIS
        PURE. Given one per-group Auto template spec (one row from Get-CAPerGroupTemplateSpecs), builds
        its admin-approved, exportable MANUAL counterpart - same crypto/EKU baseline, DisplayName
        suffixed "-MANUAL" (mirrors the existing shared IKEv2VPN-CorpLAN / -MANUAL pairing, just
        per-group instead of once). 2026-09-15, per the maintainer: "for any 'template' we generate, we also
        need to allow a manual admin request against it" - lets a tech manually request/troubleshoot
        ONE specific group's VPN cert shape (e.g. testing a particular client's own subject/OU requirements) without
        only having the single generic shared Manual template to fall back on for every group.
    .PARAMETER GroupSpec
        One entry from Get-CAPerGroupTemplateSpecs (Key like 'Group_<token>').
    .NOTES
        2026-09-15, per the maintainer ("wire this in to TameMyCerts. Same flow."): IS wired into TameMyCerts's
        own per-group OU-stamping policy - Get-CATameMyCertsPlan (CATameMyCerts.ps1) emits a SECOND
        policy XML per RadiusGroupPair, named after THIS spec's own CN ("<Auto DisplayName>-MANUAL"),
        stamping the identical OU=<token> the Auto template gets. TameMyCerts's <OutboundSubject
        Force="true"> rewrite matches purely by requesting-template CN, independent of how the request
        was submitted - so a manually-approved cert from this template gets the same OU stamp an
        auto-enrolled one would, without needing this spec's own CertificateNameFlagHex to change.
        ENROLLEE_SUPPLIES_SUBJECT is kept (below) specifically so an admin can still supply whatever CN
        they need to troubleshoot with - TameMyCerts's Force=true rule overrides just the OU RDN on top
        of that, same as it does for the Auto template.
    #>

    param([Parameter(Mandatory)]$GroupSpec)
    if ("$($GroupSpec.Key)" -notlike 'Group_*') {
        throw "Get-CAPerGroupManualTemplateSpec expects a per-group Auto spec (Key like 'Group_*') - got '$($GroupSpec.Key)'."
    }
    [pscustomobject]@{
        Key = "$($GroupSpec.Key)_Manual"; DisplayName = "$($GroupSpec.DisplayName)-MANUAL"; GroupName = $GroupSpec.GroupName
        SchemaVersion = 2; MinimalKeySize = 2048; KeySpec = 1; DefaultCsps = @()
        RaApplicationPoliciesRaw = $null; MachineType = $false
        EkuOids = @('1.3.6.1.5.5.7.3.2')                        # Client Authentication - same as the per-group Auto template
        KeyUsageHex = '0xA000'
        CertificateNameFlagHex = '0x00000001'                   # ENROLLEE_SUPPLIES_SUBJECT - same as the shared Manual template
        EnrollmentFlagHex = '0x0000000B'                        # INCLUDE_SYMMETRIC + PEND_ALL_REQUESTS + PUBLISH_TO_DS (admin-approved)
        PrivateKeyFlagHex = '0x01010010'                        # EXPORTABLE_KEY - captured value, same as the shared Manual template
        ValidityDays = 365; OverlapDays = 42
        EnrollPrincipals = @('DomainAndEnterpriseAdmins'); AutoEnrollPrincipals = @()
        Notes = "Admin-approved, exportable manual counterpart to '$($GroupSpec.DisplayName)' - for one-off/troubleshooting requests against this specific group's cert shape (e.g. Request-VPNCert.ps1 -TemplateName '$($GroupSpec.DisplayName)-MANUAL'). TameMyCerts stamps the SAME OU as the Auto template (menu 3) - same flow, just admin-approved."
    }
}

# ---------------------------------------------------------------------------
function Show-CATemplatePlan {
    <#
    .SYNOPSIS
        Prints what Create/Update templates (menu 4) would do, for the dry-run walkthrough.
    .PARAMETER Numbered
        2026-09-15, per the maintainer: menu 4 used to be all-or-nothing ("create/publish the N template(s)
        now?"). Prefixes each entry with "[N]" (matching $spec's own array order 1:1) so the caller can
        show this SAME detailed rendering as a pick-list instead of a second, redundant plain-name
        list - see Invoke-CAMenuTemplates's own selection step.
    #>

    param($CAAnswers, [switch]$Numbered)

    $spec = Get-CATemplateSpecForAnswers -CAAnswers $CAAnswers

    $umbrella = if ($CAAnswers.CA_AutoEnrollGroup) { $CAAnswers.CA_AutoEnrollGroup } else { '<CA_AutoEnrollGroup - not set>' }

    for ($__i = 0; $__i -lt $spec.Count; $__i++) {
        $t = $spec[$__i]
        Write-Host ""
        $numberPrefix = if ($Numbered) { "[$($__i + 1)] " } else { "" }
        Write-Host (" {0}Template: {1} (schema v{2})" -f $numberPrefix, $t.DisplayName, $t.SchemaVersion) -ForegroundColor White
        Write-Host (" key {0}-bit, KeySpec {1} EKU {2}" -f $t.MinimalKeySize, $t.KeySpec, ($t.EkuOids -join ', ')) -ForegroundColor Gray
        Write-Host (" KeyUsage {0} Name-Flag {1} Enrollment-Flag {2} PrivateKey-Flag {3}" -f $t.KeyUsageHex, $t.CertificateNameFlagHex, $t.EnrollmentFlagHex, $t.PrivateKeyFlagHex) -ForegroundColor Gray
        Write-Host (" validity {0}d / overlap {1}d" -f $t.ValidityDays, $t.OverlapDays) -ForegroundColor Gray
        $enroll = ($t.EnrollPrincipals | ForEach-Object { if ($_ -eq 'Umbrella') { $umbrella } elseif ($_ -eq 'DomainAndEnterpriseAdmins') { 'Domain Admins + Enterprise Admins' } elseif ($_ -eq 'OcspResponderHost') { 'OCSP responder host group' } elseif ($_ -eq 'GroupSpecific') { $t.GroupName } else { $_ } }) -join ', '
        $ae = if ($t.AutoEnrollPrincipals -contains 'Umbrella') { $umbrella } elseif ($t.AutoEnrollPrincipals -contains 'GroupSpecific') { $t.GroupName } elseif ($t.AutoEnrollPrincipals) { ($t.AutoEnrollPrincipals -join ', ') } else { '(none)' }
        Write-Host (" Enroll -> {0} AutoEnroll -> {1}" -f $enroll, $ae) -ForegroundColor Gray
        Write-Host (" {0}" -f $t.Notes) -ForegroundColor DarkGray
    }
    Write-Host ""
    Write-Host " Each would be created under CN=Certificate Templates,CN=Public Key Services,CN=Services,<configNC>" -ForegroundColor DarkGray
    Write-Host " then published to the CA (certutil -SetCATemplates +<name>)." -ForegroundColor DarkGray
}

# ---------------------------------------------------------------------------
function Resolve-CAUmbrellaGroup {
    <#
    .SYNOPSIS
        Menu 5 (2026-09-10 renumber - was menu 2) helper. Finds the auto-enrollment umbrella group
        (the ONE group that gets Enroll on the auto template at creation - every VPN user group
        nests into it; AutoEnroll itself is a separate, deliberate switch now, see menu 15). Prompts for an
        existing group name; if none exists, offers to create it and multi-select AD groups to nest
        into it. Multiple searches / multi-select supported. Returns the group's DN, or $null.
 
        See also: PushableTools\ADManager\ (menu 2, the VPN group/OU scaffold) - the fuller,
        OU-browsable tool for standing up group structure from scratch. This function's own inline
        create-a-group flow (a raw typed OU distinguishedName, no browser) stays as the quick path for
        "I just need one umbrella group and everything else already exists."
 
    .PARAMETER SuggestedName
        From CA_AutoEnrollGroup, if set.
    #>

    param([string]$SuggestedName)

    if (-not (Get-Command Get-ADGroup -ErrorAction SilentlyContinue)) {
        Write-Host " ActiveDirectory module not available - use dashboard menu 1 (Install RSAT / management" -ForegroundColor Red
        Write-Host " modules), or run this on a box with RSAT-AD-PowerShell, then re-launch CA-Manager." -ForegroundColor Red
        return $null
    }

    while ($true) {
        $name = Read-CANonEmpty -Prompt "Auto-enrollment umbrella group name" -CurrentValue $SuggestedName
        $existing = @(Get-ADGroup -Filter "Name -eq '$($name.Replace("'","''"))'" -ErrorAction SilentlyContinue)
        if ($existing.Count -eq 1) {
            Write-Host (" Found existing group: {0}" -f $existing[0].DistinguishedName) -ForegroundColor Green
            return $existing[0].DistinguishedName
        }

        Write-Host " No group named '$name' exists." -ForegroundColor Yellow
        Write-Host " Tip: for a full VPN group/OU scaffold (not just this one umbrella group), consider" -ForegroundColor DarkGray
        Write-Host " the AD-Manager tool instead (menu 2 there) - this wizard only creates a single" -ForegroundColor DarkGray
        Write-Host " group in an OU you type by hand." -ForegroundColor DarkGray
        if (-not (Read-CAConfirm -Prompt " Create it now?" -DefaultYes)) { return $null }

        $ouDn = Read-CANonEmpty -Prompt " OU to create the group in (distinguishedName)"
        $newGroupDn = Invoke-CAStep -Description "Create umbrella group '$name' in $ouDn" `
            -Commands @("New-ADGroup -Name '$name' -GroupScope Global -Path '$ouDn'") `
            -Action { (New-ADGroup -Name $name -GroupScope Global -Path $ouDn -PassThru).DistinguishedName }

        # nest the VPN user groups
        Write-Host ""
        Write-Host " Now nest the 'primary' VPN user groups into it. Search, pick, repeat; blank search to finish." -ForegroundColor Cyan
        $picked = New-Object System.Collections.Generic.List[string]
        while ($true) {
            $term = Read-Host " Group name search (blank to finish)"
            if ([string]::IsNullOrWhiteSpace($term)) { break }
            $hits = @(Get-ADGroup -Filter "Name -like '*$($term.Replace("'","''"))*'" -ErrorAction SilentlyContinue | Sort-Object Name)
            if (-not $hits) { Write-Host " (no matches)" -ForegroundColor Yellow; continue }
            for ($i = 0; $i -lt $hits.Count; $i++) { Write-Host (" {0}: {1}" -f $i, $hits[$i].Name) }
            $sel = Read-Host " Numbers to nest (comma-separated), or blank to skip"
            foreach ($n in ($sel -split '[,\s]+' | Where-Object { $_ -match '^\d+$' })) {
                $idx = [int]$n
                if ($idx -ge 0 -and $idx -lt $hits.Count -and $picked -notcontains $hits[$idx].DistinguishedName) {
                    $picked.Add($hits[$idx].DistinguishedName)
                    Write-Host (" + {0}" -f $hits[$idx].Name) -ForegroundColor Green
                }
            }
        }
        if ($picked.Count) {
            Invoke-CAStep -Description "Nest $($picked.Count) group(s) into '$name'" `
                -Commands ($picked | ForEach-Object { "Add-ADGroupMember -Identity '$name' -Members '$_'" }) `
                -Action { Add-ADGroupMember -Identity $name -Members $picked } | Out-Null
        }
        return $newGroupDn.Output
    }
}

# ---------------------------------------------------------------------------
# CREATE MECHANISM - raw System.DirectoryServices against the Configuration NC (no RSAT / PSPKI).
# Approach: allocate a template OID (msPKI-Enterprise-Oid object), CLONE the built-in "User" template
# as a known-good structural base (flags / pKICriticalExtensions / pKIMaxIssuingDepth), then override
# every value the spec dictates (schema version, EKU, key usage, validity, the three msPKI-*-Flag
# ints), publish via certutil -SetCATemplates, and set the Enroll/AutoEnroll DACL. Being iterated
# live against a client's CA server - the captured spec values are settled; only the plumbing here is new.
# ---------------------------------------------------------------------------

function Get-CAConfigNamingContext {
    return ([ADSI]"LDAP://RootDSE").Get('configurationNamingContext')
}

# ---------------------------------------------------------------------------
function Get-CAManualApprovalTemplates {
    <#
    .SYNOPSIS
        Read-only. Templates actually PUBLISHED on this CA (certutil -CATemplates) whose
        msPKI-Enrollment-Flag has CT_FLAG_PEND_ALL_REQUESTS (0x2) set - i.e. admin-approved / "Manual
        approval" templates, the shape menu 11's test-cert suite (and Request-VPNCert.ps1) issue
        against. 2026-09-15, per the maintainer: menu 11 used to take a free-typed template name (one default,
        the single shared IKEv2VPN-CorpLAN-MANUAL) - now that per-group manual templates exist
        (Get-CAPerGroupManualTemplateSpec, one per RadiusGroupPair), a tech needs to actually SEE
        what's published and pick from a numbered list instead of guessing/remembering exact names.
 
        Read via raw ADSI against the Configuration NC (same "no RSAT/PSPKI dependency" convention
        every other AD-template read/write in this file already uses), keyed off certutil's own
        published-name list (same parsing shape as Request-VPNCertCore.ps1's own
        Resolve-VPNCertTemplateName) so a template merely DEFINED in AD but never actually published to
        THIS CA is excluded - a tech should only ever be offered something they can actually issue from.
    .OUTPUTS
        Array of pscustomobject: Cn; DisplayName. Empty array if certutil reports nothing, or nothing
        published happens to be admin-approved.
    #>

    $out = & certutil.exe -CATemplates 2>&1 | Out-String
    if ([string]::IsNullOrWhiteSpace($out)) { return @() }
    $published = foreach ($line in ($out -split "`r?`n")) {
        if ($line -match '^\s*([A-Za-z0-9][\w.-]*):\s*(.+?)\s*--') {
            [pscustomobject]@{ Cn = $Matches[1].Trim(); DisplayName = $Matches[2].Trim() }
        }
    }
    if (-not $published) { return @() }

    $configNC = Get-CAConfigNamingContext
    $manual = New-Object System.Collections.Generic.List[object]
    foreach ($row in $published) {
        try {
            $t = [ADSI]"LDAP://CN=$($row.Cn),CN=Certificate Templates,CN=Public Key Services,CN=Services,$configNC"
            $flag = 0
            if ($t.Properties['msPKI-Enrollment-Flag'].Value) { $flag = [int]$t.Properties['msPKI-Enrollment-Flag'].Value }
            if (($flag -band 0x2) -eq 0x2) { $manual.Add($row) }
        } catch {
            # A published name certutil lists that doesn't resolve under the Templates container
            # (a built-in template stored elsewhere, a transient AD hiccup) - skip it silently rather
            # than fail the whole listing over one unreadable entry.
        }
    }
    return $manual.ToArray()
}

function ConvertFrom-CAKeyUsageHex {
    <#
    .SYNOPSIS
        '0xA000' -> [byte[]](0xA0, 0x00) - the pKIKeyUsage attribute's stored form.
    #>

    param([Parameter(Mandatory)][string]$Hex)
    $h = $Hex -replace '^0x', ''
    if ($h.Length % 2) { $h = "0$h" }
    $bytes = for ($i = 0; $i -lt $h.Length; $i += 2) { [Convert]::ToByte($h.Substring($i, 2), 16) }
    return [byte[]]$bytes
}

function ConvertFrom-CAHexToInt32 {
    <#
    .SYNOPSIS
        '0xA6000000' -> -1509949440. The msPKI-*-Flag attributes are 32-bit SIGNED integers in the
        schema, so a high-bit-set flag like 0xA6000000 is stored as its two's-complement negative.
        Deterministic (does not rely on PowerShell's [int]-cast overflow behaviour).
    #>

    param([Parameter(Mandatory)][string]$Hex)
    $u = [Convert]::ToUInt32(($Hex -replace '^0x', ''), 16)
    return [System.BitConverter]::ToInt32([System.BitConverter]::GetBytes($u), 0)
}

function New-CATemplateOid {
    <#
    .SYNOPSIS
        Allocates a new template OID under CN=OID,... : reads the forest's base
        (msPKI-Cert-Template-OID on the OID container), appends two random arcs, and creates the
        matching msPKI-Enterprise-Oid object. Returns the full OID string. Behind Invoke-CAStep.
    #>

    param([Parameter(Mandatory)][string]$DisplayName, [Parameter(Mandatory)][string]$ConfigNC)

    $oidContainerDn = "CN=OID,CN=Public Key Services,CN=Services,$ConfigNC"
    $baseOid = ([ADSI]"LDAP://$oidContainerDn").Get('msPKI-Cert-Template-OID')

    do {
        $a = Get-Random -Minimum 10000000 -Maximum 99999999
        $b = Get-Random -Minimum 10000000 -Maximum 99999999
        $oidCn  = "$a.$b"
        $newOid = "$baseOid.$a.$b"
        $exists = [bool][ADSI]::Exists("LDAP://CN=$oidCn,$oidContainerDn")
    } while ($exists)

    Invoke-CAStep -Description "Allocate template OID $newOid (CN=$oidCn)" `
        -Commands @("New msPKI-Enterprise-Oid CN=$oidCn msPKI-Cert-Template-OID=$newOid flags=1 DisplayName='$DisplayName'") `
        -Action {
            # Two-phase (see New-CAVpnTemplate): a not-yet-committed DirectoryEntry rejects every
            # write style. Create bare + commit, then re-bind and populate.
            $parent = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$oidContainerDn")
            $bare = $parent.Children.Add("CN=$oidCn", 'msPKI-Enterprise-Oid')
            $bare.CommitChanges()

            $o = New-Object System.DirectoryServices.DirectoryEntry("LDAP://CN=$oidCn,$oidContainerDn")
            $o.Properties['DisplayName'].Value = $DisplayName
            $o.Properties['flags'].Value = 1
            $o.Properties['msPKI-Cert-Template-OID'].Value = $newOid
            $o.CommitChanges()
        } | Out-Null
    return $newOid
}

function Set-CAEntryOctet {
    <#
    .SYNOPSIS
        Sets a single octet-string (byte[]) attribute on a DirectoryEntry. `.Properties[x].Value =
        <byte[]>` gets ENUMERATED into N single-byte values (a schema violation for a single-valued
        octet string -> "Unspecified error" on commit); `.Add(<byte[]>)` stores it as one value.
    #>

    param([Parameter(Mandatory)]$Entry, [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][byte[]]$Bytes)
    $Entry.Properties[$Name].Clear()
    [void]$Entry.Properties[$Name].Add($Bytes)
}

function Test-CATemplateComplete {
    <#
    .SYNOPSIS
        Returns the list of REQUIRED attributes missing from an existing pKICertificateTemplate.
        An empty list == a fully-populated template. A non-empty list == a leftover husk from a
        failed create run (its bare CN object committed, but Phase 2 never ran) - such an object
        must NOT be published to the CA.
    #>

    param([Parameter(Mandatory)][string]$TemplateDn)

    $required = @(
        'displayName', 'msPKI-Template-Schema-Version', 'msPKI-Cert-Template-OID',
        'pKIExtendedKeyUsage', 'pKIExpirationPeriod', 'pKIOverlapPeriod', 'pKIKeyUsage',
        'msPKI-Certificate-Name-Flag', 'msPKI-Enrollment-Flag'
    )
    $de = [ADSI]"LDAP://$TemplateDn"
    $missing = foreach ($attr in $required) {
        $v = $null
        try { $v = $de.psbase.Properties[$attr].Value } catch { }
        if ($null -eq $v -or ($v -is [string] -and [string]::IsNullOrWhiteSpace($v))) { $attr }
    }
    return @($missing)
}

function New-CAVpnTemplate {
    <#
    .SYNOPSIS
        Creates one pKICertificateTemplate from a Get-CAVpnTemplateSpec entry. Returns the internal
        CN (== spec.DisplayName with non-alphanumerics stripped, unless -InternalName given).
    #>

    param(
        [Parameter(Mandatory)]$Spec,
        [string]$InternalName,
        [string]$ConfigNC
    )
    if (-not $ConfigNC) { $ConfigNC = Get-CAConfigNamingContext }
    if (-not $InternalName) { $InternalName = ($Spec.DisplayName -replace '[^A-Za-z0-9]', '') }

    $templateDn = "CN=$InternalName,CN=Certificate Templates,CN=Public Key Services,CN=Services,$ConfigNC"

    if ([ADSI]::Exists("LDAP://$templateDn")) {
        $missing = Test-CATemplateComplete -TemplateDn $templateDn
        if ($missing.Count) {
            throw ("Template CN=$InternalName already exists but is INCOMPLETE (missing: $($missing -join ', ')) - " +
                   "a leftover from a failed create run. Delete it and re-run: certutil -SetCATemplates -$InternalName ; certutil -DeleteTemplate $InternalName")
        }
        Write-Host " Template CN=$InternalName already exists and looks complete - skipping create (delete it first to re-create)." -ForegroundColor Yellow
        return $InternalName
    }

    $oid = New-CATemplateOid -DisplayName $Spec.DisplayName -ConfigNC $ConfigNC

    $expBytes = ConvertTo-CAPkiPeriodBytes -Days $Spec.ValidityDays
    $ovlBytes = ConvertTo-CAPkiPeriodBytes -Days $Spec.OverlapDays
    $kuBytes  = ConvertFrom-CAKeyUsageHex -Hex $Spec.KeyUsageHex
    $nameFlag  = ConvertFrom-CAHexToInt32 -Hex $Spec.CertificateNameFlagHex
    $enrFlag   = ConvertFrom-CAHexToInt32 -Hex $Spec.EnrollmentFlagHex
    $pkFlag    = ConvertFrom-CAHexToInt32 -Hex $Spec.PrivateKeyFlagHex

    # Legacy `flags` (CT_FLAG_*): for a schema-v2+ template the CA honours the msPKI-*-Flag
    # attributes, not this field - but it still shows in ADSI Edit / certutil, so derive it from the
    # spec's own intent rather than cloning the built-in User template (which drags in a stray
    # EXPORTABLE_KEY + ADD_EMAIL and a "User" description). ADD_TEMPLATE_NAME(0x200) always; PUBLISH_TO_DS(0x8) /
    # AUTO_ENROLLMENT(0x20) mirror the enrollment flag; EXPORTABLE_KEY(0x10) mirrors the private-key
    # flag; MACHINE_TYPE(0x40) when the spec says so (OCSP signers enroll as the computer account,
    # and the Online Responder wizard checks this bit).
    $legacyFlags = 0x200
    if ($enrFlag -band 0x8)  { $legacyFlags = $legacyFlags -bor 0x8 }
    if ($enrFlag -band 0x20) { $legacyFlags = $legacyFlags -bor 0x20 }
    if ($pkFlag  -band 0x10) { $legacyFlags = $legacyFlags -bor 0x10 }
    if ($Spec.MachineType)   { $legacyFlags = $legacyFlags -bor 0x40 }
    $baseCritExt  = [string[]]@('2.5.29.15')   # Key Usage marked critical (matches the built-in client-auth templates)
    $baseMaxDepth = 0
    $descText     = "$($Spec.DisplayName) - created by CA-Manager. $($Spec.Notes)"
    if ($descText.Length -gt 1024) { $descText = $descText.Substring(0, 1024) }

    $templatesContainerDn = "CN=Certificate Templates,CN=Public Key Services,CN=Services,$ConfigNC"

    Invoke-CAStep -Description "Create template CN=$InternalName ('$($Spec.DisplayName)') schema v$($Spec.SchemaVersion)" `
        -Commands @(
            "New pKICertificateTemplate $templateDn"
            " displayName='$($Spec.DisplayName)' msPKI-Cert-Template-OID=$oid msPKI-Template-Schema-Version=$($Spec.SchemaVersion)"
            " pKIExtendedKeyUsage=$($Spec.EkuOids -join ',') msPKI-Certificate-Application-Policy=$($Spec.EkuOids -join ',')"
            " msPKI-Certificate-Name-Flag=$($Spec.CertificateNameFlagHex) msPKI-Enrollment-Flag=$($Spec.EnrollmentFlagHex) msPKI-Private-Key-Flag=$($Spec.PrivateKeyFlagHex)"
            " msPKI-Minimal-Key-Size=$($Spec.MinimalKeySize) pKIDefaultKeySpec=$($Spec.KeySpec) validity=$($Spec.ValidityDays)d / overlap=$($Spec.OverlapDays)d"
            " pKIDefaultCSPs=$(if ($Spec.DefaultCsps) { ($Spec.DefaultCsps -join '; ') } else { '(unset - client picks)' }) flags=$legacyFlags (derived from spec) description='$descText'"
            " msPKI-RA-Application-Policies=$(if ($Spec.SchemaVersion -ge 3 -and $Spec.RaApplicationPoliciesRaw) { 'v3 CNG-params blob (verbatim from built-in OCSPResponseSigning)' } else { '(unset - v2 template)' })"
        ) `
        -Action {
            # TWO-PHASE. A DirectoryEntry from .Children.Add() (or [ADSI].Create()) rejects EVERY
            # write style before its first commit - .Put, .PutEx(2), .PutEx(3), and
            # .Properties[x].Value all throw "Unspecified error". So: create the bare object and
            # commit it, then RE-BIND (now it has a real server-side property cache) and populate.
            $parent = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$templatesContainerDn")
            $bare = $parent.Children.Add("CN=$InternalName", 'pKICertificateTemplate')
            $bare.CommitChanges()

            $t = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$templateDn")
            $t.Properties['displayName'].Value                         = $Spec.DisplayName
            $t.Properties['description'].Value                         = $descText
            $t.Properties['flags'].Value                               = $legacyFlags
            $t.Properties['revision'].Value                            = 100
            $t.Properties['msPKI-Cert-Template-OID'].Value             = $oid
            $t.Properties['msPKI-Template-Schema-Version'].Value        = [int]$Spec.SchemaVersion
            $t.Properties['msPKI-Template-Minor-Revision'].Value        = 0
            $t.Properties['msPKI-RA-Signature'].Value                  = 0
            $t.Properties['msPKI-Minimal-Key-Size'].Value              = [int]$Spec.MinimalKeySize
            $t.Properties['msPKI-Certificate-Name-Flag'].Value         = $nameFlag
            $t.Properties['msPKI-Enrollment-Flag'].Value               = $enrFlag
            $t.Properties['msPKI-Private-Key-Flag'].Value              = $pkFlag
            $t.Properties['pKIDefaultKeySpec'].Value                   = [int]$Spec.KeySpec
            $t.Properties['pKIMaxIssuingDepth'].Value                  = $baseMaxDepth
            $t.Properties['pKICriticalExtensions'].Value               = $baseCritExt
            $t.Properties['pKIExtendedKeyUsage'].Value                 = [string[]]$Spec.EkuOids
            $t.Properties['msPKI-Certificate-Application-Policy'].Value = [string[]]$Spec.EkuOids
            # Only pin pKIDefaultCSPs if the spec names one. Empty -> the client chooses a provider
            # (like the built-in User template). A v2 template pinned to a CNG *KSP* only fails
            # enrollment with CRYPT_E_NO_PROVIDER on the legacy CryptoAPI path.
            if ($Spec.DefaultCsps -and @($Spec.DefaultCsps).Count) {
                $t.Properties['pKIDefaultCSPs'].Value = [string[]]@($Spec.DefaultCsps)
            }
            # v3+ templates must carry msPKI-RA-Application-Policies. For the OCSP signer it holds only
            # CNG key parameters (no co-sign OIDs), so it's a writable unicode string - unlike a v4
            # template's, which also needs policy OIDs and is not raw-ADSI-authorable. Absent it, the
            # Online Responder wizard fails with 0x80070490 "template ... could not be retrieved".
            if ($Spec.SchemaVersion -ge 3 -and $Spec.RaApplicationPoliciesRaw) {
                $t.Properties['msPKI-RA-Application-Policies'].Value = [string]$Spec.RaApplicationPoliciesRaw
            }
            Set-CAEntryOctet -Entry $t -Name 'pKIExpirationPeriod' -Bytes $expBytes
            Set-CAEntryOctet -Entry $t -Name 'pKIOverlapPeriod'    -Bytes $ovlBytes
            Set-CAEntryOctet -Entry $t -Name 'pKIKeyUsage'         -Bytes $kuBytes
            $t.CommitChanges()
        } | Out-Null

    return $InternalName
}

function Get-CAActiveConfigName {
    <#
    .SYNOPSIS
        The local CA's config/sanitized name (== the CN of its pKIEnrollmentService object).
    #>

    (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration' -Name Active -ErrorAction Stop).Active
}

function Publish-CATemplateToCA {
    <#
    .SYNOPSIS
        Publishes ONE template via `certutil -SetCATemplates +<name>`, checking the exit code (a
        native-exe failure otherwise slips past Invoke-CAStep's "didn't throw == done"). Prefer
        Add-CAPublishedTemplate for the multi-template case - running this in a loop on a single DC
        loses all but the last entry (each certutil call read-modify-writes certificateTemplates and
        the prior write may not have replicated back yet).
    #>

    param([Parameter(Mandatory)][string]$TemplateName)
    Invoke-CAStep -Description "Publish template '$TemplateName' to the CA" `
        -Commands @("certutil -SetCATemplates +$TemplateName") `
        -Action {
            $out = & certutil.exe -SetCATemplates "+$TemplateName" 2>&1 | Out-String
            if ($LASTEXITCODE -ne 0) { throw "certutil -SetCATemplates +$TemplateName failed (exit $LASTEXITCODE): $out" }
            $out
        } | Out-Null
}

function Add-CAPublishedTemplate {
    <#
    .SYNOPSIS
        Publishes one or more templates to the CA in a SINGLE write: unions their CNs into the
        certificateTemplates attribute of the CA's pKIEnrollmentService object via ADSI, commits
        once, then restarts CertSvc so the running CA re-reads the list. Replaces a per-template
        `certutil -SetCATemplates +X` loop (see Publish-CATemplateToCA remarks).
    #>

    param(
        [Parameter(Mandatory)][string[]]$TemplateName,
        [string]$CAName,
        [string]$ConfigNC
    )
    if (-not $ConfigNC) { $ConfigNC = Get-CAConfigNamingContext }
    if (-not $CAName)   { $CAName   = Get-CAActiveConfigName }
    $esDn = "CN=$CAName,CN=Enrollment Services,CN=Public Key Services,CN=Services,$ConfigNC"

    Invoke-CAStep -Description "Publish $($TemplateName.Count) template(s) to CA '$CAName'" `
        -Commands @(
            "Union into certificateTemplates on $esDn (one write):"
            ($TemplateName | ForEach-Object { " + $_" })
            "Restart-Service certsvc (CA re-reads the published list)"
        ) `
        -Action {
            $es = [ADSI]"LDAP://$esDn"
            $current = @($es.psbase.Properties['certificateTemplates'].Value)
            $want = @(($current + $TemplateName) | Where-Object { $_ } | Select-Object -Unique)
            $es.psbase.Properties['certificateTemplates'].Clear()
            foreach ($n in $want) { [void]$es.psbase.Properties['certificateTemplates'].Add($n) }
            $es.psbase.CommitChanges()
            Restart-Service certsvc -Force
        } | Out-Null
}

function Grant-CATemplateEnrollment {
    <#
    .SYNOPSIS
        Adds Allow-Read + Allow-Enroll (and optionally Allow-AutoEnroll) for $PrincipalName on the
        template object's DACL, via System.DirectoryServices. Behind Invoke-CAStep.
    #>

    param(
        [Parameter(Mandatory)][string]$TemplateInternalName,
        [Parameter(Mandatory)][string]$PrincipalName,     # DOMAIN\name or a group name
        [switch]$AutoEnroll,
        [string]$ConfigNC
    )
    if (-not $ConfigNC) { $ConfigNC = Get-CAConfigNamingContext }
    $templateDn = "CN=$TemplateInternalName,CN=Certificate Templates,CN=Public Key Services,CN=Services,$ConfigNC"

    $enrollRight     = [guid]'0e10c968-78fb-11d2-90d4-00c04f79dc55'
    $autoEnrollRight = [guid]'a05b8cc2-17bc-4802-a710-e7c15ab866a2'
    try {
        $sid = (New-Object System.Security.Principal.NTAccount($PrincipalName)).Translate([System.Security.Principal.SecurityIdentifier])
    } catch {
        # The raw .NET exception ("Some or all identity references could not be translated") names
        # neither the principal nor a next step - live 2026-09-10 on a client's CA server, a RadiusGroupPairs
        # UserGroupName that was never actually created in AD produced exactly this, indistinguishable
        # at a glance from a typo or a missing domain prefix.
        throw "Could not resolve '$PrincipalName' to a security identifier for CN=$TemplateInternalName - does that user/group exist in AD (check spelling/case), and does it need a 'DOMAIN\' prefix? ($($_.Exception.Message))"
    }

    $desc = "Grant $PrincipalName Read + Enroll" + $(if ($AutoEnroll) { " + AutoEnroll" } else { "" }) + " on CN=$TemplateInternalName"
    Invoke-CAStep -Description $desc `
        -Commands @("Set DACL on $templateDn ($PrincipalName : GenericRead, ExtendedRight Enroll$(if ($AutoEnroll) { ', AutoEnroll' }))") `
        -Action {
            # .psbase - PowerShell's [ADSI] adapter shadows ObjectSecurity / CommitChanges on the
            # PSObject; the real DirectoryEntry members are under .psbase.
            $de = [ADSI]"LDAP://$templateDn"
            $de.psbase.Options.SecurityMasks = [System.DirectoryServices.SecurityMasks]::Dacl
            $ns = 'System.DirectoryServices'
            $sec = $de.psbase.ObjectSecurity
            $sec.AddAccessRule((New-Object "$ns.ActiveDirectoryAccessRule"($sid, [System.DirectoryServices.ActiveDirectoryRights]::GenericRead, 'Allow')))
            $sec.AddAccessRule((New-Object "$ns.ActiveDirectoryAccessRule"($sid, [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight, 'Allow', $enrollRight)))
            if ($AutoEnroll) {
                $sec.AddAccessRule((New-Object "$ns.ActiveDirectoryAccessRule"($sid, [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight, 'Allow', $autoEnrollRight)))
            }
            $de.psbase.ObjectSecurity = $sec
            $de.psbase.CommitChanges()
        } | Out-Null
}

function Test-CATemplateAutoEnroll {
    <#
    .SYNOPSIS
        Read-only. Is $PrincipalName currently granted the AutoEnroll extended right on this
        template's DACL? Says nothing about Enroll - a principal can have Enroll without AutoEnroll
        (the whole point of "the enrollment gate", see Set-CATemplateAutoEnroll).
    #>

    param(
        [Parameter(Mandatory)][string]$TemplateInternalName,
        [Parameter(Mandatory)][string]$PrincipalName,
        [string]$ConfigNC
    )
    if (-not $ConfigNC) { $ConfigNC = Get-CAConfigNamingContext }
    $templateDn = "CN=$TemplateInternalName,CN=Certificate Templates,CN=Public Key Services,CN=Services,$ConfigNC"
    $autoEnrollRight = [guid]'a05b8cc2-17bc-4802-a710-e7c15ab866a2'
    try {
        $sid = (New-Object System.Security.Principal.NTAccount($PrincipalName)).Translate([System.Security.Principal.SecurityIdentifier])
    } catch { return $false }
    try {
        $sec = ([ADSI]"LDAP://$templateDn").psbase.ObjectSecurity
        $hit = @($sec.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) | Where-Object {
            $_.AccessControlType -eq 'Allow' -and
            $_.ActiveDirectoryRights -match 'ExtendedRight' -and
            $_.ObjectType -eq $autoEnrollRight -and
            $_.IdentityReference -eq $sid
        }
        [bool]$hit
    } catch { $false }
}

function Set-CATemplateAutoEnroll {
    <#
    .SYNOPSIS
        "The enrollment gate" - adds or removes JUST the AutoEnroll extended-right ACE for
        $PrincipalName on the template's DACL. Read + Enroll (granted once at template creation via
        Grant-CATemplateEnrollment, WITHOUT -AutoEnroll now - see Invoke-CAMenuTemplates) are left
        untouched either way.
    .NOTES
        Nothing actually auto-enrolls for a group until this is explicitly flipped on - decouples
        "does the template/ACL/TameMyCerts policy exist and look right" from "is this group live",
        so templates/TameMyCerts/App Proxy/OCSP/GPO can be built or revisited in whatever order makes
        sense for a given engagement without ever risking a premature real enrollment mid-setup.
        RemoveAccessRule matches by reconstructing the IDENTICAL rule shape used to add it (same SID,
        rights, ObjectType, default InheritanceType/flags) - .NET's ActiveDirectorySecurity compares
        access rules structurally, not by reference, so this removes cleanly without needing to look
        up and re-supply whatever the exact stored ACE instance was.
    #>

    param(
        [Parameter(Mandatory)][string]$TemplateInternalName,
        [Parameter(Mandatory)][string]$PrincipalName,
        [Parameter(Mandatory)][bool]$Enabled,
        [string]$ConfigNC
    )
    if (-not $ConfigNC) { $ConfigNC = Get-CAConfigNamingContext }
    $templateDn = "CN=$TemplateInternalName,CN=Certificate Templates,CN=Public Key Services,CN=Services,$ConfigNC"
    $autoEnrollRight = [guid]'a05b8cc2-17bc-4802-a710-e7c15ab866a2'
    try {
        $sid = (New-Object System.Security.Principal.NTAccount($PrincipalName)).Translate([System.Security.Principal.SecurityIdentifier])
    } catch {
        throw "Could not resolve '$PrincipalName' to a security identifier for CN=$TemplateInternalName - does that user/group exist in AD (check spelling/case), and does it need a 'DOMAIN\' prefix? ($($_.Exception.Message))"
    }
    $verb = if ($Enabled) { 'Grant' } else { 'Revoke' }
    Invoke-CAStep -Description "$verb AutoEnroll for $PrincipalName on CN=$TemplateInternalName" `
        -Commands @("$verb ExtendedRight AutoEnroll on $templateDn for $PrincipalName") `
        -Action {
            $de = [ADSI]"LDAP://$templateDn"
            $de.psbase.Options.SecurityMasks = [System.DirectoryServices.SecurityMasks]::Dacl
            $ns = 'System.DirectoryServices'
            $sec = $de.psbase.ObjectSecurity
            $rule = New-Object "$ns.ActiveDirectoryAccessRule"($sid, [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight, 'Allow', $autoEnrollRight)
            if ($Enabled) { $sec.AddAccessRule($rule) } else { $sec.RemoveAccessRule($rule) | Out-Null }
            $de.psbase.ObjectSecurity = $sec
            $de.psbase.CommitChanges()
        } | Out-Null
}

# ---------------------------------------------------------------------------
function Remove-CAOrphanTemplateOids {
    <#
    .SYNOPSIS
        Deletes stray msPKI-Enterprise-Oid objects left behind by failed template-create runs -
        each aborted New-CATemplateOid commits the OID object before New-CAVpnTemplate fails, so the
        CN=OID container accumulates entries no live pKICertificateTemplate references.
 
        Conservative by default: only removes template OIDs (flags=1, msPKI-Cert-Template-OID set)
        whose DisplayName matches one of -DisplayNameFilter AND which no published/unpublished
        template currently points at. Runs behind Invoke-CAStep so dry-run lists them first.
 
    .PARAMETER DisplayNameFilter
        OID DisplayNames eligible for cleanup. Defaults to the four CA-Manager template names, so a
        client CA's unrelated OIDs are never touched.
    #>

    param(
        [string[]]$DisplayNameFilter = @((Get-CAVpnTemplateSpec).DisplayName),
        [string]$ConfigNC
    )
    if (-not $ConfigNC) { $ConfigNC = Get-CAConfigNamingContext }

    $oidContainerDn      = "CN=OID,CN=Public Key Services,CN=Services,$ConfigNC"
    $templatesContainerDn = "CN=Certificate Templates,CN=Public Key Services,CN=Services,$ConfigNC"

    # OIDs currently claimed by a real template
    $liveOids = @(
        ([ADSI]"LDAP://$templatesContainerDn").psbase.Children |
            ForEach-Object { "$($_.Properties['msPKI-Cert-Template-OID'].Value)" } |
            Where-Object { $_ }
    )

    # .psbase.Children yields RAW System.DirectoryServices.DirectoryEntry objects - use them
    # directly ($o.DeleteTree()); $o.psbase on a raw DirectoryEntry is a non-IADs PSObject wrapper.
    $orphans = @(
        ([ADSI]"LDAP://$oidContainerDn").psbase.Children | Where-Object {
            $_.SchemaClassName -eq 'msPKI-Enterprise-Oid' -and
            "$($_.Properties['flags'].Value)" -eq '1' -and
            $_.Properties['msPKI-Cert-Template-OID'].Value -and
            ("$($_.Properties['DisplayName'].Value)" -in $DisplayNameFilter) -and
            ("$($_.Properties['msPKI-Cert-Template-OID'].Value)" -notin $liveOids)
        }
    )

    if (-not $orphans.Count) {
        Write-Host " No orphaned template OIDs to clean up." -ForegroundColor Green
        return 0
    }

    Write-Host (" {0} orphaned template OID object(s) found:" -f $orphans.Count) -ForegroundColor Yellow
    foreach ($o in $orphans) {
        Write-Host (" {0} DisplayName='{1}' {2}" -f $o.Properties['cn'].Value, $o.Properties['DisplayName'].Value, $o.Properties['msPKI-Cert-Template-OID'].Value) -ForegroundColor Gray
    }

    Invoke-CAStep -Description "Delete $($orphans.Count) orphaned template OID object(s)" `
        -Commands ($orphans | ForEach-Object { "Remove CN=$($_.Properties['cn'].Value),$oidContainerDn" }) `
        -Action {
            foreach ($o in $orphans) {
                try { $o.DeleteTree() }
                catch { Write-Host (" could not delete CN={0}: {1}" -f $o.Properties['cn'].Value, $_.Exception.Message) -ForegroundColor DarkYellow }
            }
        } | Out-Null
    return $orphans.Count
}