6 Commits

Author SHA1 Message Date
gsadmin 83b79b9109 Merge pull request 'Certificate metadata reconciliation and a cert-manager seeding script' (#22) from dev into main
Reviewed-on: #22
2026-07-31 01:01:25 +00:00
gsadmin 93b0cc1924 Add -Metadata to Request-InfisicalCertificate, reconciling only the supplied keys
Publish to PowerShell Gallery / build (pull_request) Successful in 31s
Publish to PowerShell Gallery / release (pull_request) Successful in 11s
Publish to PowerShell Gallery / publish (pull_request) Successful in 9s
Takes any IDictionary, so a hashtable, an [Ordered] dictionary, or a generic
Dictionary[String,String] all bind. Verified against all three issuance
parameter sets under Windows PowerShell 5.1.

The reconcile is client-side by necessity. Infisical's PATCH /certificates/{id}
replaces a certificate's metadata wholesale - certificate-v3-service deletes
every resource_metadata row for the certificate before inserting what it was
sent - so sending just the caller's keys would silently discard everything else
attached to it. The module reads the current set, merges the supplied keys over
it, and writes back the union. Keys it was not asked about survive, and when
nothing would change no request is sent at all.

Reconciliation runs on the reuse path too, so changing metadata does not force
a reissuance to take effect.

Values are flattened to strings because the API accepts only strings: 443
becomes "443", $True becomes "True", $Null becomes "". Keys are trimmed and
compared case-insensitively, matching how PowerShell callers supply them, and
blank keys are dropped since the API rejects them.

Metadata never fails an issuance that otherwise succeeded. By the time it is
applied the certificate exists and may already be installed in the store, so a
failure is reported as a warning and the certificate is still emitted.

Adds InfisicalCertificate.Metadata and InfisicalCertificateResult.Metadata as
case-insensitive dictionaries, the PATCH endpoint under both the cert-manager
and pki route namespaces, and models the metadata array already returned by the
certificate response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:46:03 -04:00
gsadmin d47a5af6b3 Add a cert-manager environment seeding script
Stands up a complete Certificate Manager environment from one declarative
configuration block, taking only a base URI, client id, and client secret.
Standalone: the module consumes the result but is not needed to produce it,
since InfisicalConnection deliberately does not expose its access token.

Seeds an RSA hierarchy carrying server and client authentication for SCCM/MECM,
and an ECDSA P-384 hierarchy carrying server, client, and code signing. Objects
are cross-referenced by name in the configuration and resolved to ids at run
time, so adding a policy or profile means adding an entry rather than editing
code.

Creating a subordinate CA needed more than one call. Infisical only self-signs
on creation for a root, and only when given an expiry; a subordinate is created
with status pending-certificate and generateIntermediateCaCertificate is never
invoked by the create path. The script performs the sequence itself: create,
GET the CSR, sign it with the parent, then import the certificate and chain
back onto the subordinate.

Idempotent by lookup on each natural key, so a re-run reports what exists and
creates only what is missing, and -WhatIf shows the whole plan without
contacting anything beyond authentication.

Also corrects a documentation error this research surfaced. The README claimed a
newly created CA defaults to direct issuance enabled, on the strength of the
database column default. The creation service passes enableDirectIssuance:false
explicitly, so every CA created through the API or UI has it disabled and
nothing can enable it afterwards - which makes certificate profiles the only
workable issuance path, not merely the recommended one.

Validated by parsing the script, exercising the policy payload builders against
the shapes Infisical's zod schemas accept, confirming every enum literal matches
a defined Infisical value, checking that all name cross-references resolve, and
running -WhatIf to the point of authentication. Not yet exercised against a live
Infisical instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:26:32 -04:00
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
19 changed files with 1246 additions and 9 deletions
+43 -1
View File
@@ -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.
+2 -2
View File
@@ -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>
+69 -2
View File
@@ -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)); }