From c75cc145cbd23f1a9e9042e8139eacd359575ec9 Mon Sep 17 00:00:00 2001 From: gsadmin Date: Fri, 31 Jul 2026 22:28:35 -0400 Subject: [PATCH] Enable enrollment protocols on the application, not by cloning profiles The enrollment methods were not appearing in the UI because they were being set in the wrong place. A profile's own enrollmentType is one value, but that is only the base; what the UI lists under an application - "configure how this application will issue certificates via API, EST, ACME, or SCEP" - is a separate configuration on the application-to-profile link, and those are independent. A single profile can answer all four at once. That replaces the previous approach. The mecm-...-scep and ec-...-acme profiles are gone; there are three profiles again, one per policy, and each declares an Enrollment block naming the protocols it should answer. Applied with PUT to /applications/{id}/profiles/{id}/enrollment/{api,scep,acme}, which is idempotent by nature, so a re-run restates the same settings. The routes take PUT, not POST, and a POST there returns 404. SCEP now uses a dynamic challenge, so there is no shared secret to distribute or rotate; each request collects a one-time password from the challenge endpoint. -ScepChallengePassword is kept for anyone who sets ChallengeType to static, and is otherwise unused. Enabling a protocol is what makes Infisical mint its endpoint - a SCEP URL, challenge URL and RA certificate, or an ACME directory URL - so the run reads them back and prints them, and returns them under Enrollment. None of it can be derived from the configuration alone. Verified against a live instance: scepConfigured and acmeConfigured come back true on the intended profiles, the dynamic challenge settings round-trip, and a re-run restates without creating anything. Co-Authored-By: Claude Opus 5 --- ...ialize-InfisicalCertManagerEnvironment.ps1 | 300 ++++++++++++------ 1 file changed, 202 insertions(+), 98 deletions(-) diff --git a/Tools/Initialize-InfisicalCertManagerEnvironment.ps1 b/Tools/Initialize-InfisicalCertManagerEnvironment.ps1 index b20814e..e6f378d 100644 --- a/Tools/Initialize-InfisicalCertManagerEnvironment.ps1 +++ b/Tools/Initialize-InfisicalCertManagerEnvironment.ps1 @@ -21,9 +21,12 @@ Two sets are seeded by default: an RSA hierarchy carrying server and client authentication for SCCM/MECM, and an ECDSA P-384 hierarchy carrying server, client, and code signing. - A profile carries one enrollment method, so a policy that should be reachable over more than one gets - a profile per method. Seeded by default: API on all three policies, SCEP alongside the RSA one for - clients that enrol without a token, and ACME alongside the EC server/client one. + Enrollment protocols are enabled on the link between the application and each profile, which is where + they are independent of one another, so a single profile can answer several at once. Seeded by + default: API everywhere, SCEP on the RSA profile with a dynamic challenge for clients that enrol + without a token, and ACME on the EC server/client profile. Enabling SCEP or ACME makes Infisical mint + the matching endpoint, and the run prints those URLs because they cannot be derived from the + configuration. The script is idempotent. Every object is looked up by its natural key before creation, so a re-run reports what already exists and creates only what is missing. It is safe to run repeatedly while @@ -161,13 +164,7 @@ Set-StrictMode -Version Latest Application = [Ordered]@{ Name = 'platform' Description = 'Endpoint and workload certificate enrollment.' - CertificateProfiles = @( - 'mecm-server-client-auth' - 'mecm-server-client-auth-scep' - 'ec-server-client-auth' - 'ec-server-client-auth-acme' - 'ec-code-signing' - ) + CertificateProfiles = @('mecm-server-client-auth', 'ec-server-client-auth', 'ec-code-signing') } Organization = [Ordered]@{ @@ -289,76 +286,65 @@ Set-StrictMode -Version Latest ) <# - A profile carries exactly one enrollment method. Infisical rejects a profile that mixes them - ("API enrollment type cannot have EST, ACME, or SCEP configuration"), so offering a policy over - more than one method means one profile per method, all pointing at the same policy and CA. + Enrollment is configured twice, at two different levels, and only the second one is what clients + actually use. - EnrollmentType is api, scep, acme, or est. Each takes only its own configuration: - api AutoRenew, RenewBeforeDays (1-30) - scep ScepChallengeType static (needs a challenge password) or dynamic (issues one per request) - acme SkipDnsOwnershipVerification, SkipEabBinding - est a passphrase and a bootstrap CA chain, so it is left to be configured by hand + The profile itself carries a single enrollmentType, and Infisical rejects a profile that mixes + them ("API enrollment type cannot have EST, ACME, or SCEP configuration"). That setting is left + at api for every profile below; it is the base the application builds on. - AutoRenew is an api-only setting. Neither the SCEP nor the ACME configuration has an equivalent, - because both protocols have the client drive renewal on its own schedule. + The application-to-profile link is where a profile is exposed over one or more protocols, which + is what the UI means by "configure how this application will issue certificates via API, EST, + ACME, or SCEP". Those are independent, so a single profile can serve all of them at once, and + enabling one is what makes Infisical mint its endpoint - a SCEP URL and RA certificate, or an + ACME directory URL. The Enrollment block below drives that step; omit a protocol to leave it off. + + Api AutoRenew, RenewBeforeDays (1-365) + Scep ChallengeType dynamic (a one-time password per request) or static (one shared secret), + IncludeCaCertInResponse, AllowCertBasedRenewal, and for dynamic the expiry in minutes + (5-1440) and the cap on outstanding challenges (1-1000) + Acme SkipDnsOwnershipVerification, SkipEabBinding + Est a passphrase and a bootstrap CA chain, so it is left to be configured by hand + + AutoRenew is an Api setting. Neither SCEP nor ACME has an equivalent, because both protocols + have the client drive renewal on its own schedule. Defaults fill in what a requester leaves out. They are required whenever the policy marks a constraint Required: Request-InfisicalCertificate does not send key usages of its own, so without a default the request arrives empty and is refused with "Missing required key usages". #> CertificateProfiles = @( - [Ordered]@{ - Slug = 'mecm-server-client-auth' - Description = 'API enrollment for SCCM/MECM site systems and clients.' - CertificateAuthority = 'rsa-issuing-ca' - CertificatePolicy = 'mecm-server-client-auth' - EnrollmentType = 'api' - AutoRenew = $True - RenewBeforeDays = 14 - Defaults = [Ordered]@{ - KeyAlgorithm = 'RSA_2048' - SignatureAlgorithm = 'RSA-SHA256' - KeyUsages = @('digital_signature', 'key_encipherment') - ExtendedKeyUsages = @('server_auth', 'client_auth') - } - } <# - SCEP alongside the API profile: it is how domain-joined Windows and mobile clients enrol - without a token, which is the NDES role SCCM/MECM would otherwise need. RSA because SCEP - client support for EC keys is patchy. + SCEP as well as API: it is how domain-joined Windows and mobile clients enrol without a + token, which is the NDES role SCCM/MECM would otherwise need. Dynamic challenge, so there is + no shared secret to distribute or rotate - each request collects a one-time password from + the challenge endpoint instead. #> [Ordered]@{ - Slug = 'mecm-server-client-auth-scep' - Description = 'SCEP enrollment for SCCM/MECM site systems and clients.' + Slug = 'mecm-server-client-auth' + Description = 'SCCM/MECM site systems and clients.' CertificateAuthority = 'rsa-issuing-ca' CertificatePolicy = 'mecm-server-client-auth' - EnrollmentType = 'scep' - ScepChallengeType = 'static' Defaults = [Ordered]@{ KeyAlgorithm = 'RSA_2048' SignatureAlgorithm = 'RSA-SHA256' KeyUsages = @('digital_signature', 'key_encipherment') ExtendedKeyUsages = @('server_auth', 'client_auth') } - } - [Ordered]@{ - Slug = 'ec-server-client-auth' - Description = 'API enrollment for general workload certificates.' - CertificateAuthority = 'ec-issuing-ca' - CertificatePolicy = 'ec-server-client-auth' - EnrollmentType = 'api' - AutoRenew = $True - RenewBeforeDays = 14 - Defaults = [Ordered]@{ - KeyAlgorithm = 'EC_secp384r1' - SignatureAlgorithm = 'ECDSA-SHA384' - KeyUsages = @('digital_signature') - ExtendedKeyUsages = @('server_auth', 'client_auth') + Enrollment = [Ordered]@{ + Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 } + Scep = [Ordered]@{ + ChallengeType = 'dynamic' + IncludeCaCertInResponse = $True + AllowCertBasedRenewal = $True + DynamicChallengeExpiryMinutes = 60 + DynamicChallengeMaxPending = 100 + } } } <# - ACME alongside the API profile, for workloads that already speak it (cert-manager, Caddy, - Traefik, acme.sh). + ACME as well as API, for workloads that already speak it (cert-manager, Caddy, Traefik, + acme.sh). SkipDnsOwnershipVerification is on because this is an internal CA issuing internal names: the DNS-01 challenge proves control of a public zone, which a name like host.contoso.local @@ -366,19 +352,20 @@ Set-StrictMode -Version Latest off if the names being issued live in a zone Infisical can actually resolve. #> [Ordered]@{ - Slug = 'ec-server-client-auth-acme' - Description = 'ACME enrollment for general workload certificates.' - CertificateAuthority = 'ec-issuing-ca' - CertificatePolicy = 'ec-server-client-auth' - EnrollmentType = 'acme' - SkipDnsOwnershipVerification = $True - SkipEabBinding = $False - Defaults = [Ordered]@{ + Slug = 'ec-server-client-auth' + Description = 'General workload certificates.' + CertificateAuthority = 'ec-issuing-ca' + CertificatePolicy = 'ec-server-client-auth' + Defaults = [Ordered]@{ KeyAlgorithm = 'EC_secp384r1' SignatureAlgorithm = 'ECDSA-SHA384' KeyUsages = @('digital_signature') ExtendedKeyUsages = @('server_auth', 'client_auth') } + Enrollment = [Ordered]@{ + Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 } + Acme = [Ordered]@{ SkipDnsOwnershipVerification = $True; SkipEabBinding = $False } + } } <# Code signing is API only. SCEP and ACME both exist to prove control of a device or a DNS @@ -386,18 +373,18 @@ Set-StrictMode -Version Latest #> [Ordered]@{ Slug = 'ec-code-signing' - Description = 'API enrollment for code signing certificates.' + Description = 'Code signing certificates.' CertificateAuthority = 'ec-issuing-ca' CertificatePolicy = 'ec-code-signing' - EnrollmentType = 'api' - AutoRenew = $True - RenewBeforeDays = 14 Defaults = [Ordered]@{ KeyAlgorithm = 'EC_secp384r1' SignatureAlgorithm = 'ECDSA-SHA384' KeyUsages = @('digital_signature') ExtendedKeyUsages = @('code_signing') } + Enrollment = [Ordered]@{ + Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 } + } } ) } @@ -731,6 +718,117 @@ Set-StrictMode -Version Latest Write-Step "attached $($missing.Count) profile(s)" -Status 'Sub' } + function Set-SeededApplicationEnrollment { + <# + Turns on each protocol a profile should answer, on its link to the application. This is the step + that makes a method visible in the UI and gives Infisical something to mint an endpoint from: + enabling SCEP creates the RA certificate and the pkiclient.exe and challenge URLs, and enabling + ACME creates the directory URL. Nothing here is exclusive, so a profile can serve several. + + PUT replaces the configuration for one protocol, which makes re-running safe: the same settings + produce the same result, and a protocol left out of the Enrollment block is simply never + touched. Removing one from the block therefore does not disable it - delete it in the UI, or + send DELETE to the same route. + #> + param([String]$ProjectId, $Application, $Profiles) + + if ($Null -eq $Application -or [String]::IsNullOrWhiteSpace($ProjectId)) { return } + + Write-Step 'Enrollment methods' + + foreach ($definition in $Configuration.CertificateProfiles) { + if (-not $Profiles.Contains($definition.Slug)) { continue } + if (-not $definition.Contains('Enrollment') -or $Null -eq $definition.Enrollment) { continue } + + $profileId = $Profiles[$definition.Slug].id + + foreach ($method in @('Api', 'Scep', 'Acme')) { + if (-not $definition.Enrollment.Contains($method)) { continue } + + $settings = $definition.Enrollment[$method] + $payload = ConvertTo-EnrollmentPayload -Method $method -Settings $settings + $route = $method.ToLowerInvariant() + + if (-not $PSCmdlet.ShouldProcess("$($definition.Slug) ($route)", 'Configure enrollment')) { + Write-Step "would enable $route on '$($definition.Slug)'" -Status 'WhatIf' + continue + } + + $payload.projectId = $ProjectId + $Null = Invoke-InfisicalApi -Method 'PUT' -Body $payload ` + -Path "/api/v1/cert-manager/applications/$($Application.id)/profiles/$profileId/enrollment/$route" + + $detail = switch ($method) { + 'Scep' { " ($($settings.ChallengeType) challenge)" } + 'Api' { if ($settings.AutoRenew) { " (auto-renew $($settings.RenewBeforeDays)d)" } else { ' (no auto-renew)' } } + default { '' } + } + Write-Step "$route on '$($definition.Slug)'$detail" -Status 'Created' + } + } + } + + function Get-SeededEnrollmentSummary { + <# + Reads back what each profile ended up answering, because the endpoint URLs and the SCEP RA + certificate are generated by Infisical and are not knowable from the configuration alone. + #> + param([String]$ProjectId, $Application, $Profiles) + + $summary = [Ordered]@{} + if ($Null -eq $Application -or [String]::IsNullOrWhiteSpace($ProjectId)) { return $summary } + + foreach ($slug in $Profiles.Keys) { + $profileId = $Profiles[$slug].id + try { + $summary[$slug] = Invoke-InfisicalApi -Method 'GET' ` + -Path "/api/v1/cert-manager/applications/$($Application.id)/profiles/$profileId/enrollment?projectId=$ProjectId" + } + catch { + Write-Verbose "Could not read enrollment for '$slug': $($_.Exception.Message)" + } + } + + return $summary + } + + function ConvertTo-EnrollmentPayload { + param([String]$Method, $Settings) + + $payload = [Ordered]@{} + switch ($Method) { + 'Api' { + $payload.autoRenew = [Bool]$Settings.AutoRenew + if ($payload.autoRenew -and $Null -ne $Settings.RenewBeforeDays) { + $payload.renewBeforeDays = $Settings.RenewBeforeDays + } + } + 'Scep' { + $challengeType = if ($Settings.Contains('ChallengeType')) { $Settings.ChallengeType } else { 'dynamic' } + $payload.challengeType = $challengeType + + # A static challenge is one shared secret; a dynamic one is minted per request instead. + if ($challengeType -eq 'static') { + $payload.challengePassword = Get-ScepChallengePassword + } + else { + if ($Settings.Contains('DynamicChallengeExpiryMinutes')) { $payload.dynamicChallengeExpiryMinutes = $Settings.DynamicChallengeExpiryMinutes } + if ($Settings.Contains('DynamicChallengeMaxPending')) { $payload.dynamicChallengeMaxPending = $Settings.DynamicChallengeMaxPending } + } + + if ($Settings.Contains('IncludeCaCertInResponse')) { $payload.includeCaCertInResponse = [Bool]$Settings.IncludeCaCertInResponse } + if ($Settings.Contains('AllowCertBasedRenewal')) { $payload.allowCertBasedRenewal = [Bool]$Settings.AllowCertBasedRenewal } + } + 'Acme' { + $payload.skipDnsOwnershipVerification = [Bool]$Settings.SkipDnsOwnershipVerification + $payload.skipEabBinding = [Bool]$Settings.SkipEabBinding + } + default { throw "Unsupported enrollment method '$Method'." } + } + + return $payload + } + function Get-SeededCertificateAuthorities { param([String]$ProjectId) @@ -1013,7 +1111,17 @@ Set-StrictMode -Version Latest continue } - $enrollmentType = if ($definition.Contains('EnrollmentType')) { $definition.EnrollmentType } else { 'api' } + <# + Always api at the profile level. The profile may only name one enrollment type, and the + protocols a client actually reaches are enabled per application-profile link further down, + where they are not mutually exclusive. + #> + $apiEnrollment = if ($definition.Enrollment.Contains('Api')) { $definition.Enrollment.Api } else { [Ordered]@{} } + $apiConfig = [Ordered]@{ autoRenew = [Bool]$apiEnrollment.AutoRenew } + if ($apiConfig.autoRenew -and $Null -ne $apiEnrollment.RenewBeforeDays) { + # The profile-level cap is 30 days, where the application-level one allows up to 365. + $apiConfig.renewBeforeDays = [Math]::Min([Int]$apiEnrollment.RenewBeforeDays, 30) + } $body = [Ordered]@{ projectId = $ProjectId @@ -1021,34 +1129,9 @@ Set-StrictMode -Version Latest description = $definition.Description caId = $Authorities[$definition.CertificateAuthority].id certificatePolicyId = $Policies[$definition.CertificatePolicy].id - enrollmentType = $enrollmentType + enrollmentType = 'api' issuerType = 'ca' - } - - # Only the configuration matching the enrollment type may be sent; the others are rejected. - switch ($enrollmentType) { - 'api' { - $apiConfig = [Ordered]@{ autoRenew = [Bool]$definition.AutoRenew } - if ($definition.AutoRenew -and $Null -ne $definition.RenewBeforeDays) { - $apiConfig.renewBeforeDays = $definition.RenewBeforeDays - } - $body.apiConfig = $apiConfig - } - 'scep' { - $challengeType = if ($definition.Contains('ScepChallengeType')) { $definition.ScepChallengeType } else { 'static' } - $scepConfig = [Ordered]@{ challengeType = $challengeType } - if ($challengeType -eq 'static') { - $scepConfig.challengePassword = Get-ScepChallengePassword - } - $body.scepConfig = $scepConfig - } - 'acme' { - $body.acmeConfig = [Ordered]@{ - skipDnsOwnershipVerification = [Bool]$definition.SkipDnsOwnershipVerification - skipEabBinding = [Bool]$definition.SkipEabBinding - } - } - default { throw "Profile '$($definition.Slug)' declares unsupported EnrollmentType '$enrollmentType'." } + apiConfig = $apiConfig } $defaults = ConvertTo-ProfileDefaults -Definition $definition.Defaults @@ -1076,6 +1159,8 @@ Set-StrictMode -Version Latest $policies = Get-SeededCertificatePolicies -ProjectId $projectId $profiles = Get-SeededCertificateProfiles -ProjectId $projectId -Authorities $authorities -Policies $policies $application = Get-SeededApplication -ProjectId $projectId -Profiles $profiles + Set-SeededApplicationEnrollment -ProjectId $projectId -Application $application -Profiles $profiles + $enrollment = Get-SeededEnrollmentSummary -ProjectId $projectId -Application $application -Profiles $profiles Write-Host '' Write-Step 'Done.' @@ -1097,6 +1182,24 @@ Set-StrictMode -Version Latest Write-Host " -CommonName `$Env:ComputerName.ToUpper() -DnsName (Get-InfisicalSANList) -Install -InstallChain" } + # Minted by Infisical when a protocol is enabled, so they can only be reported by reading them back. + foreach ($slug in $enrollment.Keys) { + $entry = $enrollment[$slug] + $scep = Get-ApiProperty -InputObject $entry -Name 'scep' + $acme = Get-ApiProperty -InputObject $entry -Name 'acme' + if ($Null -eq $scep -and $Null -eq $acme) { continue } + + Write-Host '' + Write-Host "Enrollment endpoints for '$slug':" + if ($Null -ne $scep) { + Write-Host " SCEP $(Get-ApiProperty -InputObject $scep -Name 'scepEndpointUrl')" + Write-Host " challenge $(Get-ApiProperty -InputObject $scep -Name 'challengeEndpointUrl')" + } + if ($Null -ne $acme) { + Write-Host " ACME $(Get-ApiProperty -InputObject $acme -Name 'directoryUrl')" + } + } + <# Reported here because Infisical will not hand the challenge back afterwards: a static SCEP profile is unusable to anyone who did not capture this value. Re-running the script does not @@ -1119,6 +1222,7 @@ Set-StrictMode -Version Latest CertificatePolicies = $policies CertificateProfiles = $profiles Application = $application + Enrollment = $enrollment ScepChallengePassword = $Script:ScepChallenge } }