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 |