diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b5c04..d85453f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 index 5db849e..c3b345f 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 @@ -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' } } } \ No newline at end of file diff --git a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll index a71e29a..7017513 100644 Binary files a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll and b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll differ diff --git a/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml b/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml index 5b888fb..573f514 100644 --- a/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml +++ b/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml @@ -1289,6 +1289,7 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi 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. 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". 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. + 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. diff --git a/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml b/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml index 5b888fb..573f514 100644 --- a/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml +++ b/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml @@ -1289,6 +1289,7 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi 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. 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". 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. + 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. diff --git a/README.md b/README.md index 59f8011..977fb3a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/PSInfisicalAPI.Tests/CertificateReuseScopingTests.cs b/src/PSInfisicalAPI.Tests/CertificateReuseScopingTests.cs new file mode 100644 index 0000000..062e39f --- /dev/null +++ b/src/PSInfisicalAPI.Tests/CertificateReuseScopingTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Reflection; +using PSInfisicalAPI.Pki; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + /// + /// 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. + /// + 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); + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs index 48320c1..ca332b8 100644 --- a/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs @@ -307,17 +307,39 @@ namespace PSInfisicalAPI.Cmdlets return System.Net.IPAddress.TryParse(value, out parsed); } + /// + /// 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. + /// private X509Certificate2 TryFindExisting(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string commonName, StoreLocation storeLocation) { List candidateSerials = new List(); + 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); } + /// + /// 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. + /// + 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"))