diff --git a/VERSION b/VERSION index 15b989e398..41c11ffb73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.16.0 +1.16.1 diff --git a/src/VirtualClient/VirtualClient.Actions.FunctionalTests/Memcached/MemcachedServerProfileTests.cs b/src/VirtualClient/VirtualClient.Actions.FunctionalTests/Memcached/MemcachedServerProfileTests.cs index 3debbbd0a3..34b0dc65a7 100644 --- a/src/VirtualClient/VirtualClient.Actions.FunctionalTests/Memcached/MemcachedServerProfileTests.cs +++ b/src/VirtualClient/VirtualClient.Actions.FunctionalTests/Memcached/MemcachedServerProfileTests.cs @@ -63,8 +63,9 @@ await executor.ExecuteAsync(ProfileTiming.OneIteration(), CancellationToken.None [TestCase("PERF-MEMCACHED.json")] public async Task MemcachedMemtierWorkloadProfileExecutesTheWorkloadAsExpectedOfServerOnUnixPlatformMultiVM(string profile) { - this.mockFixture.SystemManagement.Setup(mgr => mgr.GetLoggedInUserName()) - .Returns("mockuser"); + this.mockFixture.PlatformSpecifics.EnvironmentVariables.Add( + EnvironmentVariable.SUDO_USER, + "mockuser"); IEnumerable expectedCommands = new List { diff --git a/src/VirtualClient/VirtualClient.Actions/Memcached/MemcachedExecutor.cs b/src/VirtualClient/VirtualClient.Actions/Memcached/MemcachedExecutor.cs index ecdf8e8aee..aac52f09ea 100644 --- a/src/VirtualClient/VirtualClient.Actions/Memcached/MemcachedExecutor.cs +++ b/src/VirtualClient/VirtualClient.Actions/Memcached/MemcachedExecutor.cs @@ -59,7 +59,7 @@ public string Username string username = this.Parameters.GetValue(nameof(MemcachedExecutor.Username), string.Empty); if (string.IsNullOrWhiteSpace(username)) { - username = this.SystemManagement.GetLoggedInUserName(); + username = this.PlatformSpecifics.GetLoggedInUser(); } return username; diff --git a/src/VirtualClient/VirtualClient.Actions/NASParallelBench/NASParallelBenchClientExecutor.cs b/src/VirtualClient/VirtualClient.Actions/NASParallelBench/NASParallelBenchClientExecutor.cs index 242c5f3782..bb3dc2e1c1 100644 --- a/src/VirtualClient/VirtualClient.Actions/NASParallelBench/NASParallelBenchClientExecutor.cs +++ b/src/VirtualClient/VirtualClient.Actions/NASParallelBench/NASParallelBenchClientExecutor.cs @@ -57,7 +57,7 @@ public string Username string username = this.Parameters.GetValue(nameof(NASParallelBenchClientExecutor.Username), string.Empty); if (string.IsNullOrWhiteSpace(username)) { - username = this.systemManagement.GetLoggedInUserName(); + username = this.PlatformSpecifics.GetLoggedInUser(); } return username; diff --git a/src/VirtualClient/VirtualClient.Actions/SuperBenchmark/SuperBenchmarkExecutor.cs b/src/VirtualClient/VirtualClient.Actions/SuperBenchmark/SuperBenchmarkExecutor.cs index 141ef7b91d..afd8c2ea60 100644 --- a/src/VirtualClient/VirtualClient.Actions/SuperBenchmark/SuperBenchmarkExecutor.cs +++ b/src/VirtualClient/VirtualClient.Actions/SuperBenchmark/SuperBenchmarkExecutor.cs @@ -92,7 +92,7 @@ public string Username string username = this.Parameters.GetValue(nameof(SuperBenchmarkExecutor.Username), string.Empty); if (string.IsNullOrWhiteSpace(username)) { - username = this.systemManager.GetLoggedInUserName(); + username = this.PlatformSpecifics.GetLoggedInUser(); } return username; diff --git a/src/VirtualClient/VirtualClient.Contracts/Constants.cs b/src/VirtualClient/VirtualClient.Contracts/Constants.cs index f4269d1d09..c8e6bec761 100644 --- a/src/VirtualClient/VirtualClient.Contracts/Constants.cs +++ b/src/VirtualClient/VirtualClient.Contracts/Constants.cs @@ -189,6 +189,11 @@ public static class EnvironmentVariable /// Name = VC_PACKAGES_DIR /// public const string VC_PACKAGES_DIR = nameof(VC_PACKAGES_DIR); + + /// + /// Name = VC_SUDO_USER + /// + public const string VC_SUDO_USER = nameof(VC_SUDO_USER); } /// diff --git a/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs b/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs index f2108e4e97..baecbdf311 100644 --- a/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs +++ b/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs @@ -155,6 +155,32 @@ public PlatformSpecifics(PlatformID platform, Architecture architecture, string /// internal static bool RunningInContainer { get; set; } = PlatformSpecifics.IsRunningInContainer(); + /// + /// Get the logged in user/username. On Windows systems, the user is discoverable even when running as Administrator. + /// On Linux systems, the user can be discovered using certain environment variables when running under sudo/root. + /// + public string GetLoggedInUser() + { + string loggedInUserName = Environment.UserName; + if (string.Equals(loggedInUserName, "root")) + { + loggedInUserName = this.GetEnvironmentVariable(EnvironmentVariable.SUDO_USER); + if (string.Equals(loggedInUserName, "root") || string.IsNullOrEmpty(loggedInUserName)) + { + loggedInUserName = this.GetEnvironmentVariable(EnvironmentVariable.VC_SUDO_USER); + if (string.IsNullOrEmpty(loggedInUserName)) + { + throw new EnvironmentSetupException( + $"Unable to determine logged in username. The expected environment variables '{EnvironmentVariable.SUDO_USER}' and " + + $"'{EnvironmentVariable.VC_SUDO_USER}' do not exist or are set to 'root' (i.e. potentially when running as sudo/root).", + ErrorReason.EnvironmentIsInsufficent); + } + } + } + + return loggedInUserName; + } + /// /// Returns the platform + architecture name used by the Virtual Client to represent a /// common supported platform and architecture (e.g. win-x64, win-arm64, linux-x64, linux-arm64); diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/Identity/CertificateManagerTest.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/Identity/CertificateManagerTest.cs index 23bf5ad499..ea739bebad 100644 --- a/src/VirtualClient/VirtualClient.Core.UnitTests/Identity/CertificateManagerTest.cs +++ b/src/VirtualClient/VirtualClient.Core.UnitTests/Identity/CertificateManagerTest.cs @@ -5,30 +5,208 @@ namespace VirtualClient.Identity { using System; using System.Collections.Generic; - using System.IO; + using System.IO.Abstractions; using System.Linq; - using System.Reflection; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; + using System.Threading; using System.Threading.Tasks; using AutoFixture; + using Moq; using NUnit.Framework; - using VirtualClient.TestExtensions; using VirtualClient; + using VirtualClient.TestExtensions; [TestFixture] [Category("Unit")] public class CertificateManagerTest { private TestCertificateManager testCertificateManager; - private Fixture mockFixture; + private MockFixture mockFixture; [SetUp] public void InitializeTest() { - this.mockFixture = new Fixture(); + this.mockFixture = new MockFixture(); this.mockFixture.SetupCertificateMocks(); - this.testCertificateManager = new TestCertificateManager(); + this.testCertificateManager = new TestCertificateManager(this.mockFixture); + } + + [Test] + [TestCase("AME")] + [TestCase("GBL")] + [TestCase("AME Infra CA 02")] + [TestCase("DC=AME")] + [TestCase("DC=GBL")] + [TestCase("CN=AME")] + [TestCase("CN=AME Infra CA 02")] + [TestCase("CN=AME Infra CA 02, DC=AME, DC=GBL")] + [TestCase("CN=AME Infra CA 02,DC=AME,DC=GBL")] + public void CertificateManagerSearchesSupportsARangeOfFormatsForIssuersOnCertificates(string issuer) + { + X509Certificate2 certificate = this.mockFixture.Create(); + Assert.True(CertificateManager.IsMatchingIssuer(certificate, issuer)); + } + + [Test] + [TestCase("ABC")] + [TestCase("AME Infra CA 01")] + [TestCase("DC=ABC")] + [TestCase("DC=GBB")] + [TestCase("DC=AME Infra CA 01")] + [TestCase("CN=ABC")] + [TestCase("CN=AME Infra CA 01")] + [TestCase("CN=ABC Infra CA 01, DC=AME, DC=GBL")] + [TestCase("CN=AME Infra CA 01, DC=ABC, DC=GBL")] + [TestCase("CN=AME Infra CA 01, DC=AME, DC=GBB")] + public void CertificateManagerDoesNotMismatchIssuersOnCertificates(string issuer) + { + X509Certificate2 certificate = this.mockFixture.Create(); + Assert.False(CertificateManager.IsMatchingIssuer(certificate, issuer)); + } + + [Test] + [TestCase("virtualclient")] + [TestCase("virtualclient.corp")] + [TestCase("virtualclient.corp.azure.com")] + [TestCase("CN=virtualclient.corp")] + [TestCase("CN=virtualclient.corp.azure.com")] + public void CertificateManagerSearchesSupportsARangeOfFormatsForSubjectNamesOnCertificates(string subjectName) + { + X509Certificate2 certificate = this.mockFixture.Create(); + Assert.True(CertificateManager.IsMatchingSubjectName(certificate, subjectName)); + } + + [Test] + [TestCase("virtualclients")] + [TestCase("virtualclient.azure.com")] + [TestCase("CN=virtualclient.azure.com")] + [TestCase("CN=virtualclient.other.azure.com")] + public void CertificateManagerDoesNotMismatchSubjectNamesOnCertificates(string issuer) + { + X509Certificate2 certificate = this.mockFixture.Create(); + Assert.False(CertificateManager.IsMatchingSubjectName(certificate, issuer)); + } + + [Test] + public async Task CertificateManagerSearchesTheExpectedDirectoryForCertificates() + { + this.mockFixture.Setup(PlatformID.Unix); + this.testCertificateManager = new TestCertificateManager(this.mockFixture); + + string expectedDirectory = CertificateManager.DefaultUnixCertificateDirectory; + string expectedCertificateFile = this.mockFixture.Combine(expectedDirectory, "A3706B2B12D35F8B2B5F8176F7B6F18534A23FAD"); + bool confirmedDir = false; + bool confirmedFile = false; + + // Issuer: AME + // Subject Name: virtualclient.corp.azure.com + // Thumbprint: A3706B2B12D35F8B2B5F8176F7B6F18534A23FAD + // + // Note that this is an expired/invalid certificate so there are no security concerns. It is merely + // used for testing purposes. + X509Certificate2 certificate = this.mockFixture.Create(); + + // Setup: + // The certificate directory exists. + this.mockFixture.Directory + .Setup(dir => dir.Exists(expectedDirectory)) + .Returns(true); + + // Setup: + // There are certificates in the directory. + this.mockFixture.Directory + .Setup(dir => dir.GetFiles(It.IsAny())) + .Callback(actualDirectory => + { + Assert.AreEqual(expectedDirectory, actualDirectory); + confirmedDir = true; + }) + .Returns(new string[] { expectedCertificateFile }); + + // Setup: + // The certificate content/bytes. + this.mockFixture.File + .Setup(file => file.ReadAllBytesAsync(expectedCertificateFile, It.IsAny())) + .Callback((actualCertificateFile, token) => + { + Assert.AreEqual(expectedCertificateFile, actualCertificateFile); + confirmedFile = true; + }) + .ReturnsAsync(certificate.RawData); + + // Expectation: + // We do not need to compare the certificate properties. We just need to ensure we attempted to + // read from the expected directory and that the certificate deserializes without error. + await this.testCertificateManager.GetCertificateFromPathAsync("AME", "virtualclient.corp.azure.com", expectedDirectory); + + Assert.IsTrue(confirmedDir); + Assert.IsTrue(confirmedFile); + } + + [Test] + [TestCase("AME", "virtualclient.corp.azure.com")] + [TestCase("GBL", "virtualclient.corp.azure.com")] + [TestCase("AME Infra CA 02", "virtualclient")] + [TestCase("DC=AME", "corp.azure.com")] + [TestCase("DC=GBL", "azure.com")] + [TestCase("CN=AME", "virtualclient.corp.azure.com")] + [TestCase("CN=AME Infra CA 02", "CN=virtualclient.corp.azure.com")] + [TestCase("CN=AME Infra CA 02, DC=AME, DC=GBL", "CN=virtualclient.corp.azure.com")] + [TestCase("CN=AME Infra CA 02,DC=AME,DC=GBL", "CN=virtualclient.corp.azure.com")] + public async Task CertificateManagerHandlesDifferentIssuerAndSubjectNameFormats(string issuer, string subjectName) + { + this.mockFixture.Setup(PlatformID.Unix); + this.testCertificateManager = new TestCertificateManager(this.mockFixture); + + string expectedDirectory = CertificateManager.DefaultUnixCertificateDirectory; + string expectedCertificateFile = this.mockFixture.Combine(expectedDirectory, "A3706B2B12D35F8B2B5F8176F7B6F18534A23FAD"); + bool confirmedDir = false; + bool confirmedFile = false; + + // Issuer: AME + // Subject Name: virtualclient.corp.azure.com + // Thumbprint: A3706B2B12D35F8B2B5F8176F7B6F18534A23FAD + // + // Note that this is an expired/invalid certificate so there are no security concerns. It is merely + // used for testing purposes. + X509Certificate2 certificate = this.mockFixture.Create(); + + // Setup: + // The certificate directory exists. + this.mockFixture.Directory + .Setup(dir => dir.Exists(expectedDirectory)) + .Returns(true); + + // Setup: + // There are certificates in the directory. + this.mockFixture.Directory + .Setup(dir => dir.GetFiles(It.IsAny())) + .Callback(actualDirectory => + { + Assert.AreEqual(expectedDirectory, actualDirectory); + confirmedDir = true; + }) + .Returns(new string[] { expectedCertificateFile }); + + // Setup: + // The certificate content/bytes. + this.mockFixture.File + .Setup(file => file.ReadAllBytesAsync(expectedCertificateFile, It.IsAny())) + .Callback((actualCertificateFile, token) => + { + Assert.AreEqual(expectedCertificateFile, actualCertificateFile); + confirmedFile = true; + }) + .ReturnsAsync(certificate.RawData); + + // Expectation: + // We do not need to compare the certificate properties. We just need to ensure we attempted to + // read from the expected directory and that the certificate deserializes without error. + await this.testCertificateManager.GetCertificateFromPathAsync(issuer, subjectName, expectedDirectory); + + Assert.IsTrue(confirmedDir); + Assert.IsTrue(confirmedFile); } [Test] @@ -45,7 +223,7 @@ public void CertificateManagerThrowsWhenAValidThumbprintIsNotProvidedWhenSearchi public void CertificateManagerSearchesTheExpectedStoreForCertificates() { bool isExpectedStore = false; - this.testCertificateManager.OnGetCertificateFromStoreAsync = (store, thumbprint, validOnly) => + this.testCertificateManager.OnGetCertificateFromStoreAsync = (store, thumbprint) => { isExpectedStore = store.Name == StoreName.My.ToString() && store.Location == StoreLocation.LocalMachine; @@ -64,7 +242,7 @@ public void CertificateManagerSearchesForTheExpectedCertificateInTheStore() { string expectedThumbprint = "c3rt1f1c4t3thum6pr1nt"; List isExpectedCertificate = new List(); - this.testCertificateManager.OnGetCertificateFromStoreAsync = (store, thumbprint, validOnly) => + this.testCertificateManager.OnGetCertificateFromStoreAsync = (store, thumbprint) => { isExpectedCertificate.Add(thumbprint == expectedThumbprint); @@ -90,7 +268,7 @@ public void CertificateManagerSearchesIssuerAndSubjectForTheExpectedCertificateI X509Certificate2 expectedCertificate = this.mockFixture.Create(); List isExpectedCertificate = new List(); - this.testCertificateManager.OnGetCertificateByIssuerFromStoreAsync = (store, issuer, subjectName, validOnly) => + this.testCertificateManager.OnGetCertificateByIssuerFromStoreAsync = (store, issuer, subjectName) => { isExpectedCertificate.Add(issuer == expectedIssuer); isExpectedCertificate.Add(subjectName == expectedSubjectName); @@ -108,26 +286,6 @@ public void CertificateManagerSearchesIssuerAndSubjectForTheExpectedCertificateI Assert.IsFalse(isExpectedCertificate.Where(result => result == false).Any()); } - [Test] - public void CertificateManagerSearchesForAnyMatchingCertificatesByDefault() - { - List allCerts = new List(); - this.testCertificateManager.OnGetCertificateFromStoreAsync = (store, thumbprint, validOnly) => - { - allCerts.Add(validOnly == false); - - return this.mockFixture.Create(); - }; - - this.testCertificateManager.GetCertificateFromStoreAsync("c3rt1f1c4t3thum6pr1nt") - .GetAwaiter().GetResult(); - - this.testCertificateManager.GetCertificateFromStoreAsync("c3rt1f1c4t3thum6pr1nt", new List() { StoreLocation.CurrentUser }, StoreName.TrustedPeople) - .GetAwaiter().GetResult(); - - Assert.IsFalse(allCerts.Where(result => result == false).Any()); - } - [Test] public void CertificateManagerStoreThrowsOnCertificateNotFound() { @@ -146,23 +304,28 @@ public void CertificateManagerStoreThrowsOnCertificateNotFoundByIssuerAndSubject private class TestCertificateManager : CertificateManager { - public Func OnGetCertificateFromStoreAsync { get; set; } + public TestCertificateManager(MockFixture mockFixture) + : base(mockFixture.FileSystem.Object) + { + } + + public Func OnGetCertificateFromStoreAsync { get; set; } - public Func OnGetCertificateByIssuerFromStoreAsync { get; set; } + public Func OnGetCertificateByIssuerFromStoreAsync { get; set; } - protected override Task GetCertificateFromStoreAsync(X509Store store, string thumbprint, bool validCertificateOnly = false) + protected override Task GetCertificateFromStoreAsync(X509Store store, string thumbprint) { X509Certificate2 cert = null; return this.OnGetCertificateFromStoreAsync != null - ? Task.FromResult(this.OnGetCertificateFromStoreAsync.Invoke(store, thumbprint, validCertificateOnly)) + ? Task.FromResult(this.OnGetCertificateFromStoreAsync.Invoke(store, thumbprint)) : Task.FromResult(cert); } - protected override Task GetCertificateFromStoreAsync(X509Store store, string issuer, string subject, bool validCertificateOnly = false) + protected override Task GetCertificateFromStoreAsync(X509Store store, string issuer, string subject) { X509Certificate2 cert = null; return this.OnGetCertificateByIssuerFromStoreAsync != null - ? Task.FromResult(this.OnGetCertificateByIssuerFromStoreAsync.Invoke(store, issuer, subject, validCertificateOnly)) + ? Task.FromResult(this.OnGetCertificateByIssuerFromStoreAsync.Invoke(store, issuer, subject)) : Task.FromResult(cert); } } diff --git a/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs b/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs index e452894ae1..a97436fe26 100644 --- a/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs +++ b/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs @@ -5,7 +5,10 @@ namespace VirtualClient { using System; using System.Collections.Generic; + using System.IO.Abstractions; using System.Linq; + using System.Runtime.InteropServices; + using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -13,6 +16,7 @@ namespace VirtualClient using Azure.Identity; using VirtualClient.Common.Extensions; using VirtualClient.Contracts; + using VirtualClient.Identity; /// /// Provides features for managing requirements for remote endpoint access. @@ -607,13 +611,14 @@ private static async Task CreateIdentityTokenCredentialAsync(IC certificateThumbprint.ThrowIfNullOrWhiteSpace(nameof(certificateThumbprint)); // Always search CurrentUser/My store first. + PlatformID platform = Environment.OSVersion.Platform; StoreName storeName = StoreName.My; List storeLocations = new List { StoreLocation.CurrentUser }; - if (Environment.OSVersion.Platform == PlatformID.Win32NT) + if (platform == PlatformID.Win32NT) { // There is no local machine store on Unix/Linux systems. This store is available on // Windows only. @@ -627,7 +632,40 @@ private static async Task CreateIdentityTokenCredentialAsync(IC SendCertificateChain = true }; - X509Certificate2 certificate = await certificateManager.GetCertificateFromStoreAsync(certificateThumbprint, storeLocations, storeName); + X509Certificate2 certificate = null; + + if (platform == PlatformID.Unix) + { + string currentUser = Environment.UserName; + + try + { + certificate = await certificateManager.GetCertificateFromStoreAsync( + certificateThumbprint, + storeLocations, + storeName); + } + catch (CryptographicException) when (currentUser?.ToLowerInvariant() == "root") + { + // Backup: + // We are likely running as sudo/root. The .NET SDK will + // look for the certificate in the location specific to 'root' + // by default. We want to try the current user location as well. + PlatformSpecifics platformSpecifics = new PlatformSpecifics( + Environment.OSVersion.Platform, + RuntimeInformation.ProcessArchitecture); + + currentUser = platformSpecifics.GetLoggedInUser(); + + certificate = await certificateManager.GetCertificateFromPathAsync( + certificateThumbprint, + string.Format(CertificateManager.DefaultUnixCertificateDirectory, currentUser)); + } + } + else + { + certificate = await certificateManager.GetCertificateFromStoreAsync(certificateThumbprint, storeLocations, storeName); + } return new ClientCertificateCredential(tenantId, clientId, certificate, credentialOptions); } @@ -641,13 +679,14 @@ private static async Task CreateIdentityTokenCredentialAsync(IC certificateSubject.ThrowIfNullOrWhiteSpace(nameof(certificateSubject)); // Always search CurrentUser/My store first. + PlatformID platform = Environment.OSVersion.Platform; StoreName storeName = StoreName.My; List storeLocations = new List { StoreLocation.CurrentUser }; - if (Environment.OSVersion.Platform == PlatformID.Win32NT) + if (platform == PlatformID.Win32NT) { // There is no local machine store on Unix/Linux systems. This store is available on // Windows only. @@ -661,7 +700,46 @@ private static async Task CreateIdentityTokenCredentialAsync(IC SendCertificateChain = true }; - X509Certificate2 certificate = await certificateManager.GetCertificateFromStoreAsync(certificateIssuer, certificateSubject, storeLocations, storeName); + X509Certificate2 certificate = null; + + if (platform == PlatformID.Unix) + { + string currentUser = Environment.UserName; + + try + { + certificate = await certificateManager.GetCertificateFromStoreAsync( + certificateIssuer, + certificateSubject, + storeLocations, + storeName); + } + catch (CryptographicException) when (currentUser?.ToLowerInvariant() == "root") + { + // Backup: + // We are likely running as sudo/root. The .NET SDK will + // look for the certificate in the location specific to 'root' + // by default. We want to try the current user location as well. + PlatformSpecifics platformSpecifics = new PlatformSpecifics( + Environment.OSVersion.Platform, + RuntimeInformation.ProcessArchitecture); + + currentUser = platformSpecifics.GetLoggedInUser(); + + certificate = await certificateManager.GetCertificateFromPathAsync( + certificateIssuer, + certificateSubject, + string.Format(CertificateManager.DefaultUnixCertificateDirectory, currentUser)); + } + } + else + { + certificate = await certificateManager.GetCertificateFromStoreAsync( + certificateIssuer, + certificateSubject, + storeLocations, + storeName); + } return new ClientCertificateCredential(tenantId, clientId, certificate, credentialOptions); } diff --git a/src/VirtualClient/VirtualClient.Core/Identity/CertificateManager.cs b/src/VirtualClient/VirtualClient.Core/Identity/CertificateManager.cs index 717b8246b9..6577948993 100644 --- a/src/VirtualClient/VirtualClient.Core/Identity/CertificateManager.cs +++ b/src/VirtualClient/VirtualClient.Core/Identity/CertificateManager.cs @@ -5,6 +5,8 @@ namespace VirtualClient.Identity { using System; using System.Collections.Generic; + using System.IO; + using System.IO.Abstractions; using System.Linq; using System.Security; using System.Security.Cryptography; @@ -18,8 +20,70 @@ namespace VirtualClient.Identity /// public class CertificateManager : ICertificateManager { + /// + /// The default directory to search for installed certificates for use + /// with the Virtual Client. + /// + public const string DefaultUnixCertificateDirectory = "/home/{0}/.dotnet/corefx/cryptography/x509stores/my"; private static readonly Regex CommonNameExpression = new Regex("CN=", RegexOptions.Compiled | RegexOptions.IgnoreCase); + /// + /// Initializes a new instance of the class. + /// + public CertificateManager() + : this(new FileSystem()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Provides features for accessing the file system. + public CertificateManager(IFileSystem fileSystem) + { + this.FileSystem = fileSystem ?? new FileSystem(); + } + + /// + /// Provides features for accessing the file system. + /// + public IFileSystem FileSystem { get; } + + /// + /// Returns true/false whether the issuer for the certificate matches the issuer provided. + /// + /// The certificate for which to check the issuer. + /// + /// The issuer to confirm. This can be a fully qualified name or parts of it + /// (e.g. CN=ABC Infra CA 01, DC=ABC, DC=COM or ABC Infra CA 01 or ABC). + /// + /// True if the issuer matches or false if not. + public static bool IsMatchingIssuer(X509Certificate2 certificate, string issuer) + { + return Regex.IsMatch( + certificate.Issuer.RemoveWhitespace(), + issuer.RemoveWhitespace(), + RegexOptions.IgnoreCase); + } + + /// + /// Returns true/false whether the subject name for the certificate matches the + /// subject name provided. + /// + /// The certificate for which to check the subject name. + /// + /// The subject name to confirm. This can be a fully qualified name or parts of it + /// (e.g. CN=any.service.azure.com or any.service.azure.com). + /// + /// True if the subject name matches or false if not. + public static bool IsMatchingSubjectName(X509Certificate2 certificate, string subjectName) + { + return Regex.IsMatch( + certificate.Subject.RemoveWhitespace(), + subjectName.RemoveWhitespace(), + RegexOptions.IgnoreCase); + } + /// /// Normalizes the certificate thumbprint ensuring that the string value has no /// non-printable/control characters. @@ -36,18 +100,173 @@ public static string NormalizeThumbprint(string thumbprint) return Regex.Replace(thumbprint, @"[^\da-zA-z]", string.Empty).ToUpperInvariant(); } + /// + public async Task GetCertificateFromPathAsync(string thumbprint, string directoryPath) + { + thumbprint.ThrowIfNullOrWhiteSpace(nameof(thumbprint)); + directoryPath.ThrowIfNullOrWhiteSpace(nameof(directoryPath)); + + X509Certificate2 certificate = null; + string errorMessage = $"Certificate not found. A certificate with matching thumbprint '{thumbprint}' " + + $"was not found in the expected certificate directory: {directoryPath}"; + + try + { + if (this.FileSystem.Directory.Exists(directoryPath)) + { + List matchingCertificates = new List(); + string[] certificateFiles = this.FileSystem.Directory.GetFiles(directoryPath); + string normalizedThumbprint = CertificateManager.NormalizeThumbprint(thumbprint); + + foreach (string file in certificateFiles) + { + try + { + byte[] certificateBytes = await this.FileSystem.File.ReadAllBytesAsync(file); + if (certificateBytes?.Any() == true) + { + X509Certificate2 certificateFromFile = new X509Certificate2( + certificateBytes, + string.Empty.ToSecureString(), + X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet); + + if (certificateFromFile != null) + { + if (string.Equals(certificateFromFile.Thumbprint, normalizedThumbprint, StringComparison.OrdinalIgnoreCase)) + { + matchingCertificates.Add(certificateFromFile); + } + } + } + } + catch + { + // There may be non-certificate files in the directory or certificates that + // are not .pfx format. + } + } + + // Take the certificate with the latest expiration date that is not + // currently expired. + if (matchingCertificates?.Any() == true) + { + DateTime now = DateTime.Now; + certificate = matchingCertificates.Where(cert => now >= cert.NotBefore && now < cert.NotAfter)?.OrderByDescending(cert => cert.NotAfter).First(); + } + } + } + catch (IOException) + { + // Directory access store permissions. + throw; + } + catch (SecurityException) + { + // Directory access store permissions. + throw; + } + + if (certificate == null) + { + throw new CryptographicException(errorMessage); + } + + return certificate; + } + + /// + public async Task GetCertificateFromPathAsync(string issuer, string subjectName, string directoryPath) + { + issuer.ThrowIfNullOrWhiteSpace(nameof(issuer)); + subjectName.ThrowIfNullOrWhiteSpace(nameof(subjectName)); + directoryPath.ThrowIfNullOrWhiteSpace(nameof(directoryPath)); + + X509Certificate2 certificate = null; + string errorMessage = $"Certificate not found. A certificate with matching issuer '{issuer}' and subject '{subjectName}' " + + $"was not found in the expected certificate directory: {directoryPath}"; + + try + { + if (this.FileSystem.Directory.Exists(directoryPath)) + { + List matchingCertificates = new List(); + string[] certificateFiles = this.FileSystem.Directory.GetFiles(directoryPath); + + foreach (string file in certificateFiles) + { + try + { + byte[] certificateBytes = await this.FileSystem.File.ReadAllBytesAsync(file); + if (certificateBytes?.Any() == true) + { + X509Certificate2 certificateFromFile = new X509Certificate2( + certificateBytes, + string.Empty.ToSecureString(), + X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet); + + if (certificateFromFile != null) + { + if (CertificateManager.IsMatchingIssuer(certificateFromFile, issuer) + && CertificateManager.IsMatchingSubjectName(certificateFromFile, subjectName)) + { + matchingCertificates.Add(certificateFromFile); + } + } + } + } + catch + { + // There may be non-certificate files in the directory or certificates that + // are not .pfx format. + } + } + + // Take the certificate with the latest expiration date that is not + // currently expired. + if (matchingCertificates?.Any() == true) + { + DateTime now = DateTime.Now; + certificate = matchingCertificates.Where(cert => now >= cert.NotBefore && now < cert.NotAfter)?.OrderByDescending(cert => cert.NotAfter).First(); + } + } + } + catch (IOException) + { + // Directory access store permissions. + throw; + } + catch (SecurityException) + { + // Directory access store permissions. + throw; + } + + if (certificate == null) + { + throw new CryptographicException(errorMessage); + } + + return certificate; + } + /// public async Task GetCertificateFromStoreAsync(string thumbprint, IEnumerable storeLocations = null, StoreName storeName = StoreName.My) { thumbprint.ThrowIfNullOrWhiteSpace(nameof(thumbprint)); if (storeLocations == null) { - storeLocations = new List() { StoreLocation.CurrentUser, StoreLocation.LocalMachine }; + storeLocations = new List() + { + StoreLocation.CurrentUser, + StoreLocation.LocalMachine + }; } X509Certificate2 result = null; string errorMessage = $"Certificate not found. A certificate for user '{Environment.UserName}' with matching thumbprint '{thumbprint}' " + - $"was not found in any one of the following expected certificate stores: "; + $"was not found in any one of the following expected certificate stores: " + + $"{string.Join(", ", storeLocations.Select(l => $"{l.ToString()}/{storeName.ToString()}"))}"; + foreach (StoreLocation storeLocation in storeLocations) { try @@ -94,12 +313,18 @@ public async Task GetCertificateFromStoreAsync(string issuer, if (storeLocations == null) { - storeLocations = new List() { StoreLocation.CurrentUser, StoreLocation.LocalMachine }; + storeLocations = new List() + { + StoreLocation.CurrentUser, + StoreLocation.LocalMachine + }; } X509Certificate2 result = null; string errorMessage = $"Certificate not found. A certificate for user '{Environment.UserName}' with matching issuer '{issuer}' and subject '{subject}' " + - $"was not found in any one of the following expected certificate stores: "; + $"was not found in any one of the following expected certificate stores: " + + $"{string.Join(", ", storeLocations.Select(l => $"{l.ToString()}/{storeName.ToString()}"))}"; + foreach (StoreLocation storeLocation in storeLocations) { try @@ -143,11 +368,10 @@ public async Task GetCertificateFromStoreAsync(string issuer, /// /// The certificate store in which to search. /// The thumbprint of the certificate in the store. - /// True if only valid (i.e. not expired/revoked) certificates should be returned. /// /// A set of certificates from the store having a matching thumbprint. /// - protected virtual Task GetCertificateFromStoreAsync(X509Store store, string thumbprint, bool validCertificateOnly = false) + protected virtual Task GetCertificateFromStoreAsync(X509Store store, string thumbprint) { store.ThrowIfNull(nameof(store)); thumbprint.ThrowIfNullOrWhiteSpace(thumbprint); @@ -158,28 +382,33 @@ protected virtual Task GetCertificateFromStoreAsync(X509Store return Task.Run(() => { - X509Certificate2Collection certificates = null; + X509Certificate2Collection matchingCertificates = null; store.Open(OpenFlags.ReadOnly); try { string normalizedThumbprint = CertificateManager.NormalizeThumbprint(thumbprint); - certificates = store.Certificates.Find( + // There are cases on Unix systems where valid certificates exist but + // are not returned when we search for "valid only". We handle the conditions + // for validity below based on expiry dates. + matchingCertificates = store.Certificates.Find( findType: X509FindType.FindByThumbprint, findValue: normalizedThumbprint, - validOnly: validCertificateOnly); + validOnly: false); } finally { store.Close(); } + // Take the certificate with the latest expiration date that is not + // currently expired. X509Certificate2 certificate = null; - if (certificates?.Any() == true) + if (matchingCertificates?.Any() == true) { DateTime now = DateTime.Now; - certificate = certificates.Where(cert => now >= cert.NotBefore && now < cert.NotAfter)?.OrderByDescending(cert => cert.NotAfter).First(); + certificate = matchingCertificates.Where(cert => now >= cert.NotBefore && now < cert.NotAfter)?.OrderByDescending(cert => cert.NotAfter).First(); } return certificate; @@ -198,11 +427,10 @@ protected virtual Task GetCertificateFromStoreAsync(X509Store /// The subject name for the certificate within the certificate store. Both subject name and distinguished name formats are supported /// (e.g. any.service.azure.com or CN=any.service.azure.com). /// - /// True if only valid (i.e. not expired/revoked) certificates should be returned. /// /// A set of certificates from the store having a matching issuer and subject name. /// - protected virtual Task GetCertificateFromStoreAsync(X509Store store, string issuer, string subjectName, bool validCertificateOnly = false) + protected virtual Task GetCertificateFromStoreAsync(X509Store store, string issuer, string subjectName) { store.ThrowIfNull(nameof(store)); issuer.ThrowIfNullOrWhiteSpace(issuer); @@ -215,7 +443,7 @@ protected virtual Task GetCertificateFromStoreAsync(X509Store return Task.Run(() => { X509Certificate2 certificate = null; - X509Certificate2Collection certificates = null; + X509Certificate2Collection matchingCertificates = null; X509Certificate2Collection certsByIssuer = null; store.Open(OpenFlags.ReadOnly); @@ -229,7 +457,7 @@ protected virtual Task GetCertificateFromStoreAsync(X509Store certsByIssuer = store.Certificates.Find( findType: X509FindType.FindByIssuerDistinguishedName, findValue: issuer, - validOnly: validCertificateOnly); + validOnly: false); } else { @@ -243,35 +471,41 @@ protected virtual Task GetCertificateFromStoreAsync(X509Store certsByIssuer = store.Certificates.Find( findType: X509FindType.FindByIssuerName, findValue: issuer, - validOnly: validCertificateOnly); + validOnly: false); } if (certsByIssuer?.Any() == true) { + // There are cases on Unix systems where valid certificates exist but + // are not returned when we search for "valid only". We handle the conditions + // for validity below based on expiry dates. + if (CertificateManager.CommonNameExpression.IsMatch(subjectName)) { // e.g. // CN=any.service.azure.com - certificates = certsByIssuer.Find( + matchingCertificates = certsByIssuer.Find( findType: X509FindType.FindBySubjectDistinguishedName, findValue: subjectName, - validOnly: validCertificateOnly); + validOnly: false); } else { // e.g. // any.service.azure.com - certificates = certsByIssuer.Find( + matchingCertificates = certsByIssuer.Find( findType: X509FindType.FindBySubjectName, findValue: subjectName, - validOnly: validCertificateOnly); + validOnly: false); } } - if (certificates?.Any() == true) + // Take the certificate with the latest expiration date that is not + // currently expired. + if (matchingCertificates?.Any() == true) { DateTime now = DateTime.Now; - certificate = certificates.Where(cert => now >= cert.NotBefore && now < cert.NotAfter)?.OrderByDescending(cert => cert.NotAfter).First(); + certificate = matchingCertificates.Where(cert => now >= cert.NotBefore && now < cert.NotAfter)?.OrderByDescending(cert => cert.NotAfter).First(); } } finally diff --git a/src/VirtualClient/VirtualClient.Core/Identity/ICertificateManager.cs b/src/VirtualClient/VirtualClient.Core/Identity/ICertificateManager.cs index b0d87bca6f..f283bd1994 100644 --- a/src/VirtualClient/VirtualClient.Core/Identity/ICertificateManager.cs +++ b/src/VirtualClient/VirtualClient.Core/Identity/ICertificateManager.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace VirtualClient +namespace VirtualClient.Identity { using System.Collections.Generic; using System.Security.Cryptography.X509Certificates; @@ -12,27 +12,48 @@ namespace VirtualClient /// public interface ICertificateManager { + /// + /// Get certificate from specified directory path that matches the specified thumbprint. + /// + /// The thumbprint/hash (e.g. SHA1) for the certificate. + /// The directory path location in which to search for the certificate. + Task GetCertificateFromPathAsync(string thumbprint, string directoryPath); + + /// + /// Get certificate from specified directory path that matches the specified issuer and subject. + /// + /// + /// The issuer of the certificate. This can be a fully qualified name or parts of it (e.g. ABC Infra CA 01 or CN=ABC Infra CA 01, DC=ABC, DC=COM). + /// + /// + /// The subject name for the certificate. This can be a fully qualified name or parts of it (e.g. any.service.azure.com or CN=any.service.azure.com). + /// + /// The directory path location in which to search for the certificate. + Task GetCertificateFromPathAsync(string issuer, string subjectName, string directoryPath); + /// /// Get certificate from specified store that matches the specified thumbprint. /// + /// The thumbprint/hash (e.g. SHA1) for the certificate. /// The certificate store in which the certificates matching the thumbprint are stored (default = My/Personal). /// /// The certificate store location in which the certificates matching the thumbprint are stored. Default is to look at current user first, if not found, look at localmachine next. /// - /// Certificate thumbprint. - /// Certificate. Task GetCertificateFromStoreAsync(string thumbprint, IEnumerable storeLocations = null, StoreName storeName = StoreName.My); /// - /// Get certificate from specified store that matches the specified thumbprint. + /// Get certificate from specified store that matches the specified issuer and subject. /// /// The certificate store in which the certificates matching the thumbprint are stored (default = My/Personal). /// /// The certificate store location in which the certificates matching the thumbprint are stored. Default is to look at current user first, if not found, look at localmachine next. /// - /// Certificate issuer. - /// Certificate subject name. - /// Certificate. + /// + /// The issuer of the certificate. This can be a fully qualified name or parts of it (e.g. ABC Infra CA 01 or CN=ABC Infra CA 01, DC=ABC, DC=COM). + /// + /// + /// The subject name for the certificate. This can be a fully qualified name or parts of it (e.g. any.service.azure.com or CN=any.service.azure.com). + /// Task GetCertificateFromStoreAsync(string issuer, string subjectName, IEnumerable storeLocations = null, StoreName storeName = StoreName.My); } } \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.TestExtensions/AutoFixtureExtensions.cs b/src/VirtualClient/VirtualClient.TestExtensions/AutoFixtureExtensions.cs index b8ed4be2d6..940616a8e2 100644 --- a/src/VirtualClient/VirtualClient.TestExtensions/AutoFixtureExtensions.cs +++ b/src/VirtualClient/VirtualClient.TestExtensions/AutoFixtureExtensions.cs @@ -9,6 +9,7 @@ namespace VirtualClient.TestExtensions using System.Security; using System.Security.Cryptography.X509Certificates; using AutoFixture; + using VirtualClient.Common.Extensions; /// /// Extension methods for instances and for general @@ -57,36 +58,17 @@ private static X509Certificate2 CreateCertificate(bool withPrivateKey = false) if (withPrivateKey) { certificate = new X509Certificate2( - File.ReadAllBytes(Path.Combine(resourcesDirectory, "testcertificate.private")), - AutoFixtureExtensions.CertificatePass(), + File.ReadAllBytes(Path.Combine(resourcesDirectory, "testcertificate2.private")), + string.Empty.ToSecureString(), X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet); } else { certificate = new X509Certificate2( - File.ReadAllBytes(Path.Combine(resourcesDirectory, "testcertificate.private"))); + File.ReadAllBytes(Path.Combine(resourcesDirectory, "testcertificate2.private"))); } return certificate; } - - /// - /// Returns the "secret" word for the test PFX certificate. Note that this certificate is used - /// nowhere other than for testing. It can be used to do nothing at all. In order to verify certain - /// certificate operations, the full PFX is required containing the private key. In order to export the - /// certificate with the private key the "secret" word is required. - /// - /// - private static SecureString CertificatePass() - { - byte[] wordChars = new byte[] { 115, 101, 99, 114, 101, 116 }; - SecureString word = new SecureString(); - foreach (byte wordChar in wordChars) - { - word.AppendChar((char)wordChar); - } - - return word; - } } } diff --git a/src/VirtualClient/VirtualClient.TestExtensions/Resources/testcertificate.private b/src/VirtualClient/VirtualClient.TestExtensions/Resources/testcertificate.private deleted file mode 100644 index f7a031f58b..0000000000 Binary files a/src/VirtualClient/VirtualClient.TestExtensions/Resources/testcertificate.private and /dev/null differ diff --git a/src/VirtualClient/VirtualClient.TestExtensions/Resources/testcertificate2.private b/src/VirtualClient/VirtualClient.TestExtensions/Resources/testcertificate2.private new file mode 100644 index 0000000000..f7005e327c Binary files /dev/null and b/src/VirtualClient/VirtualClient.TestExtensions/Resources/testcertificate2.private differ diff --git a/src/VirtualClient/VirtualClient.TestExtensions/VirtualClient.TestExtensions.csproj b/src/VirtualClient/VirtualClient.TestExtensions/VirtualClient.TestExtensions.csproj index b122f4648d..ce213cf316 100644 --- a/src/VirtualClient/VirtualClient.TestExtensions/VirtualClient.TestExtensions.csproj +++ b/src/VirtualClient/VirtualClient.TestExtensions/VirtualClient.TestExtensions.csproj @@ -22,7 +22,7 @@ - + PreserveNewest diff --git a/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs b/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs index 9aff213ad5..1f9852411c 100644 --- a/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs +++ b/src/VirtualClient/VirtualClient.TestFramework/MockFixture.cs @@ -25,6 +25,7 @@ namespace VirtualClient using VirtualClient.Common; using VirtualClient.Common.Extensions; using VirtualClient.Contracts; + using VirtualClient.Identity; /// /// Fixture that encapsulates the setting up and mocking diff --git a/src/VirtualClient/VirtualClient.UnitTests/OptionFactoryTests.cs b/src/VirtualClient/VirtualClient.UnitTests/OptionFactoryTests.cs index 601c27eb9c..2fd6b19302 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/OptionFactoryTests.cs +++ b/src/VirtualClient/VirtualClient.UnitTests/OptionFactoryTests.cs @@ -16,6 +16,7 @@ namespace VirtualClient using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; + using VirtualClient.Identity; [TestFixture] [Category("Unit")]