diff --git a/Directory.Build.Props b/Directory.Build.Props index ccba0b2..9e34539 100644 --- a/Directory.Build.Props +++ b/Directory.Build.Props @@ -5,6 +5,7 @@ Folkehelseinstituttet true true + NU1901;NU1902;NU1903;NU1904 \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index a118a84..d5b9a6f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,8 @@ + + diff --git a/Fhi.Security/src/Fhi.Security.Cryptography/Keys/Certificate.cs b/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/Certificate.cs similarity index 100% rename from Fhi.Security/src/Fhi.Security.Cryptography/Keys/Certificate.cs rename to Fhi.Security/src/Fhi.Security.Cryptography/Certificates/Certificate.cs diff --git a/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/CertificateConfigurationExtensions.cs b/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/CertificateConfigurationExtensions.cs new file mode 100644 index 0000000..fe9550c --- /dev/null +++ b/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/CertificateConfigurationExtensions.cs @@ -0,0 +1,65 @@ +using System.Security.Cryptography.X509Certificates; +using Fhi.Security.Cryptography.Certificates; + +namespace Microsoft.Extensions.Configuration +{ + /// + /// Extension methods for loading private keys from a certificate store into IConfiguration. + /// + public static class CertificateConfigurationExtensions + { + /// + /// Reads a private key from the Windows certificate store and injects it as a PEM string + /// at in IConfiguration. + /// + /// + /// name of config parameter + /// Certificate thumbprint + /// Intended use of private key + /// Certificate location + /// The Certificate store to lead the certificate from + /// To implement another certificate store + /// + /// Optional callback invoked when a certificate is skipped (not found, expired, missing private key, etc.). + /// Use this to connect to your application's logger: + /// onWarning: d => logger.LogWarning("Certificate skipped [{Key}] {Id}: {Reason}", d.ConfigKey, d.Identifier, d.Reason) + /// + public static IConfigurationBuilder AddPrivateKeyFromCertificateStore( + this IConfigurationBuilder builder, + string configKey, + string thumbprint, + CertificateKeyUse keyUse = CertificateKeyUse.Signing, + StoreLocation storeLocation = StoreLocation.CurrentUser, + StoreName storeName = StoreName.My, + ICertificateStore? certStore = null, + Action? onValidationError = null) + { + var store = certStore ?? new WindowsCertificateStore(storeName, storeLocation); + var source = new PrivateKeyCertificateStoreConfigurationSource(store, onValidationError); + source.Add(configKey, thumbprint, keyUse); + return builder.Add(source); + } + + /// + /// Can reads multiple private keys from a certificate store into IConfiguration. + /// Use to map config keys to certificate identifiers. + /// + /// + /// + /// + /// + /// Optional callback invoked when a certificate is skipped. See single-entry overload for usage example. + /// + public static IConfigurationBuilder AddPrivateKeyFromCertificateStore( + this IConfigurationBuilder builder, + Action configure, + ICertificateStore? certStore = null, + Action? onWarning = null) + { + var store = certStore ?? new WindowsCertificateStore(); + var source = new PrivateKeyCertificateStoreConfigurationSource(store, onWarning); + configure(source); + return builder.Add(source); + } + } +} diff --git a/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/ICertificateStore.cs b/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/ICertificateStore.cs new file mode 100644 index 0000000..e1edff4 --- /dev/null +++ b/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/ICertificateStore.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography.X509Certificates; + +namespace Fhi.Security.Cryptography.Certificates +{ + /// + /// Abstraction over a certificate source (Windows certificate store, file system, etc.). + /// + public interface ICertificateStore + { + /// + /// Returns the certificate for the given identifier, or null if not found. + /// The caller is responsible for disposing the returned certificate. + /// + X509Certificate2? GetCertificate(string identifier); + } + + /// + /// Intended use of the private key extracted from the certificate. + /// + public enum CertificateKeyUse + { + /// Key is used for signing (e.g. client assertion JWTs). Requires DigitalSignature KeyUsage. + Signing, + /// Key is used for decrypting data at rest or in transit. Requires KeyEncipherment KeyUsage. + Encryption + } + + /// + /// Describes why a certificate entry was skipped during configuration load. + /// Passed to the onValidationError callback on . + /// + public record CertificateLoadDiagnostic(string ConfigKey, string Identifier, string Reason); + + internal record CertificateSecretEntry( + string ConfigKey, + string Identifier, + CertificateKeyUse KeyUse); +} diff --git a/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/PrivateKeyCertificateStoreConfigurationProvider.cs b/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/PrivateKeyCertificateStoreConfigurationProvider.cs new file mode 100644 index 0000000..4f9054f --- /dev/null +++ b/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/PrivateKeyCertificateStoreConfigurationProvider.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; + +namespace Fhi.Security.Cryptography.Certificates +{ + internal sealed class PrivateKeyCertificateStoreConfigurationProvider( + IReadOnlyList entries, + ICertificateStore certificateStore, + Action? onValidationError = null) : ConfigurationProvider + { + public override void Load() + { + var utcNow = TimeProvider.System.GetUtcNow().UtcDateTime; + + foreach (var entry in entries) + { + try + { + var cert = certificateStore.GetCertificate(entry.Identifier); + if (cert != null) + { + var validationReason = Validate(cert, entry.KeyUse, utcNow); + if (validationReason != null) + { + Diagnostic(entry, validationReason); + continue; + } + + Data[entry.ConfigKey] = cert.GetRSAPrivateKey()?.ExportRSAPrivateKeyPem(); + } + Diagnostic(entry, "Certificate not found in store."); + + } + catch (Exception ex) + { + Diagnostic(entry, $"Unexpected error: {ex.Message}"); + } + } + } + + private void Diagnostic(CertificateSecretEntry entry, string reason) + => onValidationError?.Invoke(new CertificateLoadDiagnostic(entry.ConfigKey, entry.Identifier, reason)); + + private static string? Validate(X509Certificate2 cert, CertificateKeyUse keyUse, DateTime utcNow) + { + if (!cert.HasPrivateKey && cert.GetRSAPrivateKey() == null) + return "Certificate does not contain a supported private key algorithm (RSA expected)."; + if (cert.NotAfter.ToUniversalTime() < utcNow) + return $"Certificate expired on {cert.NotAfter:yyyy-MM-dd}."; + if (cert.NotBefore.ToUniversalTime() > utcNow) + return $"Certificate is not valid until {cert.NotBefore:yyyy-MM-dd}."; + + var keyUsageExt = cert.Extensions.OfType().FirstOrDefault(); + if (keyUsageExt != null) + { + var required = keyUse == CertificateKeyUse.Signing + ? X509KeyUsageFlags.DigitalSignature + : X509KeyUsageFlags.KeyEncipherment; + + if (!keyUsageExt.KeyUsages.HasFlag(required)) + return $"Certificate is missing required KeyUsage: {required}."; + } + + return null; + } + } +} diff --git a/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/PrivateKeyCertificateStoreConfigurationSource.cs b/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/PrivateKeyCertificateStoreConfigurationSource.cs new file mode 100644 index 0000000..725b202 --- /dev/null +++ b/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/PrivateKeyCertificateStoreConfigurationSource.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Configuration; + +namespace Fhi.Security.Cryptography.Certificates +{ + /// + /// IConfigurationSource that loads private keys from a certificate store. + /// Entries are added via . + /// + public class PrivateKeyCertificateStoreConfigurationSource( + ICertificateStore certificateStore, + Action? onWarning = null) : IConfigurationSource + { + private readonly List _entries = new(); + + /// Maps to a certificate by . + public PrivateKeyCertificateStoreConfigurationSource Add( + string configKey, + string identifier, + CertificateKeyUse keyUse = CertificateKeyUse.Signing) + { + _entries.Add(new(configKey, identifier, keyUse)); + return this; + } + + public IConfigurationProvider Build(IConfigurationBuilder builder) + => new PrivateKeyCertificateStoreConfigurationProvider(_entries.AsReadOnly(), certificateStore, onWarning); + } +} diff --git a/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/WindowsCertificateStore.cs b/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/WindowsCertificateStore.cs new file mode 100644 index 0000000..91cd3c0 --- /dev/null +++ b/Fhi.Security/src/Fhi.Security.Cryptography/Certificates/WindowsCertificateStore.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography.X509Certificates; + +namespace Fhi.Security.Cryptography.Certificates +{ + /// + /// Resolves certificates from the Windows certificate store by thumbprint. + /// + internal class WindowsCertificateStore( + StoreName storeName = StoreName.My, + StoreLocation storeLocation = StoreLocation.CurrentUser) : ICertificateStore + { + public X509Certificate2? GetCertificate(string identifier) + { + using var store = new X509Store(storeName, storeLocation); + store.Open(OpenFlags.ReadOnly); + var matches = store.Certificates.Find(X509FindType.FindByThumbprint, identifier, validOnly: false); + return matches.Count == 0 ? null : matches[0]; + } + } +} diff --git a/Fhi.Security/src/Fhi.Security.Cryptography/Fhi.Security.Cryptography.csproj b/Fhi.Security/src/Fhi.Security.Cryptography/Fhi.Security.Cryptography.csproj index d96d30e..503c235 100644 --- a/Fhi.Security/src/Fhi.Security.Cryptography/Fhi.Security.Cryptography.csproj +++ b/Fhi.Security/src/Fhi.Security.Cryptography/Fhi.Security.Cryptography.csproj @@ -5,6 +5,8 @@ enable + + diff --git a/Fhi.Security/src/Fhi.Security.Cryptography/Keys/Jwk.cs b/Fhi.Security/src/Fhi.Security.Cryptography/Jwks/Jwk.cs similarity index 100% rename from Fhi.Security/src/Fhi.Security.Cryptography/Keys/Jwk.cs rename to Fhi.Security/src/Fhi.Security.Cryptography/Jwks/Jwk.cs diff --git a/Fhi.Security/src/Fhi.Security.Cryptography/Keys/Serialization/JsonWebKeySerializerOverrider.cs b/Fhi.Security/src/Fhi.Security.Cryptography/Serialization/JsonWebKeySerializerOverrider.cs similarity index 100% rename from Fhi.Security/src/Fhi.Security.Cryptography/Keys/Serialization/JsonWebKeySerializerOverrider.cs rename to Fhi.Security/src/Fhi.Security.Cryptography/Serialization/JsonWebKeySerializerOverrider.cs diff --git a/Fhi.Security/tests/Fhi.Security.Cryptography.UnitTests/Certificate/CertificateConfigurationTests.cs b/Fhi.Security/tests/Fhi.Security.Cryptography.UnitTests/Certificate/CertificateConfigurationTests.cs new file mode 100644 index 0000000..36d422f --- /dev/null +++ b/Fhi.Security/tests/Fhi.Security.Cryptography.UnitTests/Certificate/CertificateConfigurationTests.cs @@ -0,0 +1,183 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Fhi.Security.Cryptography.Certificates; +using Fhi.Security.Cryptography.UnitTests.Setup; +using Microsoft.Extensions.Configuration; + +namespace Fhi.Security.Cryptography.UnitTests.Certificate +{ + public class CertificateConfigurationTests + { + [Test] + public void GIVEN_addPrivateKeyFromCertificateStore_WHEN_thumbprintNotFound_THEN_configKeyIsAbsent() + { + var warnings = new List(); + var config = new ConfigurationBuilder() + .AddPrivateKeyFromCertificateStore("MyClient:PrivateKey", "AABBCCDD", + certStore: new InMemoryCertificateStoreMock(), + onValidationError: warnings.Add) + .Build(); + + Assert.That(config["MyClient:PrivateKey"], Is.Null); + Assert.That(warnings, Has.Count.EqualTo(1)); + Assert.That(warnings[0].Reason, Does.Contain("Certificate not found in store.")); + } + + [Test] + public void GIVEN_addPrivateKeyFromCertificateStore_WHEN_certificateHasNoPrivateKey_THEN_configKeyIsAbsentAndWarningIsRaised() + { + var keyPair = Certificates.Certificate.CreateAsymmetricKeyPair("CN", "PWD", 1); + using var publicOnlyCert = X509Certificate2.CreateFromPem(keyPair.CertificatePublicKey); + + var store = new InMemoryCertificateStoreMock(); + store.Add("thumb", publicOnlyCert); + + var warnings = new List(); + var config = new ConfigurationBuilder() + .AddPrivateKeyFromCertificateStore("MyClient:PrivateKey", "thumb", + certStore: store, onValidationError: warnings.Add) + .Build(); + + Assert.That(config["MyClient:PrivateKey"], Is.Null); + Assert.That(warnings, Has.Count.EqualTo(1)); + Assert.That(warnings[0].Reason, Does.Contain("private key")); + } + + [Test] + public void GIVEN_addPrivateKeyFromCertificateStore_WHEN_certificateFoundAndIsValid_THEN_privateKeyIsPopulated() + { + var keyPair = Certificates.Certificate.CreateAsymmetricKeyPair("CN", "PWD", 1); + using var cert = X509CertificateLoader.LoadPkcs12(keyPair.CertificatePrivateKey.ToArray(), "PWD", X509KeyStorageFlags.Exportable); + + var store = new InMemoryCertificateStoreMock(); + store.Add("any-thumb", cert); + + var config = new ConfigurationBuilder() + .AddPrivateKeyFromCertificateStore("MyClient:PrivateKey", "any-thumb", certStore: store) + .Build(); + + Assert.That(config["MyClient:PrivateKey"], Is.Not.Null.And.Not.Empty); + Assert.That(config["MyClient:PrivateKey"], Does.StartWith("-----BEGIN RSA")); + } + + [Test] + public void GIVEN_addPrivateKeyFromCertificateStore_WHEN_certificateIsExpired_THEN_configKeyIsAbsentAndWarningIsRaised() + { + using var cert = CreateCertWithValidity(DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddDays(-1)); + var store = new InMemoryCertificateStoreMock(); + store.Add("thumb", cert); + + var warnings = new List(); + var config = new ConfigurationBuilder() + .AddPrivateKeyFromCertificateStore("MyClient:PrivateKey", "thumb", + certStore: store, onValidationError: warnings.Add) + .Build(); + + Assert.That(config["MyClient:PrivateKey"], Is.Null); + Assert.That(warnings, Has.Count.EqualTo(1)); + Assert.That(warnings[0].Reason, Does.Contain("expired")); + } + + [Test] + public void GIVEN_addPrivateKeyFromCertificateStore_WHEN_certificateIsNotYetValid_THEN_configKeyIsAbsentAndWarningIsRaised() + { + using var cert = CreateCertWithValidity(DateTimeOffset.UtcNow.AddDays(1), DateTimeOffset.UtcNow.AddDays(30)); + var store = new InMemoryCertificateStoreMock(); + store.Add("thumb", cert); + + var warnings = new List(); + var config = new ConfigurationBuilder() + .AddPrivateKeyFromCertificateStore("MyClient:PrivateKey", "thumb", + certStore: store, onValidationError: warnings.Add) + .Build(); + + Assert.That(config["MyClient:PrivateKey"], Is.Null); + Assert.That(warnings, Has.Count.EqualTo(1)); + Assert.That(warnings[0].Reason, Does.Contain("not valid until")); + } + + [Test] + public void GIVEN_addPrivateKeyFromCertificateStore_WHEN_certificateKeyUsageDoesNotAllowSigning_THEN_configKeyIsAbsentAndWarningIsRaised() + { + using var cert = CreateCertWithKeyUsage(X509KeyUsageFlags.KeyEncipherment); + var store = new InMemoryCertificateStoreMock(); + store.Add("thumb", cert); + + var warnings = new List(); + var config = new ConfigurationBuilder() + .AddPrivateKeyFromCertificateStore("MyClient:PrivateKey", "thumb", + keyUse: CertificateKeyUse.Signing, + certStore: store, onValidationError: warnings.Add) + .Build(); + + Assert.That(config["MyClient:PrivateKey"], Is.Null); + Assert.That(warnings, Has.Count.EqualTo(1)); + Assert.That(warnings[0].Reason, Does.Contain("KeyUsage")); + } + + [Test] + public void GIVEN_addPrivateKeyFromCertificateStore_WHEN_certificateKeyUsageDoesNotAllowEncryption_THEN_configKeyIsAbsentAndWarningIsRaised() + { + using var cert = CreateCertWithKeyUsage(X509KeyUsageFlags.DigitalSignature); + var store = new InMemoryCertificateStoreMock(); + store.Add("thumb", cert); + + var warnings = new List(); + var config = new ConfigurationBuilder() + .AddPrivateKeyFromCertificateStore("MyClient:PrivateKey", "thumb", + keyUse: CertificateKeyUse.Encryption, + certStore: store, onValidationError: warnings.Add) + .Build(); + + Assert.That(config["MyClient:PrivateKey"], Is.Null); + Assert.That(warnings, Has.Count.EqualTo(1)); + Assert.That(warnings[0].Reason, Does.Contain("KeyUsage")); + } + + [Test] + public void GIVEN_addPrivateKeyFromCertificateStore_WHEN_multipleEntries_THEN_allConfigKeysArePopulated() + { + var keyPairA = Certificates.Certificate.CreateAsymmetricKeyPair("CN=A", "PWD", 1); + var keyPairB = Certificates.Certificate.CreateAsymmetricKeyPair("CN=B", "PWD", 1); + + using var certA = X509CertificateLoader.LoadPkcs12(keyPairA.CertificatePrivateKey.ToArray(), "PWD", X509KeyStorageFlags.Exportable); + using var certB = X509CertificateLoader.LoadPkcs12(keyPairB.CertificatePrivateKey.ToArray(), "PWD", X509KeyStorageFlags.Exportable); + + var store = new InMemoryCertificateStoreMock(); + store.Add("AABBCCDD", certA); + store.Add("EEFF0011", certB); + + var config = new ConfigurationBuilder() + .AddPrivateKeyFromCertificateStore(source => + { + source.Add("ClientA:PrivateKey", "AABBCCDD"); + source.Add("ClientB:PrivateKey", "EEFF0011"); + }, certStore: store) + .Build(); + + Assert.Multiple(() => + { + Assert.That(config["ClientA:PrivateKey"], Is.Not.Null.And.Not.Empty); + Assert.That(config["ClientA:PrivateKey"], Does.StartWith("-----BEGIN RSA")); + Assert.That(config["ClientB:PrivateKey"], Is.Not.Null.And.Not.Empty); + Assert.That(config["ClientB:PrivateKey"], Does.StartWith("-----BEGIN RSA")); + }); + } + private static X509Certificate2 CreateCertWithValidity(DateTimeOffset notBefore, DateTimeOffset notAfter) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=Test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var pfx = request.CreateSelfSigned(notBefore, notAfter).Export(X509ContentType.Pfx); + return X509CertificateLoader.LoadPkcs12(pfx, null, X509KeyStorageFlags.Exportable); + } + + private static X509Certificate2 CreateCertWithKeyUsage(X509KeyUsageFlags flags) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=Test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509KeyUsageExtension(flags, critical: true)); + var pfx = request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)).Export(X509ContentType.Pfx); + return X509CertificateLoader.LoadPkcs12(pfx, null, X509KeyStorageFlags.Exportable); + } + } +} diff --git a/Fhi.Security/tests/Fhi.Security.Cryptography.UnitTests/Fhi.Security.Cryptography.UnitTests.csproj b/Fhi.Security/tests/Fhi.Security.Cryptography.UnitTests/Fhi.Security.Cryptography.UnitTests.csproj index 4cb2efd..56e762c 100644 --- a/Fhi.Security/tests/Fhi.Security.Cryptography.UnitTests/Fhi.Security.Cryptography.UnitTests.csproj +++ b/Fhi.Security/tests/Fhi.Security.Cryptography.UnitTests/Fhi.Security.Cryptography.UnitTests.csproj @@ -13,6 +13,7 @@ + diff --git a/Fhi.Security/tests/Fhi.Security.Cryptography.UnitTests/Setup/InMemoryCertificateStoreMock.cs b/Fhi.Security/tests/Fhi.Security.Cryptography.UnitTests/Setup/InMemoryCertificateStoreMock.cs new file mode 100644 index 0000000..7ef975e --- /dev/null +++ b/Fhi.Security/tests/Fhi.Security.Cryptography.UnitTests/Setup/InMemoryCertificateStoreMock.cs @@ -0,0 +1,17 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography.X509Certificates; +using Fhi.Security.Cryptography.Certificates; + +namespace Fhi.Security.Cryptography.UnitTests.Setup +{ + internal class InMemoryCertificateStoreMock : ICertificateStore + { + private readonly ConcurrentDictionary _certificates = new(); + + public void Add(string identifier, X509Certificate2 certificate) + => _certificates[identifier] = certificate; + + public X509Certificate2? GetCertificate(string identifier) + => _certificates.TryGetValue(identifier, out var cert) ? cert : null; + } +} diff --git a/docs/Cryptography/Tutorials/certificate-store-private-key.md b/docs/Cryptography/Tutorials/certificate-store-private-key.md new file mode 100644 index 0000000..dc0bb0f --- /dev/null +++ b/docs/Cryptography/Tutorials/certificate-store-private-key.md @@ -0,0 +1,175 @@ +# Loading a private key from the Windows certificate store + +This guide covers how to install a certificate in the Windows certificate store and load the private key into `IConfiguration` using `Fhi.Security.Cryptography`. + +Typical use cases: + +- **Client assertion** — signing JWTs for authentication against HelseID or Maskinporten +- **Encryption** — encrypting or decrypting data at rest or in transit + +--- + +## Prerequisites + +```xml + +``` + +--- + +## Step 1 — Generate a certificate + +Use `Fhi.Security.Cryptography.CLI` to generate a self-signed certificate. See [](../commands/generatecertificates.ipynb) + +This produces two files: + +| File | Contents | +|---|---| +| `MyApp.pfx` | Private + public key (PKCS#12), password-protected | +| `MyApp.pem` | Public certificate (upload to HelseID/Maskinporten) | + + +--- + +## Step 2 — Install the certificate in the Windows certificate store + +### Using powershell +Open PowerShell **as administrator** (LocalMachine) or as yourself (CurrentUser): + +```powershell +# CurrentUser\My — recommended for applications running as your own user +$cert = Import-PfxCertificate ` + -FilePath ".\MyApp.pfx" ` + -CertStoreLocation "Cert:\CurrentUser\My" ` + -Password (ConvertTo-SecureString "MyPassword" -AsPlainText -Force) ` + -Exportable # required so that .NET can export the private key + +$cert.Thumbprint # copy this value +``` + +> **Important:** The `-Exportable` flag is required. Without it .NET cannot export the private key and the configuration value will never be populated. + +For services running as `NETWORK SERVICE` or a dedicated service account: + +```powershell +# LocalMachine\My — for Windows services and IIS +Import-PfxCertificate ` + -FilePath ".\MyApp.pfx" ` + -CertStoreLocation "Cert:\LocalMachine\My" ` + -Password (ConvertTo-SecureString "MyPassword" -AsPlainText -Force) ` + -Exportable +``` + +### Using Windows mmc certificate snap-in + +See [Import the certificate into the local computer store](https://learn.microsoft.com/en-us/troubleshoot/windows-server/certificates-and-public-key-infrastructure-pki/install-imported-certificates?source=recommendations#import-the-certificate-into-the-local-computer-store) + +--- + +## Step 3 — Grant the service account read access (LocalMachine only) + +If the certificate is installed in `LocalMachine\My` and the service runs as a non-administrator account: + +```powershell +$thumbprint = "" +$serviceAccount = "DOMAIN\MyServiceAccount" # or "IIS AppPool\MyAppPool" + +$cert = Get-Item "Cert:\LocalMachine\My\$thumbprint" +$keyPath = $cert.PrivateKey.CspKeyContainerInfo.UniqueKeyContainerName +$fullPath = "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys\$keyPath" + +$acl = Get-Acl $fullPath +$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( + $serviceAccount, "Read", "Allow"))) +Set-Acl $fullPath $acl +``` + +--- + +## Step 4 — Load the private key into IConfiguration + +### Single key (most common) + +```csharp +var config = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .AddPrivateKeyFromCertificateStore( + configKey: "HelseId:PrivateKey", + thumbprint: "AABBCCDDEEFF...", // from step 2 + keyUse: CertificateKeyUse.Signing, // default + storeLocation: StoreLocation.CurrentUser, + storeName: StoreName.My) + .Build(); + +// The key is now available as a PEM string +string? privateKeyPem = config["HelseId:PrivateKey"]; +``` + +### Multiple clients + +```csharp +var config = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .AddPrivateKeyFromCertificateStore(source => + { + source.Add("HelseId:PrivateKey", "AABB1122...", CertificateKeyUse.Signing); + source.Add("Maskinporten:PrivateKey", "CCDD3344...", CertificateKeyUse.Signing); + source.Add("Encryption:PrivateKey", "EEFF5566...", CertificateKeyUse.Encryption); + }) + .Build(); +``` + +### With dependency injection + +```csharp +// Program.cs +builder.Configuration.AddPrivateKeyFromCertificateStore( + configKey: "HelseId:PrivateKey", + thumbprint: builder.Configuration["HelseId:CertThumbprint"]!); + +// Consume in a service +public class TokenService(IConfiguration config) +{ + public void Configure() + { + var pem = config["HelseId:PrivateKey"]; + // load with RSA.ImportFromPem(pem) or IdentityModel + } +} +``` + +### Logging skipped certificates + +When a certificate cannot be loaded, the entry is silently skipped and the config key is left absent. +Connect the `onValidationError` callback to surface the reason through your application logger: + +```csharp +// Program.cs — ILogger not yet available, use a deferred approach +var warnings = new List(); + +builder.Configuration.AddPrivateKeyFromCertificateStore( + configKey: "HelseId:PrivateKey", + thumbprint: builder.Configuration["HelseId:CertThumbprint"]!, + onValidationError: warnings.Add); + +var app = builder.Build(); + +// Now ILogger is available — flush any warnings collected during startup +var logger = app.Services.GetRequiredService>(); +foreach (var d in warnings) + logger.LogWarning("Certificate not loaded [{ConfigKey}] '{Identifier}': {Reason}", + d.ConfigKey, d.Identifier, d.Reason); +``` + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Config value is `null`, warning says "not found" | Thumbprint not found in store | Check store location (`CurrentUser` vs `LocalMachine`) and that the thumbprint is uppercase with no spaces | +| `CryptographicException: The requested operation is not supported` | Certificate installed without `-Exportable` | Reinstall with the `-Exportable` flag | +| Config value is `null`, warning says "expired" | Certificate has passed its `NotAfter` date | Renew the certificate and reinstall | +| Config value is `null`, warning says "not valid until" | Certificate `NotBefore` is in the future | Check the system clock or the certificate validity period | +| Config value is `null`, warning says "missing required KeyUsage" | Certificate does not have `DigitalSignature` (signing) or `KeyEncipherment` (encryption) | Generate a new certificate with the correct key usage, or omit the KeyUsage extension for self-signed certs | +| Service cannot find certificate (LocalMachine) | Service account lacks read permission on the private key | Follow step 3 |