diff --git a/CHANGELOG.md b/CHANGELOG.md index 7469403..439d6aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +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. +* Improved messaging in the event an Entry Parameter is missing (or does not meet the casing requirements) +* 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 current SSL Flags (NOTE: Values greater than 4 are only supported in IIS 10 version 1809 and higher. The default value is 0): + * 0 No SNI + * 1 Use SNI + * 2 Use Centralized SSL certificate store. + * 4 Disable HTTP/2. + * 8 Disable OCSP Stapling. + * 16 Disable QUIC. + * 32 Disable TLS 1.3 over TCP. + * 64 Disable Legacy TLS. + 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. diff --git a/IISU/Certificate.cs b/IISU/Certificate.cs index f8d28c0..6ebf50f 100644 --- a/IISU/Certificate.cs +++ b/IISU/Certificate.cs @@ -14,9 +14,14 @@ // 021225 rcp 2.6.0 Cleaned up and verified code +// Ignore Spelling: Keyfactor + +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; using System; using System.Collections.Generic; +using System.IO; namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore { @@ -52,6 +57,79 @@ public static List DeserializeCertificates(string jsonResults) return new List { singleObject }; } } + + public static string WriteCertificateToTempPfx(string certificateContents) + { + if (string.IsNullOrWhiteSpace(certificateContents)) + throw new ArgumentException("Certificate contents cannot be null or empty.", nameof(certificateContents)); + + try + { + // Decode the Base64 string into bytes + byte[] certBytes = Convert.FromBase64String(certificateContents); + + // Create a unique temporary directory + string tempDirectory = Path.Combine(Path.GetTempPath(), "CertTemp"); + Directory.CreateDirectory(tempDirectory); + + // Create a unique filename + string fileName = $"cert_{Guid.NewGuid():N}.pfx"; + string filePath = Path.Combine(tempDirectory, fileName); + + // Write the bytes to the .pfx file + File.WriteAllBytes(filePath, certBytes); + + // Return the path to the newly created file + return filePath; + } + catch (FormatException) + { + throw new InvalidDataException("The provided certificate contents are not a valid Base64 string."); + } + catch (Exception ex) + { + throw new IOException($"Failed to write certificate to temp PFX file: {ex.Message}", ex); + } + } + + public static void CleanupTempCertificate(string pfxFilePath) + { + ILogger logger = LogHandler.GetClassLogger(); + + if (string.IsNullOrWhiteSpace(pfxFilePath)) + return; + + try + { + if (File.Exists(pfxFilePath)) + { + File.Delete(pfxFilePath); + } + + string? parentDir = Path.GetDirectoryName(pfxFilePath); + if (!string.IsNullOrEmpty(parentDir) && Directory.Exists(parentDir)) + { + // Delete the directory if it's empty + if (Directory.GetFiles(parentDir).Length == 0 && + Directory.GetDirectories(parentDir).Length == 0) + { + Directory.Delete(parentDir); + } + } + } + catch (IOException ioEx) + { + logger.LogWarning($"Warning: Could not delete temporary file or folder: {ioEx.Message}"); + } + catch (UnauthorizedAccessException uaEx) + { + logger.LogWarning($"Warning: Access denied when cleaning up temp file: {uaEx.Message}"); + } + catch (Exception ex) + { + logger.LogWarning($"Warning: Unexpected error during cleanup: {ex.Message}"); + } + } } } } \ No newline at end of file diff --git a/IISU/ClientPSCertStoreReEnrollment.cs b/IISU/ClientPSCertStoreReEnrollment.cs index d16eef6..da9adc2 100644 --- a/IISU/ClientPSCertStoreReEnrollment.cs +++ b/IISU/ClientPSCertStoreReEnrollment.cs @@ -34,7 +34,7 @@ namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore { - internal class ClientPSCertStoreReEnrollment + public class ClientPSCertStoreReEnrollment { private readonly ILogger _logger; private readonly IPAMSecretResolver _resolver; @@ -44,6 +44,12 @@ internal class ClientPSCertStoreReEnrollment private Collection? _results; #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + // Empty constructor for testing purposes + public ClientPSCertStoreReEnrollment() + { + _logger = LogHandler.GetClassLogger(typeof(ClientPSCertStoreReEnrollment)); + } + public ClientPSCertStoreReEnrollment(ILogger logger, IPAMSecretResolver resolver) { _logger = logger; @@ -65,7 +71,11 @@ public JobResult PerformReEnrollment(ReenrollmentJobConfiguration config, Submit var subjectText = config.JobProperties["subjectText"] as string; var providerName = config.JobProperties["ProviderName"] as string; var keyType = config.JobProperties["keyType"] as string; - var SAN = config.JobProperties["SAN"] as string; + + // Prior to Version 3.0, SANs were passed using config.JobProperties. + // Now they are passed as a config parameter, but we will check both to maintain backward compatibility. + // Version 3.0 and greater will default to the new SANs parameter. + var SAN = ResolveSANString(config); int keySize = 0; if (config.JobProperties["keySize"] is not null && int.TryParse(config.JobProperties["keySize"].ToString(), out int size)) @@ -373,5 +383,40 @@ private string ImportCertificate(byte[] certificateRawData, string storeName) } } + public string ResolveSANString(ReenrollmentJobConfiguration config) + { + if (config == null) + throw new ArgumentNullException(nameof(config)); + + string sourceUsed; + string sanValue = string.Empty; + + if (config.SANs != null && config.SANs.Count > 0) + { + var builder = new SANBuilder(config.SANs); + sanValue = builder.BuildSanString(); + sourceUsed = "config.SANs (preferred)"; + } + else if (config.JobProperties != null && + config.JobProperties.TryGetValue("SAN", out object legacySanValue) && + !string.IsNullOrWhiteSpace(legacySanValue.ToString())) + { + sanValue = legacySanValue.ToString().Trim(); + sourceUsed = "config.JobProperties[\"SAN\"] (legacy)"; + } + else + { + sanValue = string.Empty; + sourceUsed = "none (no SANs provided)"; + } + + _logger.LogTrace($"[SAN Resolver] Source used: {sourceUsed}"); + if (!string.IsNullOrEmpty(sanValue)) + _logger.LogTrace($"[SAN Resolver] Value: {sanValue}"); + else + _logger.LogTrace("[SAN Resolver] No SAN values found."); + + return sanValue; + } } } diff --git a/IISU/ImplementedStoreTypes/WinADFS/Inventory.cs b/IISU/ImplementedStoreTypes/WinADFS/Inventory.cs new file mode 100644 index 0000000..639335f --- /dev/null +++ b/IISU/ImplementedStoreTypes/WinADFS/Inventory.cs @@ -0,0 +1,221 @@ +// Copyright 2025 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +using Keyfactor.Extensions.Orchestrator.WindowsCertStore.ImplementedStoreTypes.WinAdfs; +using Keyfactor.Extensions.Orchestrator.WindowsCertStore.WinCert; +using Keyfactor.Logging; +using Keyfactor.Orchestrators.Common.Enums; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Management.Automation; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.WinAdfs +{ + public class Inventory : WinCertJobTypeBase, IInventoryJobExtension + { + private ILogger _logger; + public string ExtensionName => "WinADFSInventory"; + +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + Collection? results = null; +#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + + + public Inventory() + { + _logger = LogHandler.GetClassLogger(); + } + + public Inventory(IPAMSecretResolver resolver) + { + _resolver = resolver; + } + + public JobResult ProcessJob(InventoryJobConfiguration jobConfiguration, SubmitInventoryUpdate submitInventoryUpdate) + { + _logger = LogHandler.GetClassLogger(); + _logger.MethodEntry(); + + try + { + var inventoryItems = new List(); + + _logger.LogTrace(JobConfigurationParser.ParseInventoryJobConfiguration(jobConfiguration)); + + string serverUserName = PAMUtilities.ResolvePAMField(_resolver, _logger, "Server UserName", jobConfiguration.ServerUsername); + string serverPassword = PAMUtilities.ResolvePAMField(_resolver, _logger, "Server Password", jobConfiguration.ServerPassword); + + // De-serialize specific job properties + var jobProperties = JsonConvert.DeserializeObject(jobConfiguration.CertificateStoreDetails.Properties, new JsonSerializerSettings { DefaultValueHandling = DefaultValueHandling.Populate }); + string protocol = jobProperties.WinRmProtocol; + string port = jobProperties.WinRmPort; + bool IncludePortInSPN = jobProperties.SpnPortFlag; + string clientMachineName = jobConfiguration.CertificateStoreDetails.ClientMachine; + string storePath = jobConfiguration.CertificateStoreDetails.StorePath; + + if (storePath != null) + { + // Create the remote connection class to pass to Inventory Class + RemoteSettings settings = new(); + settings.ClientMachineName = jobConfiguration.CertificateStoreDetails.ClientMachine; + settings.Protocol = jobProperties.WinRmProtocol; + settings.Port = jobProperties.WinRmPort; + settings.IncludePortInSPN = jobProperties.SpnPortFlag; + settings.ServerUserName = serverUserName; + settings.ServerPassword = serverPassword; + + _logger.LogTrace($"Querying Window certificate in store: {storePath}"); + inventoryItems = QueryWinADFSCertificates(settings, storePath); + + _logger.LogTrace("Invoking submitInventory.."); + submitInventoryUpdate.Invoke(inventoryItems); + _logger.LogTrace($"submitInventory Invoked... {inventoryItems.Count} Items"); + + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Success, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = $"Inventory completed returning {inventoryItems.Count} Items." + }; + } + + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Warning, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = + $"No certificates were found in the Certificate Store Path: {storePath} on server: {clientMachineName}" + }; + } + catch (Exception ex) + { + _logger.LogTrace(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 + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = failureMessage + }; + } + } + + public List QueryWinADFSCertificates(RemoteSettings settings, string StoreName) + { + _logger.MethodEntry(); + List Inventory = new(); + + using (PSHelper ps = new(settings.Protocol, settings.Port, settings.IncludePortInSPN, settings.ClientMachineName, settings.ServerUserName, settings.ServerPassword, true)) + { + ps.Initialize(); + + // Get ADFS Certificates + results = ps.InvokeFunction("Get-AdfsCertificateInventory"); + if (results == null || results.Count == 0) + { + throw new Exception("No ADFS certificates were found on the target machine."); + } + + var AdfsCertificates = new List(); + + foreach (PSObject result in results) + { + AdfsCertificates.Add(new AdfsCertificateInfo + { + CertificateType = GetPropertyValue(result, "CertificateType"), + IsPrimary = bool.Parse(GetPropertyValue(result, "IsPrimary") ?? "false"), + Thumbprint = GetPropertyValue(result, "Thumbprint"), + Subject = GetPropertyValue(result, "Subject"), + Issuer = GetPropertyValue(result, "Issuer"), + NotBefore = DateTime.Parse(GetPropertyValue(result, "NotBefore")), + NotAfter = DateTime.Parse(GetPropertyValue(result, "NotAfter")), + DaysUntilExpiry = int.Parse(GetPropertyValue(result, "DaysUntilExpiry") ?? "0"), + IsExpired = bool.Parse(GetPropertyValue(result, "IsExpired") ?? "false"), + IsExpiringSoon = bool.Parse(GetPropertyValue(result, "IsExpiringSoon") ?? "false") + }); + } + + // + + var adfsThumbprint = AdfsCertificates + .FirstOrDefault(cert => cert.CertificateType == "Service-Communications" && cert.IsPrimary)?.Thumbprint; + + var parameters = new Dictionary + { + { "StoreName", StoreName }, + { "Thumbprint", adfsThumbprint } + }; + + results = ps.ExecutePowerShell("Get-KFCertificates", parameters); + + // If there are certificates, de-serialize the results and send them back to command + if (results != null && results.Count > 0) + { + var jsonResults = results[0].ToString(); + var certInfoList = Certificate.Utilities.DeserializeCertificates(jsonResults); // JsonConvert.DeserializeObject>(jsonResults); + + foreach (WinCertCertificateInfo cert in certInfoList) + { + var siteSettingsDict = new Dictionary + { + { "ProviderName", cert.ProviderName}, + { "SAN", cert.SAN } + }; + + Inventory.Add( + new CurrentInventoryItem + { + Certificates = new[] { cert.Base64Data }, + Alias = cert.Thumbprint, + PrivateKeyEntry = cert.HasPrivateKey, + UseChainLevel = false, + ItemStatus = OrchestratorInventoryItemStatus.Unknown, + Parameters = siteSettingsDict + } + ); + } + } + ps.Terminate(); + } + + return Inventory; + } + + /// + /// Helper method to get property value from PSObject + /// + private string GetPropertyValue(PSObject psObject, string propertyName) + { + try + { + return psObject.Properties[propertyName]?.Value?.ToString(); + } + catch + { + return null; + } + } + } +} diff --git a/IISU/ImplementedStoreTypes/WinAdfs/AdfsCertificateInfo.cs b/IISU/ImplementedStoreTypes/WinAdfs/AdfsCertificateInfo.cs new file mode 100644 index 0000000..253c604 --- /dev/null +++ b/IISU/ImplementedStoreTypes/WinAdfs/AdfsCertificateInfo.cs @@ -0,0 +1,43 @@ +// Copyright 2025 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License.using Keyfactor.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.ImplementedStoreTypes.WinAdfs +{ + public class AdfsCertificateInfo + { + public string CertificateType { get; set; } + public bool IsPrimary { get; set; } + public string Thumbprint { get; set; } + public string Subject { get; set; } + public string Issuer { get; set; } + public DateTime NotBefore { get; set; } + public DateTime NotAfter { get; set; } + public int DaysUntilExpiry { get; set; } + public bool IsExpired { get; set; } + public bool IsExpiringSoon { get; set; } + + public override string ToString() + { + string status = IsExpired ? "EXPIRED" : + IsExpiringSoon ? $"Expires in {DaysUntilExpiry} days" : + "OK"; + return $"{CertificateType} ({(IsPrimary ? "Primary" : "Secondary")}): {status}"; + } + } +} diff --git a/IISU/ImplementedStoreTypes/WinAdfs/AdfsCertificateRotationManager.cs b/IISU/ImplementedStoreTypes/WinAdfs/AdfsCertificateRotationManager.cs new file mode 100644 index 0000000..982477e --- /dev/null +++ b/IISU/ImplementedStoreTypes/WinAdfs/AdfsCertificateRotationManager.cs @@ -0,0 +1,970 @@ +// Copyright 2025 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License.using Keyfactor.Logging; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.ImplementedStoreTypes.WinAdfs +{ + public class AdfsCertificateRotationManager : IDisposable + { + private ILogger _logger; + + private PSHelper _primaryPsHelper; + private string _protocol; + private string _port; + private bool _useSPN; + private string _username; + private string _password; + private string _primaryNodeName; + + private List _allNodes; + private bool _disposed = false; + + public AdfsCertificateRotationManager(PSHelper primaryPsHelper, string protocol, string port, bool useSPN, string username, string password) + { + _logger = LogHandler.GetClassLogger(); + + _primaryPsHelper = primaryPsHelper ?? throw new ArgumentNullException(nameof(primaryPsHelper)); + _protocol = protocol; + _port = port; + _useSPN = useSPN; + _username = username; + _password = password; + + // Discover farm topology upon initialization + DiscoverFarmTopology(); + } + + private void DiscoverFarmTopology() + { + _logger.MethodEntry(); + _logger.LogDebug("Discovering ADFS farm topology..."); + + var results = _primaryPsHelper.InvokeFunction("Get-AdfsFarmNodeList"); + _allNodes = results.Select(r => r.ToString()).ToList(); + _primaryNodeName = _allNodes.FirstOrDefault(); + + Console.WriteLine($"✓ Discovered {_allNodes.Count} node(s):"); + foreach (var node in _allNodes) + { + bool isPrimary = node.Equals(_primaryNodeName, StringComparison.OrdinalIgnoreCase); + bool isLocal = _primaryPsHelper.IsLocalMachine && isPrimary; + string indicator = isLocal ? " (LOCAL - current machine)" : isPrimary ? " (PRIMARY)" : " (SECONDARY)"; + _logger.LogTrace($" - {node}{indicator}"); + } + _logger.MethodExit(); + } + + public CertificateRotationResult RotateServiceCommunicationCertificate(string pfxFilePath, string pfxPassword) + { + _logger.MethodEntry(); + + var result = new CertificateRotationResult(); + + try + { + // Validate PFX file exists + if (!File.Exists(pfxFilePath)) + { + throw new FileNotFoundException($"PFX file not found: {pfxFilePath}"); + } + + // Read PFX file into memory for remote transfers + byte[] pfxBytes = null; + if (!_primaryPsHelper.IsLocalMachine || _allNodes.Count > 1) + { + pfxBytes = File.ReadAllBytes(pfxFilePath); + _logger.LogTrace($"✓ PFX file loaded ({pfxBytes.Length} bytes)\n"); + } + + // Step 1: Get service account name + _logger.LogTrace("Retrieving ADFS service account name..."); + string serviceAccountName = GetServiceAccountName(); + _logger.LogTrace($"Service account name: {serviceAccountName}"); + + // Step 2: Install certificate on all nodes + _logger.LogTrace("Installing Certificate on All Nodes..."); + Dictionary nodeThumbprints = new Dictionary(); + + foreach (string node in _allNodes) + { + _logger.LogTrace($"Installing certificate on node: {node}..."); + + // Check if this is the local machine + bool isLocalNode = _primaryPsHelper.IsLocalMachine && + node.Equals(_primaryNodeName, StringComparison.OrdinalIgnoreCase); + + string thumbprint; + + if (isLocalNode) + { + // Use existing local connection + Console.WriteLine($" Using local connection (application is running on this node)"); + thumbprint = InstallCertificateOnLocalNode(pfxFilePath, pfxPassword, serviceAccountName); + } + else + { + // Create direct remote connection + thumbprint = InstallCertificateOnRemoteNode(node, pfxBytes, pfxPassword, serviceAccountName); + } + + if (!string.IsNullOrEmpty(thumbprint)) + { + nodeThumbprints[node] = thumbprint; + result.SuccessfulNodes.Add(node); + } + else + { + result.FailedNodes.Add(node); + result.Errors[node] = "Failed to install certificate"; + } + } + + // Check if all nodes succeeded + if (result.FailedNodes.Count > 0) + { + throw new Exception($"Certificate installation failed on {result.FailedNodes.Count} node(s)"); + } + + // Get the thumbprint (should be same on all nodes) + string certificateThumbprint = nodeThumbprints.Values.First(); + result.Thumbprint = certificateThumbprint; + + // Step 3: Update ADFS farm settings on primary node + _logger.LogTrace("Updating ADFS Farm Settings..."); + UpdateFarmCertificateSettings(certificateThumbprint); + + // Step 4: Restart ADFS service on all nodes + _logger.LogTrace("Restarting ADFS Service..."); + RestartAdfsServicesSmartly(); + + // Step 5: Verify installation + _logger.LogTrace("Verifying Installation..."); + VerifyCertificateInstallationSmartly(certificateThumbprint); + + // Step 6: Clean up old certificates + _logger.LogTrace("Cleaning Up Old Certificates..."); + CleanupOldCertificatesSmartly(certificateThumbprint); + + result.Success = true; + result.Message = "Certificate rotation completed successfully"; + + _logger.LogInformation($"New Certificate Thumbprint: {certificateThumbprint}"); + _logger.LogInformation($"Updated Nodes: {string.Join(", ", result.SuccessfulNodes)}"); + + } + catch (Exception ex) + { + result.Success = false; + result.Message = $"Certificate rotation failed: {ex.Message}"; + + _logger.LogError($"Certificate rotation failed: {ex.Message}"); + } + finally + { + _logger.MethodExit(); + } + + return result; + } + + private string GetServiceAccountName() + { + try + { + _logger.MethodEntry(); + + var results = _primaryPsHelper.InvokeFunction("Get-AdfsFarmProperties"); + + if (results != null && results.Count > 0) + { + string serviceAccount = results[0].Properties["ServiceAccountName"]?.Value?.ToString(); + + if (string.IsNullOrWhiteSpace(serviceAccount)) + { + _logger.LogWarning("⚠ Warning: Service account name not available from ADFS properties"); + _logger.LogWarning(" ADFS may be using gMSA or built-in account"); + return null; + } + + return serviceAccount; + } + + _logger.LogWarning("⚠ Warning: Could not retrieve ADFS properties"); + return null; + } + catch (Exception ex) + { + _logger.LogWarning($"⚠ Warning: Error retrieving service account: {ex.Message}"); + return null; + } + finally + { + _logger.MethodExit(); + } + } + + private string InstallCertificateOnLocalNode(string pfxFilePath, string pfxPassword, string serviceAccountName) + { + try + { + _logger.MethodEntry(); + + _logger.LogTrace($" Installing certificate on local node..."); + _logger.LogTrace($" Using file path: {pfxFilePath}"); + + var parameters = new Dictionary + { + { "PfxFilePath", pfxFilePath }, + { "PfxPasswordText", pfxPassword } + }; + + var results = _primaryPsHelper.InvokeFunction("Install-AdfsCertificateOnNode", parameters); + + string thumbprint = null; + if (results != null && results.Count > 0) + { + var success = results[0].Properties["Success"]?.Value; + + if (success != null && (bool)success) + { + thumbprint = results[0].Properties["Thumbprint"]?.Value?.ToString(); + string subject = results[0].Properties["Subject"]?.Value?.ToString(); + + _logger.LogTrace($" ✓ Certificate installed successfully"); + _logger.LogTrace($" Subject: {subject}"); + + // Grant permissions using local connection + GrantCertificatePermissionsLocal(thumbprint, serviceAccountName); + } + else + { + string errorMsg = results[0].Properties["ErrorMessage"]?.Value?.ToString(); + _logger.LogError($" ✗ Installation failed: {errorMsg}"); + } + } + + return thumbprint; + } + catch (Exception ex) + { + _logger.LogError($" ✗ Error: {ex.Message}"); + return null; + } + finally + { + _logger.MethodExit(); + } + } + + private string InstallCertificateOnRemoteNode(string nodeName, byte[] pfxBytes, string pfxPassword, string serviceAccountName) + { + _logger.MethodEntry(); + + PSHelper nodeHelper = null; + + try + { + _logger.LogTrace($" Establishing direct connection to {nodeName}..."); + + // Create a NEW direct PSHelper connection to this specific node + nodeHelper = new PSHelper( + _protocol, + _port, + _useSPN, + nodeName, + _username, + _password + ); + + nodeHelper.Initialize(); + + _logger.LogTrace($" ✓ Connected to {nodeName}"); + + // Create temporary PFX file on the remote node + string remoteTempPath = CreateTempPfxOnNode(nodeHelper, pfxBytes); + _logger.LogTrace($" ✓ PFX transferred to {nodeName}: {remoteTempPath}"); + + // Install certificate + _logger.LogTrace($" Installing certificate..."); + var parameters = new Dictionary + { + { "PfxFilePath", remoteTempPath }, + { "PfxPasswordText", pfxPassword } + }; + + var results = nodeHelper.InvokeFunction("Install-AdfsCertificateOnNode", parameters); + + string thumbprint = null; + if (results != null && results.Count > 0) + { + var success = results[0].Properties["Success"]?.Value; + + if (success != null && (bool)success) + { + thumbprint = results[0].Properties["Thumbprint"]?.Value?.ToString(); + string subject = results[0].Properties["Subject"]?.Value?.ToString(); + + _logger.LogTrace($" ✓ Certificate installed successfully"); + _logger.LogTrace($" Thumbprint: {thumbprint}"); + _logger.LogTrace($" Subject: {subject}"); + + // Grant permissions + GrantCertificatePermissionsOnNode(nodeHelper, thumbprint, serviceAccountName); + + // Clean up temp file + CleanupTempFileOnNode(nodeHelper, remoteTempPath); + } + else + { + string errorMsg = results[0].Properties["ErrorMessage"]?.Value?.ToString(); + _logger.LogError($" ✗ Installation failed: {errorMsg}"); + } + } + + return thumbprint; + } + catch (Exception ex) + { + _logger.LogError($" ✗ Error: {ex.Message}"); + return null; + } + finally + { + // Clean up the direct connection + if (nodeHelper != null) + { + try + { + nodeHelper.Terminate(); + nodeHelper.Dispose(); + } + catch (Exception ex) + { + _logger.LogWarning($" Warning: Error closing connection to {nodeName}: {ex.Message}"); + } + } + + _logger.MethodExit(); + } + } + + private void GrantCertificatePermissionsLocal(string thumbprint, string serviceAccountName) + { + try + { + _logger.MethodEntry(); + + _logger.LogTrace($" Granting permissions to service account..."); + + var parameters = new Dictionary + { + { "CertificateThumbprint", thumbprint } + }; + + // Only add service account if not null/empty + if (!string.IsNullOrWhiteSpace(serviceAccountName)) + { + parameters.Add("ServiceAccountName", serviceAccountName); + } + + var results = _primaryPsHelper.InvokeFunction("Grant-AdfsCertificatePermissions", parameters); + + if (results != null && results.Count > 0) + { + var success = results[0].Properties["Success"]?.Value; + var skipped = results[0].Properties["Skipped"]?.Value; + var alreadyGranted = results[0].Properties["AlreadyGranted"]?.Value; + var message = results[0].Properties["Message"]?.Value?.ToString(); + + if (success != null && (bool)success) + { + if (skipped != null && (bool)skipped) + { + _logger.LogWarning($" ⚠ Permissions skipped: {message}"); + } + else if (alreadyGranted != null && (bool)alreadyGranted) + { + _logger.LogTrace($" ✓ Permissions already granted"); + } + else + { + _logger.LogTrace($" ✓ Permissions granted"); + } + } + else + { + string errorMsg = results[0].Properties["ErrorMessage"]?.Value?.ToString(); + _logger.LogWarning($" ⚠ Warning: Could not grant permissions - {errorMsg}"); + _logger.LogWarning($" Certificate may still work if service account has existing access"); + } + } + } + catch (Exception ex) + { + _logger.LogWarning($" ⚠ Warning: Error granting permissions - {ex.Message}"); + _logger.LogWarning($" Certificate may still work if ADFS runs as SYSTEM or has existing access"); + } + finally + { + _logger.MethodExit(); + } + } + + private void GrantCertificatePermissionsOnNode(PSHelper nodeHelper, string thumbprint, string serviceAccountName) + { + try + { + _logger.MethodEntry(); + + _logger.LogTrace($" Granting permissions to service account..."); + + var parameters = new Dictionary + { + { "CertificateThumbprint", thumbprint } + }; + + // Only add service account if not null/empty + if (!string.IsNullOrWhiteSpace(serviceAccountName)) + { + parameters.Add("ServiceAccountName", serviceAccountName); + } + + var results = nodeHelper.InvokeFunction("Grant-AdfsCertificatePermissions", parameters); + + if (results != null && results.Count > 0) + { + var success = results[0].Properties["Success"]?.Value; + var skipped = results[0].Properties["Skipped"]?.Value; + var alreadyGranted = results[0].Properties["AlreadyGranted"]?.Value; + var message = results[0].Properties["Message"]?.Value?.ToString(); + + if (success != null && (bool)success) + { + if (skipped != null && (bool)skipped) + { + _logger.LogWarning($" ⚠ Permissions skipped: {message}"); + } + else if (alreadyGranted != null && (bool)alreadyGranted) + { + _logger.LogTrace($" ✓ Permissions already granted"); + } + else + { + _logger.LogTrace($" ✓ Permissions granted"); + } + } + else + { + string errorMsg = results[0].Properties["ErrorMessage"]?.Value?.ToString(); + _logger.LogWarning($" ⚠ Warning: Could not grant permissions - {errorMsg}"); + } + } + } + catch (Exception ex) + { + _logger.LogWarning($" ⚠ Warning: Error granting permissions - {ex.Message}"); + _logger.LogWarning($" Certificate may still work depending on service account configuration"); + } + finally + { + _logger.MethodExit(); + } + } + + private void UpdateFarmCertificateSettings(string thumbprint) + { + _logger.MethodEntry(); + + try + { + _logger.LogInformation("Updating farm settings on primary node..."); + + var parameters = new Dictionary + { + { "CertificateThumbprint", thumbprint } + }; + + var results = _primaryPsHelper.InvokeFunction("Update-AdfsFarmCertificateSettings", parameters); + + if (results != null && results.Count > 0) + { + var success = results[0].Properties["Success"]?.Value; + + if (success != null && (bool)success) + { + _logger.LogInformation("ADFS farm certificate settings updated"); + } + else + { + string errorMsg = results[0].Properties["ErrorMessage"]?.Value?.ToString(); + throw new Exception($"Failed to update farm settings: {errorMsg}"); + } + } + } + catch (Exception ex) + { + _logger.LogError($" Error updating farm settings: {ex.Message}"); + throw; + } + finally + { + _logger.MethodExit(); + } + } + + public static void UpdateFarmCertificateSettings(string thumbprint, PSHelper psHelper) + { + try + { + var parameters = new Dictionary + { + { "CertificateThumbprint", thumbprint } + }; + + var results = psHelper.InvokeFunction("Update-AdfsFarmCertificateSettings", parameters); + + if (results != null && results.Count > 0) + { + var success = results[0].Properties["Success"]?.Value; + + if (success != null && (bool)success) + { + } + else + { + string errorMsg = results[0].Properties["ErrorMessage"]?.Value?.ToString(); + throw new Exception($"Failed to update farm settings: {errorMsg}"); + } + } + } + catch (Exception ex) + { + throw; + } + } + + private void RestartAdfsServicesSmartly() + { + foreach (string nodeName in _allNodes) + { + bool isLocalNode = _primaryPsHelper.IsLocalMachine && + nodeName.Equals(_primaryNodeName, StringComparison.OrdinalIgnoreCase); + + if (isLocalNode) + { + // Use existing local connection + RestartAdfsServiceLocal(nodeName); + } + else + { + // Create remote connection + RestartAdfsServiceRemote(nodeName); + } + } + } + + private void RestartAdfsServiceLocal(string nodeName) + { + try + { + _logger.MethodEntry(); + + _logger.LogTrace($" Restarting ADFS on {nodeName} (local)..."); + + var results = _primaryPsHelper.InvokeFunction("Restart-AdfsServiceOnNode"); + + if (results != null && results.Count > 0) + { + var success = results[0].Properties["Success"]?.Value; + + if (success != null && (bool)success) + { + _logger.LogTrace($" ✓ ADFS service restarted on {nodeName}"); + } + } + } + catch (Exception ex) + { + _logger.LogWarning($" ✗ Error restarting {nodeName}: {ex.Message}"); + } + finally + { + _logger.MethodExit(); + } + } + + private void RestartAdfsServiceRemote(string nodeName) + { + PSHelper nodeHelper = null; + + try + { + _logger.MethodEntry(); + + _logger.LogTrace($" Restarting ADFS on {nodeName}..."); + + // Create direct connection + nodeHelper = new PSHelper(_protocol, _port, _useSPN, nodeName, _username, _password); + nodeHelper.Initialize(); + + // Restart service + var results = nodeHelper.InvokeFunction("Restart-AdfsServiceOnNode"); + + if (results != null && results.Count > 0) + { + var success = results[0].Properties["Success"]?.Value; + + if (success != null && (bool)success) + { + _logger.LogTrace($" ✓ ADFS service restarted on {nodeName}"); + } + } + } + catch (Exception ex) + { + _logger.LogWarning($" ✗ Error restarting {nodeName}: {ex.Message}"); + } + finally + { + if (nodeHelper != null) + { + try + { + nodeHelper.Terminate(); + nodeHelper.Dispose(); + } + catch { } + } + + _logger.MethodExit(); + } + } + + private void VerifyCertificateInstallationSmartly(string thumbprint) + { + _logger.MethodEntry(); + + _logger.LogTrace("Verifying certificate installation on all nodes...\n"); + + foreach (string nodeName in _allNodes) + { + bool isLocalNode = _primaryPsHelper.IsLocalMachine && + nodeName.Equals(_primaryNodeName, StringComparison.OrdinalIgnoreCase); + + if (isLocalNode) + { + VerifyCertificateLocal(nodeName, thumbprint); + } + else + { + VerifyCertificateRemote(nodeName, thumbprint); + } + } + _logger.MethodExit(); + } + + private void VerifyCertificateLocal(string nodeName, string thumbprint) + { + try + { + _logger.MethodEntry(); + + var parameters = new Dictionary + { + { "CertificateThumbprint", thumbprint } + }; + + var results = _primaryPsHelper.InvokeFunction("Test-AdfsCertificateInstalled", parameters); + + if (results != null && results.Count > 0) + { + var isInstalled = results[0].Properties["IsInstalled"]?.Value; + var hasPrivateKey = results[0].Properties["HasPrivateKey"]?.Value; + + if (isInstalled != null && (bool)isInstalled) + { + _logger.LogTrace($" ✓ {nodeName} (local): Certificate verified"); + _logger.LogTrace($" Has Private Key: {hasPrivateKey}"); + } + else + { + _logger.LogTrace($" ✗ {nodeName}: Certificate NOT found"); + } + } + } + catch (Exception ex) + { + _logger.LogWarning($" ✗ {nodeName}: Verification failed - {ex.Message}"); + } + finally + { + _logger.MethodExit(); + } + } + + private void VerifyCertificateRemote(string nodeName, string thumbprint) + { + PSHelper nodeHelper = null; + + try + { + _logger.MethodEntry(); + + // Create direct connection + nodeHelper = new PSHelper(_protocol, _port, _useSPN, nodeName, _username, _password); + nodeHelper.Initialize(); + + var parameters = new Dictionary + { + { "CertificateThumbprint", thumbprint } + }; + + var results = nodeHelper.InvokeFunction("Test-AdfsCertificateInstalled", parameters); + + if (results != null && results.Count > 0) + { + var isInstalled = results[0].Properties["IsInstalled"]?.Value; + var hasPrivateKey = results[0].Properties["HasPrivateKey"]?.Value; + + if (isInstalled != null && (bool)isInstalled) + { + _logger.LogTrace($" ✓ {nodeName}: Certificate verified"); + _logger.LogTrace($" Has Private Key: {hasPrivateKey}"); + } + else + { + _logger.LogTrace($" ✗ {nodeName}: Certificate NOT found"); + } + } + } + catch (Exception ex) + { + _logger.LogWarning($" ✗ {nodeName}: Verification failed - {ex.Message}"); + } + finally + { + if (nodeHelper != null) + { + try + { + nodeHelper.Terminate(); + nodeHelper.Dispose(); + } + catch { } + } + + _logger.MethodExit(); + } + } + + private void CleanupOldCertificatesSmartly(string newThumbprint) + { + _logger.MethodEntry(); + + // Use primary connection to get cert details + var certParams = new Dictionary + { + { "CertificateThumbprint", newThumbprint } + }; + + var certResults = _primaryPsHelper.InvokeFunction("Test-AdfsCertificateInstalled", certParams); + + if (certResults == null || certResults.Count == 0) + { + _logger.LogTrace(" Could not retrieve certificate details for cleanup"); + return; + } + + string subject = certResults[0].Properties["Subject"]?.Value?.ToString(); + DateTime notAfter = (DateTime)certResults[0].Properties["NotAfter"]?.Value; + + _logger.LogTrace($"Removing old certificates with subject: {subject}\n"); + + foreach (string nodeName in _allNodes) + { + bool isLocalNode = _primaryPsHelper.IsLocalMachine && + nodeName.Equals(_primaryNodeName, StringComparison.OrdinalIgnoreCase); + + if (isLocalNode) + { + CleanupOldCertificatesLocal(nodeName, subject, notAfter); + } + else + { + CleanupOldCertificatesRemote(nodeName, subject, notAfter); + } + } + + _logger.MethodExit(); + } + + private void CleanupOldCertificatesLocal(string nodeName, string subject, DateTime notAfter) + { + try + { + _logger.MethodEntry(); + + _logger.LogTrace($" Cleaning up {nodeName} (local)..."); + + var parameters = new Dictionary + { + { "CertificateSubject", subject }, + { "NewCertificateNotAfter", notAfter } + }; + + var results = _primaryPsHelper.InvokeFunction("Remove-OldAdfsCertificate", parameters); + + if (results != null && results.Count > 0) + { + var success = results[0].Properties["Success"]?.Value; + var removedCount = results[0].Properties["RemovedCount"]?.Value; + + if (success != null && (bool)success) + { + _logger.LogTrace($" ✓ Removed {removedCount} old certificate(s) from {nodeName}"); + } + } + } + catch (Exception ex) + { + _logger.LogWarning($" ⚠ Cleanup warning for {nodeName}: {ex.Message}"); + } + finally + { + _logger.MethodExit(); + } + } + + private void CleanupOldCertificatesRemote(string nodeName, string subject, DateTime notAfter) + { + PSHelper nodeHelper = null; + + try + { + _logger.MethodEntry(); + + _logger.LogTrace($" Cleaning up {nodeName}..."); + + // Create direct connection + nodeHelper = new PSHelper(_protocol, _port, _useSPN, nodeName, _username, _password); + nodeHelper.Initialize(); + + var parameters = new Dictionary + { + { "CertificateSubject", subject }, + { "NewCertificateNotAfter", notAfter } + }; + + var results = nodeHelper.InvokeFunction("Remove-OldAdfsCertificate", parameters); + + if (results != null && results.Count > 0) + { + var success = results[0].Properties["Success"]?.Value; + var removedCount = results[0].Properties["RemovedCount"]?.Value; + + if (success != null && (bool)success) + { + _logger.LogTrace($" ✓ Removed {removedCount} old certificate(s) from {nodeName}"); + } + } + } + catch (Exception ex) + { + _logger.LogWarning($" ⚠ Cleanup warning for {nodeName}: {ex.Message}"); + } + finally + { + if (nodeHelper != null) + { + try + { + nodeHelper.Terminate(); + nodeHelper.Dispose(); + } + catch { } + } + + _logger.MethodExit(); + } + } + + private string CreateTempPfxOnNode(PSHelper nodeHelper, byte[] pfxBytes) + { + // Convert bytes to Base64 for transfer + string base64Content = Convert.ToBase64String(pfxBytes); + + string script = $@" + $tempPath = [System.IO.Path]::Combine($env:TEMP, 'adfs_cert_' + [System.Guid]::NewGuid().ToString() + '.pfx') + $bytes = [System.Convert]::FromBase64String('{base64Content}') + [System.IO.File]::WriteAllBytes($tempPath, $bytes) + return $tempPath + "; + + var results = nodeHelper.ExecutePowerShell(script, isScript: true); + + if (results != null && results.Count > 0) + { + return results[0].ToString(); + } + + throw new Exception("Failed to create temporary PFX file on remote node"); + } + + private void CleanupTempFileOnNode(PSHelper nodeHelper, string remotePath) + { + try + { + _logger.MethodEntry(); + + var parameters = new Dictionary + { + { "FilePath", remotePath } + }; + + nodeHelper.InvokeFunction("Remove-TempFileOnNode", parameters); + } + catch (Exception ex) + { + _logger.LogWarning($" Warning: Could not remove temp file: {ex.Message}"); + } + finally + { + _logger.MethodExit(); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _primaryPsHelper = null; + } + _disposed = true; + } + } + } +} diff --git a/IISU/ImplementedStoreTypes/WinAdfs/AdfsInventory.cs b/IISU/ImplementedStoreTypes/WinAdfs/AdfsInventory.cs new file mode 100644 index 0000000..a3c203c --- /dev/null +++ b/IISU/ImplementedStoreTypes/WinAdfs/AdfsInventory.cs @@ -0,0 +1,35 @@ +// Copyright 2025 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License.using Keyfactor.Logging; +using System; +using Keyfactor.Logging; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.ImplementedStoreTypes.WinAdfs +{ + public class AdfsFarmInventory + { + public string FarmName { get; set; } + public string HostName { get; set; } + public string Identifier { get; set; } + public string ServiceAccountName { get; set; } + public int FarmBehaviorLevel { get; set; } + public List Nodes { get; set; } = new List(); + public List Certificates { get; set; } = new List(); + public DateTime InventoryDate { get; set; } + } +} diff --git a/IISU/ImplementedStoreTypes/WinAdfs/AdfsNodeInfo.cs b/IISU/ImplementedStoreTypes/WinAdfs/AdfsNodeInfo.cs new file mode 100644 index 0000000..3d43aeb --- /dev/null +++ b/IISU/ImplementedStoreTypes/WinAdfs/AdfsNodeInfo.cs @@ -0,0 +1,32 @@ +// Copyright 2025 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License.using Keyfactor.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.ImplementedStoreTypes.WinAdfs +{ + public class AdfsNodeInfo + { + public string NodeName { get; set; } + public string Role { get; set; } + public bool IsReachable { get; set; } + public string ServiceStatus { get; set; } + public DateTime? LastSyncTime { get; set; } + public string SyncStatus { get; set; } + public string ErrorMessage { get; set; } + } +} diff --git a/IISU/ImplementedStoreTypes/WinAdfs/CertificateRotationResult.cs b/IISU/ImplementedStoreTypes/WinAdfs/CertificateRotationResult.cs new file mode 100644 index 0000000..344add9 --- /dev/null +++ b/IISU/ImplementedStoreTypes/WinAdfs/CertificateRotationResult.cs @@ -0,0 +1,32 @@ +// Copyright 2025 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License.using Keyfactor.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.ImplementedStoreTypes.WinAdfs +{ + public class CertificateRotationResult + { + public bool Success { get; set; } + public string Message { get; set; } + public string Thumbprint { get; set; } + public List SuccessfulNodes { get; set; } = new List(); + public List FailedNodes { get; set; } = new List(); + public Dictionary Errors { get; set; } = new Dictionary(); + + } +} diff --git a/IISU/ImplementedStoreTypes/WinAdfs/Management.cs b/IISU/ImplementedStoreTypes/WinAdfs/Management.cs new file mode 100644 index 0000000..77c3607 --- /dev/null +++ b/IISU/ImplementedStoreTypes/WinAdfs/Management.cs @@ -0,0 +1,168 @@ +// Copyright 2025 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License.using Keyfactor.Logging; +using Keyfactor.Extensions.Orchestrator.WindowsCertStore.ImplementedStoreTypes.WinAdfs; +using Keyfactor.Logging; +using Keyfactor.Orchestrators.Common.Enums; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; + +namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.WinAdfs +{ + public class Management : WinCertJobTypeBase, IManagementJobExtension + { + public string ExtensionName => "WinAdfsManagement"; + private ILogger _logger; + + private PSHelper _psHelper; + + // Function wide config values + private string _clientMachineName = string.Empty; + private string _storePath = string.Empty; + private long _jobHistoryID = 0; + private CertStoreOperationType _operationType; + + public Management(IPAMSecretResolver resolver) + { + _resolver = resolver; + } + + public JobResult ProcessJob(ManagementJobConfiguration config) + { + try + { + // Do some setup stuff + _logger = LogHandler.GetClassLogger(); + _logger.MethodEntry(); + + try + { + _logger.LogTrace(JobConfigurationParser.ParseManagementJobConfiguration(config)); + } + catch (Exception e) + { + _logger.LogTrace(e.Message); + } + + var complete = new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = config.JobHistoryId, + FailureMessage = "Invalid Management Operation" + }; + + // Start parsing config information and establishing PS Session + _jobHistoryID = config.JobHistoryId; + _storePath = config.CertificateStoreDetails.StorePath; + _clientMachineName = config.CertificateStoreDetails.ClientMachine; + _operationType = config.OperationType; + + string serverUserName = PAMUtilities.ResolvePAMField(_resolver, _logger, "Server UserName", config.ServerUsername); + string serverPassword = PAMUtilities.ResolvePAMField(_resolver, _logger, "Server Password", config.ServerPassword); + + var jobProperties = JsonConvert.DeserializeObject(config.CertificateStoreDetails.Properties, new JsonSerializerSettings { DefaultValueHandling = DefaultValueHandling.Populate }); + + string protocol = jobProperties?.WinRmProtocol; + string port = jobProperties?.WinRmPort; + bool includePortInSPN = (bool)jobProperties?.SpnPortFlag; + + _psHelper = new(protocol, port, includePortInSPN, _clientMachineName, serverUserName, serverPassword, true); + _psHelper.Initialize(); + + switch (_operationType) + { + case CertStoreOperationType.Add: + { + string certificateContents = config.JobCertificate.Contents; + string privateKeyPassword = config.JobCertificate.PrivateKeyPassword; + + string pfxPath = Certificate.Utilities.WriteCertificateToTempPfx(certificateContents); + using (var rotationManager = new AdfsCertificateRotationManager( + _psHelper, // Primary PSHelper connection + protocol, // For creating direct connections to other nodes + port, // For creating direct connections to other nodes + includePortInSPN, // For creating direct connections to other nodes + serverUserName, // For creating direct connections to other nodes + serverPassword)) + { + var result = rotationManager.RotateServiceCommunicationCertificate(pfxPath, privateKeyPassword); + if (result.Success) + { + AdfsCertificateRotationManager.UpdateFarmCertificateSettings(result.Thumbprint, _psHelper); + } + + _logger.LogInformation($"Adfs Service Communication Certificate rotation result: {(result.Success ? "SUCCESSFUL" : "FAILED")}"); + _logger.LogInformation(result.Message); + + if (result.Success) + { + complete = new JobResult + { + Result = OrchestratorJobStatusJobResult.Success, + JobHistoryId = _jobHistoryID, + FailureMessage = $"Adfs Service Communication Certificate rotated successfully to thumbprint: {result.Thumbprint}" + }; + } + else + { + complete = new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = _jobHistoryID, + FailureMessage = $"Adfs Service Communication Certificate rotation failed. {result.Message}" + }; + } + } + + //complete = AddCertificate(certificateContents, privateKeyPassword, cryptoProvider); + Certificate.Utilities.CleanupTempCertificate(pfxPath); + _logger.LogTrace($"Completed adding the certificate to the store"); + + break; + } + default: + { + _logger.LogWarning($"Management job of type {_operationType} is not supported in WinAdfs store."); + break; + } + } + + return complete; + } + + catch (Exception ex) + { + _logger.LogTrace(LogHandler.FlattenException(ex)); + + var failureMessage = $"Management job {_operationType} failed on Store '{_storePath}' on server '{_clientMachineName}' with error: '{LogHandler.FlattenException(ex)}'"; + _logger.LogWarning(failureMessage); + + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = _jobHistoryID, + FailureMessage = failureMessage + }; + } + finally + { + if (_psHelper != null) _psHelper.Terminate(); + _logger.MethodExit(); + } + + } + } +} diff --git a/IISU/ImplementedStoreTypes/WinIIS/IISBindingInfo.cs b/IISU/ImplementedStoreTypes/WinIIS/IISBindingInfo.cs index 58b9a60..2f54c62 100644 --- a/IISU/ImplementedStoreTypes/WinIIS/IISBindingInfo.cs +++ b/IISU/ImplementedStoreTypes/WinIIS/IISBindingInfo.cs @@ -23,6 +23,19 @@ namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.IISU { + [Flags] + public enum SslFlags + { + None = 0, + SslRequireSni = 1, + SslNegotiateCert = 2, + SslRequireCert = 4, + SslMapCert = 8, + CentralCertStore = 32, + DisableHTTP2 = 64, + DisableOCSPStapling = 128 + } + public class IISBindingInfo { public string SiteName { get; set; } @@ -42,12 +55,19 @@ public IISBindingInfo() public IISBindingInfo(Dictionary bindingInfo) { - SiteName = bindingInfo["SiteName"].ToString(); - Protocol = bindingInfo["Protocol"].ToString(); - IPAddress = bindingInfo["IPAddress"].ToString(); - Port = bindingInfo["Port"].ToString(); - HostName = bindingInfo["HostName"]?.ToString(); - SniFlag = MigrateSNIFlag(bindingInfo["SniFlag"].ToString()); + try + { + SiteName = bindingInfo["SiteName"].ToString(); + Protocol = bindingInfo["Protocol"].ToString(); + IPAddress = bindingInfo["IPAddress"].ToString(); + Port = bindingInfo["Port"].ToString(); + HostName = bindingInfo["HostName"]?.ToString(); + SniFlag = MigrateSNIFlag(bindingInfo["SniFlag"].ToString()); + } + catch (KeyNotFoundException ex) + { + throw new ArgumentException($"An Entry Parameter was missing. Please check the Cert Store Type Definition, note that entry parameters are case sensitive. Message: {ex.Message}"); + } } public static IISBindingInfo ParseAliaseBindingString(string alias) @@ -69,6 +89,26 @@ public static IISBindingInfo ParseAliaseBindingString(string alias) }; } + public (bool IsValid, string Message) ValidateSslFlags(int flags) + { + const int validBits = 1 | 2 | 4 | 8 | 32 | 64 | 128; + + // Unknown bits + if ((flags & ~validBits) != 0) + return (false, $"SslFlags contains unknown bits: {flags}"); + + bool negotiate = (flags & (int)SslFlags.SslNegotiateCert) != 0; + bool require = (flags & (int)SslFlags.SslRequireCert) != 0; + bool mapCert = (flags & (int)SslFlags.SslMapCert) != 0; + + if (negotiate && require) + return (false, "Cannot use both SslNegotiateCert (0x2) and SslRequireCert (0x4)."); + + if (mapCert && !(negotiate || require)) + return (false, "SslMapCert (0x8) requires either SslNegotiateCert (0x2) or SslRequireCert (0x4)."); + + return (true, $"SslFlags value {flags} is valid."); + } private string MigrateSNIFlag(string input) { diff --git a/IISU/ImplementedStoreTypes/WinIIS/Management.cs b/IISU/ImplementedStoreTypes/WinIIS/Management.cs index 3a07501..5b43bc3 100644 --- a/IISU/ImplementedStoreTypes/WinIIS/Management.cs +++ b/IISU/ImplementedStoreTypes/WinIIS/Management.cs @@ -32,6 +32,8 @@ namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore.IISU { public class Management : WinCertJobTypeBase, IManagementJobExtension { + + public string ExtensionName => "WinIISUManagement"; private ILogger _logger; @@ -92,6 +94,26 @@ public JobResult ProcessJob(ManagementJobConfiguration config) bool includePortInSPN = (bool)jobProperties?.SpnPortFlag; 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); + + // Check if the Ssl flags are set correctly + if (bindingInfo.Protocol.ToLower() == "https" && string.IsNullOrEmpty(bindingInfo.SniFlag)) + { + throw new ArgumentException("SniFlag must be set when using HTTPS protocol. Valid values are 0 (None), 1 (SNI Enabled), or 2 (IP Based)."); + } + else if (bindingInfo.Protocol.ToLower() != "https") + { + bindingInfo.SniFlag = "0"; // Set to None if not using HTTPS + } + + var (isValid, SslErrorMessage) = bindingInfo.ValidateSslFlags(int.Parse(bindingInfo.SniFlag)); + if (!isValid) + { + throw new ArgumentException($"Invalid SSL Flag Combination: {SslErrorMessage}"); + } + + _psHelper = new(protocol, port, includePortInSPN, _clientMachineName, serverUserName, serverPassword); _psHelper.Initialize(); @@ -102,6 +124,8 @@ public JobResult ProcessJob(ManagementJobConfiguration config) { case CertStoreOperationType.Add: { + _logger.LogTrace($"Beginning the Adding of Certificate process."); + string certificateContents = config.JobCertificate.Contents; string privateKeyPassword = config.JobCertificate.PrivateKeyPassword; #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. @@ -111,7 +135,6 @@ public JobResult ProcessJob(ManagementJobConfiguration config) // Add Certificate to Cert Store try { - IISBindingInfo bindingInfo = new IISBindingInfo(config.JobProperties); OrchestratorJobStatusJobResult psResult = OrchestratorJobStatusJobResult.Unknown; string failureMessage = ""; @@ -265,7 +288,7 @@ public JobResult ProcessJob(ManagementJobConfiguration config) } finally { - _psHelper.Terminate(); + if (_psHelper != null) _psHelper.Terminate(); _logger.MethodExit(); } } diff --git a/IISU/JobConfigurationParser.cs b/IISU/JobConfigurationParser.cs index 91bfb98..6d6b8a0 100644 --- a/IISU/JobConfigurationParser.cs +++ b/IISU/JobConfigurationParser.cs @@ -110,7 +110,7 @@ public static string ParseManagementJobConfiguration(ManagementJobConfiguration try { // JobCertificate - managementParser.JobCertificateProperties.Thumbprint = config.JobCertificate.Thumbprint; + managementParser.JobCertificateProperties.Thumbprint = config.JobCertificate.Thumbprint ?? string.Empty; managementParser.JobCertificateProperties.Contents = config.JobCertificate.Contents; managementParser.JobCertificateProperties.Alias = config.JobCertificate.Alias; managementParser.JobCertificateProperties.PrivateKeyPassword = "**********"; @@ -140,7 +140,7 @@ public static string ParseInventoryJobConfiguration(InventoryJobConfiguration co inventoryParser.Capability = config.Capability; // JobProperties - JobProperties jobProperties = JsonConvert.DeserializeObject(config.CertificateStoreDetails.Properties, new JsonSerializerSettings { DefaultValueHandling = DefaultValueHandling.Populate }); + JobProperties jobProperties = JsonConvert.DeserializeObject(config.CertificateStoreDetails?.Properties ?? "{}", new JsonSerializerSettings { DefaultValueHandling = DefaultValueHandling.Populate }); inventoryParser.JobConfigurationProperties = jobProperties; // PreviousInventoryItem diff --git a/IISU/PSHelper.cs b/IISU/PSHelper.cs index acd9831..74774ef 100644 --- a/IISU/PSHelper.cs +++ b/IISU/PSHelper.cs @@ -24,6 +24,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; +using System.IO.Ports; using System.Linq; using System.Management.Automation; using System.Management.Automation.Remoting; @@ -31,6 +32,7 @@ using System.Net; using System.Runtime.InteropServices; using System.Security.AccessControl; +using System.Text; using System.Threading; namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore @@ -57,6 +59,8 @@ public class PSHelper : IDisposable private string serverPassword; private bool isLocalMachine; + private bool isADFSStore = false; + public bool IsLocalMachine { get { return isLocalMachine; } @@ -85,7 +89,13 @@ private set } } - public PSHelper(string protocol, string port, bool useSPN, string clientMachineName, string serverUserName, string serverPassword) + public PSHelper() + { + // Empty constructor for unit testing + _logger = LogHandler.GetClassLogger(); + } + + public PSHelper(string protocol, string port, bool useSPN, string clientMachineName, string serverUserName, string serverPassword, bool isADFSStore = false) { this.protocol = protocol.ToLower(); this.port = port; @@ -101,6 +111,7 @@ public PSHelper(string protocol, string port, bool useSPN, string clientMachineN _logger.LogTrace($"UseSPN: {this.useSPN}"); _logger.LogTrace($"ClientMachineName: {ClientMachineName}"); _logger.LogTrace("Constructor Completed"); + this.isADFSStore = isADFSStore; } public void Initialize() @@ -121,8 +132,8 @@ public void Initialize() _logger.LogDebug($"isLocalMachine flag set to: {isLocalMachine}"); _logger.LogDebug($"Protocol is set to: {protocol}"); - scriptFileLocation = FindPSLocation(AppDomain.CurrentDomain.BaseDirectory, "WinCertScripts.ps1"); - if (scriptFileLocation == null) { throw new Exception("Unable to find the accompanying PowerShell Script file: WinCertScripts.ps1"); } + scriptFileLocation = FindScriptsDirectory(AppDomain.CurrentDomain.BaseDirectory, "PowerShellScripts"); + if (scriptFileLocation == null) { throw new Exception("Unable to find the accompanying PowerShell Script files,"); } _logger.LogTrace($"Script file located here: {scriptFileLocation}"); @@ -147,7 +158,7 @@ public void Initialize() HostName = $hostName } | ConvertTo-Json "; - var results = ExecutePowerShell(psInfo,isScript:true); + var results = ExecutePowerShell(psInfo, isScript: true); foreach (var result in results) { _logger.LogTrace($"{result}"); @@ -156,6 +167,8 @@ public void Initialize() private void InitializeRemoteSession() { + if (this.isADFSStore) throw new Exception("Remote ADFS stores are not supported."); + if (protocol == "ssh") { _logger.LogTrace("Initializing SSH connection"); @@ -235,7 +248,7 @@ private void InitializeRemoteSession() PS.AddCommand("Invoke-Command") .AddParameter("Session", _PSSession) - .AddParameter("ScriptBlock", ScriptBlock.Create(PSHelper.LoadScript(scriptFileLocation))); + .AddParameter("ScriptBlock", ScriptBlock.Create(LoadAllScripts(scriptFileLocation))); var results = PS.Invoke(); CheckErrors(); @@ -256,6 +269,272 @@ private void InitializeLocalSession() rs.Open(); PS.Runspace = rs; + // Set execution policy + _logger.LogTrace("Setting Execution Policy to Unrestricted"); + SetExecutionPolicyUnrestricted(); + + // Check if ADFS module is available (only needed for ADFS stores) + bool adfsModuleImported = false; + if (this.isADFSStore) + { + adfsModuleImported = ImportAdfsModule(); + } + + // Load all scripts + _logger.LogTrace("Loading PowerShell scripts"); + var scriptFiles = GetScriptFiles(scriptFileLocation); + + foreach (var scriptFile in scriptFiles) + { + var fileName = Path.GetFileName(scriptFile); + bool isAdfsScript = fileName.IndexOf("adfs", StringComparison.OrdinalIgnoreCase) >= 0; + + // Decide whether to load this script + if (isAdfsScript) + { + if (this.isADFSStore) + { + if (!adfsModuleImported) + { + _logger.LogWarning($"Skipping ADFS script '{fileName}' - ADFS module not available"); + continue; + } + + _logger.LogTrace($"Loading ADFS script: {fileName}"); + } + else + { + _logger.LogTrace($"Skipping ADFS script '{fileName}' - not an ADFS store"); + continue; + } + } + else + { + _logger.LogTrace($"Loading script: {fileName}"); + } + + // Load the script + try + { + PS.AddScript($". '{scriptFile}'"); + PS.Invoke(); + + if (PS.HadErrors) + { + _logger.LogError($"Errors loading script '{fileName}':"); + foreach (var error in PS.Streams.Error) + { + _logger.LogError($" {error}"); + } + } + else + { + _logger.LogTrace($" ✓ Successfully loaded {fileName}"); + } + + CheckErrors(); + PS.Commands.Clear(); + } + catch (Exception ex) + { + _logger.LogError($"Exception loading script '{fileName}': {ex.Message}"); + } + } + + _logger.LogInformation("Local PowerShell session initialized successfully"); + } + + /// + /// Import ADFS module if available + /// + /// True if module imported successfully, false otherwise + private bool ImportAdfsModule() + { + _logger.LogTrace("Attempting to import ADFS module..."); + + try + { + // First check if module is available + PS.AddScript("Get-Module -ListAvailable -Name ADFS"); + var availableModules = PS.Invoke(); + + if (availableModules == null || availableModules.Count == 0) + { + _logger.LogWarning("ADFS module not found on this machine"); + _logger.LogWarning("This may not be an ADFS server or ADFS role is not installed"); + PS.Commands.Clear(); + return false; + } + + PS.Commands.Clear(); + + // Module is available, import it + _logger.LogTrace("ADFS module found, importing..."); + PS.AddCommand("Import-Module") + .AddParameter("Name", "ADFS") + .AddParameter("ErrorAction", "Stop"); + + var moduleResult = PS.Invoke(); + + if (PS.HadErrors) + { + _logger.LogWarning("ADFS module import had errors:"); + foreach (var error in PS.Streams.Error) + { + _logger.LogWarning($" {error}"); + } + PS.Streams.Error.Clear(); + PS.Commands.Clear(); + return false; + } + + PS.Commands.Clear(); + + // Verify module loaded + PS.AddScript("Get-Module -Name ADFS"); + var loadedModules = PS.Invoke(); + + if (loadedModules != null && loadedModules.Count > 0) + { + var module = loadedModules[0]; + var version = module.Properties["Version"]?.Value?.ToString(); + _logger.LogInformation($"✓ ADFS module imported successfully (Version: {version})"); + PS.Commands.Clear(); + return true; + } + else + { + _logger.LogWarning("ADFS module import reported success but module not loaded"); + PS.Commands.Clear(); + return false; + } + } + catch (Exception ex) + { + _logger.LogWarning($"Could not import ADFS module: {ex.Message}"); + _logger.LogWarning("ADFS cmdlets may not be available"); + + try + { + PS.Commands.Clear(); + } + catch { } + + return false; + } + } + private void SetExecutionPolicyUnrestricted() + { + try + { + PS.AddScript("Set-ExecutionPolicy Unrestricted -Scope Process -Force"); + PS.Invoke(); + + // Check if there were any errors + if (PS.HadErrors) + { + foreach (var error in PS.Streams.Error) + { + var errorMsg = error.ToString(); + + // Execution policy messages are informational, not errors + if (errorMsg.Contains("execution policy successfully") || + errorMsg.Contains("setting is overridden")) + { + _logger.LogInformation($"Execution Policy Info: {errorMsg}"); + } + else + { + // Real error + _logger.LogError($"Execution Policy Error: {errorMsg}"); + throw new Exception($"Failed to set execution policy: {errorMsg}"); + } + } + } + + _logger.LogTrace("Execution policy set successfully"); + } + finally + { + // Always clear errors and commands + PS.Streams.Error.Clear(); + 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 @@ -278,12 +557,17 @@ public void Terminate() { try { - PS.AddCommand("Remove-PSSession").AddParameter("Session", _PSSession); - PS.Invoke(); - CheckErrors(); + if (_PSSession != null && _PSSession.Count > 0) + { + _logger.LogTrace("Removing remote PSSession."); + PS.AddCommand("Remove-PSSession").AddParameter("Session", _PSSession); + PS.Invoke(); + CheckErrors(); + } } - catch (Exception) + catch (Exception ex) { + _logger.LogDebug($"Error while removing PSSession: {ex.Message}"); } } @@ -294,9 +578,13 @@ public void Terminate() File.Delete(tempKeyFilePath); _logger.LogTrace($"Temporary KeyFilePath deleted: {tempKeyFilePath}"); } - catch (Exception) + catch (FileNotFoundException) { - _logger.LogError($"Error while deleting KeyFilePath."); + _logger.LogTrace($"Temporary KeyFilePath was not found: {tempKeyFilePath}"); + } + catch (Exception ex) + { + _logger.LogDebug($"Error while deleting KeyFilePath: {ex.Message}"); } } @@ -304,15 +592,16 @@ public void Terminate() { PS.Runspace.Close(); } - catch (Exception) + catch (Exception ex) { + _logger.LogDebug($"Error while attempting to close the PowerShell Runspace: {ex.Message}"); } PS.Dispose(); } #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. - public Collection? InvokeFunction(string functionName, Dictionary? parameters = null) + public Collection? InvokeFunctionOLD(string functionName, Dictionary? parameters = null) #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. { PS.Commands.Clear(); @@ -344,6 +633,77 @@ public void Terminate() return results; } +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + public Collection? InvokeFunction(string functionName, Dictionary? parameters = null) +#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + { + PS.Commands.Clear(); + + if (isLocalMachine) + { + PS.AddCommand(functionName); + if (parameters != null) + { + foreach (var param in parameters) + { + PS.AddParameter(param.Key, param.Value); + } + } + } + else + { + string scriptBlock; + + if (parameters != null && parameters.Count > 0) + { + // Build parameter list for param() block + var paramNames = parameters.Keys.Select(k => $"${k}").ToArray(); + var paramBlock = string.Join(", ", paramNames); + + // Build function call with named parameters + var functionCall = new System.Text.StringBuilder(functionName); + foreach (var param in parameters) + { + functionCall.Append($" -{param.Key} ${param.Key}"); + } + + // Create ScriptBlock with param() and function call + scriptBlock = $@" + param({paramBlock}) + {functionCall} + "; + + _logger.LogTrace($"Remote ScriptBlock: {scriptBlock}"); + _logger.LogTrace($"ArgumentList: {string.Join(", ", parameters.Keys)}"); + + PS.AddCommand("Invoke-Command") + .AddParameter("Session", _PSSession) + .AddParameter("ScriptBlock", ScriptBlock.Create(scriptBlock)) + .AddParameter("ArgumentList", parameters.Values.ToArray()); + } + else + { + // No parameters - simple function call + scriptBlock = functionName; + + PS.AddCommand("Invoke-Command") + .AddParameter("Session", _PSSession) + .AddParameter("ScriptBlock", ScriptBlock.Create(scriptBlock)); + } + } + + _logger.LogTrace($"Attempting to InvokeFunction: {functionName}"); + var results = PS.Invoke(); + + if (PS.HadErrors) + { + string errorMessages = string.Join("; ", PS.Streams.Error.Select(e => e.ToString())); + throw new Exception($"Error executing function '{functionName}': {errorMessages}"); + } + + return results; + } + public Collection ExecutePowerShellScript(string script) { PS.AddScript(script); @@ -461,46 +821,6 @@ private void CheckErrors() } } - public static string LoadScript(string scriptFileName) - { - string scriptFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "PowerShellScripts", scriptFileName); - _logger.LogTrace($"Attempting to load script {scriptFilePath}"); - - if (File.Exists(scriptFilePath)) - { - return File.ReadAllText(scriptFilePath); - }else - { throw new Exception($"File: {scriptFilePath} was not found."); } - } - - private static string FindPSLocation(string directory, string fileName) - { - try - { - foreach (string file in Directory.GetFiles(directory)) - { - if (Path.GetFileName(file).Equals(fileName, StringComparison.OrdinalIgnoreCase)) - { - return Path.GetFullPath(file); - } - } - - foreach (string subDir in Directory.GetDirectories(directory)) - { - string result = FindPSLocation(subDir, fileName); - if (!string.IsNullOrEmpty(result)) - { - return result; - } - } - } - catch (UnauthorizedAccessException) - { - } - - return null; - } - #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. public static void ProcessPowerShellScriptEvent(object? sender, DataAddedEventArgs e) #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. @@ -639,5 +959,210 @@ private static string formatPrivateKey(string privateKey) return privateKey.Replace($" {keyType} PRIVATE ", "^^^").Replace(" ", System.Environment.NewLine).Replace("^^^", $" {keyType} PRIVATE ") + System.Environment.NewLine; } + + public static string FindScriptsDirectory(string rootDirectory, string directoryName) + { + /* + * Searches for the scripts directory starting from searchRoot + * + * Example: + * FindScriptsDirectory(@"C:\Program Files\MyApp", "Scripts") + * Returns: "C:\Program Files\MyApp\Scripts" (if found) + */ + + try + { + // Check if the current directory matches + if (Path.GetFileName(rootDirectory) + .Equals(directoryName, StringComparison.OrdinalIgnoreCase)) + { + return rootDirectory; + } + + // Recurse into subdirectories + foreach (string subDir in Directory.GetDirectories(rootDirectory)) + { + string result = FindScriptsDirectory(subDir, directoryName); + if (!string.IsNullOrEmpty(result)) + { + return result; + } + } + } + catch (UnauthorizedAccessException) + { + // Skip directories that cannot be accessed + } + catch (DirectoryNotFoundException) + { + // Skip directories that might have been deleted + } + + 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}"); + + if (File.Exists(scriptFileName)) + { + return File.ReadAllText(scriptFileName); + } + else + { throw new Exception($"File: {scriptFileName} was not found."); } + } + public string LoadAllScripts(string scriptFileLocation) + { + /* + * Loads all .ps1 files from the scripts directory into a single script string + * + * scriptFileLocation can be: + * - A file path: C:\MyApp\Scripts\WinCertScripts.ps1 + * - A directory path: C:\MyApp\Scripts + * + * Returns: Combined script content of all .ps1 files + */ + + var scriptBuilder = new StringBuilder(); + + // 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}"); + } + 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}"); + } + + _logger.LogInformation($"Loading scripts from: {scriptsDirectory}"); + + // Load all .ps1 files from the scripts directory + var scriptFiles = Directory.GetFiles(scriptsDirectory, "*.ps1").ToList(); + + if (scriptFiles.Count == 0) + { + throw new FileNotFoundException($"No .ps1 files found in: {scriptsDirectory}"); + } + + _logger.LogInformation($"Found {scriptFiles.Count} script file(s) to load"); + + // Load each script file + foreach (var scriptFile in scriptFiles) + { + var fileName = Path.GetFileName(scriptFile); + _logger.LogTrace($"Loading script: {fileName}"); + + try + { + var scriptContent = File.ReadAllText(scriptFile); + + // Remove auto-initialization lines that won't work remotely + scriptContent = RemoveAutoInitialization(scriptContent); + + scriptBuilder.AppendLine("# ============================================================================"); + scriptBuilder.AppendLine($"# Script: {fileName}"); + scriptBuilder.AppendLine("# ============================================================================"); + scriptBuilder.AppendLine(scriptContent); + scriptBuilder.AppendLine(); + scriptBuilder.AppendLine($"# --- End of {fileName} ---"); + scriptBuilder.AppendLine(); + } + catch (Exception ex) + { + _logger.LogError($"Failed to load script {fileName}: {ex.Message}"); + throw new Exception($"Failed to load script {fileName}: {ex.Message}", ex); + } + } + + scriptBuilder.AppendLine("# All scripts loaded."); + + var combinedScript = scriptBuilder.ToString(); + _logger.LogInformation($"Combined script size: {combinedScript.Length} characters ({scriptFiles.Count} files)"); + + return combinedScript; + } + + /// + /// Removes auto-initialization lines that won't work in remote context + /// + private string RemoveAutoInitialization(string scriptContent) + { + var lines = scriptContent.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); + + // Remove lines that call Initialize-Extensions or similar initialization + var filteredLines = lines.Where(line => + { + var trimmedLine = line.Trim(); + + // Skip initialization lines that depend on file system + if (trimmedLine.Equals("Initialize-Extensions", StringComparison.OrdinalIgnoreCase) || + trimmedLine.StartsWith("Initialize-Extensions ", StringComparison.OrdinalIgnoreCase) || + trimmedLine.StartsWith(". $PSScriptRoot", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + }); + + return string.Join(Environment.NewLine, filteredLines); + } } } diff --git a/IISU/PowerShellScripts/WinADFSScripts.ps1 b/IISU/PowerShellScripts/WinADFSScripts.ps1 new file mode 100644 index 0000000..7f6ce51 --- /dev/null +++ b/IISU/PowerShellScripts/WinADFSScripts.ps1 @@ -0,0 +1,943 @@ +# Version 1.0.0 + +# Summary +# Contains PowerShell functions to execute administration jobs for Windows ADFS. This script is loaded in addition to the main WinCert PowerShell scripts. +# There are additional supporting PowerShell functions to support job specific actions. + +<# +.SYNOPSIS + ADFS Inventory and Management Functions +.DESCRIPTION + Functions for collecting ADFS farm inventory, certificate information, + and managing ADFS certificates across multiple nodes. +#> + +function Get-AdfsFarmProperties { + <# + .SYNOPSIS + Get ADFS farm properties + #> + try { + $props = Get-ADFSProperties + $farmInfo = Get-AdfsFarmInformation + + return [PSCustomObject]@{ + HostName = $props.HostName + Identifier = $props.Identifier + ServiceAccountName = $props.ServiceAccountName + FarmBehaviorLevel = $farmInfo.CurrentFarmBehavior + } + } + catch { + Write-Error "Failed to get ADFS farm properties: $_" + throw + } +} + +function Get-AdfsFarmNodeList { + <# + .SYNOPSIS + Get list of all ADFS farm nodes + #> + try { + $farmInfo = Get-AdfsFarmInformation + return $farmInfo.FarmNodes + } + catch { + Write-Error "Failed to get ADFS farm nodes: $_" + throw + } +} + +function Get-AdfsNodeDetails { + <# + .SYNOPSIS + Get detailed information for a specific ADFS node + .PARAMETER NodeName + Name of the ADFS node to query + #> + param( + [Parameter(Mandatory=$true)] + [string]$NodeName + ) + + try { + # Get service status + $service = Get-Service -Name adfssrv -ErrorAction Stop + + # Try to get sync properties (only works on secondary nodes) + try { + $syncProps = Get-AdfsSyncProperties -ErrorAction Stop + $role = $syncProps.Role + $lastSync = $syncProps.LastSyncTime + $syncStatus = $syncProps.SyncStatus + } + catch { + # This is the primary node + $role = "PrimaryComputer" + $lastSync = $null + $syncStatus = "N/A" + } + + return [PSCustomObject]@{ + NodeName = $env:COMPUTERNAME + ServiceStatus = $service.Status.ToString() + Role = $role + LastSyncTime = $lastSync + SyncStatus = $syncStatus + } + } + catch { + Write-Error "Failed to get node details for ${NodeName}: $_" + throw + } +} + +function Get-AdfsCertificateInventory { + <# + .SYNOPSIS + Get all ADFS certificates with detailed information + #> + try { + $certs = Get-AdfsCertificate + $now = Get-Date + + $results = @() + foreach ($cert in $certs) { + $daysUntilExpiry = ($cert.Certificate.NotAfter - $now).Days + + $results += [PSCustomObject]@{ + CertificateType = $cert.CertificateType + IsPrimary = $cert.IsPrimary + Thumbprint = $cert.Certificate.Thumbprint + Subject = $cert.Certificate.Subject + Issuer = $cert.Certificate.Issuer + NotBefore = $cert.Certificate.NotBefore + NotAfter = $cert.Certificate.NotAfter + DaysUntilExpiry = $daysUntilExpiry + IsExpired = $daysUntilExpiry -lt 0 + IsExpiringSoon = ($daysUntilExpiry -lt 60 -and $daysUntilExpiry -ge 0) + } + } + + return $results + } + catch { + Write-Error "Failed to get ADFS certificates: $_" + throw + } +} + +function Update-AdfsServiceCommunicationsCertificate { + <# + .SYNOPSIS + Update Service-Communications certificate on current node + .PARAMETER PfxFilePath + Path to the PFX certificate file + .PARAMETER PfxPassword + Password for the PFX file (as SecureString) + #> + param( + [Parameter(Mandatory=$true)] + [string]$PfxFilePath, + + [Parameter(Mandatory=$true)] + [SecureString]$PfxPassword + ) + + try { + Write-Host "Importing certificate on $env:COMPUTERNAME..." + + # Import certificate + $cert = Import-PfxCertificate -FilePath $PfxFilePath ` + -Password $PfxPassword ` + -CertStoreLocation 'Cert:\LocalMachine\My' ` + -ErrorAction Stop + + Write-Host "✓ Certificate imported: $($cert.Thumbprint)" + + # Restart ADFS service + Write-Host "Restarting ADFS service..." + Restart-Service -Name adfssrv -Force -ErrorAction Stop + + Write-Host "✓ ADFS service restarted" + + return [PSCustomObject]@{ + Success = $true + Thumbprint = $cert.Thumbprint + NodeName = $env:COMPUTERNAME + } + } + catch { + Write-Error "Failed to update certificate on ${env:COMPUTERNAME}: $_" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = $_.Exception.Message + NodeName = $env:COMPUTERNAME + } + } +} + +function Set-AdfsFarmCertificateSettings { + <# + .SYNOPSIS + Update ADFS farm certificate settings (run on primary node only) + .PARAMETER CertificateThumbprint + Thumbprint of the new certificate + #> + param( + [Parameter(Mandatory=$true)] + [string]$CertificateThumbprint + ) + + try { + Write-Host "Updating ADFS farm certificate settings..." + + # Update SSL certificate + Set-AdfsSslCertificate -Thumbprint $CertificateThumbprint -ErrorAction Stop + Write-Host "✓ SSL certificate updated" + + # Update Service-Communications certificate + Set-AdfsCertificate -CertificateType Service-Communications ` + -Thumbprint $CertificateThumbprint -ErrorAction Stop + Write-Host "✓ Service-Communications certificate updated" + + # Check for alternate TLS client binding (certificate authentication) + $cert = Get-ChildItem -Path "Cert:\LocalMachine\My\$CertificateThumbprint" + if ($cert.DnsNameList -match 'certauth.') { + Set-AdfsAlternateTlsClientBinding -Thumbprint $CertificateThumbprint -ErrorAction Stop + Write-Host "✓ Alternate TLS client binding updated" + } + + return [PSCustomObject]@{ + Success = $true + Message = "ADFS farm certificate settings updated successfully" + } + } + catch { + Write-Error "Failed to update ADFS farm settings: $_" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = $_.Exception.Message + } + } +} + +function Test-AdfsNodeConnectivity { + <# + .SYNOPSIS + Test connectivity to an ADFS node + .PARAMETER NodeName + Name of the node to test + #> + param( + [Parameter(Mandatory=$true)] + [string]$NodeName + ) + + try { + $result = Test-NetConnection -ComputerName $NodeName -Port 5985 -InformationLevel Quiet + + return [PSCustomObject]@{ + NodeName = $NodeName + IsReachable = $result + Port = 5985 + } + } + catch { + return [PSCustomObject]@{ + NodeName = $NodeName + IsReachable = $false + ErrorMessage = $_.Exception.Message + } + } +} + +function Get-AdfsCertificateStatus { + <# + .SYNOPSIS + Get status of a specific certificate type + .PARAMETER CertificateType + Type of certificate (Token-Signing, Token-Decrypting, Service-Communications) + #> + param( + [Parameter(Mandatory=$true)] + [ValidateSet('Token-Signing', 'Token-Decrypting', 'Service-Communications')] + [string]$CertificateType + ) + + try { + $certs = Get-AdfsCertificate -CertificateType $CertificateType + $now = Get-Date + + $results = @() + foreach ($cert in $certs) { + $daysUntilExpiry = ($cert.Certificate.NotAfter - $now).Days + + $results += [PSCustomObject]@{ + CertificateType = $cert.CertificateType + IsPrimary = $cert.IsPrimary + Thumbprint = $cert.Certificate.Thumbprint + Subject = $cert.Certificate.Subject + NotAfter = $cert.Certificate.NotAfter + DaysUntilExpiry = $daysUntilExpiry + Status = if ($daysUntilExpiry -lt 0) { "EXPIRED" } + elseif ($daysUntilExpiry -lt 30) { "CRITICAL" } + elseif ($daysUntilExpiry -lt 60) { "WARNING" } + else { "OK" } + } + } + + return $results + } + catch { + Write-Error "Failed to get certificate status: $_" + throw + } +} + +function Add-AdfsSecondaryCertificate { + <# + .SYNOPSIS + Add a secondary certificate for rollover preparation + .PARAMETER CertificateType + Type of certificate (Token-Signing or Token-Decrypting) + .PARAMETER Thumbprint + Thumbprint of the certificate to add + #> + param( + [Parameter(Mandatory=$true)] + [ValidateSet('Token-Signing', 'Token-Decrypting')] + [string]$CertificateType, + + [Parameter(Mandatory=$true)] + [string]$Thumbprint + ) + + try { + Write-Host "Adding secondary $CertificateType certificate..." + + # Check if certificate exists in store + $cert = Get-ChildItem -Path "Cert:\LocalMachine\My\$Thumbprint" -ErrorAction Stop + + # Add as secondary certificate + Add-AdfsCertificate -CertificateType $CertificateType ` + -Thumbprint $Thumbprint -ErrorAction Stop + + Write-Host "✓ Secondary certificate added successfully" + Write-Host "" + Write-Host "IMPORTANT: Next Steps for Certificate Rollover" -ForegroundColor Yellow + Write-Host "1. Wait 2-4 weeks for relying parties to update from metadata" -ForegroundColor Yellow + Write-Host "2. Notify Office 365 / external partners if needed" -ForegroundColor Yellow + Write-Host "3. Promote to primary: Set-AdfsCertificate -CertificateType $CertificateType -Thumbprint $Thumbprint -IsPrimary" -ForegroundColor Yellow + Write-Host "4. After promotion, remove old certificate" -ForegroundColor Yellow + + return [PSCustomObject]@{ + Success = $true + CertificateType = $CertificateType + Thumbprint = $Thumbprint + Message = "Secondary certificate added. Wait 2-4 weeks before promoting to primary." + } + } + catch { + Write-Error "Failed to add secondary certificate: $_" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = $_.Exception.Message + } + } +} + +function Set-AdfsPrimaryCertificate { + <# + .SYNOPSIS + Promote a secondary certificate to primary + .PARAMETER CertificateType + Type of certificate (Token-Signing or Token-Decrypting) + .PARAMETER Thumbprint + Thumbprint of the certificate to promote + #> + param( + [Parameter(Mandatory=$true)] + [ValidateSet('Token-Signing', 'Token-Decrypting')] + [string]$CertificateType, + + [Parameter(Mandatory=$true)] + [string]$Thumbprint + ) + + try { + Write-Host "Promoting certificate to primary..." + + Set-AdfsCertificate -CertificateType $CertificateType ` + -Thumbprint $Thumbprint -IsPrimary -ErrorAction Stop + + Write-Host "✓ Certificate promoted to primary" + Write-Host "" + Write-Host "Next Steps:" -ForegroundColor Yellow + Write-Host "1. Monitor for any issues with relying parties" -ForegroundColor Yellow + Write-Host "2. After 1-2 weeks, remove old certificate if no issues" -ForegroundColor Yellow + + return [PSCustomObject]@{ + Success = $true + CertificateType = $CertificateType + Thumbprint = $Thumbprint + Message = "Certificate promoted to primary successfully" + } + } + catch { + Write-Error "Failed to promote certificate: $_" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = $_.Exception.Message + } + } +} + +function Copy-FileToNode { + <# + .SYNOPSIS + Copy a file to a remote node + .PARAMETER SourcePath + Path to source file + .PARAMETER DestinationPath + Destination path on remote machine + .PARAMETER NodeName + Target node name + #> + param( + [Parameter(Mandatory=$true)] + [string]$SourcePath, + + [Parameter(Mandatory=$true)] + [string]$DestinationPath, + + [Parameter(Mandatory=$true)] + [string]$NodeName + ) + + try { + Write-Host "Copying file to $NodeName..." + + # Read file content as bytes + $fileBytes = [System.IO.File]::ReadAllBytes($SourcePath) + + # Write to destination + [System.IO.File]::WriteAllBytes($DestinationPath, $fileBytes) + + Write-Host "✓ File copied successfully to $DestinationPath" + + return [PSCustomObject]@{ + Success = $true + SourcePath = $SourcePath + DestinationPath = $DestinationPath + NodeName = $env:COMPUTERNAME + } + } + catch { + Write-Error "Failed to copy file: $_" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = $_.Exception.Message + NodeName = $env:COMPUTERNAME + } + } +} + +function Install-AdfsCertificateOnNode { + <# + .SYNOPSIS + Install PFX certificate on the current node + .PARAMETER PfxFilePath + Path to the PFX certificate file + .PARAMETER PfxPasswordText + Password for the PFX file (as plain text - will be converted to SecureString) + #> + param( + [Parameter(Mandatory=$true)] + [string]$PfxFilePath, + + [Parameter(Mandatory=$true)] + [string]$PfxPasswordText + ) + + try { + Write-Host "Installing certificate on $env:COMPUTERNAME..." + + # Convert password to SecureString + $securePassword = ConvertTo-SecureString -String $PfxPasswordText -AsPlainText -Force + + # Import certificate + $cert = Import-PfxCertificate -FilePath $PfxFilePath ` + -Password $securePassword ` + -CertStoreLocation 'Cert:\LocalMachine\My' ` + -Exportable ` + -ErrorAction Stop + + Write-Host "✓ Certificate imported successfully" + Write-Host " Thumbprint: $($cert.Thumbprint)" + Write-Host " Subject: $($cert.Subject)" + Write-Host " Expires: $($cert.NotAfter)" + + return [PSCustomObject]@{ + Success = $true + Thumbprint = $cert.Thumbprint + Subject = $cert.Subject + NotAfter = $cert.NotAfter + NodeName = $env:COMPUTERNAME + } + } + catch { + Write-Error "Failed to install certificate on ${env:COMPUTERNAME}: $_" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = $_.Exception.Message + NodeName = $env:COMPUTERNAME + } + } +} + +function Grant-AdfsCertificatePermissions { + <# + .SYNOPSIS + Grant ADFS service account access to certificate private key + .PARAMETER CertificateThumbprint + Thumbprint of the certificate + .PARAMETER ServiceAccountName + Name of the ADFS service account (optional - will try to detect) + #> + param( + [Parameter(Mandatory=$true)] + [string]$CertificateThumbprint, + + [Parameter(Mandatory=$false)] + [string]$ServiceAccountName + ) + + try { + Write-Information "Checking certificate permissions on $env:COMPUTERNAME..." + + # Get the certificate + $cert = Get-ChildItem -Path "Cert:\LocalMachine\My\$CertificateThumbprint" -ErrorAction Stop + + if (-not $cert.HasPrivateKey) { + Write-Warning "Certificate does not have a private key" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = "Certificate does not have a private key" + NodeName = $env:COMPUTERNAME + } + } + + # If no service account provided, try to get it + if ([string]::IsNullOrWhiteSpace($ServiceAccountName)) { + Write-Verbose " Service account not provided, attempting to detect..." + + try { + # Try to get from ADFS properties + $adfsProps = Get-ADFSProperties -ErrorAction Stop + $ServiceAccountName = $adfsProps.ServiceAccountName + Write-Verbose " Detected service account: $ServiceAccountName" + } + catch { + Write-Warning "Could not detect ADFS service account" + } + + # If still null, try to get from service + if ([string]::IsNullOrWhiteSpace($ServiceAccountName)) { + try { + $service = Get-WmiObject Win32_Service -Filter "Name='adfssrv'" -ErrorAction Stop + $ServiceAccountName = $service.StartName + Write-Verbose " Detected from service: $ServiceAccountName" + } + catch { + Write-Warning "Could not detect service account from Windows service" + } + } + } + + # Check if we have a valid service account + if ([string]::IsNullOrWhiteSpace($ServiceAccountName)) { + Write-Warning "No service account specified and could not auto-detect" + Write-Warning "ADFS service may need manual permission grant if it runs as a domain user" + + return [PSCustomObject]@{ + Success = $true + Skipped = $true + Message = "Service account not available - permissions not granted. May require manual intervention." + NodeName = $env:COMPUTERNAME + } + } + + # Check if service account is a built-in account (which doesn't need explicit permissions) + $builtInAccounts = @('NT AUTHORITY\SYSTEM', 'NT AUTHORITY\NETWORK SERVICE', 'LocalSystem', 'SYSTEM') + if ($builtInAccounts -contains $ServiceAccountName) { + Write-Verbose " Service runs as built-in account ($ServiceAccountName) - explicit permissions not needed" + + return [PSCustomObject]@{ + Success = $true + Skipped = $true + Message = "Service runs as built-in account - explicit permissions not needed" + ServiceAccount = $ServiceAccountName + NodeName = $env:COMPUTERNAME + } + } + + Write-Verbose " Granting permissions to: $ServiceAccountName" + + # Get the private key + $rsaCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert) + $fileName = $rsaCert.Key.UniqueName + + # Private keys are stored here + $privateKeyPath = "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys\$fileName" + + if (Test-Path $privateKeyPath) { + # Get current ACL + $acl = Get-Acl -Path $privateKeyPath + + # Check if account already has permissions + $existingRule = $acl.Access | Where-Object { $_.IdentityReference -eq $ServiceAccountName } + if ($existingRule) { + Write-Verbose " ✓ Service account already has permissions" + return [PSCustomObject]@{ + Success = $true + AlreadyGranted = $true + ServiceAccount = $ServiceAccountName + NodeName = $env:COMPUTERNAME + } + } + + # Create access rule for service account + $accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $ServiceAccountName, + "Read", + "Allow" + ) + + # Add the access rule + $acl.AddAccessRule($accessRule) + + # Set the ACL + Set-Acl -Path $privateKeyPath -AclObject $acl + + Write-Verbose " ✓ Permissions granted to $ServiceAccountName" + + return [PSCustomObject]@{ + Success = $true + ServiceAccount = $ServiceAccountName + PrivateKeyPath = $privateKeyPath + NodeName = $env:COMPUTERNAME + } + } + else { + throw "Private key file not found at $privateKeyPath" + } + } + catch { + Write-Warning "Failed to grant permissions: $_" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = $_.Exception.Message + NodeName = $env:COMPUTERNAME + } + } +} + +function Grant-AdfsCertificatePermissionsOLD { + <# + .SYNOPSIS + Grant ADFS service account access to certificate private key + .PARAMETER CertificateThumbprint + Thumbprint of the certificate + .PARAMETER ServiceAccountName + Name of the ADFS service account + #> + param( + [Parameter(Mandatory=$true)] + [string]$CertificateThumbprint, + + [Parameter(Mandatory=$true)] + [string]$ServiceAccountName + ) + + try { + Write-Verbose "Granting permissions to service account on $env:COMPUTERNAME..." + + # Get the certificate + $cert = Get-ChildItem -Path "Cert:\LocalMachine\My\$CertificateThumbprint" -ErrorAction Stop + + if (-not $cert.HasPrivateKey) { + throw "Certificate does not have a private key" + } + + # Get the private key + $rsaCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert) + $fileName = $rsaCert.Key.UniqueName + + # Private keys are stored here + $privateKeyPath = "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys\$fileName" + + if (Test-Path $privateKeyPath) { + # Get current ACL + $acl = Get-Acl -Path $privateKeyPath + + # Create access rule for service account + $accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $ServiceAccountName, + "Read", + "Allow" + ) + + # Add the access rule + $acl.AddAccessRule($accessRule) + + # Set the ACL + Set-Acl -Path $privateKeyPath -AclObject $acl + + Write-Host "✓ Permissions granted to $ServiceAccountName" + + return [PSCustomObject]@{ + Success = $true + ServiceAccount = $ServiceAccountName + PrivateKeyPath = $privateKeyPath + NodeName = $env:COMPUTERNAME + } + } + else { + throw "Private key file not found at $privateKeyPath" + } + } + catch { + Write-Error "Failed to grant permissions: $_" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = $_.Exception.Message + NodeName = $env:COMPUTERNAME + } + } +} + +function Update-AdfsFarmCertificateSettings { + <# + .SYNOPSIS + Update ADFS farm certificate settings (PRIMARY NODE ONLY) + .PARAMETER CertificateThumbprint + Thumbprint of the new certificate + #> + param( + [Parameter(Mandatory=$true)] + [string]$CertificateThumbprint + ) + + try { + Write-Information "Updating ADFS farm certificate settings on primary node..." + + # Update SSL certificate + Write-Information " Updating SSL certificate..." + + # Get current computer name for -Member parameter + $currentMember = $env:COMPUTERNAME + + Set-AdfsSslCertificate -Thumbprint $CertificateThumbprint -Member $currentMember -ErrorAction Stop + + # Update Service-Communications certificate + Write-Information " Updating Service-Communications certificate..." + Set-AdfsCertificate -CertificateType Service-Communications ` + -Thumbprint $CertificateThumbprint -ErrorAction Stop + + # Check for alternate TLS client binding (certificate authentication) + $cert = Get-ChildItem -Path "Cert:\LocalMachine\My\$CertificateThumbprint" + if ($cert.DnsNameList -match 'certauth.') { + Write-Information " Updating alternate TLS client binding..." + Set-AdfsAlternateTlsClientBinding -Thumbprint $CertificateThumbprint -ErrorAction Stop + } + + Write-Information "✓ ADFS farm certificate settings updated successfully" + + return [PSCustomObject]@{ + Success = $true + Message = "ADFS farm certificate settings updated" + Thumbprint = $CertificateThumbprint + } + } + catch { + Write-Error "Failed to update ADFS farm settings: $_" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = $_.Exception.Message + } + } +} + +function Restart-AdfsServiceOnNode { + <# + .SYNOPSIS + Restart ADFS service on current node + #> + try { + Write-Host "Restarting ADFS service on $env:COMPUTERNAME..." + + Restart-Service -Name adfssrv -Force -ErrorAction Stop + + # Wait a moment and verify it's running + Start-Sleep -Seconds 2 + $service = Get-Service -Name adfssrv + + if ($service.Status -eq 'Running') { + Write-Host "✓ ADFS service restarted successfully" + + return [PSCustomObject]@{ + Success = $true + ServiceStatus = $service.Status.ToString() + NodeName = $env:COMPUTERNAME + } + } + else { + throw "ADFS service is not running after restart. Status: $($service.Status)" + } + } + catch { + Write-Error "Failed to restart ADFS service: $_" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = $_.Exception.Message + NodeName = $env:COMPUTERNAME + } + } +} + +function Remove-OldAdfsCertificate { + <# + .SYNOPSIS + Remove old certificate from node + .PARAMETER CertificateSubject + Subject of the certificate to match + .PARAMETER NewCertificateNotAfter + NotAfter date of the new certificate (to avoid removing it) + #> + param( + [Parameter(Mandatory=$true)] + [string]$CertificateSubject, + + [Parameter(Mandatory=$true)] + [DateTime]$NewCertificateNotAfter + ) + + try { + Write-Host "Removing old certificates on $env:COMPUTERNAME..." + + # Find old certificates with same subject but earlier expiration + $oldCerts = Get-ChildItem "Cert:\LocalMachine\My" | + Where-Object { + $_.Subject -match [regex]::Escape($CertificateSubject) -and + $_.NotAfter -lt $NewCertificateNotAfter + } + + $removedCount = 0 + foreach ($cert in $oldCerts) { + Write-Host " Removing certificate: $($cert.Thumbprint) (expires: $($cert.NotAfter))" + Remove-Item -Path "Cert:\LocalMachine\My\$($cert.Thumbprint)" -Force + $removedCount++ + } + + if ($removedCount -gt 0) { + Write-Host "✓ Removed $removedCount old certificate(s)" + } + else { + Write-Host " No old certificates found to remove" + } + + return [PSCustomObject]@{ + Success = $true + RemovedCount = $removedCount + NodeName = $env:COMPUTERNAME + } + } + catch { + Write-Error "Failed to remove old certificates: $_" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = $_.Exception.Message + NodeName = $env:COMPUTERNAME + } + } +} + +function Remove-TempFileOnNode { + <# + .SYNOPSIS + Remove temporary file from node + .PARAMETER FilePath + Path to file to remove + #> + param( + [Parameter(Mandatory=$true)] + [string]$FilePath + ) + + try { + if (Test-Path $FilePath) { + Remove-Item -Path $FilePath -Force -ErrorAction Stop + Write-Host "✓ Temporary file removed: $FilePath" + + return [PSCustomObject]@{ + Success = $true + FilePath = $FilePath + NodeName = $env:COMPUTERNAME + } + } + else { + Write-Host " Temporary file not found: $FilePath" + return [PSCustomObject]@{ + Success = $true + Message = "File not found" + NodeName = $env:COMPUTERNAME + } + } + } + catch { + Write-Error "Failed to remove temporary file: $_" + return [PSCustomObject]@{ + Success = $false + ErrorMessage = $_.Exception.Message + NodeName = $env:COMPUTERNAME + } + } +} + +function Test-AdfsCertificateInstalled { + <# + .SYNOPSIS + Verify certificate is installed on node + .PARAMETER CertificateThumbprint + Thumbprint of certificate to check + #> + param( + [Parameter(Mandatory=$true)] + [string]$CertificateThumbprint + ) + + try { + $cert = Get-ChildItem -Path "Cert:\LocalMachine\My\$CertificateThumbprint" -ErrorAction Stop + + return [PSCustomObject]@{ + Success = $true + IsInstalled = $true + HasPrivateKey = $cert.HasPrivateKey + Subject = $cert.Subject + Thumbprint = $cert.Thumbprint + NotAfter = $cert.NotAfter + NodeName = $env:COMPUTERNAME + } + } + catch { + return [PSCustomObject]@{ + Success = $true + IsInstalled = $false + NodeName = $env:COMPUTERNAME + } + } +} + +Write-Host "✓ ADFS Inventory and Management functions loaded" -ForegroundColor Green \ No newline at end of file diff --git a/IISU/PowerShellScripts/WinCertScripts.ps1 b/IISU/PowerShellScripts/WinCertScripts.ps1 index 00f7fdf..f13deed 100644 --- a/IISU/PowerShellScripts/WinCertScripts.ps1 +++ b/IISU/PowerShellScripts/WinCertScripts.ps1 @@ -1,4 +1,4 @@ -# Version 1.3.0 +# Version 1.5.0 # Summary # Contains PowerShell functions to execute administration jobs for general Windows certificates, IIS and SQL Server. @@ -11,6 +11,10 @@ # 08/29/25 Fixed the add cert to store function to return the correct thumbprint # Made changes to the IIS Binding logic, breaking it into manageable pieces to aid in debugging issues # 09/16/25 Updated the Get CSP function to handle null values when reading hybrid certificates +# 10/08/25 Updated the Get-KFIISBoundCertificates function to fixed the SSL flag not returning the correct value when reading IIS bindings +# Updated the New-KFIISSiteBinding to correctly update the SSL flags +# Added Test-ValidSslFlags to verify the correct bit flag +# 11/04/25 Updated Get-KFCertificates to get specific certificate by thumbprint # Set preferences globally at the script level $DebugPreference = "Continue" @@ -44,6 +48,7 @@ $InformationPreference = "Continue" # 206 Error WebAdministration module missing # 207 Error IISAdministration module missing # 300 Error Unknown or unhandled exception +# 400 Error Invalid Ssl Flag bit combination function New-ResultObject { param( @@ -68,7 +73,11 @@ function New-ResultObject { function Get-KFCertificates { param ( - [string]$StoreName = "My" # Default store name is "My" (Personal) + [Parameter(Mandatory = $false)] + [string]$StoreName = "My", # Default store name is "My" (Personal) + + [Parameter(Mandatory = $false)] + [string]$Thumbprint # Optional: specific certificate thumbprint to retrieve ) # Define the store path using the provided StoreName parameter @@ -81,8 +90,23 @@ function Get-KFCertificates { return } - # Retrieve all certificates from the specified store - $certificates = Get-ChildItem -Path $storePath + # Retrieve certificates from the specified store + if ($Thumbprint) { + # If thumbprint is provided, retrieve only that specific certificate + # Remove any spaces or special characters from the thumbprint for comparison + $cleanThumbprint = $Thumbprint -replace '[^a-fA-F0-9]', '' + $certificates = Get-ChildItem -Path $storePath | Where-Object { + ($_.Thumbprint -replace '[^a-fA-F0-9]', '') -eq $cleanThumbprint + } + + if (-not $certificates) { + Write-Error "No certificate found with thumbprint '$Thumbprint' in store '$StoreName'." + return + } + } else { + # Retrieve all certificates from the specified store + $certificates = Get-ChildItem -Path $storePath + } # Initialize an empty array to store certificate information objects $certInfoList = @() @@ -119,86 +143,6 @@ function Get-KFCertificates { } } -function Get-KFIISBoundCertificates { - $certificates = @() - $totalBoundCertificates = 0 - - try { - Add-Type -Path "$env:windir\System32\inetsrv\Microsoft.Web.Administration.dll" # -AssemblyName "Microsoft.Web.Administration" - $serverManager = New-Object Microsoft.Web.Administration.ServerManager - } catch { - Write-Error "Failed to create ServerManager. IIS might not be installed." - return - } - - $websites = $serverManager.Sites - Write-Information "There were $($websites.Count) websites found." - - foreach ($site in $websites) { - $siteName = $site.Name - $siteBoundCertificateCount = 0 - - foreach ($binding in $site.Bindings) { - if ($binding.Protocol -eq 'https' -and $binding.CertificateHash) { - - $certHash = ($binding.CertificateHash | ForEach-Object { $_.ToString("X2") }) -join "" - - $storeName = if ($binding.CertificateStoreName) { $binding.CertificateStoreName } else { "My" } - - try { - $cert = Get-ChildItem -Path "Cert:\LocalMachine\$storeName" | Where-Object { - $_.Thumbprint -eq $certHash - } - - if (-not $cert) { - Write-Warning "Certificate with thumbprint not found in Cert:\LocalMachine\$storeName" - continue - } - - $certBase64 = [Convert]::ToBase64String($cert.RawData) - - $ip, $port, $hostname = $binding.BindingInformation -split ":", 3 - - $certInfo = [PSCustomObject]@{ - SiteName = $siteName - Binding = $binding.BindingInformation - IPAddress = $ip - Port = $port - Hostname = $hostname - Protocol = $binding.Protocol - SNI = ($binding.SslFlags -band 1) -eq 1 - ProviderName = Get-CertificateCSP $cert - SAN = Get-KFSAN $cert - Certificate = $cert.Subject - ExpiryDate = $cert.NotAfter - Issuer = $cert.Issuer - Thumbprint = $cert.Thumbprint - HasPrivateKey = $cert.HasPrivateKey - CertificateBase64 = $certBase64 - } - - $certificates += $certInfo - $siteBoundCertificateCount++ - $totalBoundCertificates++ - } catch { - Write-Warning "Could not retrieve certificate details for hash $certHash in store $storeName." - Write-Warning $_ - } - } - } - - Write-Information "Website: $siteName has $siteBoundCertificateCount bindings with certificates." - } - - Write-Information "A total of $totalBoundCertificates bindings with valid certificates were found." - - if ($totalBoundCertificates -gt 0) { - $certificates | ConvertTo-Json - } else { - Write-Information "No valid certificates were found bound to websites." - } -} - function Add-KFCertificateToStore{ param ( [Parameter(Mandatory = $true)] @@ -382,23 +326,94 @@ function Remove-KFCertificateFromStore { } # IIS Functions +function Get-KFIISBoundCertificates { + $certificates = @() + $totalBoundCertificates = 0 + + try { + Add-Type -Path "$env:windir\System32\inetsrv\Microsoft.Web.Administration.dll" # -AssemblyName "Microsoft.Web.Administration" + $serverManager = New-Object Microsoft.Web.Administration.ServerManager + } catch { + Write-Error "Failed to create ServerManager. IIS might not be installed." + return + } + + $websites = $serverManager.Sites + Write-Information "There were $($websites.Count) websites found." + + foreach ($site in $websites) { + $siteName = $site.Name + $siteBoundCertificateCount = 0 + + foreach ($binding in $site.Bindings) { + if ($binding.Protocol -eq 'https' -and $binding.CertificateHash) { + $certHash = ($binding.CertificateHash | ForEach-Object { $_.ToString("X2") }) -join "" + $storeName = if ($binding.CertificateStoreName) { $binding.CertificateStoreName } else { "My" } + + try { + $cert = Get-ChildItem -Path "Cert:\LocalMachine\$storeName" | Where-Object { + $_.Thumbprint -eq $certHash + } + + if (-not $cert) { + Write-Warning "Certificate with thumbprint not found in Cert:\LocalMachine\$storeName" + continue + } + + $certBase64 = [Convert]::ToBase64String($cert.RawData) + $ip, $port, $hostname = $binding.BindingInformation -split ":", 3 + + $certInfo = [PSCustomObject]@{ + SiteName = $siteName + Binding = $binding.BindingInformation + IPAddress = $ip + Port = $port + Hostname = $hostname + Protocol = $binding.Protocol + SNI = $binding.SslFlags + ProviderName = Get-CertificateCSP $cert + SAN = Get-KFSAN $cert + Certificate = $cert.Subject + ExpiryDate = $cert.NotAfter + Issuer = $cert.Issuer + Thumbprint = $cert.Thumbprint + HasPrivateKey = $cert.HasPrivateKey + CertificateBase64 = $certBase64 + } + + $certificates += $certInfo + $siteBoundCertificateCount++ + $totalBoundCertificates++ + } catch { + Write-Warning "Could not retrieve certificate details for hash $certHash in store $storeName." + Write-Warning $_ + } + } + } + + Write-Information "Website: $siteName has $siteBoundCertificateCount bindings with certificates." + } + + Write-Information "A total of $totalBoundCertificates bindings with valid certificates were found." + + if ($totalBoundCertificates -gt 0) { + $certificates | ConvertTo-Json + } else { + Write-Information "No valid certificates were found bound to websites." + } +} function New-KFIISSiteBinding { [CmdletBinding()] [OutputType([pscustomobject])] param ( [Parameter(Mandatory = $true)] [string]$SiteName, - [string]$IPAddress = "*", - [int]$Port = 443, - [AllowEmptyString()] [string]$Hostname = "", - [ValidateSet("http", "https")] [string]$Protocol = "https", - [ValidateScript({ if ($Protocol -eq 'https' -and [string]::IsNullOrEmpty($_)) { throw "Thumbprint is required when Protocol is 'https'" @@ -406,30 +421,26 @@ function New-KFIISSiteBinding { $true })] [string]$Thumbprint, - [string]$StoreName = "My", - [int]$SslFlags = 0 ) Write-Information "Entering PowerShell Script: New-KFIISSiteBinding" -InformationAction SilentlyContinue - Write-Verbose "Function: New-KFIISSiteBinding" Write-Verbose "Parameters: $(($PSBoundParameters.GetEnumerator() | ForEach-Object { "$($_.Key): '$($_.Value)'" }) -join ', ')" try { - # This function mimics IIS Manager behavior: - # - Replaces exact binding matches (same IP:Port:Hostname) - # - Allows multiple bindings with different hostnames (SNI) - # - Lets IIS handle true conflicts rather than pre-checking - - # Step 1: Verify site exists and get management approach + # Step 1: Perform verifications and get management info + # Check SslFlags + if (-not (Test-ValidSslFlags -SslFlags $SslFlags)) { + return New-ResultObject -Status Error 400 -Step "Validation" -ErrorMessage "Invalid SSL Flag bit configuration ($SslFlags)" + } + $managementInfo = Get-IISManagementInfo -SiteName $SiteName if (-not $managementInfo.Success) { return $managementInfo.Result } - # Step 2: Remove existing HTTPS bindings for this exact binding information - # This mimics IIS behavior: replace exact matches, allow different hostnames + # Step 2: Remove existing HTTPS bindings for this binding info $searchBindings = "${IPAddress}:${Port}:${Hostname}" Write-Verbose "Removing existing HTTPS bindings for: $searchBindings" @@ -438,24 +449,69 @@ function New-KFIISSiteBinding { return $removalResult } - # Step 3: Add new binding with SSL certificate - Write-Verbose "Adding new binding with SSL certificate" + # Step 3: Determine SslFlags supported by Microsoft.Web.Administration + if ($SslFlags -gt 3) { + Write-Verbose "SslFlags value $SslFlags exceeds managed API range (0–3). Applying reduced flags for creation." + $SslFlagsApplied = ($SslFlags -band 3) + } else { + $SslFlagsApplied = $SslFlags + } + + # Step 4: Add the new binding with the reduced flag set + Write-Verbose "Adding new binding with SSL certificate (SslFlagsApplied=$SslFlagsApplied)" $addParams = @{ - SiteName = $SiteName - Protocol = $Protocol - IPAddress = $IPAddress - Port = $Port - Hostname = $Hostname - Thumbprint = $Thumbprint - StoreName = $StoreName - SslFlags = $SslFlags + SiteName = $SiteName + Protocol = $Protocol + IPAddress = $IPAddress + Port = $Port + Hostname = $Hostname + Thumbprint = $Thumbprint + StoreName = $StoreName + SslFlags = $SslFlagsApplied UseIISDrive = $managementInfo.UseIISDrive } $addResult = Add-IISBindingWithSSL @addParams - return $addResult + if ($addResult.Status -eq 'Error') { + return $addResult + } + + # Step 5: If extended flags, update via appcmd.exe + if ($SslFlags -gt 3) { + Write-Verbose "Applying full SslFlags=$SslFlags via appcmd" + + $appcmd = Join-Path $env:windir "System32\inetsrv\appcmd.exe" + + # Escape any single quotes in hostname + $safeHostname = $Hostname -replace "'", "''" + $bindingInfo = "${IPAddress}:${Port}:${safeHostname}" + + # Quote site name only if it contains spaces + if ($SiteName -match '\s') { + $siteArg = "/site.name:`"$SiteName`"" + } else { + $siteArg = "/site.name:$SiteName" + } + + # Build binding argument for appcmd + $bindingArg = "/bindings.[protocol='https',bindingInformation='$bindingInfo'].sslFlags:$SslFlags" + + Write-Verbose "Running appcmd: $appcmd $siteArg $bindingArg" + $appcmdOutput = & $appcmd set site $siteArg $bindingArg 2>&1 + Write-Verbose "appcmd output: $appcmdOutput" + + #& $appcmd set site $siteArg $bindingArg | Out-Null + + if ($LASTEXITCODE -ne 0) { + Write-Warning "appcmd failed to set extended SslFlags ($SslFlags) for binding $bindingInfo." + } else { + Write-Verbose "Successfully updated SslFlags to $SslFlags via appcmd." + } + } + + return $addResult } catch { $errorMessage = "Unexpected error in New-KFIISSiteBinding: $($_.Exception.Message)" @@ -482,6 +538,7 @@ function Remove-ExistingIISBinding { try { if ($UseIISDrive) { + Write-Verbose "Using IIS Drive to remove binding" $sitePath = "IIS:\Sites\$SiteName" $site = Get-Item $sitePath $httpsBindings = $site.Bindings.Collection | Where-Object { @@ -498,6 +555,7 @@ function Remove-ExistingIISBinding { } } else { + Write-Verbose "Using Web Administration assembly to remove binding" # ServerManager fallback Add-Type -Path "$env:windir\System32\inetsrv\Microsoft.Web.Administration.dll" $iis = New-Object Microsoft.Web.Administration.ServerManager @@ -510,9 +568,11 @@ function Remove-ExistingIISBinding { foreach ($binding in $httpsBindings) { Write-Verbose "Removing binding: $($binding.BindingInformation)" $site.Bindings.Remove($binding) + Write-Verbose "Successfully removed binding" } $iis.CommitChanges() + Write-Verbose "Committed changes to IIS" } return New-ResultObject -Status Success -Code 0 -Step RemoveBinding -Message "Successfully removed existing bindings" @@ -1464,6 +1524,16 @@ function Parse-DNSubject { return $subjectString } +# Function that determines the validity of the SSL bit flags +function Test-ValidSslFlags { + param([int]$SslFlags) + + $validBits = 1,2,4,8,32,64,128 + $invalidBits = $SslFlags -bxor ($SslFlags -band ($validBits | Measure-Object -Sum).Sum) + + return ($invalidBits -eq 0) +} + # Note: Removed Test-IISBindingConflict function - we now mimic IIS behavior # IIS replaces exact matches and allows multiple hostnames (SNI) on same IP:Port function Get-IISManagementInfo { diff --git a/IISU/SANBuilder.cs b/IISU/SANBuilder.cs new file mode 100644 index 0000000..e289ce4 --- /dev/null +++ b/IISU/SANBuilder.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.Orchestrator.WindowsCertStore +{ + public class SANBuilder + { + public Dictionary SANs { get; set; } = new Dictionary(); + public SANBuilder(Dictionary sans) + { + SANs = sans ?? throw new ArgumentNullException(nameof(sans)); + } + + public string BuildSanString() + { + if (SANs == null || SANs.Count == 0) + return string.Empty; + + var parts = new List(); + + foreach (var entry in SANs) + { + string key = NormalizeSanKey(entry.Key); + if (entry.Value == null) continue; + + parts.AddRange( + entry.Value + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => $"{key}={v.Trim()}") + ); + } + + return string.Join("&", parts); + } + + /// + /// Normalize SAN type keys to RFC-compliant names. + /// + private static string NormalizeSanKey(string key) + { + return key.Trim().ToLower() switch + { + "dns" => "dns", + "ip" or "ip4" or "ip6" => "ipaddress", + "email" or "rfc822" => "email", + "uri" => "uri", + "upn" => "upn", + _ => key.ToLower() // fallback + }; + } + + public override string ToString() + { + if (SANs == null || SANs.Count == 0) + return "No SANs defined."; + + var lines = new List(); + foreach (var entry in SANs) + { + string key = NormalizeSanKey(entry.Key); + string joined = entry.Value != null && entry.Value.Length > 0 + ? string.Join(", ", entry.Value) + : "(none)"; + lines.Add($"{key.ToUpper()}: {joined}"); + } + + return string.Join(Environment.NewLine, lines); + } + } +} diff --git a/IISU/WindowsCertStore.csproj b/IISU/WindowsCertStore.csproj index 93795e3..bd1635f 100644 --- a/IISU/WindowsCertStore.csproj +++ b/IISU/WindowsCertStore.csproj @@ -50,6 +50,9 @@ PreserveNewest + + Always + Always diff --git a/IISU/manifest.json b/IISU/manifest.json index c13cfe7..44c62f9 100644 --- a/IISU/manifest.json +++ b/IISU/manifest.json @@ -36,6 +36,14 @@ "CertStores.WinSql.ReEnrollment": { "assemblypath": "WindowsCertStore.dll", "TypeFullName": "Keyfactor.Extensions.Orchestrator.WindowsCertStore.WinSql.ReEnrollment" + }, + "CertStores.WinAdfs.Inventory": { + "assemblypath": "WindowsCertStore.dll", + "TypeFullName": "Keyfactor.Extensions.Orchestrator.WindowsCertStore.WinAdfs.Inventory" + }, + "CertStores.WinAdfs.Management": { + "assemblypath": "WindowsCertStore.dll", + "TypeFullName": "Keyfactor.Extensions.Orchestrator.WindowsCertStore.WinAdfs.Management" } } } diff --git a/README.md b/README.md index 95cf76d..810e5b1 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ ## Overview The Windows Certificate Orchestrator Extension is a multi-purpose integration that can remotely manage certificates on a Windows Server's Local Machine Store. This extension currently manages certificates for the current store types: +* WinADFS - Rotates the Service-Communications certificate on the primary and secondary AFDS nodes * WinCert - Certificates defined by path set for the Certificate Store * WinIIS - IIS Bound certificates * WinSQL - Certificates that are bound to the specified SQL Instances @@ -43,7 +44,7 @@ For a complete list of local machine cert stores you can execute the PowerShell The returned list will contain the actual certificate store name to be used when entering store location. -This extension implements four job types: Inventory, Management Add/Remove, and Reenrollment. +The ADFS extension performs both Inventory and Management Add jobs. The other extensions implements four job types: Inventory, Management Add/Remove, and Reenrollment. The Keyfactor Universal Orchestrator (UO) and WinCert Extension can be installed on either Windows or Linux operating systems. A UO service managing certificates on remote servers is considered to be acting as an Orchestrator, while a UO Service managing local certificates on the same server running the service is considered an Agent. When acting as an Orchestrator, connectivity from the orchestrator server hosting the WinCert extension to the orchestrated server hosting the certificate stores(s) being managed is achieved via either an SSH (for Linux orchestrated servers) or WinRM (for Windows orchestrated servers) connection. When acting as an agent (Windows only), WinRM may still be used, OR the certificate store can be configured to bypass a WinRM connection and instead directly access the orchestrator server's certificate stores. @@ -68,7 +69,7 @@ In version 2.0 of the IIS Orchestrator, the certificate store type has been rena **Note: If Looking to use GMSA Accounts to run the Service Keyfactor Command 10.2 or greater is required for No Value checkbox to work** -The Windows Certificate Universal Orchestrator extension implements 3 Certificate Store Types. Depending on your use case, you may elect to use one, or all of these Certificate Store Types. Descriptions of each are provided below. +The Windows Certificate Universal Orchestrator extension implements 4 Certificate Store Types. Depending on your use case, you may elect to use one, or all of these Certificate Store Types. Descriptions of each are provided below. - [Windows Certificate](#WinCert) @@ -76,6 +77,8 @@ The Windows Certificate Universal Orchestrator extension implements 3 Certificat - [WinSql](#WinSql) +- [ADFS Rotation Manager](#WinAdfs) + ## Compatibility @@ -167,7 +170,7 @@ Below is a brief summary of the CSPs and their support for RSA and ECC algorithm To use the Windows Certificate Universal Orchestrator extension, you **must** create the Certificate Store Types required for your use-case. This only needs to happen _once_ per Keyfactor Command instance. -The Windows Certificate Universal Orchestrator extension implements 3 Certificate Store Types. Depending on your use case, you may elect to use one, or all of these Certificate Store Types. +The Windows Certificate Universal Orchestrator extension implements 4 Certificate Store Types. Depending on your use case, you may elect to use one, or all of these Certificate Store Types. ### WinCert @@ -285,12 +288,18 @@ the Keyfactor Command Portal | Name | Display Name | Description | Type | Default Value | Entry has a private key | Adding an entry | Removing an entry | Reenrolling an entry | | ---- | ------------ | ---- | ------------- | ----------------------- | ---------------- | ----------------- | ------------------- | ----------- | | ProviderName | Crypto Provider Name | Name of the Windows cryptographic service provider to use when generating and storing private keys. For more information, refer to the section 'Using Crypto Service Providers' | String | | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | - | SAN | SAN | String value specifying the Subject Alternative Name (SAN) to be used when performing reenrollment jobs. Format as a list of = entries separated by ampersands; Example: 'dns=www.example.com&dns=www.example2.com' for multiple SANs. Can be made optional if RFC 2818 is disabled on the CA. | String | | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | ✅ Checked | The Entry Parameters tab should look like this: ![WinCert Entry Parameters Tab](docsource/images/WinCert-entry-parameters-store-type-dialog.png) + + ##### ProviderName + + ![WinCert Entry Parameter - ProviderName](docsource/images/WinCert-entry-parameters-store-type-dialog-ProviderName.png) + + + @@ -420,12 +429,48 @@ the Keyfactor Command Portal | SniFlag | SSL Flags | A 128-Bit Flag that determines what type of SSL settings you wish to use. The default is 0, meaning No SNI. For more information, check IIS documentation for the appropriate bit setting.) | String | 0 | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | | Protocol | Protocol | Multiple choice value specifying the protocol to bind to. Example: 'https' for secure communication. | MultipleChoice | https | 🔲 Unchecked | ✅ Checked | ✅ Checked | ✅ Checked | | ProviderName | Crypto Provider Name | Name of the Windows cryptographic service provider to use when generating and storing private keys. For more information, refer to the section 'Using Crypto Service Providers' | String | | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | - | SAN | SAN | String value specifying the Subject Alternative Name (SAN) to be used when performing reenrollment jobs. Format as a list of = entries separated by ampersands; Example: 'dns=www.example.com&dns=www.example2.com' for multiple SANs. Can be made optional if RFC 2818 is disabled on the CA. | String | | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | ✅ Checked | The Entry Parameters tab should look like this: ![IISU Entry Parameters Tab](docsource/images/IISU-entry-parameters-store-type-dialog.png) + + ##### Port + + ![IISU Entry Parameter - Port](docsource/images/IISU-entry-parameters-store-type-dialog-Port.png) + + + ##### IPAddress + + ![IISU Entry Parameter - IPAddress](docsource/images/IISU-entry-parameters-store-type-dialog-IPAddress.png) + + + ##### HostName + + ![IISU Entry Parameter - HostName](docsource/images/IISU-entry-parameters-store-type-dialog-HostName.png) + + + ##### SiteName + + ![IISU Entry Parameter - SiteName](docsource/images/IISU-entry-parameters-store-type-dialog-SiteName.png) + + + ##### SniFlag + + ![IISU Entry Parameter - SniFlag](docsource/images/IISU-entry-parameters-store-type-dialog-SniFlag.png) + + + ##### Protocol + + ![IISU Entry Parameter - Protocol](docsource/images/IISU-entry-parameters-store-type-dialog-Protocol.png) + + + ##### ProviderName + + ![IISU Entry Parameter - ProviderName](docsource/images/IISU-entry-parameters-store-type-dialog-ProviderName.png) + + + @@ -543,12 +588,152 @@ the Keyfactor Command Portal | ---- | ------------ | ---- | ------------- | ----------------------- | ---------------- | ----------------- | ------------------- | ----------- | | InstanceName | Instance Name | String value specifying the SQL Server instance name to bind the certificate to. Example: 'MSSQLServer' for the default instance or 'Instance1' for a named instance. | String | | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | | ProviderName | Crypto Provider Name | Name of the Windows cryptographic service provider to use when generating and storing private keys. For more information, refer to the section 'Using Crypto Service Providers' | String | | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | - | SAN | SAN | String value specifying the Subject Alternative Name (SAN) to be used when performing reenrollment jobs. Format as a list of = entries separated by ampersands; Example: 'dns=www.example.com&dns=www.example2.com' for multiple SANs. | String | | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | ✅ Checked | The Entry Parameters tab should look like this: ![WinSql Entry Parameters Tab](docsource/images/WinSql-entry-parameters-store-type-dialog.png) + + ##### InstanceName + + ![WinSql Entry Parameter - InstanceName](docsource/images/WinSql-entry-parameters-store-type-dialog-InstanceName.png) + + + ##### ProviderName + + ![WinSql Entry Parameter - ProviderName](docsource/images/WinSql-entry-parameters-store-type-dialog-ProviderName.png) + + + + + + +### WinAdfs + +
Click to expand details + + +WinADFS is a store type designed for managing certificates within Microsoft Active Directory Federation Services (ADFS) environments. This store type enables users to automate the management of certificates used for securing ADFS communications, including tasks such as adding, removing, and renewing certificates associated with ADFS services. +* NOTE: Only the Service-Communications certificate is currently supported. Follow your ADFS best practices for token encrypt and decrypt certificate management. +* NOTE: This extension also supports the auto-removal of expired certificates from the ADFS stores on the Primary and Secondary nodes during the certificate rotation process, along with restarting the ADFS service to apply changes. + + + + +#### ADFS Rotation Manager Requirements + +When using WinADFS, the Universal Orchestrator must act as an agent and be installed on the Primary ADFS server within the ADFS farm. This is necessary because ADFS configurations and certificate management operations must be performed directly on the ADFS server itself to ensure proper functionality and security. + + + +#### Supported Operations + +| Operation | Is Supported | +|--------------|------------------------------------------------------------------------------------------------------------------------| +| Add | ✅ Checked | +| Remove | 🔲 Unchecked | +| Discovery | 🔲 Unchecked | +| Reenrollment | 🔲 Unchecked | +| Create | 🔲 Unchecked | + +#### Store Type Creation + +##### Using kfutil: +`kfutil` is a custom CLI for the Keyfactor Command API and can be used to create certificate store types. +For more information on [kfutil](https://github.com/Keyfactor/kfutil) check out the [docs](https://github.com/Keyfactor/kfutil?tab=readme-ov-file#quickstart) +
Click to expand WinAdfs kfutil details + + ##### Using online definition from GitHub: + This will reach out to GitHub and pull the latest store-type definition + ```shell + # ADFS Rotation Manager + kfutil store-types create WinAdfs + ``` + + ##### Offline creation using integration-manifest file: + If required, it is possible to create store types from the [integration-manifest.json](./integration-manifest.json) included in this repo. + You would first download the [integration-manifest.json](./integration-manifest.json) and then run the following command + in your offline environment. + ```shell + kfutil store-types create --from-file integration-manifest.json + ``` +
+ + +#### Manual Creation +Below are instructions on how to create the WinAdfs store type manually in +the Keyfactor Command Portal +
Click to expand manual WinAdfs details + + Create a store type called `WinAdfs` with the attributes in the tables below: + + ##### Basic Tab + | Attribute | Value | Description | + | --------- | ----- | ----- | + | Name | ADFS Rotation Manager | Display name for the store type (may be customized) | + | Short Name | WinAdfs | Short display name for the store type | + | Capability | WinAdfs | Store type name orchestrator will register with. Check the box to allow entry of value | + | Supports Add | ✅ Checked | Check the box. Indicates that the Store Type supports Management Add | + | Supports Remove | 🔲 Unchecked | Indicates that the Store Type supports Management Remove | + | Supports Discovery | 🔲 Unchecked | Indicates that the Store Type supports Discovery | + | Supports Reenrollment | 🔲 Unchecked | Indicates that the Store Type supports Reenrollment | + | Supports Create | 🔲 Unchecked | Indicates that the Store Type supports store creation | + | Needs Server | ✅ Checked | Determines if a target server name is required when creating store | + | Blueprint Allowed | ✅ Checked | Determines if store type may be included in an Orchestrator blueprint | + | Uses PowerShell | 🔲 Unchecked | Determines if underlying implementation is PowerShell | + | Requires Store Password | 🔲 Unchecked | Enables users to optionally specify a store password when defining a Certificate Store. | + | Supports Entry Password | 🔲 Unchecked | Determines if an individual entry within a store can have a password. | + + The Basic tab should look like this: + + ![WinAdfs Basic Tab](docsource/images/WinAdfs-basic-store-type-dialog.png) + + ##### Advanced Tab + | Attribute | Value | Description | + | --------- | ----- | ----- | + | Supports Custom Alias | Forbidden | Determines if an individual entry within a store can have a custom Alias. | + | Private Key Handling | Required | This determines if Keyfactor can send the private key associated with a certificate to the store. Required because IIS certificates without private keys would be invalid. | + | PFX Password Style | Default | 'Default' - PFX password is randomly generated, 'Custom' - PFX password may be specified when the enrollment job is created (Requires the Allow Custom Password application setting to be enabled.) | + + The Advanced tab should look like this: + + ![WinAdfs Advanced Tab](docsource/images/WinAdfs-advanced-store-type-dialog.png) + + > For Keyfactor **Command versions 24.4 and later**, a Certificate Format dropdown is available with PFX and PEM options. Ensure that **PFX** is selected, as this determines the format of new and renewed certificates sent to the Orchestrator during a Management job. Currently, all Keyfactor-supported Orchestrator extensions support only PFX. + + ##### Custom Fields Tab + Custom fields operate at the certificate store level and are used to control how the orchestrator connects to the remote target server containing the certificate store to be managed. The following custom fields should be added to the store type: + + | Name | Display Name | Description | Type | Default Value/Options | Required | + | ---- | ------------ | ---- | --------------------- | -------- | ----------- | + | spnwithport | SPN With Port | Internally set the -IncludePortInSPN option when creating the remote PowerShell connection. Needed for some Kerberos configurations. | Bool | false | 🔲 Unchecked | + | WinRM Protocol | WinRM Protocol | Multiple choice value specifying which protocol to use. Protocols https or http use WinRM to connect from Windows to Windows Servers. Using ssh is only supported when running the orchestrator in a Linux environment. | MultipleChoice | https,http,ssh | ✅ Checked | + | WinRM Port | WinRM Port | String value specifying the port number that the Windows target server's WinRM listener is configured to use. Example: '5986' for HTTPS or '5985' for HTTP. By default, when using ssh in a Linux environment, the default port number is 22. | String | 5986 | ✅ Checked | + | ServerUsername | Server Username | Username used to log into the target server for establishing the WinRM session. Example: 'administrator' or 'domain\username'. | Secret | | 🔲 Unchecked | + | ServerPassword | Server Password | Password corresponding to the Server Username used to log into the target server. When establishing a SSH session from a Linux environment, the password must include the full SSH Private key. | Secret | | 🔲 Unchecked | + | ServerUseSsl | Use SSL | Determine whether the server uses SSL or not (This field is automatically created) | Bool | true | ✅ Checked | + + The Custom Fields tab should look like this: + + ![WinAdfs Custom Fields Tab](docsource/images/WinAdfs-custom-fields-store-type-dialog.png) + + ##### Entry Parameters Tab + + | Name | Display Name | Description | Type | Default Value | Entry has a private key | Adding an entry | Removing an entry | Reenrolling an entry | + | ---- | ------------ | ---- | ------------- | ----------------------- | ---------------- | ----------------- | ------------------- | ----------- | + | ProviderName | Crypto Provider Name | Name of the Windows cryptographic service provider to use when generating and storing private keys. For more information, refer to the section 'Using Crypto Service Providers' | String | | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | + + The Entry Parameters tab should look like this: + + ![WinAdfs Entry Parameters Tab](docsource/images/WinAdfs-entry-parameters-store-type-dialog.png) + + + ##### ProviderName + + ![WinAdfs Entry Parameter - ProviderName](docsource/images/WinAdfs-entry-parameters-store-type-dialog-ProviderName.png) + + +
@@ -601,7 +786,7 @@ the Keyfactor Command Portal ## Defining Certificate Stores -The Windows Certificate Universal Orchestrator extension implements 3 Certificate Store Types, each of which implements different functionality. Refer to the individual instructions below for each Certificate Store Type that you deemed necessary for your use case from the installation section. +The Windows Certificate Universal Orchestrator extension implements 4 Certificate Store Types, each of which implements different functionality. Refer to the individual instructions below for each Certificate Store Type that you deemed necessary for your use case from the installation section.
Windows Certificate (WinCert) @@ -879,6 +1064,100 @@ Please refer to the **Universal Orchestrator (remote)** usage section ([PAM prov > The content in this section can be supplemented by the [official Command documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Certificate%20Stores.htm?Highlight=certificate%20store). +
+ +
ADFS Rotation Manager (WinAdfs) + +When creating a Certificate Store for WinADFS, the Client Machine name must be set as an agent and use the LocalMachine moniker, for example: myADFSPrimary|LocalMachine. + + +### Store Creation + +#### Manually with the Command UI + +
Click to expand details + +1. **Navigate to the _Certificate Stores_ page in Keyfactor Command.** + + Log into Keyfactor Command, toggle the _Locations_ dropdown, and click _Certificate Stores_. + +2. **Add a Certificate Store.** + + Click the Add button to add a new Certificate Store. Use the table below to populate the **Attributes** in the **Add** form. + + | Attribute | Description | + | --------- |---------------------------------------------------------| + | Category | Select "ADFS Rotation Manager" or the customized certificate store name from the previous step. | + | Container | Optional container to associate certificate store with. | + | Client Machine | Since this extension type must run as an agent (The UO Must be installed on the PRIMARY ADFS Server), the ClientMachine must follow the naming convention as outlined in the Client Machine Instructions. Secondary ADFS Nodes will be automatically be updated with the same certificate added on the PRIMARY ADFS server. | + | Store Path | Fixed string value of 'My' indicating the Personal store on the Local Machine. All ADFS Service-Communications certificates are located in the 'My' personal store by default. | + | Orchestrator | Select an approved orchestrator capable of managing `WinAdfs` certificates. Specifically, one with the `WinAdfs` capability. | + | spnwithport | Internally set the -IncludePortInSPN option when creating the remote PowerShell connection. Needed for some Kerberos configurations. | + | WinRM Protocol | Multiple choice value specifying which protocol to use. Protocols https or http use WinRM to connect from Windows to Windows Servers. Using ssh is only supported when running the orchestrator in a Linux environment. | + | WinRM Port | String value specifying the port number that the Windows target server's WinRM listener is configured to use. Example: '5986' for HTTPS or '5985' for HTTP. By default, when using ssh in a Linux environment, the default port number is 22. | + | ServerUsername | Username used to log into the target server for establishing the WinRM session. Example: 'administrator' or 'domain\username'. | + | ServerPassword | Password corresponding to the Server Username used to log into the target server. When establishing a SSH session from a Linux environment, the password must include the full SSH Private key. | + | ServerUseSsl | Determine whether the server uses SSL or not (This field is automatically created) | + +
+ + + +#### Using kfutil CLI + +
Click to expand details + +1. **Generate a CSV template for the WinAdfs certificate store** + + ```shell + kfutil stores import generate-template --store-type-name WinAdfs --outpath WinAdfs.csv + ``` +2. **Populate the generated CSV file** + + Open the CSV file, and reference the table below to populate parameters for each **Attribute**. + + | Attribute | Description | + | --------- | ----------- | + | Category | Select "ADFS Rotation Manager" or the customized certificate store name from the previous step. | + | Container | Optional container to associate certificate store with. | + | Client Machine | Since this extension type must run as an agent (The UO Must be installed on the PRIMARY ADFS Server), the ClientMachine must follow the naming convention as outlined in the Client Machine Instructions. Secondary ADFS Nodes will be automatically be updated with the same certificate added on the PRIMARY ADFS server. | + | Store Path | Fixed string value of 'My' indicating the Personal store on the Local Machine. All ADFS Service-Communications certificates are located in the 'My' personal store by default. | + | Orchestrator | Select an approved orchestrator capable of managing `WinAdfs` certificates. Specifically, one with the `WinAdfs` capability. | + | Properties.spnwithport | Internally set the -IncludePortInSPN option when creating the remote PowerShell connection. Needed for some Kerberos configurations. | + | Properties.WinRM Protocol | Multiple choice value specifying which protocol to use. Protocols https or http use WinRM to connect from Windows to Windows Servers. Using ssh is only supported when running the orchestrator in a Linux environment. | + | Properties.WinRM Port | String value specifying the port number that the Windows target server's WinRM listener is configured to use. Example: '5986' for HTTPS or '5985' for HTTP. By default, when using ssh in a Linux environment, the default port number is 22. | + | Properties.ServerUsername | Username used to log into the target server for establishing the WinRM session. Example: 'administrator' or 'domain\username'. | + | Properties.ServerPassword | Password corresponding to the Server Username used to log into the target server. When establishing a SSH session from a Linux environment, the password must include the full SSH Private key. | + | Properties.ServerUseSsl | Determine whether the server uses SSL or not (This field is automatically created) | + +3. **Import the CSV file to create the certificate stores** + + ```shell + kfutil stores import csv --store-type-name WinAdfs --file WinAdfs.csv + ``` + +
+ + +#### PAM Provider Eligible Fields +
Attributes eligible for retrieval by a PAM Provider on the Universal Orchestrator + +If a PAM provider was installed _on the Universal Orchestrator_ in the [Installation](#Installation) section, the following parameters can be configured for retrieval _on the Universal Orchestrator_. + + | Attribute | Description | + | --------- | ----------- | + | ServerUsername | Username used to log into the target server for establishing the WinRM session. Example: 'administrator' or 'domain\username'. | + | ServerPassword | Password corresponding to the Server Username used to log into the target server. When establishing a SSH session from a Linux environment, the password must include the full SSH Private key. | + +Please refer to the **Universal Orchestrator (remote)** usage section ([PAM providers on the Keyfactor Integration Catalog](https://keyfactor.github.io/integrations-catalog/content/pam)) for your selected PAM provider for instructions on how to load attributes orchestrator-side. +> Any secret can be rendered by a PAM provider _installed on the Keyfactor Command server_. The above parameters are specific to attributes that can be fetched by an installed PAM provider running on the Universal Orchestrator server itself. + +
+ + +> The content in this section can be supplemented by the [official Command documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Certificate%20Stores.htm?Highlight=certificate%20store). + +
diff --git a/WindowsCertStore.IntegrationTests/ClientConnection.cs b/WindowsCertStore.IntegrationTests/ClientConnection.cs new file mode 100644 index 0000000..4099ad3 --- /dev/null +++ b/WindowsCertStore.IntegrationTests/ClientConnection.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace WindowsCertStore.IntegrationTests +{ + public class ClientConnection + { + public string Machine { get; set; } + public string Username { get; set; } + public string PrivateKey { get; set; } // SSH private key + } +} diff --git a/WindowsCertStore.IntegrationTests/Factories/CertificateFactory.cs b/WindowsCertStore.IntegrationTests/Factories/CertificateFactory.cs new file mode 100644 index 0000000..e6e476b --- /dev/null +++ b/WindowsCertStore.IntegrationTests/Factories/CertificateFactory.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; + +namespace WindowsCertStore.IntegrationTests.Factories +{ + public static class CertificateFactory + { + /// + /// Creates a self-signed certificate, exports it as a PFX with a password, + /// and returns the thumbprint and base64-encoded PFX. + /// + /// The subject name for the certificate (CN=...) + /// The password to protect the PFX file + /// Tuple of Thumbprint and Base64 PFX string + public static (string Thumbprint, string Base64Pfx) CreateSelfSignedCert(string subjectName, string pfxPassword) + { + using (RSA rsa = RSA.Create(2048)) + { + var distinguishedName = new X500DistinguishedName($"CN={subjectName}"); + + var request = new CertificateRequest( + distinguishedName, + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + // Add key usage & basic constraints (minimal for test certs) + request.CertificateExtensions.Add( + new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true)); + request.CertificateExtensions.Add( + new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + // Valid for 1 year + DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddMinutes(-5); + DateTimeOffset notAfter = notBefore.AddYears(1); + + using (X509Certificate2 cert = request.CreateSelfSigned(notBefore, notAfter)) + { + // Export with private key to PFX + byte[] pfxBytes = cert.Export(X509ContentType.Pfx, pfxPassword); + + // Convert PFX to base64 + string base64Pfx = Convert.ToBase64String(pfxBytes); + + // Thumbprint (uppercase, no spaces) + string thumbprint = cert.Thumbprint?.Replace(" ", "").ToUpperInvariant(); + + return (thumbprint, base64Pfx); + } + } + } + + /// + /// Generates a random PFX password. + /// + /// Length of the password (default 24) + /// Randomly generated password string + public static string GeneratePfxPassword(int length = 24) + { + if (length < 8) + throw new ArgumentException("Password length must be at least 8 characters."); + + const string validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()-_=+[]{}<>?"; + + var bytes = new byte[length]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(bytes); + } + + var sb = new StringBuilder(length); + foreach (var b in bytes) + { + sb.Append(validChars[b % validChars.Length]); + } + + return sb.ToString(); + } + } +} diff --git a/WindowsCertStore.IntegrationTests/Factories/ConfigurationFactory.cs b/WindowsCertStore.IntegrationTests/Factories/ConfigurationFactory.cs new file mode 100644 index 0000000..8618313 --- /dev/null +++ b/WindowsCertStore.IntegrationTests/Factories/ConfigurationFactory.cs @@ -0,0 +1,143 @@ +using Keyfactor.Orchestrators.Extensions; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace WindowsCertStore.IntegrationTests.Factories +{ + internal class ConfigurationFactory + { + public static IEnumerable GetInventoryConfig() + { + yield return new InventoryJobConfiguration + { + LastInventory = new List + { + new PreviousInventoryItem + { + } + }, + JobCancelled = false, + ServerError = null, + RequestStatus = 1, + ServerUsername = null, //testCase.Username, + ServerPassword = null, //testCase.Password, + UseSSL = false, + JobProperties = null, + JobTypeId = new Guid("00000000-0000-0000-0000-000000000000"), + JobId = new Guid("e92f7350-251c-4c0a-9e5d-9b3fdb745ca9"), + Capability = "CertStores.IISU.Inventory", + CertificateStoreDetails = new CertificateStore + { + ClientMachine = "", //testCase.Machine, + Properties = JsonConvert.SerializeObject(new Dictionary + { + ["spnwithport"] = "false", + ["WinRm Protocol"] = "http", + ["WinRm Port"] = "5985", + ["ServerUsername"] = "", // testCase.Username, + ["ServerPassword"] = "", // testCase.Password, + ["ServerUseSsl"] = "true" + }), + StorePath = "My", + StorePassword = null, + Type = 104 + } + }; + } + + public static IEnumerable GetManagementConfig() + { + + yield return new ManagementJobConfiguration + { + LastInventory = new List(), + JobCancelled = false, + JobCertificate = new ManagementJobCertificate(), // Class that is customized during test + JobProperties = null, // Dictionary customized during test + OperationType = Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType.Add, + Overwrite = false, + ServerError = null, + JobHistoryId = 12345, + RequestStatus = 1, + ServerUsername = "", // Customize during test + ServerPassword = "", // Customize during test + UseSSL = false, + JobTypeId = new Guid("00000000-0000-0000-0000-000000000000"), + JobId = new Guid("e92f7350-251c-4c0a-9e5d-9b3fdb745ca9"), + Capability = "CertStores.IISU.Management", + + CertificateStoreDetails = new CertificateStore + { + ClientMachine = "", // Customized during test + Properties = "", // Customized JSON string during test + StorePath = "", // Customized during test + StorePassword = null, + Type = 104 + } + }; + } + + public static IEnumerable GetInventoryTestData() + { + // Define test inputs (machine, username, and password) + var testCases = new[] + { + new { Machine = "192.168.230.137", Username = "ad\\administrator", Password = "C:\\Users\\bpokorny\\.ssh\\my_rsa" }, + new { Machine = "192.168.230.137", Username = "ad\\administrator", Password = "C:\\Users\\bpokorny\\.ssh\\my_rsa" } + }; + + foreach (var testCase in testCases) + { + yield return new object[] + { + new InventoryJobConfiguration + { + LastInventory = new List + { + new PreviousInventoryItem + { + Alias = "479D92068614E33B3CB84123AF76F1C40DF4B6F6", + PrivateKeyEntry = true, + Thumbprints = new List + { + "479D92068614E33B3CB84123AF76F1C40DF4B6F6" + } + } + }, + JobCancelled = false, + ServerError = null, + RequestStatus = 1, + ServerUsername = testCase.Username, + ServerPassword = testCase.Password, + UseSSL = false, + JobProperties = null, + JobTypeId = new Guid("00000000-0000-0000-0000-000000000000"), + JobId = new Guid("e92f7350-251c-4c0a-9e5d-9b3fdb745ca9"), + Capability = null, + CertificateStoreDetails = new CertificateStore + { + ClientMachine = testCase.Machine, + Properties = JsonConvert.SerializeObject(new Dictionary + { + ["spnwithport"] = "false", + ["WinRm Protocol"] = "ssh", + ["WinRm Port"] = "22", + ["ServerUsername"] = testCase.Username, + ["ServerPassword"] = testCase.Password, + ["ServerUseSsl"] = "true" + }), + StorePath = "My", + StorePassword = null, + Type = 104 + } + } + }; + } + } + } +} diff --git a/WindowsCertStore.IntegrationTests/Factories/ConnectionFactory.cs b/WindowsCertStore.IntegrationTests/Factories/ConnectionFactory.cs new file mode 100644 index 0000000..75c826b --- /dev/null +++ b/WindowsCertStore.IntegrationTests/Factories/ConnectionFactory.cs @@ -0,0 +1,50 @@ +using Keyfactor.Orchestrators.Extensions; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace WindowsCertStore.IntegrationTests.Factories +{ + internal class ConnectionFactory + { + // Read the list of IP addresses from an environment variable + // Get the credential information from Azure Key Vault or another secure location + public static IEnumerable GetConnection() + { + // 1. Build configuration to read from appsettings.json + var builder = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); + + var config = builder.Build(); + + // 2. Initialize VaultHelper with configuration + var vault = new VaultHelper(config); + + // 3. Retrieve connection details from configuration + var json = File.ReadAllText("servers.json"); + var machines = JsonConvert.DeserializeObject>>(json); + + foreach (var entry in machines) + { + string machineName = entry["Machine"]; + string username = vault.GetSecret("Username"); + string password = vault.GetSecret("Password"); + + yield return new object[] + { + new ClientConnection + { + Machine = machineName, + Username = username, + PrivateKey = password + } + }; + } + } + } +} diff --git a/WindowsCertStore.IntegrationTests/VaultHelper.cs b/WindowsCertStore.IntegrationTests/VaultHelper.cs new file mode 100644 index 0000000..2a00141 --- /dev/null +++ b/WindowsCertStore.IntegrationTests/VaultHelper.cs @@ -0,0 +1,26 @@ +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Configuration; + +namespace WindowsCertStore.IntegrationTests +{ + internal class VaultHelper + { + private readonly SecretClient _secretClient; + + public VaultHelper(IConfiguration configuration) + { + string vaultUri = configuration["KeyVault:Uri"]; + if (string.IsNullOrWhiteSpace(vaultUri)) + throw new InvalidOperationException("Key Vault URI not found in configuration."); + + _secretClient = new SecretClient(new Uri(vaultUri), new DefaultAzureCredential()); + } + + public string GetSecret(string name) + { + KeyVaultSecret secret = _secretClient.GetSecret(name); + return secret.Value; + } + } +} diff --git a/WindowsCertStore.IntegrationTests/WinIISIntegrationTests.cs b/WindowsCertStore.IntegrationTests/WinIISIntegrationTests.cs new file mode 100644 index 0000000..d6ab354 --- /dev/null +++ b/WindowsCertStore.IntegrationTests/WinIISIntegrationTests.cs @@ -0,0 +1,230 @@ +using Castle.Core.Logging; +using Keyfactor.Extensions.Orchestrator.WindowsCertStore.IISU; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Moq; +using Newtonsoft.Json; +using WindowsCertStore.IntegrationTests.Factories; + +namespace WindowsCertStore.IntegrationTests +{ + public class WinIISIntegrationTests + { + private static (string thumbprint, string base64Pfx, string pfxPassword) CreateTestCertificate() + { + string pfxPassword = CertificateFactory.GeneratePfxPassword(); + var (thumbprint, base64Pfx) = CertificateFactory.CreateSelfSignedCert("test.example.com", pfxPassword); + return (thumbprint, base64Pfx, pfxPassword); + } + + private static ManagementJobConfiguration CreateManagementJobConfig( + ClientConnection connection, + string thumbprint, + string base64Pfx, + string pfxPassword, + string alias, + Dictionary managementJobProperties, + string certStorejobProperties, + Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType operationType, + bool overwrite) + { + var job = ConfigurationFactory.GetManagementConfig().First(); + job.ServerUsername = connection.Username; + job.ServerPassword = connection.PrivateKey; + job.OperationType = operationType; + job.Overwrite = overwrite; + job.JobCertificate = new ManagementJobCertificate + { + Thumbprint = thumbprint, + Contents = base64Pfx, + Alias = alias, + PrivateKeyPassword = pfxPassword, + ContentsFormat = "PFX" + }; + job.JobProperties = managementJobProperties; + job.CertificateStoreDetails.ClientMachine = connection.Machine; + job.CertificateStoreDetails.StorePath = "My"; + job.CertificateStoreDetails.Properties = certStorejobProperties; + return job; + } + + private static InventoryJobConfiguration CreateInventoryJobConfig(ClientConnection connection, string certStorejobProperties) + { + var inventoryJob = ConfigurationFactory.GetInventoryConfig().First(); + inventoryJob.ServerUsername = connection.Username; + inventoryJob.ServerPassword = connection.PrivateKey; + inventoryJob.CertificateStoreDetails.ClientMachine = connection.Machine; + inventoryJob.CertificateStoreDetails.StorePath = "My"; + inventoryJob.CertificateStoreDetails.Properties = certStorejobProperties; + return inventoryJob; + } + + // Make this dynamic in the future + private static Dictionary GetManagementJobProperties() => new() + { + ["Protocol"] = "https", + ["IPAddress"] = "*", + ["SiteName"] = "Default Web Site", + ["Port"] = "443", + ["HostName"] = "", + ["SniFlag"] = "0", + ["ProviderName"] = "", + ["SAN"] = "" + }; + + // Make this dynamic in the future + private static string GetCertStoreJobProperties(ClientConnection connection) => + JsonConvert.SerializeObject(new Dictionary + { + ["spnwithport"] = "false", + ["WinRm Protocol"] = "http", + ["WinRm Port"] = "5985", + ["ServerUsername"] = connection.Username, + ["ServerPassword"] = connection.PrivateKey, + ["ServerUseSsl"] = "false" + }); + + private static (bool found, string? alias) FindAliasByThumbprint(IEnumerable inventory, string thumbprint) + { + var matchedItem = inventory + .FirstOrDefault(item => !string.IsNullOrEmpty(item.Alias) && + item.Alias.Split(':')[0].Equals(thumbprint, StringComparison.OrdinalIgnoreCase)); + return (matchedItem != null, matchedItem?.Alias); + } + + private static void AssertJobResult(Keyfactor.Orchestrators.Common.Enums.OrchestratorJobStatusJobResult result, string? failureMessage) + { + switch (result) + { + case Keyfactor.Orchestrators.Common.Enums.OrchestratorJobStatusJobResult.Failure: + Assert.Fail(failureMessage); + break; + case Keyfactor.Orchestrators.Common.Enums.OrchestratorJobStatusJobResult.Success: + case Keyfactor.Orchestrators.Common.Enums.OrchestratorJobStatusJobResult.Warning: + Assert.True(true); + break; + default: + Assert.Fail("Unexpected job result status."); + break; + } + } + + [Theory] + [MemberData(nameof(ConnectionFactory.GetConnection), MemberType = typeof(ConnectionFactory))] + public void WinIIS_Management_Add_Inventory_Remove_EndToEnd_Test(ClientConnection connection) + { + var (thumbprint, base64Pfx, pfxPassword) = CreateTestCertificate(); + var secretResolver = new Mock(); + secretResolver.Setup(m => m.Resolve(It.IsAny())).Returns((string s) => s); + + var managementJobProperties = GetManagementJobProperties(); + var certStorejobProperties = GetCertStoreJobProperties(connection); + + // Add certificate + var addJob = CreateManagementJobConfig( + connection, thumbprint, base64Pfx, pfxPassword, "Test Cert", + managementJobProperties, certStorejobProperties, + Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType.Add, true); + + var management = new Management(secretResolver.Object); + var result = management.ProcessJob(addJob); + AssertJobResult(result.Result, result.FailureMessage); + + // Inventory + var inventoryJob = CreateInventoryJobConfig(connection, certStorejobProperties); + var inventory = new Inventory(secretResolver.Object); + IEnumerable returnedInventory = new List(); + SubmitInventoryUpdate submitInventoryUpdate = items => + { + returnedInventory = items; + return true; + }; + result = inventory.ProcessJob(inventoryJob, submitInventoryUpdate); + AssertJobResult(result.Result, result.FailureMessage); + + var (thumbprintFound, returnedAlias) = FindAliasByThumbprint(returnedInventory, thumbprint); + Assert.True(thumbprintFound, $"The inventory did not return the expected certificate with thumbprint: {thumbprint}"); + + // Remove certificate + var removeJob = CreateManagementJobConfig( + connection, null, "", "", returnedAlias ?? "", + managementJobProperties, certStorejobProperties, + Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType.Remove, false); + + result = management.ProcessJob(removeJob); + Assert.NotNull(result); + AssertJobResult(result.Result, result.FailureMessage); + } + + [Theory] + [MemberData(nameof(ConnectionFactory.GetConnection), MemberType = typeof(ConnectionFactory))] + public void WinIIS_Management_Add_Inventory_Renewal_Inventory_Remove_EndToEnd_Test(ClientConnection connection) + { + var (thumbprint, base64Pfx, pfxPassword) = CreateTestCertificate(); + var secretResolver = new Mock(); + secretResolver.Setup(m => m.Resolve(It.IsAny())).Returns((string s) => s); + + var managementJobProperties = GetManagementJobProperties(); + var certStorejobProperties = GetCertStoreJobProperties(connection); + + // Add certificate + var addJob = CreateManagementJobConfig( + connection, "", base64Pfx, pfxPassword, "", + managementJobProperties, certStorejobProperties, + Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType.Add, true); + + var management = new Management(secretResolver.Object); + var result = management.ProcessJob(addJob); + AssertJobResult(result.Result, result.FailureMessage); + + // Inventory + var inventoryJob = CreateInventoryJobConfig(connection, certStorejobProperties); + var inventory = new Inventory(secretResolver.Object); + IEnumerable returnedInventory = new List(); + SubmitInventoryUpdate submitInventoryUpdate = items => + { + returnedInventory = items; + return true; + }; + result = inventory.ProcessJob(inventoryJob, submitInventoryUpdate); + AssertJobResult(result.Result, result.FailureMessage); + + var (thumbprintFound, returnedAlias) = FindAliasByThumbprint(returnedInventory, thumbprint); + Assert.True(thumbprintFound, $"The inventory did not return the expected certificate with thumbprint: {thumbprint}"); + + // Renew certificate + var (renewalThumbprint, renewalBase64Pfx, renewalPfxPassword) = CreateTestCertificate(); + var renewalJob = CreateManagementJobConfig( + connection, thumbprint, renewalBase64Pfx, renewalPfxPassword, "", + managementJobProperties, certStorejobProperties, + Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType.Add, true); + + result = management.ProcessJob(renewalJob); + AssertJobResult(result.Result, result.FailureMessage); + + // Inventory after renewal + inventoryJob = CreateInventoryJobConfig(connection, certStorejobProperties); + returnedInventory = new List(); + submitInventoryUpdate = items => + { + returnedInventory = items; + return true; + }; + result = inventory.ProcessJob(inventoryJob, submitInventoryUpdate); + AssertJobResult(result.Result, result.FailureMessage); + + var (renewalThumbprintFound, renewalReturnedAlias) = FindAliasByThumbprint(returnedInventory, renewalThumbprint); + Assert.True(renewalThumbprintFound, $"The inventory returned the expected certificate with thumbprint: {renewalThumbprint}"); + + // Remove renewed certificate + var removeJob = CreateManagementJobConfig( + connection, null, "", "", renewalReturnedAlias ?? "", + managementJobProperties, certStorejobProperties, + Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType.Remove, false); + + result = management.ProcessJob(removeJob); + Assert.NotNull(result); + AssertJobResult(result.Result, result.FailureMessage); + } + } +} diff --git a/WindowsCertStore.IntegrationTests/WinSQLIntegrationTests.cs b/WindowsCertStore.IntegrationTests/WinSQLIntegrationTests.cs new file mode 100644 index 0000000..00d6cf2 --- /dev/null +++ b/WindowsCertStore.IntegrationTests/WinSQLIntegrationTests.cs @@ -0,0 +1,226 @@ +using Keyfactor.Extensions.Orchestrator.WindowsCertStore.WinSql; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Moq; +using Newtonsoft.Json; +using WindowsCertStore.IntegrationTests.Factories; + +namespace WindowsCertStore.IntegrationTests +{ + public class WinSQLIntegrationTests + { + private static (string thumbprint, string base64Pfx, string pfxPassword) CreateTestCertificate() + { + string pfxPassword = CertificateFactory.GeneratePfxPassword(); + var (thumbprint, base64Pfx) = CertificateFactory.CreateSelfSignedCert("test.example.com", pfxPassword); + return (thumbprint, base64Pfx, pfxPassword); + } + + private static ManagementJobConfiguration CreateManagementJobConfig( + ClientConnection connection, + string thumbprint, + string base64Pfx, + string pfxPassword, + string alias, + Dictionary managementJobProperties, + string certStorejobProperties, + Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType operationType, + bool overwrite) + { + var job = ConfigurationFactory.GetManagementConfig().First(); + job.ServerUsername = connection.Username; + job.ServerPassword = connection.PrivateKey; + job.OperationType = operationType; + job.Overwrite = overwrite; + job.JobCertificate = new ManagementJobCertificate + { + Thumbprint = thumbprint, + Contents = base64Pfx, + Alias = alias, + PrivateKeyPassword = pfxPassword, + ContentsFormat = "PFX" + }; + job.JobProperties = managementJobProperties; + job.Capability = "CertStores.WinSql.Management"; + job.CertificateStoreDetails.ClientMachine = connection.Machine; + job.CertificateStoreDetails.StorePath = "My"; + job.CertificateStoreDetails.Properties = certStorejobProperties; + return job; + } + + private static InventoryJobConfiguration CreateInventoryJobConfig(ClientConnection connection, string certStorejobProperties) + { + var inventoryJob = ConfigurationFactory.GetInventoryConfig().First(); + inventoryJob.ServerUsername = connection.Username; + inventoryJob.ServerPassword = connection.PrivateKey; + inventoryJob.CertificateStoreDetails.ClientMachine = connection.Machine; + inventoryJob.CertificateStoreDetails.StorePath = "My"; + inventoryJob.CertificateStoreDetails.Properties = certStorejobProperties; + inventoryJob.Capability = "CertStores.WinSql.Inventory"; + return inventoryJob; + } + + private static Dictionary GetManagementJobProperties() => new() + { + ["InstanceName"] = "MSSQLSERVER", + ["ProviderName"] = "", + ["SAN"] = "" + }; + + private static string GetCertStoreJobProperties(ClientConnection connection) => + JsonConvert.SerializeObject(new Dictionary + { + ["spnwithport"] = "false", + ["WinRm Protocol"] = "http", + ["WinRm Port"] = "5985", + ["ServerUsername"] = connection.Username, + ["ServerPassword"] = connection.PrivateKey, + ["ServerUseSsl"] = "false", + ["RestartService"] = "false" + }); + + private static (bool found, string? alias) FindAliasByThumbprint(IEnumerable inventory, string thumbprint) + { + var matchedItem = inventory + .FirstOrDefault(item => !string.IsNullOrEmpty(item.Alias) && + item.Alias.Split(':')[0].Equals(thumbprint, StringComparison.OrdinalIgnoreCase)); + return (matchedItem != null, matchedItem?.Alias); + } + + private static void AssertJobResult(Keyfactor.Orchestrators.Common.Enums.OrchestratorJobStatusJobResult result, string? failureMessage) + { + switch (result) + { + case Keyfactor.Orchestrators.Common.Enums.OrchestratorJobStatusJobResult.Failure: + Assert.Fail(failureMessage); + break; + case Keyfactor.Orchestrators.Common.Enums.OrchestratorJobStatusJobResult.Success: + case Keyfactor.Orchestrators.Common.Enums.OrchestratorJobStatusJobResult.Warning: + Assert.True(true); + break; + default: + Assert.Fail("Unexpected job result status."); + break; + } + } + + [Theory] + [MemberData(nameof(ConnectionFactory.GetConnection), MemberType = typeof(ConnectionFactory))] + public void WinSql_Management_Add_Inventory_Remove_EndToEnd_Test(ClientConnection connection) + { + var (thumbprint, base64Pfx, pfxPassword) = CreateTestCertificate(); + + var secretResolver = new Mock(); + secretResolver.Setup(m => m.Resolve(It.IsAny())).Returns((string s) => s); + + var managementJobProperties = GetManagementJobProperties(); + var certStorejobProperties = GetCertStoreJobProperties(connection); + + // Add certificate + var addJob = CreateManagementJobConfig( + connection, thumbprint, base64Pfx, pfxPassword, "Test Cert", + managementJobProperties, certStorejobProperties, + Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType.Add, true); + + var management = new Management(secretResolver.Object); + var result = management.ProcessJob(addJob); + AssertJobResult(result.Result, result.FailureMessage); + + // Inventory + var inventoryJob = CreateInventoryJobConfig(connection, certStorejobProperties); + var inventory = new Inventory(secretResolver.Object); + IEnumerable returnedInventory = new List(); + SubmitInventoryUpdate submitInventoryUpdate = items => + { + returnedInventory = items; + return true; + }; + result = inventory.ProcessJob(inventoryJob, submitInventoryUpdate); + AssertJobResult(result.Result, result.FailureMessage); + + var (thumbprintFound, returnedAlias) = FindAliasByThumbprint(returnedInventory, thumbprint); + Assert.True(thumbprintFound, $"The inventory did not return the expected certificate with thumbprint: {thumbprint}"); + + // Remove certificate + var removeJob = CreateManagementJobConfig( + connection, null, "", "", returnedAlias ?? "", + managementJobProperties, certStorejobProperties, + Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType.Remove, false); + + result = management.ProcessJob(removeJob); + Assert.NotNull(result); + AssertJobResult(result.Result, result.FailureMessage); + } + + [Theory] + [MemberData(nameof(ConnectionFactory.GetConnection), MemberType = typeof(ConnectionFactory))] + public void WinCert_Management_Add_Inventory_Renewal_Inventory_Remove_EndToEnd_Test(ClientConnection connection) + { + var (thumbprint, base64Pfx, pfxPassword) = CreateTestCertificate(); + var secretResolver = new Mock(); + secretResolver.Setup(m => m.Resolve(It.IsAny())).Returns((string s) => s); + + var managementJobProperties = GetManagementJobProperties(); + var certStorejobProperties = GetCertStoreJobProperties(connection); + + // Add certificate + var addJob = CreateManagementJobConfig( + connection, "", base64Pfx, pfxPassword, "", + managementJobProperties, certStorejobProperties, + Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType.Add, true); + + var management = new Management(secretResolver.Object); + var result = management.ProcessJob(addJob); + AssertJobResult(result.Result, result.FailureMessage); + + // Inventory + var inventoryJob = CreateInventoryJobConfig(connection, certStorejobProperties); + var inventory = new Inventory(secretResolver.Object); + IEnumerable returnedInventory = new List(); + SubmitInventoryUpdate submitInventoryUpdate = items => + { + returnedInventory = items; + return true; + }; + result = inventory.ProcessJob(inventoryJob, submitInventoryUpdate); + AssertJobResult(result.Result, result.FailureMessage); + + var (thumbprintFound, returnedAlias) = FindAliasByThumbprint(returnedInventory, thumbprint); + Assert.True(thumbprintFound, $"The inventory did not return the expected certificate with thumbprint: {thumbprint}"); + + // Renew certificate + var (renewalThumbprint, renewalBase64Pfx, renewalPfxPassword) = CreateTestCertificate(); + var renewalJob = CreateManagementJobConfig( + connection, thumbprint, renewalBase64Pfx, renewalPfxPassword, "", + managementJobProperties, certStorejobProperties, + Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType.Add, true); + + result = management.ProcessJob(renewalJob); + AssertJobResult(result.Result, result.FailureMessage); + + // Inventory after renewal + inventoryJob = CreateInventoryJobConfig(connection, certStorejobProperties); + returnedInventory = new List(); + submitInventoryUpdate = items => + { + returnedInventory = items; + return true; + }; + result = inventory.ProcessJob(inventoryJob, submitInventoryUpdate); + AssertJobResult(result.Result, result.FailureMessage); + + var (renewalThumbprintFound, renewalReturnedAlias) = FindAliasByThumbprint(returnedInventory, renewalThumbprint); + Assert.True(renewalThumbprintFound, $"The inventory returned the expected certificate with thumbprint: {renewalThumbprint}"); + + // Remove renewed certificate + var removeJob = CreateManagementJobConfig( + connection, null, "", "", renewalReturnedAlias ?? "", + managementJobProperties, certStorejobProperties, + Keyfactor.Orchestrators.Common.Enums.CertStoreOperationType.Remove, false); + + result = management.ProcessJob(removeJob); + Assert.NotNull(result); + AssertJobResult(result.Result, result.FailureMessage); + } + } +} diff --git a/WindowsCertStore.IntegrationTests/WindowsCertStore.IntegrationTests.csproj b/WindowsCertStore.IntegrationTests/WindowsCertStore.IntegrationTests.csproj new file mode 100644 index 0000000..b4acaa3 --- /dev/null +++ b/WindowsCertStore.IntegrationTests/WindowsCertStore.IntegrationTests.csproj @@ -0,0 +1,46 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + Always + + + Always + + + + diff --git a/WindowsCertStore.IntegrationTests/appsettings.json b/WindowsCertStore.IntegrationTests/appsettings.json new file mode 100644 index 0000000..4a1c1b5 --- /dev/null +++ b/WindowsCertStore.IntegrationTests/appsettings.json @@ -0,0 +1,5 @@ +{ + "KeyVault": { + "Uri": "https://akv-wincert.vault.azure.net/" + } +} diff --git a/WindowsCertStore.IntegrationTests/servers.json b/WindowsCertStore.IntegrationTests/servers.json new file mode 100644 index 0000000..52339b5 --- /dev/null +++ b/WindowsCertStore.IntegrationTests/servers.json @@ -0,0 +1,3 @@ +[ + { "Machine": "192.168.230.137" } +] diff --git a/WindowsCertStore.UnitTests/AdfsUnitTests.cs b/WindowsCertStore.UnitTests/AdfsUnitTests.cs new file mode 100644 index 0000000..96d42b3 --- /dev/null +++ b/WindowsCertStore.UnitTests/AdfsUnitTests.cs @@ -0,0 +1,36 @@ +using Keyfactor.Extensions.Orchestrator.WindowsCertStore; +using Keyfactor.Orchestrators.Extensions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace WindowsCertStore.UnitTests +{ + public class AdfsUnitTests + { + + [Fact] + public void Test_AdfsInventory() + { + // Arrange + RemoteSettings settings = new RemoteSettings + { + ClientMachineName = "192.168.230.253", + Protocol = "http", + Port = "5985", + IncludePortInSPN = true, + ServerUserName = @"ad\administrator", + ServerPassword = "@dminP@ssword%" + }; + + // Act + Keyfactor.Extensions.Orchestrator.WindowsCertStore.WinAdfs.Inventory adfs = new(); + adfs.QueryWinADFSCertificates(settings, "My"); + + // Assert + + } + } +} diff --git a/WindowsCertStore.UnitTests/CertificateUnitTests.cs b/WindowsCertStore.UnitTests/CertificateUnitTests.cs new file mode 100644 index 0000000..ba85391 --- /dev/null +++ b/WindowsCertStore.UnitTests/CertificateUnitTests.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace WindowsCertStore.UnitTests +{ + public class CertificateUnitTests + { + [Fact] + public void Test_GetCertificateTempPFX_WithValidBase64String_ReturnsFilePath() + { + // Arrange + string base64Cert = "VGhpcyBpcyBzb21lIGJhc2UgNjQgc3RyaW5nIGluZm9ybWF0aW9u"; + + // Act + string tempFilePath = Keyfactor.Extensions.Orchestrator.WindowsCertStore.Certificate.Utilities.WriteCertificateToTempPfx(base64Cert); + + // Assert + Assert.False(string.IsNullOrEmpty(tempFilePath)); + Assert.True(System.IO.File.Exists(tempFilePath)); + + } + } +} diff --git a/WindowsCertStore.UnitTests/PSHelperUnitTests.cs b/WindowsCertStore.UnitTests/PSHelperUnitTests.cs new file mode 100644 index 0000000..0bb1737 --- /dev/null +++ b/WindowsCertStore.UnitTests/PSHelperUnitTests.cs @@ -0,0 +1,27 @@ +using Keyfactor.Extensions.Orchestrator.WindowsCertStore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace WindowsCertStore.UnitTests +{ + public class PSHelperUnitTests + { + [Fact] + public void Test_LoadAllScripts() + { + // Arrange + var psHelper = new Keyfactor.Extensions.Orchestrator.WindowsCertStore.PSHelper(); + string scriptsFolder = PSHelper.FindScriptsDirectory(AppDomain.CurrentDomain.BaseDirectory, "PowerShellScripts"); + + // Act + string scripts = psHelper.LoadAllScripts(scriptsFolder); + // Assert + scripts.Contains("# All scripts loaded."); + + // If no exception is thrown, the test passes + } + } +} diff --git a/WindowsCertStore.UnitTests/SANsUnitTests.cs b/WindowsCertStore.UnitTests/SANsUnitTests.cs new file mode 100644 index 0000000..bf068bb --- /dev/null +++ b/WindowsCertStore.UnitTests/SANsUnitTests.cs @@ -0,0 +1,96 @@ +using Keyfactor.Extensions.Orchestrator.WindowsCertStore; +using Keyfactor.Orchestrators.Extensions; +using System.Security.Permissions; + +namespace WindowsCertStore.UnitTests +{ + public class SANsUnitTests + { + + private ClientPSCertStoreReEnrollment enrollment = new ClientPSCertStoreReEnrollment(); + + [Fact] + public void Test_SANs() + { + // Arrange + var sans = new Dictionary + { + { "dns", new[] { "example.com", "www.example.com" } }, + { "ip", new[] { "192.168.1.1", "2001:0db8:85a3:0000:0000:8a2e:0370:7334" } }, + { "email", new[] { "myemail@company.com" } }, + { "uri", new[] { "http://mycompany.com" } }, + { "upn", new[] { "myusername@company.com" } } + }; + + // Act + var sanBuilder = new Keyfactor.Extensions.Orchestrator.WindowsCertStore.SANBuilder(sans); + string sanString = sanBuilder.BuildSanString(); + string sanToString = sanBuilder.ToString(); + + // Assert + Assert.Equal("dns=example.com&dns=www.example.com&ipaddress=192.168.1.1&ipaddress=2001:0db8:85a3:0000:0000:8a2e:0370:7334&email=myemail@company.com&uri=http://mycompany.com&upn=myusername@company.com", sanString); + Assert.Contains("DNS: example.com, www.example.com", sanToString); + } + [Fact] + public void ResolveSanString_PrefersConfigSANs_WhenBothSourcesExist() + { + // Arrange + var config = new ReenrollmentJobConfiguration + { + JobProperties = new Dictionary + { + { "SAN", "dns=legacy.example.com&dns=old.example.com" } + }, + SANs = new Dictionary + { + { "dns", new[] { "example.com", "www.example.com" } }, + { "ip", new[] { "192.168.1.1" } }, + { "email", new[] { "user@mycompany.com" } } + } + }; + + // Act + string result = enrollment.ResolveSANString(config); + + // Assert + Assert.Contains("dns=example.com", result); + Assert.Contains("dns=www.example.com", result); + Assert.Contains("ipaddress=192.168.1.1", result); + Assert.Contains("email=user@mycompany.com", result); + Assert.DoesNotContain("legacy.example.com", result); // ensure legacy ignored + } + + [Fact] + public void ResolveSanString_UsesLegacySAN_WhenConfigSANsMissing() + { + // Arrange + var config = new ReenrollmentJobConfiguration + { + JobProperties = new Dictionary + { + { "SAN", "dns=legacy.example.com&dns=old.example.com" } + }, + SANs = new Dictionary() + }; + + // Act + string result = enrollment.ResolveSANString(config); + + // Assert + Assert.Equal("dns=legacy.example.com&dns=old.example.com", result); + } + + [Fact] + public void ResolveSanString_ReturnsEmpty_WhenNoSANsProvided() + { + // Arrange + var config = new ReenrollmentJobConfiguration(); + + // Act + string result = enrollment.ResolveSANString(config); + + // Assert + Assert.Equal(string.Empty, result); + } + } +} \ No newline at end of file diff --git a/WindowsCertStore.UnitTests/WindowsCertStore.UnitTests.csproj b/WindowsCertStore.UnitTests/WindowsCertStore.UnitTests.csproj new file mode 100644 index 0000000..97d7012 --- /dev/null +++ b/WindowsCertStore.UnitTests/WindowsCertStore.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/WindowsCertStore.sln b/WindowsCertStore.sln index bbc52c3..113310e 100644 --- a/WindowsCertStore.sln +++ b/WindowsCertStore.sln @@ -38,28 +38,12 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WinCertTestConsole", "WinCertTestConsole\WinCertTestConsole.csproj", "{D0F4A3CC-5236-4393-9C97-AE55ACE319F2}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docsource", "docsource", "{CFCAC7FE-C9E1-4822-A1B5-45F16E62F5FF}" - ProjectSection(SolutionItems) = preProject - docsource\content.md = docsource\content.md - docsource\iisu.md = docsource\iisu.md - docsource\wincert.md = docsource\wincert.md - docsource\winsql.md = docsource\winsql.md - EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "images", "images", "{60C10FF8-54FC-4C18-A2EA-F3580ABF0405}" - ProjectSection(SolutionItems) = preProject - docsource\images\IISU-advanced-store-type-dialog.png = docsource\images\IISU-advanced-store-type-dialog.png - docsource\images\IISU-basic-store-type-dialog.png = docsource\images\IISU-basic-store-type-dialog.png - docsource\images\IISU-custom-fields-store-type-dialog.png = docsource\images\IISU-custom-fields-store-type-dialog.png - docsource\images\IISU-entry-parameters-store-type-dialog.png = docsource\images\IISU-entry-parameters-store-type-dialog.png - docsource\images\WinCert-advanced-store-type-dialog.png = docsource\images\WinCert-advanced-store-type-dialog.png - docsource\images\WinCert-basic-store-type-dialog.png = docsource\images\WinCert-basic-store-type-dialog.png - docsource\images\WinCert-custom-fields-store-type-dialog.png = docsource\images\WinCert-custom-fields-store-type-dialog.png - docsource\images\WinCert-entry-parameters-store-type-dialog.png = docsource\images\WinCert-entry-parameters-store-type-dialog.png - docsource\images\WinSql-advanced-store-type-dialog.png = docsource\images\WinSql-advanced-store-type-dialog.png - docsource\images\WinSql-basic-store-type-dialog.png = docsource\images\WinSql-basic-store-type-dialog.png - docsource\images\WinSql-custom-fields-store-type-dialog.png = docsource\images\WinSql-custom-fields-store-type-dialog.png - docsource\images\WinSql-entry-parameters-store-type-dialog.png = docsource\images\WinSql-entry-parameters-store-type-dialog.png - EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsCertStore.IntegrationTests", "WindowsCertStore.IntegrationTests\WindowsCertStore.IntegrationTests.csproj", "{74FDA232-BC6D-428F-BC28-C5BF228F04ED}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsCertStore.UnitTests", "WindowsCertStore.UnitTests\WindowsCertStore.UnitTests.csproj", "{84DD1D36-2D35-4483-836F-98EFC44CA132}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -84,6 +68,22 @@ Global {D0F4A3CC-5236-4393-9C97-AE55ACE319F2}.Release|Any CPU.ActiveCfg = Release|Any CPU {D0F4A3CC-5236-4393-9C97-AE55ACE319F2}.Release|Any CPU.Build.0 = Release|Any CPU {D0F4A3CC-5236-4393-9C97-AE55ACE319F2}.Release|x64.ActiveCfg = Release|Any CPU + {74FDA232-BC6D-428F-BC28-C5BF228F04ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74FDA232-BC6D-428F-BC28-C5BF228F04ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74FDA232-BC6D-428F-BC28-C5BF228F04ED}.Debug|x64.ActiveCfg = Debug|Any CPU + {74FDA232-BC6D-428F-BC28-C5BF228F04ED}.Debug|x64.Build.0 = Debug|Any CPU + {74FDA232-BC6D-428F-BC28-C5BF228F04ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74FDA232-BC6D-428F-BC28-C5BF228F04ED}.Release|Any CPU.Build.0 = Release|Any CPU + {74FDA232-BC6D-428F-BC28-C5BF228F04ED}.Release|x64.ActiveCfg = Release|Any CPU + {74FDA232-BC6D-428F-BC28-C5BF228F04ED}.Release|x64.Build.0 = Release|Any CPU + {84DD1D36-2D35-4483-836F-98EFC44CA132}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84DD1D36-2D35-4483-836F-98EFC44CA132}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84DD1D36-2D35-4483-836F-98EFC44CA132}.Debug|x64.ActiveCfg = Debug|Any CPU + {84DD1D36-2D35-4483-836F-98EFC44CA132}.Debug|x64.Build.0 = Debug|Any CPU + {84DD1D36-2D35-4483-836F-98EFC44CA132}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84DD1D36-2D35-4483-836F-98EFC44CA132}.Release|Any CPU.Build.0 = Release|Any CPU + {84DD1D36-2D35-4483-836F-98EFC44CA132}.Release|x64.ActiveCfg = Release|Any CPU + {84DD1D36-2D35-4483-836F-98EFC44CA132}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docsource/content.md b/docsource/content.md index 1078017..bed7359 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -1,5 +1,6 @@ ## Overview The Windows Certificate Orchestrator Extension is a multi-purpose integration that can remotely manage certificates on a Windows Server's Local Machine Store. This extension currently manages certificates for the current store types: +* WinADFS - Rotates the Service-Communications certificate on the primary and secondary AFDS nodes * WinCert - Certificates defined by path set for the Certificate Store * WinIIS - IIS Bound certificates * WinSQL - Certificates that are bound to the specified SQL Instances @@ -11,7 +12,7 @@ For a complete list of local machine cert stores you can execute the PowerShell The returned list will contain the actual certificate store name to be used when entering store location. -This extension implements four job types: Inventory, Management Add/Remove, and Reenrollment. +The ADFS extension performs both Inventory and Management Add jobs. The other extensions implements four job types: Inventory, Management Add/Remove, and Reenrollment. The Keyfactor Universal Orchestrator (UO) and WinCert Extension can be installed on either Windows or Linux operating systems. A UO service managing certificates on remote servers is considered to be acting as an Orchestrator, while a UO Service managing local certificates on the same server running the service is considered an Agent. When acting as an Orchestrator, connectivity from the orchestrator server hosting the WinCert extension to the orchestrated server hosting the certificate stores(s) being managed is achieved via either an SSH (for Linux orchestrated servers) or WinRM (for Windows orchestrated servers) connection. When acting as an agent (Windows only), WinRM may still be used, OR the certificate store can be configured to bypass a WinRM connection and instead directly access the orchestrator server's certificate stores. diff --git a/docsource/images/IISU-advanced-store-type-dialog.png b/docsource/images/IISU-advanced-store-type-dialog.png index eab1385..2d6fca8 100644 Binary files a/docsource/images/IISU-advanced-store-type-dialog.png and b/docsource/images/IISU-advanced-store-type-dialog.png differ diff --git a/docsource/images/IISU-basic-store-type-dialog.png b/docsource/images/IISU-basic-store-type-dialog.png index 17be4a5..fa4e029 100644 Binary files a/docsource/images/IISU-basic-store-type-dialog.png and b/docsource/images/IISU-basic-store-type-dialog.png differ diff --git a/docsource/images/IISU-entry-parameters-store-type-dialog-HostName.png b/docsource/images/IISU-entry-parameters-store-type-dialog-HostName.png new file mode 100644 index 0000000..4db52c1 Binary files /dev/null and b/docsource/images/IISU-entry-parameters-store-type-dialog-HostName.png differ diff --git a/docsource/images/IISU-entry-parameters-store-type-dialog-IPAddress.png b/docsource/images/IISU-entry-parameters-store-type-dialog-IPAddress.png new file mode 100644 index 0000000..006f1a1 Binary files /dev/null and b/docsource/images/IISU-entry-parameters-store-type-dialog-IPAddress.png differ diff --git a/docsource/images/IISU-entry-parameters-store-type-dialog-Port.png b/docsource/images/IISU-entry-parameters-store-type-dialog-Port.png new file mode 100644 index 0000000..0ef992b Binary files /dev/null and b/docsource/images/IISU-entry-parameters-store-type-dialog-Port.png differ diff --git a/docsource/images/IISU-entry-parameters-store-type-dialog-Protocol.png b/docsource/images/IISU-entry-parameters-store-type-dialog-Protocol.png new file mode 100644 index 0000000..d91f7f6 Binary files /dev/null and b/docsource/images/IISU-entry-parameters-store-type-dialog-Protocol.png differ diff --git a/docsource/images/IISU-entry-parameters-store-type-dialog-ProviderName.png b/docsource/images/IISU-entry-parameters-store-type-dialog-ProviderName.png new file mode 100644 index 0000000..9598bd5 Binary files /dev/null and b/docsource/images/IISU-entry-parameters-store-type-dialog-ProviderName.png differ diff --git a/docsource/images/IISU-entry-parameters-store-type-dialog-SiteName.png b/docsource/images/IISU-entry-parameters-store-type-dialog-SiteName.png new file mode 100644 index 0000000..7c704cb Binary files /dev/null and b/docsource/images/IISU-entry-parameters-store-type-dialog-SiteName.png differ diff --git a/docsource/images/IISU-entry-parameters-store-type-dialog-SniFlag.png b/docsource/images/IISU-entry-parameters-store-type-dialog-SniFlag.png new file mode 100644 index 0000000..616108b Binary files /dev/null and b/docsource/images/IISU-entry-parameters-store-type-dialog-SniFlag.png differ diff --git a/docsource/images/IISU-entry-parameters-store-type-dialog.png b/docsource/images/IISU-entry-parameters-store-type-dialog.png index e8d8817..ade20fd 100644 Binary files a/docsource/images/IISU-entry-parameters-store-type-dialog.png and b/docsource/images/IISU-entry-parameters-store-type-dialog.png differ diff --git a/docsource/images/WinAdfs-advanced-store-type-dialog.png b/docsource/images/WinAdfs-advanced-store-type-dialog.png new file mode 100644 index 0000000..2d6fca8 Binary files /dev/null and b/docsource/images/WinAdfs-advanced-store-type-dialog.png differ diff --git a/docsource/images/WinAdfs-basic-store-type-dialog.png b/docsource/images/WinAdfs-basic-store-type-dialog.png new file mode 100644 index 0000000..14bd11c Binary files /dev/null and b/docsource/images/WinAdfs-basic-store-type-dialog.png differ diff --git a/docsource/images/WinAdfs-custom-fields-store-type-dialog.png b/docsource/images/WinAdfs-custom-fields-store-type-dialog.png new file mode 100644 index 0000000..1e723bb Binary files /dev/null and b/docsource/images/WinAdfs-custom-fields-store-type-dialog.png differ diff --git a/docsource/images/WinAdfs-entry-parameters-store-type-dialog-ProviderName.png b/docsource/images/WinAdfs-entry-parameters-store-type-dialog-ProviderName.png new file mode 100644 index 0000000..9cde9d3 Binary files /dev/null and b/docsource/images/WinAdfs-entry-parameters-store-type-dialog-ProviderName.png differ diff --git a/docsource/images/WinAdfs-entry-parameters-store-type-dialog.png b/docsource/images/WinAdfs-entry-parameters-store-type-dialog.png new file mode 100644 index 0000000..fd2272b Binary files /dev/null and b/docsource/images/WinAdfs-entry-parameters-store-type-dialog.png differ diff --git a/docsource/images/WinCert-advanced-store-type-dialog.png b/docsource/images/WinCert-advanced-store-type-dialog.png index 8b43572..9000147 100644 Binary files a/docsource/images/WinCert-advanced-store-type-dialog.png and b/docsource/images/WinCert-advanced-store-type-dialog.png differ diff --git a/docsource/images/WinCert-basic-store-type-dialog.png b/docsource/images/WinCert-basic-store-type-dialog.png index 9f18381..75e8360 100644 Binary files a/docsource/images/WinCert-basic-store-type-dialog.png and b/docsource/images/WinCert-basic-store-type-dialog.png differ diff --git a/docsource/images/WinCert-entry-parameters-store-type-dialog-ProviderName.png b/docsource/images/WinCert-entry-parameters-store-type-dialog-ProviderName.png new file mode 100644 index 0000000..9cde9d3 Binary files /dev/null and b/docsource/images/WinCert-entry-parameters-store-type-dialog-ProviderName.png differ diff --git a/docsource/images/WinCert-entry-parameters-store-type-dialog.png b/docsource/images/WinCert-entry-parameters-store-type-dialog.png index df44b2b..c1be0c5 100644 Binary files a/docsource/images/WinCert-entry-parameters-store-type-dialog.png and b/docsource/images/WinCert-entry-parameters-store-type-dialog.png differ diff --git a/docsource/images/WinSql-advanced-store-type-dialog.png b/docsource/images/WinSql-advanced-store-type-dialog.png index 8b43572..9000147 100644 Binary files a/docsource/images/WinSql-advanced-store-type-dialog.png and b/docsource/images/WinSql-advanced-store-type-dialog.png differ diff --git a/docsource/images/WinSql-basic-store-type-dialog.png b/docsource/images/WinSql-basic-store-type-dialog.png index c276ba9..80d4687 100644 Binary files a/docsource/images/WinSql-basic-store-type-dialog.png and b/docsource/images/WinSql-basic-store-type-dialog.png differ diff --git a/docsource/images/WinSql-entry-parameters-store-type-dialog-InstanceName.png b/docsource/images/WinSql-entry-parameters-store-type-dialog-InstanceName.png new file mode 100644 index 0000000..49f27bf Binary files /dev/null and b/docsource/images/WinSql-entry-parameters-store-type-dialog-InstanceName.png differ diff --git a/docsource/images/WinSql-entry-parameters-store-type-dialog-ProviderName.png b/docsource/images/WinSql-entry-parameters-store-type-dialog-ProviderName.png new file mode 100644 index 0000000..b5287bb Binary files /dev/null and b/docsource/images/WinSql-entry-parameters-store-type-dialog-ProviderName.png differ diff --git a/docsource/images/WinSql-entry-parameters-store-type-dialog.png b/docsource/images/WinSql-entry-parameters-store-type-dialog.png index 7dd632d..b28874b 100644 Binary files a/docsource/images/WinSql-entry-parameters-store-type-dialog.png and b/docsource/images/WinSql-entry-parameters-store-type-dialog.png differ diff --git a/docsource/winadfs.md b/docsource/winadfs.md new file mode 100644 index 0000000..5e1b98b --- /dev/null +++ b/docsource/winadfs.md @@ -0,0 +1,14 @@ +## Overview + +WinADFS is a store type designed for managing certificates within Microsoft Active Directory Federation Services (ADFS) environments. This store type enables users to automate the management of certificates used for securing ADFS communications, including tasks such as adding, removing, and renewing certificates associated with ADFS services. +* NOTE: Only the Service-Communications certificate is currently supported. Follow your ADFS best practices for token encrypt and decrypt certificate management. +* NOTE: This extension also supports the auto-removal of expired certificates from the ADFS stores on the Primary and Secondary nodes during the certificate rotation process, along with restarting the ADFS service to apply changes. + +## Requirements + +When using WinADFS, the Universal Orchestrator must act as an agent and be installed on the Primary ADFS server within the ADFS farm. This is necessary because ADFS configurations and certificate management operations must be performed directly on the ADFS server itself to ensure proper functionality and security. + +## Certificate Store Configuration + +When creating a Certificate Store for WinADFS, the Client Machine name must be set as an agent and use the LocalMachine moniker, for example: myADFSPrimary|LocalMachine. + diff --git a/integration-manifest.json b/integration-manifest.json index ef70981..37916bc 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -116,21 +116,6 @@ "DefaultValue": "", "Options": "", "Description": "Name of the Windows cryptographic service provider to use when generating and storing private keys. For more information, refer to the section 'Using Crypto Service Providers'" - }, - { - "Name": "SAN", - "DisplayName": "SAN", - "Type": "String", - "RequiredWhen": { - "HasPrivateKey": false, - "OnAdd": false, - "OnRemove": false, - "OnReenrollment": true - }, - "DependsOn": "", - "DefaultValue": "", - "Options": "", - "Description": "String value specifying the Subject Alternative Name (SAN) to be used when performing reenrollment jobs. Format as a list of = entries separated by ampersands; Example: 'dns=www.example.com&dns=www.example2.com' for multiple SANs. Can be made optional if RFC 2818 is disabled on the CA." } ], "PasswordOptions": { @@ -320,22 +305,7 @@ "DefaultValue": "", "Options": "", "Description": "Name of the Windows cryptographic service provider to use when generating and storing private keys. For more information, refer to the section 'Using Crypto Service Providers'" - }, - { - "Name": "SAN", - "DisplayName": "SAN", - "Type": "String", - "RequiredWhen": { - "HasPrivateKey": false, - "OnAdd": false, - "OnRemove": false, - "OnReenrollment": true - }, - "DependsOn": "", - "DefaultValue": "", - "Options": "", - "Description": "String value specifying the Subject Alternative Name (SAN) to be used when performing reenrollment jobs. Format as a list of = entries separated by ampersands; Example: 'dns=www.example.com&dns=www.example2.com' for multiple SANs. Can be made optional if RFC 2818 is disabled on the CA." - } + } ], "PasswordOptions": { "EntrySupported": false, @@ -351,141 +321,225 @@ "ClientMachineDescription": "Hostname of the Windows Server containing the IIS certificate store to be managed. If this value is a hostname, a WinRM session will be established using the credentials specified in the Server Username and Server Password fields. For more information, see [Client Machine](#note-regarding-client-machine).", "StorePathDescription": "Windows certificate store path to manage. Choose 'My' for the Personal store or 'WebHosting' for the Web Hosting store." }, - { - "Name": "WinSql", - "ShortName": "WinSql", - "Capability": "WinSql", - "LocalStore": false, - "SupportedOperations": { - "Add": true, - "Create": false, - "Discovery": false, - "Enrollment": false, - "Remove": true + { + "Name": "WinSql", + "ShortName": "WinSql", + "Capability": "WinSql", + "LocalStore": false, + "SupportedOperations": { + "Add": true, + "Create": false, + "Discovery": false, + "Enrollment": false, + "Remove": true + }, + "Properties": [ + { + "Name": "spnwithport", + "DisplayName": "SPN With Port", + "Type": "Bool", + "DependsOn": "", + "DefaultValue": "false", + "Required": false, + "Description": "Internally set the -IncludePortInSPN option when creating the remote PowerShell connection. Needed for some Kerberos configurations." + }, + { + "Name": "WinRM Protocol", + "DisplayName": "WinRM Protocol", + "Type": "MultipleChoice", + "DependsOn": "", + "DefaultValue": "https,http,ssh", + "Required": true, + "Description": "Multiple choice value specifying which protocol to use. Protocols https or http use WinRM to connect from Windows to Windows Servers. Using ssh is only supported when running the orchestrator in a Linux environment." + }, + { + "Name": "WinRM Port", + "DisplayName": "WinRM Port", + "Type": "String", + "DependsOn": "", + "DefaultValue": "5986", + "Required": true, + "Description": "String value specifying the port number that the Windows target server's WinRM listener is configured to use. Example: '5986' for HTTPS or '5985' for HTTP. By default, when using ssh in a Linux environment, the default port number is 22." + }, + { + "Name": "ServerUsername", + "DisplayName": "Server Username", + "Type": "Secret", + "DependsOn": "", + "DefaultValue": "", + "Required": false, + "Description": "Username used to log into the target server for establishing the WinRM session. Example: 'administrator' or 'domain\\username'." + }, + { + "Name": "ServerPassword", + "DisplayName": "Server Password", + "Type": "Secret", + "DependsOn": "", + "DefaultValue": "", + "Required": false, + "Description": "Password corresponding to the Server Username used to log into the target server. When establishing a SSH session from a Linux environment, the password must include the full SSH Private key." + }, + { + "Name": "ServerUseSsl", + "DisplayName": "Use SSL", + "Type": "Bool", + "DependsOn": "", + "DefaultValue": "true", + "Required": true, + "Description": "Determine whether the server uses SSL or not (This field is automatically created)" + }, + { + "Name": "RestartService", + "DisplayName": "Restart SQL Service After Cert Installed", + "Type": "Bool", + "DependsOn": "", + "DefaultValue": "false", + "Required": true, + "Description": "Boolean value (true or false) indicating whether to restart the SQL Server service after installing the certificate. Example: 'true' to enable service restart after installation." + } + ], + "EntryParameters": [ + { + "Name": "InstanceName", + "DisplayName": "Instance Name", + "Type": "String", + "RequiredWhen": { + "HasPrivateKey": false, + "OnAdd": false, + "OnRemove": false, + "OnReenrollment": false }, - "Properties": [ - { - "Name": "spnwithport", - "DisplayName": "SPN With Port", - "Type": "Bool", - "DependsOn": "", - "DefaultValue": "false", - "Required": false, - "Description": "Internally set the -IncludePortInSPN option when creating the remote PowerShell connection. Needed for some Kerberos configurations." - }, - { - "Name": "WinRM Protocol", - "DisplayName": "WinRM Protocol", - "Type": "MultipleChoice", - "DependsOn": "", - "DefaultValue": "https,http,ssh", - "Required": true, - "Description": "Multiple choice value specifying which protocol to use. Protocols https or http use WinRM to connect from Windows to Windows Servers. Using ssh is only supported when running the orchestrator in a Linux environment." - }, - { - "Name": "WinRM Port", - "DisplayName": "WinRM Port", - "Type": "String", - "DependsOn": "", - "DefaultValue": "5986", - "Required": true, - "Description": "String value specifying the port number that the Windows target server's WinRM listener is configured to use. Example: '5986' for HTTPS or '5985' for HTTP. By default, when using ssh in a Linux environment, the default port number is 22." - }, - { - "Name": "ServerUsername", - "DisplayName": "Server Username", - "Type": "Secret", - "DependsOn": "", - "DefaultValue": "", - "Required": false, - "Description": "Username used to log into the target server for establishing the WinRM session. Example: 'administrator' or 'domain\\username'." - }, - { - "Name": "ServerPassword", - "DisplayName": "Server Password", - "Type": "Secret", - "DependsOn": "", - "DefaultValue": "", - "Required": false, - "Description": "Password corresponding to the Server Username used to log into the target server. When establishing a SSH session from a Linux environment, the password must include the full SSH Private key." - }, - { - "Name": "ServerUseSsl", - "DisplayName": "Use SSL", - "Type": "Bool", - "DependsOn": "", - "DefaultValue": "true", - "Required": true, - "Description": "Determine whether the server uses SSL or not (This field is automatically created)" - }, - { - "Name": "RestartService", - "DisplayName": "Restart SQL Service After Cert Installed", - "Type": "Bool", - "DependsOn": "", - "DefaultValue": "false", - "Required": true, - "Description": "Boolean value (true or false) indicating whether to restart the SQL Server service after installing the certificate. Example: 'true' to enable service restart after installation." - } - ], - "EntryParameters": [ - { - "Name": "InstanceName", - "DisplayName": "Instance Name", - "Type": "String", - "RequiredWhen": { - "HasPrivateKey": false, - "OnAdd": false, - "OnRemove": false, - "OnReenrollment": false - }, - "Description": "String value specifying the SQL Server instance name to bind the certificate to. Example: 'MSSQLServer' for the default instance or 'Instance1' for a named instance." - }, - { - "Name": "ProviderName", - "DisplayName": "Crypto Provider Name", - "Type": "String", - "RequiredWhen": { - "HasPrivateKey": false, - "OnAdd": false, - "OnRemove": false, - "OnReenrollment": false - }, - "DependsOn": "", - "DefaultValue": "", - "Options": "", - "Description": "Name of the Windows cryptographic service provider to use when generating and storing private keys. For more information, refer to the section 'Using Crypto Service Providers'" - }, - { - "Name": "SAN", - "DisplayName": "SAN", - "Type": "String", - "RequiredWhen": { - "HasPrivateKey": false, - "OnAdd": false, - "OnRemove": false, - "OnReenrollment": true - }, - "DependsOn": "", - "DefaultValue": "", - "Options": "", - "Description": "String value specifying the Subject Alternative Name (SAN) to be used when performing reenrollment jobs. Format as a list of = entries separated by ampersands; Example: 'dns=www.example.com&dns=www.example2.com' for multiple SANs." - } - ], - "PasswordOptions": { - "EntrySupported": false, - "StoreRequired": false, - "Style": "Default" + "Description": "String value specifying the SQL Server instance name to bind the certificate to. Example: 'MSSQLServer' for the default instance or 'Instance1' for a named instance." + }, + { + "Name": "ProviderName", + "DisplayName": "Crypto Provider Name", + "Type": "String", + "RequiredWhen": { + "HasPrivateKey": false, + "OnAdd": false, + "OnRemove": false, + "OnReenrollment": false }, - "StorePathValue": "My", - "PrivateKeyAllowed": "Optional", - "ServerRequired": true, - "PowerShell": false, - "BlueprintAllowed": true, - "CustomAliasAllowed": "Forbidden", - "ClientMachineDescription": "Hostname of the Windows Server containing the SQL Server Certificate Store to be managed. If this value is a hostname, a WinRM session will be established using the credentials specified in the Server Username and Server Password fields. For more information, see [Client Machine](#note-regarding-client-machine).", - "StorePathDescription": "Fixed string value 'My' indicating the Personal store on the Local Machine. This denotes the Windows certificate store to be managed for SQL Server." - } + "DependsOn": "", + "DefaultValue": "", + "Options": "", + "Description": "Name of the Windows cryptographic service provider to use when generating and storing private keys. For more information, refer to the section 'Using Crypto Service Providers'" + } + ], + "PasswordOptions": { + "EntrySupported": false, + "StoreRequired": false, + "Style": "Default" + }, + "StorePathValue": "My", + "PrivateKeyAllowed": "Optional", + "ServerRequired": true, + "PowerShell": false, + "BlueprintAllowed": true, + "CustomAliasAllowed": "Forbidden", + "ClientMachineDescription": "Hostname of the Windows Server containing the SQL Server Certificate Store to be managed. If this value is a hostname, a WinRM session will be established using the credentials specified in the Server Username and Server Password fields. For more information, see [Client Machine](#note-regarding-client-machine).", + "StorePathDescription": "Fixed string value 'My' indicating the Personal store on the Local Machine. This denotes the Windows certificate store to be managed for SQL Server." + }, + { + "Name": "ADFS Rotation Manager", + "ShortName": "WinAdfs", + "Capability": "WinAdfs", + "LocalStore": false, + "SupportedOperations": { + "Add": true, + "Create": false, + "Discovery": false, + "Enrollment": false, + "Remove": false + }, + "Properties": [ + { + "Name": "spnwithport", + "DisplayName": "SPN With Port", + "Type": "Bool", + "DependsOn": "", + "DefaultValue": "false", + "Required": false, + "Description": "Internally set the -IncludePortInSPN option when creating the remote PowerShell connection. Needed for some Kerberos configurations." + }, + { + "Name": "WinRM Protocol", + "DisplayName": "WinRM Protocol", + "Type": "MultipleChoice", + "DependsOn": "", + "DefaultValue": "https,http,ssh", + "Required": true, + "Description": "Multiple choice value specifying which protocol to use. Protocols https or http use WinRM to connect from Windows to Windows Servers. Using ssh is only supported when running the orchestrator in a Linux environment." + }, + { + "Name": "WinRM Port", + "DisplayName": "WinRM Port", + "Type": "String", + "DependsOn": "", + "DefaultValue": "5986", + "Required": true, + "Description": "String value specifying the port number that the Windows target server's WinRM listener is configured to use. Example: '5986' for HTTPS or '5985' for HTTP. By default, when using ssh in a Linux environment, the default port number is 22." + }, + { + "Name": "ServerUsername", + "DisplayName": "Server Username", + "Type": "Secret", + "DependsOn": "", + "DefaultValue": "", + "Required": false, + "Description": "Username used to log into the target server for establishing the WinRM session. Example: 'administrator' or 'domain\\username'." + }, + { + "Name": "ServerPassword", + "DisplayName": "Server Password", + "Type": "Secret", + "DependsOn": "", + "DefaultValue": "", + "Required": false, + "Description": "Password corresponding to the Server Username used to log into the target server. When establishing a SSH session from a Linux environment, the password must include the full SSH Private key." + }, + { + "Name": "ServerUseSsl", + "DisplayName": "Use SSL", + "Type": "Bool", + "DependsOn": "", + "DefaultValue": "true", + "Required": true, + "Description": "Determine whether the server uses SSL or not (This field is automatically created)" + } + ], + "EntryParameters": [ + { + "Name": "ProviderName", + "DisplayName": "Crypto Provider Name", + "Type": "String", + "RequiredWhen": { + "HasPrivateKey": false, + "OnAdd": false, + "OnRemove": false, + "OnReenrollment": false + }, + "DependsOn": "", + "DefaultValue": "", + "Options": "", + "Description": "Name of the Windows cryptographic service provider to use when generating and storing private keys. For more information, refer to the section 'Using Crypto Service Providers'" + } + ], + "PasswordOptions": { + "EntrySupported": false, + "StoreRequired": false, + "Style": "Default" + }, + "StorePathValue": "My", + "PrivateKeyAllowed": "Required", + "ServerRequired": true, + "PowerShell": false, + "BlueprintAllowed": true, + "CustomAliasAllowed": "Forbidden", + "ClientMachineDescription": "Since this extension type must run as an agent (The UO Must be installed on the PRIMARY ADFS Server), the ClientMachine must follow the naming convention as outlined in the Client Machine Instructions. Secondary ADFS Nodes will be automatically be updated with the same certificate added on the PRIMARY ADFS server.", + "StorePathDescription": "Fixed string value of 'My' indicating the Personal store on the Local Machine. All ADFS Service-Communications certificates are located in the 'My' personal store by default." + } ] } }