diff --git a/Frends.HTTP.DownloadFile/CHANGELOG.md b/Frends.HTTP.DownloadFile/CHANGELOG.md index 597e1b9..20f6d39 100644 --- a/Frends.HTTP.DownloadFile/CHANGELOG.md +++ b/Frends.HTTP.DownloadFile/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.4.0] - 2025-11-18 +### Added +- Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when using certificate authentication. + ## [1.3.0] - 2025-05-15 ### Changed - Added new Overwrite parameter to control whether the downloaded file should replace an existing one. diff --git a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/Frends.HTTP.DownloadFile.Tests.csproj b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/Frends.HTTP.DownloadFile.Tests.csproj index 40a3537..9163594 100644 --- a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/Frends.HTTP.DownloadFile.Tests.csproj +++ b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/Frends.HTTP.DownloadFile.Tests.csproj @@ -13,6 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/UnitTests.cs b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/UnitTests.cs index 00db7d0..cfb392a 100644 --- a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/UnitTests.cs +++ b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/UnitTests.cs @@ -1,11 +1,12 @@ using Frends.HTTP.DownloadFile.Definitions; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Pluralsight.Crypto; using System; using System.Collections.Generic; using System.IO; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; +using Pluralsight.Crypto; namespace Frends.HTTP.DownloadFile.Tests; @@ -14,7 +15,9 @@ public class UnitTests { private static readonly string _directory = Path.Combine(Environment.CurrentDirectory, "testfiles"); private static readonly string _filePath = Path.Combine(_directory, "picture.jpg"); - private static readonly string _targetFileAddress = @"https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/1200px-Google_2015_logo.svg.png"; + + private static readonly string _targetFileAddress = + "https://frendsfonts.blob.core.windows.net/images/frendsLogo.png";//@"http://localhost:9999/testfile.png"; private readonly string _certificatePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TestFiles", "certwithpk.pfx"); private readonly string _privateKeyPassword = "password"; @@ -138,9 +141,21 @@ public async Task TestFileDownload_WithHeaders() { var headers = new[] { new Header() { Name = "foo", Value = "bar" } }; - var auths = new List() { Authentication.None, Authentication.Basic, Authentication.WindowsAuthentication, Authentication.WindowsIntegratedSecurity, Authentication.OAuth }; + var auths = new List + { + Authentication.None, + Authentication.Basic, + Authentication.WindowsAuthentication, + Authentication.WindowsIntegratedSecurity, + Authentication.OAuth + }; - var certSource = new List() { CertificateSource.CertificateStore, CertificateSource.File, CertificateSource.String }; + var certSource = new List + { + CertificateSource.CertificateStore, + CertificateSource.File, + CertificateSource.String + }; var input = new Input { @@ -188,7 +203,12 @@ public async Task TestFileDownload_WithHeaders() [TestMethod] public async Task TestFileDownload_Certification() { - var certSources = new List() { CertificateSource.File, CertificateSource.String, CertificateSource.CertificateStore }; + var certSources = new List + { + CertificateSource.File, + CertificateSource.String, + CertificateSource.CertificateStore + }; var input = new Input { @@ -328,4 +348,339 @@ public async Task TestFileDownload_WithOverwriteTrue_ShouldOverwriteExistingFile var actualContent = File.ReadAllText(_filePath); Assert.AreNotEqual("OLD CONTENT", actualContent, "File should have been overwritten."); } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public async Task TestFileDownload_WithEmptyUrl_ShouldThrowException() + { + var input = new Input + { + Url = "", + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + Authentication = Authentication.None, + ConnectionTimeoutSeconds = 60 + }; + + await HTTP.DownloadFile(input, options, default); + } + + [TestMethod] + public async Task TestFileDownload_WithCertificateStoreLocation_CurrentUser() + { + var tp = CertificateHandler(_certificatePath, _privateKeyPassword, false, null); + + var input = new Input + { + Url = _targetFileAddress, + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + AllowInvalidResponseContentTypeCharSet = true, + Authentication = Authentication.ClientCertificate, + AutomaticCookieHandling = true, + CertificateThumbprint = tp, + ClientCertificateFilePath = _certificatePath, + ClientCertificateInBase64 = "", + ClientCertificateKeyPhrase = _privateKeyPassword, + ClientCertificateSource = CertificateSource.CertificateStore, + CertificateStoreLocation = CertificateStoreLocation.CurrentUser, + ConnectionTimeoutSeconds = 60, + FollowRedirects = true, + LoadEntireChainForCertificate = false, + Password = "", + ThrowExceptionOnErrorResponse = true, + Token = "", + Username = "domain\\username" + }; + + var result = await HTTP.DownloadFile(input, options, default); + + Assert.IsNotNull(result); + Assert.IsTrue(result.Success); + Assert.IsNotNull(result.FilePath); + Assert.IsTrue(File.Exists(result.FilePath)); + + CertificateHandler(_certificatePath, _privateKeyPassword, true, tp); + } + + [TestMethod] + [ExpectedException(typeof(Exception))] + public async Task TestFileDownload_WithCertificateStoreLocation_LocalMachine_NotFound() + { + // Use real HTTP client factory to test certificate lookup failure + HTTP.ClientFactory = new HttpClientFactory(); + + var input = new Input + { + Url = _targetFileAddress, + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + AllowInvalidResponseContentTypeCharSet = true, + Authentication = Authentication.ClientCertificate, + AutomaticCookieHandling = true, + CertificateThumbprint = "NONEXISTENTTHUMBPRINT", + ClientCertificateFilePath = "", + ClientCertificateInBase64 = "", + ClientCertificateKeyPhrase = "", + ClientCertificateSource = CertificateSource.CertificateStore, + CertificateStoreLocation = CertificateStoreLocation.LocalMachine, + ConnectionTimeoutSeconds = 60, + FollowRedirects = true, + LoadEntireChainForCertificate = false, + Password = "", + ThrowExceptionOnErrorResponse = true, + Token = "", + Username = "domain\\username" + }; + + await HTTP.DownloadFile(input, options, default); + } + + [TestMethod] + public async Task TestFileDownload_WithOverwriteFalse_ExistingFile_ShouldThrow() + { + File.WriteAllText(_filePath, "OLD CONTENT"); + + var input = new Input + { + Url = _targetFileAddress, + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + AllowInvalidResponseContentTypeCharSet = true, + Authentication = Authentication.None, + AutomaticCookieHandling = true, + CertificateThumbprint = "", + ClientCertificateFilePath = "", + ClientCertificateInBase64 = "", + ClientCertificateKeyPhrase = "", + ClientCertificateSource = CertificateSource.File, + ConnectionTimeoutSeconds = 60, + FollowRedirects = true, + LoadEntireChainForCertificate = true, + Password = "", + ThrowExceptionOnErrorResponse = true, + Token = "", + Username = "domain\\username", + Overwrite = false + }; + + await Assert.ThrowsExceptionAsync(async () => + await HTTP.DownloadFile(input, options, default)); + } + + [TestMethod] + [ExpectedException(typeof(Exception))] + public async Task TestFileDownload_WindowsAuth_InvalidUsername_ShouldThrow() + { + // Use real HTTP client factory to test username validation + HTTP.ClientFactory = new HttpClientFactory(); + + var input = new Input + { + Url = _targetFileAddress, + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + AllowInvalidResponseContentTypeCharSet = true, + Authentication = Authentication.WindowsAuthentication, + AutomaticCookieHandling = true, + CertificateThumbprint = "", + ClientCertificateFilePath = "", + ClientCertificateInBase64 = "", + ClientCertificateKeyPhrase = "", + ClientCertificateSource = CertificateSource.File, + ConnectionTimeoutSeconds = 60, + FollowRedirects = true, + LoadEntireChainForCertificate = false, + Password = "password", + ThrowExceptionOnErrorResponse = true, + Token = "", + Username = "invalid_username_without_domain" + }; + + await HTTP.DownloadFile(input, options, default); + } + + [TestMethod] + public async Task TestFileDownload_CertificateFromFile_WithoutKeyPhrase() + { + var tp = CertificateHandler(_certificatePath, _privateKeyPassword, false, null); + + var input = new Input + { + Url = _targetFileAddress, + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + AllowInvalidResponseContentTypeCharSet = true, + Authentication = Authentication.ClientCertificate, + AutomaticCookieHandling = true, + CertificateThumbprint = "", + ClientCertificateFilePath = _certificatePath, + ClientCertificateInBase64 = "", + ClientCertificateKeyPhrase = _privateKeyPassword, + ClientCertificateSource = CertificateSource.File, + ConnectionTimeoutSeconds = 60, + FollowRedirects = true, + LoadEntireChainForCertificate = false, + Password = "", + ThrowExceptionOnErrorResponse = true, + Token = "", + Username = "domain\\username" + }; + + var result = await HTTP.DownloadFile(input, options, default); + + Assert.IsNotNull(result); + Assert.IsTrue(result.Success); + + CertificateHandler(_certificatePath, _privateKeyPassword, true, tp); + } + + [TestMethod] + public async Task TestFileDownload_CertificateFromString_WithKeyPhrase() + { + var tp = CertificateHandler(_certificatePath, _privateKeyPassword, false, null); + + var input = new Input + { + Url = _targetFileAddress, + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + AllowInvalidResponseContentTypeCharSet = true, + Authentication = Authentication.ClientCertificate, + AutomaticCookieHandling = true, + CertificateThumbprint = "", + ClientCertificateFilePath = "", + ClientCertificateInBase64 = Convert.ToBase64String(File.ReadAllBytes(_certificatePath)), + ClientCertificateKeyPhrase = _privateKeyPassword, + ClientCertificateSource = CertificateSource.String, + ConnectionTimeoutSeconds = 60, + FollowRedirects = true, + LoadEntireChainForCertificate = false, + Password = "", + ThrowExceptionOnErrorResponse = true, + Token = "", + Username = "domain\\username" + }; + + var result = await HTTP.DownloadFile(input, options, default); + + Assert.IsNotNull(result); + Assert.IsTrue(result.Success); + + CertificateHandler(_certificatePath, _privateKeyPassword, true, tp); + } + + [TestMethod] + public async Task TestFileDownload_WithNullHeaders() + { + var input = new Input + { + Url = _targetFileAddress, + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + AllowInvalidResponseContentTypeCharSet = true, + Authentication = Authentication.Basic, + AutomaticCookieHandling = true, + CertificateThumbprint = "", + ClientCertificateFilePath = "", + ClientCertificateInBase64 = "", + ClientCertificateKeyPhrase = "", + ClientCertificateSource = CertificateSource.File, + ConnectionTimeoutSeconds = 60, + FollowRedirects = true, + LoadEntireChainForCertificate = false, + Password = "password", + ThrowExceptionOnErrorResponse = false, + Token = "", + Username = "domain\\username" + }; + + var result = await HTTP.DownloadFile(input, options, default); + + Assert.IsNotNull(result); + Assert.IsTrue(result.Success); + } + + [TestMethod] + public async Task TestFileDownload_CachedClient() + { + var input = new Input + { + Url = _targetFileAddress, + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + AllowInvalidResponseContentTypeCharSet = true, + Authentication = Authentication.None, + AutomaticCookieHandling = true, + CertificateThumbprint = "", + ClientCertificateFilePath = "", + ClientCertificateInBase64 = "", + ClientCertificateKeyPhrase = "", + ClientCertificateSource = CertificateSource.File, + ConnectionTimeoutSeconds = 60, + FollowRedirects = true, + LoadEntireChainForCertificate = false, + Password = "", + ThrowExceptionOnErrorResponse = false, + Token = "", + Username = "" + }; + + // First request creates client + var result1 = await HTTP.DownloadFile(input, options, default); + Assert.IsTrue(result1.Success); + + // Cleanup and recreate directory for second request + Cleanup(); + Directory.CreateDirectory(_directory); + + // Second request should use cached client + var result2 = await HTTP.DownloadFile(input, options, default); + Assert.IsTrue(result2.Success); + } } \ No newline at end of file diff --git a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/CertificateStoreLocation.cs b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/CertificateStoreLocation.cs new file mode 100644 index 0000000..83cfeb7 --- /dev/null +++ b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/CertificateStoreLocation.cs @@ -0,0 +1,16 @@ +namespace Frends.HTTP.DownloadFile.Definitions; + +/// +/// Certificate store location. +/// +public enum CertificateStoreLocation +{ + /// + /// The X.509 certificate store assigned to the current user. + /// + CurrentUser, + /// + /// The X.509 certificate store assigned to the local machine. + /// + LocalMachine +} diff --git a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/Options.cs b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/Options.cs index d2d70a0..9bb7b41 100644 --- a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/Options.cs +++ b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/Options.cs @@ -81,6 +81,15 @@ public class Options [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] public string CertificateThumbprint { get; set; } + /// + /// Applicable only when Certificate Source is "CertificateStore". + /// Store location for the certificate. + /// + /// CurrentUser + [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] + [DefaultValue(CertificateStoreLocation.CurrentUser)] + public CertificateStoreLocation CertificateStoreLocation { get; set; } + /// /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source /// diff --git a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Extensions.cs b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Extensions.cs index 3ab6b87..1bc35f0 100644 --- a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Extensions.cs +++ b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Extensions.cs @@ -54,7 +54,7 @@ private static X509Certificate[] GetCertificates(Options options) { case CertificateSource.CertificateStore: var thumbprint = options.CertificateThumbprint; - certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate); + certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate, options.CertificateStoreLocation); break; case CertificateSource.File: certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, options.ClientCertificateKeyPhrase); @@ -94,14 +94,21 @@ private static X509Certificate2[] GetCertificatesFromFile(string clientCertifica } private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, - bool loadEntireChain) + bool loadEntireChain, CertificateStoreLocation storeLocation) { thumbprint = Regex.Replace(thumbprint, @"[^\da-zA-z]", string.Empty).ToUpper(); - using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + var location = storeLocation == CertificateStoreLocation.CurrentUser + ? StoreLocation.CurrentUser + : StoreLocation.LocalMachine; + var locationText = storeLocation == CertificateStoreLocation.CurrentUser + ? "current user" + : "local machine"; + + using var store = new X509Store(StoreName.My, location); store.Open(OpenFlags.ReadOnly); var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); if (signingCert.Count == 0) - throw new FileNotFoundException($"Certificate with thumbprint: '{thumbprint}' not found in current user cert store."); + throw new FileNotFoundException($"Certificate with thumbprint: '{thumbprint}' not found in {locationText} cert store."); var certificate = signingCert[0]; @@ -114,7 +121,7 @@ private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, // include the whole chain var certificates = chain - .ChainElements.Cast() + .ChainElements .Select(c => c.Certificate) .OrderByDescending(c => c.HasPrivateKey) .ToArray(); diff --git a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.csproj b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.csproj index 524cdd5..a870dfb 100644 --- a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.csproj +++ b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.csproj @@ -2,7 +2,7 @@ net6.0 - 1.3.0 + 1.4.0 Frends Frends Frends diff --git a/Frends.HTTP.Request/CHANGELOG.md b/Frends.HTTP.Request/CHANGELOG.md index 6b27b00..e9a5705 100644 --- a/Frends.HTTP.Request/CHANGELOG.md +++ b/Frends.HTTP.Request/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.6.0] - 2025-11-18 +### Added +- Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when using certificate authentication. + ## [1.5.0] - 2025-10-03 ### Changed - Changed default return format from String to JToken diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/CertificateStoreLocation.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/CertificateStoreLocation.cs new file mode 100644 index 0000000..7cbf8cb --- /dev/null +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/CertificateStoreLocation.cs @@ -0,0 +1,16 @@ +namespace Frends.HTTP.Request.Definitions; + +/// +/// Certificate store location. +/// +public enum CertificateStoreLocation +{ + /// + /// The X.509 certificate store assigned to the current user. + /// + CurrentUser, + /// + /// The X.509 certificate store assigned to the local machine. + /// + LocalMachine +} diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs index 27d1ccd..73f27d4 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs @@ -80,6 +80,15 @@ public class Options [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] public string CertificateThumbprint { get; set; } + /// + /// Applicable only when Certificate Source is "CertificateStore". + /// Store location for the certificate. + /// + /// CurrentUser + [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] + [DefaultValue(CertificateStoreLocation.CurrentUser)] + public CertificateStoreLocation CertificateStoreLocation { get; set; } + /// /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source /// diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Extensions.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Extensions.cs index 5c41542..ba4efdf 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Extensions.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Extensions.cs @@ -61,7 +61,7 @@ private static X509Certificate[] GetCertificates(Options options) { case CertificateSource.CertificateStore: var thumbprint = options.CertificateThumbprint; - certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate); + certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate, options.CertificateStoreLocation); break; case CertificateSource.File: certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, options.ClientCertificateKeyPhrase); @@ -105,17 +105,24 @@ private static X509Certificate2[] GetCertificatesFromFile(string clientCertifica } private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, - bool loadEntireChain) + bool loadEntireChain, CertificateStoreLocation storeLocation) { thumbprint = Regex.Replace(thumbprint, @"[^\da-zA-z]", string.Empty).ToUpper(); - using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + var location = storeLocation == CertificateStoreLocation.CurrentUser + ? StoreLocation.CurrentUser + : StoreLocation.LocalMachine; + var locationText = storeLocation == CertificateStoreLocation.CurrentUser + ? "current user" + : "local machine"; + + using (var store = new X509Store(StoreName.My, location)) { store.Open(OpenFlags.ReadOnly); var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); if (signingCert.Count == 0) { throw new FileNotFoundException( - $"Certificate with thumbprint: '{thumbprint}' not found in current user cert store."); + $"Certificate with thumbprint: '{thumbprint}' not found in {locationText} cert store."); } var certificate = signingCert[0]; diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj index 382e8e1..3eb843e 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj @@ -2,7 +2,7 @@ net6.0 - 1.5.0 + 1.6.0 Frends Frends Frends diff --git a/Frends.HTTP.RequestBytes/CHANGELOG.md b/Frends.HTTP.RequestBytes/CHANGELOG.md index d03e15b..e972dc9 100644 --- a/Frends.HTTP.RequestBytes/CHANGELOG.md +++ b/Frends.HTTP.RequestBytes/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.4.0] - 2025-11-18 +### Added +- Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when using certificate authentication. + ## [1.3.0] - 2025-10-10 ### Changed - Changed the return type of RequestBytes from Task to Task diff --git a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/CertificateStoreLocation.cs b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/CertificateStoreLocation.cs new file mode 100644 index 0000000..cdeadc7 --- /dev/null +++ b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/CertificateStoreLocation.cs @@ -0,0 +1,16 @@ +namespace Frends.HTTP.RequestBytes.Definitions; + +/// +/// Certificate store location. +/// +public enum CertificateStoreLocation +{ + /// + /// The X.509 certificate store assigned to the current user. + /// + CurrentUser, + /// + /// The X.509 certificate store assigned to the local machine. + /// + LocalMachine +} diff --git a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/Options.cs b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/Options.cs index c1df7ed..eb3953f 100644 --- a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/Options.cs +++ b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/Options.cs @@ -80,6 +80,15 @@ public class Options [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] public string CertificateThumbprint { get; set; } + /// + /// Applicable only when Certificate Source is "CertificateStore". + /// Store location for the certificate. + /// + /// CurrentUser + [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] + [DefaultValue(CertificateStoreLocation.CurrentUser)] + public CertificateStoreLocation CertificateStoreLocation { get; set; } + /// /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source /// diff --git a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Extensions.cs b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Extensions.cs index d73fc0b..48b4807 100644 --- a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Extensions.cs +++ b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Extensions.cs @@ -61,7 +61,7 @@ private static X509Certificate[] GetCertificates(Options options) { case CertificateSource.CertificateStore: var thumbprint = options.CertificateThumbprint; - certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate); + certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate, options.CertificateStoreLocation); break; case CertificateSource.File: certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, options.ClientCertificateKeyPhrase); @@ -105,17 +105,24 @@ private static X509Certificate2[] GetCertificatesFromFile(string clientCertifica } private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, - bool loadEntireChain) + bool loadEntireChain, CertificateStoreLocation storeLocation) { thumbprint = Regex.Replace(thumbprint, @"[^\da-zA-z]", string.Empty).ToUpper(); - using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + var location = storeLocation == CertificateStoreLocation.CurrentUser + ? StoreLocation.CurrentUser + : StoreLocation.LocalMachine; + var locationText = storeLocation == CertificateStoreLocation.CurrentUser + ? "current user" + : "local machine"; + + using (var store = new X509Store(StoreName.My, location)) { store.Open(OpenFlags.ReadOnly); var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); if (signingCert.Count == 0) { throw new FileNotFoundException( - $"Certificate with thumbprint: '{thumbprint}' not found in current user cert store."); + $"Certificate with thumbprint: '{thumbprint}' not found in {locationText} cert store."); } var certificate = signingCert[0]; diff --git a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes.csproj b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes.csproj index 8e84d7a..4d7743f 100644 --- a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes.csproj +++ b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes.csproj @@ -2,7 +2,7 @@ net6.0 - 1.3.0 + 1.4.0 Frends Frends Frends diff --git a/Frends.HTTP.SendAndReceiveBytes/CHANGELOG.md b/Frends.HTTP.SendAndReceiveBytes/CHANGELOG.md index 3f1633e..a3ea9bc 100644 --- a/Frends.HTTP.SendAndReceiveBytes/CHANGELOG.md +++ b/Frends.HTTP.SendAndReceiveBytes/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.4.0] - 2025-11-18 +### Added +- Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when using certificate authentication. + ## [1.3.0] - 2025-10-10 ### Changed - Changed the return type of SendAndReceiveBytes from Task to Task diff --git a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/CertificateStoreLocation.cs b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/CertificateStoreLocation.cs new file mode 100644 index 0000000..e225084 --- /dev/null +++ b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/CertificateStoreLocation.cs @@ -0,0 +1,16 @@ +namespace Frends.HTTP.SendAndReceiveBytes.Definitions; + +/// +/// Certificate store location. +/// +public enum CertificateStoreLocation +{ + /// + /// The X.509 certificate store assigned to the current user. + /// + CurrentUser, + /// + /// The X.509 certificate store assigned to the local machine. + /// + LocalMachine +} diff --git a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/Options.cs b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/Options.cs index 0a3e108..22e4c4b 100644 --- a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/Options.cs +++ b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/Options.cs @@ -83,6 +83,15 @@ public class Options [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] public string CertificateThumbprint { get; set; } + /// + /// Applicable only when Certificate Source is "CertificateStore". + /// Store location for the certificate. + /// + /// CurrentUser + [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] + [DefaultValue(CertificateStoreLocation.CurrentUser)] + public CertificateStoreLocation CertificateStoreLocation { get; set; } + /// /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source /// diff --git a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Extensions.cs b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Extensions.cs index 308f16d..8340723 100644 --- a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Extensions.cs +++ b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Extensions.cs @@ -59,7 +59,7 @@ private static X509Certificate[] GetCertificates(Options options) { case CertificateSource.CertificateStore: var thumbprint = options.CertificateThumbprint; - certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate); + certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate, options.CertificateStoreLocation); break; case CertificateSource.File: certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, options.ClientCertificateKeyPhrase); @@ -103,17 +103,24 @@ private static X509Certificate2[] GetCertificatesFromFile(string clientCertifica } private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, - bool loadEntireChain) + bool loadEntireChain, CertificateStoreLocation storeLocation) { thumbprint = Regex.Replace(thumbprint, @"[^\da-zA-z]", string.Empty).ToUpper(); - using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + var location = storeLocation == CertificateStoreLocation.CurrentUser + ? StoreLocation.CurrentUser + : StoreLocation.LocalMachine; + var locationText = storeLocation == CertificateStoreLocation.CurrentUser + ? "current user" + : "local machine"; + + using (var store = new X509Store(StoreName.My, location)) { store.Open(OpenFlags.ReadOnly); var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); if (signingCert.Count == 0) { throw new FileNotFoundException( - $"Certificate with thumbprint: '{thumbprint}' not found in current user cert store."); + $"Certificate with thumbprint: '{thumbprint}' not found in {locationText} cert store."); } var certificate = signingCert[0]; diff --git a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes.csproj b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes.csproj index 21b2249..1d53546 100644 --- a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes.csproj +++ b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes.csproj @@ -2,7 +2,7 @@ net6.0 - 1.3.0 + 1.4.0 Frends Frends Frends diff --git a/Frends.HTTP.SendBytes/CHANGELOG.md b/Frends.HTTP.SendBytes/CHANGELOG.md index f633c78..fac8fe5 100644 --- a/Frends.HTTP.SendBytes/CHANGELOG.md +++ b/Frends.HTTP.SendBytes/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.5.0] - 2025-11-18 +### Added +- Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when using certificate authentication. + ## [1.4.0] - 2025-10-10 ### Changed - Changed the return type of SendBytes from Task to Task diff --git a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/CertificateStoreLocation.cs b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/CertificateStoreLocation.cs new file mode 100644 index 0000000..56069bd --- /dev/null +++ b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/CertificateStoreLocation.cs @@ -0,0 +1,16 @@ +namespace Frends.HTTP.SendBytes.Definitions; + +/// +/// Certificate store location. +/// +public enum CertificateStoreLocation +{ + /// + /// The X.509 certificate store assigned to the current user. + /// + CurrentUser, + /// + /// The X.509 certificate store assigned to the local machine. + /// + LocalMachine +} diff --git a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/Options.cs b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/Options.cs index 42ca4ab..2ff9451 100644 --- a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/Options.cs +++ b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/Options.cs @@ -80,6 +80,15 @@ public class Options [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] public string CertificateThumbprint { get; set; } + /// + /// Applicable only when Certificate Source is "CertificateStore". + /// Store location for the certificate. + /// + /// CurrentUser + [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] + [DefaultValue(CertificateStoreLocation.CurrentUser)] + public CertificateStoreLocation CertificateStoreLocation { get; set; } + /// /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source /// diff --git a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Extensions.cs b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Extensions.cs index 2602daa..3adee62 100644 --- a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Extensions.cs +++ b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Extensions.cs @@ -59,7 +59,7 @@ private static X509Certificate[] GetCertificates(Options options) { case CertificateSource.CertificateStore: var thumbprint = options.CertificateThumbprint; - certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate); + certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate, options.CertificateStoreLocation); break; case CertificateSource.File: certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, options.ClientCertificateKeyPhrase); @@ -103,17 +103,24 @@ private static X509Certificate2[] GetCertificatesFromFile(string clientCertifica } private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, - bool loadEntireChain) + bool loadEntireChain, CertificateStoreLocation storeLocation) { thumbprint = Regex.Replace(thumbprint, @"[^\da-zA-z]", string.Empty).ToUpper(); - using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + var location = storeLocation == CertificateStoreLocation.CurrentUser + ? StoreLocation.CurrentUser + : StoreLocation.LocalMachine; + var locationText = storeLocation == CertificateStoreLocation.CurrentUser + ? "current user" + : "local machine"; + + using (var store = new X509Store(StoreName.My, location)) { store.Open(OpenFlags.ReadOnly); var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); if (signingCert.Count == 0) { throw new FileNotFoundException( - $"Certificate with thumbprint: '{thumbprint}' not found in current user cert store."); + $"Certificate with thumbprint: '{thumbprint}' not found in {locationText} cert store."); } var certificate = signingCert[0]; diff --git a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes.csproj b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes.csproj index e0f3a22..781c5c0 100644 --- a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes.csproj +++ b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes.csproj @@ -2,7 +2,7 @@ net6.0 - 1.4.0 + 1.5.0 Frends Frends Frends