787 lines
38 KiB
C#
787 lines
38 KiB
C#
|
using Alkami.Ops.Certificates.Data;
|
||
|
using Alkami.Ops.Certificates.SecretServer;
|
||
|
using Alkami.Ops.Certificates.Utilities;
|
||
|
using Alkami.Ops.Common.Cryptography;
|
||
|
using System;
|
||
|
using System.Collections.Concurrent;
|
||
|
using System.Collections.Generic;
|
||
|
using System.IO;
|
||
|
using System.IO.Compression;
|
||
|
using System.Linq;
|
||
|
using System.Management.Automation;
|
||
|
using System.Security.Cryptography;
|
||
|
using System.Security.Cryptography.X509Certificates;
|
||
|
using System.Text.RegularExpressions;
|
||
|
using System.Threading.Tasks;
|
||
|
|
||
|
namespace Alkami.Ops.Certificates
|
||
|
{
|
||
|
internal class SecretServerImporter : IDisposable
|
||
|
{
|
||
|
private readonly string _tempPath;
|
||
|
private readonly string _globalPassword;
|
||
|
|
||
|
private readonly SecretServerClient _secretServerClient;
|
||
|
|
||
|
private const string CertificateTemplateTypeName = "Automated_Certificate";
|
||
|
private readonly string ExportCertificatesScript;
|
||
|
private static long certCount = 0;
|
||
|
|
||
|
/// <summary>
|
||
|
/// Constructor.
|
||
|
/// </summary>
|
||
|
/// <param name="secretSite">The base URL of the secret server site.</param>
|
||
|
/// <param name="secretUser">Secret Server username with API access.</param>
|
||
|
/// <param name="secretPassword">Secret server user password.</param>
|
||
|
public SecretServerImporter(string secretSite, string secretUser, string secretPassword)
|
||
|
{
|
||
|
_tempPath = Path.Combine(Path.GetTempPath(), "CertificateExport");
|
||
|
if (Directory.Exists(_tempPath))
|
||
|
{
|
||
|
Extensions.ClearDirectory(_tempPath);
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
Directory.CreateDirectory(_tempPath);
|
||
|
}
|
||
|
|
||
|
_globalPassword = GeneratePassword();
|
||
|
|
||
|
_secretServerClient = new SecretServerClient(secretSite, secretUser, secretPassword);
|
||
|
|
||
|
// Read in powershell scripts.
|
||
|
ExportCertificatesScript = Resources.ExportRemoteCertificates;
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Cleanup
|
||
|
/// </summary>
|
||
|
public void Dispose()
|
||
|
{
|
||
|
if (Directory.Exists(_tempPath))
|
||
|
{
|
||
|
Directory.Delete(_tempPath, true);
|
||
|
}
|
||
|
|
||
|
_secretServerClient?.Dispose();
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Creates machine-friendly per-pod secrets, so a server can download 4 secrets instead of 200.
|
||
|
/// </summary>
|
||
|
/// <param name="friendlySecretBaseFolderPath"></param>
|
||
|
/// <param name="machineSecretBaseFolderPath"></param>
|
||
|
public void CreatePodSecrets(string friendlySecretBaseFolderPath, string machineSecretBaseFolderPath, string[] importableUsers)
|
||
|
{
|
||
|
var baseFolder = _secretServerClient.GetOrAddFolderAsync(friendlySecretBaseFolderPath).GetAwaiter().GetResult();
|
||
|
|
||
|
// The secret server will only return -all- folders under a subfolder. Can't just query for folders at a time.
|
||
|
var folders = _secretServerClient.GetFoldersByParentFolder(baseFolder.ID);
|
||
|
|
||
|
// Parse apart the paths of the folders to strip off the base path.
|
||
|
friendlySecretBaseFolderPath = friendlySecretBaseFolderPath.Replace('/', '\\');
|
||
|
var folderPaths = folders.Select(folder => folder.FolderPath)
|
||
|
.Select(path => path.Substring(path.IndexOf(friendlySecretBaseFolderPath) + friendlySecretBaseFolderPath.Length));
|
||
|
|
||
|
// Parse apart the environment types, and pods that exist (+ common)
|
||
|
// Levels:
|
||
|
// 1) EnvironmentType
|
||
|
// 2) Environment (or Common)
|
||
|
// 3) Web / App / All
|
||
|
var splitPaths = folderPaths.Select(path => path.Split(new char[] { '\\' }, StringSplitOptions.RemoveEmptyEntries)).ToArray();
|
||
|
|
||
|
var environmentTypes = splitPaths
|
||
|
.Where(split => split.Length >= 1)
|
||
|
.Select(split => split[0])
|
||
|
.Distinct();
|
||
|
|
||
|
foreach (var environmentType in environmentTypes)
|
||
|
{
|
||
|
var environments = splitPaths
|
||
|
.Where(split => split.Length >= 2)
|
||
|
.Where(split => string.Equals(split[0], environmentType, StringComparison.OrdinalIgnoreCase))
|
||
|
.Select(split => split[1])
|
||
|
.Distinct();
|
||
|
|
||
|
foreach (var environment in environments)
|
||
|
{
|
||
|
CreatePodSecrets(friendlySecretBaseFolderPath, machineSecretBaseFolderPath, environmentType, environment, importableUsers);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Creates machine-friendly per-pod secrets, so a server can download 4 secrets instead of 200.
|
||
|
/// </summary>
|
||
|
/// <param name="friendlySecretBaseFolderPath">Secret server path of the friendly secrets folder.</param>
|
||
|
/// <param name="machineSecretBaseFolderPath">Secret server path of the machine per-pod secrets folder.</param>
|
||
|
/// <param name="environmentType">The type of environment.</param>
|
||
|
/// <param name="environment">The name of the environment. (Or "Common")</param>
|
||
|
private void CreatePodSecrets(string friendlySecretBaseFolderPath, string machineSecretBaseFolderPath, string environmentType, string environment, string[] importableUsers)
|
||
|
{
|
||
|
using (PowerShell powerShellSession = PowerShell.Create())
|
||
|
{
|
||
|
var environmentPath = Path.Combine(friendlySecretBaseFolderPath, environmentType, environment);
|
||
|
string[] serverTypes = new string[] { "All", "Web", "App" };
|
||
|
|
||
|
// Get the secret template type.
|
||
|
var secretTemplateType = _secretServerClient.GetSecretTemplateByName(CertificateTemplateTypeName);
|
||
|
|
||
|
foreach (var serverType in serverTypes)
|
||
|
{
|
||
|
// Clear out the temp path directory. Only one of these processes should run at a time.
|
||
|
// TODO: Rewrite the function to be pathed by environment type and environment so multiple of them can be run in parallel.
|
||
|
if (Directory.Exists(_tempPath) && Directory.EnumerateFiles(_tempPath).Any())
|
||
|
{
|
||
|
Extensions.ClearDirectory(_tempPath);
|
||
|
}
|
||
|
|
||
|
var secretsPath = Path.Combine(environmentPath, serverType);
|
||
|
|
||
|
var folder = _secretServerClient.GetOrAddFolderAsync(secretsPath).GetAwaiter().GetResult();
|
||
|
var secrets = _secretServerClient.GetSecretsByFolder(folder);
|
||
|
|
||
|
// Load the full secrets.
|
||
|
var fullSecrets = secrets.Select(secret => _secretServerClient.GetSecretByID(secret.ID).GetAwaiter().GetResult());
|
||
|
|
||
|
// Download each secret to a separate folder by ID.
|
||
|
foreach (var secret in fullSecrets)
|
||
|
{
|
||
|
// Download the zip.
|
||
|
Console.WriteLine($"Attempting to download {secret.ID}");
|
||
|
var zipName = $"{secret.ID}.zip";
|
||
|
var zipDownloadLocation = Path.Combine(_tempPath, zipName);
|
||
|
if (!_secretServerClient.DownloadFile(zipDownloadLocation, secret).GetAwaiter().GetResult())
|
||
|
{
|
||
|
Console.WriteLine($"Failed to download secret ID {secret.ID}");
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// Unzip the file to a per-secret folder.
|
||
|
var outputFolder = Path.Combine(_tempPath, secret.ID.ToString());
|
||
|
if (!Directory.Exists(outputFolder))
|
||
|
{
|
||
|
Directory.CreateDirectory(outputFolder);
|
||
|
}
|
||
|
|
||
|
// Unzip it.
|
||
|
ZipFile.ExtractToDirectory(zipDownloadLocation, outputFolder);
|
||
|
|
||
|
var pfxFiles = Directory.GetFiles(outputFolder, "*.pfx", SearchOption.AllDirectories);
|
||
|
foreach (var file in pfxFiles)
|
||
|
{
|
||
|
// re-export secret password with global password
|
||
|
var cert = new X509Certificate2(file, secret["Import Password"], X509KeyStorageFlags.Exportable);
|
||
|
byte[] exportedBytes = cert.Export(X509ContentType.Pkcs12, _globalPassword);
|
||
|
File.WriteAllBytes(file, exportedBytes);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Copy all of the certificates together, preserving folder paths.
|
||
|
var podSecretOutputFolder = Path.Combine(_tempPath, "PodSecretOutputFolder");
|
||
|
if (!Directory.Exists(podSecretOutputFolder))
|
||
|
{
|
||
|
Directory.CreateDirectory(podSecretOutputFolder);
|
||
|
}
|
||
|
var secretFolders = Directory.GetDirectories(_tempPath);
|
||
|
foreach (var sourceFolder in secretFolders)
|
||
|
{
|
||
|
// Source directories, a folder for each secret server secret.
|
||
|
var sourceDirectories = Directory.GetDirectories(sourceFolder, "*", SearchOption.AllDirectories);
|
||
|
|
||
|
// Create folders in the destination folder
|
||
|
foreach (string dirPath in sourceDirectories)
|
||
|
{
|
||
|
var dstPath = dirPath.Replace(sourceFolder, podSecretOutputFolder);
|
||
|
if (!Directory.Exists(dstPath))
|
||
|
{
|
||
|
Directory.CreateDirectory(dstPath);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Copy over the secrets.
|
||
|
foreach (string newPath in Directory.GetFiles(sourceFolder, "*.*", SearchOption.AllDirectories))
|
||
|
{
|
||
|
// If the file already exists in the combined secret, and it isn't the same file, come up with a new non-duplicate name and copy the secret over.
|
||
|
var podSecretDestinationPath = newPath.Replace(sourceFolder, podSecretOutputFolder);
|
||
|
|
||
|
int counter = 1;
|
||
|
bool doCopy = true;
|
||
|
do
|
||
|
{
|
||
|
// Copy over the file if it doesn't exist.
|
||
|
if (!File.Exists(podSecretDestinationPath))
|
||
|
{
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
// If the files are equal, the cert already exists in the combined folder, so break out.
|
||
|
if (Extensions.FilesAreEqual(new FileInfo(newPath), new FileInfo(podSecretDestinationPath)))
|
||
|
{
|
||
|
doCopy = false;
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
// Otherwise we're going to try to come up with a unique filename.
|
||
|
// This loop is going to keep going until we either find the exact same cert, or we arrive on a unique file name.
|
||
|
string nonConflictingPath = Path.Combine(
|
||
|
Path.GetDirectoryName(newPath),
|
||
|
Path.GetFileNameWithoutExtension(newPath),
|
||
|
counter.ToString(),
|
||
|
Path.GetExtension(newPath));
|
||
|
podSecretDestinationPath = nonConflictingPath;
|
||
|
counter++;
|
||
|
} while (true);
|
||
|
|
||
|
if (doCopy)
|
||
|
{
|
||
|
File.Copy(newPath, podSecretDestinationPath, true);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Zip up the combined certs.
|
||
|
var podSecretName = serverType;
|
||
|
var podSecretZipPath = Path.Combine(_tempPath, $"{podSecretName}.zip");
|
||
|
ZipFile.CreateFromDirectory(podSecretOutputFolder, podSecretZipPath);
|
||
|
|
||
|
// Create secret with combined zip.
|
||
|
var secretPath = Path.Combine(machineSecretBaseFolderPath, environmentType, environment);
|
||
|
var destinationSecretFolder = _secretServerClient.GetOrAddFolderAsync(secretPath).GetAwaiter().GetResult();
|
||
|
|
||
|
// See if a secret already exists, and if it does, delete it.
|
||
|
var existingSecret = _secretServerClient.FindSecretByName(serverType, destinationSecretFolder.ID);
|
||
|
if (existingSecret != null)
|
||
|
{
|
||
|
_secretServerClient.DeleteSecret(existingSecret.ID).GetAwaiter().GetResult();
|
||
|
}
|
||
|
|
||
|
// Create the new secret stub by template ID.
|
||
|
var secretStub = _secretServerClient.CreateSecretStubByTemplateIDAsync(secretTemplateType.ID, podSecretName + "-" + destinationSecretFolder.ID, destinationSecretFolder.ID).GetAwaiter().GetResult();
|
||
|
|
||
|
var currentZipHash = "";
|
||
|
using (var stream = File.OpenRead(podSecretZipPath))
|
||
|
{
|
||
|
currentZipHash = Extensions.GetMd5HashString(stream);
|
||
|
}
|
||
|
|
||
|
// Fill out secret details.
|
||
|
secretStub["Import Password"] = _globalPassword;
|
||
|
secretStub["Certificate Name"] = podSecretName;
|
||
|
secretStub["Thumbprint"] = "N/A";
|
||
|
secretStub["ExpirationDate"] = "";
|
||
|
secretStub["File Hash"] = currentZipHash;
|
||
|
|
||
|
// Create the secret.
|
||
|
var newSecret = _secretServerClient.CreateSecret(secretStub).GetAwaiter().GetResult();
|
||
|
|
||
|
// Upload the zip.
|
||
|
_secretServerClient.UploadFile(newSecret, podSecretZipPath).GetAwaiter().GetResult();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Imports certificates from all of the given servers into the secret server.
|
||
|
/// </summary>
|
||
|
/// <param name="servers">List of server hostnames.</param>
|
||
|
/// <param name="baseSecretFolder">Secret server folder path to drop the secrets in.</param>
|
||
|
public void Import(IEnumerable<string> servers, string baseSecretFolder)
|
||
|
{
|
||
|
// Make sure the servers are unique just in case.
|
||
|
servers = servers.Select(s => s.ToLower()).Distinct();
|
||
|
|
||
|
var serverData = new Dictionary<string, ServerInfo>();
|
||
|
var environmentData = new Dictionary<string, EnvironmentInfo>();
|
||
|
var certificateData = new Dictionary<string, CertificateInfo>();
|
||
|
|
||
|
if (Directory.Exists(_tempPath))
|
||
|
{
|
||
|
// Remove directories and files under _tempPath.
|
||
|
foreach (var directory in Directory.GetDirectories(_tempPath))
|
||
|
{
|
||
|
Directory.Delete(directory, true);
|
||
|
}
|
||
|
|
||
|
foreach (var file in Directory.GetFiles(_tempPath))
|
||
|
{
|
||
|
File.Delete(file);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Gather server/environment data from the servers.
|
||
|
using (var timer = new QuickWatch("GatherEnvironmentData"))
|
||
|
{
|
||
|
GatherEnvironmentData(servers, ref environmentData, ref serverData);
|
||
|
}
|
||
|
|
||
|
// Download certificates from each server.
|
||
|
using (var timer = new QuickWatch("DownloadCertificatesFromServers"))
|
||
|
{
|
||
|
DownloadCertificatesFromServers(servers, _tempPath);
|
||
|
}
|
||
|
|
||
|
// Build the tree of certificate information, to weed out unique certificates that need uploading.
|
||
|
using (var timer = new QuickWatch("BuildCertificateData"))
|
||
|
{
|
||
|
BuildCertificateData(_tempPath, ref serverData, ref certificateData);
|
||
|
}
|
||
|
|
||
|
// Upload the secrets to the secret server.
|
||
|
using (var timer = new QuickWatch("UploadCertificatesToSecretServer"))
|
||
|
{
|
||
|
UploadCertificatesToSecretServer(baseSecretFolder, ref environmentData, ref certificateData);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Gathers environment data from each of the servers, and tracks the data in _environmentData and _serverData
|
||
|
/// </summary>
|
||
|
/// <param name="servers"></param>
|
||
|
private void GatherEnvironmentData(IEnumerable<string> servers, ref Dictionary<string, EnvironmentInfo> environmentData, ref Dictionary<string, ServerInfo> serverData)
|
||
|
{
|
||
|
// Read server/environment data from all of the servers.
|
||
|
foreach (var server in servers)
|
||
|
{
|
||
|
// Grab the server name/type/envType/host from the server's machine config.
|
||
|
var serverInfo = Extensions.GetServerInfo(server);
|
||
|
|
||
|
// Look up the environment.
|
||
|
var environmentNameLower = serverInfo.EnvironmentName.ToLower();
|
||
|
EnvironmentInfo environment = null;
|
||
|
if (!environmentData.TryGetValue(environmentNameLower, out environment))
|
||
|
{
|
||
|
environment = new EnvironmentInfo(serverInfo.EnvironmentName, serverInfo.EnvironmentType);
|
||
|
environmentData[environmentNameLower] = environment;
|
||
|
}
|
||
|
|
||
|
// Recreate the server to include the environment.
|
||
|
serverInfo = new ServerInfo(serverInfo, environment);
|
||
|
serverData[server] = serverInfo;
|
||
|
|
||
|
// Record the server in the environment.
|
||
|
environment.Servers.Add(serverInfo);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Generates a strong password of [length] characters.
|
||
|
/// </summary>
|
||
|
/// <param name="length">The length of the password</param>
|
||
|
/// <returns></returns>
|
||
|
private string GeneratePassword(int length = 32)
|
||
|
{
|
||
|
RNGCryptoServiceProvider cryptRNG = new RNGCryptoServiceProvider();
|
||
|
byte[] tokenBuffer = new byte[length];
|
||
|
cryptRNG.GetBytes(tokenBuffer);
|
||
|
return Convert.ToBase64String(tokenBuffer);
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Aliases store string names to the Alkami certificate import folder names.
|
||
|
/// </summary>
|
||
|
/// <param name="storename">String name of the store.</param>
|
||
|
/// <returns></returns>
|
||
|
private string GetStoreFolderName(string storename)
|
||
|
{
|
||
|
switch (storename.ToLower())
|
||
|
{
|
||
|
case "my":
|
||
|
return "Personal";
|
||
|
|
||
|
case "ca":
|
||
|
return "IA";
|
||
|
|
||
|
case "root":
|
||
|
return "Root";
|
||
|
|
||
|
case "trustedpeople":
|
||
|
return "TrustedPeople";
|
||
|
|
||
|
default:
|
||
|
throw new Exception("Unknown Store Name Type");
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Downloads certificates from servers and unzips them to the output directory.
|
||
|
/// </summary>
|
||
|
/// <param name="servers"></param>
|
||
|
/// <param name="outputDirectory"></param>
|
||
|
private void DownloadCertificatesFromServers(IEnumerable<string> servers, string outputDirectory)
|
||
|
{
|
||
|
// Export certs from every server.
|
||
|
var serverString = string.Join(",", servers);
|
||
|
using (PowerShell instance = PowerShell.Create())
|
||
|
{
|
||
|
instance.AddScript(ExportCertificatesScript);
|
||
|
instance.AddParameter("serverString", serverString);
|
||
|
instance.AddParameter("exportPassword", _globalPassword);
|
||
|
instance.AddParameter("importPath", outputDirectory);
|
||
|
var psOutput = instance.Invoke();
|
||
|
|
||
|
if (instance.Streams.Error.Any())
|
||
|
{
|
||
|
Console.WriteLine("There were Errors executing powershell:");
|
||
|
foreach (var error in instance.Streams.Error)
|
||
|
{
|
||
|
Console.WriteLine(error);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Loads all certificates into memory, builds certificate chains, and determines certificate cert/server/environment relationships.
|
||
|
/// </summary>
|
||
|
/// <param name="serverCertificateDirectory">The directory of certificates, per server folder.</param>
|
||
|
private void BuildCertificateData(string serverCertificateDirectory, ref Dictionary<string, ServerInfo> serverData, ref Dictionary<string, CertificateInfo> certificateData)
|
||
|
{
|
||
|
var certificateFiles = Directory.GetFiles(serverCertificateDirectory, "*", SearchOption.AllDirectories).Select(p => Path.GetFullPath(p)).ToArray();
|
||
|
var certificateFileHasPrivateKey = new Dictionary<string, bool>();
|
||
|
var fileToCertificate = new ConcurrentDictionary<string, CertificateInfo>();
|
||
|
|
||
|
// Load all of the certificates into memory so we can make sense of them.
|
||
|
var options = new ParallelOptions();
|
||
|
options.MaxDegreeOfParallelism = 32;
|
||
|
foreach (var file in certificateFiles)
|
||
|
{
|
||
|
var extension = Path.GetExtension(file);
|
||
|
|
||
|
string newPassword = null;
|
||
|
X509Certificate2 cert = null;
|
||
|
if (extension == ".cer")
|
||
|
{
|
||
|
cert = new X509Certificate2(file);
|
||
|
}
|
||
|
else if (extension == ".pfx")
|
||
|
{
|
||
|
cert = new X509Certificate2(file, _globalPassword, X509KeyStorageFlags.Exportable);
|
||
|
|
||
|
// Generate a unique password for this cert going forward.
|
||
|
newPassword = GeneratePassword();
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
throw new Exception($"Unhandled certificate extension {extension}");
|
||
|
}
|
||
|
|
||
|
// Store if the particular cert has a private key.
|
||
|
certificateFileHasPrivateKey[file] = cert.HasPrivateKey;
|
||
|
|
||
|
var certInfo = new CertificateInfo(cert, file, newPassword);
|
||
|
if (!fileToCertificate.TryAdd(file, certInfo))
|
||
|
{
|
||
|
throw new Exception($"Was unable to track certificate {file}. Investigate.");
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// Identify unique certificates from the list of certs from every server which includes duplicates.
|
||
|
// Prefer to select unique certs that include private keys.
|
||
|
var uniqueCertificates = new Dictionary<string, CertificateInfo>();
|
||
|
var fileToCertificateEnum = fileToCertificate.Where(cert => cert.Value.Password != null).Concat(fileToCertificate);
|
||
|
foreach (var certInfoKV in fileToCertificateEnum)
|
||
|
{
|
||
|
var certInfo = certInfoKV.Value;
|
||
|
if (!uniqueCertificates.ContainsKey(certInfo.Thumbprint))
|
||
|
{
|
||
|
uniqueCertificates.Add(certInfo.Thumbprint, certInfo);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Run back through each non-unique certificate, and assign servers/environments to each unique cert.
|
||
|
// This is tracked by the folder that the certificate was loaded from. File paths in the form of:
|
||
|
// "basePath\serverName\certificateStore\certificateName.pfx"
|
||
|
foreach (var file in certificateFiles)
|
||
|
{
|
||
|
// Select the server/store out of the folder path.
|
||
|
var fileSplit = file.Split(new char[] { '\\' });
|
||
|
var serverName = fileSplit[fileSplit.Length - 3].ToLower();
|
||
|
var storeName = fileSplit[fileSplit.Length - 2].ToLower();
|
||
|
|
||
|
// Look up the unique certificate by the thumbprint of the non-unique certificate.
|
||
|
var nonUniqueCertInfo = fileToCertificate[file];
|
||
|
var uniqueCertInfo = uniqueCertificates[nonUniqueCertInfo.Thumbprint];
|
||
|
|
||
|
// Add the server/environment that the cert is in to the unique cert info object.
|
||
|
var serverInfo = serverData[serverName];
|
||
|
var environmentName = serverInfo.EnvironmentName.ToLower();
|
||
|
uniqueCertInfo.Servers.TryAdd(serverName, serverInfo);
|
||
|
uniqueCertInfo.Environments.TryAdd(environmentName, serverInfo.Environment);
|
||
|
|
||
|
// Record the store that the certificate belongs in, if it does not already exist.
|
||
|
if (!uniqueCertInfo.Stores.Any(certStore => string.Equals(certStore.StoreName, storeName, StringComparison.OrdinalIgnoreCase)))
|
||
|
{
|
||
|
bool storeHasPrivateKey = certificateFileHasPrivateKey[file];
|
||
|
StoreInfo store = new StoreInfo(storeName, storeHasPrivateKey);
|
||
|
uniqueCertInfo.Stores.Add(store);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Copy all of the unique certificates up to _certificateData, which will be the store of certs for other functions.
|
||
|
foreach (var certInfoKV in uniqueCertificates)
|
||
|
{
|
||
|
certificateData.Add(certInfoKV.Key, certInfoKV.Value);
|
||
|
}
|
||
|
|
||
|
// For each unique certificate, build out the certificate chain and figure out which certs require which other certs.
|
||
|
// Later we will use this data to create secrets for the "leaf certificates" that are not intermediates of other certificates.
|
||
|
foreach (var certificateInfo in uniqueCertificates)
|
||
|
{
|
||
|
var certificate = certificateInfo.Value.Certificate;
|
||
|
|
||
|
X509Chain chain = new X509Chain();
|
||
|
chain.Build(certificate);
|
||
|
|
||
|
List<X509Certificate2> chainCerts = chain.ChainElements.ToList();
|
||
|
|
||
|
// Exit if there's no chain to walk.
|
||
|
if (chainCerts.Count <= 1)
|
||
|
{
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// Build the tree of parent/child certificate associations.
|
||
|
for (int i = 1; i < chainCerts.Count; i++)
|
||
|
{
|
||
|
var parentCert = chainCerts[i];
|
||
|
var childCert = chainCerts[i - 1];
|
||
|
|
||
|
// Apparently certificates in the chain can exist and validate WITHOUT being loaded into the stores of the remote machines.
|
||
|
// They are most likely getting validated through the domain controller.
|
||
|
if (!certificateData.ContainsKey(parentCert.Thumbprint))
|
||
|
{
|
||
|
string filename = null; // No filename because it wasn't exported from a cert store.
|
||
|
var info = new CertificateInfo(parentCert, filename);
|
||
|
certificateData[parentCert.Thumbprint] = info;
|
||
|
}
|
||
|
|
||
|
// Attach the parent/child info to certificates for tracking.
|
||
|
var parentInfo = certificateData[parentCert.Thumbprint];
|
||
|
var childInfo = certificateData[childCert.Thumbprint];
|
||
|
childInfo.Parent = parentInfo;
|
||
|
parentInfo.Children.Add(childInfo);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Uploads tracked certificates in _certificateData to the secret server at the base path.
|
||
|
/// </summary>
|
||
|
/// <param name="baseSecretFolder">The base folder path in the secret server to upload certs to.</param>
|
||
|
private void UploadCertificatesToSecretServer(string baseSecretFolder, ref Dictionary<string, EnvironmentInfo> environmentData, ref Dictionary<string, CertificateInfo> certificateData)
|
||
|
{
|
||
|
// Grab the list of certificates to create secrets for.
|
||
|
var certsToExport = certificateData.Select(x => x.Value).ToList();
|
||
|
|
||
|
var secretImportZipPath = Path.Combine(_tempPath, "SecretImportTemp");
|
||
|
if (!Directory.Exists(secretImportZipPath))
|
||
|
{
|
||
|
Directory.CreateDirectory(secretImportZipPath);
|
||
|
}
|
||
|
|
||
|
// Create folders on the secret server for every environment, so we can multithread the certificate uploads without fear of stomping.
|
||
|
var majorPodRegex = @"[^\.](\d+)";
|
||
|
foreach (var environment in environmentData)
|
||
|
{
|
||
|
var type = environment.Value.EnvironmentType;
|
||
|
type = char.ToUpper(type[0]) + type.Substring(1);
|
||
|
|
||
|
var path = Path.Combine(baseSecretFolder, type);
|
||
|
|
||
|
// Define the server types.
|
||
|
var serverTypeFolders = new string[] { "Web", "App", "All" };
|
||
|
|
||
|
// Create the common folder for the environment type.
|
||
|
foreach (var serverType in serverTypeFolders)
|
||
|
{
|
||
|
var serverTypePath = Path.Combine(path, "Common", serverType);
|
||
|
var folder = _secretServerClient.GetOrAddFolderAsync(serverTypePath).GetAwaiter().GetResult();
|
||
|
}
|
||
|
|
||
|
// Only production cares about separate cert folders per environment.
|
||
|
if (type == "Production")
|
||
|
{
|
||
|
var matches = Regex.Matches(environment.Value.Name, majorPodRegex);
|
||
|
string majorPod = matches[0].Captures[0].Value.Trim();
|
||
|
path = Path.Combine(path, majorPod);
|
||
|
}
|
||
|
|
||
|
// Server type folders inside the pod.
|
||
|
foreach (var serverType in serverTypeFolders)
|
||
|
{
|
||
|
var serverTypePath = Path.Combine(path, serverType);
|
||
|
var folder = _secretServerClient.GetOrAddFolderAsync(serverTypePath).GetAwaiter().GetResult();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Fetch all the secrets from the base folder, so we can check for secrets we've already loaded.
|
||
|
bool includeSubfolders = true;
|
||
|
var baseFolder = _secretServerClient.GetOrAddFolderAsync(baseSecretFolder).GetAwaiter().GetResult();
|
||
|
var allSecrets = _secretServerClient.GetSecretsByFolder(baseFolder, includeSubfolders);
|
||
|
|
||
|
try
|
||
|
{
|
||
|
var secretTemplateType = _secretServerClient.GetSecretTemplateByName(CertificateTemplateTypeName);
|
||
|
|
||
|
// Create separate .zip individual files for each certificate being uploaded.
|
||
|
var options = new ParallelOptions();
|
||
|
options.MaxDegreeOfParallelism = 32;
|
||
|
Parallel.ForEach(certsToExport, options, (cert) =>
|
||
|
{
|
||
|
// Create a folder for the cert.
|
||
|
var certFolderPath = Path.Combine(secretImportZipPath, cert.Thumbprint);
|
||
|
if (!Directory.Exists(certFolderPath))
|
||
|
{
|
||
|
Directory.CreateDirectory(certFolderPath);
|
||
|
}
|
||
|
|
||
|
// Copy all of the certs for the chain
|
||
|
var currentCert = cert;
|
||
|
Console.WriteLine($"Working on {cert.Name}");
|
||
|
do
|
||
|
{
|
||
|
// Don't export this cert because it didn't exist in the stores of the remote machines.
|
||
|
if (cert.FileName == null)
|
||
|
{
|
||
|
currentCert = currentCert.Parent;
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// For each store the cert belongs in.
|
||
|
foreach (var store in cert.Stores)
|
||
|
{
|
||
|
// Export certificates to the appropriate place and store folder.
|
||
|
var outputDirectory = Path.Combine(certFolderPath, store.StoreName);
|
||
|
if (!Directory.Exists(outputDirectory))
|
||
|
{
|
||
|
Directory.CreateDirectory(outputDirectory);
|
||
|
}
|
||
|
|
||
|
var password = store.HasPrivateKey ? cert.Password : null;
|
||
|
CertificateHelper.ExportCertificate(cert.Certificate, outputDirectory, password);
|
||
|
}
|
||
|
|
||
|
// Move along to the next cert in the chain.
|
||
|
currentCert = currentCert.Parent;
|
||
|
} while (currentCert != null);
|
||
|
|
||
|
// Zip up the cert for upload to secret.
|
||
|
var outputZipPath = Path.Combine(secretImportZipPath, $"{cert.Thumbprint}.zip");
|
||
|
ZipFile.CreateFromDirectory(certFolderPath, outputZipPath);
|
||
|
|
||
|
var currentZipHash = "";
|
||
|
using (var stream = File.OpenRead(outputZipPath))
|
||
|
{
|
||
|
currentZipHash = Extensions.GetMd5HashString(stream);
|
||
|
}
|
||
|
|
||
|
// Figure out what environment subfolder the certificate is going in on the secret server.
|
||
|
var serverNames = cert.Servers.Select(serverInfoKV => serverInfoKV.Key.ToLower());
|
||
|
|
||
|
var appServerNames = new string[] { "vma", "app", "fab", "mic" };
|
||
|
var webServerNames = new string[] { "vmw", "web" };
|
||
|
bool onApps = appServerNames.Any(type => serverNames.Any(serverName => serverName.Contains(type)));
|
||
|
bool onWebs = webServerNames.Any(type => serverNames.Any(serverName => serverName.Contains(type)));
|
||
|
bool onBothWebsAndApps = onApps && onWebs;
|
||
|
|
||
|
// Determine the types of environments that this cert exists in.
|
||
|
// We duplicate certs for each environment. It's too reckless to have a "every environment type" common folder.
|
||
|
var environmentTypes = cert.Environments
|
||
|
.Select(e => e.Value.EnvironmentType.ToLower())
|
||
|
.Distinct()
|
||
|
.Select(et => char.ToUpper(et.ElementAt(0)) + et.Substring(1)); // Upper case environment type names.
|
||
|
|
||
|
foreach (var environmentType in environmentTypes)
|
||
|
{
|
||
|
// Build secret server folder path to create the secret.
|
||
|
|
||
|
// Determine if the cert is on multiple pods within an environment type.
|
||
|
var environments = cert.Environments.Where(e => e.Value.EnvironmentType.ToLower() == environmentType.ToLower());
|
||
|
bool onMultiplePods = environments.Count() >= 2;
|
||
|
|
||
|
var secretServerFolderPath = Path.Combine(baseSecretFolder, environmentType);
|
||
|
|
||
|
if (onMultiplePods)
|
||
|
{
|
||
|
secretServerFolderPath = Path.Combine(secretServerFolderPath, "Common");
|
||
|
}
|
||
|
else if (environmentType == "Production")
|
||
|
{
|
||
|
var matches = Regex.Matches(environments.First().Value.Name, majorPodRegex);
|
||
|
string majorPod = matches[0].Captures[0].Value.Trim();
|
||
|
|
||
|
secretServerFolderPath = Path.Combine(secretServerFolderPath, majorPod);
|
||
|
}
|
||
|
|
||
|
string serverFolder = string.Empty;
|
||
|
if (onBothWebsAndApps)
|
||
|
{
|
||
|
serverFolder = "All";
|
||
|
}
|
||
|
else if (onWebs)
|
||
|
{
|
||
|
serverFolder = "Web";
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
serverFolder = "App";
|
||
|
}
|
||
|
|
||
|
secretServerFolderPath = Path.Combine(secretServerFolderPath, serverFolder);
|
||
|
|
||
|
var fullSecretPath = Path.Combine(secretServerFolderPath, cert.UniqueName);
|
||
|
var folder = _secretServerClient.GetOrAddFolderAsync(secretServerFolderPath).GetAwaiter().GetResult();
|
||
|
|
||
|
// Figure out if the secret already exists for the cert.
|
||
|
var secretName = cert.UniqueName;
|
||
|
if (allSecrets.Any(existingSecret => existingSecret.FolderID == folder.ID && string.Equals(existingSecret.Name, secretName, StringComparison.OrdinalIgnoreCase)))
|
||
|
{
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// Create the secret.
|
||
|
var secretStub = _secretServerClient.CreateSecretStubByTemplateIDAsync(secretTemplateType.ID, secretName + "-" + folder.ID, folder.ID).GetAwaiter().GetResult();
|
||
|
var expiration = cert.Certificate.NotAfter.ToString();
|
||
|
|
||
|
// Fill out details about the cert.
|
||
|
secretStub["Import Password"] = cert.Password;
|
||
|
secretStub["Certificate Name"] = secretName;
|
||
|
secretStub["Thumbprint"] = cert.Thumbprint;
|
||
|
secretStub["ExpirationDate"] = expiration;
|
||
|
secretStub["File Hash"] = currentZipHash;
|
||
|
|
||
|
var foundCert = _secretServerClient.FindSecretByName(secretName);
|
||
|
if (foundCert != null)
|
||
|
{
|
||
|
Console.WriteLine($"Secret exists {secretName}");
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
try
|
||
|
{
|
||
|
// Create the secret.
|
||
|
var secret = _secretServerClient.CreateSecret(secretStub).GetAwaiter().GetResult();
|
||
|
|
||
|
// Upload the cert .zip.
|
||
|
_secretServerClient.UploadFile(secret, outputZipPath).GetAwaiter().GetResult();
|
||
|
}
|
||
|
catch (Exception e)
|
||
|
{
|
||
|
Console.WriteLine("{0} Exception caught.", e.Message);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
finally
|
||
|
{
|
||
|
// Clean up the cert directory.
|
||
|
if (Directory.Exists(secretImportZipPath))
|
||
|
{
|
||
|
Directory.Delete(secretImportZipPath, true);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|