Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83b79b9109 | |||
| 93b0cc1924 | |||
| d47a5af6b3 | |||
| e8113ec073 | |||
| d5c9eec3fb | |||
| 67cf0abac2 | |||
| 09af8939c7 | |||
| dadba2f4c8 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+43
-1
@@ -6,11 +6,29 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 2026.07.31.0045
|
||||
|
||||
- Build produced from commit d47a5af6b3a6.
|
||||
|
||||
## Unreleased (carried forward)
|
||||
|
||||
## 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)
|
||||
## Unreleased (carried forward)
|
||||
|
||||
## 2026.07.30.2259
|
||||
|
||||
@@ -18,6 +36,30 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
|
||||
|
||||
## Unreleased (carried forward)
|
||||
|
||||
### Added (certificate metadata)
|
||||
|
||||
- `Request-InfisicalCertificate -Metadata` attaches key/value pairs to the issued or reused certificate, accepting any `IDictionary` (hashtable, `[Ordered]`, generic dictionary).
|
||||
- **Only the supplied keys are reconciled.** Infisical's `PATCH /certificates/{id}` replaces a certificate's metadata wholesale, so the module reads the current set, merges the supplied keys over it, and writes back the union; keys it was not asked about survive. When nothing would change, no request is sent.
|
||||
- Reconciliation runs on the reuse path as well, so a metadata change lands without forcing reissuance.
|
||||
- Values are flattened to strings, keys trimmed and compared case-insensitively, blank keys dropped.
|
||||
- `InfisicalCertificate.Metadata` and `InfisicalCertificateResult.Metadata` expose a certificate's metadata as a case-insensitive dictionary.
|
||||
- A metadata failure is reported as a warning rather than failing an issuance that otherwise succeeded, since by that point the certificate exists and may already be installed.
|
||||
|
||||
### Added (tooling)
|
||||
|
||||
- `Scripts/Initialize-InfisicalCertManagerEnvironment.ps1` seeds a Certificate Manager project, CA hierarchy, certificate policies, and API enrollment profiles from one declarative configuration block, taking only a base URI, client id, and client secret. Idempotent and `-WhatIf`-aware. Seeds an RSA hierarchy for SCCM/MECM server and client authentication, and an ECDSA P-384 hierarchy for server/client authentication and code signing.
|
||||
|
||||
### Fixed (documentation)
|
||||
|
||||
- Corrected the claim that a newly created certificate authority has direct issuance enabled. The creation service passes `enableDirectIssuance: false` explicitly, so **every** CA created through the API or UI has it disabled regardless of the database default, and nothing can enable it afterwards. Certificate profiles remain the only path that ignores the flag.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@{
|
||||
RootModule = 'PSInfisicalAPI.psm1'
|
||||
ModuleVersion = '2026.07.30.2305'
|
||||
ModuleVersion = '2026.07.31.0045'
|
||||
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 = 'f65124fd9911'
|
||||
CommitHash = 'd47a5af6b3a6'
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1289,6 +1289,8 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
|
||||
<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>-Metadata attaches key/value pairs to the certificate in Infisical and accepts any IDictionary, such as a hashtable or an [Ordered] dictionary. Only the supplied keys are reconciled; keys already on the certificate that the call does not mention are left alone, so several callers can each own their own keys. This is performed client-side because Infisical's PATCH replaces a certificate's metadata wholesale, so the module reads the current set, merges the supplied keys over it, and writes back the union; when nothing would change no request is sent. Reconciliation also runs on the reuse path, so a metadata change lands without forcing reissuance. Values are flattened to strings, keys are trimmed and compared case-insensitively, and blank keys are dropped. The resulting metadata is returned on the result's Metadata property. A metadata failure is reported as a warning and does not fail an issuance that otherwise succeeded.</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>
|
||||
|
||||
@@ -1289,6 +1289,8 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
|
||||
<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>-Metadata attaches key/value pairs to the certificate in Infisical and accepts any IDictionary, such as a hashtable or an [Ordered] dictionary. Only the supplied keys are reconciled; keys already on the certificate that the call does not mention are left alone, so several callers can each own their own keys. This is performed client-side because Infisical's PATCH replaces a certificate's metadata wholesale, so the module reads the current set, merges the supplied keys over it, and writes back the union; when nothing would change no request is sent. Reconciliation also runs on the reuse path, so a metadata change lands without forcing reissuance. Values are flattened to strings, keys are trimmed and compared case-insensitively, and blank keys are dropped. The resulting metadata is returned on the result's Metadata property. A metadata failure is reported as a warning and does not fail an issuance that otherwise succeeded.</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>
|
||||
|
||||
@@ -309,6 +309,65 @@ Windows will report "The issuer of this certificate could not be found" until th
|
||||
|
||||
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`.
|
||||
|
||||
### Metadata
|
||||
|
||||
`-Metadata` attaches key/value pairs to the certificate in Infisical, and accepts any `IDictionary` — a hashtable, an `[Ordered]` dictionary, or a generic `Dictionary[String,String]`:
|
||||
|
||||
```powershell
|
||||
$RequestInfisicalCertificateParameters.Metadata = [Ordered]@{
|
||||
Environment = 'Production'
|
||||
Owner = 'Platform Engineering'
|
||||
ManagedBy = 'Invoke-SecretStaging'
|
||||
Site = 'HQ'
|
||||
}
|
||||
```
|
||||
|
||||
**Only the supplied keys are reconciled.** Keys already on the certificate that this call does not mention are left alone, so several callers can each own their own keys without clobbering each other:
|
||||
|
||||
```powershell
|
||||
Request-InfisicalCertificate @Parameters -Metadata @{ Owner = 'Platform' } # certificate now has Owner
|
||||
Request-InfisicalCertificate @Parameters -Metadata @{ Site = 'HQ' } # Owner survives; Site added
|
||||
Request-InfisicalCertificate @Parameters -Metadata @{ Owner = 'Security' } # Owner updated; Site survives
|
||||
```
|
||||
|
||||
This is done client-side. Infisical's `PATCH /certificates/{id}` replaces a certificate's metadata wholesale — the service deletes every row before inserting what it was sent — so the module reads the current set, merges the supplied keys over it, and writes back the union. When nothing would change, no request is sent at all.
|
||||
|
||||
Reconciliation runs on the reuse path too, so a metadata change lands without forcing reissuance. Values are flattened to strings (`443` becomes `"443"`, `$True` becomes `"True"`, `$Null` becomes `""`), keys are trimmed and compared case-insensitively, and blank keys are dropped.
|
||||
|
||||
The result carries the certificate's metadata after reconciliation:
|
||||
|
||||
```powershell
|
||||
$Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParameters
|
||||
$Certificate.Metadata['Environment'] # Production
|
||||
```
|
||||
|
||||
Metadata never fails an issuance that otherwise succeeded. By the time it is applied the certificate exists and may already be installed, so a failure is reported as a warning and the certificate is still returned.
|
||||
|
||||
Metadata is also a search filter — `Get-InfisicalCertificate` accepts `-Metadata` to find certificates by the keys you stamped on them.
|
||||
|
||||
### 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.
|
||||
@@ -320,7 +379,7 @@ When the resolved location is `LocalMachine` and `-KeyStorageFlags` was not supp
|
||||
| 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. |
|
||||
| `-CertificateAuthorityId` | **Per request**, unconstrained | Rarely usable — needs direct issuance, which cannot be enabled (see below). |
|
||||
| `-PkiSubscriberSlug` | **Fixed** by the subscriber record | One named identity — a specific service or host, provisioned in advance. |
|
||||
|
||||
**A PKI subscriber is a per-identity object, not a fleet template.** `signSubscriberCert` rejects any CSR whose CN differs from the subscriber's:
|
||||
@@ -390,7 +449,15 @@ t.renameColumn("requireTemplateForIssuance", "enableDirectIssuance");
|
||||
.update({ name: slugifiedName, enableDirectIssuance: !ca.enableDirectIssuance });
|
||||
```
|
||||
|
||||
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`.
|
||||
Any CA created before that migration with "require template for issuance" enabled now reads `EnableDirectIssuance = False` permanently.
|
||||
|
||||
Creating a new CA does not help either. Although the database column defaults to `true`, the creation service passes `false` explicitly:
|
||||
|
||||
```ts
|
||||
const ca = await certificateAuthorityDAL.create({ projectId, name: resolvedCaName, status, enableDirectIssuance: false }, tx);
|
||||
```
|
||||
|
||||
So **every CA created through the API or UI has direct issuance disabled**, and nothing can turn it on afterwards. Use a certificate profile, which ignores the flag entirely.
|
||||
|
||||
```powershell
|
||||
Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal |
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace PSInfisicalAPI.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// PATCH /certificates/{id} replaces a certificate's metadata wholesale, so "reconcile only the supplied
|
||||
/// keys" has to be produced client-side by merging over the current set. These pin that merge.
|
||||
/// </summary>
|
||||
public class CertificateMetadataTests
|
||||
{
|
||||
private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly;
|
||||
|
||||
private static Dictionary<string, string> NormalizeMetadata(IDictionary source)
|
||||
{
|
||||
Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet", true);
|
||||
MethodInfo method = cmdletType.GetMethod("NormalizeMetadata", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.NotNull(method);
|
||||
return (Dictionary<string, string>)method.Invoke(null, new object[] { source });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metadata_Parameter_Accepts_Any_IDictionary_On_Every_Parameter_Set()
|
||||
{
|
||||
Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet", true);
|
||||
PropertyInfo metadata = cmdletType.GetProperty("Metadata");
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(typeof(IDictionary), metadata.PropertyType);
|
||||
|
||||
foreach (CustomAttributeData attribute in metadata.GetCustomAttributesData())
|
||||
{
|
||||
if (attribute.AttributeType != typeof(System.Management.Automation.ParameterAttribute)) { continue; }
|
||||
foreach (CustomAttributeNamedArgument named in attribute.NamedArguments)
|
||||
{
|
||||
Assert.NotEqual("ParameterSetName", named.MemberName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hashtable_And_OrderedDictionary_Both_Normalize()
|
||||
{
|
||||
Hashtable hashtable = new Hashtable { { "Environment", "Production" }, { "Owner", "Platform" } };
|
||||
Dictionary<string, string> fromHashtable = NormalizeMetadata(hashtable);
|
||||
Assert.Equal(2, fromHashtable.Count);
|
||||
Assert.Equal("Production", fromHashtable["Environment"]);
|
||||
|
||||
OrderedDictionary ordered = new OrderedDictionary();
|
||||
ordered.Add("Environment", "Production");
|
||||
ordered.Add("Owner", "Platform");
|
||||
Dictionary<string, string> fromOrdered = NormalizeMetadata(ordered);
|
||||
Assert.Equal(2, fromOrdered.Count);
|
||||
Assert.Equal("Platform", fromOrdered["Owner"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Non_String_Values_Are_Flattened_For_An_Api_That_Takes_Only_Strings()
|
||||
{
|
||||
Hashtable source = new Hashtable
|
||||
{
|
||||
{ "Port", 443 },
|
||||
{ "Enabled", true },
|
||||
{ "Ratio", 1.5d },
|
||||
{ "Issued", new DateTime(2026, 7, 30, 0, 0, 0, DateTimeKind.Utc) }
|
||||
};
|
||||
|
||||
Dictionary<string, string> result = NormalizeMetadata(source);
|
||||
|
||||
Assert.Equal("443", result["Port"]);
|
||||
Assert.Equal("True", result["Enabled"]);
|
||||
Assert.Equal("1.5", result["Ratio"]);
|
||||
Assert.False(string.IsNullOrEmpty(result["Issued"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_Values_Become_Empty_Strings_And_Blank_Keys_Are_Dropped()
|
||||
{
|
||||
// The API models a valueless key as an empty string, and rejects an empty key outright.
|
||||
Hashtable source = new Hashtable
|
||||
{
|
||||
{ "Present", null },
|
||||
{ " ", "orphan" },
|
||||
{ " Padded ", "trimmed" }
|
||||
};
|
||||
|
||||
Dictionary<string, string> result = NormalizeMetadata(source);
|
||||
|
||||
Assert.Equal(string.Empty, result["Present"]);
|
||||
Assert.False(result.ContainsKey(" "));
|
||||
Assert.True(result.ContainsKey("Padded"));
|
||||
Assert.Equal("trimmed", result["Padded"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keys_Are_Case_Insensitive()
|
||||
{
|
||||
Hashtable source = new Hashtable { { "Environment", "Production" } };
|
||||
Dictionary<string, string> result = NormalizeMetadata(source);
|
||||
Assert.Equal("Production", result["ENVIRONMENT"]);
|
||||
Assert.Equal("Production", result["environment"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_Null_Or_Empty_Dictionary_Normalizes_To_Nothing()
|
||||
{
|
||||
Assert.Empty(NormalizeMetadata(null));
|
||||
Assert.Empty(NormalizeMetadata(new Hashtable()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Request_Serializes_As_The_Key_Value_Array_The_Api_Expects()
|
||||
{
|
||||
Type requestType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalUpdateCertificateMetadataRequestDto", true);
|
||||
Type entryType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateMetadataEntryDto", true);
|
||||
|
||||
object entry = Activator.CreateInstance(entryType);
|
||||
entryType.GetProperty("Key").SetValue(entry, "Environment");
|
||||
entryType.GetProperty("Value").SetValue(entry, "Production");
|
||||
|
||||
Type listType = typeof(List<>).MakeGenericType(entryType);
|
||||
object list = Activator.CreateInstance(listType);
|
||||
listType.GetMethod("Add").Invoke(list, new object[] { entry });
|
||||
|
||||
object request = Activator.CreateInstance(requestType);
|
||||
requestType.GetProperty("Metadata").SetValue(request, list);
|
||||
|
||||
string json = JsonConvert.SerializeObject(request);
|
||||
Assert.Equal("{\"metadata\":[{\"key\":\"Environment\",\"value\":\"Production\"}]}", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Response_Metadata_Maps_To_A_Case_Insensitive_Dictionary()
|
||||
{
|
||||
Type mapper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateMapper", true);
|
||||
MethodInfo map = mapper.GetMethod("MapMetadata", BindingFlags.Public | BindingFlags.Static);
|
||||
Assert.NotNull(map);
|
||||
|
||||
Type entryType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateSearchMetadataEntryDto", true);
|
||||
Array entries = Array.CreateInstance(entryType, 2);
|
||||
|
||||
object first = Activator.CreateInstance(entryType);
|
||||
entryType.GetProperty("Key").SetValue(first, "Environment");
|
||||
entryType.GetProperty("Value").SetValue(first, "Production");
|
||||
entries.SetValue(first, 0);
|
||||
|
||||
object second = Activator.CreateInstance(entryType);
|
||||
entryType.GetProperty("Key").SetValue(second, "Owner");
|
||||
entryType.GetProperty("Value").SetValue(second, null);
|
||||
entries.SetValue(second, 1);
|
||||
|
||||
Dictionary<string, string> result = (Dictionary<string, string>)map.Invoke(null, new object[] { entries });
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("Production", result["ENVIRONMENT"]);
|
||||
Assert.Equal(string.Empty, result["Owner"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Absent_Response_Metadata_Maps_To_An_Empty_Dictionary_Not_Null()
|
||||
{
|
||||
Type mapper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateMapper", true);
|
||||
MethodInfo map = mapper.GetMethod("MapMetadata", BindingFlags.Public | BindingFlags.Static);
|
||||
|
||||
Dictionary<string, string> result = (Dictionary<string, string>)map.Invoke(null, new object[] { null });
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// current supplied expected merged
|
||||
[InlineData("a=1;b=2", "c=3", "a=1;b=2;c=3")] // untouched keys survive a partial update
|
||||
[InlineData("a=1;b=2", "b=9", "a=1;b=9")] // supplied key wins
|
||||
[InlineData("", "a=1", "a=1")] // first write onto a bare certificate
|
||||
[InlineData("a=1", "A=2", "a=2")] // case-insensitive key collision updates in place
|
||||
[InlineData("a=1;b=2", "", "a=1;b=2")] // nothing supplied changes nothing
|
||||
public void Merge_Reconciles_Only_The_Supplied_Keys(string current, string supplied, string expected)
|
||||
{
|
||||
// Mirrors ReconcileCertificateMetadata's merge, which cannot be exercised directly without an
|
||||
// HTTP round trip. The rule under test: start from current, overlay supplied, never remove.
|
||||
Dictionary<string, string> merged = new Dictionary<string, string>(Parse(current), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (KeyValuePair<string, string> pair in Parse(supplied))
|
||||
{
|
||||
merged[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
Assert.Equal(Format(Parse(expected)), Format(merged));
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Parse(string value)
|
||||
{
|
||||
Dictionary<string, string> result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrEmpty(value)) { return result; }
|
||||
|
||||
foreach (string pair in value.Split(';'))
|
||||
{
|
||||
if (string.IsNullOrEmpty(pair)) { continue; }
|
||||
string[] parts = pair.Split('=');
|
||||
result[parts[0]] = parts.Length > 1 ? parts[1] : string.Empty;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Format(Dictionary<string, string> value)
|
||||
{
|
||||
List<string> pairs = new List<string>();
|
||||
foreach (KeyValuePair<string, string> entry in value)
|
||||
{
|
||||
pairs.Add(string.Concat(entry.Key.ToLowerInvariant(), "=", entry.Value));
|
||||
}
|
||||
|
||||
pairs.Sort(StringComparer.Ordinal);
|
||||
return string.Join(";", pairs.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Result_Object_Carries_The_Reconciled_Metadata()
|
||||
{
|
||||
PSInfisicalAPI.Models.InfisicalCertificateResult result = new PSInfisicalAPI.Models.InfisicalCertificateResult();
|
||||
Assert.Null(result.Metadata);
|
||||
|
||||
result.Metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { { "Environment", "Production" } };
|
||||
Assert.Equal("Production", result.Metadata["environment"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metadata_Update_Endpoint_Is_Registered_For_Both_Route_Namespaces()
|
||||
{
|
||||
IReadOnlyList<PSInfisicalAPI.Endpoints.InfisicalEndpointDefinition> candidates =
|
||||
PSInfisicalAPI.Endpoints.InfisicalEndpointRegistry.GetCandidates(
|
||||
PSInfisicalAPI.Endpoints.InfisicalEndpointNames.UpdateCertificateMetadata);
|
||||
|
||||
Assert.Contains(candidates, c => c.Template == "/api/v1/cert-manager/certificates/{certificateId}");
|
||||
Assert.Contains(candidates, c => c.Template == "/api/v1/pki/certificates/{certificateId}");
|
||||
Assert.All(candidates, c => Assert.Equal("PATCH", c.Method));
|
||||
Assert.All(candidates, c => Assert.True(c.RequiresAuthorization));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,12 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
// 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; }
|
||||
|
||||
/// <summary>
|
||||
/// Metadata to attach to the issued or reused certificate. Only the supplied keys are reconciled;
|
||||
/// any other metadata already on the certificate is left alone.
|
||||
/// </summary>
|
||||
[Parameter] public IDictionary Metadata { get; set; }
|
||||
[Parameter(ParameterSetName = "ByCa")] public string PkiCollectionId { get; set; }
|
||||
[Parameter(ParameterSetName = "ByCa")]
|
||||
[Parameter(ParameterSetName = "ByProfile")] public string[] KeyUsage { get; set; }
|
||||
@@ -90,7 +96,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
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, resolvedStoreLocation);
|
||||
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"), ")."));
|
||||
@@ -116,6 +122,9 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
}
|
||||
|
||||
// Reconciled on the reuse path too, so a metadata change lands without forcing reissuance.
|
||||
reuseResult.Metadata = ApplyMetadata(client, connection, existing.SerialNumber);
|
||||
|
||||
WriteObject(reuseResult);
|
||||
return;
|
||||
}
|
||||
@@ -161,6 +170,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
|
||||
InfisicalCertificateResult resultObj = InfisicalCertificateRequestHelpers.BuildResult(cert, signed);
|
||||
resultObj.Metadata = ApplyMetadata(client, connection, signed.SerialNumber);
|
||||
|
||||
bool hasExplicitPath = !string.IsNullOrEmpty(PrivateKeyPath);
|
||||
if (hasExplicitPath && !string.IsNullOrEmpty(resultObj.PrivateKeyPem))
|
||||
@@ -183,6 +193,79 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles -Metadata onto the certificate identified by <paramref name="serialNumber"/> and returns
|
||||
/// the certificate's resulting metadata. Only the supplied keys are touched, so a certificate can carry
|
||||
/// metadata from several sources without them overwriting each other.
|
||||
/// <para>
|
||||
/// Metadata never fails an issuance that otherwise succeeded: by the time this runs the certificate
|
||||
/// exists and may already be installed, so a failure here is reported as a warning and the certificate
|
||||
/// is still emitted.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private Dictionary<string, string> ApplyMetadata(InfisicalPkiClient client, InfisicalConnection connection, string serialNumber)
|
||||
{
|
||||
Dictionary<string, string> desired = NormalizeMetadata(Metadata);
|
||||
if (desired.Count == 0) { return null; }
|
||||
|
||||
if (string.IsNullOrEmpty(serialNumber))
|
||||
{
|
||||
Logger.Warning(Component, "-Metadata was supplied but the certificate has no serial number to identify it by; metadata was not applied.");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
InfisicalCertificate record = client.RetrieveCertificate(connection, serialNumber);
|
||||
if (record == null || string.IsNullOrEmpty(record.Id))
|
||||
{
|
||||
Logger.Warning(Component, string.Concat("-Metadata was supplied but certificate '", serialNumber, "' could not be resolved in Infisical; metadata was not applied."));
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, string> result = client.ReconcileCertificateMetadata(connection, record.Id, desired);
|
||||
Logger.Information(Component, string.Concat(
|
||||
"Reconciled ", desired.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
" metadata key(s) onto certificate '", record.Id, "'; it now carries ",
|
||||
(result != null ? result.Count : 0).ToString(System.Globalization.CultureInfo.InvariantCulture), " key(s)."));
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception metadataException)
|
||||
{
|
||||
if (IsPipelineControlException(metadataException)) { throw; }
|
||||
Logger.Warning(Component, string.Concat("The certificate was issued but its metadata could not be applied: ", metadataException.Message));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flattens the caller's dictionary into string key/value pairs. PowerShell hands over hashtables whose
|
||||
/// keys and values are arbitrary objects, and the API accepts only strings.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, string> NormalizeMetadata(IDictionary source)
|
||||
{
|
||||
Dictionary<string, string> result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (source == null) { return result; }
|
||||
|
||||
foreach (DictionaryEntry entry in source)
|
||||
{
|
||||
if (entry.Key == null) { continue; }
|
||||
|
||||
string key = Convert.ToString(entry.Key, System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (string.IsNullOrWhiteSpace(key)) { continue; }
|
||||
key = key.Trim();
|
||||
|
||||
string value = entry.Value != null
|
||||
? Convert.ToString(entry.Value, System.Globalization.CultureInfo.InvariantCulture)
|
||||
: string.Empty;
|
||||
|
||||
result[key] = value ?? string.Empty;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <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.
|
||||
@@ -307,17 +390,39 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
return System.Net.IPAddress.TryParse(value, out parsed);
|
||||
}
|
||||
|
||||
private X509Certificate2 TryFindExisting(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string commonName, StoreLocation storeLocation)
|
||||
/// <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)
|
||||
{
|
||||
@@ -325,7 +430,61 @@ 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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
|
||||
@@ -61,6 +61,7 @@ namespace PSInfisicalAPI.Endpoints
|
||||
public const string SearchCertificates = "SearchCertificates";
|
||||
public const string RetrieveCertificate = "RetrieveCertificate";
|
||||
public const string GetCertificateBundle = "GetCertificateBundle";
|
||||
public const string UpdateCertificateMetadata = "UpdateCertificateMetadata";
|
||||
public const string SignCertificateBySubscriber = "SignCertificateBySubscriber";
|
||||
public const string SignCertificateByCa = "SignCertificateByCa";
|
||||
public const string IssueCertificateByProfile = "IssueCertificateByProfile";
|
||||
|
||||
@@ -698,6 +698,26 @@ namespace PSInfisicalAPI.Endpoints
|
||||
ContainsSecretMaterialInResponse = true
|
||||
});
|
||||
|
||||
Add(map, new InfisicalEndpointDefinition
|
||||
{
|
||||
Name = InfisicalEndpointNames.UpdateCertificateMetadata,
|
||||
Resource = "Pki",
|
||||
Version = "v1",
|
||||
Method = "PATCH",
|
||||
Template = "/api/v1/cert-manager/certificates/{certificateId}",
|
||||
RequiresAuthorization = true
|
||||
});
|
||||
|
||||
Add(map, new InfisicalEndpointDefinition
|
||||
{
|
||||
Name = InfisicalEndpointNames.UpdateCertificateMetadata,
|
||||
Resource = "Pki",
|
||||
Version = "v1",
|
||||
Method = "PATCH",
|
||||
Template = "/api/v1/pki/certificates/{certificateId}",
|
||||
RequiresAuthorization = true
|
||||
});
|
||||
|
||||
Add(map, new InfisicalEndpointDefinition
|
||||
{
|
||||
Name = InfisicalEndpointNames.SignCertificateBySubscriber,
|
||||
|
||||
@@ -36,6 +36,12 @@ namespace PSInfisicalAPI.Models
|
||||
public string Source { get; set; }
|
||||
public string EnrollmentType { get; set; }
|
||||
public bool HasPrivateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Metadata key/value pairs attached to the certificate. Case-insensitive on key, matching how the
|
||||
/// API treats them and how callers supply them from PowerShell.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.Dictionary<string, string> Metadata { get; set; }
|
||||
public int? RevocationReason { get; set; }
|
||||
public string RenewalError { get; set; }
|
||||
public int? RenewBeforeDays { get; set; }
|
||||
|
||||
@@ -17,6 +17,11 @@ namespace PSInfisicalAPI.Models
|
||||
public string StatusMessage { get; set; }
|
||||
public string CertificateRequestId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The certificate's metadata in Infisical after reconciliation, or null when -Metadata was not used.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.Dictionary<string, string> Metadata { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (Leaf != null) { return Leaf.Subject; }
|
||||
|
||||
@@ -37,6 +37,7 @@ namespace PSInfisicalAPI.Pki
|
||||
[JsonProperty("source")] public string Source { get; set; }
|
||||
[JsonProperty("enrollmentType")] public string EnrollmentType { get; set; }
|
||||
[JsonProperty("hasPrivateKey")] public bool HasPrivateKey { get; set; }
|
||||
[JsonProperty("metadata", NullValueHandling = NullValueHandling.Ignore)] public InfisicalCertificateSearchMetadataEntryDto[] Metadata { get; set; }
|
||||
[JsonProperty("revocationReason")] public int? RevocationReason { get; set; }
|
||||
[JsonProperty("renewalError")] public string RenewalError { get; set; }
|
||||
[JsonProperty("renewBeforeDays")] public int? RenewBeforeDays { get; set; }
|
||||
@@ -96,6 +97,26 @@ namespace PSInfisicalAPI.Pki
|
||||
[JsonProperty("value", NullValueHandling = NullValueHandling.Ignore)] public string Value { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Body for PATCH /certificates/{id}. The API replaces the certificate's entire metadata set with what
|
||||
/// is sent, so this must always carry the full desired set rather than a delta.
|
||||
/// </summary>
|
||||
internal sealed class InfisicalUpdateCertificateMetadataRequestDto
|
||||
{
|
||||
[JsonProperty("metadata")] public List<InfisicalCertificateMetadataEntryDto> Metadata { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class InfisicalCertificateMetadataEntryDto
|
||||
{
|
||||
[JsonProperty("key")] public string Key { get; set; }
|
||||
[JsonProperty("value")] public string Value { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class InfisicalUpdateCertificateMetadataResponseDto
|
||||
{
|
||||
[JsonProperty("metadata")] public List<InfisicalCertificateMetadataEntryDto> Metadata { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class InfisicalCertificateBundleResponseDto
|
||||
{
|
||||
[JsonProperty("serialNumber")] public string SerialNumber { get; set; }
|
||||
|
||||
@@ -7,6 +7,24 @@ namespace PSInfisicalAPI.Pki
|
||||
{
|
||||
internal static class InfisicalCertificateMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Projects the API's metadata array into a case-insensitive dictionary. A certificate with no
|
||||
/// metadata maps to an empty dictionary rather than null, so callers can index it unconditionally.
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> MapMetadata(InfisicalCertificateSearchMetadataEntryDto[] entries)
|
||||
{
|
||||
Dictionary<string, string> result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (entries == null) { return result; }
|
||||
|
||||
foreach (InfisicalCertificateSearchMetadataEntryDto entry in entries)
|
||||
{
|
||||
if (entry == null || string.IsNullOrEmpty(entry.Key)) { continue; }
|
||||
result[entry.Key] = entry.Value ?? string.Empty;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static InfisicalCertificate Map(InfisicalCertificateResponseDto dto, string fallbackProjectId)
|
||||
{
|
||||
if (dto == null)
|
||||
@@ -17,6 +35,7 @@ namespace PSInfisicalAPI.Pki
|
||||
return new InfisicalCertificate
|
||||
{
|
||||
Id = dto.Id,
|
||||
Metadata = MapMetadata(dto.Metadata),
|
||||
ProjectId = !string.IsNullOrEmpty(dto.ProjectId) ? dto.ProjectId : fallbackProjectId,
|
||||
CaId = dto.CaId,
|
||||
CaName = dto.CaName,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -209,6 +209,104 @@ namespace PSInfisicalAPI.Pki
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies metadata to a certificate, reconciling only the supplied keys.
|
||||
/// <para>
|
||||
/// PATCH /certificates/{id} replaces a certificate's metadata wholesale - the service deletes every
|
||||
/// existing row before inserting what it was sent - so sending just the caller's keys would silently
|
||||
/// discard everything else attached to the certificate. The current set is read first and the supplied
|
||||
/// keys are merged over it, leaving untouched keys intact. Re-running with the same input is a no-op.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>The certificate's full metadata after reconciliation.</returns>
|
||||
public Dictionary<string, string> ReconcileCertificateMetadata(InfisicalConnection connection, string certificateId, IReadOnlyDictionary<string, string> desired)
|
||||
{
|
||||
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
|
||||
if (string.IsNullOrEmpty(certificateId)) { throw new InfisicalConfigurationException("CertificateId is required."); }
|
||||
|
||||
Dictionary<string, string> current = GetCertificateMetadata(connection, certificateId);
|
||||
if (desired == null || desired.Count == 0) { return current; }
|
||||
|
||||
Dictionary<string, string> merged = new Dictionary<string, string>(current, StringComparer.OrdinalIgnoreCase);
|
||||
bool changed = false;
|
||||
foreach (KeyValuePair<string, string> pair in desired)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pair.Key)) { continue; }
|
||||
string value = pair.Value ?? string.Empty;
|
||||
|
||||
string existing;
|
||||
if (merged.TryGetValue(pair.Key, out existing) && string.Equals(existing, value, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[pair.Key] = value;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
{
|
||||
_logger.Verbose(Component, string.Concat("Certificate metadata already matches the requested keys for '", certificateId, "'; no update sent."));
|
||||
return merged;
|
||||
}
|
||||
|
||||
List<InfisicalCertificateMetadataEntryDto> entries = new List<InfisicalCertificateMetadataEntryDto>();
|
||||
foreach (KeyValuePair<string, string> pair in merged)
|
||||
{
|
||||
entries.Add(new InfisicalCertificateMetadataEntryDto { Key = pair.Key, Value = pair.Value ?? string.Empty });
|
||||
}
|
||||
|
||||
Dictionary<string, string> pathParameters = new Dictionary<string, string> { { "certificateId", certificateId } };
|
||||
string body = _serializer.Serialize(new InfisicalUpdateCertificateMetadataRequestDto { Metadata = entries });
|
||||
|
||||
try
|
||||
{
|
||||
_logger.Information(Component, string.Concat("Attempting to update metadata on certificate '", certificateId, "'. Please Wait..."));
|
||||
InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.UpdateCertificateMetadata, "UpdateCertificateMetadata", pathParameters, null, body);
|
||||
InfisicalUpdateCertificateMetadataResponseDto dto = _serializer.Deserialize<InfisicalUpdateCertificateMetadataResponseDto>(response.Body);
|
||||
response.Clear();
|
||||
|
||||
Dictionary<string, string> result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (dto != null && dto.Metadata != null)
|
||||
{
|
||||
foreach (InfisicalCertificateMetadataEntryDto entry in dto.Metadata)
|
||||
{
|
||||
if (entry == null || string.IsNullOrEmpty(entry.Key)) { continue; }
|
||||
result[entry.Key] = entry.Value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result = merged;
|
||||
}
|
||||
|
||||
_logger.Information(Component, "Infisical certificate metadata update was successful.");
|
||||
return result;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_logger.Error(Component, "Infisical certificate metadata update failed.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the metadata currently attached to a certificate, addressed by its identifier.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GetCertificateMetadata(InfisicalConnection connection, string certificateId)
|
||||
{
|
||||
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
|
||||
if (string.IsNullOrEmpty(certificateId)) { throw new InfisicalConfigurationException("CertificateId is required."); }
|
||||
|
||||
InfisicalCertificate certificate = RetrieveCertificate(connection, certificateId);
|
||||
if (certificate == null || certificate.Metadata == null)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return new Dictionary<string, string>(certificate.Metadata, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public InfisicalSignedCertificate SignCertificateBySubscriber(InfisicalConnection connection, string subscriberName, string projectId, string csrPem)
|
||||
{
|
||||
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
|
||||
|
||||
Reference in New Issue
Block a user