Install issuers before the leaf, verify the chain, and name the leaf by hostname
Publish to PowerShell Gallery / build (pull_request) Successful in 26s
Publish to PowerShell Gallery / release (pull_request) Successful in 9s
Publish to PowerShell Gallery / publish (pull_request) Failing after 8s

Chain members are now installed before the leaf, so the certificate is
chainable the moment it appears in the store rather than momentarily orphaned.

After -InstallChain the chain is validated against the machine's own stores.
An incomplete result is reported as a warning naming the certificate whose
issuer is missing, which is the exact condition Windows surfaces as "The issuer
of this certificate could not be found" - previously that was only discoverable
in certmgr after the fact.

Chain routing is unchanged and already handles arbitrary depth: 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. Only
the leaf honours -StoreName (default My). This is now stated in the docs,
because the split was not obvious.

The installed certificate's Windows friendly name defaults to the common name
in upper case, which is what operators look for in certmgr. -FriendlyName
overrides it and moves from the ByCa parameter set to all of them; the CA path
still forwards the same value to Infisical as the issued certificate's
friendlyName.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 19:06:13 -04:00
parent f65124fd99
commit 883322cadf
9 changed files with 247 additions and 7 deletions
+11 -1
View File
@@ -6,18 +6,28 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
## Unreleased
## 2026.07.30.2305
- Build produced from commit f65124fd9911.
## Unreleased (carried forward)
## 2026.07.30.2259
- Build produced from commit f56fd15b3864.
## Unreleased (carried forward)
## Unreleased (carried forward)
### 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.
- Chain members are installed **before** the leaf, so the certificate is chainable the moment it lands in the store.
### Added (certificate installation)
- After `-InstallChain`, the chain is validated against the machine's stores. An incomplete chain is reported as a warning naming the certificate whose issuer is missing — the condition Windows surfaces 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 now accepted on every parameter set (previously `-CertificateAuthorityId` only, where it is still forwarded to Infisical as the issued certificate's `friendlyName`).
- `-StoreLocation` defaults to the process elevation when not supplied: `LocalMachine` when elevated, `CurrentUser` otherwise. Applies to `Request-InfisicalCertificate` and `Install-InfisicalCertificate`, and the resolved value is reported on the verbose stream. Explicitly passing `-StoreLocation` still wins.
- When the resolved location is `LocalMachine` and `-KeyStorageFlags` was not supplied, the private key is written to the machine key store, so an installed certificate has a usable key outside the calling user's profile.
- Reuse detection searches the same store location the install will write to, instead of always searching `CurrentUser`.
+2 -2
View File
@@ -1,6 +1,6 @@
@{
RootModule = 'PSInfisicalAPI.psm1'
ModuleVersion = '2026.07.30.2259'
ModuleVersion = '2026.07.30.2305'
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 = 'f56fd15b3864'
CommitHash = 'f65124fd9911'
}
}
}
Binary file not shown.
@@ -1287,6 +1287,8 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
<maml:para>-CommonName takes the bare value ('web01.contoso.com'), not an RDN; a leading 'CN=' is stripped because the CSR builder adds the prefix itself. -DnsName accepts the mixed output of Get-InfisicalSANList: IP literals in that list are emitted as iPAddress SAN entries rather than dNSName entries.</maml:para>
<maml:para>When -StoreLocation is not supplied it is chosen from the process elevation: an elevated session installs to LocalMachine, otherwise CurrentUser. The choice is reported on the verbose stream. Chain members are routed by type regardless of location - self-signed certificates to the Root store, others to CertificateAuthority. When the resolved location is LocalMachine and -KeyStorageFlags was not supplied, the private key is placed in the machine key store so the installed certificate has a usable key outside the calling user's profile.</maml:para>
<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:alert>
</maml:alertSet>
<command:examples>
@@ -1287,6 +1287,8 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
<maml:para>-CommonName takes the bare value ('web01.contoso.com'), not an RDN; a leading 'CN=' is stripped because the CSR builder adds the prefix itself. -DnsName accepts the mixed output of Get-InfisicalSANList: IP literals in that list are emitted as iPAddress SAN entries rather than dNSName entries.</maml:para>
<maml:para>When -StoreLocation is not supplied it is chosen from the process elevation: an elevated session installs to LocalMachine, otherwise CurrentUser. The choice is reported on the verbose stream. Chain members are routed by type regardless of location - self-signed certificates to the Root store, others to CertificateAuthority. When the resolved location is LocalMachine and -KeyStorageFlags was not supplied, the private key is placed in the machine key store so the installed certificate has a usable key outside the calling user's profile.</maml:para>
<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:alert>
</maml:alertSet>
<command:examples>
+17 -1
View File
@@ -291,7 +291,23 @@ When `-StoreLocation` is not supplied, the cmdlet picks it from the process's el
| Elevated | `LocalMachine\My` | `LocalMachine\CertificateAuthority` | `LocalMachine\Root` |
| Not elevated | `CurrentUser\My` | `CurrentUser\CertificateAuthority` | `CurrentUser\Root` |
Chain members are routed by what they are, not by a single flag: a self-signed certificate goes to the trusted-root store, anything else to the intermediate store. Pass `-StoreLocation` explicitly to override the elevation default, and `-StoreName` to redirect the leaf.
The routing split is deliberate:
- **The leaf** honours `-StoreName` (default `My`) and lands in the resolved location.
- **Chain members** ignore `-StoreName` and 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. A chain of any depth is walked, so an issuing subordinate installs alongside every CA above it up to the root.
`-StoreLocation` still applies to all of them, so an elevated run puts the whole chain machine-wide and a non-elevated run puts it all under the user.
Issuers are installed **before** the leaf, so the certificate is chainable the moment it appears in the store. Afterwards the chain is validated against the machine's own stores, and an incomplete result is called out by name rather than left to be discovered in `certmgr`:
```text
WARNING: [...] Certificate chain is incomplete (PartialChain). The highest certificate installed is
'CN=Contoso Intermediate Certificate Authority, OU=IT, O=Contoso, C=US', whose issuer
'CN=Contoso Root Certificate Authority, OU=IT, O=Contoso, C=US' is not present in the trusted stores.
Windows will report "The issuer of this certificate could not be found" until that issuer is installed.
```
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`.
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.
@@ -14,14 +14,120 @@ namespace PSInfisicalAPI.Tests
private sealed class CapturingLogger : IInfisicalLogger
{
public List<string> Warnings { get; } = new List<string>();
public List<string> Information_ { get; } = new List<string>();
public void Information(string component, string message) { }
public void Information(string component, string message) { Information_.Add(message); }
public void Verbose(string component, string message) { }
public void Debug(string component, string message) { }
public void Warning(string component, string message) { Warnings.Add(message); }
public void Error(string component, string message) { }
}
private static X509Certificate2 CreateRoot(string name, out System.Security.Cryptography.RSA key)
{
key = System.Security.Cryptography.RSA.Create(2048);
CertificateRequest request = new CertificateRequest(
string.Concat("CN=", name), key,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, true, 1, true));
return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(10));
}
[Fact]
public void ApplyFriendlyName_Sets_The_Windows_Friendly_Name()
{
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo apply = helper.GetMethod("ApplyFriendlyName", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(apply);
System.Security.Cryptography.RSA key;
using (X509Certificate2 cert = CreateRoot("FriendlyName.Probe", out key))
using (key)
{
apply.Invoke(null, new object[] { cert, "WEB01", NullInfisicalLogger.Instance, "Test" });
Assert.Equal("WEB01", cert.FriendlyName);
// A null or empty name must not clear a previously set value.
apply.Invoke(null, new object[] { cert, null, NullInfisicalLogger.Instance, "Test" });
apply.Invoke(null, new object[] { cert, string.Empty, NullInfisicalLogger.Instance, "Test" });
Assert.Equal("WEB01", cert.FriendlyName);
}
}
[Fact]
public void ApplyFriendlyName_Tolerates_Null_Certificate_And_Logger()
{
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo apply = helper.GetMethod("ApplyFriendlyName", BindingFlags.Public | BindingFlags.Static);
apply.Invoke(null, new object[] { null, "WEB01", null, "Test" });
}
[Fact]
public void VerifyInstalledChain_Names_The_Missing_Issuer_On_A_Partial_Chain()
{
// An intermediate whose root is not in any trusted store is exactly the state that surfaces in
// Windows as "The issuer of this certificate could not be found".
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo verify = helper.GetMethod("VerifyInstalledChain", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(verify);
System.Security.Cryptography.RSA rootKey;
using (X509Certificate2 root = CreateRoot("VerifyChain.Root", out rootKey))
using (rootKey)
using (System.Security.Cryptography.RSA interKey = System.Security.Cryptography.RSA.Create(2048))
{
CertificateRequest interRequest = new CertificateRequest(
"CN=VerifyChain.Intermediate", interKey,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
interRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
using (X509Certificate2 intermediate = interRequest.Create(root, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(9), new byte[] { 9, 8, 7, 6, 5, 4, 3, 2 }))
{
CapturingLogger logger = new CapturingLogger();
verify.Invoke(null, new object[] { intermediate, logger, "Test" });
string warning = Assert.Single(logger.Warnings);
Assert.Contains("issuer", warning, StringComparison.OrdinalIgnoreCase);
Assert.Contains("VerifyChain.Root", warning);
}
}
}
[Fact]
public void VerifyInstalledChain_Tolerates_Null_Inputs()
{
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo verify = helper.GetMethod("VerifyInstalledChain", BindingFlags.Public | BindingFlags.Static);
verify.Invoke(null, new object[] { null, new CapturingLogger(), "Test" });
System.Security.Cryptography.RSA key;
using (X509Certificate2 cert = CreateRoot("VerifyChain.NullLogger", out key))
using (key)
{
verify.Invoke(null, new object[] { cert, null, "Test" });
}
}
[Fact]
public void FriendlyName_Is_Available_On_Every_Issuance_Parameter_Set()
{
// The Windows friendly name applies regardless of how the certificate was issued.
Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet", true);
PropertyInfo friendlyName = cmdletType.GetProperty("FriendlyName");
Assert.NotNull(friendlyName);
foreach (CustomAttributeData attribute in friendlyName.GetCustomAttributesData())
{
if (attribute.AttributeType != typeof(System.Management.Automation.ParameterAttribute)) { continue; }
foreach (CustomAttributeNamedArgument named in attribute.NamedArguments)
{
Assert.NotEqual("ParameterSetName", named.MemberName);
}
}
}
private static void InvokeTrustPromptWarning(StoreName storeName, StoreLocation storeLocation, IInfisicalLogger logger)
{
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
@@ -49,7 +49,9 @@ namespace PSInfisicalAPI.Cmdlets
[Parameter(ParameterSetName = "ByProfile")] public string NotBefore { get; set; }
[Parameter(ParameterSetName = "ByCa")]
[Parameter(ParameterSetName = "ByProfile")] public string NotAfter { get; set; }
[Parameter(ParameterSetName = "ByCa")] public string FriendlyName { get; set; }
// 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; }
[Parameter(ParameterSetName = "ByCa")] public string PkiCollectionId { get; set; }
[Parameter(ParameterSetName = "ByCa")]
[Parameter(ParameterSetName = "ByProfile")] public string[] KeyUsage { get; set; }
@@ -140,13 +142,22 @@ namespace PSInfisicalAPI.Cmdlets
X509KeyStorageFlags resolvedFlags = ResolveEffectiveKeyStorageFlags(resolvedStoreLocation);
X509Certificate2 cert = PemCertificateBuilder.Build(signed.CertificatePem, signed.PrivateKeyPem, signed.CertificateChainPem, resolvedFlags);
InfisicalCertificateRequestHelpers.ApplyFriendlyName(cert, ResolveLocalFriendlyName(csrSubject), Logger, Component);
if (Install.IsPresent)
{
InfisicalCertificateRequestHelpers.InstallToStore(cert, StoreName, resolvedStoreLocation, Force.IsPresent, Logger, Component);
// Issuers first, so the leaf is already chainable the moment it lands in the store.
if (InstallChain.IsPresent)
{
InfisicalCertificateRequestHelpers.InstallChain(signed, resolvedStoreLocation, Force.IsPresent, Logger, Component);
}
InfisicalCertificateRequestHelpers.InstallToStore(cert, StoreName, resolvedStoreLocation, Force.IsPresent, Logger, Component);
if (InstallChain.IsPresent)
{
InfisicalCertificateRequestHelpers.VerifyInstalledChain(cert, Logger, Component);
}
}
InfisicalCertificateResult resultObj = InfisicalCertificateRequestHelpers.BuildResult(cert, signed);
@@ -172,6 +183,17 @@ namespace PSInfisicalAPI.Cmdlets
}
}
/// <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.
/// </summary>
private string ResolveLocalFriendlyName(InfisicalCsrSubject subject)
{
if (!string.IsNullOrEmpty(FriendlyName)) { return FriendlyName; }
if (subject == null || string.IsNullOrEmpty(subject.CommonName)) { return null; }
return subject.CommonName.ToUpperInvariant();
}
/// <summary>
/// States which issuer will sign this request, and rejects an unusable one before a keypair is generated.
/// Direct CA signing is only permitted when the CA has direct issuance enabled; without this check the
@@ -141,11 +141,93 @@ namespace PSInfisicalAPI.Pki
}
}
/// <summary>
/// Routes a chain member by what it is rather than by any caller preference: a self-signed certificate is
/// a root and belongs in the trusted-root store, anything else is a subordinate CA and belongs in the
/// intermediate store. Only the leaf honours -StoreName.
/// </summary>
public static StoreName GetChainCertificateTargetStore(X509Certificate2 cert)
{
return IsSelfSigned(cert) ? StoreName.Root : StoreName.CertificateAuthority;
}
/// <summary>
/// Sets the Windows friendly name shown in certmgr. Not available on every platform, and never important
/// enough to fail an otherwise successful issuance.
/// </summary>
public static void ApplyFriendlyName(X509Certificate2 cert, string friendlyName, IInfisicalLogger logger, string component)
{
if (cert == null || string.IsNullOrEmpty(friendlyName)) { return; }
try
{
cert.FriendlyName = friendlyName;
if (logger != null) { logger.Verbose(component, string.Concat("Set certificate friendly name to '", friendlyName, "'.")); }
}
catch (Exception exception)
{
if (logger != null) { logger.Verbose(component, string.Concat("Could not set the certificate friendly name (continuing): ", exception.Message)); }
}
}
/// <summary>
/// Confirms the freshly installed certificate actually chains to a trusted root using the machine's own
/// stores. A chain that stops short is what surfaces in Windows as "The issuer of this certificate could
/// not be found", so name the missing issuer rather than letting it be discovered later.
/// </summary>
public static void VerifyInstalledChain(X509Certificate2 leaf, IInfisicalLogger logger, string component)
{
if (leaf == null || logger == null) { return; }
try
{
using (X509Chain chain = new X509Chain())
{
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
if (chain.Build(leaf))
{
logger.Information(component, string.Concat("Certificate chain verified to a trusted root (", chain.ChainElements.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " elements)."));
return;
}
bool partialChain = false;
List<string> problems = new List<string>();
foreach (X509ChainStatus status in chain.ChainStatus)
{
if (status.Status == X509ChainStatusFlags.NoError) { continue; }
if (status.Status == X509ChainStatusFlags.PartialChain || status.Status == X509ChainStatusFlags.UntrustedRoot) { partialChain = true; }
problems.Add(status.Status.ToString());
}
string topSubject = leaf.Subject;
string missingIssuer = leaf.Issuer;
if (chain.ChainElements.Count > 0)
{
X509Certificate2 top = chain.ChainElements[chain.ChainElements.Count - 1].Certificate;
topSubject = top.Subject;
missingIssuer = top.Issuer;
}
string detail = problems.Count > 0 ? string.Join(", ", problems.ToArray()) : "unknown";
if (partialChain)
{
logger.Warning(component, string.Concat(
"Certificate chain is incomplete (", detail, "). The highest certificate installed is '", topSubject,
"', whose issuer '", missingIssuer, "' is not present in the trusted stores. Windows will report ",
"\"The issuer of this certificate could not be found\" until that issuer is installed."));
}
else
{
logger.Warning(component, string.Concat("Certificate chain did not validate (", detail, ")."));
}
}
}
catch (Exception exception)
{
logger.Verbose(component, string.Concat("Chain verification could not run (continuing): ", exception.Message));
}
}
public static X509KeyStorageFlags ResolveKeyStorageFlags(InfisicalPrivateKeyProtection protection, bool persistKey, bool machineKey)
{
X509KeyStorageFlags flags = X509KeyStorageFlags.DefaultKeySet;