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>
This commit is contained in:
+13
-1
@@ -6,11 +6,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 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 +24,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
|
||||
|
||||
## Unreleased (carried forward)
|
||||
|
||||
### Fixed (certificate reuse)
|
||||
|
||||
- **Switching certificate profiles reused the old certificate.** The reuse check matched on common name alone, so requesting from a client-authentication profile on a host already holding a server-authentication certificate for the same name returned the existing certificate — with the wrong extended key usages. The reuse search is now scoped by `-CertificateProfileId` or `-CertificateAuthorityId`.
|
||||
- A reuse search that completed and found nothing no longer falls through to a name-only local match. Previously an empty result disabled the serial filter entirely, which is what allowed a certificate from another issuer to be returned.
|
||||
- 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.30.2344'
|
||||
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 = 'dadba2f4c890'
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1289,6 +1289,7 @@ $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>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 does not compare subject alternative names, so use -Force after changing the SAN set. 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,7 @@ $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>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 does not compare subject alternative names, so use -Force after changing the SAN set. 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,22 @@ 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`.
|
||||
|
||||
### 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).
|
||||
```
|
||||
|
||||
`-Force` issues unconditionally, and `-AllowRenewal` with `-RenewalThresholdDays` rotates a certificate that is inside its renewal window.
|
||||
|
||||
> **Known gap:** reuse does not compare SANs. Adding a name to `-DnsName` and re-running returns the existing certificate, which lacks the new SAN. Use `-Force` after changing the SAN set.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,17 +307,39 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
return System.Net.IPAddress.TryParse(value, out parsed);
|
||||
}
|
||||
|
||||
/// <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> 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,9 +347,52 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
Logger.Verbose(Component, string.Concat("Infisical search for idempotency check failed: ", searchException.Message));
|
||||
}
|
||||
|
||||
// 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."));
|
||||
}
|
||||
|
||||
return InfisicalLocalCertificateLookup.FindMatch(StoreName, storeLocation, commonName, candidateSerials);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Narrows a certificate search to the issuer this invocation targets, and names that scope for logging.
|
||||
/// The subscriber path has no server-side filter, but a subscriber pins its own common name, so matching
|
||||
/// on the name is already equivalent to matching on the subscriber.
|
||||
/// </summary>
|
||||
private string ApplyIssuerScope(InfisicalCertificateSearchQuery query)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(CertificateProfileId))
|
||||
{
|
||||
query.ProfileIds = new[] { CertificateProfileId };
|
||||
return string.Concat("certificate profile '", CertificateProfileId, "'");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(CertificateAuthorityId))
|
||||
{
|
||||
query.CaIds = new[] { CertificateAuthorityId };
|
||||
return string.Concat("certificate authority '", CertificateAuthorityId, "'");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(PkiSubscriberSlug))
|
||||
{
|
||||
return string.Concat("PKI subscriber '", PkiSubscriberSlug, "'");
|
||||
}
|
||||
|
||||
return "this project";
|
||||
}
|
||||
|
||||
private X509KeyStorageFlags ResolveEffectiveKeyStorageFlags(StoreLocation storeLocation)
|
||||
{
|
||||
if (MyInvocation.BoundParameters.ContainsKey("KeyStorageFlags"))
|
||||
|
||||
Reference in New Issue
Block a user