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
@@ -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;