From b94ec7c5c44578d5b0bb539bf408579f43ccb634 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Fri, 27 Feb 2026 13:26:22 -0800 Subject: [PATCH 01/18] main changes --- .../EndpointUtilityTests.cs | 32 ------------------- .../VirtualClient.Core/EndpointUtility.cs | 20 ------------ .../KeyVaultAccessTokenTests.cs | 5 --- .../CertificateInstallation.cs | 31 ++++++++++++++++++ .../KeyVaultAccessToken.cs | 30 +++++++++-------- .../VirtualClient.Main/BootstrapCommand.cs | 9 ++++++ .../VirtualClient.Main/OptionFactory.cs | 19 +++++++++++ .../VirtualClient.Main/Program.cs | 11 ++++++- .../profiles/BOOTSTRAP-CERTIFICATE.json | 9 +++--- 9 files changed, 91 insertions(+), 75 deletions(-) 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/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.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..a0871f0e5f 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -81,6 +81,17 @@ public bool WithPrivateKey } } + /// + /// + /// + public string CertificateDownloadDir + { + get + { + return this.Parameters.GetValue(nameof(this.CertificateDownloadDir), string.Empty); + } + } + /// /// Gets the access token used to authenticate with Azure services. /// @@ -126,6 +137,26 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel { throw new PlatformNotSupportedException($"The '{nameof(CertificateInstallation)}' component is not supported on platform '{this.Platform}'."); } + + // If a download directory is specified, we will also export the certificate to that location. + if (!string.IsNullOrEmpty(this.CertificateDownloadDir)) + { + string certificateFileName = this.WithPrivateKey + ? $"{this.CertificateName}.pfx" + : $"{this.CertificateName}.cer"; + + string certificatePath = this.Combine(this.CertificateDownloadDir, certificateFileName); + + // Delete existing certificate file + if (!this.fileSystem.File.Exists(certificatePath)) + { + this.fileSystem.File.Delete(certificatePath); + } + + // Export the new certificate + await this.fileSystem.File.WriteAllBytesAsync(certificatePath, certificate.Export(X509ContentType.Pfx)); + Console.WriteLine($"Certificate exported to {certificatePath}"); + } } catch (Exception exc) { diff --git a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs index af2ec86799..f3aaebf9e5 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; } + public 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..a2c6a80270 100644 --- a/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs @@ -53,6 +53,14 @@ internal class BootstrapCommand : ExecuteProfileCommand /// public string TenantId { get; set; } + /// + /// Directory path where downloaded certificates can be stored. + /// + /// Set this property to a valid directory path to ensure that certificates can be + /// downloaded and saved. + /// + public string CertificateDownloadDir { get; set; } + /// /// Executes the bootstrap command. /// Supports: @@ -142,6 +150,7 @@ protected void SetupCertificateInstallation() // Set certificate-related parameters this.Parameters["KeyVaultUri"] = this.KeyVault; this.Parameters["CertificateName"] = this.CertificateName; + this.Parameters["CertificateDownloadDir"] = this.CertificateDownloadDir; } protected void SetupPackageInstallation() diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 647f081234..458e768538 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -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 CreateCertificateDownloadDirectoryOption(bool required = false, object defaultValue = null) + { + Option option = new Option(new string[] { "--certificateDownloadDir", "--certDownloadDir" }) + { + Name = "CertificateDownloadDir", + Description = "Set (optional) directory path which certificates downloaded from Key Vault can be saved.", + ArgumentHelpName = "tid", + 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..06e002fe3e 100644 --- a/src/VirtualClient/VirtualClient.Main/Program.cs +++ b/src/VirtualClient/VirtualClient.Main/Program.cs @@ -313,7 +313,13 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT OptionFactory.CreateVerboseFlag(required: false, false), // --token - OptionFactory.CreateTokenOption(required: false) + OptionFactory.CreateTokenOption(required: false), + + // --CertificateDownloadDir + OptionFactory.CreateCertificateDownloadDirectoryOption(required: false), + + // --tenant-id + OptionFactory.CreateTenantIdOption(required: false), }; // Single command execution is also supported. Behind the scenes this uses a @@ -474,6 +480,9 @@ private static Command CreateBootstrapSubcommand(DefaultSettings settings) // --token OptionFactory.CreateTokenOption(required: false), + + // --CertificateDownloadDir + OptionFactory.CreateCertificateDownloadDirectoryOption(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 5996ecefd6..ca37393fa8 100644 --- a/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json +++ b/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json @@ -4,7 +4,8 @@ "KeyVaultUri": null, "CertificateName": null, "AccessToken": null, - "LogFileName": null + "LogFileName": null, + "CertificateDownloadDir": null }, "Actions": [ { @@ -14,9 +15,9 @@ "KeyVaultUri": "$.Parameters.KeyVaultUri", "CertificateName": "$.Parameters.CertificateName", "AccessToken": "$.Parameters.AccessToken", - "AccessTokenPath": "$.Parameters.LogFileName" + "AccessTokenPath": "$.Parameters.LogFileName", + "CertificateDownloadDir": "$.Parameters.CertificateDownloadDir" } } ] -} - \ No newline at end of file +} \ No newline at end of file From 6fb81c46450d17b8f1739b7e1364e864074d7408 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Fri, 27 Feb 2026 14:21:12 -0800 Subject: [PATCH 02/18] Minor fix --- .../CertificateInstallation.cs | 2 +- .../VirtualClient.Main/BootstrapCommand.cs | 6 +++++- .../BootstrapCommandTests.cs | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index a0871f0e5f..033edb749b 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -148,7 +148,7 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel string certificatePath = this.Combine(this.CertificateDownloadDir, certificateFileName); // Delete existing certificate file - if (!this.fileSystem.File.Exists(certificatePath)) + if (this.fileSystem.File.Exists(certificatePath)) { this.fileSystem.File.Delete(certificatePath); } diff --git a/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs b/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs index a2c6a80270..070dbe9768 100644 --- a/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs @@ -147,10 +147,14 @@ protected void SetupCertificateInstallation() "The Key Vault URI must be provided (--key-vault) when installing certificates (--cert-name)."); } + if (!string.IsNullOrWhiteSpace(this.CertificateDownloadDir)) + { + this.Parameters["CertificateDownloadDir"] = this.CertificateDownloadDir; + } + // Set certificate-related parameters this.Parameters["KeyVaultUri"] = this.KeyVault; this.Parameters["CertificateName"] = this.CertificateName; - this.Parameters["CertificateDownloadDir"] = this.CertificateDownloadDir; } protected void SetupPackageInstallation() diff --git a/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs b/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs index 60f5190599..9ab429d7d5 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", + CertificateDownloadDir = "C:\\certs" + }; + + command.SetupCertificateInstallationPublic(); + + Assert.AreEqual(command.Parameters["KeyVaultUri"], command.KeyVault); + Assert.AreEqual(command.Parameters["CertificateName"], command.CertificateName); + Assert.AreEqual(command.Parameters["CertificateDownloadDir"], command.CertificateDownloadDir); + Assert.AreEqual(command.Parameters.Count, 3); + } + [Test] [TestCase(null)] [TestCase("")] From 02a029aeec784ad852e1012692ed48008c5ccceb Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Sat, 28 Feb 2026 12:38:30 -0800 Subject: [PATCH 03/18] Allowing cert to be exported even after loading. --- .../Identity/CertificateLoaderHelper.cs | 39 +++++++++++++++ .../VirtualClient.Core/KeyVaultManager.cs | 48 ++++++++++--------- .../CertificateInstallation.cs | 32 ++++++++++--- 3 files changed, 91 insertions(+), 28 deletions(-) create mode 100644 src/VirtualClient/VirtualClient.Core/Identity/CertificateLoaderHelper.cs 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..8842bc74c0 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); + SecretClient secretClient = this.CreateSecretClient(vaultUri, credentials); + 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/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index 033edb749b..096bbcd8a4 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -62,7 +62,7 @@ public string CertificateName /// /// Gets the path to the file where the access token is saved. /// - public string AccessTokenPath + public string AccessTokenPath { get { @@ -138,12 +138,16 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel throw new PlatformNotSupportedException($"The '{nameof(CertificateInstallation)}' component is not supported on platform '{this.Platform}'."); } - // If a download directory is specified, we will also export the certificate to that location. + // Export the certificate if requested if (!string.IsNullOrEmpty(this.CertificateDownloadDir)) { - string certificateFileName = this.WithPrivateKey + string certificateFileName = this.WithPrivateKey ? $"{this.CertificateName}.pfx" : $"{this.CertificateName}.cer"; + + X509ContentType contentType = this.WithPrivateKey + ? X509ContentType.Pfx + : X509ContentType.Cert; string certificatePath = this.Combine(this.CertificateDownloadDir, certificateFileName); @@ -153,9 +157,8 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel this.fileSystem.File.Delete(certificatePath); } - // Export the new certificate - await this.fileSystem.File.WriteAllBytesAsync(certificatePath, certificate.Export(X509ContentType.Pfx)); - Console.WriteLine($"Certificate exported to {certificatePath}"); + byte[] certBytes = certificate.Export(contentType, string.Empty); + await this.fileSystem.File.WriteAllBytesAsync(certificatePath, certBytes); } } catch (Exception exc) @@ -276,5 +279,22 @@ protected IKeyVaultManager GetKeyVaultManager() $"Either valid --KeyVault or --Token or --TokenPath must be passed in order to set up authentication with Key Vault."); } } + + /// + /// Tries to get certificate data, returning null if an exception occurs. + /// + private byte[] TryGetCertData(Func getCertData) + { + try + { + return getCertData(); + } + catch (Exception exc) + { + Console.WriteLine(exc.ToString()); + Console.WriteLine("\n\n\n\n=================================================================================\n"); + return null; + } + } } } \ No newline at end of file From 39bf1f0f4dbe046a503e1684294c92cbc6e6d2a7 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Sat, 28 Feb 2026 12:51:24 -0800 Subject: [PATCH 04/18] Adding doc --- .../VirtualClient.Core/KeyVaultManager.cs | 4 ++-- .../CertificateInstallation.cs | 18 ------------------ website/docs/guides/0010-command-line.md | 1 + 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs index 8842bc74c0..25fdb9bbea 100644 --- a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs +++ b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs @@ -220,8 +220,8 @@ public async Task GetCertificateAsync( var credentials = ((DependencyKeyVaultStore)this.StoreDescription).Credentials; - CertificateClient certificateClient = this.CreateCertificateClient(vaultUri, credentials); - SecretClient secretClient = this.CreateSecretClient(vaultUri, credentials); + CertificateClient certificateClient = this.CreateCertificateClient(vaultUri, credentials); // For public cert. + SecretClient secretClient = this.CreateSecretClient(vaultUri, credentials); // For private cert (PFX) try { diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index 096bbcd8a4..5a23799c34 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -151,7 +151,6 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel string certificatePath = this.Combine(this.CertificateDownloadDir, certificateFileName); - // Delete existing certificate file if (this.fileSystem.File.Exists(certificatePath)) { this.fileSystem.File.Delete(certificatePath); @@ -279,22 +278,5 @@ protected IKeyVaultManager GetKeyVaultManager() $"Either valid --KeyVault or --Token or --TokenPath must be passed in order to set up authentication with Key Vault."); } } - - /// - /// Tries to get certificate data, returning null if an exception occurs. - /// - private byte[] TryGetCertData(Func getCertData) - { - try - { - return getCertData(); - } - catch (Exception exc) - { - Console.WriteLine(exc.ToString()); - Console.WriteLine("\n\n\n\n=================================================================================\n"); - return null; - } - } } } \ No newline at end of file diff --git a/website/docs/guides/0010-command-line.md b/website/docs/guides/0010-command-line.md index 68b892c218..3d69b22494 100644 --- a/website/docs/guides/0010-command-line.md +++ b/website/docs/guides/0010-command-line.md @@ -155,6 +155,7 @@ The following tables describe the various subcommands that are supported by the | --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.). | | --tenant-Id, --tid=\ | No | string | Azure Active Directory tenant ID used for authentication. | + | --certificateDownloadDir | No | string | Directory path where downloaded certificates can also be stored. | | --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). | From b5a8e35cdcfb8db150ad0444b5250b1e8fdf6610 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Sat, 28 Feb 2026 17:57:23 -0800 Subject: [PATCH 05/18] Adding ut --- .../KeyVaultManagerTests.cs | 23 ++ .../CertificateInstallationTests.cs | 265 ++++++++++++++++++ 2 files changed, 288 insertions(+) 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.Dependencies.UnitTests/CertificateInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs index 606bb112ec..4b1d1914c9 100644 --- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs @@ -518,6 +518,271 @@ public void GetKeyVaultManager_ThrowsWhenNoKeyVaultManagerOrAccessTokenProvided( } } + [Test] + [TestCase(true, "testCert.pfx")] + [TestCase(false, "testCert.cer")] + public async Task ExecuteAsync_SavesCertificateToDownloadDirectory(bool withPrivateKey, string expectedFileName) + { + this.mockFixture.Setup(PlatformID.Win32NT); + string downloadDir = "/tmp/certificates"; + string expectedPath = this.mockFixture.Combine(downloadDir, expectedFileName); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, + { nameof(CertificateInstallation.WithPrivateKey), withPrivateKey } + }; + + this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, 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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + // Verify the file was written with the correct path + this.mockFixture.File.Verify( + f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Test] + public async Task ExecuteAsync_SavesPrivateCertificateAsPfxFormat() + { + this.mockFixture.Setup(PlatformID.Win32NT); + this.SetupPrivateCertificate(); + + string downloadDir = "/tmp/certificates"; + string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.pfx"); + byte[] capturedBytes = null; + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, + { nameof(CertificateInstallation.WithPrivateKey), true } + }; + + this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny())) + .Callback((path, bytes, token) => capturedBytes = bytes) + .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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + // Verify the bytes captured are in PFX format + Assert.IsNotNull(capturedBytes); + byte[] expectedBytes = this.testCertificate.Export(X509ContentType.Pfx, string.Empty); + Assert.AreEqual(expectedBytes.Length, capturedBytes.Length); + } + + [Test] + public async Task ExecuteAsync_SavesPublicCertificateAsCerFormat() + { + this.mockFixture.Setup(PlatformID.Win32NT); + string downloadDir = "/tmp/certificates"; + string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.cer"); + byte[] capturedBytes = null; + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, + { nameof(CertificateInstallation.WithPrivateKey), false } + }; + + this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny())) + .Callback((path, bytes, token) => capturedBytes = bytes) + .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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + // Verify the bytes captured are in Cert format + Assert.IsNotNull(capturedBytes); + byte[] expectedBytes = this.testCertificate.Export(X509ContentType.Cert, string.Empty); + CollectionAssert.AreEqual(expectedBytes, capturedBytes); + } + + [Test] + public async Task ExecuteAsync_DeletesExistingCertificateFileBeforeWriting() + { + this.mockFixture.Setup(PlatformID.Win32NT); + string downloadDir = "/tmp/certificates"; + string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.pfx"); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, + { nameof(CertificateInstallation.WithPrivateKey), true } + }; + + // Setup file exists to return true + this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(true); + this.mockFixture.File.Setup(f => f.Delete(expectedPath)); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, 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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + // Verify the existing file was deleted before writing + this.mockFixture.File.Verify(f => f.Delete(expectedPath), Times.Once); + this.mockFixture.File.Verify( + f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Test] + public async Task ExecuteAsync_DoesNotSaveCertificateWhenDownloadDirNotProvided() + { + this.mockFixture.Setup(PlatformID.Win32NT); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.WithPrivateKey), true } + // CertificateDownloadDir is not provided + }; + + 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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + // Verify no file operations were performed + this.mockFixture.File.Verify(f => f.Exists(It.IsAny()), Times.Never); + this.mockFixture.File.Verify(f => f.Delete(It.IsAny()), Times.Never); + this.mockFixture.File.Verify( + f => f.WriteAllBytesAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task ExecuteAsync_SavesCertificateOnUnixPlatform() + { + this.mockFixture.Setup(PlatformID.Unix); + string downloadDir = "/tmp/certificates"; + string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.pfx"); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, + { nameof(CertificateInstallation.WithPrivateKey), true } + }; + + this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, 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) => Task.CompletedTask; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + // Verify certificate was saved to download directory even on Unix + this.mockFixture.File.Verify( + f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny()), + Times.Once); + } + + private void SetupPrivateCertificate() + { + var distinguishedName = new X500DistinguishedName("CN=TestCert"); + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(distinguishedName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + this.testCertificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1)); + } + private class TestCertificateInstallation : CertificateInstallation { public TestCertificateInstallation(IServiceCollection dependencies, IDictionary parameters) From 50aff1159543ec8154418643a5df0b1a532f881a Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Sat, 28 Feb 2026 18:06:35 -0800 Subject: [PATCH 06/18] Adding comments --- .../VirtualClient.Dependencies/CertificateInstallation.cs | 2 +- src/VirtualClient/VirtualClient.Main/OptionFactory.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index 5a23799c34..35502cada3 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -82,7 +82,7 @@ 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 CertificateDownloadDir { diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 458e768538..4c4d863c51 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -665,8 +665,8 @@ public static Option CreateCertificateDownloadDirectoryOption(bool required = fa Option option = new Option(new string[] { "--certificateDownloadDir", "--certDownloadDir" }) { Name = "CertificateDownloadDir", - Description = "Set (optional) directory path which certificates downloaded from Key Vault can be saved.", - ArgumentHelpName = "tid", + Description = "Defines the directory where certificates downloaded from Key Vault will be saved.", + ArgumentHelpName = "path", AllowMultipleArgumentsPerToken = false }; From 3db2a7d705647e6a38b6a5f0fdab4680f0847397 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Mon, 2 Mar 2026 14:34:21 -0800 Subject: [PATCH 07/18] Addressing PR comments --- .../CertificateInstallationTests.cs | 612 +++++------------- .../CertificateInstallation.cs | 154 ++--- .../VirtualClient.Main/BootstrapCommand.cs | 11 +- .../VirtualClient.Main/OptionFactory.cs | 12 +- .../VirtualClient.Main/Program.cs | 12 +- .../profiles/BOOTSTRAP-CERTIFICATE.json | 6 +- .../BootstrapCommandTests.cs | 4 +- .../BootstrapCommandValidatorTests.cs | 1 - website/docs/guides/0010-command-line.md | 6 +- 9 files changed, 230 insertions(+), 588 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs index 4b1d1914c9..02193b9d27 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,35 +126,39 @@ 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() + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public async Task ExecuteAsync_InstallsCertificateOnWindows(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Setup(platform); this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, { 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 +171,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 +181,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 +191,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 +259,59 @@ public void ExecuteAsync_WrapsExceptionsInDependencyException() } [Test] - public async Task InstallCertificateOnWindowsAsync_InstallsCertificateToCurrentUserStore() - { - 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() + [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.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(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public async Task InstallCertificateLocallyAsync_SetsPermissionsOnUnix(PlatformID platform) { this.mockFixture.Setup(PlatformID.Unix); - - string certificateDirectory = "/home/sudouser/.dotnet/corefx/cryptography/x509stores/my"; - string certificatePath = this.mockFixture.Combine(certificateDirectory, $"{this.testCertificate.Thumbprint}.pfx"); - + string certificateDirectory = "/etc/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); 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 +322,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 +349,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 +377,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 +411,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 +443,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,276 +461,30 @@ public void GetKeyVaultManager_ThrowsWhenNoKeyVaultManagerOrAccessTokenProvided( using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) { - InvalidOperationException exception = Assert.Throws( - () => component.GetKeyVaultManager()); - - StringAssert.Contains("Key Vault manager has not been properly initialized", exception.Message); + InvalidOperationException exception = Assert.Throws(() => component.GetKeyVaultManager()); + StringAssert.Contains("The Key Vault manager has not been properly initialized", exception.Message); } } [Test] - [TestCase(true, "testCert.pfx")] - [TestCase(false, "testCert.cer")] - public async Task ExecuteAsync_SavesCertificateToDownloadDirectory(bool withPrivateKey, string expectedFileName) + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public async Task InstallCertificateOnMachineAsync_UsesPlatformStore_Windows(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); - string downloadDir = "/tmp/certificates"; - string expectedPath = this.mockFixture.Combine(downloadDir, expectedFileName); - + this.mockFixture.Setup(platform); + this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, - { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, - { nameof(CertificateInstallation.WithPrivateKey), withPrivateKey } - }; - - this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, 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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; - - await component.ExecuteAsync(EventContext.None, CancellationToken.None); - } - - // Verify the file was written with the correct path - this.mockFixture.File.Verify( - f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny()), - Times.Once); - } - - [Test] - public async Task ExecuteAsync_SavesPrivateCertificateAsPfxFormat() - { - this.mockFixture.Setup(PlatformID.Win32NT); - this.SetupPrivateCertificate(); - - string downloadDir = "/tmp/certificates"; - string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.pfx"); - byte[] capturedBytes = null; - - this.mockFixture.Parameters = new Dictionary() - { - { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, - { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, - { nameof(CertificateInstallation.WithPrivateKey), true } - }; - - this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny())) - .Callback((path, bytes, token) => capturedBytes = bytes) - .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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; - - await component.ExecuteAsync(EventContext.None, CancellationToken.None); - } - - // Verify the bytes captured are in PFX format - Assert.IsNotNull(capturedBytes); - byte[] expectedBytes = this.testCertificate.Export(X509ContentType.Pfx, string.Empty); - Assert.AreEqual(expectedBytes.Length, capturedBytes.Length); - } - - [Test] - public async Task ExecuteAsync_SavesPublicCertificateAsCerFormat() - { - this.mockFixture.Setup(PlatformID.Win32NT); - string downloadDir = "/tmp/certificates"; - string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.cer"); - byte[] capturedBytes = null; - - this.mockFixture.Parameters = new Dictionary() - { - { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, - { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, - { nameof(CertificateInstallation.WithPrivateKey), false } - }; - - this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny())) - .Callback((path, bytes, token) => capturedBytes = bytes) - .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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; - - await component.ExecuteAsync(EventContext.None, CancellationToken.None); - } - - // Verify the bytes captured are in Cert format - Assert.IsNotNull(capturedBytes); - byte[] expectedBytes = this.testCertificate.Export(X509ContentType.Cert, string.Empty); - CollectionAssert.AreEqual(expectedBytes, capturedBytes); - } - - [Test] - public async Task ExecuteAsync_DeletesExistingCertificateFileBeforeWriting() - { - this.mockFixture.Setup(PlatformID.Win32NT); - string downloadDir = "/tmp/certificates"; - string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.pfx"); - - this.mockFixture.Parameters = new Dictionary() - { - { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, - { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, - { nameof(CertificateInstallation.WithPrivateKey), true } - }; - - // Setup file exists to return true - this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(true); - this.mockFixture.File.Setup(f => f.Delete(expectedPath)); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, 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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; - - await component.ExecuteAsync(EventContext.None, CancellationToken.None); - } - - // Verify the existing file was deleted before writing - this.mockFixture.File.Verify(f => f.Delete(expectedPath), Times.Once); - this.mockFixture.File.Verify( - f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny()), - Times.Once); - } - - [Test] - public async Task ExecuteAsync_DoesNotSaveCertificateWhenDownloadDirNotProvided() - { - this.mockFixture.Setup(PlatformID.Win32NT); - - this.mockFixture.Parameters = new Dictionary() - { - { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, - { nameof(CertificateInstallation.WithPrivateKey), true } - // CertificateDownloadDir is not provided - }; - - 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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; - - await component.ExecuteAsync(EventContext.None, CancellationToken.None); - } - - // Verify no file operations were performed - this.mockFixture.File.Verify(f => f.Exists(It.IsAny()), Times.Never); - this.mockFixture.File.Verify(f => f.Delete(It.IsAny()), Times.Never); - this.mockFixture.File.Verify( - f => f.WriteAllBytesAsync(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - - [Test] - public async Task ExecuteAsync_SavesCertificateOnUnixPlatform() - { - this.mockFixture.Setup(PlatformID.Unix); - string downloadDir = "/tmp/certificates"; - string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.pfx"); - - this.mockFixture.Parameters = new Dictionary() - { - { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, - { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, - { nameof(CertificateInstallation.WithPrivateKey), true } + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } }; - this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, 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) => Task.CompletedTask; - - await component.ExecuteAsync(EventContext.None, CancellationToken.None); + // 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); } - - // Verify certificate was saved to download directory even on Unix - this.mockFixture.File.Verify( - f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny()), - Times.Once); - } - - private void SetupPrivateCertificate() - { - var distinguishedName = new X500DistinguishedName("CN=TestCert"); - - using var rsa = RSA.Create(2048); - var request = new CertificateRequest(distinguishedName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - this.testCertificate = request.CreateSelfSigned( - DateTimeOffset.UtcNow.AddDays(-1), - DateTimeOffset.UtcNow.AddYears(1)); } private class TestCertificateInstallation : CertificateInstallation @@ -790,18 +494,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() @@ -809,28 +511,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/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index 35502cada3..ae0d89d211 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; @@ -84,11 +85,11 @@ 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 CertificateDownloadDir + public string CertificateInstallationDir { get { - return this.Parameters.GetValue(nameof(this.CertificateDownloadDir), string.Empty); + return this.Parameters.GetValue(nameof(this.CertificateInstallationDir)); } } @@ -125,39 +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 - { - throw new PlatformNotSupportedException($"The '{nameof(CertificateInstallation)}' component is not supported on platform '{this.Platform}'."); - } + await this.InstallCertificateOnMachineAsync(certificate, cancellationToken); - // Export the certificate if requested - if (!string.IsNullOrEmpty(this.CertificateDownloadDir)) + if (!string.IsNullOrWhiteSpace(this.CertificateInstallationDir)) { - string certificateFileName = this.WithPrivateKey - ? $"{this.CertificateName}.pfx" - : $"{this.CertificateName}.cer"; - - X509ContentType contentType = this.WithPrivateKey - ? X509ContentType.Pfx - : X509ContentType.Cert; - - string certificatePath = this.Combine(this.CertificateDownloadDir, certificateFileName); - - if (this.fileSystem.File.Exists(certificatePath)) - { - this.fileSystem.File.Delete(certificatePath); - } - - byte[] certBytes = certificate.Export(contentType, string.Empty); - await this.fileSystem.File.WriteAllBytesAsync(certificatePath, certBytes); + await this.InstallCertificateLocallyAsync(certificate, cancellationToken); } } catch (Exception exc) @@ -169,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(() => { @@ -185,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. /// @@ -278,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.Main/BootstrapCommand.cs b/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs index 070dbe9768..27e77c02f5 100644 --- a/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs @@ -54,12 +54,9 @@ internal class BootstrapCommand : ExecuteProfileCommand public string TenantId { get; set; } /// - /// Directory path where downloaded certificates can be stored. + /// Directory path where certificates can be stored. This is optional, but if not provided, certificates will not be saved to disk. /// - /// Set this property to a valid directory path to ensure that certificates can be - /// downloaded and saved. - /// - public string CertificateDownloadDir { get; set; } + public string CertificateInstallationDir { get; set; } /// /// Executes the bootstrap command. @@ -147,9 +144,9 @@ protected void SetupCertificateInstallation() "The Key Vault URI must be provided (--key-vault) when installing certificates (--cert-name)."); } - if (!string.IsNullOrWhiteSpace(this.CertificateDownloadDir)) + if (!string.IsNullOrWhiteSpace(this.CertificateInstallationDir)) { - this.Parameters["CertificateDownloadDir"] = this.CertificateDownloadDir; + this.Parameters["CertificateInstallationDir"] = this.CertificateInstallationDir; } // Set certificate-related parameters diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 4c4d863c51..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.", @@ -660,11 +660,11 @@ public static Option CreateTenantIdOption(bool required = false, object defaultV /// /// Sets this option as required. /// Sets the default value when none is provided. - public static Option CreateCertificateDownloadDirectoryOption(bool required = false, object defaultValue = null) + public static Option CreateCertificateInstallationDirectoryOption(bool required = false, object defaultValue = null) { - Option option = new Option(new string[] { "--certificateDownloadDir", "--certDownloadDir" }) + Option option = new Option(new string[] { "--cert-installation-dir" }) { - Name = "CertificateDownloadDir", + Name = "CertificateInstallationDir", Description = "Defines the directory where certificates downloaded from Key Vault will be saved.", ArgumentHelpName = "path", AllowMultipleArgumentsPerToken = false diff --git a/src/VirtualClient/VirtualClient.Main/Program.cs b/src/VirtualClient/VirtualClient.Main/Program.cs index 06e002fe3e..6130d474a3 100644 --- a/src/VirtualClient/VirtualClient.Main/Program.cs +++ b/src/VirtualClient/VirtualClient.Main/Program.cs @@ -311,13 +311,7 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT // --verbose OptionFactory.CreateVerboseFlag(required: false, false), - - // --token - OptionFactory.CreateTokenOption(required: false), - - // --CertificateDownloadDir - OptionFactory.CreateCertificateDownloadDirectoryOption(required: false), - + // --tenant-id OptionFactory.CreateTenantIdOption(required: false), }; @@ -481,8 +475,8 @@ private static Command CreateBootstrapSubcommand(DefaultSettings settings) // --token OptionFactory.CreateTokenOption(required: false), - // --CertificateDownloadDir - OptionFactory.CreateCertificateDownloadDirectoryOption(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 ca37393fa8..c70cd18ddf 100644 --- a/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json +++ b/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json @@ -5,7 +5,8 @@ "CertificateName": null, "AccessToken": null, "LogFileName": null, - "CertificateDownloadDir": null + "CertificateInstallationDir": null, + "WithPrivateKey": true }, "Actions": [ { @@ -16,7 +17,8 @@ "CertificateName": "$.Parameters.CertificateName", "AccessToken": "$.Parameters.AccessToken", "AccessTokenPath": "$.Parameters.LogFileName", - "CertificateDownloadDir": "$.Parameters.CertificateDownloadDir" + "CertificateInstallationDir": "$.Parameters.CertificateInstallationDir", + "WithPrivateKey": "$.Parameters.WithPrivateKey" } } ] diff --git a/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs b/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs index 9ab429d7d5..415c318d32 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs +++ b/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs @@ -137,14 +137,14 @@ public void SetupCertificateInstallation_AllowsCertificateDownloadDirectory() KeyVault = "https://myvault.vault.azure.net/", AccessToken = "token123", TenantId = "00000000-0000-0000-0000-000000000001", - CertificateDownloadDir = "C:\\certs" + CertificateInstallationDir = "C:\\certs" }; command.SetupCertificateInstallationPublic(); Assert.AreEqual(command.Parameters["KeyVaultUri"], command.KeyVault); Assert.AreEqual(command.Parameters["CertificateName"], command.CertificateName); - Assert.AreEqual(command.Parameters["CertificateDownloadDir"], command.CertificateDownloadDir); + Assert.AreEqual(command.Parameters["CertificateInstallationDir"], command.CertificateInstallationDir); Assert.AreEqual(command.Parameters.Count, 3); } 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 3d69b22494..362637bb23 100644 --- a/website/docs/guides/0010-command-line.md +++ b/website/docs/guides/0010-command-line.md @@ -151,11 +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. | - | --certificateDownloadDir | No | string | Directory path where downloaded certificates can also be stored. | + | --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). | From 989f114b76f8d8f1a54f1184a995ac0442819b35 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Mon, 2 Mar 2026 14:51:59 -0800 Subject: [PATCH 08/18] UT fix --- .../CertificateInstallationTests.cs | 15 +++++---------- .../CertificateInstallation.cs | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs index 02193b9d27..e144bd5cd9 100644 --- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs @@ -147,11 +147,9 @@ public void ExecuteAsync_ThrowsWhenCertificateNameIsNull(PlatformID platform) } [Test] - [TestCase(PlatformID.Win32NT)] - [TestCase(PlatformID.Unix)] - public async Task ExecuteAsync_InstallsCertificateOnWindows(PlatformID platform) + public async Task ExecuteAsync_InstallsCertificateOnWindows() { - this.mockFixture.Setup(platform); + this.mockFixture.Setup(PlatformID.Win32NT); this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, @@ -287,13 +285,10 @@ public async Task InstallCertificateLocallyAsync_InstallsCertificateToDirectory( } [Test] - [TestCase(PlatformID.Win32NT)] - [TestCase(PlatformID.Unix)] - public async Task InstallCertificateLocallyAsync_SetsPermissionsOnUnix(PlatformID platform) + [TestCase("/etc/certs")] + public async Task InstallCertificateLocallyAsync_SetsPermissionsOnUnix(string certificateDirectory) { - this.mockFixture.Setup(PlatformID.Unix); - string certificateDirectory = "/etc/certs"; - + this.mockFixture.Setup(PlatformID.Unix); this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index ae0d89d211..c9086e28bb 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -89,7 +89,7 @@ public string CertificateInstallationDir { get { - return this.Parameters.GetValue(nameof(this.CertificateInstallationDir)); + return this.Parameters.GetValue(nameof(this.CertificateInstallationDir), string.Empty); } } From c3a9d240aa1b2fab2a502d03f044c1a8d673c2a9 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Fri, 27 Feb 2026 13:26:22 -0800 Subject: [PATCH 09/18] main changes --- .../EndpointUtilityTests.cs | 32 ------------------- .../VirtualClient.Core/EndpointUtility.cs | 20 ------------ .../KeyVaultAccessTokenTests.cs | 5 --- .../CertificateInstallation.cs | 31 ++++++++++++++++++ .../KeyVaultAccessToken.cs | 30 +++++++++-------- .../VirtualClient.Main/BootstrapCommand.cs | 9 ++++++ .../VirtualClient.Main/OptionFactory.cs | 19 +++++++++++ .../VirtualClient.Main/Program.cs | 11 ++++++- .../profiles/BOOTSTRAP-CERTIFICATE.json | 9 +++--- 9 files changed, 91 insertions(+), 75 deletions(-) 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/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.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..a0871f0e5f 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -81,6 +81,17 @@ public bool WithPrivateKey } } + /// + /// + /// + public string CertificateDownloadDir + { + get + { + return this.Parameters.GetValue(nameof(this.CertificateDownloadDir), string.Empty); + } + } + /// /// Gets the access token used to authenticate with Azure services. /// @@ -126,6 +137,26 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel { throw new PlatformNotSupportedException($"The '{nameof(CertificateInstallation)}' component is not supported on platform '{this.Platform}'."); } + + // If a download directory is specified, we will also export the certificate to that location. + if (!string.IsNullOrEmpty(this.CertificateDownloadDir)) + { + string certificateFileName = this.WithPrivateKey + ? $"{this.CertificateName}.pfx" + : $"{this.CertificateName}.cer"; + + string certificatePath = this.Combine(this.CertificateDownloadDir, certificateFileName); + + // Delete existing certificate file + if (!this.fileSystem.File.Exists(certificatePath)) + { + this.fileSystem.File.Delete(certificatePath); + } + + // Export the new certificate + await this.fileSystem.File.WriteAllBytesAsync(certificatePath, certificate.Export(X509ContentType.Pfx)); + Console.WriteLine($"Certificate exported to {certificatePath}"); + } } catch (Exception exc) { diff --git a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs index af2ec86799..f3aaebf9e5 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; } + public 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..a2c6a80270 100644 --- a/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs @@ -53,6 +53,14 @@ internal class BootstrapCommand : ExecuteProfileCommand /// public string TenantId { get; set; } + /// + /// Directory path where downloaded certificates can be stored. + /// + /// Set this property to a valid directory path to ensure that certificates can be + /// downloaded and saved. + /// + public string CertificateDownloadDir { get; set; } + /// /// Executes the bootstrap command. /// Supports: @@ -142,6 +150,7 @@ protected void SetupCertificateInstallation() // Set certificate-related parameters this.Parameters["KeyVaultUri"] = this.KeyVault; this.Parameters["CertificateName"] = this.CertificateName; + this.Parameters["CertificateDownloadDir"] = this.CertificateDownloadDir; } protected void SetupPackageInstallation() diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 647f081234..458e768538 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -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 CreateCertificateDownloadDirectoryOption(bool required = false, object defaultValue = null) + { + Option option = new Option(new string[] { "--certificateDownloadDir", "--certDownloadDir" }) + { + Name = "CertificateDownloadDir", + Description = "Set (optional) directory path which certificates downloaded from Key Vault can be saved.", + ArgumentHelpName = "tid", + 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..06e002fe3e 100644 --- a/src/VirtualClient/VirtualClient.Main/Program.cs +++ b/src/VirtualClient/VirtualClient.Main/Program.cs @@ -313,7 +313,13 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT OptionFactory.CreateVerboseFlag(required: false, false), // --token - OptionFactory.CreateTokenOption(required: false) + OptionFactory.CreateTokenOption(required: false), + + // --CertificateDownloadDir + OptionFactory.CreateCertificateDownloadDirectoryOption(required: false), + + // --tenant-id + OptionFactory.CreateTenantIdOption(required: false), }; // Single command execution is also supported. Behind the scenes this uses a @@ -474,6 +480,9 @@ private static Command CreateBootstrapSubcommand(DefaultSettings settings) // --token OptionFactory.CreateTokenOption(required: false), + + // --CertificateDownloadDir + OptionFactory.CreateCertificateDownloadDirectoryOption(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 5996ecefd6..ca37393fa8 100644 --- a/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json +++ b/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json @@ -4,7 +4,8 @@ "KeyVaultUri": null, "CertificateName": null, "AccessToken": null, - "LogFileName": null + "LogFileName": null, + "CertificateDownloadDir": null }, "Actions": [ { @@ -14,9 +15,9 @@ "KeyVaultUri": "$.Parameters.KeyVaultUri", "CertificateName": "$.Parameters.CertificateName", "AccessToken": "$.Parameters.AccessToken", - "AccessTokenPath": "$.Parameters.LogFileName" + "AccessTokenPath": "$.Parameters.LogFileName", + "CertificateDownloadDir": "$.Parameters.CertificateDownloadDir" } } ] -} - \ No newline at end of file +} \ No newline at end of file From 032fb2bbabd6fa14f86a0e72f2cad66c8e343eb4 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Fri, 27 Feb 2026 14:21:12 -0800 Subject: [PATCH 10/18] Minor fix --- .../CertificateInstallation.cs | 2 +- .../VirtualClient.Main/BootstrapCommand.cs | 6 +++++- .../BootstrapCommandTests.cs | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index a0871f0e5f..033edb749b 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -148,7 +148,7 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel string certificatePath = this.Combine(this.CertificateDownloadDir, certificateFileName); // Delete existing certificate file - if (!this.fileSystem.File.Exists(certificatePath)) + if (this.fileSystem.File.Exists(certificatePath)) { this.fileSystem.File.Delete(certificatePath); } diff --git a/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs b/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs index a2c6a80270..070dbe9768 100644 --- a/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs @@ -147,10 +147,14 @@ protected void SetupCertificateInstallation() "The Key Vault URI must be provided (--key-vault) when installing certificates (--cert-name)."); } + if (!string.IsNullOrWhiteSpace(this.CertificateDownloadDir)) + { + this.Parameters["CertificateDownloadDir"] = this.CertificateDownloadDir; + } + // Set certificate-related parameters this.Parameters["KeyVaultUri"] = this.KeyVault; this.Parameters["CertificateName"] = this.CertificateName; - this.Parameters["CertificateDownloadDir"] = this.CertificateDownloadDir; } protected void SetupPackageInstallation() diff --git a/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs b/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs index 60f5190599..9ab429d7d5 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", + CertificateDownloadDir = "C:\\certs" + }; + + command.SetupCertificateInstallationPublic(); + + Assert.AreEqual(command.Parameters["KeyVaultUri"], command.KeyVault); + Assert.AreEqual(command.Parameters["CertificateName"], command.CertificateName); + Assert.AreEqual(command.Parameters["CertificateDownloadDir"], command.CertificateDownloadDir); + Assert.AreEqual(command.Parameters.Count, 3); + } + [Test] [TestCase(null)] [TestCase("")] From 18ccb60d20efad499a54d52102cebcaea05d9114 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Sat, 28 Feb 2026 12:38:30 -0800 Subject: [PATCH 11/18] Allowing cert to be exported even after loading. --- .../Identity/CertificateLoaderHelper.cs | 39 +++++++++++++++ .../VirtualClient.Core/KeyVaultManager.cs | 48 ++++++++++--------- .../CertificateInstallation.cs | 32 ++++++++++--- 3 files changed, 91 insertions(+), 28 deletions(-) create mode 100644 src/VirtualClient/VirtualClient.Core/Identity/CertificateLoaderHelper.cs 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..8842bc74c0 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); + SecretClient secretClient = this.CreateSecretClient(vaultUri, credentials); + 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/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index 033edb749b..096bbcd8a4 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -62,7 +62,7 @@ public string CertificateName /// /// Gets the path to the file where the access token is saved. /// - public string AccessTokenPath + public string AccessTokenPath { get { @@ -138,12 +138,16 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel throw new PlatformNotSupportedException($"The '{nameof(CertificateInstallation)}' component is not supported on platform '{this.Platform}'."); } - // If a download directory is specified, we will also export the certificate to that location. + // Export the certificate if requested if (!string.IsNullOrEmpty(this.CertificateDownloadDir)) { - string certificateFileName = this.WithPrivateKey + string certificateFileName = this.WithPrivateKey ? $"{this.CertificateName}.pfx" : $"{this.CertificateName}.cer"; + + X509ContentType contentType = this.WithPrivateKey + ? X509ContentType.Pfx + : X509ContentType.Cert; string certificatePath = this.Combine(this.CertificateDownloadDir, certificateFileName); @@ -153,9 +157,8 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel this.fileSystem.File.Delete(certificatePath); } - // Export the new certificate - await this.fileSystem.File.WriteAllBytesAsync(certificatePath, certificate.Export(X509ContentType.Pfx)); - Console.WriteLine($"Certificate exported to {certificatePath}"); + byte[] certBytes = certificate.Export(contentType, string.Empty); + await this.fileSystem.File.WriteAllBytesAsync(certificatePath, certBytes); } } catch (Exception exc) @@ -276,5 +279,22 @@ protected IKeyVaultManager GetKeyVaultManager() $"Either valid --KeyVault or --Token or --TokenPath must be passed in order to set up authentication with Key Vault."); } } + + /// + /// Tries to get certificate data, returning null if an exception occurs. + /// + private byte[] TryGetCertData(Func getCertData) + { + try + { + return getCertData(); + } + catch (Exception exc) + { + Console.WriteLine(exc.ToString()); + Console.WriteLine("\n\n\n\n=================================================================================\n"); + return null; + } + } } } \ No newline at end of file From 340cfc606721c81f7753fa5fc6c4a52f4c12550f Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Sat, 28 Feb 2026 12:51:24 -0800 Subject: [PATCH 12/18] Adding doc --- .../VirtualClient.Core/KeyVaultManager.cs | 4 ++-- .../CertificateInstallation.cs | 18 ------------------ website/docs/guides/0010-command-line.md | 1 + 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs index 8842bc74c0..25fdb9bbea 100644 --- a/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs +++ b/src/VirtualClient/VirtualClient.Core/KeyVaultManager.cs @@ -220,8 +220,8 @@ public async Task GetCertificateAsync( var credentials = ((DependencyKeyVaultStore)this.StoreDescription).Credentials; - CertificateClient certificateClient = this.CreateCertificateClient(vaultUri, credentials); - SecretClient secretClient = this.CreateSecretClient(vaultUri, credentials); + CertificateClient certificateClient = this.CreateCertificateClient(vaultUri, credentials); // For public cert. + SecretClient secretClient = this.CreateSecretClient(vaultUri, credentials); // For private cert (PFX) try { diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index 096bbcd8a4..5a23799c34 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -151,7 +151,6 @@ protected override async Task ExecuteAsync(EventContext telemetryContext, Cancel string certificatePath = this.Combine(this.CertificateDownloadDir, certificateFileName); - // Delete existing certificate file if (this.fileSystem.File.Exists(certificatePath)) { this.fileSystem.File.Delete(certificatePath); @@ -279,22 +278,5 @@ protected IKeyVaultManager GetKeyVaultManager() $"Either valid --KeyVault or --Token or --TokenPath must be passed in order to set up authentication with Key Vault."); } } - - /// - /// Tries to get certificate data, returning null if an exception occurs. - /// - private byte[] TryGetCertData(Func getCertData) - { - try - { - return getCertData(); - } - catch (Exception exc) - { - Console.WriteLine(exc.ToString()); - Console.WriteLine("\n\n\n\n=================================================================================\n"); - return null; - } - } } } \ No newline at end of file diff --git a/website/docs/guides/0010-command-line.md b/website/docs/guides/0010-command-line.md index 68b892c218..3d69b22494 100644 --- a/website/docs/guides/0010-command-line.md +++ b/website/docs/guides/0010-command-line.md @@ -155,6 +155,7 @@ The following tables describe the various subcommands that are supported by the | --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.). | | --tenant-Id, --tid=\ | No | string | Azure Active Directory tenant ID used for authentication. | + | --certificateDownloadDir | No | string | Directory path where downloaded certificates can also be stored. | | --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). | From fa578935e0c1fc094f143a99963a2c215caccef1 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Sat, 28 Feb 2026 17:57:23 -0800 Subject: [PATCH 13/18] Adding ut --- .../KeyVaultManagerTests.cs | 23 ++ .../CertificateInstallationTests.cs | 265 ++++++++++++++++++ 2 files changed, 288 insertions(+) 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.Dependencies.UnitTests/CertificateInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs index 606bb112ec..4b1d1914c9 100644 --- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs @@ -518,6 +518,271 @@ public void GetKeyVaultManager_ThrowsWhenNoKeyVaultManagerOrAccessTokenProvided( } } + [Test] + [TestCase(true, "testCert.pfx")] + [TestCase(false, "testCert.cer")] + public async Task ExecuteAsync_SavesCertificateToDownloadDirectory(bool withPrivateKey, string expectedFileName) + { + this.mockFixture.Setup(PlatformID.Win32NT); + string downloadDir = "/tmp/certificates"; + string expectedPath = this.mockFixture.Combine(downloadDir, expectedFileName); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, + { nameof(CertificateInstallation.WithPrivateKey), withPrivateKey } + }; + + this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, 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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + // Verify the file was written with the correct path + this.mockFixture.File.Verify( + f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Test] + public async Task ExecuteAsync_SavesPrivateCertificateAsPfxFormat() + { + this.mockFixture.Setup(PlatformID.Win32NT); + this.SetupPrivateCertificate(); + + string downloadDir = "/tmp/certificates"; + string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.pfx"); + byte[] capturedBytes = null; + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, + { nameof(CertificateInstallation.WithPrivateKey), true } + }; + + this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny())) + .Callback((path, bytes, token) => capturedBytes = bytes) + .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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + // Verify the bytes captured are in PFX format + Assert.IsNotNull(capturedBytes); + byte[] expectedBytes = this.testCertificate.Export(X509ContentType.Pfx, string.Empty); + Assert.AreEqual(expectedBytes.Length, capturedBytes.Length); + } + + [Test] + public async Task ExecuteAsync_SavesPublicCertificateAsCerFormat() + { + this.mockFixture.Setup(PlatformID.Win32NT); + string downloadDir = "/tmp/certificates"; + string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.cer"); + byte[] capturedBytes = null; + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, + { nameof(CertificateInstallation.WithPrivateKey), false } + }; + + this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny())) + .Callback((path, bytes, token) => capturedBytes = bytes) + .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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + // Verify the bytes captured are in Cert format + Assert.IsNotNull(capturedBytes); + byte[] expectedBytes = this.testCertificate.Export(X509ContentType.Cert, string.Empty); + CollectionAssert.AreEqual(expectedBytes, capturedBytes); + } + + [Test] + public async Task ExecuteAsync_DeletesExistingCertificateFileBeforeWriting() + { + this.mockFixture.Setup(PlatformID.Win32NT); + string downloadDir = "/tmp/certificates"; + string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.pfx"); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, + { nameof(CertificateInstallation.WithPrivateKey), true } + }; + + // Setup file exists to return true + this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(true); + this.mockFixture.File.Setup(f => f.Delete(expectedPath)); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, 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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + // Verify the existing file was deleted before writing + this.mockFixture.File.Verify(f => f.Delete(expectedPath), Times.Once); + this.mockFixture.File.Verify( + f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Test] + public async Task ExecuteAsync_DoesNotSaveCertificateWhenDownloadDirNotProvided() + { + this.mockFixture.Setup(PlatformID.Win32NT); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.WithPrivateKey), true } + // CertificateDownloadDir is not provided + }; + + 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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + // Verify no file operations were performed + this.mockFixture.File.Verify(f => f.Exists(It.IsAny()), Times.Never); + this.mockFixture.File.Verify(f => f.Delete(It.IsAny()), Times.Never); + this.mockFixture.File.Verify( + f => f.WriteAllBytesAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task ExecuteAsync_SavesCertificateOnUnixPlatform() + { + this.mockFixture.Setup(PlatformID.Unix); + string downloadDir = "/tmp/certificates"; + string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.pfx"); + + this.mockFixture.Parameters = new Dictionary() + { + { nameof(CertificateInstallation.CertificateName), "testCert" }, + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, + { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, + { nameof(CertificateInstallation.WithPrivateKey), true } + }; + + this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); + this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, 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) => Task.CompletedTask; + + await component.ExecuteAsync(EventContext.None, CancellationToken.None); + } + + // Verify certificate was saved to download directory even on Unix + this.mockFixture.File.Verify( + f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny()), + Times.Once); + } + + private void SetupPrivateCertificate() + { + var distinguishedName = new X500DistinguishedName("CN=TestCert"); + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(distinguishedName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + this.testCertificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1)); + } + private class TestCertificateInstallation : CertificateInstallation { public TestCertificateInstallation(IServiceCollection dependencies, IDictionary parameters) From 51a4e624dd1601eee0d94e872ed34640b25ffe79 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Sat, 28 Feb 2026 18:06:35 -0800 Subject: [PATCH 14/18] Adding comments --- .../VirtualClient.Dependencies/CertificateInstallation.cs | 2 +- src/VirtualClient/VirtualClient.Main/OptionFactory.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index 5a23799c34..35502cada3 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -82,7 +82,7 @@ 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 CertificateDownloadDir { diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 458e768538..4c4d863c51 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -665,8 +665,8 @@ public static Option CreateCertificateDownloadDirectoryOption(bool required = fa Option option = new Option(new string[] { "--certificateDownloadDir", "--certDownloadDir" }) { Name = "CertificateDownloadDir", - Description = "Set (optional) directory path which certificates downloaded from Key Vault can be saved.", - ArgumentHelpName = "tid", + Description = "Defines the directory where certificates downloaded from Key Vault will be saved.", + ArgumentHelpName = "path", AllowMultipleArgumentsPerToken = false }; From cb9d335b016992228946c8378e4b21562af13c63 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Mon, 2 Mar 2026 14:34:21 -0800 Subject: [PATCH 15/18] Addressing PR comments --- .../CertificateInstallationTests.cs | 612 +++++------------- .../CertificateInstallation.cs | 154 ++--- .../VirtualClient.Main/BootstrapCommand.cs | 11 +- .../VirtualClient.Main/OptionFactory.cs | 12 +- .../VirtualClient.Main/Program.cs | 12 +- .../profiles/BOOTSTRAP-CERTIFICATE.json | 6 +- .../BootstrapCommandTests.cs | 4 +- .../BootstrapCommandValidatorTests.cs | 1 - website/docs/guides/0010-command-line.md | 6 +- 9 files changed, 230 insertions(+), 588 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs index 4b1d1914c9..02193b9d27 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,35 +126,39 @@ 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() + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public async Task ExecuteAsync_InstallsCertificateOnWindows(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); + this.mockFixture.Setup(platform); this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, { 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 +171,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 +181,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 +191,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 +259,59 @@ public void ExecuteAsync_WrapsExceptionsInDependencyException() } [Test] - public async Task InstallCertificateOnWindowsAsync_InstallsCertificateToCurrentUserStore() - { - 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() + [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.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(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public async Task InstallCertificateLocallyAsync_SetsPermissionsOnUnix(PlatformID platform) { this.mockFixture.Setup(PlatformID.Unix); - - string certificateDirectory = "/home/sudouser/.dotnet/corefx/cryptography/x509stores/my"; - string certificatePath = this.mockFixture.Combine(certificateDirectory, $"{this.testCertificate.Thumbprint}.pfx"); - + string certificateDirectory = "/etc/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); 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 +322,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 +349,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 +377,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 +411,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 +443,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,276 +461,30 @@ public void GetKeyVaultManager_ThrowsWhenNoKeyVaultManagerOrAccessTokenProvided( using (TestCertificateInstallation component = new TestCertificateInstallation(this.mockFixture.Dependencies, this.mockFixture.Parameters)) { - InvalidOperationException exception = Assert.Throws( - () => component.GetKeyVaultManager()); - - StringAssert.Contains("Key Vault manager has not been properly initialized", exception.Message); + InvalidOperationException exception = Assert.Throws(() => component.GetKeyVaultManager()); + StringAssert.Contains("The Key Vault manager has not been properly initialized", exception.Message); } } [Test] - [TestCase(true, "testCert.pfx")] - [TestCase(false, "testCert.cer")] - public async Task ExecuteAsync_SavesCertificateToDownloadDirectory(bool withPrivateKey, string expectedFileName) + [TestCase(PlatformID.Win32NT)] + [TestCase(PlatformID.Unix)] + public async Task InstallCertificateOnMachineAsync_UsesPlatformStore_Windows(PlatformID platform) { - this.mockFixture.Setup(PlatformID.Win32NT); - string downloadDir = "/tmp/certificates"; - string expectedPath = this.mockFixture.Combine(downloadDir, expectedFileName); - + this.mockFixture.Setup(platform); + this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, - { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, - { nameof(CertificateInstallation.WithPrivateKey), withPrivateKey } - }; - - this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, 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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; - - await component.ExecuteAsync(EventContext.None, CancellationToken.None); - } - - // Verify the file was written with the correct path - this.mockFixture.File.Verify( - f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny()), - Times.Once); - } - - [Test] - public async Task ExecuteAsync_SavesPrivateCertificateAsPfxFormat() - { - this.mockFixture.Setup(PlatformID.Win32NT); - this.SetupPrivateCertificate(); - - string downloadDir = "/tmp/certificates"; - string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.pfx"); - byte[] capturedBytes = null; - - this.mockFixture.Parameters = new Dictionary() - { - { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, - { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, - { nameof(CertificateInstallation.WithPrivateKey), true } - }; - - this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny())) - .Callback((path, bytes, token) => capturedBytes = bytes) - .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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; - - await component.ExecuteAsync(EventContext.None, CancellationToken.None); - } - - // Verify the bytes captured are in PFX format - Assert.IsNotNull(capturedBytes); - byte[] expectedBytes = this.testCertificate.Export(X509ContentType.Pfx, string.Empty); - Assert.AreEqual(expectedBytes.Length, capturedBytes.Length); - } - - [Test] - public async Task ExecuteAsync_SavesPublicCertificateAsCerFormat() - { - this.mockFixture.Setup(PlatformID.Win32NT); - string downloadDir = "/tmp/certificates"; - string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.cer"); - byte[] capturedBytes = null; - - this.mockFixture.Parameters = new Dictionary() - { - { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, - { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, - { nameof(CertificateInstallation.WithPrivateKey), false } - }; - - this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny())) - .Callback((path, bytes, token) => capturedBytes = bytes) - .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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; - - await component.ExecuteAsync(EventContext.None, CancellationToken.None); - } - - // Verify the bytes captured are in Cert format - Assert.IsNotNull(capturedBytes); - byte[] expectedBytes = this.testCertificate.Export(X509ContentType.Cert, string.Empty); - CollectionAssert.AreEqual(expectedBytes, capturedBytes); - } - - [Test] - public async Task ExecuteAsync_DeletesExistingCertificateFileBeforeWriting() - { - this.mockFixture.Setup(PlatformID.Win32NT); - string downloadDir = "/tmp/certificates"; - string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.pfx"); - - this.mockFixture.Parameters = new Dictionary() - { - { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, - { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, - { nameof(CertificateInstallation.WithPrivateKey), true } - }; - - // Setup file exists to return true - this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(true); - this.mockFixture.File.Setup(f => f.Delete(expectedPath)); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, 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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; - - await component.ExecuteAsync(EventContext.None, CancellationToken.None); - } - - // Verify the existing file was deleted before writing - this.mockFixture.File.Verify(f => f.Delete(expectedPath), Times.Once); - this.mockFixture.File.Verify( - f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny()), - Times.Once); - } - - [Test] - public async Task ExecuteAsync_DoesNotSaveCertificateWhenDownloadDirNotProvided() - { - this.mockFixture.Setup(PlatformID.Win32NT); - - this.mockFixture.Parameters = new Dictionary() - { - { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, - { nameof(CertificateInstallation.WithPrivateKey), true } - // CertificateDownloadDir is not provided - }; - - 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.OnInstallCertificateOnWindows = (cert, token) => Task.CompletedTask; - - await component.ExecuteAsync(EventContext.None, CancellationToken.None); - } - - // Verify no file operations were performed - this.mockFixture.File.Verify(f => f.Exists(It.IsAny()), Times.Never); - this.mockFixture.File.Verify(f => f.Delete(It.IsAny()), Times.Never); - this.mockFixture.File.Verify( - f => f.WriteAllBytesAsync(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - - [Test] - public async Task ExecuteAsync_SavesCertificateOnUnixPlatform() - { - this.mockFixture.Setup(PlatformID.Unix); - string downloadDir = "/tmp/certificates"; - string expectedPath = this.mockFixture.Combine(downloadDir, "testCert.pfx"); - - this.mockFixture.Parameters = new Dictionary() - { - { nameof(CertificateInstallation.CertificateName), "testCert" }, - { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" }, - { nameof(CertificateInstallation.CertificateDownloadDir), downloadDir }, - { nameof(CertificateInstallation.WithPrivateKey), true } + { nameof(CertificateInstallation.KeyVaultUri), "https://testvault.vault.azure.net/" } }; - this.mockFixture.File.Setup(f => f.Exists(expectedPath)).Returns(false); - this.mockFixture.File.Setup(f => f.WriteAllBytesAsync(expectedPath, 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) => Task.CompletedTask; - - await component.ExecuteAsync(EventContext.None, CancellationToken.None); + // 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); } - - // Verify certificate was saved to download directory even on Unix - this.mockFixture.File.Verify( - f => f.WriteAllBytesAsync(expectedPath, It.IsAny(), It.IsAny()), - Times.Once); - } - - private void SetupPrivateCertificate() - { - var distinguishedName = new X500DistinguishedName("CN=TestCert"); - - using var rsa = RSA.Create(2048); - var request = new CertificateRequest(distinguishedName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - this.testCertificate = request.CreateSelfSigned( - DateTimeOffset.UtcNow.AddDays(-1), - DateTimeOffset.UtcNow.AddYears(1)); } private class TestCertificateInstallation : CertificateInstallation @@ -790,18 +494,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() @@ -809,28 +511,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/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index 35502cada3..ae0d89d211 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; @@ -84,11 +85,11 @@ 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 CertificateDownloadDir + public string CertificateInstallationDir { get { - return this.Parameters.GetValue(nameof(this.CertificateDownloadDir), string.Empty); + return this.Parameters.GetValue(nameof(this.CertificateInstallationDir)); } } @@ -125,39 +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 - { - throw new PlatformNotSupportedException($"The '{nameof(CertificateInstallation)}' component is not supported on platform '{this.Platform}'."); - } + await this.InstallCertificateOnMachineAsync(certificate, cancellationToken); - // Export the certificate if requested - if (!string.IsNullOrEmpty(this.CertificateDownloadDir)) + if (!string.IsNullOrWhiteSpace(this.CertificateInstallationDir)) { - string certificateFileName = this.WithPrivateKey - ? $"{this.CertificateName}.pfx" - : $"{this.CertificateName}.cer"; - - X509ContentType contentType = this.WithPrivateKey - ? X509ContentType.Pfx - : X509ContentType.Cert; - - string certificatePath = this.Combine(this.CertificateDownloadDir, certificateFileName); - - if (this.fileSystem.File.Exists(certificatePath)) - { - this.fileSystem.File.Delete(certificatePath); - } - - byte[] certBytes = certificate.Export(contentType, string.Empty); - await this.fileSystem.File.WriteAllBytesAsync(certificatePath, certBytes); + await this.InstallCertificateLocallyAsync(certificate, cancellationToken); } } catch (Exception exc) @@ -169,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(() => { @@ -185,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. /// @@ -278,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.Main/BootstrapCommand.cs b/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs index 070dbe9768..27e77c02f5 100644 --- a/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/BootstrapCommand.cs @@ -54,12 +54,9 @@ internal class BootstrapCommand : ExecuteProfileCommand public string TenantId { get; set; } /// - /// Directory path where downloaded certificates can be stored. + /// Directory path where certificates can be stored. This is optional, but if not provided, certificates will not be saved to disk. /// - /// Set this property to a valid directory path to ensure that certificates can be - /// downloaded and saved. - /// - public string CertificateDownloadDir { get; set; } + public string CertificateInstallationDir { get; set; } /// /// Executes the bootstrap command. @@ -147,9 +144,9 @@ protected void SetupCertificateInstallation() "The Key Vault URI must be provided (--key-vault) when installing certificates (--cert-name)."); } - if (!string.IsNullOrWhiteSpace(this.CertificateDownloadDir)) + if (!string.IsNullOrWhiteSpace(this.CertificateInstallationDir)) { - this.Parameters["CertificateDownloadDir"] = this.CertificateDownloadDir; + this.Parameters["CertificateInstallationDir"] = this.CertificateInstallationDir; } // Set certificate-related parameters diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 4c4d863c51..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.", @@ -660,11 +660,11 @@ public static Option CreateTenantIdOption(bool required = false, object defaultV /// /// Sets this option as required. /// Sets the default value when none is provided. - public static Option CreateCertificateDownloadDirectoryOption(bool required = false, object defaultValue = null) + public static Option CreateCertificateInstallationDirectoryOption(bool required = false, object defaultValue = null) { - Option option = new Option(new string[] { "--certificateDownloadDir", "--certDownloadDir" }) + Option option = new Option(new string[] { "--cert-installation-dir" }) { - Name = "CertificateDownloadDir", + Name = "CertificateInstallationDir", Description = "Defines the directory where certificates downloaded from Key Vault will be saved.", ArgumentHelpName = "path", AllowMultipleArgumentsPerToken = false diff --git a/src/VirtualClient/VirtualClient.Main/Program.cs b/src/VirtualClient/VirtualClient.Main/Program.cs index 06e002fe3e..6130d474a3 100644 --- a/src/VirtualClient/VirtualClient.Main/Program.cs +++ b/src/VirtualClient/VirtualClient.Main/Program.cs @@ -311,13 +311,7 @@ internal static CommandLineBuilder SetupCommandLine(string[] args, CancellationT // --verbose OptionFactory.CreateVerboseFlag(required: false, false), - - // --token - OptionFactory.CreateTokenOption(required: false), - - // --CertificateDownloadDir - OptionFactory.CreateCertificateDownloadDirectoryOption(required: false), - + // --tenant-id OptionFactory.CreateTenantIdOption(required: false), }; @@ -481,8 +475,8 @@ private static Command CreateBootstrapSubcommand(DefaultSettings settings) // --token OptionFactory.CreateTokenOption(required: false), - // --CertificateDownloadDir - OptionFactory.CreateCertificateDownloadDirectoryOption(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 ca37393fa8..c70cd18ddf 100644 --- a/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json +++ b/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json @@ -5,7 +5,8 @@ "CertificateName": null, "AccessToken": null, "LogFileName": null, - "CertificateDownloadDir": null + "CertificateInstallationDir": null, + "WithPrivateKey": true }, "Actions": [ { @@ -16,7 +17,8 @@ "CertificateName": "$.Parameters.CertificateName", "AccessToken": "$.Parameters.AccessToken", "AccessTokenPath": "$.Parameters.LogFileName", - "CertificateDownloadDir": "$.Parameters.CertificateDownloadDir" + "CertificateInstallationDir": "$.Parameters.CertificateInstallationDir", + "WithPrivateKey": "$.Parameters.WithPrivateKey" } } ] diff --git a/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs b/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs index 9ab429d7d5..415c318d32 100644 --- a/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs +++ b/src/VirtualClient/VirtualClient.UnitTests/BootstrapCommandTests.cs @@ -137,14 +137,14 @@ public void SetupCertificateInstallation_AllowsCertificateDownloadDirectory() KeyVault = "https://myvault.vault.azure.net/", AccessToken = "token123", TenantId = "00000000-0000-0000-0000-000000000001", - CertificateDownloadDir = "C:\\certs" + CertificateInstallationDir = "C:\\certs" }; command.SetupCertificateInstallationPublic(); Assert.AreEqual(command.Parameters["KeyVaultUri"], command.KeyVault); Assert.AreEqual(command.Parameters["CertificateName"], command.CertificateName); - Assert.AreEqual(command.Parameters["CertificateDownloadDir"], command.CertificateDownloadDir); + Assert.AreEqual(command.Parameters["CertificateInstallationDir"], command.CertificateInstallationDir); Assert.AreEqual(command.Parameters.Count, 3); } 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 3d69b22494..362637bb23 100644 --- a/website/docs/guides/0010-command-line.md +++ b/website/docs/guides/0010-command-line.md @@ -151,11 +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. | - | --certificateDownloadDir | No | string | Directory path where downloaded certificates can also be stored. | + | --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). | From 08a90505e10f7726fc7789178fcc479884a8b4e5 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Mon, 2 Mar 2026 14:51:59 -0800 Subject: [PATCH 16/18] UT fix --- .../CertificateInstallationTests.cs | 15 +++++---------- .../CertificateInstallation.cs | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs index 02193b9d27..e144bd5cd9 100644 --- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/CertificateInstallationTests.cs @@ -147,11 +147,9 @@ public void ExecuteAsync_ThrowsWhenCertificateNameIsNull(PlatformID platform) } [Test] - [TestCase(PlatformID.Win32NT)] - [TestCase(PlatformID.Unix)] - public async Task ExecuteAsync_InstallsCertificateOnWindows(PlatformID platform) + public async Task ExecuteAsync_InstallsCertificateOnWindows() { - this.mockFixture.Setup(platform); + this.mockFixture.Setup(PlatformID.Win32NT); this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, @@ -287,13 +285,10 @@ public async Task InstallCertificateLocallyAsync_InstallsCertificateToDirectory( } [Test] - [TestCase(PlatformID.Win32NT)] - [TestCase(PlatformID.Unix)] - public async Task InstallCertificateLocallyAsync_SetsPermissionsOnUnix(PlatformID platform) + [TestCase("/etc/certs")] + public async Task InstallCertificateLocallyAsync_SetsPermissionsOnUnix(string certificateDirectory) { - this.mockFixture.Setup(PlatformID.Unix); - string certificateDirectory = "/etc/certs"; - + this.mockFixture.Setup(PlatformID.Unix); this.mockFixture.Parameters = new Dictionary() { { nameof(CertificateInstallation.CertificateName), "testCert" }, diff --git a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs index ae0d89d211..c9086e28bb 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/CertificateInstallation.cs @@ -89,7 +89,7 @@ public string CertificateInstallationDir { get { - return this.Parameters.GetValue(nameof(this.CertificateInstallationDir)); + return this.Parameters.GetValue(nameof(this.CertificateInstallationDir), string.Empty); } } From e8e3cedb2dbc42ca482c07f3bd1a9d4046a9ec2c Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Tue, 3 Mar 2026 10:04:29 -0800 Subject: [PATCH 17/18] public -> protected --- .../VirtualClient.Dependencies/KeyVaultAccessToken.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs index f3aaebf9e5..6f1393390d 100644 --- a/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs +++ b/src/VirtualClient/VirtualClient.Dependencies/KeyVaultAccessToken.cs @@ -41,7 +41,7 @@ public KeyVaultAccessToken(IServiceCollection dependencies, IDictionary - public string KeyVaultUri + protected string KeyVaultUri { get { From 3e2455088fd081d8ff4e39000862b6d4c31d714a Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Thu, 5 Mar 2026 14:31:48 -0800 Subject: [PATCH 18/18] Fixing profile --- .../profiles/BOOTSTRAP-CERTIFICATE.json | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json b/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json index 171896f944..c70cd18ddf 100644 --- a/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json +++ b/src/VirtualClient/VirtualClient.Main/profiles/BOOTSTRAP-CERTIFICATE.json @@ -1,5 +1,4 @@ { -<<<<<<< users/nchapagain/EnableCertDownload "Description": "Installs dependencies from a package store.", "Parameters": { "KeyVaultUri": null, @@ -23,27 +22,4 @@ } } ] -} -======= - "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" - } - } - ] -} - ->>>>>>> main +} \ No newline at end of file