Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Build.Props
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<Company>Folkehelseinstituttet</Company>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
</PropertyGroup>
</Project>

2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<PackageVersion Include="Duende.AccessTokenManagement" Version="4.1.0" />
<PackageVersion Include="Fhi.Authentication.Extensions" Version="4.0.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.Testing" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.1" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Security.Cryptography.X509Certificates;
using Fhi.Security.Cryptography.Certificates;

namespace Microsoft.Extensions.Configuration
{
/// <summary>
/// Extension methods for loading private keys from a certificate store into IConfiguration.
/// </summary>
public static class CertificateConfigurationExtensions
{
/// <summary>
/// Reads a private key from the Windows certificate store and injects it as a PEM string
/// at <paramref name="configKey"/> in IConfiguration.
/// </summary>
/// <param name="builder"></param>
/// <param name="configKey">name of config parameter</param>
/// <param name="thumbprint">Certificate thumbprint</param>
/// <param name="keyUse">Intended use of private key</param>
/// <param name="storeLocation">Certificate location</param>
/// <param name="storeName">The Certificate store to lead the certificate from</param>
/// <param name="certStore">To implement another certificate store</param>
/// <param name="onValidationError">
/// Optional callback invoked when a certificate is skipped (not found, expired, missing private key, etc.).
/// Use this to connect to your application's logger:
/// <code>onWarning: d => logger.LogWarning("Certificate skipped [{Key}] {Id}: {Reason}", d.ConfigKey, d.Identifier, d.Reason)</code>
/// </param>
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<CertificateLoadDiagnostic>? onValidationError = null)
{
var store = certStore ?? new WindowsCertificateStore(storeName, storeLocation);
var source = new PrivateKeyCertificateStoreConfigurationSource(store, onValidationError);
source.Add(configKey, thumbprint, keyUse);
return builder.Add(source);
}

/// <summary>
/// Can reads multiple private keys from a certificate store into IConfiguration.
/// Use <paramref name="configure"/> to map config keys to certificate identifiers.
/// </summary>
/// <param name="builder"></param>
/// <param name="configure"></param>
/// <param name="certStore"></param>
/// <param name="onWarning">
/// Optional callback invoked when a certificate is skipped. See single-entry overload for usage example.
/// </param>
public static IConfigurationBuilder AddPrivateKeyFromCertificateStore(
this IConfigurationBuilder builder,
Action<PrivateKeyCertificateStoreConfigurationSource> configure,
ICertificateStore? certStore = null,
Action<CertificateLoadDiagnostic>? onWarning = null)
{
var store = certStore ?? new WindowsCertificateStore();
var source = new PrivateKeyCertificateStoreConfigurationSource(store, onWarning);
configure(source);
return builder.Add(source);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Security.Cryptography.X509Certificates;

namespace Fhi.Security.Cryptography.Certificates
{
/// <summary>
/// Abstraction over a certificate source (Windows certificate store, file system, etc.).
/// </summary>
public interface ICertificateStore
{
/// <summary>
/// Returns the certificate for the given identifier, or null if not found.
/// The caller is responsible for disposing the returned certificate.
/// </summary>
X509Certificate2? GetCertificate(string identifier);
}

/// <summary>
/// Intended use of the private key extracted from the certificate.
/// </summary>
public enum CertificateKeyUse
{
/// <summary>Key is used for signing (e.g. client assertion JWTs). Requires DigitalSignature KeyUsage.</summary>
Signing,
/// <summary>Key is used for decrypting data at rest or in transit. Requires KeyEncipherment KeyUsage.</summary>
Encryption
}

/// <summary>
/// Describes why a certificate entry was skipped during configuration load.
/// Passed to the <c>onValidationError</c> callback on <see cref="Microsoft.Extensions.Configuration.CertificateConfigurationExtensions"/>.
/// </summary>
public record CertificateLoadDiagnostic(string ConfigKey, string Identifier, string Reason);

internal record CertificateSecretEntry(
string ConfigKey,
string Identifier,
CertificateKeyUse KeyUse);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Configuration;

namespace Fhi.Security.Cryptography.Certificates
{
internal sealed class PrivateKeyCertificateStoreConfigurationProvider(
IReadOnlyList<CertificateSecretEntry> entries,
ICertificateStore certificateStore,
Action<CertificateLoadDiagnostic>? 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<X509KeyUsageExtension>().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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Microsoft.Extensions.Configuration;

namespace Fhi.Security.Cryptography.Certificates
{
/// <summary>
/// IConfigurationSource that loads private keys from a certificate store.
/// Entries are added via <see cref="Add"/>.
/// </summary>
public class PrivateKeyCertificateStoreConfigurationSource(
ICertificateStore certificateStore,
Action<CertificateLoadDiagnostic>? onWarning = null) : IConfigurationSource
{
private readonly List<CertificateSecretEntry> _entries = new();

/// <summary>Maps <paramref name="configKey"/> to a certificate by <paramref name="identifier"/>.</summary>
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Security.Cryptography.X509Certificates;

namespace Fhi.Security.Cryptography.Certificates
{
/// <summary>
/// Resolves certificates from the Windows certificate store by thumbprint.
/// </summary>
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];
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup>
Expand Down
Loading
Loading