b27fe6f002
The line still interpolated $enrollmentType, which went away when profile creation stopped varying by enrollment method. Every run since had adopted existing profiles, so the create path never executed and never surfaced it. A run against an emptied project failed there immediately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1233 lines
59 KiB
PowerShell
1233 lines
59 KiB
PowerShell
<#
|
|
.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
|