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:
2026-07-30 19:00:06 -04:00
parent f56fd15b38
commit f65124fd99
12 changed files with 357 additions and 33 deletions
+19 -2
View File
@@ -6,17 +6,34 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
## Unreleased
## 2026.07.30.2259
- Build produced from commit f56fd15b3864.
## 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.
### Added (certificate installation)
- `-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`.
- Elevation detection moved to `InfisicalCmdletBase` and is shared with `Write-InfisicalScepMdmProfileToWmi`.
## 2026.07.30.2239
- Build produced from commit f62b3e90b1b1.
## Unreleased (carried forward)
## Unreleased (carried forward)
## 2026.07.30.2151
- Build produced from commit 14c8c4f3845b.
## Unreleased (carried forward)
## Unreleased (carried forward)
### Breaking
+2 -2
View File
@@ -1,6 +1,6 @@
@{
RootModule = 'PSInfisicalAPI.psm1'
ModuleVersion = '2026.07.30.2239'
ModuleVersion = '2026.07.30.2259'
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 = 'f62b3e90b1b1'
CommitHash = 'f56fd15b3864'
}
}
}
Binary file not shown.
@@ -1285,6 +1285,8 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
<maml:para>-CertificateAuthorityId only works against a CA that permits direct issuance (Get-InfisicalCertificateAuthority reports this as EnableDirectIssuance). The cmdlet resolves the issuer and validates this before generating a keypair, naming the subscriber, CA, or profile it will use on the verbose stream and in the -WhatIf target. Profile issuance is the only path that ignores that flag, so -CertificateProfileId works against a CA whose EnableDirectIssuance is False. Note that enableDirectIssuance appears in no Infisical create or update schema, so it cannot be changed through the API or UI after the CA exists.</maml:para>
<maml:para>There is no -CertificateTemplateId parameter because Infisical's REST API exposes no template-based issuance route; when the API asks for 'a certificate template or subscriber', supply -CertificateProfileId or -PkiSubscriberSlug, or use a CA that allows direct issuance.</maml:para>
<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:alert>
</maml:alertSet>
<command:examples>
@@ -1285,6 +1285,8 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
<maml:para>-CertificateAuthorityId only works against a CA that permits direct issuance (Get-InfisicalCertificateAuthority reports this as EnableDirectIssuance). The cmdlet resolves the issuer and validates this before generating a keypair, naming the subscriber, CA, or profile it will use on the verbose stream and in the -WhatIf target. Profile issuance is the only path that ignores that flag, so -CertificateProfileId works against a CA whose EnableDirectIssuance is False. Note that enableDirectIssuance appears in no Infisical create or update schema, so it cannot be changed through the API or UI after the CA exists.</maml:para>
<maml:para>There is no -CertificateTemplateId parameter because Infisical's REST API exposes no template-based issuance route; when the API asks for 'a certificate template or subscriber', supply -CertificateProfileId or -PkiSubscriberSlug, or use a CA that allows direct issuance.</maml:para>
<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:alert>
</maml:alertSet>
<command:examples>
+119 -4
View File
@@ -161,17 +161,42 @@ $ConnectInfisicalParameters = New-Object -TypeName 'System.Collections.Specializ
$Connection = Connect-Infisical @ConnectInfisicalParameters
$Project = Get-InfisicalProject -Type cert-manager | Where-Object {($_.Name -eq 'Platform')} | Select-Object -First 1
$Profile = Get-InfisicalCertificateProfile -ProjectId ($Project.Id) | Select-Object -First 1
$Project = Get-InfisicalProject -Type cert-manager | Select-Object -First 1
$Project
#region Certificate authorities. Not required for profile issuance - the profile already binds its CA - but
# useful for confirming the chain you expect to be installed.
$CAList = Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal
$RootCA = $CAList | Where-Object {([String]::IsNullOrEmpty($_.ParentCaId) -eq $True)} | Select-Object -First 1
$RootCA
$IntermediateCA = $CAList | Where-Object {([String]::IsNullOrEmpty($_.ParentCaId) -eq $False)}
$IntermediateCA
#endregion
$CertificateProfile = Get-InfisicalCertificateProfile -ProjectId ($Project.Id) -IncludeConfigs |
Where-Object {($_.EnrollmentType -iin @('API')) -and ($_.Slug -imatch '.*Server.*')} |
Select-Object -First 1
$CertificateProfile
$SanList = Get-InfisicalSANList
$SanList
$RequestInfisicalCertificateParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
$RequestInfisicalCertificateParameters.ProjectId = $Project.Id
$RequestInfisicalCertificateParameters.CertificateProfileId = $Profile.Id
$RequestInfisicalCertificateParameters.CertificateProfileId = $CertificateProfile.Id
$RequestInfisicalCertificateParameters.CommonName = $Env:ComputerName.ToUpper()
$RequestInfisicalCertificateParameters.DnsName = New-Object -TypeName 'System.Collections.Generic.List[System.String]'
$RequestInfisicalCertificateParameters.DnsName.AddRange($SanList)
$RequestInfisicalCertificateParameters.DnsName.Add('myrecord.mydomain.com')
$RequestInfisicalCertificateParameters.DnsName.Add('app.contoso.com')
$RequestInfisicalCertificateParameters.DnsName.Add('api.contoso.com')
$RequestInfisicalCertificateParameters.DnsName.Add('boot.contoso.com')
$RequestInfisicalCertificateParameters.Ttl = '90d'
$RequestInfisicalCertificateParameters.Install = $True
$RequestInfisicalCertificateParameters.InstallChain = $True
@@ -182,6 +207,96 @@ $Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParamete
$Null = Disconnect-Infisical -Verbose
```
Note `$CertificateProfile` rather than `$Profile`: `$Profile` is an automatic variable in PowerShell (the path to the current profile script), and assigning to it works but shadows something the host relies on.
`-StoreName`/`-StoreLocation` are omitted deliberately — see [Where certificates get installed](#where-certificates-get-installed).
### Example output
```text
Id : 00000000-0000-0000-0000-000000000000
Name : Microsoft Endpoint Configuration Manager
Slug : mecm
Description :
OrganizationId : 11111111-1111-1111-1111-111111111111
Type : cert-manager
AutoCapitalization : False
EnvironmentSlugs : {dev, staging, prod}
CreatedAtUtc : 3/12/2026 8:32:52 PM +00:00
UpdatedAtUtc : 6/21/2026 7:00:28 PM +00:00
Name : root-ca
CommonName : Contoso Root Certificate Authority
Type : internal
Status : active
KeyAlgorithm : RSA_2048
NotAfter : 03/25/2036 00:00:00
Id : 22222222-2222-2222-2222-222222222222
Name : intermediate-ca
CommonName : Contoso Intermediate Certificate Authority
Type : internal
Status : active
KeyAlgorithm : RSA_2048
NotAfter : 03/25/2031 00:00:00
Id : 33333333-3333-3333-3333-333333333333
Id : 44444444-4444-4444-4444-444444444444
ProjectId : 00000000-0000-0000-0000-000000000000
CaId : 33333333-3333-3333-3333-333333333333
CertificatePolicyId : 55555555-5555-5555-5555-555555555555
Slug : serverauthentication
Description :
EnrollmentType : api
IssuerType : ca
EstConfigId :
ApiConfigId : 66666666-6666-6666-6666-666666666666
AcmeConfigId :
ScepConfigId :
CreatedAtUtc : 3/25/2026 4:53:53 PM +00:00
UpdatedAtUtc : 3/25/2026 4:53:53 PM +00:00
Defaults : PSInfisicalAPI.Models.InfisicalCertificateProfileDefaults
CertificateAuthority : PSInfisicalAPI.Models.InfisicalCertificateAuthoritySummary
CertificatePolicy :
ApiConfig : PSInfisicalAPI.Models.InfisicalCertificateProfileApiConfig
WEB01
10.20.30.40
WEB01.contoso.com
127.0.0.1
::1
VERBOSE: [...] - [Information] - [PkiClient] - Attempting to search Infisical certificates. Please Wait...
VERBOSE: [...] - [Verbose] - [HttpClient] - Attempting HTTP POST to https://infisical.contoso.com/api/v1/projects/00000000-0000-0000-0000-000000000000/certificates/search. Please Wait...
VERBOSE: [...] - [Verbose] - [HttpClient] - HTTP POST completed with status 200.
VERBOSE: [...] - [Information] - [PkiClient] - Infisical certificate search was successful.
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Process is elevated; defaulting -StoreLocation to LocalMachine. Pass -StoreLocation explicitly to override.
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing via certificate profile '44444444-4444-4444-4444-444444444444' in project '00000000-0000-0000-0000-000000000000'.
VERBOSE: Performing the operation "Request new certificate" on target "certificate profile '44444444-4444-4444-4444-444444444444' for CN=WEB01".
VERBOSE: [...] - [Information] - [PkiClient] - Attempting to issue certificate via profile '44444444-4444-4444-4444-444444444444'. Please Wait...
VERBOSE: [...] - [Verbose] - [HttpClient] - Attempting HTTP POST to https://infisical.contoso.com/api/v1/cert-manager/certificates. Please Wait...
VERBOSE: [...] - [Verbose] - [HttpClient] - HTTP POST completed with status 200.
VERBOSE: [...] - [Information] - [PkiClient] - Infisical certificate issuance (profile) was successful.
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Installed certificate to LocalMachine\My [F480A920DFB41EA8EE3E9178C1BC6A5EC7055B96].
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Installed certificate to LocalMachine\CertificateAuthority [89A486A532D94EFE4391BEF2EA7F5E7E2B654AB0].
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Installed certificate to LocalMachine\Root [1F3A77B0C2D45E6819AB3C7D0E5F2A9B4C81D6E7].
```
### Where certificates get installed
When `-StoreLocation` is not supplied, the cmdlet picks it from the process's elevation, and says which it chose on the verbose stream:
| Session | Leaf | Intermediates | Roots |
| ------------- | -------------------------- | -------------------------------------------- | ---------------------------- |
| 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.
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.
### Choosing an issuance path
`Request-InfisicalCertificate` has three mutually exclusive issuance parameter sets. The deciding question is **whether the common name varies per request**:
@@ -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);