From b8a96330c07a482796987c385c0157e6daef764f Mon Sep 17 00:00:00 2001 From: TekH Date: Wed, 14 Oct 2026 13:49:38 +0200 Subject: [PATCH] Add PKCS#11 startup self-test health reporting --- .../Common/Configurations/Pkcs11Options.cs | 25 +++- .../Common/Dto/Pkcs11SelfTestStatusDto.cs | 10 ++ .../Services/IPkcs11SelfTestStatusProvider.cs | 8 ++ .../ServiceCollectionExtensions.cs | 4 + ...elopeGenerator.Infrastructure.Crypt.csproj | 8 ++ .../Services/Pkcs11InteropBootstrap.cs | 46 +++++-- .../Services/Pkcs11SelfTestStatusStore.cs | 24 ++++ .../Services/Pkcs11SessionRunner.cs | 2 +- .../Pkcs11StartupSelfTestHostedService.cs | 112 ++++++++++++++++++ .../Services/Pkcs11ToolBridge.cs | 8 +- .../Controllers/HealthController.cs | 18 +++ .../appsettings.Development.json | 16 ++- .../EnvelopeGenerator.Server/appsettings.json | 3 +- 13 files changed, 259 insertions(+), 25 deletions(-) create mode 100644 EnvelopeGenerator.Application/Common/Dto/Pkcs11SelfTestStatusDto.cs create mode 100644 EnvelopeGenerator.Application/Common/Interfaces/Services/IPkcs11SelfTestStatusProvider.cs create mode 100644 EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11SelfTestStatusStore.cs create mode 100644 EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11StartupSelfTestHostedService.cs create mode 100644 EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/HealthController.cs diff --git a/EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs b/EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs index f2a8f092..a6ad386f 100644 --- a/EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs +++ b/EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs @@ -15,11 +15,10 @@ public sealed class Pkcs11Options public const string SectionName = "Crypt:Pkcs11"; /// - /// Absolute or resolvable file path of the PKCS#11 provider library. + /// Optional base directory that contains the pkcs11 folder. + /// If null/empty, runtime uses the application base directory. /// - [Required] - [MinLength(1)] - public required string LibraryPath { get; set; } + public string? AssetsRootPath { get; set; } /// /// Optional numeric slot identifier used to directly select the target token slot. @@ -37,9 +36,23 @@ public sealed class Pkcs11Options public string? ProxySocket { get; set; } /// - /// Optional full path to pkcs11-tool, used by diagnostics and fallback paths. + /// Resolved absolute PKCS#11 proxy module path used at runtime. + /// Computed from and runtime defaults. /// - public string? ToolPath { get; set; } + public string ResolvedLibraryPath { get; private set; } = string.Empty; + + /// + /// Resolved absolute pkcs11-tool path used at runtime. + /// Computed from and runtime defaults. + /// Can be null when tool fallback is not available. + /// + public string? ResolvedToolPath { get; private set; } + + public void SetResolvedPaths(string resolvedLibraryPath, string? resolvedToolPath) + { + ResolvedLibraryPath = resolvedLibraryPath; + ResolvedToolPath = resolvedToolPath; + } /// /// User PIN used to authenticate to the PKCS#11 token session. diff --git a/EnvelopeGenerator.Application/Common/Dto/Pkcs11SelfTestStatusDto.cs b/EnvelopeGenerator.Application/Common/Dto/Pkcs11SelfTestStatusDto.cs new file mode 100644 index 00000000..43df76ba --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Dto/Pkcs11SelfTestStatusDto.cs @@ -0,0 +1,10 @@ +namespace EnvelopeGenerator.Application.Common.Dto; + +public sealed record Pkcs11SelfTestStatusDto( + bool Completed, + bool Success, + string Mode, + string Summary, + DateTimeOffset? CheckedAtUtc, + string? InteropError, + string? ToolError); diff --git a/EnvelopeGenerator.Application/Common/Interfaces/Services/IPkcs11SelfTestStatusProvider.cs b/EnvelopeGenerator.Application/Common/Interfaces/Services/IPkcs11SelfTestStatusProvider.cs new file mode 100644 index 00000000..693b7d02 --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Interfaces/Services/IPkcs11SelfTestStatusProvider.cs @@ -0,0 +1,8 @@ +using EnvelopeGenerator.Application.Common.Dto; + +namespace EnvelopeGenerator.Application.Common.Interfaces.Services; + +public interface IPkcs11SelfTestStatusProvider +{ + Pkcs11SelfTestStatusDto GetStatus(); +} diff --git a/EnvelopeGenerator.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs b/EnvelopeGenerator.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs index de0a8718..b1320e8c 100644 --- a/EnvelopeGenerator.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs +++ b/EnvelopeGenerator.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs @@ -16,7 +16,11 @@ public static class ServiceCollectionExtensions .Bind(configuration.GetSection(Pkcs11Options.SectionName)) .ValidateDataAnnotations() .ValidateOnStart(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHostedService(); return services; } } diff --git a/EnvelopeGenerator.Infrastructure.Crypt/EnvelopeGenerator.Infrastructure.Crypt.csproj b/EnvelopeGenerator.Infrastructure.Crypt/EnvelopeGenerator.Infrastructure.Crypt.csproj index 5d2d97dd..fb0fc085 100644 --- a/EnvelopeGenerator.Infrastructure.Crypt/EnvelopeGenerator.Infrastructure.Crypt.csproj +++ b/EnvelopeGenerator.Infrastructure.Crypt/EnvelopeGenerator.Infrastructure.Crypt.csproj @@ -12,6 +12,7 @@ + @@ -24,4 +25,11 @@ + + + PreserveNewest + pkcs11\%(RecursiveDir)%(Filename)%(Extension) + + + diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11InteropBootstrap.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11InteropBootstrap.cs index 51f88997..833a2516 100644 --- a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11InteropBootstrap.cs +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11InteropBootstrap.cs @@ -6,6 +6,8 @@ internal static class Pkcs11InteropBootstrap { private static readonly object Sync = new(); private static bool Initialized; + private const string ProxyRelativePath = "pkcs11\\windows\\proxy\\libpkcs11-proxy.dll"; + private const string ToolRelativePath = "pkcs11\\windows\\opensc\\tools\\pkcs11-tool.exe"; public static void EnsureLoaded(Pkcs11Options options) { @@ -17,7 +19,11 @@ internal static class Pkcs11InteropBootstrap if (Initialized) return; - EnsureNativeDependencySearchPath(options.LibraryPath); + var libraryPath = ResolveLibraryPath(options); + var toolPath = ResolveToolPath(options); + options.SetResolvedPaths(libraryPath, toolPath); + + EnsureNativeDependencySearchPath(options.ResolvedLibraryPath); if (!string.IsNullOrWhiteSpace(options.ProxySocket)) { @@ -25,9 +31,9 @@ internal static class Pkcs11InteropBootstrap Environment.SetEnvironmentVariable("PKCS11_DAEMON_SOCKET", options.ProxySocket); } - if (!string.IsNullOrWhiteSpace(options.ToolPath)) + if (!string.IsNullOrWhiteSpace(options.ResolvedToolPath)) { - var toolDir = Path.GetDirectoryName(options.ToolPath); + var toolDir = Path.GetDirectoryName(options.ResolvedToolPath); if (!string.IsNullOrWhiteSpace(toolDir)) EnsurePathContains(toolDir); } @@ -37,20 +43,46 @@ internal static class Pkcs11InteropBootstrap } } + private static string ResolveLibraryPath(Pkcs11Options options) + { + var assetsRoot = string.IsNullOrWhiteSpace(options.AssetsRootPath) + ? AppContext.BaseDirectory + : options.AssetsRootPath; + + var resolved = Path.Combine(assetsRoot, ProxyRelativePath); + if (File.Exists(resolved)) + return resolved; + + throw new FileNotFoundException($"PKCS#11 proxy module not found. Checked path: {resolved}", resolved); + } + + private static string? ResolveToolPath(Pkcs11Options options) + { + var assetsRoot = string.IsNullOrWhiteSpace(options.AssetsRootPath) + ? AppContext.BaseDirectory + : options.AssetsRootPath; + + var resolved = Path.Combine(assetsRoot, ToolRelativePath); + if (File.Exists(resolved)) + return resolved; + + return null; + } + private static void WarmupWithPkcs11Tool(Pkcs11Options options) { - if (string.IsNullOrWhiteSpace(options.ToolPath)) + if (string.IsNullOrWhiteSpace(options.ResolvedToolPath)) return; - if (!File.Exists(options.ToolPath)) + if (!File.Exists(options.ResolvedToolPath)) return; var process = new System.Diagnostics.Process { StartInfo = new System.Diagnostics.ProcessStartInfo { - FileName = options.ToolPath, - Arguments = $"--module \"{options.LibraryPath}\" -L", + FileName = options.ResolvedToolPath, + Arguments = $"--module \"{options.ResolvedLibraryPath}\" -L", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11SelfTestStatusStore.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11SelfTestStatusStore.cs new file mode 100644 index 00000000..5dfddb1b --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11SelfTestStatusStore.cs @@ -0,0 +1,24 @@ +using EnvelopeGenerator.Application.Common.Dto; +using EnvelopeGenerator.Application.Common.Interfaces.Services; +using System.Threading; + +namespace EnvelopeGenerator.Infrastructure.Crypt.Services; + +internal sealed class Pkcs11SelfTestStatusStore : IPkcs11SelfTestStatusProvider +{ + private Pkcs11SelfTestStatusDto _status = new( + Completed: false, + Success: false, + Mode: "pending", + Summary: "PKCS#11 startup self-test has not run yet.", + CheckedAtUtc: null, + InteropError: null, + ToolError: null); + + public Pkcs11SelfTestStatusDto GetStatus() => Volatile.Read(ref _status); + + public void SetStatus(Pkcs11SelfTestStatusDto status) + { + Volatile.Write(ref _status, status); + } +} diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11SessionRunner.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11SessionRunner.cs index c03b3ffe..0d53ef8a 100644 --- a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11SessionRunner.cs +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11SessionRunner.cs @@ -11,7 +11,7 @@ internal static class Pkcs11SessionRunner Pkcs11InteropBootstrap.EnsureLoaded(options); var factories = new Pkcs11InteropFactories(); - using var pkcs11 = LoadLibraryWithFallback(factories, options.LibraryPath, options.ProxySocket); + using var pkcs11 = LoadLibraryWithFallback(factories, options.ResolvedLibraryPath, options.ProxySocket); var slot = ResolveSlot(pkcs11.GetSlotList(SlotsType.WithTokenPresent), options); using var session = slot.OpenSession(SessionType.ReadOnly); diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11StartupSelfTestHostedService.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11StartupSelfTestHostedService.cs new file mode 100644 index 00000000..74d6e2ac --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11StartupSelfTestHostedService.cs @@ -0,0 +1,112 @@ +using EnvelopeGenerator.Application.Common.Configurations; +using EnvelopeGenerator.Application.Common.Dto; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Net.Pkcs11Interop.Common; +using Net.Pkcs11Interop.HighLevelAPI; +using System.Security.Cryptography; + +namespace EnvelopeGenerator.Infrastructure.Crypt.Services; + +internal sealed class Pkcs11StartupSelfTestHostedService( + IOptions options, + Pkcs11SelfTestStatusStore statusStore, + ILogger logger) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + var pkcs11 = options.Value; + + try + { + RunInteropProbe(pkcs11); + statusStore.SetStatus(new Pkcs11SelfTestStatusDto( + Completed: true, + Success: true, + Mode: "interop", + Summary: "PKCS#11 startup self-test succeeded via interop.", + CheckedAtUtc: DateTimeOffset.UtcNow, + InteropError: null, + ToolError: null)); + logger.LogInformation("PKCS#11 startup self-test succeeded (mode=interop, library={LibraryPath}, proxy={ProxySocket}).", pkcs11.ResolvedLibraryPath, pkcs11.ProxySocket); + return Task.CompletedTask; + } + catch (Exception interopEx) when (Pkcs11ToolBridge.IsAvailable(pkcs11)) + { + try + { + RunToolProbe(pkcs11); + statusStore.SetStatus(new Pkcs11SelfTestStatusDto( + Completed: true, + Success: true, + Mode: "pkcs11-tool", + Summary: "PKCS#11 interop failed, tool fallback succeeded.", + CheckedAtUtc: DateTimeOffset.UtcNow, + InteropError: interopEx.Message, + ToolError: null)); + logger.LogWarning(interopEx, "PKCS#11 interop self-test failed, but tool fallback is healthy (mode=pkcs11-tool, tool={ToolPath}).", pkcs11.ResolvedToolPath); + return Task.CompletedTask; + } + catch (Exception toolEx) + { + statusStore.SetStatus(new Pkcs11SelfTestStatusDto( + Completed: true, + Success: false, + Mode: "failed", + Summary: "PKCS#11 startup self-test failed for both interop and tool fallback.", + CheckedAtUtc: DateTimeOffset.UtcNow, + InteropError: interopEx.Message, + ToolError: toolEx.Message)); + logger.LogError(toolEx, "PKCS#11 startup self-test failed for both interop and tool fallback. library={LibraryPath}, proxy={ProxySocket}, tool={ToolPath}", pkcs11.ResolvedLibraryPath, pkcs11.ProxySocket, pkcs11.ResolvedToolPath); + return Task.CompletedTask; + } + } + catch (Exception ex) + { + statusStore.SetStatus(new Pkcs11SelfTestStatusDto( + Completed: true, + Success: false, + Mode: "failed", + Summary: "PKCS#11 startup self-test failed via interop and no tool fallback is available.", + CheckedAtUtc: DateTimeOffset.UtcNow, + InteropError: ex.Message, + ToolError: null)); + logger.LogError(ex, "PKCS#11 startup self-test failed (mode=interop, no fallback available). library={LibraryPath}, proxy={ProxySocket}", pkcs11.ResolvedLibraryPath, pkcs11.ProxySocket); + return Task.CompletedTask; + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private static void RunInteropProbe(Pkcs11Options options) + { + Pkcs11SessionRunner.Run(options, session => + { + EnsureObjectExists(session, CKO.CKO_CERTIFICATE, options.CertificateLabel, "certificate"); + EnsureObjectExists(session, CKO.CKO_PRIVATE_KEY, options.PrivateKeyLabel, "private key"); + return true; + }); + } + + private static void RunToolProbe(Pkcs11Options options) + { + _ = Pkcs11ToolBridge.ReadCertificateBytes(options); + var testDigest = SHA256.HashData([1, 2, 3, 4]); + var digestInfo = Pkcs11DigestInfoBuilder.Build(testDigest, HashAlgorithmName.SHA256); + _ = Pkcs11ToolBridge.SignDigestInfo(options, digestInfo); + } + + private static void EnsureObjectExists(ISession session, CKO objectClass, string label, string objectType) + { + var attrs = new List + { + session.Factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, objectClass), + session.Factories.ObjectAttributeFactory.Create(CKA.CKA_LABEL, label) + }; + + var handles = session.FindAllObjects(attrs); + if (handles.Count == 0) + throw new InvalidOperationException($"PKCS#11 {objectType} '{label}' not found in token."); + } +} diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11ToolBridge.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11ToolBridge.cs index 8fb26978..9c564763 100644 --- a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11ToolBridge.cs +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11ToolBridge.cs @@ -8,7 +8,7 @@ internal static class Pkcs11ToolBridge { public static bool IsAvailable(Pkcs11Options options) { - return !string.IsNullOrWhiteSpace(options.ToolPath) && File.Exists(options.ToolPath); + return !string.IsNullOrWhiteSpace(options.ResolvedToolPath) && File.Exists(options.ResolvedToolPath); } public static byte[] ReadCertificateBytes(Pkcs11Options options) @@ -75,7 +75,7 @@ internal static class Pkcs11ToolBridge var args = new List { "--module", - options.LibraryPath, + options.ResolvedLibraryPath, "--login", "--pin", options.UserPin @@ -101,7 +101,7 @@ internal static class Pkcs11ToolBridge { StartInfo = new ProcessStartInfo { - FileName = options.ToolPath!, + FileName = options.ResolvedToolPath!, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -136,7 +136,7 @@ internal static class Pkcs11ToolBridge private static void EnsureAvailable(Pkcs11Options options) { if (!IsAvailable(options)) - throw new InvalidOperationException("pkcs11-tool is not available. Configure Crypt:Pkcs11:ToolPath."); + throw new InvalidOperationException("pkcs11-tool is not available at resolved pkcs11 assets path."); } private static void DeleteIfExists(string path) diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/HealthController.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/HealthController.cs new file mode 100644 index 00000000..6dc171cf --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/HealthController.cs @@ -0,0 +1,18 @@ +using EnvelopeGenerator.Application.Common.Interfaces.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace EnvelopeGenerator.Server.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class HealthController(IPkcs11SelfTestStatusProvider pkcs11StatusProvider) : ControllerBase +{ + [AllowAnonymous] + [HttpGet("crypt")] + public IActionResult GetCryptHealth() + { + var status = pkcs11StatusProvider.GetStatus(); + return Ok(status); + } +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.Development.json b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.Development.json index 3940dae0..461e53a4 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.Development.json +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.Development.json @@ -1,10 +1,16 @@ { -"Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } } -}, + , + "Crypt": { + "Pkcs11": { + "AssetsRootPath": null + } + }, "AuthClientParams": { "Url": "http://172.24.12.39:9090/auth-hub", "PublicKeys": [ diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json index 420fc04c..2462a81f 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json @@ -57,11 +57,10 @@ }, "Crypt": { "Pkcs11": { - "LibraryPath": "C:\\PKCS11\\libpkcs11-proxy.dll", + "AssetsRootPath": "E:\\DataFiles\\Digital Data\\EnvelopeGenerator", "SlotId": 2021549410, "TokenLabel": "CompanyProdToken", "ProxySocket": "tcp://172.24.12.64:5657", - "ToolPath": "C:\\Users\\tekh\\tools\\opensc\\PFiles64\\OpenSC Project\\OpenSC\\tools\\pkcs11-tool.exe", "UserPin": "", "PrivateKeyLabel": "company-sign-key-v1", "CertificateLabel": "company-sign-cert-v1",