diff --git a/.github/workflows/keyfactor-starter-workflow.yml b/.github/workflows/keyfactor-starter-workflow.yml index 8ec5771e..b0c525a6 100644 --- a/.github/workflows/keyfactor-starter-workflow.yml +++ b/.github/workflows/keyfactor-starter-workflow.yml @@ -11,7 +11,7 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@v4 + uses: keyfactor/actions/.github/workflows/starter.yml@v5 with: command_token_url: ${{ vars.COMMAND_TOKEN_URL }} # Only required for doctool generated screenshots command_hostname: ${{ vars.COMMAND_HOSTNAME }} # Only required for doctool generated screenshots diff --git a/.gitignore b/.gitignore index 4ce6fdde..9cad393c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +# Local test credentials (never commit) +local.runsettings + # User-specific files *.rsuser *.suo diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..60db609b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "PowerShell: Launch Script", + "type": "PowerShell", + "request": "launch", + "script": "${file}", + "args": [] + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ce49d57b..30a276f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,21 @@ +4.0.0 + +* As of this version of the extension, SANs will be handled through the ODKG Enrollment page in Command and will no longer use the SAN Entry Parameter. This version, we are removing all support for the SAN Entry Parameter. If you are still using the SAN Entry Parameter, you will need to remove it from your store types and re-run inventory to remove it from your database. +* Adding JEA Support for local PowerShell execution. This will allow for more secure execution of the extension when running in a local PowerShell Runspace. To utilize this feature, you will need to create a JEA endpoint on the target server and specify the endpoint name as a new parameter in the specific Cert Store definition. Refer to the README for more details. +* .NET6 assemblies are no longer supported. +* Fixed a problem when passing a bad CSP, the job was reporting successful, but did not actually add/bind the certificate. +* Enhanced Crypto Service Provider (CSP) discovery and validation to work correctly on localized Windows installations. Customers running Traditional Chinese Windows (zh-TW / CP950) reported that certificate add jobs were failing with "Crypto Service Provider ... is either invalid or not found on this system." even when the requested CSP was installed. The root cause was that `Get-CryptoProviders` parsed `certutil -csplist` output and matched on the literal English label "Provider Name:", which is translated on non en-US Windows, causing the enumerated provider list to come back empty and every CSP name to fail validation. Provider enumeration now reads from the culture-invariant registry hive `HKLM:\SOFTWARE\Microsoft\Cryptography\Defaults\Provider` (with a code-page-safe `certutil` fallback for hardened systems where the registry hive is unavailable), and CSP name matching in `Validate-CryptoProvider` now uses ordinal, case-insensitive comparison so results are unaffected by the current thread culture (Turkish-I folding, full-/half-width character folding, etc.). + +3.0.2 + +* Fixed SQL service restart behavior: previously the extension could stop multiple SQL services and fail to start all of them. It now restarts only the SQL service associated with the certificate being renewed. + 3.0.1 -* Fixed an issues when renewing ECC Certificates + +* Fixed an issues when renewing ECC Certificates 3.0.0 + * As of this version of the extension, SANs will be handled through the ODKG Enrollment page in Command, and will no longer use the SAN Entry Parameter. This version, we are removing the Entry Parameter "SAN" from the integration-manifest.json, but will still support previous versions of Command in the event the SAN Entry Parameter is passed. The next major version (4.0) will remove all support for the SAN Entry Parameter. * Added WinADFS Store Type for rotating certificates in ADFS environments. Please note, only the service-communications certificate is rotated throughout your farm. * Internal only: Added Integration Tests to aid in future development and testing. @@ -9,38 +23,44 @@ * Fixed the SNI/SSL flag being returned during inventory, now returns extended SSL flags * Fixed the SNI/SSL flag when binding the certificate to allow for extended SSL flags * Added SSL Flag validation to make sure the bit flag is correct. These are the valid bit flags for the version of Windows: - ### Windows Server 2012 R2 / Windows 8.1 and earlier (IIS 8.5): - * 0 No SNI - * 1 Use SNI - * 2 Use Centralized SSL certificate store. + ### Windows Server 2012 R2 / Windows 8.1 and earlier (IIS 8.5) + + * 0 No SNI + * 1 Use SNI + * 2 Use Centralized SSL certificate store. + + ### Windows Server 2016 (IIS 10.0) - ### Windows Server 2016 (IIS 10.0): - * 0 No SNI - * 1 Use SNI - * 4 Disable HTTP/2. + * 0 No SNI + * 1 Use SNI + * 4 Disable HTTP/2. ### Windows Server 2019 (IIS 10.0.17763) - * 0 No SNI - * 1 Use SNI - * 4 Disable HTTP/2. - * 8 Disable OCSP Stapling. + + * 0 No SNI + * 1 Use SNI + * 4 Disable HTTP/2. + * 8 Disable OCSP Stapling. ### Windows Server 2022+ (IIS 10.0.20348+) - * 0 No SNI - * 1 Use SNI - * 4 Disable HTTP/2. - * 8 Disable OCSP Stapling. - * 16 Disable QUIC. - * 32 Disable TLS 1.3 over TCP. - * 64 Disable Legacy TLS. + + * 0 No SNI + * 1 Use SNI + * 4 Disable HTTP/2. + * 8 Disable OCSP Stapling. + * 16 Disable QUIC. + * 32 Disable TLS 1.3 over TCP. + * 64 Disable Legacy TLS. 2.6.4 + * Fixed an issue with SSL Flags greater than 3 were not being applied correctly to newer IIS servers. * Fixed an issue when formatting private RSA keys when connecting using the ssh protocol. * When using ssh protocol in containers, the SQL ACL on private keys was not being updating correctly. This has been fixed. * Updated documentation to indicate that the username and password fields on the Cert Store are automatically added by Command. 2.6.3 + * Fixed re-enrollment or ODKG job when RDN Components contained escaped commas. * Updated renewal job for IIS Certs to delete the old cert if not bound or used by other web sites. * Improved Inventory reporting of CSP when cert uses newer CNG Keys. @@ -51,23 +71,25 @@ * Fixed an issue with (remote) ODKG jobs that caused an error when the CSP was not specified that did not require binding. 2.6.2 + * Fixed error when attempting to connect to remote computer using UO service account * Fixed error when connecting to remote computer using HTTPS; was defaulting to HTTP * Fixed the creation of a certificate when the Cryptographic Service Provider was changed by the user * Updated logic when getting the CSP. Now supports modern CHG and legacy CAPI APIs. This will allow the CSP to show in the stores inventory. * Re-factored code to eliminate warnings * Bumped up he following packages to eliminate .net vulnerabilities and obsolete packages: - * Keyfactor.Orchestrators.IOrchestratorJobExtensions" Version="1.0.0" + * Keyfactor.Orchestrators.IOrchestratorJobExtensions" Version="1.0.0" * Microsoft.PowerShell.SDK" Version="7.4.10" Condition="'$(TargetFramework)' == 'net8.0'" * runtime.linux-arm64.runtime.native.System.IO.Ports" Version="9.0.5" * runtime.osx-arm64.runtime.native.System.IO.Ports" Version="9.0.5" * System.Formats.Asn1" Version="8.0.2" Condition="'$(TargetFramework)' == 'net6.0'" - * System.Formats.Asn1" Version="9.0.0" Condition="'$(TargetFramework)' == 'net8.0'" + * System.Formats.Asn1" Version="9.0.0" Condition="'$(TargetFramework)' == 'net8.0'" * System.IO.Packaging" Version="6.0.2" Condition="'$(TargetFramework)' == 'net6.0'" * System.IO.Packaging" Version="8.0.1" Condition="'$(TargetFramework)' == 'net8.0'" * System.Text.Json" Version="8.0.5" 2.6.1 + * Documentation updates for the 2.6 release * Fix a naming typo in the 2.5 migration SQL script * Update integration-manifest.json @@ -77,6 +99,7 @@ * Bumped System.IO.Packaging to 6.0.2 & 8.0.1 for .Net vulnerabilities. 2.6.0 + * Added the ability to run the extension in a Linux environment. To utilize this change, for each Cert Store Types (WinCert/WinIIS/WinSQL), add ssh to the Custom Field WinRM Protocol. When using ssh as a protocol, make sure to enter the appropriate ssh port number under WinRM Port. * NOTE: For legacy purposes the Display names WinRM Protocol and WinRM Port are maintained although the type of protocols now includes ssh. * Moved all inventory and management jobs to external PowerShell script file .\PowerShellScripts\WinCertScripts.ps1 @@ -84,9 +107,11 @@ * NOTE: This version was not publicly released. 2.5.1 + * Fixed WinSQL service name when InstanceID differs from InstanceName 2.5.0 + * Added the Bindings to the end of the thumbprint to make the alias unique. * Using new IISWebBindings cmdlet to use additional SSL flags when binding certificate to website. * NOTE: The property SNIFlag has changed from a multi-select to a string with default of "0". To properly use the new SNI/SSL flags you can delete the SNIFlag from the store type and re-add the field as described in the ReadMe. If you have several existing cert stores, you may can execute the SQL script (IISU Sni Flag 2.5 upgrade script) to update the field type. Consult your Keyfactor Rep for help. @@ -96,22 +121,27 @@ * Removed renewal thumbprint logic to update multiple website; each job now updates its own specific certificate. 2.4.4 + * Fix an issue with WinRM parameters when migrating Legacy IIS Stores to the WinCert type * Fix an issue with "Delete" script in the Legacy IIS Migration that did not remove some records from dependent tables 2.4.3 + * Adding Legacy IIS Migration scripting and ReadMe guide 2.4.2 + * Correct false positive error when completing an IIS inventory job. * Revert to specifying the version of PowerShell to use when establishing a local PowerShell Runspace. * Fixed typo in error message. 2.4.1 + * Modified the CertUtil logic to use the -addstore argument when no password is sent with the certificate information. * Added additional error trapping and trace logs 2.4.0 + * Changed the way certificates are added to cert stores. CertUtil is now used to import the PFX certificate into the associated store. The CSP is now considered when maintaining certificates, empty CSP values will result in using the machines default CSP. * Added the Crypto Service Provider and SAN Entry Parameters to be used on Inventory queries, Adding and ReEnrollments for the WinCert, WinSQL and IISU extensions. * Changed how Client Machine Names are handled when a 'localhost' connection is desired. The new naming convention is: {machineName}|localmachine. This will eliminate the issue of unique naming conflicts. @@ -119,66 +149,79 @@ * Updated the integration-manifest.json file for new fields in cert store types. 2.3.2 + * Changed the Open Cert Store access level from a '5' to 'MaxAllowed' 2.3.1 + * Added additional error trapping for WinRM connections to allow actual error on failure. 2.3.0 + * Added Sql Server Binding Support * Modified WinCert Advanced PrivateKeyAllowed setting from Required to Optional 2.2.2 + * Removed empty constructor to resolve PAM provider error when using WinCert store types 2.2.1 + * Fixed issue where https binding without cert was causing an error 2.2.0 -* Added Support for GMSA Account by using no value for ServerUsernanme and ServerPassword. KF Command version 10.2 or later is required to specify empty credentials. + +* Added Support for GMSA Account by using no value for ServerUsernanme and ServerPassword. KF Command version 10.2 or later is required to specify empty credentials. + * Added local PowerShell support, triggered when specifying 'localhost' as the client machine while using the IISU or WinCert Orchestrator. This change was tested using KF Command 10.3 * Moved to .NET 6 2.1.1 + * Fixed the missing site name error when issuing a WinCert job when writing trace log settings to the log file. * Several display names changed in the documented certificate store type definitions. There are no changes to the internal type or parameter names, so no migration is necessary for currently configured stores. - * Display name for IISU changed to "IIS Bound Certificate". - * Display name for WinCert changed to "Windows Certificate". - * Display names for several Store and Entry parameters changed to be more descriptive and UI friendly. + * Display name for IISU changed to "IIS Bound Certificate". + * Display name for WinCert changed to "Windows Certificate". + * Display names for several Store and Entry parameters changed to be more descriptive and UI friendly. * Significant ReadMe cleanup 2.1.0 + * Fixed issue that was occurring during renewal when there were bindings outside of http and https like net.tcp * Added PAM registration/initialization documentation in README.md -* Resolved Null HostName error +* Resolved Null HostName error * Added WinCert Cert Store Type * Added custom property parser to not show any passwords * Removed any password references in trace logs and output settings in JSON format 2.0.0 + * Add support for re-enrollment jobs (On Device Key Generation) with the ability to specify a cryptographic provider. Specification of cryptographic provider allows HSM (Hardware Security Module) use. * Local PAM Support added (requires Universal Orchestrator Framework version 10.1) * Certificate store type changed from IISBin to IISU. See README for migration notes. - 1.1.3 + * Made WinRM port a store parameter * Made WinRM protocol a store parameter * IISWBin 1.1.3 upgrade script.sql added to upgrade from 1.1.2 1.1.0 + * Migrate to Universal Orchestrator (KF9 / .NET Core) * Perform Renewals using RenewalThumbprint 1.0.3 + * Add support for the SNI Flags when creating new bindings. Supported flags include: - * 0 No SNI + * 0 No SNI * 1 SNI Enabled * 2 Non SNI binding which uses Central Certificate Store * 3 SNI binding which uses Central Certificate Store * Last release to support Windows Orchestrator (KF8) 1.0.2 + * Remove dependence on Windows.Web.Administration on the orchestrator server. The agent will now use the local version on the managed server via remote PowerShell * add support for the IncludePortInSPN flag * add support to use credentials from Keyfactor for Add/Remove/Inventory jobs. diff --git a/IISU/ClientPSCertStoreReEnrollment.cs b/IISU/ClientPSCertStoreReEnrollment.cs index da9adc2b..1c7cc02c 100644 --- a/IISU/ClientPSCertStoreReEnrollment.cs +++ b/IISU/ClientPSCertStoreReEnrollment.cs @@ -90,12 +90,11 @@ public JobResult PerformReEnrollment(ReenrollmentJobConfiguration config, Submit string protocol = jobProperties.WinRmProtocol; string port = jobProperties.WinRmPort; bool includePortInSPN = jobProperties.SpnPortFlag; + string jeaEndpoint = jobProperties?.JEAEndpointName ?? ""; string clientMachineName = config.CertificateStoreDetails.ClientMachine; string storePath = config.CertificateStoreDetails.StorePath; - //_psHelper = new(protocol, port, includePortInSPN, clientMachineName, serverUserName, serverPassword); - - _psHelper = new(protocol, port, includePortInSPN, clientMachineName, serverUserName, serverPassword); + _psHelper = new(protocol, port, includePortInSPN, clientMachineName, serverUserName, serverPassword, jeaEndpoint: jeaEndpoint); _psHelper.Initialize(); using (_psHelper) @@ -160,25 +159,25 @@ public JobResult PerformReEnrollment(ReenrollmentJobConfiguration config, Submit { case "Success": psResult = OrchestratorJobStatusJobResult.Success; - _logger.LogDebug($"PowerShell function New-KFIISSiteBinding returned successfully with Code: {code}, on Step: {step}"); + _logger.LogDebug($"PowerShell function New-KeyfactorIISSiteBinding returned successfully with Code: {code}, on Step: {step}"); break; case "Skipped": psResult = OrchestratorJobStatusJobResult.Failure; - failureMessage = ($"PowerShell function New-KFIISSiteBinding failed on step: {step} - message:\n {errorMessage}"); + failureMessage = ($"PowerShell function New-KeyfactorIISSiteBinding failed on step: {step} - message:\n {errorMessage}"); _logger.LogDebug(failureMessage); break; case "Warning": psResult = OrchestratorJobStatusJobResult.Warning; - _logger.LogDebug($"PowerShell function New-KFIISSiteBinding returned with a Warning on step: {step} with code: {code} - message: {message}"); + _logger.LogDebug($"PowerShell function New-KeyfactorIISSiteBinding returned with a Warning on step: {step} with code: {code} - message: {message}"); break; case "Error": psResult = OrchestratorJobStatusJobResult.Failure; - failureMessage = ($"PowerShell function New-KFIISSiteBinding failed on step: {step} with code: {code} - message: {errorMessage}"); + failureMessage = ($"PowerShell function New-KeyfactorIISSiteBinding failed on step: {step} with code: {code} - message: {errorMessage}"); _logger.LogDebug(failureMessage); break; default: psResult = OrchestratorJobStatusJobResult.Unknown; - _logger.LogWarning("Unknown status returned from New-KFIISSiteBinding: " + status); + _logger.LogWarning("Unknown status returned from New-KeyfactorIISSiteBinding: " + status); break; } } @@ -294,9 +293,9 @@ private string CreateCSR(string subjectText, string providerName, string keyType { "keyLength", keySize }, { "SAN", SAN } }; - _logger.LogInformation("Attempting to execute PS function (New-CsrEnrollment)"); - _results = _psHelper.ExecutePowerShell("New-CsrEnrollment", parameters); - _logger.LogInformation("Returned from executing PS function (New-CsrEnrollment)"); + _logger.LogInformation("Attempting to execute PS function (New-KeyfactorODKGEnrollment)"); + _results = _psHelper.ExecutePowerShell("New-KeyfactorODKGEnrollment", parameters); + _logger.LogInformation("Returned from executing PS function (New-KeyfactorODKGEnrollment)"); // This should return the CSR that was generated if (_results == null || _results.Count == 0) @@ -356,9 +355,9 @@ private string ImportCertificate(byte[] certificateRawData, string storeName) { "storeName", storeName } }; - _logger.LogTrace("Attempting to execute PS function (Import-SignedCertificate)"); - _results = _psHelper.ExecutePowerShell("Import-SignedCertificate", parameters); - _logger.LogTrace("Returned from executing PS function (Import-SignedCertificate)"); + _logger.LogTrace("Attempting to execute PS function (Import-KeyfactorSignedCertificate)"); + _results = _psHelper.ExecutePowerShell("Import-KeyfactorSignedCertificate", parameters); + _logger.LogTrace("Returned from executing PS function (Import-KeyfactorSignedCertificate)"); // This should return the CSR that was generated if (_results != null && _results.Count > 0) @@ -399,7 +398,7 @@ public string ResolveSANString(ReenrollmentJobConfiguration config) } else if (config.JobProperties != null && config.JobProperties.TryGetValue("SAN", out object legacySanValue) && - !string.IsNullOrWhiteSpace(legacySanValue.ToString())) + (legacySanValue is not null && !string.IsNullOrWhiteSpace(legacySanValue.ToString()))) { sanValue = legacySanValue.ToString().Trim(); sourceUsed = "config.JobProperties[\"SAN\"] (legacy)"; diff --git a/IISU/ImplementedStoreTypes/Win/Inventory.cs b/IISU/ImplementedStoreTypes/Win/Inventory.cs index 546640b8..c540188c 100644 --- a/IISU/ImplementedStoreTypes/Win/Inventory.cs +++ b/IISU/ImplementedStoreTypes/Win/Inventory.cs @@ -81,6 +81,7 @@ public JobResult ProcessJob(InventoryJobConfiguration jobConfiguration, SubmitIn settings.IncludePortInSPN = jobProperties.SpnPortFlag; settings.ServerUserName = serverUserName; settings.ServerPassword = serverPassword; + settings.JEAEndpointName = jobProperties.JEAEndpointName; _logger.LogTrace($"Querying Window certificate in store: {storePath}"); inventoryItems = QueryWinCertCertificates(settings, storePath); @@ -109,7 +110,7 @@ public JobResult ProcessJob(InventoryJobConfiguration jobConfiguration, SubmitIn { _logger.LogTrace(LogHandler.FlattenException(ex)); - var failureMessage = $"Inventory job failed for Site '{jobConfiguration.CertificateStoreDetails.StorePath}' on server '{jobConfiguration.CertificateStoreDetails.ClientMachine}' with error: '{LogHandler.FlattenException(ex)}'"; + var failureMessage = $"Inventory job failed for Site '{jobConfiguration.CertificateStoreDetails.StorePath}' on server '{jobConfiguration.CertificateStoreDetails.ClientMachine}' with error: '{ex.Message}'"; _logger.LogWarning(failureMessage); return new JobResult @@ -126,7 +127,7 @@ public List QueryWinCertCertificates(RemoteSettings settin { List Inventory = new(); - using (PSHelper ps = new(settings.Protocol, settings.Port, settings.IncludePortInSPN, settings.ClientMachineName, settings.ServerUserName, settings.ServerPassword)) + using (PSHelper ps = new(settings.Protocol, settings.Port, settings.IncludePortInSPN, settings.ClientMachineName, settings.ServerUserName, settings.ServerPassword, jeaEndpoint: settings.JEAEndpointName)) { ps.Initialize(); @@ -135,7 +136,7 @@ public List QueryWinCertCertificates(RemoteSettings settin { "StoreName", StoreName } }; - results = ps.ExecutePowerShell("Get-KFCertificates", parameters); + results = ps.ExecutePowerShell("Get-KeyfactorCertificates", parameters); // If there are certificates, deserialize the results and send them back to command if (results != null && results.Count > 0) @@ -147,8 +148,7 @@ public List QueryWinCertCertificates(RemoteSettings settin { var siteSettingsDict = new Dictionary { - { "ProviderName", cert.ProviderName}, - { "SAN", cert.SAN } + { "ProviderName", cert.ProviderName} }; Inventory.Add( diff --git a/IISU/ImplementedStoreTypes/Win/Management.cs b/IISU/ImplementedStoreTypes/Win/Management.cs index bbb860f9..63e92615 100644 --- a/IISU/ImplementedStoreTypes/Win/Management.cs +++ b/IISU/ImplementedStoreTypes/Win/Management.cs @@ -26,6 +26,7 @@ using Keyfactor.Logging; using System.Collections.ObjectModel; using System.Collections.Generic; +using Keyfactor.Extensions.Orchestrator.WindowsCertStore.Models; namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.WinCert { @@ -88,8 +89,9 @@ public JobResult ProcessJob(ManagementJobConfiguration config) string protocol = jobProperties?.WinRmProtocol; string port = jobProperties?.WinRmPort; bool includePortInSPN = (bool)jobProperties?.SpnPortFlag; + string jeaEndpoint = jobProperties?.JEAEndpointName ?? ""; - _psHelper = new(protocol, port, includePortInSPN, _clientMachineName, serverUserName, serverPassword); + _psHelper = new(protocol, port, includePortInSPN, _clientMachineName, serverUserName, serverPassword, jeaEndpoint: jeaEndpoint); switch (_operationType) { @@ -125,7 +127,7 @@ public JobResult ProcessJob(ManagementJobConfiguration config) { _logger.LogTrace(LogHandler.FlattenException(ex)); - var failureMessage = $"Management job {_operationType} failed on Store '{_storePath}' on server '{_clientMachineName}' with error: '{LogHandler.FlattenException(ex)}'"; + var failureMessage = $"Management job {_operationType} failed on Store '{_storePath}' on server '{_clientMachineName}' with error: '{ex.Message}'"; _logger.LogWarning(failureMessage); return new JobResult @@ -145,7 +147,7 @@ public JobResult AddCertificate(string certificateContents, string privateKeyPas { _psHelper.Initialize(); - _logger.LogTrace("Attempting to execute PS function (Add-KFCertificateToStore)"); + _logger.LogTrace("Attempting to execute PS function (Add-KeyfactorCertificate)"); // Mandatory parameters var parameters = new Dictionary @@ -158,20 +160,34 @@ public JobResult AddCertificate(string certificateContents, string privateKeyPas if (!string.IsNullOrEmpty(privateKeyPassword)) { parameters.Add("PrivateKeyPassword", privateKeyPassword); } if (!string.IsNullOrEmpty(cryptoProvider)) { parameters.Add("CryptoServiceProvider", cryptoProvider); } - _results = _psHelper.ExecutePowerShell("Add-KFCertificateToStore", parameters); - _logger.LogTrace("Returned from executing PS function (Add-KFCertificateToStore)"); + _results = _psHelper.ExecutePowerShell("Add-KeyfactorCertificate", parameters); + _logger.LogTrace("Returned from executing PS function (Add-KeyfactorCertificate)"); - // This should return the thumbprint of the certificate - if (_results != null && _results.Count > 0) - { - var thumbprint = _results[0].ToString(); - _logger.LogTrace($"Added certificate to store {_storePath}, returned with the thumbprint {thumbprint}"); - } - else + ResultObject addResult = ResultObject.FromPSResults(_results); + _logger.LogTrace($"Add-KeyfactorCertificate returned Status={addResult.Status}, Code={addResult.Code}, Step={addResult.Step}, Thumbprint='{addResult.Thumbprint}'"); + + _psHelper.Terminate(); + + if (!addResult.IsSuccess) { - _logger.LogTrace("No results were returned. There could have been an error while adding the certificate. Look in the trace logs for PowerShell information."); + string detail = !string.IsNullOrEmpty(addResult.ErrorMessage) + ? addResult.ErrorMessage + : addResult.Message; + + string failureMessage = + $"Add certificate to store '{_storePath}' failed at step '{addResult.Step}' (code {addResult.Code}): {detail}"; + + _logger.LogWarning(failureMessage); + + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = _jobHistoryID, + FailureMessage = failureMessage + }; } - _psHelper.Terminate(); + + _logger.LogTrace($"Added certificate to store {_storePath}, thumbprint {addResult.Thumbprint}"); } return new JobResult @@ -212,8 +228,8 @@ public JobResult RemoveCertificate(string thumbprint) { "StorePath", _storePath } }; - _psHelper.ExecutePowerShell("Remove-KFCertificateFromStore", parameters); - _logger.LogTrace("Returned from executing PS function (Remove-KFCertificateFromStore)"); + _psHelper.ExecutePowerShell("Remove-KeyfactorCertificate", parameters); + _logger.LogTrace("Returned from executing PS function (Remove-KeyfactorCertificate)"); _psHelper.Terminate(); } diff --git a/IISU/ImplementedStoreTypes/Win/WinCertCertificateInfo.cs b/IISU/ImplementedStoreTypes/Win/WinCertCertificateInfo.cs index 27d5e00e..7dfafa6e 100644 --- a/IISU/ImplementedStoreTypes/Win/WinCertCertificateInfo.cs +++ b/IISU/ImplementedStoreTypes/Win/WinCertCertificateInfo.cs @@ -24,7 +24,6 @@ public class WinCertCertificateInfo public string Issuer { get; set; } public string Thumbprint { get; set; } public bool HasPrivateKey { get; set; } - public string SAN { get; set; } public string ProviderName { get; set; } public string Base64Data { get; set; } } diff --git a/IISU/ImplementedStoreTypes/Win/WinInventory.cs b/IISU/ImplementedStoreTypes/Win/WinInventory.cs index 0cae7b8c..a60e067a 100644 --- a/IISU/ImplementedStoreTypes/Win/WinInventory.cs +++ b/IISU/ImplementedStoreTypes/Win/WinInventory.cs @@ -24,6 +24,7 @@ namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.WinCert { + [Obsolete("This class is no longer used and will be removed in a future release.")] internal class WinInventory : ClientPSCertStoreInventory { private ILogger _logger; diff --git a/IISU/ImplementedStoreTypes/WinADFS/Inventory.cs b/IISU/ImplementedStoreTypes/WinADFS/Inventory.cs index 639335f2..ffb8e416 100644 --- a/IISU/ImplementedStoreTypes/WinADFS/Inventory.cs +++ b/IISU/ImplementedStoreTypes/WinADFS/Inventory.cs @@ -180,8 +180,7 @@ public List QueryWinADFSCertificates(RemoteSettings settin { var siteSettingsDict = new Dictionary { - { "ProviderName", cert.ProviderName}, - { "SAN", cert.SAN } + { "ProviderName", cert.ProviderName} }; Inventory.Add( diff --git a/IISU/ImplementedStoreTypes/WinAdfs/AdfsCertificateRotationManager.cs b/IISU/ImplementedStoreTypes/WinAdfs/AdfsCertificateRotationManager.cs index 982477e1..ad2ceaf9 100644 --- a/IISU/ImplementedStoreTypes/WinAdfs/AdfsCertificateRotationManager.cs +++ b/IISU/ImplementedStoreTypes/WinAdfs/AdfsCertificateRotationManager.cs @@ -553,7 +553,7 @@ public static void UpdateFarmCertificateSettings(string thumbprint, PSHelper psH } } } - catch (Exception ex) + catch (Exception) { throw; } diff --git a/IISU/ImplementedStoreTypes/WinAdfs/Management.cs b/IISU/ImplementedStoreTypes/WinAdfs/Management.cs index 77c3607b..a1b33d2e 100644 --- a/IISU/ImplementedStoreTypes/WinAdfs/Management.cs +++ b/IISU/ImplementedStoreTypes/WinAdfs/Management.cs @@ -147,7 +147,7 @@ public JobResult ProcessJob(ManagementJobConfiguration config) { _logger.LogTrace(LogHandler.FlattenException(ex)); - var failureMessage = $"Management job {_operationType} failed on Store '{_storePath}' on server '{_clientMachineName}' with error: '{LogHandler.FlattenException(ex)}'"; + var failureMessage = $"Management job {_operationType} failed on Store '{_storePath}' on server '{_clientMachineName}' with error: '{ex.Message}'"; _logger.LogWarning(failureMessage); return new JobResult diff --git a/IISU/ImplementedStoreTypes/WinIIS/Inventory.cs b/IISU/ImplementedStoreTypes/WinIIS/Inventory.cs index 65c1b68c..f5214850 100644 --- a/IISU/ImplementedStoreTypes/WinIIS/Inventory.cs +++ b/IISU/ImplementedStoreTypes/WinIIS/Inventory.cs @@ -83,6 +83,7 @@ public JobResult ProcessJob(InventoryJobConfiguration jobConfiguration, SubmitIn settings.IncludePortInSPN = jobProperties.SpnPortFlag; settings.ServerUserName = serverUserName; settings.ServerPassword = serverPassword; + settings.JEAEndpointName = jobProperties.JEAEndpointName; _logger.LogTrace("Querying IIS Inventory.."); inventoryItems = QueryIISCertificates(settings); @@ -127,7 +128,7 @@ public List QueryIISCertificates(RemoteSettings settings) { List Inventory = new(); - using (PSHelper ps = new(settings.Protocol, settings.Port, settings.IncludePortInSPN, settings.ClientMachineName, settings.ServerUserName, settings.ServerPassword)) + using (PSHelper ps = new(settings.Protocol, settings.Port, settings.IncludePortInSPN, settings.ClientMachineName, settings.ServerUserName, settings.ServerPassword, jeaEndpoint: settings.JEAEndpointName)) { ps.Initialize(); @@ -142,7 +143,7 @@ public List QueryIISCertificates(RemoteSettings settings) // results = ps.InvokeFunction("Get-KFIISBoundCertificates"); //} - results = ps.ExecutePowerShell("Get-KFIISBoundCertificates"); + results = ps.ExecutePowerShell("Get-KeyfactorIISBoundCertificates"); // If there are certificates, deserialize the results and send them back to command if (results != null && results.Count > 0) diff --git a/IISU/ImplementedStoreTypes/WinIIS/Management.cs b/IISU/ImplementedStoreTypes/WinIIS/Management.cs index b458584d..99f6e8af 100644 --- a/IISU/ImplementedStoreTypes/WinIIS/Management.cs +++ b/IISU/ImplementedStoreTypes/WinIIS/Management.cs @@ -27,7 +27,6 @@ using Microsoft.Extensions.Logging; using Microsoft.PowerShell.Commands; using Newtonsoft.Json; - namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.IISU { public class Management : WinCertJobTypeBase, IManagementJobExtension @@ -92,12 +91,13 @@ public JobResult ProcessJob(ManagementJobConfiguration config) string protocol = jobProperties?.WinRmProtocol; string port = jobProperties?.WinRmPort; bool includePortInSPN = (bool)jobProperties?.SpnPortFlag; + string jeaEndpoint = jobProperties?.JEAEndpointName ?? ""; string alias = config.JobCertificate?.Alias?.Split(':').FirstOrDefault() ?? string.Empty; // Thumbprint is first part of the alias // Assign the binding information IISBindingInfo bindingInfo = new IISBindingInfo(config.JobProperties); - _psHelper = new(protocol, port, includePortInSPN, _clientMachineName, serverUserName, serverPassword); + _psHelper = new(protocol, port, includePortInSPN, _clientMachineName, serverUserName, serverPassword, jeaEndpoint: jeaEndpoint); _psHelper.Initialize(); @@ -121,9 +121,31 @@ public JobResult ProcessJob(ManagementJobConfiguration config) OrchestratorJobStatusJobResult psResult = OrchestratorJobStatusJobResult.Unknown; string failureMessage = ""; - - string newThumbprint = AddCertificate(certificateContents, privateKeyPassword, cryptoProvider); - _logger.LogTrace($"Completed adding the certificate to the store"); + + ResultObject addResult = AddCertificate(certificateContents, privateKeyPassword, cryptoProvider); + _logger.LogTrace($"Completed adding the certificate to the store. Status={addResult.Status}, Code={addResult.Code}, Step={addResult.Step}"); + + if (!addResult.IsSuccess) + { + string detail = !string.IsNullOrEmpty(addResult.ErrorMessage) + ? addResult.ErrorMessage + : addResult.Message; + + string addFailureMessage = + $"Add certificate to store '{_storePath}' failed at step '{addResult.Step}' (code {addResult.Code}): {detail}"; + + _logger.LogError(addFailureMessage); + + complete = new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = _jobHistoryID, + FailureMessage = addFailureMessage + }; + break; + } + + string newThumbprint = addResult.Thumbprint; _logger.LogTrace($"New thumbprint: {newThumbprint}"); // Bind Certificate to IIS Site @@ -200,8 +222,9 @@ public JobResult ProcessJob(ManagementJobConfiguration config) { Result = OrchestratorJobStatusJobResult.Failure, JobHistoryId = _jobHistoryID, - FailureMessage = $"No thumbprint was returned. Unable to bind certificate to site: {bindingInfo.SiteName}." - }; } + FailureMessage = $"Add-KeyfactorCertificate reported Success but did not return a thumbprint. Unable to bind certificate to site: {bindingInfo.SiteName}." + }; + } } catch (Exception ex) { @@ -276,13 +299,11 @@ public JobResult ProcessJob(ManagementJobConfiguration config) } } - public string AddCertificate(string certificateContents, string privateKeyPassword, string cryptoProvider) + public ResultObject AddCertificate(string certificateContents, string privateKeyPassword, string cryptoProvider) { try { - string newThumbprint = string.Empty; - - _logger.LogTrace("Attempting to execute PS function (Add-KFCertificateToStore)"); + _logger.LogTrace("Attempting to execute PS function (Add-KeyfactorCertificate)"); // Mandatory parameters var parameters = new Dictionary @@ -295,21 +316,18 @@ public string AddCertificate(string certificateContents, string privateKeyPasswo if (!string.IsNullOrEmpty(privateKeyPassword)) { parameters.Add("PrivateKeyPassword", privateKeyPassword); } if (!string.IsNullOrEmpty(cryptoProvider)) { parameters.Add("CryptoServiceProvider", cryptoProvider); } - _results = _psHelper.ExecutePowerShell("Add-KFCertificateToStore", parameters); - _logger.LogTrace("Returned from executing PS function (Add-KFCertificateToStore)"); + _results = _psHelper.ExecutePowerShell("Add-KeyfactorCertificate", parameters); + _logger.LogTrace("Returned from executing PS function (Add-KeyfactorCertificate)"); - // This should return the thumbprint of the certificate - if (_results != null && _results.Count > 0) - { - newThumbprint = _results[0].ToString(); - _logger.LogTrace($"Added certificate to store {_storePath}, returned with the thumbprint {newThumbprint}"); - } - else + ResultObject result = ResultObject.FromPSResults(_results); + _logger.LogTrace($"Add-KeyfactorCertificate returned Status={result.Status}, Code={result.Code}, Step={result.Step}, Thumbprint='{result.Thumbprint}'"); + + if (!result.IsSuccess && !string.IsNullOrEmpty(result.ErrorMessage)) { - _logger.LogTrace("No results were returned. There could have been an error while adding the certificate. Look in the trace logs for PowerShell information."); + _logger.LogWarning($"Add-KeyfactorCertificate error: {result.ErrorMessage}"); } - return newThumbprint; + return result; } catch (Exception ex) { @@ -330,7 +348,7 @@ public void RemoveIISCertificate(string thumbprint) { "StoreName", _storePath } }; - _psHelper.ExecutePowerShell("Remove-KFIISCertificateIfUnused", parameters); + _psHelper.ExecutePowerShell("Remove-KeyfactorIISCertificateIfUnused", parameters); } diff --git a/IISU/ImplementedStoreTypes/WinIIS/WinIISBinding.cs b/IISU/ImplementedStoreTypes/WinIIS/WinIISBinding.cs index 678a2b4d..3f2ff418 100644 --- a/IISU/ImplementedStoreTypes/WinIIS/WinIISBinding.cs +++ b/IISU/ImplementedStoreTypes/WinIIS/WinIISBinding.cs @@ -34,7 +34,7 @@ public class WinIISBinding public static Collection BindCertificate(PSHelper psHelper, IISBindingInfo bindingInfo, string thumbprint, string renewalThumbprint, string storePath) { _logger = LogHandler.GetClassLogger(typeof(WinIISBinding)); - _logger.LogTrace("Attempting to bind and execute PS function (New-KFIISSiteBinding)"); + _logger.LogTrace("Attempting to bind and execute PS function (New-KeyfactorIISSiteBinding)"); // Mandatory parameters var parameters = new Dictionary @@ -53,7 +53,7 @@ public static Collection BindCertificate(PSHelper psHelper, IISBinding try { - return psHelper.ExecutePowerShell("New-KFIISSiteBinding", parameters); // returns true if successful + return psHelper.ExecutePowerShell("New-KeyfactorIISSiteBinding", parameters); } catch (Exception ex) { @@ -64,26 +64,20 @@ public static Collection BindCertificate(PSHelper psHelper, IISBinding public static bool UnBindCertificate(PSHelper psHelper, IISBindingInfo bindingInfo) { _logger = LogHandler.GetClassLogger(typeof(WinIISBinding)); - _logger.LogTrace("Attempting to UnBind and execute PS function (Remove-KFIISSiteBinding)"); + _logger.LogTrace("Attempting to UnBind and execute PS function (Remove-KeyfactorIISSiteBinding)"); + + string bindingInfoStr = $"{bindingInfo.IPAddress}:{bindingInfo.Port}:{bindingInfo.HostName ?? string.Empty}"; - // Mandatory parameters var parameters = new Dictionary { { "SiteName", bindingInfo.SiteName }, - { "IPAddress", bindingInfo.IPAddress }, - { "Port", bindingInfo.Port }, + { "BindingInfo", bindingInfoStr }, }; - // Optional parameters - if (!string.IsNullOrEmpty(bindingInfo.HostName)) - { - parameters.Add("HostName", bindingInfo.HostName); - } - try { - var results = psHelper.ExecutePowerShell("Remove-KFIISSiteBinding", parameters); - _logger.LogTrace("Returned from executing PS function (Remove-KFIISSiteBinding)"); + var results = psHelper.ExecutePowerShell("Remove-KeyfactorIISSiteBinding", parameters); + _logger.LogTrace("Returned from executing PS function (Remove-KeyfactorIISSiteBinding)"); if (results == null || results.Count == 0) { @@ -91,15 +85,13 @@ public static bool UnBindCertificate(PSHelper psHelper, IISBindingInfo bindingIn return false; } - if (results[0].BaseObject is bool success) - { - return success; - } - else - { - _logger.LogWarning("Unexpected result type from PowerShell function."); - return false; - } + string status = results[0].Properties["Status"]?.Value as string ?? string.Empty; + if (status == "Success" || status == "Skipped") + return true; + + string errorMsg = results[0].Properties["ErrorMessage"]?.Value as string ?? string.Empty; + _logger.LogWarning($"Remove-KeyfactorIISSiteBinding returned status '{status}': {errorMsg}"); + return false; } catch (Exception ex) { diff --git a/IISU/ImplementedStoreTypes/WinSQL/Inventory.cs b/IISU/ImplementedStoreTypes/WinSQL/Inventory.cs index 765cf0a7..1eca1384 100644 --- a/IISU/ImplementedStoreTypes/WinSQL/Inventory.cs +++ b/IISU/ImplementedStoreTypes/WinSQL/Inventory.cs @@ -81,9 +81,10 @@ public JobResult ProcessJob(InventoryJobConfiguration jobConfiguration, SubmitIn settings.IncludePortInSPN = jobProperties.SpnPortFlag; settings.ServerUserName = serverUserName; settings.ServerPassword = serverPassword; + settings.JEAEndpointName = jobProperties.JEAEndpointName; _logger.LogTrace($"Attempting to read bound SQL Server certificates from cert store: {storePath}"); - inventoryItems = QuerySQLCertificates(settings, storePath); + inventoryItems = QuerySQLCertificates(settings); _logger.LogTrace("Invoking submitInventory.."); submitInventoryUpdate.Invoke(inventoryItems); @@ -109,7 +110,7 @@ public JobResult ProcessJob(InventoryJobConfiguration jobConfiguration, SubmitIn { _logger.LogTrace(LogHandler.FlattenException(ex)); - var failureMessage = $"SQL Inventory job failed for Site '{jobConfiguration.CertificateStoreDetails.StorePath}' on server '{jobConfiguration.CertificateStoreDetails.ClientMachine}' with error: '{LogHandler.FlattenException(ex)}'"; + var failureMessage = $"SQL Inventory job failed for Site '{jobConfiguration.CertificateStoreDetails.StorePath}' on server '{jobConfiguration.CertificateStoreDetails.ClientMachine}' with error: '{ex.Message}'"; _logger.LogWarning(failureMessage); return new JobResult @@ -121,20 +122,15 @@ public JobResult ProcessJob(InventoryJobConfiguration jobConfiguration, SubmitIn } } - public List QuerySQLCertificates(RemoteSettings settings, string storeName) + public List QuerySQLCertificates(RemoteSettings settings) { List Inventory = new(); - using (PSHelper ps = new(settings.Protocol, settings.Port, settings.IncludePortInSPN, settings.ClientMachineName, settings.ServerUserName, settings.ServerPassword)) + using (PSHelper ps = new(settings.Protocol, settings.Port, settings.IncludePortInSPN, settings.ClientMachineName, settings.ServerUserName, settings.ServerPassword, jeaEndpoint: settings.JEAEndpointName)) { ps.Initialize(); - var parameters = new Dictionary - { - { "StoreName", storeName } - }; - - results = ps.ExecutePowerShell("GET-KFSQLInventory", parameters); + results = ps.ExecutePowerShell("Get-KeyfactorSQLInventory"); // If there are certificates, deserialize the results and send them back to command if (results != null && results.Count > 0) diff --git a/IISU/ImplementedStoreTypes/WinSQL/Management.cs b/IISU/ImplementedStoreTypes/WinSQL/Management.cs index 2499dc5e..8569e782 100644 --- a/IISU/ImplementedStoreTypes/WinSQL/Management.cs +++ b/IISU/ImplementedStoreTypes/WinSQL/Management.cs @@ -20,6 +20,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Management.Automation; +using Keyfactor.Extensions.Orchestrator.WindowsCertStore.Models; using Keyfactor.Logging; using Keyfactor.Orchestrators.Common.Enums; using Keyfactor.Orchestrators.Extensions; @@ -91,7 +92,8 @@ public JobResult ProcessJob(ManagementJobConfiguration config) string protocol = jobProperties?.WinRmProtocol; string port = jobProperties?.WinRmPort; bool includePortInSPN = (bool)jobProperties?.SpnPortFlag; - + string jeaEndpoint = jobProperties?.JEAEndpointName ?? ""; + RestartSQLService = jobProperties.RestartService; if (config.JobProperties.ContainsKey("InstanceName")) @@ -104,7 +106,7 @@ public JobResult ProcessJob(ManagementJobConfiguration config) RenewalThumbprint = config.JobProperties["RenewalThumbprint"]?.ToString(); } - _psHelper = new(protocol, port, includePortInSPN, _clientMachineName, serverUserName, serverPassword); + _psHelper = new(protocol, port, includePortInSPN, _clientMachineName, serverUserName, serverPassword, jeaEndpoint: jeaEndpoint); _psHelper.Initialize(); @@ -121,11 +123,32 @@ public JobResult ProcessJob(ManagementJobConfiguration config) // Add Certificate to Cert Store try { - string newThumbprint = AddCertificate(certificateContents, privateKeyPassword, cryptoProvider); - _logger.LogTrace($"Completed adding the certificate to the store"); + ResultObject addResult = AddCertificate(certificateContents, privateKeyPassword, cryptoProvider); + _logger.LogTrace($"Completed adding the certificate to the store. Status={addResult.Status}, Code={addResult.Code}, Step={addResult.Step}"); + + if (!addResult.IsSuccess) + { + string detail = !string.IsNullOrEmpty(addResult.ErrorMessage) + ? addResult.ErrorMessage + : addResult.Message; + + string addFailureMessage = + $"Add certificate to store '{_storePath}' failed at step '{addResult.Step}' (code {addResult.Code}): {detail}"; + + _logger.LogError(addFailureMessage); + + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = _jobHistoryID, + FailureMessage = addFailureMessage + }; + } + + string newThumbprint = addResult.Thumbprint; // Bind Certificate to SQL Instance - if (newThumbprint != null) + if (!string.IsNullOrEmpty(newThumbprint)) { if (WinSqlBinding.BindSQLCertificate(_psHelper, SQLInstanceNames, newThumbprint, RenewalThumbprint, _storePath, RestartSQLService)) { @@ -147,6 +170,15 @@ public JobResult ProcessJob(ManagementJobConfiguration config) } } + else + { + complete = new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = _jobHistoryID, + FailureMessage = $"Add-KeyfactorCertificate reported Success but did not return a thumbprint. Unable to bind certificate to SQL Instance." + }; + } } catch (Exception ex) { @@ -206,7 +238,7 @@ public JobResult ProcessJob(ManagementJobConfiguration config) { _logger.LogTrace(LogHandler.FlattenException(ex)); - var failureMessage = $"Management job {config.OperationType} failed for Site '{config.CertificateStoreDetails.StorePath}' on server '{config.CertificateStoreDetails.ClientMachine}' with error: '{LogHandler.FlattenException(ex)}'"; + var failureMessage = $"Management job {config.OperationType} failed for Site '{config.CertificateStoreDetails.StorePath}' on server '{config.CertificateStoreDetails.ClientMachine}' with error: '{ex.Message}'"; _logger.LogWarning(failureMessage); return new JobResult @@ -234,8 +266,8 @@ public JobResult RemoveCertificate(string thumbprint) { "StorePath", _storePath } }; - _psHelper.ExecutePowerShell("Remove-KFCertificateFromStore", parameters); - _logger.LogTrace("Returned from executing PS function (Remove-KFCertificateFromStore)"); + _psHelper.ExecutePowerShell("Remove-KeyfactorCertificate", parameters); + _logger.LogTrace("Returned from executing PS function (Remove-KeyfactorCertificate)"); _psHelper.Terminate(); } @@ -261,13 +293,11 @@ public JobResult RemoveCertificate(string thumbprint) } } - public string AddCertificate(string certificateContents, string privateKeyPassword, string cryptoProvider) + public ResultObject AddCertificate(string certificateContents, string privateKeyPassword, string cryptoProvider) { try { - string newThumbprint = string.Empty; - - _logger.LogTrace("Attempting to execute PS function (Add-KFCertificateToStore)"); + _logger.LogTrace("Attempting to execute PS function (Add-KeyfactorCertificate)"); // Mandatory parameters var parameters = new Dictionary @@ -280,21 +310,18 @@ public string AddCertificate(string certificateContents, string privateKeyPasswo if (!string.IsNullOrEmpty(privateKeyPassword)) { parameters.Add("PrivateKeyPassword", privateKeyPassword); } if (!string.IsNullOrEmpty(cryptoProvider)) { parameters.Add("CryptoServiceProvider", cryptoProvider); } - _results = _psHelper.ExecutePowerShell("Add-KFCertificateToStore", parameters); - _logger.LogTrace("Returned from executing PS function (Add-KFCertificateToStore)"); + _results = _psHelper.ExecutePowerShell("Add-KeyfactorCertificate", parameters); + _logger.LogTrace("Returned from executing PS function (Add-KeyfactorCertificate)"); - // This should return the thumbprint of the certificate - if (_results != null && _results.Count > 0) - { - newThumbprint = _results[0].ToString(); - _logger.LogTrace($"Added certificate to store {_storePath}, returned with the thumbprint {newThumbprint}"); - } - else + ResultObject result = ResultObject.FromPSResults(_results); + _logger.LogTrace($"Add-KeyfactorCertificate returned Status={result.Status}, Code={result.Code}, Step={result.Step}, Thumbprint='{result.Thumbprint}'"); + + if (!result.IsSuccess && !string.IsNullOrEmpty(result.ErrorMessage)) { - _logger.LogTrace("No results were returned. There could have been an error while adding the certificate. Look in the trace logs for PowerShell information."); + _logger.LogWarning($"Add-KeyfactorCertificate error: {result.ErrorMessage}"); } - return newThumbprint; + return result; } catch (Exception ex) { diff --git a/IISU/ImplementedStoreTypes/WinSQL/WinSqlBinding.cs b/IISU/ImplementedStoreTypes/WinSQL/WinSqlBinding.cs index 673f7b1d..ff7d8a80 100644 --- a/IISU/ImplementedStoreTypes/WinSQL/WinSqlBinding.cs +++ b/IISU/ImplementedStoreTypes/WinSQL/WinSqlBinding.cs @@ -57,23 +57,23 @@ public static bool BindSQLCertificate(PSHelper psHelper, string SQLInstanceNames parameters["RestartService"] = restartSQLService; } - _results = psHelper.ExecutePowerShell("Bind-KFSqlCertificate", parameters); + _results = psHelper.ExecutePowerShell("New-KeyfactorSQLBinding", parameters); if (_results != null && _results.Count > 0) { // Extract value from PSObject and convert to bool if (bool.TryParse(_results[0]?.BaseObject?.ToString(), out bool result)) { - _logger.LogTrace($"PowerShell function Bind-KFSqlCertificate returned: {result}"); + _logger.LogTrace($"PowerShell function New-KeyfactorSQLBinding returned: {result}"); return result; } } - _logger.LogWarning("PowerShell function Bind-KFSqlCertificate did not return a valid boolean result."); + _logger.LogWarning("PowerShell function New-KeyfactorSQLBinding did not return a valid boolean result."); return false; } catch (Exception ex) { - _logger.LogError(ex, "Error executing PowerShell function: Bind-KFSqlCertificate"); + _logger.LogError(ex, "Error executing PowerShell function: New-KeyfactorSQLBinding"); return false; } } @@ -96,22 +96,22 @@ public static bool UnBindSQLCertificate(PSHelper psHelper, string SQLInstanceNam parameters["RestartService"] = restartSQLService; } - _results = psHelper.ExecutePowerShell("Unbind-KFSqlCertificate", parameters); + _results = psHelper.ExecutePowerShell("Remove-KeyfactorSQLCertificate", parameters); if (_results != null && _results.Count > 0) { if (bool.TryParse(_results[0]?.BaseObject?.ToString(), out bool result)) { - _logger.LogTrace($"PowerShell function Unbind-KFSqlCertificate returned: {result}"); + _logger.LogTrace($"PowerShell function Remove-KeyfactorSQLCertificate returned: {result}"); return result; } } - _logger.LogWarning("PowerShell function Unbind-KFSqlCertificate did not return a valid boolean result."); + _logger.LogWarning("PowerShell function Remove-KeyfactorSQLCertificate did not return a valid boolean result."); return false; } catch (Exception ex) { - _logger.LogError(ex, "Error occurred while unbinding certificate(s) from SQL instance(s)"); + _logger.LogError(ex, "Error executing PowerShell function: Remove-KeyfactorSQLCertificate"); return false; } } diff --git a/IISU/Models/JobProperties.cs b/IISU/Models/JobProperties.cs index a6ef63b0..d9d70747 100644 --- a/IISU/Models/JobProperties.cs +++ b/IISU/Models/JobProperties.cs @@ -51,5 +51,9 @@ public JobProperties() [JsonProperty("RestartService")] [DefaultValue(true)] public bool RestartService { get; set; } + + [JsonProperty("JEAEndpointName")] + [DefaultValue("")] + public string JEAEndpointName { get; set; } = ""; } } \ No newline at end of file diff --git a/IISU/Models/ResultObject.cs b/IISU/Models/ResultObject.cs index 6aa75aab..407e1957 100644 --- a/IISU/Models/ResultObject.cs +++ b/IISU/Models/ResultObject.cs @@ -1,6 +1,9 @@ using System; +using System.Collections; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; +using System.Management.Automation; using System.Text; using System.Threading.Tasks; @@ -8,11 +11,125 @@ namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.Models { public class ResultObject { + public const string StatusSuccess = "Success"; + public const string StatusWarning = "Warning"; + public const string StatusSkipped = "Skipped"; + public const string StatusError = "Error"; + public string Status { get; set; } public int Code { get; set; } public string Step { get; set; } public string Message { get; set; } public string ErrorMessage { get; set; } - public Dictionary Details { get; set; } + public Dictionary Details { get; set; } = new Dictionary(); + + /// + /// True when Status is Success (case-insensitive). + /// + public bool IsSuccess => + string.Equals(Status, StatusSuccess, StringComparison.OrdinalIgnoreCase); + + /// + /// Convenience accessor for the Thumbprint value written into Details by + /// scripts such as Add-KeyfactorCertificate. Returns an empty string when + /// missing. + /// + public string Thumbprint => + Details != null && Details.TryGetValue("Thumbprint", out var v) && v != null + ? v.ToString() + : string.Empty; + + /// + /// Builds a ResultObject from a PowerShell PSObject that follows the + /// New-KeyfactorResult contract (Status, Code, Step, Message, + /// ErrorMessage, Details). Missing properties become sensible defaults. + /// + public static ResultObject FromPSObject(PSObject psObject) + { + var result = new ResultObject + { + Status = StatusError, + Code = -1, + Step = string.Empty, + Message = string.Empty, + ErrorMessage = string.Empty, + Details = new Dictionary() + }; + + if (psObject == null) + { + result.ErrorMessage = "PowerShell returned a null result object."; + return result; + } + + result.Status = psObject.Properties["Status"]?.Value as string ?? StatusError; + result.Step = psObject.Properties["Step"]?.Value as string ?? string.Empty; + result.Message = psObject.Properties["Message"]?.Value as string ?? string.Empty; + result.ErrorMessage = psObject.Properties["ErrorMessage"]?.Value as string ?? string.Empty; + + var codeValue = psObject.Properties["Code"]?.Value; + if (codeValue is int intCode) + { + result.Code = intCode; + } + else if (codeValue != null && int.TryParse(codeValue.ToString(), out var parsed)) + { + result.Code = parsed; + } + + var detailsValue = psObject.Properties["Details"]?.Value; + if (detailsValue is PSObject detailsPs && detailsPs.BaseObject is IDictionary dictBase) + { + CopyDictionary(dictBase, result.Details); + } + else if (detailsValue is IDictionary directDict) + { + CopyDictionary(directDict, result.Details); + } + + return result; + } + + /// + /// Builds a ResultObject from the first item of a PowerShell result + /// collection. When the collection is null or empty, returns an Error + /// ResultObject explaining that no result was produced. + /// + public static ResultObject FromPSResults(Collection results) + { + if (results == null || results.Count == 0 || results[0] == null) + { + return new ResultObject + { + Status = StatusError, + Code = -1, + Step = "CatchAll", + ErrorMessage = "PowerShell script returned no results.", + Details = new Dictionary() + }; + } + + return FromPSObject(results[0]); + } + + private static void CopyDictionary(IDictionary source, Dictionary target) + { + foreach (DictionaryEntry entry in source) + { + var key = entry.Key?.ToString(); + if (string.IsNullOrEmpty(key)) + { + continue; + } + + var value = entry.Value; + if (value is PSObject psValue) + { + value = psValue.BaseObject ?? psValue; + } + + target[key] = value; + } + } } } diff --git a/IISU/PSHelper.cs b/IISU/PSHelper.cs index 31b73af9..0d447d4f 100644 --- a/IISU/PSHelper.cs +++ b/IISU/PSHelper.cs @@ -60,6 +60,8 @@ public class PSHelper : IDisposable private bool isLocalMachine; private bool isADFSStore = false; + private string jeaEndpoint = ""; + private bool useJea => !string.IsNullOrEmpty(jeaEndpoint); public bool IsLocalMachine { @@ -95,7 +97,7 @@ public PSHelper() _logger = LogHandler.GetClassLogger(); } - public PSHelper(string protocol, string port, bool useSPN, string clientMachineName, string serverUserName, string serverPassword, bool isADFSStore = false) + public PSHelper(string protocol, string port, bool useSPN, string clientMachineName, string serverUserName, string serverPassword, bool isADFSStore = false, string jeaEndpoint = "") { this.protocol = protocol.ToLower(); this.port = port; @@ -103,6 +105,8 @@ public PSHelper(string protocol, string port, bool useSPN, string clientMachineN ClientMachineName = clientMachineName; this.serverUserName = serverUserName; this.serverPassword = serverPassword; + this.isADFSStore = isADFSStore; + this.jeaEndpoint = jeaEndpoint; _logger = LogHandler.GetClassLogger(); _logger.LogTrace("Entered PSHelper Constructor"); @@ -110,8 +114,9 @@ public PSHelper(string protocol, string port, bool useSPN, string clientMachineN _logger.LogTrace($"Port: {this.port}"); _logger.LogTrace($"UseSPN: {this.useSPN}"); _logger.LogTrace($"ClientMachineName: {ClientMachineName}"); + _logger.LogTrace($"JEA Active: {this.useJea}"); + _logger.LogTrace($"JEAEndpoint: {this.jeaEndpoint}"); _logger.LogTrace("Constructor Completed"); - this.isADFSStore = isADFSStore; } public void Initialize() @@ -132,11 +137,20 @@ public void Initialize() _logger.LogDebug($"isLocalMachine flag set to: {isLocalMachine}"); _logger.LogDebug($"Protocol is set to: {protocol}"); - scriptFileLocation = FindScriptsDirectory(AppDomain.CurrentDomain.BaseDirectory, "PowerShellScripts"); - if (scriptFileLocation == null) { throw new Exception("Unable to find the accompanying PowerShell Script files,"); } + scriptFileLocation = FindScriptsDirectory(AppDomain.CurrentDomain.BaseDirectory, "PowerShell"); + if (scriptFileLocation == null) { throw new Exception("Unable to find the accompanying PowerShell Script files."); } _logger.LogTrace($"Script file located here: {scriptFileLocation}"); + if (isLocalMachine && useJea) + { + throw new Exception( + $"Ambiguous configuration: the store target is set to the local machine but JEA endpoint '{jeaEndpoint}' is also configured. " + + "JEA requires a remote WinRM connection and cannot be used with a local machine store. " + + "To resolve: either clear the JEA Endpoint Name to use a direct local connection, " + + "or replace 'LocalMachine'/'localhost' with the server's hostname or IP address to connect via JEA over WinRM."); + } + if (!isLocalMachine) { InitializeRemoteSession(); @@ -146,8 +160,15 @@ public void Initialize() InitializeLocalSession(); } - // Display Hosting information - string psInfo = @" + // Display hosting information. + // Skipped in JEA sessions: [System.Environment] and [System.Net.Dns] are blocked + // by ConstrainedLanguage and this script runs as untrusted inline code, not as a + // trusted module function. + // TODO: Create Get-KeyfactorHostInfo in Keyfactor.WinCert.Common so JEA sessions + // can also log host details at startup. + if (!useJea) + { + string psInfo = @" $psVersion = $PSVersionTable.PSVersion $os = [System.Environment]::OSVersion $hostName = [System.Net.Dns]::GetHostName() @@ -158,16 +179,20 @@ public void Initialize() HostName = $hostName } | ConvertTo-Json "; - var results = ExecutePowerShell(psInfo, isScript: true); - foreach (var result in results) - { - _logger.LogTrace($"{result}"); + var results = ExecutePowerShell(psInfo, isScript: true); + foreach (var result in results) + { + _logger.LogTrace($"{result}"); + } } } private void InitializeRemoteSession() { if (this.isADFSStore) throw new Exception("Remote ADFS stores are not supported."); + if (this.useJea && protocol == "ssh") throw new Exception("JEA is not supported over SSH. Use WinRM (http/https) for JEA."); + + double timeoutSeconds = 30.0; if (protocol == "ssh") { @@ -202,16 +227,21 @@ private void InitializeRemoteSession() _logger.LogTrace("Initializing WinRM connection"); try { - // Create the PSSessionOption object - var sessionOption = new PSSessionOption + PS.AddCommand("New-PSSession") + .AddParameter("ComputerName", ClientMachineName) + .AddParameter("Port", port); + + if (useSPN) { - IncludePortInSPN = useSPN - }; + var sessionOption = new PSSessionOption { IncludePortInSPN = true }; + PS.AddParameter("SessionOption", sessionOption); + } - PS.AddCommand("New-PSSession") - .AddParameter("ComputerName", ClientMachineName) - .AddParameter("Port", port) - .AddParameter("SessionOption", sessionOption); + if (useJea) + { + PS.AddParameter("ConfigurationName", jeaEndpoint); + _logger.LogDebug($"JEA enabled - connecting to endpoint: {jeaEndpoint}"); + } if (protocol == "https") { @@ -228,17 +258,25 @@ private void InitializeRemoteSession() } } - catch (Exception) + catch (Exception ex) { + _logger.LogError($"An error occurred while attempting to establish a remote connection.\n {ex.Message}"); throw new Exception("Problems establishing network credentials. Please check the User name and Password for the Certificate Store"); } } - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - _logger.LogTrace("Attempting to invoke PS-Session command on remote machine."); - _PSSession = PS.Invoke(); + + var asyncResult = PS.BeginInvoke(); + if (!asyncResult.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(timeoutSeconds))) + { + PS.Stop(); + throw new TimeoutException( + $"Could not establish a remote PowerShell session to '{machineName}:{port}' within {timeoutSeconds} seconds. " + + "Verify WinRM is reachable and the firewall allows the connection."); + } + _PSSession = new Collection(PS.EndInvoke(asyncResult)); if (_PSSession.Count > 0) { @@ -246,19 +284,74 @@ private void InitializeRemoteSession() PS.Commands.Clear(); _logger.LogTrace("PS-Session established"); + if (!useJea) + { + PS.AddCommand("Invoke-Command") + .AddParameter("Session", _PSSession) + .AddParameter("ScriptBlock", ScriptBlock.Create(LoadAllScripts(scriptFileLocation))); + + PS.Invoke(); + CheckErrors(); + _logger.LogTrace("Scripts loaded into remote session successfully."); + } + else + { + _logger.LogDebug($"JEA session active on endpoint '{jeaEndpoint}' - skipping script injection, functions are pre-registered."); + + // Pre-flight: verify Keyfactor modules are installed on the JEA endpoint. + PS.AddCommand("Invoke-Command") + .AddParameter("Session", _PSSession) + .AddParameter("ScriptBlock", ScriptBlock.Create("[bool](Get-Command 'New-KeyfactorResult' -ErrorAction SilentlyContinue)")); + var preFlightResults = PS.Invoke(); + PS.Commands.Clear(); + + bool modulesInstalled = preFlightResults != null && + preFlightResults.Count > 0 && + preFlightResults[0]?.BaseObject is bool preFlightBool && + preFlightBool; + + if (!modulesInstalled) + { + throw new Exception( + $"JEA endpoint '{jeaEndpoint}' is reachable but Keyfactor modules are not installed. " + + "Install Keyfactor.WinCert.Common (and any required store-type modules) under " + + "'C:\\Program Files\\WindowsPowerShell\\Modules\\' on the target machine, " + + "re-register the JEA session configuration, and restart WinRM."); + } + + _logger.LogDebug("JEA pre-flight passed: Keyfactor modules are installed on the endpoint."); + } + + // Set $InformationPreference globally so Write-Information output is forwarded + // back to PSHelper's Information stream listener for all function calls in this session. PS.AddCommand("Invoke-Command") .AddParameter("Session", _PSSession) - .AddParameter("ScriptBlock", ScriptBlock.Create(LoadAllScripts(scriptFileLocation))); - - var results = PS.Invoke(); - CheckErrors(); - _logger.LogTrace("Script loaded into remote session successfully."); + .AddParameter("ScriptBlock", ScriptBlock.Create("$global:InformationPreference = 'Continue'")); + PS.Invoke(); + PS.Commands.Clear(); + _logger.LogTrace("Remote session preference variables configured."); } else { - throw new Exception("Failed to create the remote PowerShell Session."); - } + // Attempt to extract error details from the PowerShell error stream + var errorDetails = new StringBuilder(); + if (PS.HadErrors && PS.Streams.Error.Count > 0) + { + foreach (var error in PS.Streams.Error) + { + if (error == null) continue; + errorDetails.AppendLine(error.Exception?.Message ?? error.ToString()); + } + } + + var errorSummary = errorDetails.Length > 0 + ? $" Errors:{Environment.NewLine}{errorDetails.ToString().TrimEnd()}" + : " No errors were recorded in the PowerShell error stream."; + + throw new Exception( + $"Failed to create the remote PowerShell session to '{machineName}'. {errorSummary}"); + } } private void InitializeLocalSession() @@ -273,6 +366,13 @@ private void InitializeLocalSession() _logger.LogTrace("Setting Execution Policy to Unrestricted"); SetExecutionPolicyUnrestricted(); + // Set $InformationPreference globally so Write-Information output is forwarded + // back to PSHelper's Information stream listener for all function calls in this session. + PS.AddScript("$global:InformationPreference = 'Continue'"); + PS.Invoke(); + PS.Commands.Clear(); + _logger.LogTrace("Local session preference variables configured."); + // Check if ADFS module is available (only needed for ADFS stores) bool adfsModuleImported = false; if (this.isADFSStore) @@ -280,40 +380,61 @@ private void InitializeLocalSession() adfsModuleImported = ImportAdfsModule(); } - // Load all scripts - _logger.LogTrace("Loading PowerShell scripts"); - var scriptFiles = GetScriptFiles(scriptFileLocation); + // Import all module .psm1 files alphabetically (ensures Common loads before IIS, etc.) + _logger.LogTrace("Loading PowerShell modules"); + var moduleDirs = Directory.GetDirectories(scriptFileLocation) + .OrderBy(d => d) + .ToList(); - foreach (var scriptFile in scriptFiles) + foreach (var moduleDir in moduleDirs) { - var fileName = Path.GetFileName(scriptFile); - bool isAdfsScript = fileName.IndexOf("adfs", StringComparison.OrdinalIgnoreCase) >= 0; + var moduleName = Path.GetFileName(moduleDir); + var modulePsm1 = Path.Combine(moduleDir, $"{moduleName}.psm1"); - // Decide whether to load this script - if (isAdfsScript) + if (!File.Exists(modulePsm1)) { - if (this.isADFSStore) - { - if (!adfsModuleImported) - { - _logger.LogWarning($"Skipping ADFS script '{fileName}' - ADFS module not available"); - continue; - } + _logger.LogTrace($"No .psm1 found in {moduleName}, skipping"); + continue; + } - _logger.LogTrace($"Loading ADFS script: {fileName}"); - } - else - { - _logger.LogTrace($"Skipping ADFS script '{fileName}' - not an ADFS store"); - continue; - } + _logger.LogTrace($"Importing module: {modulePsm1}"); + PS.AddCommand("Import-Module") + .AddParameter("Name", modulePsm1) + .AddParameter("Force"); + PS.Invoke(); + + if (PS.HadErrors) + { + _logger.LogError($"Errors importing {moduleName} module:"); + foreach (var error in PS.Streams.Error) + _logger.LogError($" {error}"); + PS.Streams.Error.Clear(); } else { - _logger.LogTrace($"Loading script: {fileName}"); + _logger.LogInformation($"{moduleName} module imported successfully."); + } + PS.Commands.Clear(); + } + + // Load flat legacy .ps1 scripts from the PowerShell root directory + var rootScripts = Directory.GetFiles(scriptFileLocation, "*.ps1") + .Where(f => !f.EndsWith(".example", StringComparison.OrdinalIgnoreCase)) + .OrderBy(f => f) + .ToList(); + + foreach (var scriptFile in rootScripts) + { + var fileName = Path.GetFileName(scriptFile); + bool isAdfsScript = fileName.IndexOf("adfs", StringComparison.OrdinalIgnoreCase) >= 0; + + if (isAdfsScript && (!this.isADFSStore || !adfsModuleImported)) + { + _logger.LogTrace($"Skipping ADFS script '{fileName}'"); + continue; } - // Load the script + _logger.LogTrace($"Loading script: {fileName}"); try { PS.AddScript($". '{scriptFile}'"); @@ -323,16 +444,14 @@ private void InitializeLocalSession() { _logger.LogError($"Errors loading script '{fileName}':"); foreach (var error in PS.Streams.Error) - { _logger.LogError($" {error}"); - } + PS.Streams.Error.Clear(); } else { - _logger.LogTrace($" ✓ Successfully loaded {fileName}"); + _logger.LogTrace($" Successfully loaded {fileName}"); } - CheckErrors(); PS.Commands.Clear(); } catch (Exception ex) @@ -461,94 +580,6 @@ private void SetExecutionPolicyUnrestricted() PS.Commands.Clear(); } } - private void InitializeLocalSessionOLD2() - { - _logger.LogTrace("Creating out-of-process Powershell Runspace."); - PowerShellProcessInstance psInstance = new PowerShellProcessInstance(new Version(5, 1), null, null, false); - Runspace rs = RunspaceFactory.CreateOutOfProcessRunspace(new TypeTable(Array.Empty()), psInstance); - rs.Open(); - PS.Runspace = rs; - - // Set execution policy - ignore informational messages - _logger.LogTrace("Setting Execution Policy to Unrestricted"); - SetExecutionPolicyUnrestricted(); - - // Load all scripts - _logger.LogTrace("Loading PowerShell scripts"); - var scriptFiles = GetScriptFiles(scriptFileLocation); - _logger.LogInformation($"Found {scriptFiles.Count} script file(s) to load"); - - foreach (var scriptFile in scriptFiles) - { - var fileName = Path.GetFileName(scriptFile); - - if (this.isADFSStore && fileName.ToLower().Contains("adfs")) - { - // Import ADFS module (CRITICAL!) - _logger.LogTrace("Importing ADFS module"); - try - { - PS.AddCommand("Import-Module").AddParameter("Name", "ADFS"); - var moduleResult = PS.Invoke(); - - if (PS.HadErrors) - { - _logger.LogWarning("ADFS module import had errors (may not be available on this machine)"); - foreach (var error in PS.Streams.Error) - { - _logger.LogWarning($" {error}"); - } - PS.Streams.Error.Clear(); - } - else - { - _logger.LogInformation("ADFS module imported successfully"); - } - - PS.Commands.Clear(); - } - catch (Exception ex) - { - _logger.LogWarning($"Could not import ADFS module: {ex.Message}"); - _logger.LogWarning("ADFS cmdlets may not be available"); - } - - _logger.LogTrace($"Skipping non-ADFS script: {fileName} for ADFS store type"); - continue; - } - - _logger.LogTrace($"Loading script: {fileName}"); - - PS.AddScript($". '{scriptFile}'"); - PS.Invoke(); - CheckErrors(); // Check errors for actual scripts - PS.Commands.Clear(); - } - - _logger.LogInformation("Local PowerShell session initialized successfully"); - } - private void InitializeLocalSessionOLD() - { - _logger.LogTrace("Creating out-of-process Powershell Runspace."); - PowerShellProcessInstance psInstance = new PowerShellProcessInstance(new Version(5, 1), null, null, false); - Runspace rs = RunspaceFactory.CreateOutOfProcessRunspace(new TypeTable(Array.Empty()), psInstance); - rs.Open(); - PS.Runspace = rs; - - _logger.LogTrace("Setting Execution Policy to Unrestricted"); - PS.AddScript("Set-ExecutionPolicy Unrestricted -Scope Process -Force"); - PS.Invoke(); // Ensure the script is invoked and loaded - CheckErrors(); - - PS.Commands.Clear(); // Clear commands after loading functions - - _logger.LogTrace("Setting script file into memory"); - PS.AddScript(". '" + scriptFileLocation + "'"); - PS.Invoke(); // Ensure the script is invoked and loaded - CheckErrors(); - - PS.Commands.Clear(); // Clear commands after loading functions - } public void Terminate() { @@ -735,14 +766,12 @@ public Collection ExecutePowerShellScript(string script) } else { - // For remote execution, use Invoke-Command - var scriptBlock = isScript - ? ScriptBlock.Create(commandOrScript) // Use the script as a ScriptBlock - : ScriptBlock.Create($"& {{ {commandOrScript} }}"); // Wrap commands in ScriptBlock - + // For remote execution use Invoke-Command. The command/script becomes the + // scriptblock body directly — no & { } child-scope wrapper, which can + // prevent JEA ConstrainedLanguage sessions from seeing visible functions. PS.AddCommand("Invoke-Command") .AddParameter("Session", _PSSession) - .AddParameter("ScriptBlock", scriptBlock); + .AddParameter("ScriptBlock", ScriptBlock.Create(commandOrScript)); } // Add Parameters if provided @@ -757,12 +786,11 @@ public Collection ExecutePowerShellScript(string script) } else { - // Remote execution: Use ArgumentList for parameters - var paramBlock = string.Join(", ", parameters.Select(p => - { - string typeName = p.Value?.GetType().Name ?? "object"; - return $"[{typeName}] ${p.Key}"; - })); + // Remote execution: Use ArgumentList for parameters. + // No type annotations in the param block — they are unnecessary for + // correct ArgumentList binding and some CLR types (arrays, nulls) produce + // names that break ConstrainedLanguage JEA sessions. + var paramBlock = string.Join(", ", parameters.Keys.Select(k => $"${k}")); var paramUsage = string.Join(" ", parameters.Select(p => $"-{p.Key} ${p.Key}")); @@ -858,8 +886,11 @@ public static void ProcessPowerShellScriptEvent(object? sender, DataAddedEventAr var infoMessages = sender as PSDataCollection; if (infoMessages != null) { - var infoMessage = infoMessages[e.Index]; - _logger.LogInformation($"INFO: {infoMessage.MessageData.ToString()}"); + var msg = infoMessages[e.Index].MessageData?.ToString() ?? string.Empty; + if (msg.StartsWith("[VERBOSE] ", StringComparison.Ordinal)) + _logger.LogTrace($"{msg[10..]}"); + else + _logger.LogInformation($"{msg}"); } break; @@ -868,7 +899,7 @@ public static void ProcessPowerShellScriptEvent(object? sender, DataAddedEventAr if (warningMessages != null) { var warningMessage = warningMessages[e.Index]; - _logger.LogWarning($"WARN: {warningMessage.Message}"); + _logger.LogWarning($"{warningMessage.Message}"); } break; default: @@ -961,6 +992,46 @@ private static string formatPrivateKey(string privateKey) return privateKey.Replace(header, "HEADER").Replace(footer, "FOOTER").Replace(" ", Environment.NewLine).Replace("HEADER", header).Replace("FOOTER", footer) + Environment.NewLine; } + private static List GetOrderedScriptFiles(string scriptsDirectory) + { + /* + * Returns .ps1 files in dependency order for remote non-JEA sessions: + * 1. Flat .ps1 files in the root PowerShell directory (legacy scripts) + * 2. For each module subdirectory: Private/*.ps1 first, then Public/*.ps1 + */ + var ordered = new List(); + + // Root flat scripts (WinCertScripts.ps1, WinADFSScripts.ps1, etc.) + ordered.AddRange( + Directory.GetFiles(scriptsDirectory, "*.ps1") + .Where(f => !f.EndsWith(".example", StringComparison.OrdinalIgnoreCase)) + .OrderBy(f => f)); + + // Module subdirectories: Private before Public so helpers are defined first + foreach (var subDir in Directory.GetDirectories(scriptsDirectory).OrderBy(d => d)) + { + var privatePath = Path.Combine(subDir, "Private"); + if (Directory.Exists(privatePath)) + { + ordered.AddRange( + Directory.GetFiles(privatePath, "*.ps1", SearchOption.AllDirectories) + .Where(f => !f.EndsWith(".example", StringComparison.OrdinalIgnoreCase)) + .OrderBy(f => f)); + } + + var publicPath = Path.Combine(subDir, "Public"); + if (Directory.Exists(publicPath)) + { + ordered.AddRange( + Directory.GetFiles(publicPath, "*.ps1", SearchOption.AllDirectories) + .Where(f => !f.EndsWith(".example", StringComparison.OrdinalIgnoreCase)) + .OrderBy(f => f)); + } + } + + return ordered; + } + public static string FindScriptsDirectory(string rootDirectory, string directoryName) { /* @@ -1001,53 +1072,7 @@ public static string FindScriptsDirectory(string rootDirectory, string directory return null; } - private List GetScriptFiles(string scriptFileLocation) - { - /* - * Gets all .ps1 files from the scripts directory - * - * scriptFileLocation can be: - * - A file path: C:\MyApp\Scripts\WinCertScripts.ps1 - * - A directory path: C:\MyApp\Scripts - * - * Returns: List of full file paths to all .ps1 files - */ - - // Determine the scripts directory - string scriptsDirectory; - - if (File.Exists(scriptFileLocation)) - { - // It's a file path - get the directory - scriptsDirectory = Path.GetDirectoryName(scriptFileLocation); - _logger.LogTrace($"Script file provided: {scriptFileLocation}"); - _logger.LogTrace($"Using directory: {scriptsDirectory}"); - } - else if (Directory.Exists(scriptFileLocation)) - { - // It's already a directory - scriptsDirectory = scriptFileLocation; - _logger.LogTrace($"Script directory provided: {scriptFileLocation}"); - } - else - { - throw new DirectoryNotFoundException($"Scripts location not found: {scriptFileLocation}"); - } - - // Get all .ps1 files, excluding .example files - var scriptFiles = Directory.GetFiles(scriptsDirectory, "*.ps1") - .Where(f => !f.EndsWith(".example", StringComparison.OrdinalIgnoreCase)) - .ToList(); - - if (scriptFiles.Count == 0) - { - throw new FileNotFoundException($"No .ps1 files found in: {scriptsDirectory}"); - } - - _logger.LogTrace($"Found {scriptFiles.Count} script file(s): {string.Join(", ", scriptFiles.Select(Path.GetFileName))}"); - return scriptFiles; - } public static string LoadScript(string scriptFileName) { _logger.LogTrace($"Attempting to load script {scriptFileName}"); @@ -1094,8 +1119,8 @@ public string LoadAllScripts(string scriptFileLocation) _logger.LogInformation($"Loading scripts from: {scriptsDirectory}"); - // Load all .ps1 files from the scripts directory - var scriptFiles = Directory.GetFiles(scriptsDirectory, "*.ps1").ToList(); + // Load scripts in dependency order: root files first, then module Private then Public + var scriptFiles = GetOrderedScriptFiles(scriptsDirectory); if (scriptFiles.Count == 0) { diff --git a/IISU/PowerShell/Build/KeyfactorWinCert.pssc b/IISU/PowerShell/Build/KeyfactorWinCert.pssc new file mode 100644 index 00000000..1af65bf5 --- /dev/null +++ b/IISU/PowerShell/Build/KeyfactorWinCert.pssc @@ -0,0 +1,253 @@ +# +# KeyfactorWinCert.pssc +# JEA Session Configuration file for Keyfactor Windows Certificate Store management +# ============================================================ +# OVERVIEW +# ============================================================ +# +# This file defines a JEA (Just Enough Administration) endpoint that controls: +# +# 1. WHO can connect -- defined in RoleDefinitions (and the Permission +# list set when registering with Register-PSSessionConfiguration) +# 2. WHAT they can do -- defined by the RoleCapabilities (.psrc files) +# referenced inside RoleDefinitions +# 3. WHAT IDENTITY executes -- defined by GroupManagedServiceAccount (gMSA) +# or RunAsVirtualAccount +# +# The recommended production pattern uses TWO separate gMSA accounts: +# +# LOW-PRIV gMSA (e.g. AD\KeyfactorSVC$) +# - The identity your Windows Service or orchestrator runs as. +# - Used only to AUTHENTICATE to WinRM on the remote machine. +# - Needs no special rights beyond WinRM access. +# - Listed in RoleDefinitions as the connecting identity. +# - Password is managed automatically by Active Directory. +# - Never needs to be entered or stored anywhere. +# +# HIGH-PRIV gMSA (e.g. AD\KeyfactorJEA$) +# - The identity that commands EXECUTE AS inside the JEA session. +# - Granted only the specific rights needed (e.g. IIS, cert store, SQL). +# - Set via GroupManagedServiceAccount -- never exposed over the network. +# - Password is managed automatically by Active Directory. +# - Cannot be used to open an interactive session directly. +# +# Visual flow: +# +# Windows Service WinRM / JEA Remote Resource +# (AD\KeyfactorSVC$) --> Endpoint (IIS, Cert Store) +# | 'keyfactor.wincert' ^ +# | | | +# | authenticates | RoleDefinitions | +# | via Kerberos | match KeyfactorSVC$ | +# | | | +# | | session runs AS | +# | | AD\KeyfactorJEA$ --------+ +# | | (has IIS/cert rights) +# +------------------------+ +# No password needed -- +# Kerberos handles it +# +# ============================================================ +# PREREQUISITES (run once per target machine as Administrator) +# ============================================================ +# +# 1. Install the Keyfactor modules to the system module path. +# Modules placed here are treated as FULLY TRUSTED by PowerShell, +# which is required for them to work under ConstrainedLanguage mode. +# +# $base = 'C:\Program Files\WindowsPowerShell\Modules' +# Copy-Item -Path '.\Keyfactor.WinCert.Common' -Destination "$base\Keyfactor.WinCert.Common" -Recurse -Force +# Copy-Item -Path '.\Keyfactor.WinCert.IIS' -Destination "$base\Keyfactor.WinCert.IIS" -Recurse -Force +# Copy-Item -Path '.\Keyfactor.WinCert.SQL' -Destination "$base\Keyfactor.WinCert.SQL" -Recurse -Force +# +# (Only install the modules needed for the store types you use on this endpoint.) +# +# 2. Install the gMSA accounts on this machine (PRODUCTION only). +# This must be done on EACH machine that will host the JEA endpoint. +# The machine must be in the gMSA's PrincipalsAllowedToRetrieveManagedPassword group in AD. +# The gMSA accounts are only shown for example -- your AD administrator may have named them differently. +# +# # Install the HIGH-PRIV RunAs gMSA (executes the commands) +# Install-ADServiceAccount -Identity 'KeyfactorJEA$' +# Test-ADServiceAccount -Identity 'KeyfactorJEA$' # Must return True +# +# # Install the LOW-PRIV connecting gMSA (authenticates to WinRM) +# Install-ADServiceAccount -Identity 'KeyfactorSVC$' +# Test-ADServiceAccount -Identity 'KeyfactorSVC$' # Must return True +# +# If Test-ADServiceAccount returns False, the machine has not been added to +# PrincipalsAllowedToRetrieveManagedPassword in AD. Contact your AD administrator. +# +# 3. (Optional) Create the transcript directory for audit logging. +# Only required if TranscriptDirectory is uncommented in the session configuration below. +# +# New-Item -ItemType Directory -Path 'C:\ProgramData\Keyfactor\JEA\Transcripts' -Force +# +# 4. Register this session configuration (required once; re-run after any change): +# +# Register-PSSessionConfiguration ` +# -Name 'keyfactor.wincert' ` +# -Path 'C:\path\to\KeyfactorWinCert.pssc' ` +# -Force +# Restart-Service WinRM +# +# The Name parameter is the endpoint name that the cert store will specify when connecting (e.g. New-PSSession -ConfigurationName 'keyfactor.wincert'). +# +# 5. Verify the endpoint is registered: +# +# Get-PSSessionConfiguration | Where-Object Name -eq 'keyfactor.wincert' +# +# 6. To update or remove the endpoint: +# +# Unregister-PSSessionConfiguration -Name 'keyfactor.wincert' +# Restart-Service WinRM +# +# ============================================================ +# TESTING THE ENDPOINT +# ============================================================ +# +# # Connect and list available commands +# $s = New-PSSession -ComputerName ` +# -ConfigurationName 'keyfactor.wincert' ` +# -Credential (Get-Credential) +# Invoke-Command -Session $s -ScriptBlock { Get-Command } +# +# # Test WinCert inventory +# Invoke-Command -Session $s -ScriptBlock { Get-KeyfactorCertificates -StoreName 'My' } +# +# # Test IIS inventory (requires Keyfactor.WinCert.IIS module installed) +# Invoke-Command -Session $s -ScriptBlock { Get-KeyfactorIISBoundCertificates } +# +# Remove-PSSession $s +# +# ============================================================ +@{ + SchemaVersion = '2.0.0.0' + GUID = '4d1866a4-858a-4c60-866c-b46f546dc014' + Author = 'Keyfactor' + Description = 'JEA session configuration for Keyfactor Windows Certificate Store management' + + # RestrictedRemoteServer limits the session to only what is explicitly permitted. + SessionType = 'RestrictedRemoteServer' + + # ConstrainedLanguage allows param() blocks and variable usage in caller scriptblocks + # (required for PSHelper's parameter-passing pattern). Module functions installed under + # C:\Program Files\WindowsPowerShell\Modules\ run as FULLY TRUSTED regardless of this setting. + LanguageMode = 'ConstrainedLanguage' + + # RUN-AS ACCOUNT + # ------------------------------------------------------------------ + # This defines WHAT IDENTITY executes commands inside the session. + # The connecting user's own rights are NOT used -- all commands run + # as this account regardless of who connected. + # + # CHOOSE ONE of the following options: + # + # OPTION A -- Group Managed Service Account (PRODUCTION RECOMMENDED) + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Use a gMSA that has been granted only the rights needed on this machine. + # AD manages the password automatically -- no password rotation or storage needed. + # The gMSA must be installed on this machine first (see PREREQUISITES, Step 2). + # + # IMPORTANT: Do NOT include the trailing '$' here. + # Windows automatically appends '$' when registering the configuration. + # If you include '$' yourself, Windows will register it as 'AD\KeyfactorJEA$$' + # and all connection attempts will fail. + # + # Correct: GroupManagedServiceAccount = 'AD\KeyfactorJEA' <-- no $ + # Incorrect: GroupManagedServiceAccount = 'AD\KeyfactorJEA$' <-- breaks registration + # + # Uncomment and set for PRODUCTION: + # + GroupManagedServiceAccount = 'AD\KeyfactorJEA' + # + # Replace 'AD' with your actual NetBIOS domain name and the Group Managed Account that has been granted the necessary permissions (e.g. DOMAIN\{gMSA}). + # + # + # OPTION B -- Virtual Account (DEVELOPMENT / TESTING ONLY) + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Windows auto-creates a temporary local admin account for the duration + # of the session. No AD account or gMSA setup required. + # NOT recommended for production -- the virtual account has local admin + # rights and cannot be granted specific AD resource permissions. + # + # Comment out GroupManagedServiceAccount above and uncomment for DEV/TEST: + # + # RunAsVirtualAccount = $true + + # --- Transcript Logging (Optional) --- + # Uncomment TranscriptDirectory to record a full transcript of every JEA session. + # Useful during initial testing and for ongoing security audits. + # The directory must exist before registering the configuration -- create it with: + # New-Item -ItemType Directory -Path 'C:\ProgramData\Keyfactor\JEA\Transcripts' -Force + # When commented out, no transcripts are written. + # + # TranscriptDirectory = 'C:\ProgramData\Keyfactor\JEA\Transcripts' + + # --- Role Definitions --- + # Maps the CONNECTING IDENTITY to one or more RoleCapabilities. + # This controls WHO is allowed in and WHAT commands they can run. + # + # Key points: + # - The connecting identity is authenticated by WinRM BEFORE this mapping is checked. + # - Multiple capabilities are merged -- the session exposes all their VisibleFunctions. + # - An identity not listed here will be denied access even if WinRM lets them connect. + # - Group membership is evaluated at connection time (e.g. BUILTIN\Administrators + # matches any local admin or domain admin on this machine). + # - gMSA accounts MUST include the trailing '$' in the account name. + # + # Available RoleCapabilities (add only modules installed on this machine): + # 'Keyfactor.WinCert.Common' -- core certificate store operations (always include) + # 'Keyfactor.WinCert.IIS' -- IIS binding management + # 'Keyfactor.WinCert.SQL' -- SQL certificate management + # + # gMSA CONNECTING IDENTITY PATTERN + # ------------------------------------------------------------------ + # When a Windows Service runs as a gMSA (e.g. AD\KeyfactorSVC$), it connects + # to WinRM using Kerberos automatically -- no credentials are passed in code. + # To allow this, add the LOW-PRIV gMSA to RoleDefinitions as shown below. + # + # The low-priv gMSA (KeyfactorSVC$) authenticates the connection. + # The high-priv gMSA (KeyfactorJEA$, set via GroupManagedServiceAccount above) + # executes the commands. These are intentionally two different accounts. + # + # To add a gMSA as a connecting identity: + # 'DOMAIN\gMSAName$' = @{ RoleCapabilities = '...' } + # + # To add an AD security group (recommended over individual user accounts): + # 'DOMAIN\GroupName' = @{ RoleCapabilities = '...' } + # + # Example of Role Definitions: + # WinCert + IIS: @{ RoleCapabilities = 'Keyfactor.WinCert.Common', 'Keyfactor.WinCert.IIS' } + # WinCert + SQL: @{ RoleCapabilities = 'Keyfactor.WinCert.Common', 'Keyfactor.WinCert.SQL' } + # WinCert + IIS + SQL: @{ RoleCapabilities = 'Keyfactor.WinCert.Common', 'Keyfactor.WinCert.IIS', 'Keyfactor.WinCert.SQL' } + # WinCert only: @{ RoleCapabilities = 'Keyfactor.WinCert.Common' } + RoleDefinitions = @{ + # LOW-PRIV SERVICE gMSA -- the connecting identity used by the Windows Service. + # This account authenticates via Kerberos; no password is ever stored or passed. + # Once connected, commands execute as AD\KeyfactorJEA$ (the RunAs gMSA above), + # not as KeyfactorSVC$ -- so KeyfactorSVC$ itself needs no elevated rights. + # Replace 'AD' with your NetBIOS domain name. The trailing '$' is required. + 'AD\KeyfactorSVC$' = @{ RoleCapabilities = 'Keyfactor.WinCert.Common', 'Keyfactor.WinCert.IIS', 'Keyfactor.WinCert.SQL' } + + # AD SECURITY GROUP (recommended for human operator access). + # Using a group rather than individual accounts means you manage membership + # in AD without having to re-register the JEA endpoint. + # Uncomment and set to your group when ready: + # 'AD\KeyfactorOperators' = @{ RoleCapabilities = 'Keyfactor.WinCert.Common', 'Keyfactor.WinCert.IIS' } + + # LOCAL/DOMAIN ADMINISTRATORS -- full access for break-glass and troubleshooting. + # BUILTIN\Administrators matches any member of the local Administrators group, + # which includes Domain Admins on domain-joined machines. Be aware that domain + # admins will connect through this rule and execute as KeyfactorJEA$, which + # means their individual identity is not directly reflected in resource audit logs. + # Consider removing this entry and using a dedicated AD group in production + # to enforce individual accountability. + # 'BUILTIN\Administrators' = @{ RoleCapabilities = 'Keyfactor.WinCert.Common', 'Keyfactor.WinCert.IIS', 'Keyfactor.WinCert.SQL' } + + # INDIVIDUAL USER ACCOUNTS -- useful during initial setup and testing. + # For production, prefer AD security groups over individual accounts to reduce + # the maintenance burden of updating this file when personnel change. + # 'AD\UserAccount' = @{ RoleCapabilities = 'Keyfactor.WinCert.Common', 'Keyfactor.WinCert.IIS' } + } +} diff --git a/IISU/PowerShell/Keyfactor.WinCert.Common/Keyfactor.WinCert.Common.psm1 b/IISU/PowerShell/Keyfactor.WinCert.Common/Keyfactor.WinCert.Common.psm1 new file mode 100644 index 00000000..7c5fb437 --- /dev/null +++ b/IISU/PowerShell/Keyfactor.WinCert.Common/Keyfactor.WinCert.Common.psm1 @@ -0,0 +1,39 @@ +# Keyfactor.WinCert.Common.psm1 +# +# Static explicit dot-sourcing is used instead of Get-ChildItem/Select-Object discovery. +# This avoids a dependency on proxy-restricted cmdlets during JEA session initialization. +# (In RestrictedRemoteServer, Select-Object -ExpandProperty is not available in the proxy.) + +# Private helpers must be loaded before the public functions that call them. +. "$PSScriptRoot\Private\Get-CertificateSAN.ps1" +. "$PSScriptRoot\Private\Get-CertificateCSP.ps1" +. "$PSScriptRoot\Private\Get-CryptoProviders.ps1" +. "$PSScriptRoot\Private\Test-CryptoServiceProvider.ps1" +. "$PSScriptRoot\Private\Validate-CryptoProvider.ps1" +. "$PSScriptRoot\Private\Convert-DNSSubject.ps1" + +# Public functions +. "$PSScriptRoot\Public\New-KeyfactorResult.ps1" +. "$PSScriptRoot\Public\Get-KeyfactorCertificates.ps1" +. "$PSScriptRoot\Public\Add-KeyfactorCertificate.ps1" +. "$PSScriptRoot\Public\Remove-KeyfactorCertificate.ps1" +. "$PSScriptRoot\Public\New-KeyfactorODKGEnrollment.ps1" +. "$PSScriptRoot\Public\Import-KeyfactorSignedCertificate.ps1" +. "$PSScriptRoot\Public\Get-KeyfactorDiagnostics.ps1" + + +# Export only public functions for non-JEA use. +# In JEA sessions, VisibleFunctions in the .psrc is the actual access control mechanism. +Export-ModuleMember -Function @( + # Public functions + 'New-KeyfactorResult', + 'Get-KeyfactorCertificates', + 'Add-KeyfactorCertificate', + 'Remove-KeyfactorCertificate', + 'New-KeyfactorODKGEnrollment', + 'Import-KeyfactorSignedCertificate', + 'Get-KeyfactorDiagnostics', + # Shared certificate inspection utilities — exported so other modules (e.g. IIS) can call them + 'Get-CertificateCSP', + 'Get-CertificateSAN' +) diff --git a/IISU/PowerShell/Keyfactor.WinCert.Common/Private/Convert-DNSSubject.ps1 b/IISU/PowerShell/Keyfactor.WinCert.Common/Private/Convert-DNSSubject.ps1 new file mode 100644 index 00000000..c2089b7a --- /dev/null +++ b/IISU/PowerShell/Keyfactor.WinCert.Common/Private/Convert-DNSSubject.ps1 @@ -0,0 +1,103 @@ +function Convert-DNSSubject { + <# + .SYNOPSIS + Parses a Distinguished Name (DN) subject string and properly quotes RDN values containing escaped commas. + + .DESCRIPTION + This function takes a DN subject string and parses the Relative Distinguished Name (RDN) components, + adding proper quotes around values that contain escaped commas and escaping quotes for use in + PowerShell here-strings. Only RDN values with escaped commas get quoted. + + .PARAMETER Subject + The DN subject string to parse (e.g., "CN=Keyfactor,O=Keyfactor\, Inc") + + .EXAMPLE + Convert-DNSSubject -Subject "CN=Keyfactor,O=Keyfactor\, Inc" + Returns: CN=Keyfactor,O=""Keyfactor, Inc"" + + .EXAMPLE + Convert-DNSSubject -Subject "CN=Test User,O=Company\, LLC,OU=IT Department\, Security" + Returns: CN=Test User,O=""Company, LLC"",OU=""IT Department, Security"" + #> + + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string]$Subject + ) + + # Initialize variables + $parsedComponents = @() + $currentComponent = "" + $i = 0 + + # Convert string to character array for easier parsing + $chars = $Subject.ToCharArray() + + while ($i -lt $chars.Length) { + $char = $chars[$i] + + # Check if we hit a comma + if ($char -eq ',') { + # Look back to see if it's escaped + $isEscaped = $false + if ($i -gt 0 -and $chars[$i-1] -eq '\') { + $isEscaped = $true + } + + if ($isEscaped) { + # This is an escaped comma, add it to current component + $currentComponent += $char + } else { + # This is a separator comma, finish current component + if ($currentComponent.Trim() -ne "") { + $parsedComponents += $currentComponent.Trim() + $currentComponent = "" + } + } + } else { + # Regular character, add to current component + $currentComponent += $char + } + + $i++ + } + + # Add the last component + if ($currentComponent.Trim() -ne "") { + $parsedComponents += $currentComponent.Trim() + } + + # Process each component to add quotes where needed + $processedComponents = @() + + foreach ($component in $parsedComponents) { + # Split on first equals sign to get attribute and value + $equalIndex = $component.IndexOf('=') + if ($equalIndex -gt 0) { + $attribute = $component.Substring(0, $equalIndex).Trim() + $value = $component.Substring($equalIndex + 1).Trim() + + # Clean up escaped commas first + $cleanValue = $value -replace '\\,', ',' + + # Check if original value had escaped commas (needs quotes) + if ($value -match '\\,') { + # This RDN value had escaped commas, so wrap in doubled quotes and escape quotes + $escapedValue = $cleanValue -replace '"', '""' + $processedComponents += "$attribute=`"`"$escapedValue`"`"" + } else { + # No escaped commas, keep as simple value but escape any existing quotes + $escapedValue = $cleanValue -replace '"', '""' + $processedComponents += "$attribute=$escapedValue" + } + } else { + # Invalid component format, keep as is + $processedComponents += $component + } + } + + # Join components back together (no outer quotes needed since it goes in PowerShell string) + $subjectString = ($processedComponents -join ',') + return $subjectString +} \ No newline at end of file diff --git a/IISU/PowerShell/Keyfactor.WinCert.Common/Private/Get-CertificateCSP.ps1 b/IISU/PowerShell/Keyfactor.WinCert.Common/Private/Get-CertificateCSP.ps1 new file mode 100644 index 00000000..308811cb --- /dev/null +++ b/IISU/PowerShell/Keyfactor.WinCert.Common/Private/Get-CertificateCSP.ps1 @@ -0,0 +1,89 @@ +function Get-CertificateCSP +{ + param( + [System.Security.Cryptography.X509Certificates.X509Certificate2]$cert + ) + + # Helper: extract KSP/provider name from a CNG key object + function Get-CngProviderName { + param($key) + try { + # RSACng / ECDsaCng expose a .Key property (CngKey) + if ($key.PSObject.Properties['Key']) { + $cngKey = $key.Key + if ($cngKey -and $cngKey.Provider -and $cngKey.Provider.Provider) { + return [string]$cngKey.Provider.Provider + } + } + } + catch { + Write-Information "[VERBOSE] CNG provider lookup failed: $($_.Exception.Message)" + } + return $null + } + + try { + if (-not $cert.HasPrivateKey) { + return "No private key" + } + + # ── 1. Legacy CryptoAPI path (RSACryptoServiceProvider) ────────────── + $privateKey = $cert.PrivateKey + if ($privateKey -and $privateKey.CspKeyContainerInfo) { + $providerName = $privateKey.CspKeyContainerInfo.ProviderName + if ($providerName) { + return [string]$providerName + } + } + + # ── 2. CNG RSA (RSACng) ─────────────────────────────────────────────── + try { + $rsaKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert) + if ($rsaKey) { + $providerName = Get-CngProviderName $rsaKey + if ($providerName) { return $providerName } + } + } + catch { + Write-Information "[VERBOSE] RSA CNG detection failed: $($_.Exception.Message)" + } + + # ── 3. ECC / ECDsa (ECDsaCng) ───────────────────────────────────────── + # ECC keys always use CNG (KSPs), never legacy CSPs + try { + $ecKey = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPrivateKey($cert) + if ($ecKey) { + $providerName = Get-CngProviderName $ecKey + if ($providerName) { return $providerName } + + Write-Information "[VERBOSE] ECC key detected but no resolvable provider name (type: $($ecKey.GetType().Name))" + return "" + } + } + catch { + Write-Information "[VERBOSE] ECDsa CNG detection failed: $($_.Exception.Message)" + } + + # ── 4. DSA (bonus) ──────────────────────────────────────────────────── + try { + $dsaKey = [System.Security.Cryptography.X509Certificates.DSACertificateExtensions]::GetDSAPrivateKey($cert) + if ($dsaKey) { + $providerName = Get-CngProviderName $dsaKey + if ($providerName) { return $providerName } + + Write-Information "[VERBOSE] DSA key detected but no resolvable provider name (type: $($dsaKey.GetType().Name))" + return "" + } + } + catch { + Write-Information "[VERBOSE] DSA CNG detection failed: $($_.Exception.Message)" + } + + Write-Information "[VERBOSE] No supported key type detected; provider name could not be determined" + return "" + } + catch { + Write-Warning "Error retrieving CSP for certificate '$($cert.Subject)': $($_.Exception.Message)" + return "" + } +} \ No newline at end of file diff --git a/IISU/PowerShell/Keyfactor.WinCert.Common/Private/Get-CertificateSAN.ps1 b/IISU/PowerShell/Keyfactor.WinCert.Common/Private/Get-CertificateSAN.ps1 new file mode 100644 index 00000000..3e6b3e8f --- /dev/null +++ b/IISU/PowerShell/Keyfactor.WinCert.Common/Private/Get-CertificateSAN.ps1 @@ -0,0 +1,8 @@ +function Get-CertificateSAN($cert) +{ + $san = $cert.Extensions | Where-Object { $_.Oid.FriendlyName -eq "Subject Alternative Name" } + if ($san) { + return ($san.Format(1) -split ", " -join "; ") + } + return $null +} \ No newline at end of file diff --git a/IISU/PowerShell/Keyfactor.WinCert.Common/Private/Get-CryptoProviders.ps1 b/IISU/PowerShell/Keyfactor.WinCert.Common/Private/Get-CryptoProviders.ps1 new file mode 100644 index 00000000..0f215308 --- /dev/null +++ b/IISU/PowerShell/Keyfactor.WinCert.Common/Private/Get-CryptoProviders.ps1 @@ -0,0 +1,57 @@ +function Get-CryptoProviders { + # Retrieves the list of available Crypto Service Providers. + # + # Preferred source is the registry because CSP names are stored as + # culture-invariant subkey names under + # HKLM:\SOFTWARE\Microsoft\Cryptography\Defaults\Provider + + try { + Write-Information "[VERBOSE] Retrieving Crypto Service Providers from registry..." + + $regPath = 'HKLM:\SOFTWARE\Microsoft\Cryptography\Defaults\Provider' + $cspInfoList = @() + + if (Test-Path -LiteralPath $regPath) { + $cspInfoList = @( + Get-ChildItem -LiteralPath $regPath -ErrorAction Stop | + Select-Object -ExpandProperty PSChildName + ) + } + + if ($cspInfoList.Count -eq 0) { + Write-Information "[VERBOSE] Registry enumeration returned no CSPs; falling back to certutil." + + $prevOutEncoding = [Console]::OutputEncoding + try { + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $certUtilOutput = & certutil.exe -csplist 2>&1 + } finally { + [Console]::OutputEncoding = $prevOutEncoding + } + + # The "Provider Name:" label is localized, so match on the structural + # shape of the line instead of the English text: an un-indented + # "