Fix chain-install hang and pick the certificate store by process elevation
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 (scheduled task, MECM task sequence) the cmdlet appeared to stop right after installing the intermediate, with no indication why. A warning is now emitted before the blocking call. -StoreLocation now defaults to the process elevation when the caller does not supply it: LocalMachine when elevated, CurrentUser otherwise. This is what most callers want, and it sidesteps the trust prompt entirely because writing LocalMachine\Root already required elevation. Applied to both Request-InfisicalCertificate and Install-InfisicalCertificate; the resolved value is reported on the verbose stream and an explicit -StoreLocation wins. Chain routing is unchanged and already correct: self-signed certificates go to the Root store and everything else to CertificateAuthority, within whichever location was resolved. When the resolved location is LocalMachine and -KeyStorageFlags was not supplied, the private key is written to the machine key store. Without this 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. Reuse detection now searches the store location the install will write to rather than always searching CurrentUser, so -AllowRenewal and the existing certificate short-circuit behave consistently with where certificates land. Elevation detection moved to InfisicalCmdletBase (evaluated through the engine, since the module targets netstandard2.0 and carries no System.Security.Principal.Windows reference) and is shared with Write-InfisicalScepMdmProfileToWmi, which loses its private copy. README gains the fuller worked example, a genericized output transcript, and a "Where certificates get installed" section; cmdlet help updated to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using PSInfisicalAPI.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace PSInfisicalAPI.Tests
|
||||
{
|
||||
public class CertificateStoreTargetingTests
|
||||
{
|
||||
private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly;
|
||||
|
||||
private sealed class CapturingLogger : IInfisicalLogger
|
||||
{
|
||||
public List<string> Warnings { get; } = new List<string>();
|
||||
|
||||
public void Information(string component, string 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 void InvokeTrustPromptWarning(StoreName storeName, StoreLocation storeLocation, IInfisicalLogger logger)
|
||||
{
|
||||
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
|
||||
MethodInfo method = helper.GetMethod("WarnIfInteractiveTrustPromptExpected", BindingFlags.Public | BindingFlags.Static);
|
||||
Assert.NotNull(method);
|
||||
method.Invoke(null, new object[] { storeName, storeLocation, logger, "TestComponent" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentUser_Root_Install_Warns_About_The_Blocking_Trust_Dialog()
|
||||
{
|
||||
// X509Store.Add on CurrentUser\Root raises a modal Windows trust dialog and blocks until answered.
|
||||
// Without this warning the caller sees an unexplained hang.
|
||||
CapturingLogger logger = new CapturingLogger();
|
||||
InvokeTrustPromptWarning(StoreName.Root, StoreLocation.CurrentUser, logger);
|
||||
|
||||
string warning = Assert.Single(logger.Warnings);
|
||||
Assert.Contains("CurrentUser\\Root", warning);
|
||||
Assert.Contains("security confirmation", warning);
|
||||
Assert.Contains("LocalMachine", warning);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StoreName.Root, StoreLocation.LocalMachine)]
|
||||
[InlineData(StoreName.My, StoreLocation.CurrentUser)]
|
||||
[InlineData(StoreName.My, StoreLocation.LocalMachine)]
|
||||
[InlineData(StoreName.CertificateAuthority, StoreLocation.CurrentUser)]
|
||||
[InlineData(StoreName.CertificateAuthority, StoreLocation.LocalMachine)]
|
||||
public void Non_Prompting_Store_Targets_Stay_Silent(StoreName storeName, StoreLocation storeLocation)
|
||||
{
|
||||
CapturingLogger logger = new CapturingLogger();
|
||||
InvokeTrustPromptWarning(storeName, storeLocation, logger);
|
||||
Assert.Empty(logger.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarnIfInteractiveTrustPromptExpected_Tolerates_A_Null_Logger()
|
||||
{
|
||||
InvokeTrustPromptWarning(StoreName.Root, StoreLocation.CurrentUser, null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Base_Cmdlet_Exposes_Elevation_Aware_Store_Resolution()
|
||||
{
|
||||
Type baseType = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase);
|
||||
|
||||
MethodInfo resolve = baseType.GetMethod("ResolveStoreLocation", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(resolve);
|
||||
Assert.Equal(typeof(StoreLocation), resolve.ReturnType);
|
||||
|
||||
MethodInfo elevated = baseType.GetMethod("IsElevated", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(elevated);
|
||||
Assert.Equal(typeof(bool), elevated.ReturnType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chain_Certificates_Route_Root_And_Intermediate_To_Their_Own_Stores()
|
||||
{
|
||||
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
|
||||
MethodInfo classify = helper.GetMethod("GetChainCertificateTargetStore", BindingFlags.Public | BindingFlags.Static);
|
||||
|
||||
using (System.Security.Cryptography.RSA rootRsa = System.Security.Cryptography.RSA.Create(2048))
|
||||
using (System.Security.Cryptography.RSA leafRsa = System.Security.Cryptography.RSA.Create(2048))
|
||||
{
|
||||
DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddMinutes(-5);
|
||||
DateTimeOffset notAfter = DateTimeOffset.UtcNow.AddDays(1);
|
||||
|
||||
CertificateRequest rootRequest = new CertificateRequest(
|
||||
"CN=StoreTargeting.Root", rootRsa,
|
||||
System.Security.Cryptography.HashAlgorithmName.SHA256,
|
||||
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
|
||||
rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
|
||||
using (X509Certificate2 root = rootRequest.CreateSelfSigned(notBefore, notAfter))
|
||||
{
|
||||
CertificateRequest interRequest = new CertificateRequest(
|
||||
"CN=StoreTargeting.Intermediate", leafRsa,
|
||||
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, notBefore, notAfter, new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }))
|
||||
{
|
||||
Assert.Equal(StoreName.Root, (StoreName)classify.Invoke(null, new object[] { root }));
|
||||
Assert.Equal(StoreName.CertificateAuthority, (StoreName)classify.Invoke(null, new object[] { intermediate }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Management.Automation;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using PSInfisicalAPI.Connections;
|
||||
using PSInfisicalAPI.Errors;
|
||||
using PSInfisicalAPI.Http;
|
||||
@@ -12,6 +15,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
{
|
||||
private IInfisicalLogger _logger;
|
||||
private IInfisicalHttpClient _httpClient;
|
||||
private bool? _isElevated;
|
||||
|
||||
protected IInfisicalLogger Logger
|
||||
{
|
||||
@@ -45,6 +49,56 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
return current != null && current.SkipCertificateCheck;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports whether the host process is running elevated. Evaluated through the PowerShell engine rather
|
||||
/// than WindowsIdentity directly, because the module targets netstandard2.0 and does not carry a
|
||||
/// System.Security.Principal.Windows reference. Cached for the lifetime of the cmdlet instance.
|
||||
/// </summary>
|
||||
protected bool IsElevated()
|
||||
{
|
||||
if (_isElevated.HasValue) { return _isElevated.Value; }
|
||||
|
||||
try
|
||||
{
|
||||
Collection<PSObject> results = InvokeCommand.InvokeScript(
|
||||
"[bool]([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)");
|
||||
_isElevated = results != null
|
||||
&& results.Count > 0
|
||||
&& results[0] != null
|
||||
&& results[0].BaseObject != null
|
||||
&& Convert.ToBoolean(results[0].BaseObject, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Logger.Verbose(GetType().Name, string.Concat("Elevation check failed; assuming non-elevated. ", exception.Message));
|
||||
_isElevated = false;
|
||||
}
|
||||
|
||||
return _isElevated.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the certificate store location when the caller did not bind -StoreLocation. An elevated process
|
||||
/// installs machine-wide so services and other users can use the certificate; a non-elevated one has no
|
||||
/// write access to LocalMachine and falls back to the user's own stores.
|
||||
/// </summary>
|
||||
protected StoreLocation ResolveStoreLocation(StoreLocation boundValue)
|
||||
{
|
||||
if (MyInvocation != null && MyInvocation.BoundParameters.ContainsKey("StoreLocation"))
|
||||
{
|
||||
return boundValue;
|
||||
}
|
||||
|
||||
bool elevated = IsElevated();
|
||||
StoreLocation resolved = elevated ? StoreLocation.LocalMachine : StoreLocation.CurrentUser;
|
||||
Logger.Information(GetType().Name, string.Concat(
|
||||
"Process is ", elevated ? "elevated" : "not elevated",
|
||||
"; defaulting -StoreLocation to ", resolved.ToString(),
|
||||
". Pass -StoreLocation explicitly to override."));
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports an operation failure as a non-terminating error, which is what lets -ErrorAction decide the
|
||||
/// outcome: Continue prints and carries on, SilentlyContinue and Ignore suppress, Inquire prompts, and
|
||||
|
||||
@@ -32,20 +32,22 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
{
|
||||
try
|
||||
{
|
||||
StoreLocation resolvedStoreLocation = ResolveStoreLocation(StoreLocation);
|
||||
|
||||
X509Certificate2 cert = ResolveCertificate();
|
||||
if (cert == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InstallCertificate(cert, StoreName, StoreLocation);
|
||||
InstallCertificate(cert, StoreName, resolvedStoreLocation);
|
||||
|
||||
if (IncludeChain.IsPresent && string.Equals(ParameterSetName, "FromCertificate", StringComparison.Ordinal) == false)
|
||||
{
|
||||
foreach (X509Certificate2 chainCert in ResolveChain())
|
||||
{
|
||||
StoreName chainStore = InfisicalCertificateRequestHelpers.GetChainCertificateTargetStore(chainCert);
|
||||
InstallCertificate(chainCert, chainStore, StoreLocation);
|
||||
InstallCertificate(chainCert, chainStore, resolvedStoreLocation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +90,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
return;
|
||||
}
|
||||
|
||||
InfisicalCertificateRequestHelpers.WarnIfInteractiveTrustPromptExpected(storeName, storeLocation, Logger, "InstallInfisicalCertificateCmdlet");
|
||||
store.Add(cert);
|
||||
Logger.Information("InstallInfisicalCertificateCmdlet", string.Concat("Installed certificate to ", target, "."));
|
||||
}
|
||||
|
||||
@@ -79,13 +79,16 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
|
||||
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
|
||||
|
||||
// Resolved once so reuse detection looks in the same stores the install will write to.
|
||||
StoreLocation resolvedStoreLocation = ResolveStoreLocation(StoreLocation);
|
||||
|
||||
InfisicalCsrSubject csrSubject = InfisicalCertificateRequestHelpers.MergeSubject(Subject, CommonName, Country, State, Locality, Organization, OrganizationalUnit, EmailAddress);
|
||||
List<string> ipAddresses = new List<string>();
|
||||
List<string> dnsNames = BuildDnsNames(csrSubject, ipAddresses);
|
||||
if (string.IsNullOrEmpty(csrSubject.CommonName) && dnsNames.Count > 0) { csrSubject.CommonName = dnsNames[0]; }
|
||||
if (string.IsNullOrEmpty(csrSubject.CommonName)) { throw new InvalidOperationException("Subject CommonName could not be determined and no DnsName was provided."); }
|
||||
|
||||
X509Certificate2 existing = TryFindExisting(client, connection, ProjectId, csrSubject.CommonName);
|
||||
X509Certificate2 existing = TryFindExisting(client, connection, ProjectId, csrSubject.CommonName, resolvedStoreLocation);
|
||||
if (existing != null && !Force.IsPresent && !(AllowRenewal.IsPresent && InfisicalLocalCertificateLookup.IsRenewable(existing, RenewalThresholdDays)))
|
||||
{
|
||||
Logger.Information(Component, string.Concat("Reusing existing certificate (Thumbprint=", existing.Thumbprint, ", NotAfter=", existing.NotAfter.ToString("u"), ")."));
|
||||
@@ -134,15 +137,15 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
return;
|
||||
}
|
||||
|
||||
X509KeyStorageFlags resolvedFlags = ResolveEffectiveKeyStorageFlags();
|
||||
X509KeyStorageFlags resolvedFlags = ResolveEffectiveKeyStorageFlags(resolvedStoreLocation);
|
||||
X509Certificate2 cert = PemCertificateBuilder.Build(signed.CertificatePem, signed.PrivateKeyPem, signed.CertificateChainPem, resolvedFlags);
|
||||
|
||||
if (Install.IsPresent)
|
||||
{
|
||||
InfisicalCertificateRequestHelpers.InstallToStore(cert, StoreName, StoreLocation, Force.IsPresent, Logger, Component);
|
||||
InfisicalCertificateRequestHelpers.InstallToStore(cert, StoreName, resolvedStoreLocation, Force.IsPresent, Logger, Component);
|
||||
if (InstallChain.IsPresent)
|
||||
{
|
||||
InfisicalCertificateRequestHelpers.InstallChain(signed, StoreLocation, Force.IsPresent, Logger, Component);
|
||||
InfisicalCertificateRequestHelpers.InstallChain(signed, resolvedStoreLocation, Force.IsPresent, Logger, Component);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +285,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
return System.Net.IPAddress.TryParse(value, out parsed);
|
||||
}
|
||||
|
||||
private X509Certificate2 TryFindExisting(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string commonName)
|
||||
private X509Certificate2 TryFindExisting(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string commonName, StoreLocation storeLocation)
|
||||
{
|
||||
List<string> candidateSerials = new List<string>();
|
||||
try
|
||||
@@ -300,17 +303,26 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
Logger.Verbose(Component, string.Concat("Infisical search for idempotency check failed: ", searchException.Message));
|
||||
}
|
||||
|
||||
return InfisicalLocalCertificateLookup.FindMatch(StoreName, StoreLocation, commonName, candidateSerials);
|
||||
return InfisicalLocalCertificateLookup.FindMatch(StoreName, storeLocation, commonName, candidateSerials);
|
||||
}
|
||||
|
||||
private X509KeyStorageFlags ResolveEffectiveKeyStorageFlags()
|
||||
private X509KeyStorageFlags ResolveEffectiveKeyStorageFlags(StoreLocation storeLocation)
|
||||
{
|
||||
if (MyInvocation.BoundParameters.ContainsKey("KeyStorageFlags"))
|
||||
{
|
||||
return KeyStorageFlags;
|
||||
}
|
||||
|
||||
return InfisicalCertificateRequestHelpers.ResolveKeyStorageFlags(PrivateKeyProtection, PersistKey.IsPresent, MachineKey.IsPresent);
|
||||
// A certificate installed into LocalMachine needs its private key in the machine key store, otherwise
|
||||
// the key lands in the calling user's profile and the installed certificate has no usable key for
|
||||
// services or other users.
|
||||
bool machineKey = MachineKey.IsPresent || (Install.IsPresent && storeLocation == StoreLocation.LocalMachine);
|
||||
if (machineKey && !MachineKey.IsPresent)
|
||||
{
|
||||
Logger.Verbose(Component, "Installing to LocalMachine; using a machine key store so the private key is usable outside this user profile.");
|
||||
}
|
||||
|
||||
return InfisicalCertificateRequestHelpers.ResolveKeyStorageFlags(PrivateKeyProtection, PersistKey.IsPresent, machineKey);
|
||||
}
|
||||
|
||||
private InfisicalSignedCertificate SignCertificate(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string csrPem, InfisicalCsrSubject subject)
|
||||
|
||||
@@ -66,21 +66,6 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsElevated()
|
||||
{
|
||||
try
|
||||
{
|
||||
Collection<PSObject> results = InvokeCommand.InvokeScript("[bool]([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)");
|
||||
if (results == null || results.Count == 0 || results[0] == null || results[0].BaseObject == null) { return false; }
|
||||
return Convert.ToBoolean(results[0].BaseObject, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Verbose(Component, string.Concat("Elevation check failed; assuming non-elevated. ", ex.Message));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Collection<PSObject> InvokeNewCimInstance(string ns, string className, Hashtable properties)
|
||||
{
|
||||
Dictionary<string, object> variables = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
|
||||
|
||||
@@ -96,6 +96,7 @@ namespace PSInfisicalAPI.Pki
|
||||
store.RemoveRange(existing);
|
||||
}
|
||||
|
||||
WarnIfInteractiveTrustPromptExpected(storeName, storeLocation, logger, component);
|
||||
store.Add(cert);
|
||||
logger.Information(component, string.Concat("Installed certificate to ", target, "."));
|
||||
}
|
||||
@@ -105,6 +106,24 @@ namespace PSInfisicalAPI.Pki
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adding to the per-user Root store makes Windows raise a modal trust confirmation dialog, and
|
||||
/// X509Store.Add blocks until it is answered. When that dialog is hidden or the session is
|
||||
/// non-interactive the caller just sees an unexplained hang, so say so before blocking. LocalMachine\Root
|
||||
/// does not prompt, because writing it already required elevation.
|
||||
/// </summary>
|
||||
public static void WarnIfInteractiveTrustPromptExpected(StoreName storeName, StoreLocation storeLocation, IInfisicalLogger logger, string component)
|
||||
{
|
||||
if (storeName != StoreName.Root || storeLocation != StoreLocation.CurrentUser) { return; }
|
||||
if (logger == null) { return; }
|
||||
|
||||
logger.Warning(component, string.Concat(
|
||||
"Installing a root certificate into CurrentUser\\Root. Windows will display a security confirmation ",
|
||||
"dialog and this call cannot continue until it is answered - if no dialog is visible, check for it ",
|
||||
"behind the console window or on another desktop. Run elevated (or pass ",
|
||||
"-StoreLocation LocalMachine) to install machine-wide without a prompt."));
|
||||
}
|
||||
|
||||
public static void InstallChain(InfisicalSignedCertificate signed, StoreLocation storeLocation, bool force, IInfisicalLogger logger, string component)
|
||||
{
|
||||
List<X509Certificate2> chainCerts = CollectChainCertificates(signed);
|
||||
|
||||
Reference in New Issue
Block a user