9 Commits

Author SHA1 Message Date
gsadmin e8113ec073 Merge pull request 'Return the right certificate from the reuse check' (#21) from dev into main
Reviewed-on: #21
2026-07-30 23:59:02 +00:00
gsadmin d5c9eec3fb Require reuse candidates to carry every requested subject alternative name
Publish to PowerShell Gallery / build (pull_request) Successful in 43s
Publish to PowerShell Gallery / release (pull_request) Successful in 12s
Publish to PowerShell Gallery / publish (pull_request) Successful in 9s
Extending -DnsName or -IpAddress and re-running returned the existing
certificate, which lacked the name that had just been added - the reuse check
compared only the common name. A candidate must now carry every requested name,
and the name that disqualified it is reported so the reissue is explainable.

Reading SANs back off an installed certificate needs a decoder: netstandard2.0
has no X509SubjectAlternativeNameExtension, and X509Extension.Format produces
localized text that cannot be compared. The extension is decoded from its DER
bytes with BouncyCastle, already carried for CSR generation.

The rule is coverage rather than equality, since a certificate carrying more
names than requested still satisfies the request. DNS names compare
case-insensitively and IP addresses are normalized through IPAddress, so ::1 and
0:0:0:0:0:0:0:1 are the same name. Trimming the SAN set therefore still reuses;
-Force covers that case.

FindMatch keeps its original four-argument overload so existing callers are
unaffected, and only reports a rejected name when no candidate qualified.

Verified against a live CurrentUser\My store with a certificate carrying
SANPROBE, SANPROBE.contoso.com and 10.20.30.40: identical and subset requests
reuse, a new DNS name or IP forces reissue naming the missing entry, differing
case reuses, and an empty request reuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:51:19 -04:00
gsadmin 67cf0abac2 Scope certificate reuse to the requested issuer
Switching certificate profiles returned the existing certificate instead of
issuing a new one. The reuse check searched Infisical by common name and status
only, so a host already holding a server-authentication certificate for its own
name was handed that certificate back when asking a client-authentication
profile - same subject, wrong extended key usages.

The search is now scoped by -CertificateProfileId or -CertificateAuthorityId.
Both filters already existed on InfisicalCertificateSearchQuery and serialize as
profileIds/caIds; the reuse path simply never set them. The subscriber path
needs no filter because a subscriber pins its own common name, so matching the
name is already equivalent to matching the subscriber.

A second defect compounded it: InfisicalLocalCertificateLookup.FindMatch only
applies its serial filter when the candidate set is non-empty, so a search that
legitimately returned nothing degraded into a name-only local match - exactly
the case that hands back another issuer's certificate. A completed search that
finds nothing is now a definite "nothing to reuse".

The lenient fallback is kept for the case where Infisical cannot be reached,
since failing a renewal because the API is down is worse, but it now announces
itself as a warning rather than being silent.

Reuse still does not compare subject alternative names; that gap is documented
with -Force as the workaround rather than half-addressed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:44:59 -04:00
gsadmin 09af8939c7 Merge pull request 'Fix certificate chain install hang, elevation-aware stores, and idempotent gallery publish' (#20) from dev into main
Reviewed-on: #20
2026-07-30 23:20:57 +00:00
gsadmin dadba2f4c8 Make the PowerShell Gallery publish step idempotent
Publish to PowerShell Gallery / build (pull_request) Successful in 25s
Publish to PowerShell Gallery / release (pull_request) Successful in 10s
Publish to PowerShell Gallery / publish (pull_request) Successful in 8s
The publish job failed on PR #19 with a 409 for version 2026.7.30.2309, but
that version is live on the gallery (created 23:10:04) - the push landed and
the client still surfaced an error, so the retry collided with the upload that
had just succeeded. The run went red over a publish that actually worked.

The step now checks the gallery before pushing and skips when the version is
already there, and on a publish error it re-checks before failing. This mirrors
the release job, which already looks for an existing tag and skips.

Version comparison normalizes each segment the way NuGet does, since the
manifest carries zero-padded segments (2026.07.30.2309) while the gallery lists
the stripped form (2026.7.30.2309); comparing the raw strings would never match
and the guard would never fire.

Verified: both workflows still parse as YAML with the same three jobs, the
normalizer reproduces the gallery form for four published versions, and a live
lookup confirms the guard would have exited 0 on the run that failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:15:13 -04:00
gsadmin c114bd3b9c Merge pull request 'Correct issuance-path guidance: profiles for fleets, subscribers are per-identity' (#19) from dev into main
Reviewed-on: #19
2026-07-30 23:08:58 +00:00
gsadmin 883322cadf Install issuers before the leaf, verify the chain, and name the leaf by hostname
Publish to PowerShell Gallery / build (pull_request) Successful in 26s
Publish to PowerShell Gallery / release (pull_request) Successful in 9s
Publish to PowerShell Gallery / publish (pull_request) Failing after 8s
Chain members are now installed before the leaf, so the certificate is
chainable the moment it appears in the store rather than momentarily orphaned.

After -InstallChain the chain is validated against the machine's own stores.
An incomplete result is reported as a warning naming the certificate whose
issuer is missing, which is the exact condition Windows surfaces as "The issuer
of this certificate could not be found" - previously that was only discoverable
in certmgr after the fact.

Chain routing is unchanged and already handles arbitrary depth: a self-signed
certificate is a root and goes to the trusted-root store, anything with an
issuer above it is a subordinate CA and goes to the intermediate store. Only
the leaf honours -StoreName (default My). This is now stated in the docs,
because the split was not obvious.

The installed certificate's Windows friendly name defaults to the common name
in upper case, which is what operators look for in certmgr. -FriendlyName
overrides it and moves from the ByCa parameter set to all of them; the CA path
still forwards the same value to Infisical as the issued certificate's
friendlyName.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:06:13 -04:00
gsadmin f65124fd99 Fix chain-install hang and pick the certificate store by process elevation
Request-InfisicalCertificate -InstallChain could hang indefinitely. Adding a
root certificate to CurrentUser\Root makes Windows raise a modal trust
confirmation dialog, and X509Store.Add blocks until it is answered. When that
dialog was hidden or the session non-interactive (scheduled task, MECM task
sequence) the cmdlet appeared to stop right after installing the intermediate,
with no indication why. A warning is now emitted before the blocking call.

-StoreLocation now defaults to the process elevation when the caller does not
supply it: LocalMachine when elevated, CurrentUser otherwise. This is what most
callers want, and it sidesteps the trust prompt entirely because writing
LocalMachine\Root already required elevation. Applied to both
Request-InfisicalCertificate and Install-InfisicalCertificate; the resolved
value is reported on the verbose stream and an explicit -StoreLocation wins.

Chain routing is unchanged and already correct: self-signed certificates go to
the Root store and everything else to CertificateAuthority, within whichever
location was resolved.

When the resolved location is LocalMachine and -KeyStorageFlags was not
supplied, the private key is written to the machine key store. Without this the
key lands in the calling user's profile while the certificate sits in
LocalMachine\My, which is the usual cause of an installed certificate that
reports no usable private key to a service.

Reuse detection now searches the store location the install will write to
rather than always searching CurrentUser, so -AllowRenewal and the existing
certificate short-circuit behave consistently with where certificates land.

Elevation detection moved to InfisicalCmdletBase (evaluated through the engine,
since the module targets netstandard2.0 and carries no
System.Security.Principal.Windows reference) and is shared with
Write-InfisicalScepMdmProfileToWmi, which loses its private copy.

README gains the fuller worked example, a genericized output transcript, and a
"Where certificates get installed" section; cmdlet help updated to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:00:06 -04:00
gsadmin f56fd15b38 Correct issuance-path guidance: profiles for fleets, subscribers are per-identity
The subscriber guidance shipped in #18 was wrong for fleet enrollment.
signSubscriberCert rejects any CSR whose CN differs from the subscriber's
commonName, and allowlists the subscriber's subjectAlternativeNames, so a
subscriber is a single named identity rather than a template. Enrolling N
machines through subscribers would require N subscribers.

Certificate profiles are the correct path: they accept a per-request common
name constrained by policy allowed/required/denied lists, and profile issuance
is the only path that skips the CA direct-issuance gate
(!isFromProfile && !ca.enableDirectIssuance && !certificateTemplate), so a
profile issues against a CA whose EnableDirectIssuance is False.

Also documents that enableDirectIssuance cannot be changed after CA creation:
it appears in no Infisical create or update schema (the generic CA schemas
accept only name and status). Migration 20250521110635_add-external-ca-pki.ts
renamed requireTemplateForIssuance to enableDirectIssuance and inverted every
existing value, so CAs that previously required a template now read False
permanently. The remedies are a profile, or a new CA (column defaults to true).

README end-to-end example switched from subscriber to profile issuance, and the
issuance-path table now leads with whether the common name varies per request.
Cmdlet help for Request-InfisicalCertificate and Get-InfisicalCertificateAuthority
updated to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:40:17 -04:00
18 changed files with 1465 additions and 81 deletions
+62 -6
View File
@@ -319,9 +319,65 @@ jobs:
run: |
$ErrorActionPreference = 'Stop'
$moduleDir = Join-Path $PWD 'Module/PSInfisicalAPI'
Write-Host "Publishing module from: $moduleDir"
Publish-PSResource `
-Path $moduleDir `
-Repository PSGallery `
-ApiKey $env:PSGALLERY_API_KEY `
-Verbose
$manifest = Test-ModuleManifest -Path (Join-Path $moduleDir 'PSInfisicalAPI.psd1')
$version = $manifest.Version.ToString()
# NuGet strips leading zeros from each segment, so the manifest's 2026.07.30.2309 is listed on the
# gallery as 2026.7.30.2309. Compare on the normalized form or every lookup misses.
function Get-NormalizedVersion {
param([string]$Value)
$parts = $Value -split '\.'
$normalized = foreach ($part in $parts) {
$number = 0
if ([int]::TryParse($part, [ref]$number)) { $number.ToString([System.Globalization.CultureInfo]::InvariantCulture) } else { $part }
}
return ($normalized -join '.')
}
function Test-PublishedVersion {
param([string]$Normalized)
$uri = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PSInfisicalAPI'&`$select=Version"
try {
$feed = Invoke-RestMethod -Uri $uri -TimeoutSec 120
} catch {
Write-Host "==> Could not query the gallery for existing versions: $($_.Exception.Message)"
return $false
}
foreach ($entry in @($feed)) {
$candidate = $entry.properties.Version
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
if ((Get-NormalizedVersion -Value $candidate) -eq $Normalized) { return $true }
}
return $false
}
$normalizedVersion = Get-NormalizedVersion -Value $version
Write-Host "==> Module version : $version (gallery form: $normalizedVersion)"
Write-Host "==> Publishing from: $moduleDir"
if (Test-PublishedVersion -Normalized $normalizedVersion) {
Write-Host "==> Version $normalizedVersion is already on the PowerShell Gallery; nothing to publish."
exit 0
}
try {
Publish-PSResource `
-Path $moduleDir `
-Repository PSGallery `
-ApiKey $env:PSGALLERY_API_KEY `
-Verbose
Write-Host "==> Published $normalizedVersion to the PowerShell Gallery."
} catch {
# A push can be accepted by the gallery and still surface as an error here; when that happens the
# retry comes back as 409. Re-check before failing the run over an upload that actually landed.
Write-Host "==> Publish-PSResource reported: $($_.Exception.Message)"
Start-Sleep -Seconds 15
if (Test-PublishedVersion -Normalized $normalizedVersion) {
Write-Host "==> Version $normalizedVersion is present on the gallery; treating the publish as successful."
exit 0
}
throw
}
+62 -6
View File
@@ -320,9 +320,65 @@ jobs:
run: |
$ErrorActionPreference = 'Stop'
$moduleDir = Join-Path $PWD 'Module/PSInfisicalAPI'
Write-Host "Publishing module from: $moduleDir"
Publish-PSResource `
-Path $moduleDir `
-Repository PSGallery `
-ApiKey $env:PSGALLERY_API_KEY `
-Verbose
$manifest = Test-ModuleManifest -Path (Join-Path $moduleDir 'PSInfisicalAPI.psd1')
$version = $manifest.Version.ToString()
# NuGet strips leading zeros from each segment, so the manifest's 2026.07.30.2309 is listed on the
# gallery as 2026.7.30.2309. Compare on the normalized form or every lookup misses.
function Get-NormalizedVersion {
param([string]$Value)
$parts = $Value -split '\.'
$normalized = foreach ($part in $parts) {
$number = 0
if ([int]::TryParse($part, [ref]$number)) { $number.ToString([System.Globalization.CultureInfo]::InvariantCulture) } else { $part }
}
return ($normalized -join '.')
}
function Test-PublishedVersion {
param([string]$Normalized)
$uri = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PSInfisicalAPI'&`$select=Version"
try {
$feed = Invoke-RestMethod -Uri $uri -TimeoutSec 120
} catch {
Write-Host "==> Could not query the gallery for existing versions: $($_.Exception.Message)"
return $false
}
foreach ($entry in @($feed)) {
$candidate = $entry.properties.Version
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
if ((Get-NormalizedVersion -Value $candidate) -eq $Normalized) { return $true }
}
return $false
}
$normalizedVersion = Get-NormalizedVersion -Value $version
Write-Host "==> Module version : $version (gallery form: $normalizedVersion)"
Write-Host "==> Publishing from: $moduleDir"
if (Test-PublishedVersion -Normalized $normalizedVersion) {
Write-Host "==> Version $normalizedVersion is already on the PowerShell Gallery; nothing to publish."
exit 0
}
try {
Publish-PSResource `
-Path $moduleDir `
-Repository PSGallery `
-ApiKey $env:PSGALLERY_API_KEY `
-Verbose
Write-Host "==> Published $normalizedVersion to the PowerShell Gallery."
} catch {
# A push can be accepted by the gallery and still surface as an error here; when that happens the
# retry comes back as 409. Re-check before failing the run over an upload that actually landed.
Write-Host "==> Publish-PSResource reported: $($_.Exception.Message)"
Start-Sleep -Seconds 15
if (Test-PublishedVersion -Normalized $normalizedVersion) {
Write-Host "==> Version $normalizedVersion is present on the gallery; treating the publish as successful."
exit 0
}
throw
}
+53 -1
View File
@@ -6,11 +6,63 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
## Unreleased
## 2026.07.30.2350
- Build produced from commit 67cf0abac2dd.
## Unreleased (carried forward)
## 2026.07.30.2344
- Build produced from commit dadba2f4c890.
## Unreleased (carried forward)
## 2026.07.30.2305
- Build produced from commit f65124fd9911.
## Unreleased (carried forward)
## 2026.07.30.2259
- Build produced from commit f56fd15b3864.
## Unreleased (carried forward)
### Fixed (certificate reuse)
- **Switching certificate profiles reused the old certificate.** The reuse check matched on common name alone, so requesting from a client-authentication profile on a host already holding a server-authentication certificate for the same name returned the existing certificate — with the wrong extended key usages. The reuse search is now scoped by `-CertificateProfileId` or `-CertificateAuthorityId`.
- A reuse search that completed and found nothing no longer falls through to a name-only local match. Previously an empty result disabled the serial filter entirely, which is what allowed a certificate from another issuer to be returned.
- **Adding a SAN reused the old certificate.** Reuse compared only the common name, so extending `-DnsName` or `-IpAddress` returned the existing certificate without the new name. A candidate must now carry every requested name, and the name that disqualified it is reported. Comparison is coverage rather than equality — extra names on the certificate still qualify — with case-insensitive DNS matching and normalized IP addresses so `::1` matches `0:0:0:0:0:0:0:1`.
- When Infisical cannot be reached the check still falls back to matching on the common name, but now says so with a warning instead of silently.
### Fixed (certificate installation)
- **`Request-InfisicalCertificate -InstallChain` could hang indefinitely.** Adding a root certificate to `CurrentUser\Root` makes Windows raise a modal trust confirmation dialog, and `X509Store.Add` blocks until it is answered; when that dialog was hidden or the session non-interactive, the cmdlet appeared to stop right after installing the intermediate. A warning is now emitted before the blocking call, and the new elevation-aware default avoids the prompt entirely for elevated sessions.
- Chain members are installed **before** the leaf, so the certificate is chainable the moment it lands in the store.
### Added (certificate installation)
- After `-InstallChain`, the chain is validated against the machine's stores. An incomplete chain is reported as a warning naming the certificate whose issuer is missing — the condition Windows surfaces as "The issuer of this certificate could not be found".
- The installed certificate's Windows friendly name defaults to the common name in upper case. `-FriendlyName` overrides it and is now accepted on every parameter set (previously `-CertificateAuthorityId` only, where it is still forwarded to Infisical as the issued certificate's `friendlyName`).
- `-StoreLocation` defaults to the process elevation when not supplied: `LocalMachine` when elevated, `CurrentUser` otherwise. Applies to `Request-InfisicalCertificate` and `Install-InfisicalCertificate`, and the resolved value is reported on the verbose stream. Explicitly passing `-StoreLocation` still wins.
- When the resolved location is `LocalMachine` and `-KeyStorageFlags` was not supplied, the private key is written to the machine key store, so an installed certificate has a usable key outside the calling user's profile.
- Reuse detection searches the same store location the install will write to, instead of always searching `CurrentUser`.
- Elevation detection moved to `InfisicalCmdletBase` and is shared with `Write-InfisicalScepMdmProfileToWmi`.
## 2026.07.30.2239
- Build produced from commit f62b3e90b1b1.
## Unreleased (carried forward)
## 2026.07.30.2151
- Build produced from commit 14c8c4f3845b.
## Unreleased (carried forward)
## Unreleased (carried forward)
### Breaking
+2 -2
View File
@@ -1,6 +1,6 @@
@{
RootModule = 'PSInfisicalAPI.psm1'
ModuleVersion = '2026.07.30.2151'
ModuleVersion = '2026.07.30.2350'
GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51'
Author = 'Grace Solutions'
CompanyName = 'Grace Solutions'
@@ -74,7 +74,7 @@
LicenseUri = 'https://www.gnu.org/licenses/agpl-3.0.html'
ProjectUri = 'https://prod.git.gracesolution.info/gsadmin/PSInfisicalAPI'
ReleaseNotes = 'See CHANGELOG.md in the project repository for release history.'
CommitHash = '14c8c4f3845b'
CommitHash = '67cf0abac2dd'
}
}
}
Binary file not shown.
@@ -1066,7 +1066,7 @@ $RemoveInfisicalTagResult = Remove-InfisicalTag @RemoveInfisicalTagParameters</d
<maml:alertSet>
<maml:title>Notes</maml:title>
<maml:alert>
<maml:para>ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used. Only CAs whose EnableDirectIssuance property is True can sign a CSR through -CertificateAuthorityId; the others require a subscriber or certificate profile instead.</maml:para>
<maml:para>ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used. Only CAs whose EnableDirectIssuance property is True can sign a CSR through -CertificateAuthorityId; the others must issue through Request-InfisicalCertificate -CertificateProfileId, which bypasses that check. EnableDirectIssuance is fixed at CA creation and appears in no Infisical update schema, so it cannot be toggled afterwards; a CA migrated from the older requireTemplateForIssuance column reads False permanently.</maml:para>
</maml:alert>
</maml:alertSet>
<command:examples>
@@ -1281,8 +1281,15 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
<maml:title>Notes</maml:title>
<maml:alert>
<maml:para>Default -PrivateKeyProtection is 'LocalOnly': the leaf is loaded into memory without persisting the private key and PrivateKeyPem is scrubbed from the emitted result unless -PrivateKeyPath or an explicit -KeyStorageFlags binding overrides it. The reuse path completes its chain from the Infisical bundle when local stores are incomplete; pass -LocalChainOnly to suppress that fetch entirely.</maml:para>
<maml:para>-CertificateAuthorityId only works against a CA that permits direct issuance (Get-InfisicalCertificateAuthority reports this as EnableDirectIssuance). The cmdlet resolves the issuer and validates this before generating a keypair, naming the subscriber, CA, or profile it will use on the verbose stream and in the -WhatIf target. There is no -CertificateTemplateId parameter because Infisical's REST API exposes no template-based issuance route; when the API asks for 'a certificate template or subscriber', supply -PkiSubscriberSlug or -CertificateProfileId, or enable direct issuance on the CA.</maml:para>
<maml:para>Choose the parameter set by whether the common name varies per request. -CertificateProfileId and -CertificateAuthorityId both accept a per-request common name and suit fleet enrollment; -PkiSubscriberSlug does not, because Infisical rejects any CSR whose CN differs from the subscriber's ('Common name (CN) in the CSR does not match the subscriber's common name') and allowlists the subscriber's subjectAlternativeNames. A subscriber is a single named identity, so enrolling many machines through subscribers requires one subscriber per machine.</maml:para>
<maml:para>-CertificateAuthorityId only works against a CA that permits direct issuance (Get-InfisicalCertificateAuthority reports this as EnableDirectIssuance). The cmdlet resolves the issuer and validates this before generating a keypair, naming the subscriber, CA, or profile it will use on the verbose stream and in the -WhatIf target. Profile issuance is the only path that ignores that flag, so -CertificateProfileId works against a CA whose EnableDirectIssuance is False. Note that enableDirectIssuance appears in no Infisical create or update schema, so it cannot be changed through the API or UI after the CA exists.</maml:para>
<maml:para>There is no -CertificateTemplateId parameter because Infisical's REST API exposes no template-based issuance route; when the API asks for 'a certificate template or subscriber', supply -CertificateProfileId or -PkiSubscriberSlug, or use a CA that allows direct issuance.</maml:para>
<maml:para>-CommonName takes the bare value ('web01.contoso.com'), not an RDN; a leading 'CN=' is stripped because the CSR builder adds the prefix itself. -DnsName accepts the mixed output of Get-InfisicalSANList: IP literals in that list are emitted as iPAddress SAN entries rather than dNSName entries.</maml:para>
<maml:para>When -StoreLocation is not supplied it is chosen from the process elevation: an elevated session installs to LocalMachine, otherwise CurrentUser. The choice is reported on the verbose stream. Chain members are routed by type regardless of location - self-signed certificates to the Root store, others to CertificateAuthority. When the resolved location is LocalMachine and -KeyStorageFlags was not supplied, the private key is placed in the machine key store so the installed certificate has a usable key outside the calling user's profile.</maml:para>
<maml:para>Installing a root into CurrentUser\Root makes Windows display a modal trust confirmation dialog, and the call blocks until it is answered; in a non-interactive session this looks like a hang. The cmdlet emits a warning before blocking. Run elevated or pass -StoreLocation LocalMachine to install machine-wide without a prompt.</maml:para>
<maml:para>Only the leaf honours -StoreName (default My). Chain members are routed by what they are: a self-signed certificate is a root and goes to the trusted-root store, anything with an issuer above it is a subordinate CA and goes to the intermediate store, for a chain of any depth. Issuers are installed before the leaf, and the chain is then validated against the machine's stores; an incomplete chain is reported as a warning naming the missing issuer, which is the condition Windows shows as "The issuer of this certificate could not be found".</maml:para>
<maml:para>The installed certificate's Windows friendly name defaults to the common name in upper case. -FriendlyName overrides it and is accepted on every parameter set; on the -CertificateAuthorityId path the same value is additionally sent to Infisical as the issued certificate's friendlyName.</maml:para>
<maml:para>The reuse check is scoped to the issuer being requested: the search is filtered by -CertificateProfileId or -CertificateAuthorityId, so a certificate issued by a different profile is not reused. This matters when two profiles over one CA differ in key usage, such as server authentication versus client authentication, where a common-name match alone would return a certificate with the wrong extended key usages. Reuse additionally requires the existing certificate to carry every requested subject alternative name, so adding an entry to -DnsName or -IpAddress issues a new certificate instead of returning one that would fail validation for the new name. The rule is coverage rather than equality: a certificate carrying more names than requested still qualifies, DNS names compare case-insensitively, and IP addresses are normalized so ::1 matches 0:0:0:0:0:0:0:1. Use -Force when the SAN set needs trimming rather than extending. When Infisical cannot be reached the check falls back to matching on the common name alone and says so with a warning.</maml:para>
</maml:alert>
</maml:alertSet>
<command:examples>
@@ -1066,7 +1066,7 @@ $RemoveInfisicalTagResult = Remove-InfisicalTag @RemoveInfisicalTagParameters</d
<maml:alertSet>
<maml:title>Notes</maml:title>
<maml:alert>
<maml:para>ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used. Only CAs whose EnableDirectIssuance property is True can sign a CSR through -CertificateAuthorityId; the others require a subscriber or certificate profile instead.</maml:para>
<maml:para>ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used. Only CAs whose EnableDirectIssuance property is True can sign a CSR through -CertificateAuthorityId; the others must issue through Request-InfisicalCertificate -CertificateProfileId, which bypasses that check. EnableDirectIssuance is fixed at CA creation and appears in no Infisical update schema, so it cannot be toggled afterwards; a CA migrated from the older requireTemplateForIssuance column reads False permanently.</maml:para>
</maml:alert>
</maml:alertSet>
<command:examples>
@@ -1281,8 +1281,15 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
<maml:title>Notes</maml:title>
<maml:alert>
<maml:para>Default -PrivateKeyProtection is 'LocalOnly': the leaf is loaded into memory without persisting the private key and PrivateKeyPem is scrubbed from the emitted result unless -PrivateKeyPath or an explicit -KeyStorageFlags binding overrides it. The reuse path completes its chain from the Infisical bundle when local stores are incomplete; pass -LocalChainOnly to suppress that fetch entirely.</maml:para>
<maml:para>-CertificateAuthorityId only works against a CA that permits direct issuance (Get-InfisicalCertificateAuthority reports this as EnableDirectIssuance). The cmdlet resolves the issuer and validates this before generating a keypair, naming the subscriber, CA, or profile it will use on the verbose stream and in the -WhatIf target. There is no -CertificateTemplateId parameter because Infisical's REST API exposes no template-based issuance route; when the API asks for 'a certificate template or subscriber', supply -PkiSubscriberSlug or -CertificateProfileId, or enable direct issuance on the CA.</maml:para>
<maml:para>Choose the parameter set by whether the common name varies per request. -CertificateProfileId and -CertificateAuthorityId both accept a per-request common name and suit fleet enrollment; -PkiSubscriberSlug does not, because Infisical rejects any CSR whose CN differs from the subscriber's ('Common name (CN) in the CSR does not match the subscriber's common name') and allowlists the subscriber's subjectAlternativeNames. A subscriber is a single named identity, so enrolling many machines through subscribers requires one subscriber per machine.</maml:para>
<maml:para>-CertificateAuthorityId only works against a CA that permits direct issuance (Get-InfisicalCertificateAuthority reports this as EnableDirectIssuance). The cmdlet resolves the issuer and validates this before generating a keypair, naming the subscriber, CA, or profile it will use on the verbose stream and in the -WhatIf target. Profile issuance is the only path that ignores that flag, so -CertificateProfileId works against a CA whose EnableDirectIssuance is False. Note that enableDirectIssuance appears in no Infisical create or update schema, so it cannot be changed through the API or UI after the CA exists.</maml:para>
<maml:para>There is no -CertificateTemplateId parameter because Infisical's REST API exposes no template-based issuance route; when the API asks for 'a certificate template or subscriber', supply -CertificateProfileId or -PkiSubscriberSlug, or use a CA that allows direct issuance.</maml:para>
<maml:para>-CommonName takes the bare value ('web01.contoso.com'), not an RDN; a leading 'CN=' is stripped because the CSR builder adds the prefix itself. -DnsName accepts the mixed output of Get-InfisicalSANList: IP literals in that list are emitted as iPAddress SAN entries rather than dNSName entries.</maml:para>
<maml:para>When -StoreLocation is not supplied it is chosen from the process elevation: an elevated session installs to LocalMachine, otherwise CurrentUser. The choice is reported on the verbose stream. Chain members are routed by type regardless of location - self-signed certificates to the Root store, others to CertificateAuthority. When the resolved location is LocalMachine and -KeyStorageFlags was not supplied, the private key is placed in the machine key store so the installed certificate has a usable key outside the calling user's profile.</maml:para>
<maml:para>Installing a root into CurrentUser\Root makes Windows display a modal trust confirmation dialog, and the call blocks until it is answered; in a non-interactive session this looks like a hang. The cmdlet emits a warning before blocking. Run elevated or pass -StoreLocation LocalMachine to install machine-wide without a prompt.</maml:para>
<maml:para>Only the leaf honours -StoreName (default My). Chain members are routed by what they are: a self-signed certificate is a root and goes to the trusted-root store, anything with an issuer above it is a subordinate CA and goes to the intermediate store, for a chain of any depth. Issuers are installed before the leaf, and the chain is then validated against the machine's stores; an incomplete chain is reported as a warning naming the missing issuer, which is the condition Windows shows as "The issuer of this certificate could not be found".</maml:para>
<maml:para>The installed certificate's Windows friendly name defaults to the common name in upper case. -FriendlyName overrides it and is accepted on every parameter set; on the -CertificateAuthorityId path the same value is additionally sent to Infisical as the issued certificate's friendlyName.</maml:para>
<maml:para>The reuse check is scoped to the issuer being requested: the search is filtered by -CertificateProfileId or -CertificateAuthorityId, so a certificate issued by a different profile is not reused. This matters when two profiles over one CA differ in key usage, such as server authentication versus client authentication, where a common-name match alone would return a certificate with the wrong extended key usages. Reuse additionally requires the existing certificate to carry every requested subject alternative name, so adding an entry to -DnsName or -IpAddress issues a new certificate instead of returning one that would fail validation for the new name. The rule is coverage rather than equality: a certificate carrying more names than requested still qualifies, DNS names compare case-insensitively, and IP addresses are normalized so ::1 matches 0:0:0:0:0:0:0:1. Use -Force when the SAN set needs trimming rather than extending. When Infisical cannot be reached the check falls back to matching on the common name alone and says so with a warning.</maml:para>
</maml:alert>
</maml:alertSet>
<command:examples>
+216 -35
View File
@@ -146,7 +146,9 @@ Disconnect-Infisical
## End-to-end: request and install a chained certificate
Connects, selects a `cert-manager` project, sources SANs from `Get-InfisicalSANList`, requests a certificate through a PKI subscriber, installs it (and its chain) into the current-user store, and disconnects. Each call uses a splatted `OrderedDictionary` constructed with `OrdinalIgnoreCase` so parameter names round-trip case-insensitively.
Connects, selects a `cert-manager` project, sources SANs from `Get-InfisicalSANList`, requests a certificate through a certificate profile, installs it (and its chain) into the current-user store, and disconnects. Each call uses a splatted `OrderedDictionary` constructed with `OrdinalIgnoreCase` so parameter names round-trip case-insensitively.
This is the shape to use for **fleet enrollment**, where each machine needs its own common name. See [Choosing an issuance path](#choosing-an-issuance-path) — a PKI subscriber is *not* the right tool for this, because it pins one fixed common name.
```powershell
$ConnectInfisicalParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
@@ -159,42 +161,205 @@ $ConnectInfisicalParameters = New-Object -TypeName 'System.Collections.Specializ
$Connection = Connect-Infisical @ConnectInfisicalParameters
$Project = Get-InfisicalProject -Type cert-manager | Where-Object {($_.Name -eq 'Platform')} | Select-Object -First 1
$Subscriber = Get-InfisicalPkiSubscriber -ProjectId ($Project.Id) | Select-Object -First 1
$SanList = Get-InfisicalSANList
$Project = Get-InfisicalProject -Type cert-manager | Select-Object -First 1
$Project
#region Certificate authorities. Not required for profile issuance - the profile already binds its CA - but
# useful for confirming the chain you expect to be installed.
$CAList = Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal
$RootCA = $CAList | Where-Object {([String]::IsNullOrEmpty($_.ParentCaId) -eq $True)} | Select-Object -First 1
$RootCA
$IntermediateCA = $CAList | Where-Object {([String]::IsNullOrEmpty($_.ParentCaId) -eq $False)}
$IntermediateCA
#endregion
$CertificateProfile = Get-InfisicalCertificateProfile -ProjectId ($Project.Id) -IncludeConfigs |
Where-Object {($_.EnrollmentType -iin @('API')) -and ($_.Slug -imatch '.*Server.*')} |
Select-Object -First 1
$CertificateProfile
$SanList = Get-InfisicalSANList
$SanList
$RequestInfisicalCertificateParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
$RequestInfisicalCertificateParameters.ProjectId = $Project.Id
$RequestInfisicalCertificateParameters.PkiSubscriberSlug = $Subscriber.Name
$RequestInfisicalCertificateParameters.CommonName = $Env:ComputerName.ToUpper()
$RequestInfisicalCertificateParameters.DnsName = New-Object -TypeName 'System.Collections.Generic.List[System.String]'
$RequestInfisicalCertificateParameters.ProjectId = $Project.Id
$RequestInfisicalCertificateParameters.CertificateProfileId = $CertificateProfile.Id
$RequestInfisicalCertificateParameters.CommonName = $Env:ComputerName.ToUpper()
$RequestInfisicalCertificateParameters.DnsName = New-Object -TypeName 'System.Collections.Generic.List[System.String]'
$RequestInfisicalCertificateParameters.DnsName.AddRange($SanList)
$RequestInfisicalCertificateParameters.DnsName.Add('myrecord.mydomain.com')
$RequestInfisicalCertificateParameters.Install = $True
$RequestInfisicalCertificateParameters.InstallChain = $True
$RequestInfisicalCertificateParameters.Verbose = $True
$RequestInfisicalCertificateParameters.DnsName.Add('app.contoso.com')
$RequestInfisicalCertificateParameters.DnsName.Add('api.contoso.com')
$RequestInfisicalCertificateParameters.DnsName.Add('boot.contoso.com')
$RequestInfisicalCertificateParameters.Ttl = '90d'
$RequestInfisicalCertificateParameters.Install = $True
$RequestInfisicalCertificateParameters.InstallChain = $True
$RequestInfisicalCertificateParameters.Verbose = $True
$Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParameters
$Null = Disconnect-Infisical -Verbose
```
Note `$CertificateProfile` rather than `$Profile`: `$Profile` is an automatic variable in PowerShell (the path to the current profile script), and assigning to it works but shadows something the host relies on.
`-StoreName`/`-StoreLocation` are omitted deliberately — see [Where certificates get installed](#where-certificates-get-installed).
### Example output
```text
Id : 00000000-0000-0000-0000-000000000000
Name : Microsoft Endpoint Configuration Manager
Slug : mecm
Description :
OrganizationId : 11111111-1111-1111-1111-111111111111
Type : cert-manager
AutoCapitalization : False
EnvironmentSlugs : {dev, staging, prod}
CreatedAtUtc : 3/12/2026 8:32:52 PM +00:00
UpdatedAtUtc : 6/21/2026 7:00:28 PM +00:00
Name : root-ca
CommonName : Contoso Root Certificate Authority
Type : internal
Status : active
KeyAlgorithm : RSA_2048
NotAfter : 03/25/2036 00:00:00
Id : 22222222-2222-2222-2222-222222222222
Name : intermediate-ca
CommonName : Contoso Intermediate Certificate Authority
Type : internal
Status : active
KeyAlgorithm : RSA_2048
NotAfter : 03/25/2031 00:00:00
Id : 33333333-3333-3333-3333-333333333333
Id : 44444444-4444-4444-4444-444444444444
ProjectId : 00000000-0000-0000-0000-000000000000
CaId : 33333333-3333-3333-3333-333333333333
CertificatePolicyId : 55555555-5555-5555-5555-555555555555
Slug : serverauthentication
Description :
EnrollmentType : api
IssuerType : ca
EstConfigId :
ApiConfigId : 66666666-6666-6666-6666-666666666666
AcmeConfigId :
ScepConfigId :
CreatedAtUtc : 3/25/2026 4:53:53 PM +00:00
UpdatedAtUtc : 3/25/2026 4:53:53 PM +00:00
Defaults : PSInfisicalAPI.Models.InfisicalCertificateProfileDefaults
CertificateAuthority : PSInfisicalAPI.Models.InfisicalCertificateAuthoritySummary
CertificatePolicy :
ApiConfig : PSInfisicalAPI.Models.InfisicalCertificateProfileApiConfig
WEB01
10.20.30.40
WEB01.contoso.com
127.0.0.1
::1
VERBOSE: [...] - [Information] - [PkiClient] - Attempting to search Infisical certificates. Please Wait...
VERBOSE: [...] - [Verbose] - [HttpClient] - Attempting HTTP POST to https://infisical.contoso.com/api/v1/projects/00000000-0000-0000-0000-000000000000/certificates/search. Please Wait...
VERBOSE: [...] - [Verbose] - [HttpClient] - HTTP POST completed with status 200.
VERBOSE: [...] - [Information] - [PkiClient] - Infisical certificate search was successful.
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Process is elevated; defaulting -StoreLocation to LocalMachine. Pass -StoreLocation explicitly to override.
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing via certificate profile '44444444-4444-4444-4444-444444444444' in project '00000000-0000-0000-0000-000000000000'.
VERBOSE: Performing the operation "Request new certificate" on target "certificate profile '44444444-4444-4444-4444-444444444444' for CN=WEB01".
VERBOSE: [...] - [Information] - [PkiClient] - Attempting to issue certificate via profile '44444444-4444-4444-4444-444444444444'. Please Wait...
VERBOSE: [...] - [Verbose] - [HttpClient] - Attempting HTTP POST to https://infisical.contoso.com/api/v1/cert-manager/certificates. Please Wait...
VERBOSE: [...] - [Verbose] - [HttpClient] - HTTP POST completed with status 200.
VERBOSE: [...] - [Information] - [PkiClient] - Infisical certificate issuance (profile) was successful.
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Installed certificate to LocalMachine\My [F480A920DFB41EA8EE3E9178C1BC6A5EC7055B96].
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Installed certificate to LocalMachine\CertificateAuthority [89A486A532D94EFE4391BEF2EA7F5E7E2B654AB0].
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Installed certificate to LocalMachine\Root [1F3A77B0C2D45E6819AB3C7D0E5F2A9B4C81D6E7].
```
### Where certificates get installed
When `-StoreLocation` is not supplied, the cmdlet picks it from the process's elevation, and says which it chose on the verbose stream:
| Session | Leaf | Intermediates | Roots |
| ------------- | -------------------------- | -------------------------------------------- | ---------------------------- |
| Elevated | `LocalMachine\My` | `LocalMachine\CertificateAuthority` | `LocalMachine\Root` |
| Not elevated | `CurrentUser\My` | `CurrentUser\CertificateAuthority` | `CurrentUser\Root` |
The routing split is deliberate:
- **The leaf** honours `-StoreName` (default `My`) and lands in the resolved location.
- **Chain members** ignore `-StoreName` and are routed by what they are — a self-signed certificate is a root and goes to the trusted-root store; anything with an issuer above it is a subordinate CA and goes to the intermediate store. A chain of any depth is walked, so an issuing subordinate installs alongside every CA above it up to the root.
`-StoreLocation` still applies to all of them, so an elevated run puts the whole chain machine-wide and a non-elevated run puts it all under the user.
Issuers are installed **before** the leaf, so the certificate is chainable the moment it appears in the store. Afterwards the chain is validated against the machine's own stores, and an incomplete result is called out by name rather than left to be discovered in `certmgr`:
```text
WARNING: [...] Certificate chain is incomplete (PartialChain). The highest certificate installed is
'CN=Contoso Intermediate Certificate Authority, OU=IT, O=Contoso, C=US', whose issuer
'CN=Contoso Root Certificate Authority, OU=IT, O=Contoso, C=US' is not present in the trusted stores.
Windows will report "The issuer of this certificate could not be found" until that issuer is installed.
```
The installed certificate's Windows friendly name defaults to the common name in upper case (`WEB01`), which is what shows in `certmgr`. Pass `-FriendlyName` on any parameter set to override it; on the `-CertificateAuthorityId` path the same value is also forwarded to Infisical as the issued certificate's `friendlyName`.
### Reuse and renewal
A second run does not issue a new certificate if a still-valid one is already installed. That check is **scoped to the issuer you asked for**, not just the common name: the reuse search is filtered by `-CertificateProfileId` or `-CertificateAuthorityId`, so switching profiles issues a new certificate rather than handing back the old one.
This matters when two profiles over the same CA differ in key usage. Requesting from a client-authentication profile on a host that already holds a server-authentication certificate for the same name issues a new certificate, because a name match alone would return one with the wrong EKUs:
```text
VERBOSE: Reuse search for CN=WEB01 scoped to certificate profile 'a42f8446-...' returned 0 active certificate(s).
```
Reuse also requires the existing certificate to carry **every** name being requested. Adding an entry to `-DnsName` and re-running issues a new certificate rather than returning one that would fail validation for the name you just added:
```text
VERBOSE: An existing certificate for CN=WEB01 does not carry the requested name DNS:api.contoso.com;
requesting a new certificate rather than reusing one that would fail validation for it.
```
The rule is coverage, not equality — a certificate carrying more names than requested still satisfies the request. DNS names compare case-insensitively and IP addresses are normalized, so `::1` matches `0:0:0:0:0:0:0:1`. Removing a name from the request therefore reuses the existing certificate; use `-Force` when you need the SAN set trimmed rather than extended.
`-Force` issues unconditionally, and `-AllowRenewal` with `-RenewalThresholdDays` rotates a certificate that is inside its renewal window.
If Infisical cannot be reached, the reuse check cannot confirm which certificates belong to which issuer and falls back to matching on the common name alone. That is announced as a warning, since it can return a certificate from a different profile.
When the resolved location is `LocalMachine` and `-KeyStorageFlags` was not supplied, the private key is written to the machine key store. Without that the key lands in the calling user's profile while the certificate sits in `LocalMachine\My`, which is the usual cause of an installed certificate that reports no usable private key to a service.
> **Non-elevated root installs prompt.** Adding a root to `CurrentUser\Root` makes Windows raise a modal trust dialog, and the call blocks until it is answered — if the dialog is hidden or the session is non-interactive (a scheduled task, an MECM task sequence), the cmdlet appears to hang indefinitely. It warns before blocking. Run elevated, or pass `-StoreLocation LocalMachine`, to install machine-wide with no prompt.
### Choosing an issuance path
`Request-InfisicalCertificate` has three mutually exclusive issuance parameter sets. Which one works depends on how the project is configured in Infisical:
`Request-InfisicalCertificate` has three mutually exclusive issuance parameter sets. The deciding question is **whether the common name varies per request**:
| Parameter | Use when |
| -------------------------- | ------------------------------------------------------------------------------------------ |
| `-PkiSubscriberSlug` | The project defines PKI subscribers (`Get-InfisicalPkiSubscriber`). Preferred for most setups. |
| `-CertificateProfileId` | The project issues through certificate profiles (`Get-InfisicalCertificateProfile`). |
| `-CertificateAuthorityId` | Signing straight against a CA. Requires **direct issuance** to be enabled on that CA. |
| Parameter | Common name | Use when |
| -------------------------- | ------------------------------------ | ------------------------------------------------------------------------ |
| `-CertificateProfileId` | **Per request**, constrained by policy | Fleet enrollment — many machines, each with its own CN. Works on any CA. |
| `-CertificateAuthorityId` | **Per request**, unconstrained | Fleet enrollment where no policy is wanted. Needs direct issuance on the CA. |
| `-PkiSubscriberSlug` | **Fixed** by the subscriber record | One named identity — a specific service or host, provisioned in advance. |
There is no `-CertificateTemplateId` parameter. Infisical's REST API exposes no template-based issuance route — templates are consumed internally by EST and subscribers — so when the API says *"Certificate template or subscriber is required for issuance"*, the reachable answers are a subscriber, a profile, or enabling direct issuance.
**A PKI subscriber is a per-identity object, not a fleet template.** `signSubscriberCert` rejects any CSR whose CN differs from the subscriber's:
```text
Common name (CN) in the CSR does not match the subscriber's common name
```
It also allowlists SANs — every `dNSName`/`email` SAN in the CSR must appear in the subscriber's `subjectAlternativeNames` — and requires CSR key usages to be a subset of the subscriber's. (IP SANs are not covered by that check.) Enrolling *N* machines through subscribers therefore means creating *N* subscribers. Prefer a profile.
There is no `-CertificateTemplateId` parameter. Infisical's REST API exposes no template-based issuance route — templates are consumed internally by EST and subscribers — so when the API says *"Certificate template or subscriber is required for issuance"*, the reachable answers are a profile, a subscriber, or direct issuance.
The cmdlet resolves and reports the issuer before generating a keypair, so `-Verbose` tells you exactly what will sign the request:
```text
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing via PKI subscriber 'web-tier' in project '2122628e-...'.
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing via certificate profile 'a1b2c3d4-...' in project '2122628e-...'.
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing directly via certificate authority 'intermediate-ca' (bf661d78-...); direct issuance is enabled.
```
@@ -203,34 +368,52 @@ VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing d
```powershell
Request-InfisicalCertificate @RequestInfisicalCertificateParameters -WhatIf
# What if: Performing the operation "Request new certificate" on target
# "PKI subscriber 'web-tier' for CN=WEB01".
# "certificate profile 'a1b2c3d4-...' for CN=WEB01".
```
#### Discovering subscribers
#### Discovering profiles (recommended)
A subscriber is a named enrollment identity that pins the CA, TTL, key usages, and SAN policy, so the request carries only a CSR. List what a project offers:
A certificate profile binds a CA to a certificate policy. The policy constrains the subject, key usages, and extended key usages with `allowed`/`required`/`denied` lists, so the common name still varies per request while staying inside guardrails.
```powershell
Get-InfisicalCertificateProfile -ProjectId ($Project.Id) | Format-Table Id, Name, CaId
Get-InfisicalCertificatePolicy -ProjectId ($Project.Id) | Format-Table Id, Name
```
Profile issuance is the only path that does **not** consult the CA's direct-issuance flag — the service short-circuits it:
```ts
if (!isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) { throw ... }
```
So a profile issues successfully against a CA whose `EnableDirectIssuance` is `False`. If a project has no profiles, create a policy then a profile under **Certificate Management** in the Infisical UI.
#### Discovering subscribers
```powershell
Get-InfisicalPkiSubscriber -ProjectId ($Project.Id) |
Format-Table Name, CommonName, Status, Ttl, CaId
```
Pass the subscriber's `Name` to `-PkiSubscriberSlug`. Because the subscriber owns the lifetime and usage policy, `-Ttl`, `-KeyUsage`, and `-ExtendedKeyUsage` are not accepted on this parameter set — set them on the subscriber in Infisical instead.
Pass the subscriber's `Name` to `-PkiSubscriberSlug`, and set `-CommonName` to exactly that subscriber's `CommonName`. Because the subscriber owns the lifetime and usage policy, `-Ttl`, `-KeyUsage`, and `-ExtendedKeyUsage` are not accepted on this parameter set — set them on the subscriber in Infisical instead.
If the project has no subscribers, either create one (**Certificate Management > Subscribers > Add Subscriber**) or use one of the other two paths.
An empty result means the project has no subscribers; create one under **Certificate Management > Subscribers**. This module is read-only for subscribers, so creation is UI or raw API (`POST /api/v1/pki/subscribers`).
#### Enabling direct issuance on a CA
#### Direct issuance on a CA
Direct issuance lets a CA sign a bare CSR with no subscriber or template in front of it. It is a per-CA setting, and Infisical rejects the request with `400 Certificate template or subscriber is required for issuance` when it is off. This module now catches that before building a CSR:
Direct issuance lets a CA sign a bare CSR with no profile, subscriber, or template in front of it. When it is off, Infisical rejects the request with `400 Certificate template or subscriber is required for issuance`; this module catches that before building a CSR.
```text
Request-InfisicalCertificate : Certificate authority 'intermediate-ca' (bf661d78-...) has direct issuance
disabled, so it cannot sign a CSR on its own. Either enable direct issuance on the CA in Infisical
(Certificate Authorities > the CA > Enable Direct Issuance), or issue through a subscriber or profile
instead: Request-InfisicalCertificate -PkiSubscriberSlug <name> ... or -CertificateProfileId <id> ...
> **There is no UI toggle or API field for this.** `enableDirectIssuance` appears in no create or update schema — the generic CA schemas accept only `name` and `status`. It is set at CA creation (the column defaults to `true`) and is not editable afterwards through the public API.
A CA can therefore read `False` for a reason that is not obvious. Migration `20250521110635_add-external-ca-pki.ts` renamed the older `requireTemplateForIssuance` column to `enableDirectIssuance` and **inverted** every existing value:
```ts
t.renameColumn("requireTemplateForIssuance", "enableDirectIssuance");
...
.update({ name: slugifiedName, enableDirectIssuance: !ca.enableDirectIssuance });
```
To turn it on, in the Infisical UI open the `cert-manager` project, go to **Certificate Authorities**, select the CA, and enable **Direct Issuance** in its settings. The setting is surfaced by this module as `EnableDirectIssuance`, so you can confirm it and pick an eligible CA in one step:
Any CA created before that migration with "require template for issuance" enabled now reads `EnableDirectIssuance = False` permanently. The options are to **use a profile** (which ignores the flag), or to create a new CA — new CAs default to `true`.
```powershell
Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal |
@@ -244,8 +427,6 @@ $RequestInfisicalCertificateParameters.CertificateAuthorityId = $Ca.Id
$RequestInfisicalCertificateParameters.Ttl = '90d' # required by the CA path
```
Prefer a subscriber or profile for routine enrollment: direct issuance bypasses the naming and key-usage constraints those layers enforce. Reserve it for bootstrap or break-glass cases.
### Subject and SAN handling
- `-CommonName` takes the bare value (`WEB01.contoso.com`), not an RDN. `CN=WEB01` is accepted and normalized, since the CSR builder adds the `CN=` prefix itself.
@@ -0,0 +1,116 @@
using System;
using System.Reflection;
using PSInfisicalAPI.Pki;
using Xunit;
namespace PSInfisicalAPI.Tests
{
/// <summary>
/// Reuse must be scoped to the issuer being requested. Two profiles over the same CA issue certificates with
/// the same common name but different key usages (server authentication vs client authentication), so a
/// name-only match hands back a certificate that does not satisfy the request that was made.
/// </summary>
public class CertificateReuseScopingTests
{
private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly;
private static string InvokeApplyIssuerScope(PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet, InfisicalCertificateSearchQuery query)
{
MethodInfo method = cmdlet.GetType().GetMethod("ApplyIssuerScope", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(method);
return (string)method.Invoke(cmdlet, new object[] { query });
}
[Fact]
public void Profile_Issuance_Scopes_The_Reuse_Search_To_That_Profile()
{
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
{
ProjectId = "proj-1",
CertificateProfileId = "profile-clientauth"
};
InfisicalCertificateSearchQuery query = new InfisicalCertificateSearchQuery();
string scope = InvokeApplyIssuerScope(cmdlet, query);
Assert.Equal(new[] { "profile-clientauth" }, query.ProfileIds);
Assert.Null(query.CaIds);
Assert.Contains("profile-clientauth", scope);
}
[Fact]
public void Ca_Issuance_Scopes_The_Reuse_Search_To_That_Ca()
{
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
{
ProjectId = "proj-1",
CertificateAuthorityId = "ca-1"
};
InfisicalCertificateSearchQuery query = new InfisicalCertificateSearchQuery();
string scope = InvokeApplyIssuerScope(cmdlet, query);
Assert.Equal(new[] { "ca-1" }, query.CaIds);
Assert.Null(query.ProfileIds);
Assert.Contains("ca-1", scope);
}
[Fact]
public void Subscriber_Issuance_Needs_No_Server_Side_Scope()
{
// A subscriber pins its own common name, so a name match is already a subscriber match.
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
{
ProjectId = "proj-1",
PkiSubscriberSlug = "web-tier"
};
InfisicalCertificateSearchQuery query = new InfisicalCertificateSearchQuery();
string scope = InvokeApplyIssuerScope(cmdlet, query);
Assert.Null(query.ProfileIds);
Assert.Null(query.CaIds);
Assert.Contains("web-tier", scope);
}
[Fact]
public void Two_Profiles_Produce_Distinct_Reuse_Scopes()
{
InfisicalCertificateSearchQuery serverQuery = new InfisicalCertificateSearchQuery();
InvokeApplyIssuerScope(new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet { CertificateProfileId = "profile-serverauth" }, serverQuery);
InfisicalCertificateSearchQuery clientQuery = new InfisicalCertificateSearchQuery();
InvokeApplyIssuerScope(new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet { CertificateProfileId = "profile-clientauth" }, clientQuery);
Assert.NotEqual(serverQuery.ProfileIds[0], clientQuery.ProfileIds[0]);
}
[Fact]
public void Issuer_Scope_Survives_Serialization_Into_The_Search_Request()
{
// The scope is only effective if it actually reaches the wire.
Type clientType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalPkiClient", true);
MethodInfo build = clientType.GetMethod("BuildSearchRequest", BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
Assert.NotNull(build);
InfisicalCertificateSearchQuery query = new InfisicalCertificateSearchQuery
{
ProjectId = "proj-1",
CommonName = "WEB01",
Status = "active",
ProfileIds = new[] { "profile-clientauth" }
};
object dto = build.Invoke(null, new object[] { query });
Assert.NotNull(dto);
PropertyInfo profileIds = dto.GetType().GetProperty("ProfileIds");
Assert.NotNull(profileIds);
Assert.Equal(new[] { "profile-clientauth" }, (string[])profileIds.GetValue(dto));
string json = Newtonsoft.Json.JsonConvert.SerializeObject(dto);
Assert.Contains("profileIds", json);
Assert.Contains("profile-clientauth", json);
}
}
}
@@ -0,0 +1,220 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using PSInfisicalAPI.Pki;
using Xunit;
namespace PSInfisicalAPI.Tests
{
/// <summary>
/// Reuse must not return a certificate that predates a newly requested SAN. These build real certificates
/// through the module's own CSR path so the SAN reader is exercised against genuine DER, not a hand-rolled
/// approximation of it.
/// </summary>
public class CertificateSanCoverageTests
{
private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly;
/// <summary>
/// Produces a self-signed certificate carrying exactly the requested SANs, by round-tripping the module's
/// CSR builder output into a signed certificate.
/// </summary>
private static X509Certificate2 CreateCertificateWithSans(string commonName, string[] dnsNames, string[] ipAddresses)
{
InfisicalCsrSubject subject = new InfisicalCsrSubject { CommonName = commonName };
InfisicalCsrResult csr = InfisicalCsrBuilder.Build(subject, dnsNames, ipAddresses, new InfisicalCsrOptions());
Org.BouncyCastle.Pkcs.Pkcs10CertificationRequest request;
using (System.IO.StringReader reader = new System.IO.StringReader(csr.CsrPem))
{
Org.BouncyCastle.OpenSsl.PemReader pemReader = new Org.BouncyCastle.OpenSsl.PemReader(reader);
request = (Org.BouncyCastle.Pkcs.Pkcs10CertificationRequest)pemReader.ReadObject();
}
Org.BouncyCastle.Asn1.Pkcs.CertificationRequestInfo info = request.GetCertificationRequestInfo();
Org.BouncyCastle.Asn1.X509.X509Extensions extensions = null;
foreach (Org.BouncyCastle.Asn1.Asn1Encodable attributeEncodable in info.Attributes)
{
Org.BouncyCastle.Asn1.Cms.Attribute attribute = Org.BouncyCastle.Asn1.Cms.Attribute.GetInstance(attributeEncodable);
if (attribute.AttrType.Equals(Org.BouncyCastle.Asn1.Pkcs.PkcsObjectIdentifiers.Pkcs9AtExtensionRequest))
{
extensions = Org.BouncyCastle.Asn1.X509.X509Extensions.GetInstance(attribute.AttrValues[0]);
}
}
Assert.NotNull(extensions);
Org.BouncyCastle.Asn1.X509.X509Extension sanExtension =
extensions.GetExtension(Org.BouncyCastle.Asn1.X509.X509Extensions.SubjectAlternativeName);
Assert.NotNull(sanExtension);
using (RSA rsa = RSA.Create(2048))
{
CertificateRequest netRequest = new CertificateRequest(
string.Concat("CN=", commonName), rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
netRequest.CertificateExtensions.Add(new X509Extension(
new Oid("2.5.29.17"),
sanExtension.Value.GetOctets(),
false));
return netRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(30));
}
}
private static bool CoversRequestedNames(X509Certificate2 cert, string[] dns, string[] ips, out string missing)
{
Type reader = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateSanReader", true);
MethodInfo method = reader.GetMethod("CoversRequestedNames", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(method);
object[] args = new object[] { cert, dns, ips, null };
bool result = (bool)method.Invoke(null, args);
missing = (string)args[3];
return result;
}
private static (HashSet<string> Dns, HashSet<string> Ips) ReadSans(X509Certificate2 cert)
{
Type reader = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateSanReader", true);
MethodInfo read = reader.GetMethod("Read", BindingFlags.Public | BindingFlags.Static);
object sans = read.Invoke(null, new object[] { cert });
HashSet<string> dns = (HashSet<string>)sans.GetType().GetProperty("DnsNames").GetValue(sans);
HashSet<string> ips = (HashSet<string>)sans.GetType().GetProperty("IpAddresses").GetValue(sans);
return (dns, ips);
}
[Fact]
public void Reader_Recovers_Both_Dns_And_Ip_Sans()
{
using (X509Certificate2 cert = CreateCertificateWithSans(
"WEB01",
new[] { "WEB01", "WEB01.contoso.com" },
new[] { "10.20.30.40", "127.0.0.1", "::1" }))
{
(HashSet<string> dns, HashSet<string> ips) = ReadSans(cert);
Assert.Equal(2, dns.Count);
Assert.Contains("WEB01", dns);
Assert.Contains("WEB01.contoso.com", dns);
Assert.Equal(3, ips.Count);
Assert.Contains("10.20.30.40", ips);
Assert.Contains("127.0.0.1", ips);
Assert.Contains("::1", ips);
}
}
[Fact]
public void A_Certificate_Covering_Every_Requested_Name_Is_Reusable()
{
using (X509Certificate2 cert = CreateCertificateWithSans(
"WEB01", new[] { "WEB01", "WEB01.contoso.com" }, new[] { "10.20.30.40" }))
{
string missing;
Assert.True(CoversRequestedNames(cert, new[] { "WEB01", "WEB01.contoso.com" }, new[] { "10.20.30.40" }, out missing));
Assert.Null(missing);
}
}
[Fact]
public void A_Newly_Requested_Dns_Name_Disqualifies_The_Existing_Certificate()
{
// The reported gap: adding a name to -DnsName previously returned the old certificate.
using (X509Certificate2 cert = CreateCertificateWithSans(
"WEB01", new[] { "WEB01", "WEB01.contoso.com" }, null))
{
string missing;
bool covers = CoversRequestedNames(
cert,
new[] { "WEB01", "WEB01.contoso.com", "api.contoso.com" },
null,
out missing);
Assert.False(covers);
Assert.Equal("DNS:api.contoso.com", missing);
}
}
[Fact]
public void A_Newly_Requested_Ip_Disqualifies_The_Existing_Certificate()
{
using (X509Certificate2 cert = CreateCertificateWithSans("WEB01", new[] { "WEB01" }, new[] { "10.20.30.40" }))
{
string missing;
Assert.False(CoversRequestedNames(cert, new[] { "WEB01" }, new[] { "10.20.30.41" }, out missing));
Assert.Equal("IP:10.20.30.41", missing);
}
}
[Fact]
public void Extra_Names_On_The_Certificate_Do_Not_Disqualify_It()
{
// A superset still satisfies the request; only a missing name forces reissuance.
using (X509Certificate2 cert = CreateCertificateWithSans(
"WEB01", new[] { "WEB01", "WEB01.contoso.com", "legacy.contoso.com" }, new[] { "10.20.30.40", "127.0.0.1" }))
{
string missing;
Assert.True(CoversRequestedNames(cert, new[] { "WEB01" }, new[] { "127.0.0.1" }, out missing));
Assert.Null(missing);
}
}
[Fact]
public void Dns_Comparison_Is_Case_Insensitive()
{
using (X509Certificate2 cert = CreateCertificateWithSans("WEB01", new[] { "WEB01.Contoso.COM" }, null))
{
string missing;
Assert.True(CoversRequestedNames(cert, new[] { "web01.contoso.com" }, null, out missing));
}
}
[Theory]
[InlineData("::1", "0:0:0:0:0:0:0:1")]
[InlineData("0:0:0:0:0:0:0:1", "::1")]
[InlineData("10.20.30.40", "10.20.30.40")]
public void Ip_Comparison_Normalizes_Textual_Variations(string inCertificate, string requested)
{
using (X509Certificate2 cert = CreateCertificateWithSans("WEB01", new[] { "WEB01" }, new[] { inCertificate }))
{
string missing;
Assert.True(CoversRequestedNames(cert, null, new[] { requested }, out missing), string.Concat("missing: ", missing));
}
}
[Fact]
public void A_Certificate_Without_Any_San_Extension_Fails_A_San_Request()
{
using (RSA rsa = RSA.Create(2048))
{
CertificateRequest request = new CertificateRequest(
"CN=NoSans", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
using (X509Certificate2 cert = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1)))
{
string missing;
Assert.False(CoversRequestedNames(cert, new[] { "NoSans" }, null, out missing));
Assert.Equal("DNS:NoSans", missing);
// With nothing requested there is nothing to fail on.
Assert.True(CoversRequestedNames(cert, null, null, out missing));
}
}
}
[Fact]
public void FindMatch_Keeps_Its_Original_Signature_For_Callers_Without_San_Requirements()
{
Type lookup = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalLocalCertificateLookup", true);
MethodInfo original = lookup.GetMethod(
"FindMatch",
BindingFlags.Public | BindingFlags.Static,
null,
new[] { typeof(StoreName), typeof(StoreLocation), typeof(string), typeof(IEnumerable<string>) },
null);
Assert.NotNull(original);
}
}
}
@@ -0,0 +1,221 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Security.Cryptography.X509Certificates;
using PSInfisicalAPI.Logging;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class CertificateStoreTargetingTests
{
private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly;
private sealed class CapturingLogger : IInfisicalLogger
{
public List<string> Warnings { get; } = new List<string>();
public List<string> Information_ { get; } = new List<string>();
public void Information(string component, string message) { Information_.Add(message); }
public void Verbose(string component, string message) { }
public void Debug(string component, string message) { }
public void Warning(string component, string message) { Warnings.Add(message); }
public void Error(string component, string message) { }
}
private static X509Certificate2 CreateRoot(string name, out System.Security.Cryptography.RSA key)
{
key = System.Security.Cryptography.RSA.Create(2048);
CertificateRequest request = new CertificateRequest(
string.Concat("CN=", name), key,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, true, 1, true));
return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(10));
}
[Fact]
public void ApplyFriendlyName_Sets_The_Windows_Friendly_Name()
{
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo apply = helper.GetMethod("ApplyFriendlyName", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(apply);
System.Security.Cryptography.RSA key;
using (X509Certificate2 cert = CreateRoot("FriendlyName.Probe", out key))
using (key)
{
apply.Invoke(null, new object[] { cert, "WEB01", NullInfisicalLogger.Instance, "Test" });
Assert.Equal("WEB01", cert.FriendlyName);
// A null or empty name must not clear a previously set value.
apply.Invoke(null, new object[] { cert, null, NullInfisicalLogger.Instance, "Test" });
apply.Invoke(null, new object[] { cert, string.Empty, NullInfisicalLogger.Instance, "Test" });
Assert.Equal("WEB01", cert.FriendlyName);
}
}
[Fact]
public void ApplyFriendlyName_Tolerates_Null_Certificate_And_Logger()
{
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo apply = helper.GetMethod("ApplyFriendlyName", BindingFlags.Public | BindingFlags.Static);
apply.Invoke(null, new object[] { null, "WEB01", null, "Test" });
}
[Fact]
public void VerifyInstalledChain_Names_The_Missing_Issuer_On_A_Partial_Chain()
{
// An intermediate whose root is not in any trusted store is exactly the state that surfaces in
// Windows as "The issuer of this certificate could not be found".
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo verify = helper.GetMethod("VerifyInstalledChain", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(verify);
System.Security.Cryptography.RSA rootKey;
using (X509Certificate2 root = CreateRoot("VerifyChain.Root", out rootKey))
using (rootKey)
using (System.Security.Cryptography.RSA interKey = System.Security.Cryptography.RSA.Create(2048))
{
CertificateRequest interRequest = new CertificateRequest(
"CN=VerifyChain.Intermediate", interKey,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
interRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
using (X509Certificate2 intermediate = interRequest.Create(root, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(9), new byte[] { 9, 8, 7, 6, 5, 4, 3, 2 }))
{
CapturingLogger logger = new CapturingLogger();
verify.Invoke(null, new object[] { intermediate, logger, "Test" });
string warning = Assert.Single(logger.Warnings);
Assert.Contains("issuer", warning, StringComparison.OrdinalIgnoreCase);
Assert.Contains("VerifyChain.Root", warning);
}
}
}
[Fact]
public void VerifyInstalledChain_Tolerates_Null_Inputs()
{
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo verify = helper.GetMethod("VerifyInstalledChain", BindingFlags.Public | BindingFlags.Static);
verify.Invoke(null, new object[] { null, new CapturingLogger(), "Test" });
System.Security.Cryptography.RSA key;
using (X509Certificate2 cert = CreateRoot("VerifyChain.NullLogger", out key))
using (key)
{
verify.Invoke(null, new object[] { cert, null, "Test" });
}
}
[Fact]
public void FriendlyName_Is_Available_On_Every_Issuance_Parameter_Set()
{
// The Windows friendly name applies regardless of how the certificate was issued.
Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet", true);
PropertyInfo friendlyName = cmdletType.GetProperty("FriendlyName");
Assert.NotNull(friendlyName);
foreach (CustomAttributeData attribute in friendlyName.GetCustomAttributesData())
{
if (attribute.AttributeType != typeof(System.Management.Automation.ParameterAttribute)) { continue; }
foreach (CustomAttributeNamedArgument named in attribute.NamedArguments)
{
Assert.NotEqual("ParameterSetName", named.MemberName);
}
}
}
private static void InvokeTrustPromptWarning(StoreName storeName, StoreLocation storeLocation, IInfisicalLogger logger)
{
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo method = helper.GetMethod("WarnIfInteractiveTrustPromptExpected", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(method);
method.Invoke(null, new object[] { storeName, storeLocation, logger, "TestComponent" });
}
[Fact]
public void CurrentUser_Root_Install_Warns_About_The_Blocking_Trust_Dialog()
{
// X509Store.Add on CurrentUser\Root raises a modal Windows trust dialog and blocks until answered.
// Without this warning the caller sees an unexplained hang.
CapturingLogger logger = new CapturingLogger();
InvokeTrustPromptWarning(StoreName.Root, StoreLocation.CurrentUser, logger);
string warning = Assert.Single(logger.Warnings);
Assert.Contains("CurrentUser\\Root", warning);
Assert.Contains("security confirmation", warning);
Assert.Contains("LocalMachine", warning);
}
[Theory]
[InlineData(StoreName.Root, StoreLocation.LocalMachine)]
[InlineData(StoreName.My, StoreLocation.CurrentUser)]
[InlineData(StoreName.My, StoreLocation.LocalMachine)]
[InlineData(StoreName.CertificateAuthority, StoreLocation.CurrentUser)]
[InlineData(StoreName.CertificateAuthority, StoreLocation.LocalMachine)]
public void Non_Prompting_Store_Targets_Stay_Silent(StoreName storeName, StoreLocation storeLocation)
{
CapturingLogger logger = new CapturingLogger();
InvokeTrustPromptWarning(storeName, storeLocation, logger);
Assert.Empty(logger.Warnings);
}
[Fact]
public void WarnIfInteractiveTrustPromptExpected_Tolerates_A_Null_Logger()
{
InvokeTrustPromptWarning(StoreName.Root, StoreLocation.CurrentUser, null);
}
[Fact]
public void Base_Cmdlet_Exposes_Elevation_Aware_Store_Resolution()
{
Type baseType = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase);
MethodInfo resolve = baseType.GetMethod("ResolveStoreLocation", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(resolve);
Assert.Equal(typeof(StoreLocation), resolve.ReturnType);
MethodInfo elevated = baseType.GetMethod("IsElevated", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(elevated);
Assert.Equal(typeof(bool), elevated.ReturnType);
}
[Fact]
public void Chain_Certificates_Route_Root_And_Intermediate_To_Their_Own_Stores()
{
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo classify = helper.GetMethod("GetChainCertificateTargetStore", BindingFlags.Public | BindingFlags.Static);
using (System.Security.Cryptography.RSA rootRsa = System.Security.Cryptography.RSA.Create(2048))
using (System.Security.Cryptography.RSA leafRsa = System.Security.Cryptography.RSA.Create(2048))
{
DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddMinutes(-5);
DateTimeOffset notAfter = DateTimeOffset.UtcNow.AddDays(1);
CertificateRequest rootRequest = new CertificateRequest(
"CN=StoreTargeting.Root", rootRsa,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
using (X509Certificate2 root = rootRequest.CreateSelfSigned(notBefore, notAfter))
{
CertificateRequest interRequest = new CertificateRequest(
"CN=StoreTargeting.Intermediate", leafRsa,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
interRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
using (X509Certificate2 intermediate = interRequest.Create(root, notBefore, notAfter, new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }))
{
Assert.Equal(StoreName.Root, (StoreName)classify.Invoke(null, new object[] { root }));
Assert.Equal(StoreName.CertificateAuthority, (StoreName)classify.Invoke(null, new object[] { intermediate }));
}
}
}
}
}
}
@@ -1,6 +1,9 @@
using System;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Management.Automation;
using System.Runtime.ExceptionServices;
using System.Security.Cryptography.X509Certificates;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Http;
@@ -12,6 +15,7 @@ namespace PSInfisicalAPI.Cmdlets
{
private IInfisicalLogger _logger;
private IInfisicalHttpClient _httpClient;
private bool? _isElevated;
protected IInfisicalLogger Logger
{
@@ -45,6 +49,56 @@ namespace PSInfisicalAPI.Cmdlets
return current != null && current.SkipCertificateCheck;
}
/// <summary>
/// Reports whether the host process is running elevated. Evaluated through the PowerShell engine rather
/// than WindowsIdentity directly, because the module targets netstandard2.0 and does not carry a
/// System.Security.Principal.Windows reference. Cached for the lifetime of the cmdlet instance.
/// </summary>
protected bool IsElevated()
{
if (_isElevated.HasValue) { return _isElevated.Value; }
try
{
Collection<PSObject> results = InvokeCommand.InvokeScript(
"[bool]([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)");
_isElevated = results != null
&& results.Count > 0
&& results[0] != null
&& results[0].BaseObject != null
&& Convert.ToBoolean(results[0].BaseObject, CultureInfo.InvariantCulture);
}
catch (Exception exception)
{
Logger.Verbose(GetType().Name, string.Concat("Elevation check failed; assuming non-elevated. ", exception.Message));
_isElevated = false;
}
return _isElevated.Value;
}
/// <summary>
/// Picks the certificate store location when the caller did not bind -StoreLocation. An elevated process
/// installs machine-wide so services and other users can use the certificate; a non-elevated one has no
/// write access to LocalMachine and falls back to the user's own stores.
/// </summary>
protected StoreLocation ResolveStoreLocation(StoreLocation boundValue)
{
if (MyInvocation != null && MyInvocation.BoundParameters.ContainsKey("StoreLocation"))
{
return boundValue;
}
bool elevated = IsElevated();
StoreLocation resolved = elevated ? StoreLocation.LocalMachine : StoreLocation.CurrentUser;
Logger.Information(GetType().Name, string.Concat(
"Process is ", elevated ? "elevated" : "not elevated",
"; defaulting -StoreLocation to ", resolved.ToString(),
". Pass -StoreLocation explicitly to override."));
return resolved;
}
/// <summary>
/// Reports an operation failure as a non-terminating error, which is what lets -ErrorAction decide the
/// outcome: Continue prints and carries on, SilentlyContinue and Ignore suppress, Inquire prompts, and
@@ -32,20 +32,22 @@ namespace PSInfisicalAPI.Cmdlets
{
try
{
StoreLocation resolvedStoreLocation = ResolveStoreLocation(StoreLocation);
X509Certificate2 cert = ResolveCertificate();
if (cert == null)
{
return;
}
InstallCertificate(cert, StoreName, StoreLocation);
InstallCertificate(cert, StoreName, resolvedStoreLocation);
if (IncludeChain.IsPresent && string.Equals(ParameterSetName, "FromCertificate", StringComparison.Ordinal) == false)
{
foreach (X509Certificate2 chainCert in ResolveChain())
{
StoreName chainStore = InfisicalCertificateRequestHelpers.GetChainCertificateTargetStore(chainCert);
InstallCertificate(chainCert, chainStore, StoreLocation);
InstallCertificate(chainCert, chainStore, resolvedStoreLocation);
}
}
@@ -88,6 +90,7 @@ namespace PSInfisicalAPI.Cmdlets
return;
}
InfisicalCertificateRequestHelpers.WarnIfInteractiveTrustPromptExpected(storeName, storeLocation, Logger, "InstallInfisicalCertificateCmdlet");
store.Add(cert);
Logger.Information("InstallInfisicalCertificateCmdlet", string.Concat("Installed certificate to ", target, "."));
}
@@ -49,7 +49,9 @@ namespace PSInfisicalAPI.Cmdlets
[Parameter(ParameterSetName = "ByProfile")] public string NotBefore { get; set; }
[Parameter(ParameterSetName = "ByCa")]
[Parameter(ParameterSetName = "ByProfile")] public string NotAfter { get; set; }
[Parameter(ParameterSetName = "ByCa")] public string FriendlyName { get; set; }
// Available on every parameter set: it names the installed certificate in the Windows store. The CA path
// additionally forwards it to Infisical as the issued certificate's friendlyName.
[Parameter] public string FriendlyName { get; set; }
[Parameter(ParameterSetName = "ByCa")] public string PkiCollectionId { get; set; }
[Parameter(ParameterSetName = "ByCa")]
[Parameter(ParameterSetName = "ByProfile")] public string[] KeyUsage { get; set; }
@@ -79,13 +81,16 @@ namespace PSInfisicalAPI.Cmdlets
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
// Resolved once so reuse detection looks in the same stores the install will write to.
StoreLocation resolvedStoreLocation = ResolveStoreLocation(StoreLocation);
InfisicalCsrSubject csrSubject = InfisicalCertificateRequestHelpers.MergeSubject(Subject, CommonName, Country, State, Locality, Organization, OrganizationalUnit, EmailAddress);
List<string> ipAddresses = new List<string>();
List<string> dnsNames = BuildDnsNames(csrSubject, ipAddresses);
if (string.IsNullOrEmpty(csrSubject.CommonName) && dnsNames.Count > 0) { csrSubject.CommonName = dnsNames[0]; }
if (string.IsNullOrEmpty(csrSubject.CommonName)) { throw new InvalidOperationException("Subject CommonName could not be determined and no DnsName was provided."); }
X509Certificate2 existing = TryFindExisting(client, connection, ProjectId, csrSubject.CommonName);
X509Certificate2 existing = TryFindExisting(client, connection, ProjectId, csrSubject.CommonName, resolvedStoreLocation, dnsNames, ipAddresses);
if (existing != null && !Force.IsPresent && !(AllowRenewal.IsPresent && InfisicalLocalCertificateLookup.IsRenewable(existing, RenewalThresholdDays)))
{
Logger.Information(Component, string.Concat("Reusing existing certificate (Thumbprint=", existing.Thumbprint, ", NotAfter=", existing.NotAfter.ToString("u"), ")."));
@@ -134,15 +139,24 @@ namespace PSInfisicalAPI.Cmdlets
return;
}
X509KeyStorageFlags resolvedFlags = ResolveEffectiveKeyStorageFlags();
X509KeyStorageFlags resolvedFlags = ResolveEffectiveKeyStorageFlags(resolvedStoreLocation);
X509Certificate2 cert = PemCertificateBuilder.Build(signed.CertificatePem, signed.PrivateKeyPem, signed.CertificateChainPem, resolvedFlags);
InfisicalCertificateRequestHelpers.ApplyFriendlyName(cert, ResolveLocalFriendlyName(csrSubject), Logger, Component);
if (Install.IsPresent)
{
InfisicalCertificateRequestHelpers.InstallToStore(cert, StoreName, StoreLocation, Force.IsPresent, Logger, Component);
// Issuers first, so the leaf is already chainable the moment it lands in the store.
if (InstallChain.IsPresent)
{
InfisicalCertificateRequestHelpers.InstallChain(signed, StoreLocation, Force.IsPresent, Logger, Component);
InfisicalCertificateRequestHelpers.InstallChain(signed, resolvedStoreLocation, Force.IsPresent, Logger, Component);
}
InfisicalCertificateRequestHelpers.InstallToStore(cert, StoreName, resolvedStoreLocation, Force.IsPresent, Logger, Component);
if (InstallChain.IsPresent)
{
InfisicalCertificateRequestHelpers.VerifyInstalledChain(cert, Logger, Component);
}
}
@@ -169,6 +183,17 @@ namespace PSInfisicalAPI.Cmdlets
}
}
/// <summary>
/// The Windows friendly name shown in certmgr. Defaults to the common name in upper case, which is the
/// host identity operators look for; -FriendlyName overrides it.
/// </summary>
private string ResolveLocalFriendlyName(InfisicalCsrSubject subject)
{
if (!string.IsNullOrEmpty(FriendlyName)) { return FriendlyName; }
if (subject == null || string.IsNullOrEmpty(subject.CommonName)) { return null; }
return subject.CommonName.ToUpperInvariant();
}
/// <summary>
/// States which issuer will sign this request, and rejects an unusable one before a keypair is generated.
/// Direct CA signing is only permitted when the CA has direct issuance enabled; without this check the
@@ -282,17 +307,39 @@ namespace PSInfisicalAPI.Cmdlets
return System.Net.IPAddress.TryParse(value, out parsed);
}
private X509Certificate2 TryFindExisting(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string commonName)
/// <summary>
/// Finds a still-valid local certificate that this same request would have produced. The match is scoped
/// to the issuer being asked for: a certificate issued by a different profile or CA carries different key
/// usages and policy, so reusing one across issuers hands back a certificate that does not satisfy the
/// request that was actually made.
/// </summary>
private X509Certificate2 TryFindExisting(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string commonName, StoreLocation storeLocation, List<string> requestedDnsNames, List<string> requestedIpAddresses)
{
List<string> candidateSerials = new List<string>();
bool searchCompleted = false;
try
{
InfisicalCertificateSearchQuery query = new InfisicalCertificateSearchQuery { ProjectId = projectId, CommonName = commonName, Status = "active", Limit = 50 };
InfisicalCertificateSearchQuery query = new InfisicalCertificateSearchQuery
{
ProjectId = projectId,
CommonName = commonName,
Status = "active",
Limit = 50
};
string scope = ApplyIssuerScope(query);
InfisicalCertificateSearchResult page = client.SearchCertificates(connection, query);
searchCompleted = true;
if (page != null && page.Certificates != null)
{
foreach (InfisicalCertificate hit in page.Certificates) { if (!string.IsNullOrEmpty(hit.SerialNumber)) { candidateSerials.Add(hit.SerialNumber); } }
}
Logger.Verbose(Component, string.Concat(
"Reuse search for CN=", commonName, " scoped to ", scope, " returned ",
candidateSerials.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " active certificate(s)."));
}
catch (Exception searchException)
{
@@ -300,17 +347,80 @@ namespace PSInfisicalAPI.Cmdlets
Logger.Verbose(Component, string.Concat("Infisical search for idempotency check failed: ", searchException.Message));
}
return InfisicalLocalCertificateLookup.FindMatch(StoreName, StoreLocation, commonName, candidateSerials);
// A completed search that found nothing is a definite answer: this issuer has never issued for this
// common name, so there is nothing to reuse. Falling through to a name-only local match here is what
// let a certificate from another profile be handed back.
if (searchCompleted && candidateSerials.Count == 0)
{
return null;
}
if (!searchCompleted)
{
Logger.Warning(Component, string.Concat(
"Could not confirm with Infisical which certificates belong to this issuer, so reuse falls back to ",
"matching on the common name alone. That can return a certificate issued by a different profile or CA; ",
"pass -Force to issue unconditionally."));
}
string missingName;
X509Certificate2 match = InfisicalLocalCertificateLookup.FindMatch(
StoreName, storeLocation, commonName, candidateSerials, requestedDnsNames, requestedIpAddresses, out missingName);
if (match == null && missingName != null)
{
Logger.Information(Component, string.Concat(
"An existing certificate for CN=", commonName, " does not carry the requested name ", missingName,
"; requesting a new certificate rather than reusing one that would fail validation for it."));
}
return match;
}
private X509KeyStorageFlags ResolveEffectiveKeyStorageFlags()
/// <summary>
/// Narrows a certificate search to the issuer this invocation targets, and names that scope for logging.
/// The subscriber path has no server-side filter, but a subscriber pins its own common name, so matching
/// on the name is already equivalent to matching on the subscriber.
/// </summary>
private string ApplyIssuerScope(InfisicalCertificateSearchQuery query)
{
if (!string.IsNullOrEmpty(CertificateProfileId))
{
query.ProfileIds = new[] { CertificateProfileId };
return string.Concat("certificate profile '", CertificateProfileId, "'");
}
if (!string.IsNullOrEmpty(CertificateAuthorityId))
{
query.CaIds = new[] { CertificateAuthorityId };
return string.Concat("certificate authority '", CertificateAuthorityId, "'");
}
if (!string.IsNullOrEmpty(PkiSubscriberSlug))
{
return string.Concat("PKI subscriber '", PkiSubscriberSlug, "'");
}
return "this project";
}
private X509KeyStorageFlags ResolveEffectiveKeyStorageFlags(StoreLocation storeLocation)
{
if (MyInvocation.BoundParameters.ContainsKey("KeyStorageFlags"))
{
return KeyStorageFlags;
}
return InfisicalCertificateRequestHelpers.ResolveKeyStorageFlags(PrivateKeyProtection, PersistKey.IsPresent, MachineKey.IsPresent);
// A certificate installed into LocalMachine needs its private key in the machine key store, otherwise
// the key lands in the calling user's profile and the installed certificate has no usable key for
// services or other users.
bool machineKey = MachineKey.IsPresent || (Install.IsPresent && storeLocation == StoreLocation.LocalMachine);
if (machineKey && !MachineKey.IsPresent)
{
Logger.Verbose(Component, "Installing to LocalMachine; using a machine key store so the private key is usable outside this user profile.");
}
return InfisicalCertificateRequestHelpers.ResolveKeyStorageFlags(PrivateKeyProtection, PersistKey.IsPresent, machineKey);
}
private InfisicalSignedCertificate SignCertificate(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string csrPem, InfisicalCsrSubject subject)
@@ -66,21 +66,6 @@ namespace PSInfisicalAPI.Cmdlets
}
}
private bool IsElevated()
{
try
{
Collection<PSObject> results = InvokeCommand.InvokeScript("[bool]([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)");
if (results == null || results.Count == 0 || results[0] == null || results[0].BaseObject == null) { return false; }
return Convert.ToBoolean(results[0].BaseObject, CultureInfo.InvariantCulture);
}
catch (Exception ex)
{
Logger.Verbose(Component, string.Concat("Elevation check failed; assuming non-elevated. ", ex.Message));
return false;
}
}
private Collection<PSObject> InvokeNewCimInstance(string ns, string className, Hashtable properties)
{
Dictionary<string, object> variables = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
@@ -96,6 +96,7 @@ namespace PSInfisicalAPI.Pki
store.RemoveRange(existing);
}
WarnIfInteractiveTrustPromptExpected(storeName, storeLocation, logger, component);
store.Add(cert);
logger.Information(component, string.Concat("Installed certificate to ", target, "."));
}
@@ -105,6 +106,24 @@ namespace PSInfisicalAPI.Pki
}
}
/// <summary>
/// Adding to the per-user Root store makes Windows raise a modal trust confirmation dialog, and
/// X509Store.Add blocks until it is answered. When that dialog is hidden or the session is
/// non-interactive the caller just sees an unexplained hang, so say so before blocking. LocalMachine\Root
/// does not prompt, because writing it already required elevation.
/// </summary>
public static void WarnIfInteractiveTrustPromptExpected(StoreName storeName, StoreLocation storeLocation, IInfisicalLogger logger, string component)
{
if (storeName != StoreName.Root || storeLocation != StoreLocation.CurrentUser) { return; }
if (logger == null) { return; }
logger.Warning(component, string.Concat(
"Installing a root certificate into CurrentUser\\Root. Windows will display a security confirmation ",
"dialog and this call cannot continue until it is answered - if no dialog is visible, check for it ",
"behind the console window or on another desktop. Run elevated (or pass ",
"-StoreLocation LocalMachine) to install machine-wide without a prompt."));
}
public static void InstallChain(InfisicalSignedCertificate signed, StoreLocation storeLocation, bool force, IInfisicalLogger logger, string component)
{
List<X509Certificate2> chainCerts = CollectChainCertificates(signed);
@@ -122,11 +141,93 @@ namespace PSInfisicalAPI.Pki
}
}
/// <summary>
/// Routes a chain member by what it is rather than by any caller preference: a self-signed certificate is
/// a root and belongs in the trusted-root store, anything else is a subordinate CA and belongs in the
/// intermediate store. Only the leaf honours -StoreName.
/// </summary>
public static StoreName GetChainCertificateTargetStore(X509Certificate2 cert)
{
return IsSelfSigned(cert) ? StoreName.Root : StoreName.CertificateAuthority;
}
/// <summary>
/// Sets the Windows friendly name shown in certmgr. Not available on every platform, and never important
/// enough to fail an otherwise successful issuance.
/// </summary>
public static void ApplyFriendlyName(X509Certificate2 cert, string friendlyName, IInfisicalLogger logger, string component)
{
if (cert == null || string.IsNullOrEmpty(friendlyName)) { return; }
try
{
cert.FriendlyName = friendlyName;
if (logger != null) { logger.Verbose(component, string.Concat("Set certificate friendly name to '", friendlyName, "'.")); }
}
catch (Exception exception)
{
if (logger != null) { logger.Verbose(component, string.Concat("Could not set the certificate friendly name (continuing): ", exception.Message)); }
}
}
/// <summary>
/// Confirms the freshly installed certificate actually chains to a trusted root using the machine's own
/// stores. A chain that stops short is what surfaces in Windows as "The issuer of this certificate could
/// not be found", so name the missing issuer rather than letting it be discovered later.
/// </summary>
public static void VerifyInstalledChain(X509Certificate2 leaf, IInfisicalLogger logger, string component)
{
if (leaf == null || logger == null) { return; }
try
{
using (X509Chain chain = new X509Chain())
{
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
if (chain.Build(leaf))
{
logger.Information(component, string.Concat("Certificate chain verified to a trusted root (", chain.ChainElements.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " elements)."));
return;
}
bool partialChain = false;
List<string> problems = new List<string>();
foreach (X509ChainStatus status in chain.ChainStatus)
{
if (status.Status == X509ChainStatusFlags.NoError) { continue; }
if (status.Status == X509ChainStatusFlags.PartialChain || status.Status == X509ChainStatusFlags.UntrustedRoot) { partialChain = true; }
problems.Add(status.Status.ToString());
}
string topSubject = leaf.Subject;
string missingIssuer = leaf.Issuer;
if (chain.ChainElements.Count > 0)
{
X509Certificate2 top = chain.ChainElements[chain.ChainElements.Count - 1].Certificate;
topSubject = top.Subject;
missingIssuer = top.Issuer;
}
string detail = problems.Count > 0 ? string.Join(", ", problems.ToArray()) : "unknown";
if (partialChain)
{
logger.Warning(component, string.Concat(
"Certificate chain is incomplete (", detail, "). The highest certificate installed is '", topSubject,
"', whose issuer '", missingIssuer, "' is not present in the trusted stores. Windows will report ",
"\"The issuer of this certificate could not be found\" until that issuer is installed."));
}
else
{
logger.Warning(component, string.Concat("Certificate chain did not validate (", detail, ")."));
}
}
}
catch (Exception exception)
{
logger.Verbose(component, string.Concat("Chain verification could not run (continuing): ", exception.Message));
}
}
public static X509KeyStorageFlags ResolveKeyStorageFlags(InfisicalPrivateKeyProtection protection, bool persistKey, bool machineKey)
{
X509KeyStorageFlags flags = X509KeyStorageFlags.DefaultKeySet;
@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Security.Cryptography.X509Certificates;
using Org.BouncyCastle.Asn1;
using Org.BouncyCastle.Asn1.X509;
using NetX509Extension = System.Security.Cryptography.X509Certificates.X509Extension;
namespace PSInfisicalAPI.Pki
{
/// <summary>
/// The subject alternative names carried by a certificate, split the way a request specifies them.
/// </summary>
internal sealed class InfisicalCertificateSans
{
public HashSet<string> DnsNames { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public HashSet<string> IpAddresses { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Reads the subject alternative name extension from an installed certificate.
/// <para>
/// netstandard2.0 has no X509SubjectAlternativeNameExtension, and the string form produced by
/// X509Extension.Format is localized and therefore unusable for comparison, so the extension is decoded from
/// its DER bytes with BouncyCastle, which the module already carries for CSR generation.
/// </para>
/// </summary>
internal static class InfisicalCertificateSanReader
{
private const string SubjectAlternativeNameOid = "2.5.29.17";
public static InfisicalCertificateSans Read(X509Certificate2 cert)
{
InfisicalCertificateSans result = new InfisicalCertificateSans();
if (cert == null) { return result; }
foreach (NetX509Extension extension in cert.Extensions)
{
if (extension == null || extension.Oid == null) { continue; }
if (!string.Equals(extension.Oid.Value, SubjectAlternativeNameOid, StringComparison.Ordinal)) { continue; }
try
{
// X509Extension.RawData is the content of the extnValue OCTET STRING, so it decodes straight
// into the GeneralNames SEQUENCE.
Asn1Object decoded = Asn1Object.FromByteArray(extension.RawData);
GeneralNames names = GeneralNames.GetInstance(decoded);
if (names == null) { continue; }
foreach (GeneralName name in names.GetNames())
{
if (name == null) { continue; }
AddName(result, name);
}
}
catch (Exception)
{
// A certificate this malformed cannot be matched against a request; treat it as carrying no
// usable SANs rather than failing the caller's issuance.
}
}
return result;
}
private static void AddName(InfisicalCertificateSans target, GeneralName name)
{
switch (name.TagNo)
{
case GeneralName.DnsName:
{
string value = name.Name != null ? name.Name.ToString() : null;
if (!string.IsNullOrEmpty(value)) { target.DnsNames.Add(value.Trim()); }
break;
}
case GeneralName.IPAddress:
{
string value = FormatIpAddress(name);
if (!string.IsNullOrEmpty(value)) { target.IpAddresses.Add(value); }
break;
}
}
}
/// <summary>
/// An iPAddress general name holds raw address octets, four for IPv4 and sixteen for IPv6.
/// </summary>
private static string FormatIpAddress(GeneralName name)
{
try
{
Asn1OctetString octets = Asn1OctetString.GetInstance(name.Name);
if (octets == null) { return null; }
byte[] bytes = octets.GetOctets();
if (bytes == null) { return null; }
if (bytes.Length != 4 && bytes.Length != 16) { return null; }
return NormalizeIpAddress(new IPAddress(bytes).ToString());
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Collapses the textual variations of one address so "::1" and "0:0:0:0:0:0:0:1" compare equal.
/// </summary>
public static string NormalizeIpAddress(string value)
{
if (string.IsNullOrEmpty(value)) { return value; }
IPAddress parsed;
if (IPAddress.TryParse(value.Trim(), out parsed))
{
return parsed.ToString();
}
return value.Trim();
}
/// <summary>
/// Reports whether a candidate certificate carries every name the caller asked for. A certificate with
/// extra names still satisfies the request; one missing a requested name does not, and reusing it would
/// hand back a certificate that fails validation for the name that was added.
/// </summary>
public static bool CoversRequestedNames(X509Certificate2 candidate, IEnumerable<string> dnsNames, IEnumerable<string> ipAddresses, out string missingName)
{
missingName = null;
if (candidate == null) { return false; }
InfisicalCertificateSans present = Read(candidate);
if (dnsNames != null)
{
foreach (string dns in dnsNames)
{
if (string.IsNullOrEmpty(dns)) { continue; }
if (!present.DnsNames.Contains(dns.Trim()))
{
missingName = string.Concat("DNS:", dns.Trim());
return false;
}
}
}
if (ipAddresses != null)
{
foreach (string ip in ipAddresses)
{
if (string.IsNullOrEmpty(ip)) { continue; }
if (!present.IpAddresses.Contains(NormalizeIpAddress(ip)))
{
missingName = string.Concat("IP:", NormalizeIpAddress(ip));
return false;
}
}
}
return true;
}
}
}
@@ -8,9 +8,36 @@ namespace PSInfisicalAPI.Pki
{
public static X509Certificate2 FindMatch(StoreName storeName, StoreLocation storeLocation, string commonName, IEnumerable<string> candidateSerialNumbers)
{
string ignored;
return FindMatch(storeName, storeLocation, commonName, candidateSerialNumbers, null, null, out ignored);
}
/// <summary>
/// Finds the longest-lived installed certificate for a subject that also carries every requested subject
/// alternative name. A certificate that predates a newly added SAN would fail validation for that name,
/// so it is not a reusable answer to the current request.
/// </summary>
/// <param name="rejectedForMissingName">
/// The first name that disqualified an otherwise-matching certificate, so the caller can explain why it
/// is reissuing rather than reusing.
/// </param>
public static X509Certificate2 FindMatch(
StoreName storeName,
StoreLocation storeLocation,
string commonName,
IEnumerable<string> candidateSerialNumbers,
IEnumerable<string> requiredDnsNames,
IEnumerable<string> requiredIpAddresses,
out string rejectedForMissingName)
{
rejectedForMissingName = null;
HashSet<string> serialSet = NormalizeSerials(candidateSerialNumbers);
string subjectFilter = !string.IsNullOrEmpty(commonName) ? string.Concat("CN=", commonName) : null;
List<string> dnsList = ToList(requiredDnsNames);
List<string> ipList = ToList(requiredIpAddresses);
bool requireSans = dnsList.Count > 0 || ipList.Count > 0;
X509Store store = new X509Store(storeName, storeLocation);
try
{
@@ -33,12 +60,24 @@ namespace PSInfisicalAPI.Pki
}
}
if (requireSans)
{
string missingName;
if (!InfisicalCertificateSanReader.CoversRequestedNames(candidate, dnsList, ipList, out missingName))
{
if (rejectedForMissingName == null) { rejectedForMissingName = missingName; }
continue;
}
}
if (bestMatch == null || candidate.NotAfter > bestMatch.NotAfter)
{
bestMatch = candidate;
}
}
// Only report a rejection when nothing else qualified; a covering certificate makes it irrelevant.
if (bestMatch != null) { rejectedForMissingName = null; }
return bestMatch;
}
finally
@@ -47,6 +86,18 @@ namespace PSInfisicalAPI.Pki
}
}
private static List<string> ToList(IEnumerable<string> values)
{
List<string> result = new List<string>();
if (values == null) { return result; }
foreach (string value in values)
{
if (!string.IsNullOrEmpty(value)) { result.Add(value); }
}
return result;
}
public static bool IsRenewable(X509Certificate2 cert, int renewalThresholdDays)
{
if (cert == null) { return true; }