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:
2026-07-30 19:44:59 -04:00
parent dadba2f4c8
commit 67cf0abac2
8 changed files with 215 additions and 4 deletions
@@ -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"))