<# .SYNOPSIS Seeds an Infisical Certificate Manager project with a CA hierarchy, certificate policies, and enrollment profiles for API, SCEP, and ACME. .DESCRIPTION Stands up a complete cert-manager environment from a single declarative configuration block, so a new Infisical instance can be brought to a known state without clicking through the UI. What it creates, in dependency order: 1. A cert-manager project. 2. A root CA and a subordinate CA. Infisical does not sign a subordinate on creation, so the script performs the full dance itself: create (status pending-certificate) -> GET the CSR -> sign it with the root -> import the signed certificate and chain back onto the subordinate. 3. Certificate policies, which constrain subject attributes, key usages, extended key usages, key algorithms, and maximum validity. 4. Certificate profiles, which bind a policy to an issuing CA and expose it for enrollment. 5. A certificate application, which is what the UI and the cmdlets group profiles under. 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. 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 building out a configuration. Certificates are requested against a profile with Request-InfisicalCertificate -CertificateProfileId. Profiles are used rather than direct CA issuance because Infisical creates every CA with direct issuance disabled and exposes no API to enable it; profile issuance is the only path that does not consult that flag. .PARAMETER BaseUri Base URI of the Infisical instance, for example https://infisical.contoso.com. .PARAMETER ClientId Universal Auth machine identity client id. .PARAMETER ClientSecret Universal Auth machine identity client secret, as a SecureString. .PARAMETER AccessToken An existing bearer token, as a SecureString, used instead of -ClientId and -ClientSecret. Accepts a machine identity token or the token from a signed-in browser session, which avoids creating a machine identity just to seed a throwaway instance. The token is proven against the API before anything is created, so an expired one fails immediately rather than midway through building a CA hierarchy. To take one from a browser session: sign in to Infisical, open the developer tools Network tab, select any request to /api/, and copy the value after "Bearer " from its Authorization header. .PARAMETER OrganizationId Organization to create the project under. Optional when the identity is scoped to a single organization, in which case it is resolved automatically. .PARAMETER ScepChallengePassword Shared secret that static SCEP profiles enrol against, as a SecureString. Optional; when omitted a random one is generated and printed once at the end of the run. Infisical does not expose the challenge afterwards, so a generated value is only recoverable from that output. .PARAMETER SkipCertificateCheck Accepts an untrusted TLS certificate on the Infisical endpoint. For lab instances only. .EXAMPLE $Secret = Read-Host -AsSecureString 'Client Secret' .\Initialize-InfisicalCertManagerEnvironment.ps1 -BaseUri 'https://infisical.contoso.com' ` -ClientId '00000000-0000-0000-0000-000000000000' -ClientSecret $Secret -WhatIf Shows every object that would be created without changing anything. Run this first. .EXAMPLE $Token = Read-Host -AsSecureString 'Access Token' .\Initialize-InfisicalCertManagerEnvironment.ps1 -BaseUri 'https://infisical.contoso.com' -AccessToken $Token -WhatIf Plans the same run using a token pasted from a browser session, with no machine identity involved. .EXAMPLE .\Initialize-InfisicalCertManagerEnvironment.ps1 -BaseUri 'https://infisical.contoso.com' ` -ClientId '00000000-0000-0000-0000-000000000000' -ClientSecret $Secret Seeds the environment, then emits an object describing everything created or found. .NOTES Requires no modules. PSInfisicalAPI consumes the result; it is not needed to produce it. #> [CmdletBinding(SupportsShouldProcess = $True, DefaultParameterSetName = 'UniversalAuth')] param( [Parameter(Mandatory = $True)] [ValidateNotNullOrEmpty()] [String]$BaseUri, [Parameter(Mandatory = $True, ParameterSetName = 'UniversalAuth')] [ValidateNotNullOrEmpty()] [String]$ClientId, [Parameter(Mandatory = $True, ParameterSetName = 'UniversalAuth')] [ValidateNotNull()] [System.Security.SecureString]$ClientSecret, [Parameter(Mandatory = $True, ParameterSetName = 'Token')] [ValidateNotNull()] [System.Security.SecureString]$AccessToken, [Parameter(Mandatory = $False)] [String]$OrganizationId, [Parameter(Mandatory = $False)] [ValidateNotNull()] [System.Security.SecureString]$ScepChallengePassword, [Parameter(Mandatory = $False)] [Switch]$SkipCertificateCheck ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest #region Configuration <# Everything the script creates is described here. Keys map to Infisical concepts; each takes a list of dictionaries so a set can be extended by adding an entry rather than by editing code. Cross-references are by name: a CertificateAuthority names its Parent, and a CertificateProfile names its CertificateAuthority and CertificatePolicy. The script resolves those to ids at runtime, so the block reads as a description of the environment rather than a sequence of API calls. Enumeration values are the ones Infisical accepts verbatim: KeyAlgorithm RSA_2048 | RSA_3072 | RSA_4096 | EC_prime256v1 | EC_secp384r1 | EC_secp521r1 SignatureAlgorithm RSA-SHA256 | RSA-SHA384 | RSA-SHA512 | ECDSA-SHA256 | ECDSA-SHA384 | ECDSA-SHA512 KeyUsages digital_signature | key_encipherment | non_repudiation | data_encipherment | key_agreement | key_cert_sign | crl_sign | encipher_only | decipher_only ExtendedKeyUsages client_auth | server_auth | code_signing | email_protection | ocsp_signing | time_stamping SubjectAttribute common_name | organization | country | state | locality | organizational_unit SanType dns_name | ip_address | email | uri #> $Configuration = [Ordered]@{ <# Used only when the organization has no Certificate Manager project yet. If one already exists it is adopted and these values are ignored, because applications are served from a single project per organization and a second one could not hold them. #> Project = [Ordered]@{ Name = 'Certificate Manager' Slug = 'certificate-manager' Description = 'Seeded PKI environment for endpoint and workload certificates.' } <# The application profiles are grouped under, which is what Get-InfisicalCertificateProfile -ApplicationId scopes to. Set Name to '' to skip. #> Application = [Ordered]@{ Name = 'platform' Description = 'Endpoint and workload certificate enrollment.' CertificateProfiles = @('mecm-server-client-auth', 'ec-server-client-auth', 'ec-code-signing') } Organization = [Ordered]@{ Name = 'Contoso' OrganizationalUnit = 'IT' Country = 'US' State = '' Locality = '' } CertificateAuthorities = @( [Ordered]@{ Name = 'rsa-root-ca' Type = 'root' Parent = $Null CommonName = 'Contoso RSA Root Certificate Authority' KeyAlgorithm = 'RSA_2048' ValidityYears = 10 MaxPathLength = 1 } [Ordered]@{ Name = 'rsa-issuing-ca' Type = 'intermediate' Parent = 'rsa-root-ca' CommonName = 'Contoso RSA Issuing Certificate Authority' KeyAlgorithm = 'RSA_2048' ValidityYears = 5 MaxPathLength = 0 } [Ordered]@{ Name = 'ec-root-ca' Type = 'root' Parent = $Null CommonName = 'Contoso EC Root Certificate Authority' KeyAlgorithm = 'EC_secp384r1' ValidityYears = 10 MaxPathLength = 1 } [Ordered]@{ Name = 'ec-issuing-ca' Type = 'intermediate' Parent = 'ec-root-ca' CommonName = 'Contoso EC Issuing Certificate Authority' KeyAlgorithm = 'EC_secp384r1' ValidityYears = 5 MaxPathLength = 0 } ) CertificatePolicies = @( <# Every attribute a request carries must have a policy entry, or issuance is refused with "not allowed by template policy (no subject policies defined)". An entry of Allowed = @('*') is therefore not decoration: it is what keeps the common name and SAN list open, which is what fleet enrollment needs, since every machine presents its own name. Narrow a value by replacing the wildcard, for example Allowed = @('*.contoso.com'). SignatureAlgorithms is likewise mandatory. A policy that omits it rejects every request with "Signature algorithm ... not defined in template". #> [Ordered]@{ Name = 'mecm-server-client-auth' Description = 'SCCM/MECM site systems and clients. RSA, server and client authentication.' MaxValidity = '365d' KeyAlgorithms = @('RSA_2048', 'RSA_3072', 'RSA_4096') SignatureAlgorithms = @('RSA-SHA256', 'RSA-SHA384', 'RSA-SHA512') KeyUsages = [Ordered]@{ Required = @('digital_signature', 'key_encipherment') } ExtendedKeyUsages = [Ordered]@{ Required = @('server_auth', 'client_auth') } Subject = @( [Ordered]@{ Type = 'common_name'; Allowed = @('*') } [Ordered]@{ Type = 'organization'; Allowed = @('*') } [Ordered]@{ Type = 'organizational_unit'; Allowed = @('*') } [Ordered]@{ Type = 'country'; Allowed = @('*') } [Ordered]@{ Type = 'state'; Allowed = @('*') } [Ordered]@{ Type = 'locality'; Allowed = @('*') } ) SubjectAltNames = @( [Ordered]@{ Type = 'dns_name'; Allowed = @('*') } [Ordered]@{ Type = 'ip_address'; Allowed = @('*') } ) } [Ordered]@{ Name = 'ec-server-client-auth' Description = 'General workload certificates. ECDSA P-384, server and client authentication.' MaxValidity = '90d' KeyAlgorithms = @('EC_secp384r1') SignatureAlgorithms = @('ECDSA-SHA384') KeyUsages = [Ordered]@{ Required = @('digital_signature') } ExtendedKeyUsages = [Ordered]@{ Required = @('server_auth', 'client_auth') } Subject = @( [Ordered]@{ Type = 'common_name'; Allowed = @('*') } [Ordered]@{ Type = 'organization'; Allowed = @('*') } [Ordered]@{ Type = 'organizational_unit'; Allowed = @('*') } [Ordered]@{ Type = 'country'; Allowed = @('*') } [Ordered]@{ Type = 'state'; Allowed = @('*') } [Ordered]@{ Type = 'locality'; Allowed = @('*') } ) SubjectAltNames = @( [Ordered]@{ Type = 'dns_name'; Allowed = @('*') } [Ordered]@{ Type = 'ip_address'; Allowed = @('*') } ) } [Ordered]@{ Name = 'ec-code-signing' Description = 'Code signing. ECDSA P-384, code signing only.' MaxValidity = '365d' KeyAlgorithms = @('EC_secp384r1') SignatureAlgorithms = @('ECDSA-SHA384') KeyUsages = [Ordered]@{ Required = @('digital_signature') } ExtendedKeyUsages = [Ordered]@{ Required = @('code_signing'); Denied = @('server_auth', 'client_auth') } Subject = @( [Ordered]@{ Type = 'common_name'; Allowed = @('*') } [Ordered]@{ Type = 'organization'; Allowed = @('*') } [Ordered]@{ Type = 'organizational_unit'; Allowed = @('*') } [Ordered]@{ Type = 'country'; Allowed = @('*') } ) SubjectAltNames = @() } ) <# Enrollment is configured twice, at two different levels, and only the second one is what clients actually use. 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. 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 = @( <# 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' Description = 'SCCM/MECM site systems and clients.' CertificateAuthority = 'rsa-issuing-ca' CertificatePolicy = 'mecm-server-client-auth' Defaults = [Ordered]@{ KeyAlgorithm = 'RSA_2048' SignatureAlgorithm = 'RSA-SHA256' KeyUsages = @('digital_signature', 'key_encipherment') 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 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 cannot satisfy, so leaving it enforced makes the profile unusable rather than safer. Turn it off if the names being issued live in a zone Infisical can actually resolve. #> [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 name, and neither says anything about who may sign code. #> [Ordered]@{ Slug = 'ec-code-signing' Description = 'Code signing certificates.' CertificateAuthority = 'ec-issuing-ca' CertificatePolicy = 'ec-code-signing' Defaults = [Ordered]@{ KeyAlgorithm = 'EC_secp384r1' SignatureAlgorithm = 'ECDSA-SHA384' KeyUsages = @('digital_signature') ExtendedKeyUsages = @('code_signing') } Enrollment = [Ordered]@{ Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 } } } ) } #endregion #region Infrastructure $Script:BearerToken = $Null $Script:ApiRoot = $BaseUri.TrimEnd('/') $Script:ScepChallenge = $Null $Script:ScepChallengeGenerated = $False function Write-Step { param([String]$Message, [String]$Status = 'Info') $prefix = switch ($Status) { 'Created' { ' [+]' } 'Exists' { ' [=]' } 'WhatIf' { ' [?]' } 'Sub' { ' ' } default { '==>' } } Write-Host ("{0} {1}" -f $prefix, $Message) } function Get-ApiProperty { <# Reads a property that may not be present. Set-StrictMode makes an absent property a terminating error, and response shapes vary across Infisical versions and route namespaces, so every read of an API response goes through here rather than risking a crash on a field the server did not send. #> param($InputObject, [Parameter(Mandatory = $True)][String]$Name) if ($Null -eq $InputObject) { return $Null } $property = $InputObject.PSObject.Properties[$Name] if ($Null -eq $property) { return $Null } return $property.Value } function Get-ApiCollection { <# Returns a list from a response, as an array. Infisical is not consistent about wrapping: the secrets and pki routes wrap a list in a named property, while the cert-manager routes return a bare JSON array. Named properties are tried first, then the response itself if it is already a list, so one call site handles either shape. #> param($InputObject, [Parameter(Mandatory = $True)][String[]]$Name) foreach ($candidate in $Name) { $value = Get-ApiProperty -InputObject $InputObject -Name $candidate if ($Null -ne $value) { return @($value) } } # A bare array carries no named property to find, so fall through to the response itself. Strings are # excluded because they are enumerable but are never a list of resources. if ($Null -ne $InputObject -and $InputObject -isnot [String] -and $InputObject -is [System.Collections.IEnumerable]) { return @($InputObject) } return @() } function Get-ApiObject { <# Returns a single resource from a response, handling the same wrapped-or-bare split as Get-ApiCollection. An unwrapped response is recognised by carrying an id of its own. #> param($InputObject, [Parameter(Mandatory = $True)][String[]]$Name) foreach ($candidate in $Name) { $value = Get-ApiProperty -InputObject $InputObject -Name $candidate if ($Null -ne $value) { return $value } } if ($Null -ne (Get-ApiProperty -InputObject $InputObject -Name 'id')) { return $InputObject } return $Null } function ConvertFrom-SecureStringToPlainText { param([System.Security.SecureString]$Value) $pointer = [System.Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($Value) try { return [System.Runtime.InteropServices.Marshal]::PtrToStringUni($pointer) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($pointer) } } function Invoke-InfisicalApi { <# Single choke point for every call. Adds the bearer token, converts the body, and surfaces the API's own error text, which is far more useful than the raw status line. #> param( [Parameter(Mandatory = $True)][String]$Method, [Parameter(Mandatory = $True)][String]$Path, [Parameter(Mandatory = $False)]$Body, [Parameter(Mandatory = $False)][Switch]$NoAuth ) $uri = "$($Script:ApiRoot)$Path" $parameters = @{ Method = $Method Uri = $uri ContentType = 'application/json' ErrorAction = 'Stop' } if (-not $NoAuth.IsPresent) { $parameters.Headers = @{ Authorization = "Bearer $($Script:BearerToken)" } } if ($Null -ne $Body) { $parameters.Body = ($Body | ConvertTo-Json -Depth 12 -Compress) } if ($SkipCertificateCheck.IsPresent -and $PSVersionTable.PSVersion.Major -ge 6) { $parameters.SkipCertificateCheck = $True } try { return Invoke-RestMethod @parameters } catch { $detail = $Null try { $response = $_.Exception.Response if ($Null -ne $response) { $stream = $response.GetResponseStream() if ($Null -ne $stream) { $stream.Position = 0 $detail = (New-Object System.IO.StreamReader($stream)).ReadToEnd() } } } catch { } if ([String]::IsNullOrWhiteSpace($detail) -and $_.ErrorDetails) { $detail = $_.ErrorDetails.Message } $message = "$Method $Path failed: $($_.Exception.Message)" if (-not [String]::IsNullOrWhiteSpace($detail)) { $message = "$message`n API said: $detail" } throw $message } } function Connect-InfisicalApi { Write-Step "Authenticating to $($Script:ApiRoot)" if ($PSCmdlet.ParameterSetName -eq 'Token') { # A token supplied directly is used as-is. This accepts a machine identity token or the bearer token # from a signed-in browser session, which is the quickest way to seed a throwaway instance. $Script:BearerToken = ConvertFrom-SecureStringToPlainText -Value $AccessToken # Proven before anything is created, so an expired or mistyped token fails here rather than midway # through building a CA hierarchy. The probe is the project listing because that is the next call # the script makes anyway, so a success proves both the token and the route. try { $null = Invoke-InfisicalApi -Method 'GET' -Path '/api/v1/projects' } catch { $Script:BearerToken = $Null # Only an authentication or authorisation failure says anything about the token. Reporting a # 404 or a connection failure as "your token was rejected" sends the reader after the wrong # problem entirely. if ($_.Exception.Message -match '\((401|403)\)|Unauthorized|Forbidden') { throw "The supplied -AccessToken was rejected by $($Script:ApiRoot). If it came from a browser session it may have expired; copy a fresh one. Underlying error: $($_)" } throw "Could not reach $($Script:ApiRoot) to verify the token; this is not a token problem. Underlying error: $($_)" } Write-Step 'Authenticated with the supplied token.' -Status 'Sub' return } $body = @{ clientId = $ClientId clientSecret = (ConvertFrom-SecureStringToPlainText -Value $ClientSecret) } $response = Invoke-InfisicalApi -Method 'POST' -Path '/api/v1/auth/universal-auth/login' -Body $body -NoAuth if ($Null -eq $response -or [String]::IsNullOrWhiteSpace((Get-ApiProperty -InputObject $response -Name 'accessToken'))) { throw 'Authentication succeeded but returned no access token.' } $Script:BearerToken = Get-ApiProperty -InputObject $response -Name 'accessToken' Write-Step 'Authenticated with universal auth.' -Status 'Sub' } function Resolve-OrganizationId { if (-not [String]::IsNullOrWhiteSpace($OrganizationId)) { return $OrganizationId } # The singular v1 route is the listing; /api/v2/organizations mounts only /:organizationId/* sub-routes. $response = Invoke-InfisicalApi -Method 'GET' -Path '/api/v1/organization' $organizations = Get-ApiCollection -InputObject $response -Name 'organizations' if ($organizations.Count -eq 0) { throw 'No organizations are visible to this identity; pass -OrganizationId.' } if ($organizations.Count -gt 1) { $names = ($organizations | ForEach-Object { "$($_.name) ($($_.id))" }) -join ', ' throw "This identity can see multiple organizations; pass -OrganizationId. Visible: $names" } Write-Step "Resolved organization '$($organizations[0].name)' ($($organizations[0].id))." -Status 'Sub' return $organizations[0].id } #endregion #region Seeding function Get-SeededProject { <# Adopts the organization's existing Certificate Manager project rather than creating one whenever the configured slug happens not to match. This matters more than it looks. Certificate applications are served only from the organization's active Certificate Manager project, so a second one would be created successfully and then be unable to hold applications at all. An organization wants one, and the script's job is to populate it. #> param([String]$OrgId) Write-Step 'Project' $response = Invoke-InfisicalApi -Method 'GET' -Path '/api/v1/projects' $all = Get-ApiCollection -InputObject $response -Name 'projects','workspaces' $certManagerProjects = @($all | Where-Object { $_.type -eq 'cert-manager' }) if ($certManagerProjects.Count -gt 0) { $existing = $certManagerProjects | Where-Object { $_.slug -eq $Configuration.Project.Slug } | Select-Object -First 1 if ($Null -eq $existing) { $existing = $certManagerProjects[0] } Write-Step "project '$($existing.name)' ($($existing.id))" -Status 'Exists' if ($existing.slug -ne $Configuration.Project.Slug) { Write-Step "adopting it rather than creating '$($Configuration.Project.Slug)'; an organization serves applications from one Certificate Manager project" -Status 'Sub' } if ($certManagerProjects.Count -gt 1) { Write-Step "note: this organization has $($certManagerProjects.Count) Certificate Manager projects; applications work only in the active one" -Status 'Sub' } return $existing } if (-not $PSCmdlet.ShouldProcess($Configuration.Project.Name, 'Create cert-manager project')) { Write-Step "would create project '$($Configuration.Project.Name)' (this organization has none)" -Status 'WhatIf' return $Null } $body = @{ projectName = $Configuration.Project.Name slug = $Configuration.Project.Slug type = 'cert-manager' projectDescription = $Configuration.Project.Description } $created = Invoke-InfisicalApi -Method 'POST' -Path '/api/v2/workspace' -Body $body $project = Get-ApiObject -InputObject $created -Name 'project','workspace' Write-Step "project '$($project.name)' ($($project.id))" -Status 'Created' return $project } function Get-SeededApplication { <# Applications are how the console groups profiles, members, and certificates, and how Get-InfisicalCertificateProfile -ApplicationId scopes a lookup. Seeding profiles without one leaves an environment the documented workflow cannot navigate. #> param([String]$ProjectId, $Profiles) Write-Step 'Certificate application' if ([String]::IsNullOrWhiteSpace($Configuration.Application.Name)) { Write-Step 'no application configured; skipping' -Status 'Sub' return $Null } $profileIds = @($Configuration.Application.CertificateProfiles | Where-Object { $Profiles.Contains($_) } | ForEach-Object { $Profiles[$_].id }) $existing = $Null if (-not [String]::IsNullOrWhiteSpace($ProjectId)) { $response = Invoke-InfisicalApi -Method 'GET' -Path "/api/v1/cert-manager/applications?projectId=$ProjectId" $existing = (Get-ApiCollection -InputObject $response -Name 'applications') | Where-Object { $_.name -eq $Configuration.Application.Name } | Select-Object -First 1 } if ($Null -ne $existing) { Write-Step "application '$($existing.name)' ($($existing.id))" -Status 'Exists' Add-MissingApplicationProfiles -ProjectId $ProjectId -Application $existing -ProfileIds $profileIds return $existing } if ([String]::IsNullOrWhiteSpace($ProjectId) -or -not $PSCmdlet.ShouldProcess($Configuration.Application.Name, 'Create certificate application')) { Write-Step "would create application '$($Configuration.Application.Name)' with $($profileIds.Count) profile(s)" -Status 'WhatIf' return $Null } $body = [Ordered]@{ projectId = $ProjectId name = $Configuration.Application.Name description = $Configuration.Application.Description } if ($profileIds.Count -gt 0) { $body.profileIds = $profileIds } $created = Invoke-InfisicalApi -Method 'POST' -Path '/api/v1/cert-manager/applications' -Body $body $application = Get-ApiObject -InputObject $created -Name 'application' Write-Step "application '$($application.name)' ($($application.id)) with $($profileIds.Count) profile(s)" -Status 'Created' return $application } function Add-MissingApplicationProfiles { param([String]$ProjectId, $Application, $ProfileIds) if (@($ProfileIds).Count -eq 0) { return } $response = Invoke-InfisicalApi -Method 'GET' -Path "/api/v1/cert-manager/applications/$($Application.id)/profiles?projectId=$ProjectId" # This route returns application-to-profile join rows, which carry the profile under profileId rather # than as an object with an id of its own. $attached = @((Get-ApiCollection -InputObject $response -Name 'profiles') | ForEach-Object { $attachedId = Get-ApiProperty -InputObject $_ -Name 'profileId' if ($Null -eq $attachedId) { $attachedId = Get-ApiProperty -InputObject $_ -Name 'id' } $attachedId } | Where-Object { $Null -ne $_ }) $missing = @($ProfileIds | Where-Object { $attached -notcontains $_ }) if ($missing.Count -eq 0) { Write-Step "all $(@($ProfileIds).Count) configured profile(s) already attached" -Status 'Sub' return } if (-not $PSCmdlet.ShouldProcess($Application.name, "Attach $($missing.Count) profile(s)")) { Write-Step "would attach $($missing.Count) profile(s)" -Status 'WhatIf' return } $Null = Invoke-InfisicalApi -Method 'POST' -Path "/api/v1/cert-manager/applications/$($Application.id)/profiles" -Body @{ projectId = $ProjectId; profileIds = $missing } 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) Write-Step 'Certificate authorities' $resolved = [Ordered]@{} $existingByName = @{} if (-not [String]::IsNullOrWhiteSpace($ProjectId)) { $response = Invoke-InfisicalApi -Method 'GET' -Path "/api/v1/cert-manager/ca/internal?projectId=$ProjectId" foreach ($ca in (Get-ApiCollection -InputObject $response -Name 'certificateAuthorities','cas')) { $existingByName[$ca.name] = $ca } } foreach ($definition in $Configuration.CertificateAuthorities) { if ($existingByName.ContainsKey($definition.Name)) { $existing = $existingByName[$definition.Name] $resolved[$definition.Name] = $existing Write-Step "CA '$($definition.Name)' ($($existing.id)) status=$($existing.status)" -Status 'Exists' continue } if ([String]::IsNullOrWhiteSpace($ProjectId) -or -not $PSCmdlet.ShouldProcess($definition.Name, "Create $($definition.Type) CA")) { Write-Step "would create $($definition.Type) CA '$($definition.Name)' ($($definition.KeyAlgorithm))" -Status 'WhatIf' continue } $notAfter = [DateTime]::UtcNow.AddYears($definition.ValidityYears).ToString('yyyy-MM-ddTHH:mm:ss.fffZ') $caConfiguration = [Ordered]@{ type = $definition.Type commonName = $definition.CommonName organization = $Configuration.Organization.Name ou = $Configuration.Organization.OrganizationalUnit country = $Configuration.Organization.Country province = $Configuration.Organization.State locality = $Configuration.Organization.Locality keyAlgorithm = $definition.KeyAlgorithm } # Infisical only self-signs on creation for a root, and only when it is given an expiry. A # subordinate is created pending and signed in the step below. if ($definition.Type -eq 'root') { $caConfiguration.notAfter = $notAfter $caConfiguration.maxPathLength = $definition.MaxPathLength } $body = @{ projectId = $ProjectId name = $definition.Name status = 'active' configuration = $caConfiguration } $created = Invoke-InfisicalApi -Method 'POST' -Path '/api/v1/cert-manager/ca/internal' -Body $body $ca = Get-ApiObject -InputObject $created -Name 'certificateAuthority','ca' $resolved[$definition.Name] = $ca Write-Step "$($definition.Type) CA '$($definition.Name)' ($($ca.id))" -Status 'Created' if ($definition.Type -eq 'intermediate') { Complete-SubordinateCertificateAuthority -Definition $definition -Subordinate $ca -Resolved $resolved -NotAfter $notAfter } } return $resolved } function Complete-SubordinateCertificateAuthority { <# A subordinate is created with status pending-certificate and no certificate of its own. Infisical exposes no endpoint that creates and signs in one call, so the CSR is fetched, signed by the parent, and imported back. #> param($Definition, $Subordinate, $Resolved, [String]$NotAfter) if ([String]::IsNullOrWhiteSpace($Definition.Parent)) { throw "Subordinate CA '$($Definition.Name)' has no Parent configured." } if (-not $Resolved.Contains($Definition.Parent)) { throw "Subordinate CA '$($Definition.Name)' names parent '$($Definition.Parent)', which was not created or found." } $parent = $Resolved[$Definition.Parent] Write-Step "signing '$($Definition.Name)' with '$($Definition.Parent)'" -Status 'Sub' $csrResponse = Invoke-InfisicalApi -Method 'GET' -Path "/api/v1/pki/ca/$($Subordinate.id)/csr" if ([String]::IsNullOrWhiteSpace((Get-ApiProperty -InputObject $csrResponse -Name 'csr'))) { throw "CA '$($Definition.Name)' returned no CSR to sign." } $signBody = @{ csr = Get-ApiProperty -InputObject $csrResponse -Name 'csr' notAfter = $NotAfter maxPathLength = $Definition.MaxPathLength } $signed = Invoke-InfisicalApi -Method 'POST' -Path "/api/v1/pki/ca/$($parent.id)/sign-intermediate" -Body $signBody $importBody = @{ certificate = Get-ApiProperty -InputObject $signed -Name 'certificate' certificateChain = Get-ApiProperty -InputObject $signed -Name 'certificateChain' } $Null = Invoke-InfisicalApi -Method 'POST' -Path "/api/v1/pki/ca/$($Subordinate.id)/import-certificate" -Body $importBody Write-Step "'$($Definition.Name)' signed and activated" -Status 'Sub' } function Get-SeededCertificatePolicies { param([String]$ProjectId) Write-Step 'Certificate policies' $resolved = [Ordered]@{} $existingByName = @{} if (-not [String]::IsNullOrWhiteSpace($ProjectId)) { $response = Invoke-InfisicalApi -Method 'GET' -Path "/api/v1/cert-manager/certificate-policies?projectId=$ProjectId" foreach ($policy in (Get-ApiCollection -InputObject $response -Name 'certificatePolicies','policies')) { $existingByName[$policy.name] = $policy } } foreach ($definition in $Configuration.CertificatePolicies) { if ($existingByName.ContainsKey($definition.Name)) { $existing = $existingByName[$definition.Name] $resolved[$definition.Name] = $existing Write-Step "policy '$($definition.Name)' ($($existing.id))" -Status 'Exists' continue } if ([String]::IsNullOrWhiteSpace($ProjectId) -or -not $PSCmdlet.ShouldProcess($definition.Name, 'Create certificate policy')) { Write-Step "would create policy '$($definition.Name)'" -Status 'WhatIf' continue } $body = [Ordered]@{ projectId = $ProjectId name = $definition.Name description = $definition.Description validity = @{ max = $definition.MaxValidity } algorithms = @{ keyAlgorithm = @($definition.KeyAlgorithms) signature = @($definition.SignatureAlgorithms) } } $subject = @(ConvertTo-PolicyConstraintList -Definitions $definition.Subject) if ($subject.Count -gt 0) { $body.subject = $subject } $sans = @(ConvertTo-PolicyConstraintList -Definitions $definition.SubjectAltNames) if ($sans.Count -gt 0) { $body.sans = $sans } $keyUsages = ConvertTo-PolicyConstraint -Definition $definition.KeyUsages if ($Null -ne $keyUsages) { $body.keyUsages = $keyUsages } $extendedKeyUsages = ConvertTo-PolicyConstraint -Definition $definition.ExtendedKeyUsages if ($Null -ne $extendedKeyUsages) { $body.extendedKeyUsages = $extendedKeyUsages } $created = Invoke-InfisicalApi -Method 'POST' -Path '/api/v1/cert-manager/certificate-policies' -Body $body $policy = Get-ApiObject -InputObject $created -Name 'certificatePolicy' $resolved[$definition.Name] = $policy Write-Step "policy '$($definition.Name)' ($($policy.id))" -Status 'Created' } return $resolved } function ConvertTo-PolicyConstraint { <# Infisical rejects a constraint object that carries none of allowed/required/denied, so an empty definition becomes null rather than an empty object. #> param($Definition) if ($Null -eq $Definition) { return $Null } $result = [Ordered]@{} foreach ($key in @('Allowed', 'Required', 'Denied')) { if (-not $Definition.Contains($key)) { continue } $values = @($Definition[$key]) if ($values.Count -eq 0) { continue } $result[$key.ToLowerInvariant()] = $values } if ($result.Count -eq 0) { return $Null } return $result } function Get-ScepChallengePassword { <# Returns the shared secret a static SCEP profile enrols against. One value is used for the whole run and reported once at the end, since a caller that was not given the password cannot enrol with it. Supply -ScepChallengePassword to set a known value instead; a generated one exists only in this run's output, and cannot be read back out of Infisical afterwards. #> if ($Null -ne $Script:ScepChallenge) { return $Script:ScepChallenge } if ($PSBoundParameters.ContainsKey('ScepChallengePassword')) { $Script:ScepChallenge = ConvertFrom-SecureStringToPlainText -SecureString $ScepChallengePassword return $Script:ScepChallenge } $bytes = [Byte[]]::new(24) $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } # Base64 minus the characters that get mangled in SCEP client configuration fields. $Script:ScepChallenge = ([Convert]::ToBase64String($bytes) -replace '[+/=]', '').Substring(0, 24) $Script:ScepChallengeGenerated = $True return $Script:ScepChallenge } function ConvertTo-ProfileDefaults { <# Profile defaults are flat: scalars for the algorithms and plain string arrays for the usages, unlike the allowed/required/denied objects a policy takes. Keys are camelCased to match the API. #> param($Definition) if ($Null -eq $Definition) { return $Null } $result = [Ordered]@{} foreach ($key in @('KeyAlgorithm', 'SignatureAlgorithm')) { if (-not $Definition.Contains($key)) { continue } if ([String]::IsNullOrWhiteSpace($Definition[$key])) { continue } $result[$key.Substring(0, 1).ToLowerInvariant() + $key.Substring(1)] = $Definition[$key] } foreach ($key in @('KeyUsages', 'ExtendedKeyUsages')) { if (-not $Definition.Contains($key)) { continue } $values = @($Definition[$key]) if ($values.Count -eq 0) { continue } $result[$key.Substring(0, 1).ToLowerInvariant() + $key.Substring(1)] = $values } if ($result.Count -eq 0) { return $Null } return $result } function ConvertTo-PolicyConstraintList { param($Definitions) $list = @() foreach ($definition in @($Definitions)) { if ($Null -eq $definition) { continue } $constraint = ConvertTo-PolicyConstraint -Definition $definition if ($Null -eq $constraint) { continue } $entry = [Ordered]@{ type = $definition.Type } foreach ($pair in $constraint.GetEnumerator()) { $entry[$pair.Key] = $pair.Value } $list += $entry } return $list } function Get-SeededCertificateProfiles { param([String]$ProjectId, $Authorities, $Policies) Write-Step 'Certificate profiles' $resolved = [Ordered]@{} $existingBySlug = @{} if (-not [String]::IsNullOrWhiteSpace($ProjectId)) { $response = Invoke-InfisicalApi -Method 'GET' -Path "/api/v1/cert-manager/certificate-profiles?projectId=$ProjectId" foreach ($existingProfile in (Get-ApiCollection -InputObject $response -Name 'certificateProfiles','profiles')) { $existingBySlug[$existingProfile.slug] = $existingProfile } } foreach ($definition in $Configuration.CertificateProfiles) { if ($existingBySlug.ContainsKey($definition.Slug)) { $existing = $existingBySlug[$definition.Slug] $resolved[$definition.Slug] = $existing $existingType = Get-ApiProperty -InputObject $existing -Name 'enrollmentType' Write-Step "profile '$($definition.Slug)' ($($existing.id)) enrollment=$existingType" -Status 'Exists' continue } $haveDependencies = $Authorities.Contains($definition.CertificateAuthority) -and $Policies.Contains($definition.CertificatePolicy) if ([String]::IsNullOrWhiteSpace($ProjectId) -or -not $haveDependencies -or -not $PSCmdlet.ShouldProcess($definition.Slug, 'Create certificate profile')) { Write-Step "would create profile '$($definition.Slug)' (CA '$($definition.CertificateAuthority)', policy '$($definition.CertificatePolicy)')" -Status 'WhatIf' continue } <# 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 slug = $definition.Slug description = $definition.Description caId = $Authorities[$definition.CertificateAuthority].id certificatePolicyId = $Policies[$definition.CertificatePolicy].id enrollmentType = 'api' issuerType = 'ca' apiConfig = $apiConfig } $defaults = ConvertTo-ProfileDefaults -Definition $definition.Defaults if ($Null -ne $defaults) { $body.defaults = $defaults } $created = Invoke-InfisicalApi -Method 'POST' -Path '/api/v1/cert-manager/certificate-profiles' -Body $body $createdProfile = Get-ApiObject -InputObject $created -Name 'certificateProfile' $resolved[$definition.Slug] = $createdProfile Write-Step "profile '$($definition.Slug)' ($($createdProfile.id))" -Status 'Created' } return $resolved } #endregion #region Main try { Connect-InfisicalApi $resolvedOrganizationId = Resolve-OrganizationId $project = Get-SeededProject -OrgId $resolvedOrganizationId $projectId = if ($Null -ne $project) { $project.id } else { $Null } $authorities = Get-SeededCertificateAuthorities -ProjectId $projectId $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.' if ($Null -ne $projectId -and $profiles.Count -gt 0) { $firstProfileSlug = @($Configuration.CertificateProfiles)[0].Slug Write-Host '' Write-Host 'Request a certificate against a seeded profile:' Write-Host '' $connectHint = if ($PSCmdlet.ParameterSetName -eq 'Token') { " Connect-Infisical -BaseUri '$($Script:ApiRoot)' -AccessToken `$Token" } else { " Connect-Infisical -BaseUri '$($Script:ApiRoot)' -ClientId '$ClientId' -ClientSecret `$Secret" } Write-Host $connectHint Write-Host " `$Application = Get-InfisicalCertificateApplication | Where-Object {(`$_.Name -ieq '$($Configuration.Application.Name)')}" Write-Host " `$CertificateProfile = Get-InfisicalCertificateProfile -ApplicationId `$Application.Id | Where-Object {(`$_.Slug -ieq '$firstProfileSlug')}" Write-Host " Request-InfisicalCertificate -CertificateProfileId `$CertificateProfile.Id ``" 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 reissue it, since an existing profile is adopted rather than recreated. #> if ($Script:ScepChallengeGenerated) { Write-Host '' Write-Host 'Generated SCEP challenge password (record it now, it cannot be read back):' Write-Host '' Write-Host " $($Script:ScepChallenge)" Write-Host '' Write-Host ' Pass -ScepChallengePassword next time to set a known value instead.' } [PSCustomObject]@{ BaseUri = $Script:ApiRoot OrganizationId = $resolvedOrganizationId ProjectId = $projectId CertificateAuthorities = $authorities CertificatePolicies = $policies CertificateProfiles = $profiles Application = $application Enrollment = $enrollment ScepChallengePassword = $Script:ScepChallenge } } finally { $Script:BearerToken = $Null } #endregion