diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs index 323ec33648..791ec100e5 100644 --- a/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs +++ b/src/VirtualClient/VirtualClient.Core.UnitTests/EndpointUtilityTests.cs @@ -854,37 +854,5 @@ public void CreateKeyVaultStoreReference_ConnectionString_ThrowsOnInvalid() "InvalidConnectionString", this.mockFixture.CertificateManager.Object)); } - - [Test] - [TestCase("https://anyvault.vault.azure.net/?cid=123456&tid=654321")] - [TestCase("https://anycontentstorage.blob.core.windows.net?cid=123456&tid=654321")] - [TestCase("https://anypackagestorage.blob.core.windows.net?tid=654321")] - [TestCase("https://anynamespace.servicebus.windows.net?cid=123456&tid=654321")] - [TestCase("https://my-keyvault.vault.azure.net/?;tid=654321")] - public void TryParseMicrosoftEntraTenantIdReference_Uri_WorksAsExpected(string input) - { - // Arrange - Uri uri = new Uri(input); - bool result = EndpointUtility.TryParseMicrosoftEntraTenantIdReference(uri, out string actualTenantId); - - // Assert - Assert.True(result); - Assert.AreEqual("654321", actualTenantId); - } - - [Test] - [TestCase("https://anycontentstorage.blob.core.windows.net?cid=123456&tenantId=654321")] - [TestCase("https://anypackagestorage.blob.core.windows.net?miid=654321")] - [TestCase("https://my-keyvault.vault.azure.net/;cid=654321")] - public void TryParseMicrosoftEntraTenantIdReference_Uri_ReturnFalseWhenInvalid(string input) - { - // Arrange - Uri uri = new Uri(input); - bool result = EndpointUtility.TryParseMicrosoftEntraTenantIdReference(uri, out string actualTenantId); - - // Assert - Assert.IsFalse(result); - Assert.IsNull(actualTenantId); - } } } diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs index c3c6c6ac40..8e90f486e2 100644 --- a/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs +++ b/src/VirtualClient/VirtualClient.Core.UnitTests/KeyVaultManagerTests.cs @@ -41,6 +41,25 @@ public void SetupDefaultBehaviors() .Setup(c => c.GetSecretAsync("mysecret", null, It.IsAny())) .ReturnsAsync(Response.FromValue(secret, Mock.Of())); + this.secretClientMock + .Setup(c => c.GetSecretAsync(It.Is(x => x == "mysecret"), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(secret, Mock.Of())); + + var pfxCertificate = this.GenerateTestCertificateWithPrivateKey(); + var pfxBytes = pfxCertificate.Export(X509ContentType.Pfx, ""); + var pfxBase64 = Convert.ToBase64String(pfxBytes); + var certSecret = SecretModelFactory.KeyVaultSecret( + properties: SecretModelFactory.SecretProperties( + name: "mycert", + version: "v3", + vaultUri: new Uri("https://myvault.vault.azure.net/"), + id: new Uri("https://myvault.vault.azure.net/secrets/mycert/v3")), + pfxBase64); + + this.secretClientMock + .Setup(c => c.GetSecretAsync(It.Is(s => s == "mycert"), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(certSecret, Mock.Of())); + // Mock the key this.keyClientMock = new Mock(MockBehavior.Strict, new Uri("https://myvault.vault.azure.net/"), new MockTokenCredential()); var key = KeyModelFactory.KeyVaultKey(properties: KeyModelFactory.KeyProperties( @@ -124,10 +143,14 @@ public async Task KeyVaultManagerReturnsExpectedCertificate(bool retrieveWithPri if (retrieveWithPrivateKey) { Assert.IsTrue(result.HasPrivateKey); + Assert.IsNotNull(result.Export(X509ContentType.Pfx, string.Empty)); // Verifies cert is exportable with private key + this.secretClientMock.Verify(c => c.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } else { Assert.IsFalse(result.HasPrivateKey); + Assert.IsNotNull(result.Export(X509ContentType.Cert, string.Empty)); + this.certificateClientMock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Once); } } diff --git a/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs b/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs index dff6394fb1..42c9b19c70 100644 --- a/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs +++ b/src/VirtualClient/VirtualClient.Core/EndpointUtility.cs @@ -398,26 +398,6 @@ public static bool TryParseCertificateReference(Uri uri, out string issuer, out return TryGetCertificateReferenceForUri(queryParameters, out issuer, out subject); } - /// - /// Tries to parse the Microsoft Entra reference information from the provided uri. If the uri does not contain the correctly formatted client ID - /// and tenant ID information the method will return false, and keep the two out parameters as null. - /// Ex. https://anystore.blob.core.windows.net?cid={clientId};tid={tenantId} - /// - /// The uri to attempt to parse the values from. - /// The tenant ID from the Microsoft Entra reference. - /// True/False if the method was able to successfully parse both the client ID and the tenant ID from the Microsoft Entra reference. - public static bool TryParseMicrosoftEntraTenantIdReference(Uri uri, out string tenantId) - { - string queryString = Uri.UnescapeDataString(uri.Query).Trim('?').Replace("&", ",,,"); - - IDictionary queryParameters = TextParsingExtensions.ParseDelimitedValues(queryString)?.ToDictionary( - entry => entry.Key, - entry => entry.Value?.ToString(), - StringComparer.OrdinalIgnoreCase); - - return TryGetMicrosoftEntraTenantId(queryParameters, out tenantId); - } - /// /// Returns the endpoint by verifying package uri checks. /// if the endpoint is a package uri without http or https protocols then append the protocol else return the endpoint value. diff --git a/src/VirtualClient/VirtualClient.Core/Identity/CertificateLoaderHelper.cs b/src/VirtualClient/VirtualClient.Core/Identity/CertificateLoaderHelper.cs new file mode 100644 index 0000000000..00f1a9637a --- /dev/null +++ b/src/VirtualClient/VirtualClient.Core/Identity/CertificateLoaderHelper.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +namespace VirtualClient.Identity +{ + using System.Security.Cryptography.X509Certificates; + + /// + /// Certificate Loader to cleanly handle differences in .NET versions for loading certificates from byte arrays. + /// + internal static class CertificateLoaderHelper + { + internal static X509Certificate2 LoadPublic(byte[] cerBytes) + { +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadCertificate(cerBytes); +#else + return new X509Certificate2(cerBytes); +#endif + } + + internal static X509Certificate2 LoadPkcs12( + byte[] pfxBytes, + string password, + X509KeyStorageFlags flags) + { +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12( + pfxBytes, + password, + flags); +#else + return new X509Certificate2( + pfxBytes, + password, + flags); +#endif + } + } +} diff --git a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs index 03b811a12f..25fdb9bbea 100644 --- a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs +++ b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs @@ -15,6 +15,7 @@ namespace VirtualClient using Azure.Security.KeyVault.Secrets; using Polly; using VirtualClient.Common.Extensions; + using VirtualClient.Identity; /// /// Provides methods for retrieving secrets, keys, and certificates from an Azure Key Vault. @@ -211,13 +212,17 @@ public async Task GetCertificateAsync( this.StoreDescription.ThrowIfNull(nameof(this.StoreDescription)); certName.ThrowIfNullOrWhiteSpace(nameof(certName), "The certificate name cannot be null or empty."); - // Use the keyVaultUri if provided as a parameter, otherwise use the store's EndpointUri Uri vaultUri = !string.IsNullOrWhiteSpace(keyVaultUri) ? new Uri(keyVaultUri) : ((DependencyKeyVaultStore)this.StoreDescription).EndpointUri; CertificateClient client = this.CreateCertificateClient(vaultUri, ((DependencyKeyVaultStore)this.StoreDescription).Credentials); + var credentials = ((DependencyKeyVaultStore)this.StoreDescription).Credentials; + + CertificateClient certificateClient = this.CreateCertificateClient(vaultUri, credentials); // For public cert. + SecretClient secretClient = this.CreateSecretClient(vaultUri, credentials); // For private cert (PFX) + try { return await (retryPolicy ?? KeyVaultManager.DefaultRetryPolicy).ExecuteAsync(async () => @@ -225,26 +230,32 @@ public async Task GetCertificateAsync( // Get the full certificate with private key (PFX) if requested if (retrieveWithPrivateKey) { - X509Certificate2 privateKeyCert = await client - .DownloadCertificateAsync(certName, cancellationToken: cancellationToken) - .ConfigureAwait(false); + KeyVaultSecret secret = await secretClient.GetSecretAsync(certName, cancellationToken: cancellationToken); + + if (secret?.Value == null) + { + throw new DependencyException($"Secret for certificate '{certName}' not found in vault '{vaultUri}'."); + } + + byte[] pfxBytes = Convert.FromBase64String(secret.Value); - if (privateKeyCert is null || !privateKeyCert.HasPrivateKey) + X509Certificate2 pfxCertificate = CertificateLoaderHelper.LoadPkcs12( + pfxBytes, + string.Empty, + X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet); + + if (!pfxCertificate.HasPrivateKey) { - throw new DependencyException("Failed to retrieve certificate content with private key."); + throw new DependencyException($"Certificate '{certName}' does not contain a private key."); } - return privateKeyCert; + return pfxCertificate; } else { - // If private key not needed, load cert from PublicBytes - KeyVaultCertificateWithPolicy cert = await client.GetCertificateAsync(certName, cancellationToken: cancellationToken); -#if NET9_0_OR_GREATER - return X509CertificateLoader.LoadCertificate(cert.Cer); -#elif NET8_0_OR_GREATER - return new X509Certificate2(cert.Cer); -#endif + // Public certificate only + KeyVaultCertificateWithPolicy certBundle = await certificateClient.GetCertificateAsync(certName, cancellationToken: cancellationToken); + return CertificateLoaderHelper.LoadPublic(certBundle.Cer); } }).ConfigureAwait(false); } @@ -269,13 +280,6 @@ public async Task GetCertificateAsync( ex, ErrorReason.HttpNonSuccessResponse); } - catch (Exception ex) - { - throw new DependencyException( - $"Failed to get certificate '{certName}' from vault '{vaultUri}'.", - ex, - ErrorReason.HttpNonSuccessResponse); - } } /// @@ -328,4 +332,4 @@ private void ValidateKeyVaultStore() } } } -} +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs index 606bb112ec..e144bd5cd9 100644 --- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs @@ -1,26 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Moq; +using NUnit.Framework; +using Polly; +using VirtualClient.Common.Telemetry; + namespace VirtualClient.Dependencies { - using System; - using System.Collections.Generic; - using System.IO; - using System.Net.Http; - using System.Security.Cryptography; - using System.Security.Cryptography.X509Certificates; - using System.Threading; - using System.Threading.Tasks; - using Azure.Core; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.DependencyInjection.Extensions; - using Moq; - using NUnit.Framework; - using Polly; - using VirtualClient.Common.Telemetry; - using VirtualClient.Contracts; - using VirtualClient.Identity; - [TestFixture] [Category("Unit")] public class CertificateInstallationTests @@ -52,9 +48,11 @@ public void Cleanup() } [Test] - public async Task InitializeAsync_LoadsAccessTokenFromParameter() + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public async Task InitializeAsync_LoadsAccessTokenFromParameter(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Setup(platform); string expectedToken = "test-access-token-12345"; this.mockFixture.Parameters = new Dictionary() @@ -72,9 +70,11 @@ public async Task InitializeAsync_LoadsAccessTokenFromParameter() } [Test] - public async Task InitializeAsync_LoadsAccessTokenFromFile() + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public async Task InitializeAsync_LoadsAccessTokenFromFile(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Setup(platform); string expectedToken = "file-access-token-67890"; string tokenFilePath = "/tmp/token.txt"; @@ -97,9 +97,11 @@ public async Task InitializeAsync_LoadsAccessTokenFromFile() } [Test] - public async Task InitializeAsync_PrefersAccessTokenParameterOverFile() + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public async Task InitializeAsync_PrefersAccessTokenParameterOverFile(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Setup(platform); string parameterToken = "parameter-token-123"; string fileToken = "file-token-321"; @@ -124,26 +126,28 @@ public async Task InitializeAsync_PrefersAccessTokenParameterOverFile() } [Test] - public void ExecuteAsync_ThrowsWhenCertificateNameIsNull() + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public void ExecuteAsync_ThrowsWhenCertificateNameIsNull(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); - this.mockFixture.Parameters = new Dictionary() + this.mockFixture.Setup(platform); + this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } }; using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) { - Exception exception = Assert.ThrowsAsync( + KeyNotFoundException exception = Assert.ThrowsAsync( () => component.ExecuteAsync(EventContext.None, CancellationToken.None)); Assert.IsNotNull(exception); - Assert.IsTrue(exception.Message.Contains("An entry with key 'CertificateName' does not exist in the dictionary.")); + Assert.IsTrue(exception.Message.Contains("CertificateName")); } } [Test] - public async Task ExecuteAsyncInstallsCertificateOnWindows() + public async Task ExecuteAsync_InstallsCertificateOnWindows() { this.mockFixture.Setup(PlatformID.Win32NT); this.mockFixture.Parameters = new Dictionary() @@ -152,7 +156,7 @@ public async Task ExecuteAsyncInstallsCertificateOnWindows() { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } }; - bool windowsInstallCalled = false; + bool machineInstallCalled = false; using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) { @@ -165,9 +169,9 @@ public async Task ExecuteAsyncInstallsCertificateOnWindows() It.IsAny())) .ReturnsAsync(this.testCertificate); - component.OnInstallCertificateOnWindows = (cert, token) => + component.OnInstallCertificateOnMachine = (cert, token) => { - windowsInstallCalled = true; + machineInstallCalled = true; Assert.AreEqual(this.testCertificate, cert); return Task.CompletedTask; }; @@ -175,7 +179,7 @@ public async Task ExecuteAsyncInstallsCertificateOnWindows() await component.ExecuteAsync(EventContext.None, CancellationToken.None); } - Assert.IsTrue(windowsInstallCalled); + Assert.IsTrue(machineInstallCalled); this.mockFixture.KeyVaultManager.Verify(m => m.GetCertificateAsync( "testCert", It.IsAny(), @@ -185,51 +189,48 @@ public async Task ExecuteAsyncInstallsCertificateOnWindows() } [Test] - public async Task ExecuteAsyncInstallsCertificateOnUnix() + [TestCase(PlatformID.Win32NT, @"C:\Certs")] + [TestCase(PlatformID.Unix, "/tmp/certs")] + public async Task ExecuteAsync_InstallsCertificateLocally_WhenDirectoryProvided(PlatformID platform, string installDir) { - this.mockFixture.Setup(PlatformID.Unix); + this.mockFixture.Setup(platform); this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateInstallationDir), installDir } }; - bool unixInstallCalled = false; + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) { this.mockFixture.KeyVaultManager - .Setup(m => m.GetCertificateAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .ReturnsAsync(this.testCertificate); - - component.OnInstallCertificateOnUnix = (cert, token) => - { - unixInstallCalled = true; - Assert.AreEqual(this.testCertificate, cert); - return Task.CompletedTask; - }; + .Setup(m => m.GetCertificateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(this.testCertificate); await component.ExecuteAsync(EventContext.None, CancellationToken.None); } - Assert.IsTrue(unixInstallCalled); - this.mockFixture.KeyVaultManager.Verify(m => m.GetCertificateAsync( - "testCert", - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Once); + this.mockFixture.Directory.Verify(d => d.CreateDirectory(installDir), Times.Once); + this.mockFixture.File.Verify(f => f.WriteAllBytesAsync( + It.Is(path => @path.StartsWith(@installDir)), + It.IsAny(), + It.IsAny()), Times.Once); } [Test] - public void ExecuteAsync_WrapsExceptionsInDependencyException() + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public void ExecuteAsync_WrapsExceptionsInDependencyException(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Setup(platform); this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, @@ -256,123 +257,56 @@ public void ExecuteAsync_WrapsExceptionsInDependencyException() } [Test] - public async Task InstallCertificateOnWindowsAsync_InstallsCertificateToCurrentUserStore() + [TestCase(PlatformID.Win32NT, @"C:\Users\Any\Certs")] + [TestCase(PlatformID.Unix, "/tmp/certs")] + public async Task InstallCertificateLocallyAsync_InstallsCertificateToDirectory(PlatformID platform, string certificateDirectory) { - this.mockFixture.Setup(PlatformID.Win32NT); - this.mockFixture.Parameters = new Dictionary() - { - { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } - }; - - using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) - { - // This test verifies the method completes without throwing errors. - // Better approach is to create an abstraction. - await component.InstallCertificateRespectively(PlatformID.Win32NT, this.testCertificate, CancellationToken.None); - } - } - - [Test] - public async Task InstallCertificateOnUnixAsync_InstallsCertificateForRegularUser() - { - this.mockFixture.Setup(PlatformID.Unix); - string certificateDirectory = "/home/testuser/.dotnet/corefx/cryptography/x509stores/my"; + this.mockFixture.Setup(platform); string certificatePath = this.mockFixture.Combine(certificateDirectory, $"{this.testCertificate.Thumbprint}.pfx"); this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateInstallationDir), certificateDirectory } }; this.mockFixture.Directory.Setup(d => d.Exists(certificateDirectory)).Returns(false); this.mockFixture.Directory.Setup(d => d.CreateDirectory(certificateDirectory)); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(certificatePath, It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); - - this.mockFixture.ProcessManager.OnCreateProcess = (exe, arguments, workingDir) => - { - if (exe == "chmod") - { - Assert.AreEqual($"-R 777 {certificateDirectory}", arguments); - } - - return new InMemoryProcess() - { - ExitCode = 0, - OnStart = () => true, - OnHasExited = () => true - }; - }; + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) { - component.SetEnvironmentVariable(EnvironmentVariable.USER, "testuser"); - await component.InstallCertificateRespectively(PlatformID.Unix, this.testCertificate, CancellationToken.None); + await component.CallInstallCertificateLocallyAsync(this.testCertificate, CancellationToken.None); } this.mockFixture.Directory.Verify(d => d.CreateDirectory(certificateDirectory), Times.Once); - this.mockFixture.File.Verify(f => f.WriteAllBytesAsync(certificatePath, It.IsAny(), It.IsAny()), Times.Once); + this.mockFixture.File.Verify(f => f.WriteAllBytesAsync(It.Is(p => p.StartsWith(certificateDirectory)), It.IsAny(), It.IsAny()), Times.Once); } [Test] - public async Task InstallCertificateOnUnixAsync_InstallsCertificateForSudoUser() + [TestCase("/etc/certs")] + public async Task InstallCertificateLocallyAsync_SetsPermissionsOnUnix(string certificateDirectory) { - this.mockFixture.Setup(PlatformID.Unix); - - string certificateDirectory = "/home/sudouser/.dotnet/corefx/cryptography/x509stores/my"; - string certificatePath = this.mockFixture.Combine(certificateDirectory, $"{this.testCertificate.Thumbprint}.pfx"); - + this.mockFixture.Setup(PlatformID.Unix); this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateInstallationDir), certificateDirectory } }; this.mockFixture.Directory.Setup(d => d.Exists(certificateDirectory)).Returns(false); this.mockFixture.Directory.Setup(d => d.CreateDirectory(certificateDirectory)); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(certificatePath, It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); this.mockFixture.ProcessManager.OnCreateProcess = (exe, arguments, workingDir) => { - return new InMemoryProcess() + if (exe == "chmod") { - ExitCode = 0, - OnStart = () => true, - OnHasExited = () => true - }; - }; - - using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) - { - component.SetEnvironmentVariable(EnvironmentVariable.USER, "root"); - component.SetEnvironmentVariable(EnvironmentVariable.SUDO_USER, "sudouser"); - await component.InstallCertificateRespectively(PlatformID.Unix, this.testCertificate, CancellationToken.None); - } - - this.mockFixture.Directory.Verify(d => d.CreateDirectory(certificateDirectory), Times.Once); - this.mockFixture.File.Verify(f => f.WriteAllBytesAsync(certificatePath, It.IsAny(), It.IsAny()), Times.Once); - } - - [Test] - public async Task InstallCertificateOnUnixAsync_InstallsCertificateForRootUser() - { - this.mockFixture.Setup(PlatformID.Unix); - - string certificateDirectory = "/root/.dotnet/corefx/cryptography/x509stores/my"; - string certificatePath = this.mockFixture.Combine(certificateDirectory, $"{this.testCertificate.Thumbprint}.pfx"); - - this.mockFixture.Parameters = new Dictionary() - { - { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } - }; - - this.mockFixture.Directory.Setup(d => d.Exists(certificateDirectory)).Returns(true); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(certificatePath, It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + Assert.AreEqual($"-R 777 {certificateDirectory}", arguments); + } - this.mockFixture.ProcessManager.OnCreateProcess = (exe, arguments, workingDir) => - { return new InMemoryProcess() { ExitCode = 0, @@ -383,25 +317,26 @@ public async Task InstallCertificateOnUnixAsync_InstallsCertificateForRootUser() using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) { - component.SetEnvironmentVariable(EnvironmentVariable.USER, "root"); - await component.InstallCertificateRespectively(PlatformID.Unix, this.testCertificate, CancellationToken.None); + await component.CallInstallCertificateLocallyAsync(this.testCertificate, CancellationToken.None); } - this.mockFixture.Directory.Verify(d => d.CreateDirectory(certificateDirectory), Times.Never); - this.mockFixture.File.Verify(f => f.WriteAllBytesAsync(certificatePath, It.IsAny(), It.IsAny()), Times.Once); + this.mockFixture.Directory.Verify(d => d.CreateDirectory(certificateDirectory), Times.Once); + this.mockFixture.File.Verify(f => f.WriteAllBytesAsync(It.Is(p => p.StartsWith(certificateDirectory)), It.IsAny(), It.IsAny()), Times.Once); } [Test] - public void InstallCertificateOnUnixAsync_ThrowsUnauthorizedAccessExceptionWithAppropriateMessage() + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public void InstallCertificateLocallyAsync_ThrowsUnauthorizedAccessExceptionWhenPermissionsDenied(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Unix); - - string certificateDirectory = "/home/testuser/.dotnet/corefx/cryptography/x509stores/my"; + this.mockFixture.Setup(platform); + string certificateDirectory = @"C:\System\Certs"; this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateInstallationDir), certificateDirectory } }; this.mockFixture.Directory.Setup(d => d.Exists(certificateDirectory)).Returns(false); @@ -409,20 +344,19 @@ public void InstallCertificateOnUnixAsync_ThrowsUnauthorizedAccessExceptionWithA using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) { - component.SetEnvironmentVariable(EnvironmentVariable.USER, "testuser"); - UnauthorizedAccessException exception = Assert.ThrowsAsync( - () => component.InstallCertificateRespectively(PlatformID.Unix, this.testCertificate, CancellationToken.None)); + () => component.CallInstallCertificateLocallyAsync(this.testCertificate, CancellationToken.None)); StringAssert.Contains("Access permissions denied", exception.Message); - StringAssert.Contains("sudo/root privileges", exception.Message); } } [Test] - public void GetKeyVaultManager_ReturnsInjectedKeyVaultManager() + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public void GetKeyVaultManager_ReturnsInjectedKeyVaultManager(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Setup(platform); this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" } @@ -438,15 +372,23 @@ public void GetKeyVaultManager_ReturnsInjectedKeyVaultManager() } [Test] - public async Task GetKeyVaultManager_CreatesKeyVaultManagerWithAccessToken() + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public async Task GetKeyVaultManager_CreatesKeyVaultManagerWithAccessToken(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Setup(platform); // Remove the injected KeyVaultManager this.mockFixture.Dependencies.RemoveAll(); - - this.mockFixture.KeyVaultManager = new Mock(MockBehavior.Loose); - this.mockFixture.Dependencies.AddSingleton((p) => this.mockFixture.KeyVaultManager.Object); + + // To pass the ThrowIfNull, we must have an IKeyVaultManager. + // In the "create new" scenario, we likely rely on checking StoreDescription on the existing one, + // or the component logic is such that Injecting it is mandatory. + // If the code is: IKeyVaultManager keyVaultManager = this.Dependencies.GetService(); keyVaultManager.ThrowIfNull(...) + // Then we MUST have it in dependencies. + // We inject a mock with null StoreDescription to simulate needing to create a new one. + var mockKeyVault = new Mock(MockBehavior.Loose); + this.mockFixture.Dependencies.AddSingleton(mockKeyVault.Object); string accessToken = "test-token-abc123"; string keyVaultUri = "https://testvault.vault.azure.net/"; @@ -464,21 +406,23 @@ public async Task GetKeyVaultManager_CreatesKeyVaultManagerWithAccessToken() IKeyVaultManager manager = component.GetKeyVaultManager(); Assert.IsNotNull(manager); + Assert.AreNotSame(mockKeyVault.Object, manager); // Should be a new instance Assert.IsNotNull(manager.StoreDescription); Assert.AreEqual(keyVaultUri, ((DependencyKeyVaultStore)manager.StoreDescription).EndpointUri.ToString()); } } [Test] - public async Task GetKeyVaultManagerWithTokenThrowsWhenKeyVaultUriNotProvided() + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public async Task GetKeyVaultManagerWithTokenThrowsWhenKeyVaultUriNotProvided(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Setup(platform); - // Remove the injected KeyVaultManager + // Remove the injected KeyVaultManager and replace with one that has null StoreDescription this.mockFixture.Dependencies.RemoveAll(); - - this.mockFixture.KeyVaultManager = new Mock(MockBehavior.Loose); - this.mockFixture.Dependencies.AddSingleton((p) => this.mockFixture.KeyVaultManager.Object); + var mockKeyVault = new Mock(MockBehavior.Loose); + this.mockFixture.Dependencies.AddSingleton(mockKeyVault.Object); this.mockFixture.Parameters = new Dictionary() { @@ -494,15 +438,16 @@ public async Task GetKeyVaultManagerWithTokenThrowsWhenKeyVaultUriNotProvided() } [Test] - public void GetKeyVaultManager_ThrowsWhenNoKeyVaultManagerOrAccessTokenProvided() + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public void GetKeyVaultManager_ThrowsWhenNoKeyVaultManagerOrAccessTokenProvided(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Setup(platform); - // Remove the injected KeyVaultManager and setup one without StoreDescription + // Remove the injected KeyVaultManager and replace with one that has null StoreDescription this.mockFixture.Dependencies.RemoveAll(); - var emptyMock = new Mock(); - emptyMock.Setup(m => m.StoreDescription).Returns((DependencyKeyVaultStore)null); - this.mockFixture.Dependencies.AddSingleton(emptyMock.Object); + var mockKeyVault = new Mock(MockBehavior.Loose); + this.mockFixture.Dependencies.AddSingleton(mockKeyVault.Object); this.mockFixture.Parameters = new Dictionary() { @@ -511,10 +456,29 @@ public void GetKeyVaultManager_ThrowsWhenNoKeyVaultManagerOrAccessTokenProvided( using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) { - InvalidOperationException exception = Assert.Throws( - () => component.GetKeyVaultManager()); + InvalidOperationException exception = Assert.Throws(() => component.GetKeyVaultManager()); + StringAssert.Contains("The Key Vault manager has not been properly initialized", exception.Message); + } + } + + [Test] + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public async Task InstallCertificateOnMachineAsync_UsesPlatformStore_Windows(PlatformID platform) + { + this.mockFixture.Setup(platform); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } + }; - StringAssert.Contains("Key Vault manager has not been properly initialized", exception.Message); + using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) + { + // This is a smoke test to ensure the base method calls Task.Run and doesn't crash. + // It does NOT verify the store content because we can't easily assert X509Store state in unit tests without side effects. + await component.CallInstallCertificateOnMachineAsync(this.testCertificate, CancellationToken.None); } } @@ -525,18 +489,16 @@ public TestCertificateInstallation(IServiceCollection dependencies, IDictionary< { } - public Func OnInstallCertificateOnWindows { get; set; } + public Func OnInstallCertificateOnMachine { get; set; } - public Func OnInstallCertificateOnUnix { get; set; } - - public new Task InitializeAsync(EventContext context, CancellationToken cancellationToken) + public new Task InitializeAsync(EventContext telemetryContext, CancellationToken cancellationToken) { - return base.InitializeAsync(context, cancellationToken); + return base.InitializeAsync(telemetryContext, cancellationToken); } - public new Task ExecuteAsync(EventContext context, CancellationToken cancellationToken) + public new Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken) { - return base.ExecuteAsync(context, cancellationToken); + return base.ExecuteAsync(telemetryContext, cancellationToken); } public new IKeyVaultManager GetKeyVaultManager() @@ -544,28 +506,24 @@ public TestCertificateInstallation(IServiceCollection dependencies, IDictionary< return base.GetKeyVaultManager(); } - public Task InstallCertificateRespectively(PlatformID platformID, X509Certificate2 certificate, CancellationToken cancellationToken) + public Task CallInstallCertificateLocallyAsync(X509Certificate2 certificate, CancellationToken cancellationToken) { - if (platformID == PlatformID.Unix) - { - return this.InstallCertificateOnUnixAsync(certificate, cancellationToken); - } - - return this.InstallCertificateOnWindowsAsync(certificate, cancellationToken); + return this.InstallCertificateLocallyAsync(certificate, cancellationToken); } - protected override Task InstallCertificateOnUnixAsync(X509Certificate2 certificate, CancellationToken cancellationToken) + public Task CallInstallCertificateOnMachineAsync(X509Certificate2 certificate, CancellationToken cancellationToken) { - return this.OnInstallCertificateOnUnix != null - ? this.OnInstallCertificateOnUnix(certificate, cancellationToken) - : base.InstallCertificateOnUnixAsync(certificate, cancellationToken); + return base.InstallCertificateOnMachineAsync(certificate, cancellationToken); } - protected override Task InstallCertificateOnWindowsAsync(X509Certificate2 certificate, CancellationToken cancellationToken) + protected override Task InstallCertificateOnMachineAsync(X509Certificate2 certificate, CancellationToken cancellationToken) { - return this.OnInstallCertificateOnWindows != null - ? this.OnInstallCertificateOnWindows(certificate, cancellationToken) - : base.InstallCertificateOnWindowsAsync(certificate, cancellationToken); + if (this.OnInstallCertificateOnMachine != null) + { + return this.OnInstallCertificateOnMachine.Invoke(certificate, cancellationToken); + } + + return base.InstallCertificateOnMachineAsync(certificate, cancellationToken); } } } diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs index 551b341873..7034389dc8 100644 --- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/KeyVaultAccessTokenTests.cs @@ -288,11 +288,6 @@ public Task InitializeAsyncInternal(EventContext context, CancellationToken toke public TokenRequestContext GetTokenRequestContextInternal() { - if (this.Parameters.ContainsKey(nameof(this.KeyVaultUri))) - { - this.KeyVaultUri = this.Parameters[nameof(this.KeyVaultUri)].ToString(); - } - return this.GetTokenRequestContext(); } diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index 8af44c84ee..c9086e28bb 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -17,6 +17,7 @@ /// Virtual Client component that installs certificates from Azure Key Vault /// into the appropriate certificate store for the operating system. /// + [SupportedPlatforms("linux-arm64,linux-x64,win-arm64,win-x64", true)] public class CertificateInstallation : VirtualClientComponent { private ISystemManagement systemManagement; @@ -62,7 +63,7 @@ public string CertificateName /// /// Gets the path to the file where the access token is saved. /// - public string AccessTokenPath + public string AccessTokenPath { get { @@ -81,6 +82,17 @@ public bool WithPrivateKey } } + /// + /// Gets the directory where the certificate will be exported. If not provided, the certificate will not be exported to a file. + /// + public string CertificateInstallationDir + { + get + { + return this.Parameters.GetValue(nameof(this.CertificateInstallationDir), string.Empty); + } + } + /// /// Gets the access token used to authenticate with Azure services. /// @@ -114,17 +126,11 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel IKeyVaultManager keyVault = this.GetKeyVaultManager(); X509Certificate2 certificate = await keyVault.GetCertificateAsync(this.CertificateName, cancellationToken, null, this.WithPrivateKey); - if (this.Platform == PlatformID.Win32NT) - { - await this.InstallCertificateOnWindowsAsync(certificate, cancellationToken); - } - else if (this.Platform == PlatformID.Unix) - { - await this.InstallCertificateOnUnixAsync(certificate, cancellationToken); - } - else + await this.InstallCertificateOnMachineAsync(certificate, cancellationToken); + + if (!string.IsNullOrWhiteSpace(this.CertificateInstallationDir)) { - throw new PlatformNotSupportedException($"The '{nameof(CertificateInstallation)}' component is not supported on platform '{this.Platform}'."); + await this.InstallCertificateLocallyAsync(certificate, cancellationToken); } } catch (Exception exc) @@ -136,9 +142,9 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel } /// - /// Installs the certificate in the appropriate certificate store on a Windows system. + /// Installs the certificate in the appropriate certificate store. /// - protected virtual Task InstallCertificateOnWindowsAsync(X509Certificate2 certificate, CancellationToken cancellationToken) + protected virtual Task InstallCertificateOnMachineAsync(X509Certificate2 certificate, CancellationToken cancellationToken) { return Task.Run(() => { @@ -152,72 +158,6 @@ protected virtual Task InstallCertificateOnWindowsAsync(X509Certificate2 certifi }); } - /// - /// Installs the certificate in the appropriate certificate store on a Unix/Linux system. - /// - protected virtual async Task InstallCertificateOnUnixAsync(X509Certificate2 certificate, CancellationToken cancellationToken) - { - // On Unix/Linux systems, we install the certificate in the default location for the - // user as well as in a static location. In the future we will likely use the static location - // only. - string certificateDirectory = null; - - try - { - // When "sudo" is used to run the installer, we need to know the logged - // in user account. On Linux systems, there is an environment variable 'SUDO_USER' - // that defines the logged in user. - - string user = this.GetEnvironmentVariable(EnvironmentVariable.USER); - string sudoUser = this.GetEnvironmentVariable(EnvironmentVariable.SUDO_USER); - certificateDirectory = $"/home/{user}/.dotnet/corefx/cryptography/x509stores/my"; - - if (!string.IsNullOrWhiteSpace(sudoUser)) - { - // The installer is being executed with "sudo" privileges. We want to use the - // logged in user profile vs. "root". - certificateDirectory = $"/home/{sudoUser}/.dotnet/corefx/cryptography/x509stores/my"; - } - else if (user == "root") - { - // The installer is being executed from the "root" account on Linux. - certificateDirectory = $"/root/.dotnet/corefx/cryptography/x509stores/my"; - } - - Console.WriteLine($"Certificate Store = {certificateDirectory}"); - - if (!this.fileSystem.Directory.Exists(certificateDirectory)) - { - this.fileSystem.Directory.CreateDirectory(certificateDirectory); - } - - using (X509Store store = new X509Store(StoreName.My, StoreLocation.CurrentUser, OpenFlags.ReadWrite)) - { - store.Open(OpenFlags.ReadWrite); - store.Add(certificate); - store.Close(); - } - - await this.fileSystem.File.WriteAllBytesAsync( - this.Combine(certificateDirectory, $"{certificate.Thumbprint}.pfx"), - certificate.Export(X509ContentType.Pfx)); - - // Permissions 777 (-rwxrwxrwx) - // https://linuxhandbook.com/linux-file-permissions/ - using (IProcessProxy process = this.processManager.CreateProcess("chmod", $"-R 777 {certificateDirectory}")) - { - await process.StartAndWaitAsync(cancellationToken); - process.ThrowIfErrored(); - } - } - catch (UnauthorizedAccessException) - { - throw new UnauthorizedAccessException( - $"Access permissions denied for certificate directory '{certificateDirectory}'. Execute the installer with " + - $"sudo/root privileges to install SDK certificates in privileged locations."); - } - } - /// /// Gets the Key Vault manager to use to retrieve certificates from Key Vault. /// @@ -245,5 +185,50 @@ protected IKeyVaultManager GetKeyVaultManager() $"Either valid --KeyVault or --Token or --TokenPath must be passed in order to set up authentication with Key Vault."); } } + + /// + /// Installs the certificate in static location + /// + protected async Task InstallCertificateLocallyAsync(X509Certificate2 certificate, CancellationToken cancellationToken) + { + try + { + string certificateFileName = this.WithPrivateKey + ? $"{this.CertificateName}.pfx" + : $"{this.CertificateName}.cer"; + + X509ContentType contentType = this.WithPrivateKey + ? X509ContentType.Pfx + : X509ContentType.Cert; + + byte[] certBytes = certificate.Export(contentType, string.Empty); + + string certificatePath = this.Combine(this.CertificateInstallationDir, certificateFileName); + + if (!this.fileSystem.Directory.Exists(this.CertificateInstallationDir)) + { + this.fileSystem.Directory.CreateDirectory(this.CertificateInstallationDir); + } + + await this.fileSystem.File.WriteAllBytesAsync(certificatePath, certBytes); + + if (this.Platform == PlatformID.Unix) + { + // Permissions 777 (-rwxrwxrwx) + // https://linuxhandbook.com/linux-file-permissions/ + using (IProcessProxy process = this.processManager.CreateProcess("chmod", $"-R 777 {this.CertificateInstallationDir}")) + { + await process.StartAndWaitAsync(cancellationToken); + process.ThrowIfErrored(); + } + } + } + catch (UnauthorizedAccessException) + { + throw new UnauthorizedAccessException( + $"Access permissions denied for certificate directory '{this.CertificateInstallationDir}'. Execute the installer with " + + $"admin/sudo/root privileges to install certificates in privileged locations."); + } + } } } \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs index af2ec86799..6f1393390d 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs @@ -41,12 +41,24 @@ public KeyVaultAccessToken(IServiceCollection dependencies, IDictionary - protected string KeyVaultUri { get; set; } + protected string KeyVaultUri + { + get + { + return this.Parameters.GetValue(nameof(this.KeyVaultUri)); + } + } /// /// Gets the Azure tenant ID used to acquire an access token. /// - protected string TenantId { get; set; } + protected string TenantId + { + get + { + return this.Parameters.GetValue(nameof(this.TenantId)); + } + } /// /// Gets or sets the full file path where the acquired access token will be written when file logging is enabled. @@ -84,16 +96,8 @@ protected override async Task InitializeAsync(EventContext telemetryContext, Can /// protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken) { - this.KeyVaultUri = this.Parameters.GetValue(nameof(this.KeyVaultUri)); this.KeyVaultUri.ThrowIfNullOrWhiteSpace(nameof(this.KeyVaultUri)); - - string tenantId = this.Parameters.GetValue(nameof(this.TenantId)); - if (string.IsNullOrWhiteSpace(tenantId)) - { - EndpointUtility.TryParseMicrosoftEntraTenantIdReference(new Uri(this.KeyVaultUri), out tenantId); - } - - tenantId.ThrowIfNullOrWhiteSpace(nameof(tenantId)); + this.TenantId.ThrowIfNullOrWhiteSpace(nameof(this.TenantId)); string accessToken = null; if (!cancellationToken.IsCancellationRequested) @@ -108,7 +112,7 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel InteractiveBrowserCredential credential = new InteractiveBrowserCredential( new InteractiveBrowserCredentialOptions { - TenantId = tenantId + TenantId = this.TenantId }); accessToken = await this.AcquireInteractiveTokenAsync(credential, requestContext, cancellationToken); @@ -119,7 +123,7 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel // the user with a code and URL to complete authentication from another device. DeviceCodeCredential credential = new DeviceCodeCredential(new DeviceCodeCredentialOptions { - TenantId = tenantId, + TenantId = this.TenantId, DeviceCodeCallback = (codeInfo, token) => { Console.WriteLine(string.Empty); diff --git a/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs b/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs index 79ced8fbed..27e77c02f5 100644 --- a/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs @@ -53,6 +53,11 @@ internal class BootstrapCommand : ExecuteProfileCommand /// public string TenantId { get; set; } + /// + /// Directory path where certificates can be stored. This is optional, but if not provided, certificates will not be saved to disk. + /// + public string CertificateInstallationDir { get; set; } + /// /// Executes the bootstrap command. /// Supports: @@ -139,6 +144,11 @@ protected void SetupCertificateInstallation() "The Key Vault URI must be provided (--key-vault) when installing certificates (--cert-name)."); } + if (!string.IsNullOrWhiteSpace(this.CertificateInstallationDir)) + { + this.Parameters["CertificateInstallationDir"] = this.CertificateInstallationDir; + } + // Set certificate-related parameters this.Parameters["KeyVaultUri"] = this.KeyVault; this.Parameters["CertificateName"] = this.CertificateName; diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 647f081234..b231a83150 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -605,7 +605,7 @@ public static Option CreateKeyVaultOption(bool required = false, object defaultV /// Sets the default value when none is provided. public static Option CreateTokenOption(bool required = false, object defaultValue = null) { - Option option = new Option(new string[] { "--token", "--access-token" }) + Option option = new Option(new string[] { "--token" }) { Name = "AccessToken", Description = "Authentication token for Azure Key Vault access. When not provided, uses default Azure credential authentication (Azure CLI, Managed Identity, etc.).", @@ -624,7 +624,7 @@ public static Option CreateTokenOption(bool required = false, object defaultValu /// Sets the default value when none is provided. public static Option CreateCertificateNameOption(bool required = false, object defaultValue = null) { - Option option = new Option(new string[] { "--certname", "--certificate-name", "--cert-name" }) + Option option = new Option(new string[] {"--certificate-name", "--cert-name" }) { Name = "CertificateName", Description = "The name of the certificate in Azure Key Vault to install to the local certificate store.", @@ -643,7 +643,7 @@ public static Option CreateCertificateNameOption(bool required = false, object d /// Sets the default value when none is provided. public static Option CreateTenantIdOption(bool required = false, object defaultValue = null) { - Option option = new Option(new string[] { "--tenant-id", "--tid" }) + Option option = new Option(new string[] { "--tenant-id" }) { Name = "TenantId", Description = "The tenant ID associated with your Microsoft Entra ID.", @@ -655,6 +655,25 @@ public static Option CreateTenantIdOption(bool required = false, object defaultV return option; } + /// + /// Command line option defines the directory to which certificates downloaded from Key Vault can be saved. + /// + /// Sets this option as required. + /// Sets the default value when none is provided. + public static Option CreateCertificateInstallationDirectoryOption(bool required = false, object defaultValue = null) + { + Option option = new Option(new string[] { "--cert-installation-dir" }) + { + Name = "CertificateInstallationDir", + Description = "Defines the directory where certificates downloaded from Key Vault will be saved.", + ArgumentHelpName = "path", + AllowMultipleArgumentsPerToken = false + }; + + OptionFactory.SetOptionRequirements(option, required, defaultValue); + return option; + } + /// /// Command line option defines the path to the environment layout file. /// diff --git a/src/VirtualClient/VirtualClient.Main/Program.cs b/src/VirtualClient/VirtualClient.Main/Program.cs index c6d249056e..6130d474a3 100644 --- a/src/VirtualClient/VirtualClient.Main/Program.cs +++ b/src/VirtualClient/VirtualClient.Main/Program.cs @@ -311,9 +311,9 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT // --verbose OptionFactory.CreateVerboseFlag(required: false, false), - - // --token - OptionFactory.CreateTokenOption(required: false) + + // --tenant-id + OptionFactory.CreateTenantIdOption(required: false), }; // Single command execution is also supported. Behind the scenes this uses a @@ -474,6 +474,9 @@ private static Command CreateBootstrapSubcommand(DefaultSettings settings) // --token OptionFactory.CreateTokenOption(required: false), + + // --cert-installation-dir + OptionFactory.CreateCertificateInstallationDirectoryOption(required: false), // --tenant-id OptionFactory.CreateTenantIdOption(required: false), diff --git a/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json b/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json index 53043c0b10..c70cd18ddf 100644 --- a/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json +++ b/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json @@ -1,22 +1,25 @@ { - "Description": "Installs dependencies from a package store.", - "Parameters": { - "KeyVaultUri": null, - "CertificateName": null, - "AccessToken": null, - "LogFileName": null - }, - "Actions": [ - { - "Type": "CertificateInstallation", - "Parameters": { - "Scenario": "InstallCertificate", - "KeyVaultUri": "$.Parameters.KeyVaultUri", - "CertificateName": "$.Parameters.CertificateName", - "AccessToken": "$.Parameters.AccessToken", - "AccessTokenPath": "$.Parameters.LogFileName" - } - } - ] -} - \ No newline at end of file + "Description": "Installs dependencies from a package store.", + "Parameters": { + "KeyVaultUri": null, + "CertificateName": null, + "AccessToken": null, + "LogFileName": null, + "CertificateInstallationDir": null, + "WithPrivateKey": true + }, + "Actions": [ + { + "Type": "CertificateInstallation", + "Parameters": { + "Scenario": "InstallCertificate", + "KeyVaultUri": "$.Parameters.KeyVaultUri", + "CertificateName": "$.Parameters.CertificateName", + "AccessToken": "$.Parameters.AccessToken", + "AccessTokenPath": "$.Parameters.LogFileName", + "CertificateInstallationDir": "$.Parameters.CertificateInstallationDir", + "WithPrivateKey": "$.Parameters.WithPrivateKey" + } + } + ] +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs b/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs index 60f5190599..415c318d32 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs +++ b/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs @@ -127,6 +127,27 @@ public void SetupCertificateInstallation_SetsUpOnlyExpectedParameters() Assert.IsFalse(command.Parameters.ContainsKey("TenantId")); } + [Test] + public void SetupCertificateInstallation_AllowsCertificateDownloadDirectory() + { + var command = new TestBootstrapCommand + { + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase), + CertificateName = "mycert", + KeyVault = "https://myvault.vault.azure.net/", + AccessToken = "token123", + TenantId = "00000000-0000-0000-0000-000000000001", + CertificateInstallationDir = "C:\\certs" + }; + + command.SetupCertificateInstallationPublic(); + + Assert.AreEqual(command.Parameters["KeyVaultUri"], command.KeyVault); + Assert.AreEqual(command.Parameters["CertificateName"], command.CertificateName); + Assert.AreEqual(command.Parameters["CertificateInstallationDir"], command.CertificateInstallationDir); + Assert.AreEqual(command.Parameters.Count, 3); + } + [Test] [TestCase(null)] [TestCase("")] diff --git a/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandValidatorTests.cs b/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandValidatorTests.cs index f83b8ba8b3..5707a6bf8f 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandValidatorTests.cs +++ b/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandValidatorTests.cs @@ -27,7 +27,6 @@ public void BootstrapCommand_RequiresAtLeastOneOperation() [Test] [TestCase("--cert-name")] - [TestCase("--certname")] [TestCase("--certificate-name")] public void BootstrapCommand_CertificateInstall_RequiresKeyVault(string certAlias) { diff --git a/website/docs/guides/0010-command-line.md b/website/docs/guides/0010-command-line.md index 68b892c218..362637bb23 100644 --- a/website/docs/guides/0010-command-line.md +++ b/website/docs/guides/0010-command-line.md @@ -151,10 +151,11 @@ The following tables describe the various subcommands that are supported by the |-----------------------------------------------------------------|----------|------------------------------|-------------| | --pkg, --package=\ | No* | string/blob name | Name/ID of a package to bootstrap/install (e.g. `anypackage.1.0.0.zip`). Required when doing **package bootstrapping**. | | --ps, --packages, --package-store=\ | No | string/connection string/SAS | Connection description for an Azure Storage Account/container to download packages from. See [Azure Storage Account Integration](./0600-integration-blob-storage.md). | - | --certificateName, --cert-name=\ | No* | string/certificate name | Name of the certificate in Key Vault to bootstrap/install (e.g. `--cert-name="crc-sdk-cert"`). Required when doing **certificate bootstrapping**. | + | --certificate-name --cert-name | No* | string/certificate name | Name of the certificate in Key Vault to bootstrap/install (e.g. `--cert-name="sdk-cert"`). Required when doing **certificate bootstrapping**. | | --key-vault, --kv=\ | No* | uri | Azure Key Vault URI to source the certificate from (e.g. `https://myvault.vault.azure.net/`). Required when doing **certificate bootstrapping**. | - | --token, --access-token=\ | No | string | Optional access token used to authenticate to Key Vault when installing certificates. If not provided, Virtual Client uses the default Azure credential flow (e.g. Azure CLI, Managed Identity, etc.). | + | --token | No | string | Optional access token used to authenticate to Key Vault when installing certificates. If not provided, Virtual Client uses the default Azure credential flow (e.g. Azure CLI, Managed Identity, etc.). | | --tenant-Id, --tid=\ | No | string | Azure Active Directory tenant ID used for authentication. | + | --cert-installation-dir | No | string | Directory path where certificates can be stored. If not provided, certificates will not be saved to disk. | | --c, --client-id=\ | No | string/text | Identifier to uniquely identify the instance (telemetry correlation). | | --clean=\ | No | string | Perform an initial cleanup (logs/packages/state/temp/all). | | --cs, --content, --content-store=\ | No | string/connection string/SAS | Storage connection for uploading files/content (e.g. logs). |