From f361898ba48245b4c5e4c218b5776ab3bf34664e Mon Sep 17 00:00:00 2001 From: TekH Date: Tue, 13 Oct 2026 14:45:47 +0200 Subject: [PATCH] Add PKCS#11-based PDF signing infrastructure Introduced a cryptographic infrastructure for PDF signing and validation using PKCS#11-based hardware security modules (HSMs) or software tokens. Key changes include: - Added `PdfDigitalSignatureService` for signing PDFs, multi-signature support, and signature validation. - Implemented `Pkcs11ExternalSigner` and `Pkcs11PdfSigningRsa` for PKCS#11-based signing with DevExpress PDF infrastructure. - Added utilities like `Pkcs11SessionRunner`, `Pkcs11InteropBootstrap`, and `Pkcs11ToolBridge` for session management, library initialization, and fallback mechanisms. - Introduced `Pkcs11Options` for managing PKCS#11 configurations. - Updated project dependencies to include `DevExpress.Document.Processor`, `System.Security.Cryptography.Pkcs`, and `Pkcs11Interop`. - Enhanced error handling and fallback support for PKCS#11 operations. These changes enable robust and extensible PDF signing capabilities with detailed signature metadata extraction and validation. --- .../ServiceCollectionExtensions.cs | 22 ++ ...elopeGenerator.Infrastructure.Crypt.csproj | 8 + .../Services/PdfDigitalSignatureService.cs | 353 ++++++++++++++++++ .../Services/Pkcs11DigestInfoBuilder.cs | 43 +++ .../Services/Pkcs11ExternalSigner.cs | 49 +++ .../Services/Pkcs11InteropBootstrap.cs | 87 +++++ .../Services/Pkcs11PdfSigningRsa.cs | 64 ++++ .../Services/Pkcs11SessionRunner.cs | 124 ++++++ .../Services/Pkcs11ToolBridge.cs | 147 ++++++++ 9 files changed, 897 insertions(+) create mode 100644 EnvelopeGenerator.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs create mode 100644 EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11DigestInfoBuilder.cs create mode 100644 EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11ExternalSigner.cs create mode 100644 EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11InteropBootstrap.cs create mode 100644 EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11PdfSigningRsa.cs create mode 100644 EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11SessionRunner.cs create mode 100644 EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11ToolBridge.cs diff --git a/EnvelopeGenerator.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs b/EnvelopeGenerator.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..de0a8718 --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using EnvelopeGenerator.Application.Common.Configurations; +using EnvelopeGenerator.Application.Common.Interfaces.Services; +using EnvelopeGenerator.Infrastructure.Crypt.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace EnvelopeGenerator.Infrastructure.Crypt.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCryptInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services + .AddOptions() + .Bind(configuration.GetSection(Pkcs11Options.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + services.AddSingleton(); + return services; + } +} diff --git a/EnvelopeGenerator.Infrastructure.Crypt/EnvelopeGenerator.Infrastructure.Crypt.csproj b/EnvelopeGenerator.Infrastructure.Crypt/EnvelopeGenerator.Infrastructure.Crypt.csproj index a1aae790..5d2d97dd 100644 --- a/EnvelopeGenerator.Infrastructure.Crypt/EnvelopeGenerator.Infrastructure.Crypt.csproj +++ b/EnvelopeGenerator.Infrastructure.Crypt/EnvelopeGenerator.Infrastructure.Crypt.csproj @@ -7,13 +7,21 @@ + + + + + + + + diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs new file mode 100644 index 00000000..17de0ca6 --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs @@ -0,0 +1,353 @@ +using EnvelopeGenerator.Application.Common.Configurations; +using EnvelopeGenerator.Application.Common.Dto; +using EnvelopeGenerator.Application.Common.Enums; +using EnvelopeGenerator.Application.Common.Interfaces.Services; +using DevExpress.Office.DigitalSignatures; +using DevExpress.Pdf; +using Microsoft.Extensions.Options; +using System.Formats.Asn1; +using System.Security.Cryptography.Pkcs; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace EnvelopeGenerator.Infrastructure.Crypt.Services; + +public sealed class PdfDigitalSignatureService(IOptions options) : IPdfDigitalSignatureService +{ + public Task SignPdfAsync(byte[] pdfBytes, PdfSignOptionsDto? optionsModel = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pdfBytes); + var pkcs11Options = options.Value; + var request = optionsModel ?? new PdfSignOptionsDto(); + var fallbackSigningTime = pkcs11Options.UseUtcSigningTime ? DateTimeOffset.UtcNow : DateTimeOffset.Now; + + using var inputPdf = new MemoryStream(pdfBytes); + using var signer = new PdfDocumentSigner(inputPdf); + + var digestCalculator = new DigestCalculator(HashAlgorithmType.SHA256); + var signerInfo = new ExternalSignerInfo(PdfSignatureType.Pkcs7, 32768, digestCalculator); + var signatureFieldInfo = new PdfSignatureFieldInfo(1) + { + Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmss}" + }; + + var builder = new PdfDeferredSignatureBuilder(signerInfo, signatureFieldInfo) + { + Name = request.Name ?? pkcs11Options.CertificateLabel, + Reason = request.Reason ?? pkcs11Options.DefaultReason, + Location = request.Location, + ContactInfo = request.ContactInfo, + SigningTime = request.SigningTime ?? fallbackSigningTime, + ApplicationName = request.ApplicationName ?? pkcs11Options.DefaultApplicationName, + CertificationLevel = ToDevExpressCertificationLevel(request.CertificationLevel) + ?? ToDevExpressCertificationLevel(pkcs11Options.DefaultCertificationLevel) + ?? PdfCertificationLevel.NoCertification + }; + + var deferredSigner = signer.SignDeferred(builder); + var externalSigner = new Pkcs11ExternalSigner(pkcs11Options); + byte[] signature = externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid); + + using var outputPdf = new MemoryStream(); + deferredSigner.Sign(outputPdf, signature); + return Task.FromResult(outputPdf.ToArray()); + } + + public async Task SignPdfAsync(byte[] pdfBytes, IReadOnlyList optionsList, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pdfBytes); + ArgumentNullException.ThrowIfNull(optionsList); + + if (optionsList.Count == 0) + return pdfBytes; + + var currentPdf = pdfBytes; + + foreach (var signOptions in optionsList) + { + cancellationToken.ThrowIfCancellationRequested(); + currentPdf = await SignPdfAsync(currentPdf, signOptions, cancellationToken); + } + + return currentPdf; + } + + public Task CheckPdfSignatureAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pdfBytes); + + using var inputPdf = new MemoryStream(pdfBytes); + using var signer = new PdfDocumentSigner(inputPdf); + var signatures = signer.GetSignatureInfo(); + + if (signatures is null || signatures.Count == 0) + { + return Task.FromResult(new PdfSignatureCheckResultDto( + Signed: false, + Signatures: [])); + } + + var items = new List(signatures.Count); + + foreach (var signature in signatures) + { + try + { + var pkcs7 = signer.GetPdfPkcs7Signature(signature.FieldName); + bool valid = pkcs7.VerifySignature(); + bool coversWholeDocument = true; + var certificate = pkcs7.GetSignatureCertificate(); + var now = DateTimeOffset.UtcNow; + var serialNumber = ToColonSerial(certificate?.SerialNumber); + DateTimeOffset? validFrom = certificate is null ? null : new DateTimeOffset(certificate.NotBefore).ToUniversalTime(); + DateTimeOffset? validTo = certificate is null ? null : new DateTimeOffset(certificate.NotAfter).ToUniversalTime(); + var issuedTo = ParsePrincipal(certificate?.Subject); + var issuedBy = ParsePrincipal(certificate?.Issuer); + var certificateInfo = BuildCertificateInfo(certificate, now); + var digestAlgorithm = InferDigestAlgorithm(certificate); + var signatureAlgorithm = InferSignatureAlgorithm(certificate); + var signerDisplayName = issuedTo.CommonName ?? signature.SignerName; + var messageDigestHex = TryExtractMessageDigestHex(signature.Contents); + + items.Add(new PdfSignatureCheckItemDto( + CoversWholeDoc: coversWholeDocument, + Reason: signature.Reason, + Location: signature.Location, + DeclaredName: signature.SignerName, + ContactInfo: signature.ContactInfo, + DigestAlgorithm: digestAlgorithm, + SignatureAlgorithm: signatureAlgorithm, + SignerName: signerDisplayName, + Issuer: issuedBy.Full, + SerialNumber: serialNumber, + ValidFrom: validFrom, + ValidTo: validTo, + ExpiryCheckedAgainst: "now", + Certificates: [certificateInfo], + Unchanged: valid, + FieldName: signature.FieldName, + IsCryptographicallyValid: valid, + SigningTime: signature.SigningTime?.ToUniversalTime().ToString("O"), + Filter: signature.Filter, + SubFilter: signature.SubFilter, + ApplicationName: null, + MessageDigestHex: messageDigestHex, + Error: null)); + } + catch (Exception ex) + { + items.Add(new PdfSignatureCheckItemDto( + CoversWholeDoc: false, + Reason: signature.Reason, + Location: signature.Location, + DeclaredName: signature.SignerName, + ContactInfo: signature.ContactInfo, + DigestAlgorithm: null, + SignatureAlgorithm: null, + SignerName: signature.SignerName, + Issuer: null, + SerialNumber: null, + ValidFrom: null, + ValidTo: null, + ExpiryCheckedAgainst: "now", + Certificates: [], + Unchanged: false, + FieldName: signature.FieldName, + IsCryptographicallyValid: false, + SigningTime: signature.SigningTime?.ToUniversalTime().ToString("O"), + Filter: signature.Filter, + SubFilter: signature.SubFilter, + ApplicationName: null, + MessageDigestHex: null, + Error: ex.Message)); + } + } + + return Task.FromResult(new PdfSignatureCheckResultDto( + Signed: true, + Signatures: items)); + } + + private static PdfSignatureCertificateInfoDto BuildCertificateInfo(X509Certificate2? certificate, DateTimeOffset now) + { + if (certificate is null) + { + return new PdfSignatureCertificateInfoDto( + IssuedTo: new PdfSignaturePrincipalDto(null, null, null, null), + IssuedBy: new PdfSignaturePrincipalDto(null, null, null, null), + SerialNumber: null, + ValidFrom: null, + ValidTo: null, + SelfSigned: false, + Expired: false, + NotYetValid: false, + IsSigner: true, + Version: null, + KeyAlgorithm: null, + SignatureAlgorithm: null, + Pem: null, + KeySizeBits: null); + } + + var issuedTo = ParsePrincipal(certificate.Subject); + var issuedBy = ParsePrincipal(certificate.Issuer); + var validFrom = new DateTimeOffset(certificate.NotBefore).ToUniversalTime(); + var validTo = new DateTimeOffset(certificate.NotAfter).ToUniversalTime(); + + return new PdfSignatureCertificateInfoDto( + IssuedTo: issuedTo, + IssuedBy: issuedBy, + SerialNumber: ToColonSerial(certificate.SerialNumber), + ValidFrom: validFrom, + ValidTo: validTo, + SelfSigned: string.Equals(certificate.Subject, certificate.Issuer, StringComparison.OrdinalIgnoreCase), + Expired: validTo < now, + NotYetValid: validFrom > now, + IsSigner: true, + Version: certificate.Version, + KeyAlgorithm: certificate.PublicKey.Oid?.FriendlyName ?? certificate.PublicKey.Oid?.Value, + SignatureAlgorithm: certificate.SignatureAlgorithm.FriendlyName ?? certificate.SignatureAlgorithm.Value, + Pem: ToPem(certificate.RawData), + KeySizeBits: TryGetKeySize(certificate)); + } + + private static int? TryGetKeySize(X509Certificate2 certificate) + { + try + { + return certificate.PublicKey.Key.KeySize; + } + catch + { + return null; + } + } + + private static string? InferDigestAlgorithm(X509Certificate2? certificate) + { + if (certificate?.SignatureAlgorithm is null) + return null; + + var source = (certificate.SignatureAlgorithm.FriendlyName ?? certificate.SignatureAlgorithm.Value ?? string.Empty).ToUpperInvariant(); + if (source.Contains("SHA512", StringComparison.Ordinal)) return "SHA-512"; + if (source.Contains("SHA384", StringComparison.Ordinal)) return "SHA-384"; + if (source.Contains("SHA256", StringComparison.Ordinal)) return "SHA-256"; + if (source.Contains("SHA1", StringComparison.Ordinal)) return "SHA-1"; + return certificate.SignatureAlgorithm.FriendlyName ?? certificate.SignatureAlgorithm.Value; + } + + private static string? InferSignatureAlgorithm(X509Certificate2? certificate) + { + var oidName = certificate?.PublicKey.Oid?.FriendlyName?.ToUpperInvariant(); + if (oidName is not null && oidName.Contains("RSA", StringComparison.Ordinal)) + return "RSA"; + if (oidName is not null && oidName.Contains("ECDSA", StringComparison.Ordinal)) + return "ECDSA"; + return certificate?.PublicKey.Oid?.FriendlyName ?? certificate?.PublicKey.Oid?.Value; + } + + private static PdfSignaturePrincipalDto ParsePrincipal(string? dn) + { + if (string.IsNullOrWhiteSpace(dn)) + return new PdfSignaturePrincipalDto(null, null, null, dn); + + string? commonName = null; + string? organization = null; + string? country = null; + + foreach (var part in dn.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + var equalsIndex = part.IndexOf('='); + if (equalsIndex <= 0 || equalsIndex >= part.Length - 1) + continue; + + var key = part[..equalsIndex].Trim(); + var value = part[(equalsIndex + 1)..].Trim(); + + if (key.Equals("CN", StringComparison.OrdinalIgnoreCase)) commonName = value; + else if (key.Equals("O", StringComparison.OrdinalIgnoreCase)) organization = value; + else if (key.Equals("C", StringComparison.OrdinalIgnoreCase)) country = value; + } + + return new PdfSignaturePrincipalDto(commonName, organization, country, dn); + } + + private static PdfCertificationLevel? ToDevExpressCertificationLevel(PdfSignatureCertificationLevel? value) + { + return value is null ? null : (PdfCertificationLevel)(int)value.Value; + } + + private static string? ToColonSerial(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Replace(":", string.Empty, StringComparison.Ordinal).Trim(); + if (normalized.Length % 2 != 0) + return normalized; + + return string.Join(':', Enumerable.Range(0, normalized.Length / 2).Select(i => normalized.Substring(i * 2, 2).ToUpperInvariant())); + } + + private static string ToPem(byte[] der) + { + var base64 = Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks); + var sb = new StringBuilder(); + sb.AppendLine("-----BEGIN CERTIFICATE-----"); + sb.AppendLine(base64); + sb.Append("-----END CERTIFICATE-----"); + return sb.ToString(); + } + + private static string? TryExtractMessageDigestHex(byte[]? signatureContents) + { + if (signatureContents is null || signatureContents.Length == 0) + return null; + + try + { + var cms = new SignedCms(); + cms.Decode(signatureContents); + + var signerInfo = cms.SignerInfos.Count > 0 ? cms.SignerInfos[0] : null; + if (signerInfo is null) + return null; + + foreach (CryptographicAttributeObject attr in signerInfo.SignedAttributes) + { + if (!string.Equals(attr.Oid?.Value, "1.2.840.113549.1.9.4", StringComparison.Ordinal)) + continue; + + foreach (var value in attr.Values) + { + var octets = TryReadOctetString(value.RawData); + if (octets is not null && octets.Length > 0) + return Convert.ToHexString(octets); + } + } + } + catch + { + return null; + } + + return null; + } + + private static byte[]? TryReadOctetString(byte[] rawData) + { + try + { + var reader = new AsnReader(rawData, AsnEncodingRules.BER); + var octets = reader.ReadOctetString(); + if (reader.HasData) + return null; + return octets; + } + catch + { + return null; + } + } +} diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11DigestInfoBuilder.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11DigestInfoBuilder.cs new file mode 100644 index 00000000..a1741c06 --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11DigestInfoBuilder.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; + +namespace EnvelopeGenerator.Infrastructure.Crypt.Services; + +internal static class Pkcs11DigestInfoBuilder +{ + public static byte[] Build(byte[] hash, HashAlgorithmName hashAlgorithm) + { + byte[] prefix = hashAlgorithm.Name switch + { + "SHA256" => + [ + 0x30, 0x31, + 0x30, 0x0d, + 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, + 0x05, 0x00, + 0x04, 0x20 + ], + "SHA384" => + [ + 0x30, 0x41, + 0x30, 0x0d, + 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x02, + 0x05, 0x00, + 0x04, 0x30 + ], + "SHA512" => + [ + 0x30, 0x51, + 0x30, 0x0d, + 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03, + 0x05, 0x00, + 0x04, 0x40 + ], + _ => throw new NotSupportedException($"Hash algorithm '{hashAlgorithm.Name}' is not supported.") + }; + + var digestInfo = new byte[prefix.Length + hash.Length]; + Buffer.BlockCopy(prefix, 0, digestInfo, 0, prefix.Length); + Buffer.BlockCopy(hash, 0, digestInfo, prefix.Length, hash.Length); + return digestInfo; + } +} diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11ExternalSigner.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11ExternalSigner.cs new file mode 100644 index 00000000..cfc8914c --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11ExternalSigner.cs @@ -0,0 +1,49 @@ +using EnvelopeGenerator.Application.Common.Configurations; +using DevExpress.Office.DigitalSignatures; +using DevExpress.Pdf; + +namespace EnvelopeGenerator.Infrastructure.Crypt.Services; + +internal sealed class Pkcs11ExternalSigner(Pkcs11Options options) : Pkcs7SignerBase +{ + private readonly Pkcs11PdfSigningRsa rsa = new(options); + private readonly DigestCalculator digestCalculator = new(HashAlgorithmType.SHA256); + private readonly IReadOnlyList certificates = [ReadCertificateBytesWithFallback(options)]; + + protected override IDigestCalculator DigestCalculator => digestCalculator; + + protected override string SigningAlgorithmOID => "1.2.840.113549.1.1.1"; + + protected override IEnumerable GetCertificates() => certificates; + + protected override byte[] SignDigest(byte[] digest) + { + return rsa.SignHash(digest, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1); + } + + private static byte[] ReadCertificateBytesWithFallback(Pkcs11Options options) + { + try + { + return Pkcs11SessionRunner.Run(options, session => + { + var attrs = new List + { + session.Factories.ObjectAttributeFactory.Create(Net.Pkcs11Interop.Common.CKA.CKA_CLASS, Net.Pkcs11Interop.Common.CKO.CKO_CERTIFICATE), + session.Factories.ObjectAttributeFactory.Create(Net.Pkcs11Interop.Common.CKA.CKA_LABEL, options.CertificateLabel) + }; + + var handles = session.FindAllObjects(attrs); + if (handles.Count == 0) + throw new InvalidOperationException($"Certificate '{options.CertificateLabel}' not found in token."); + + var values = session.GetAttributeValue(handles[0], new List { Net.Pkcs11Interop.Common.CKA.CKA_VALUE }); + return values[0].GetValueAsByteArray(); + }); + } + catch when (Pkcs11ToolBridge.IsAvailable(options)) + { + return Pkcs11ToolBridge.ReadCertificateBytes(options); + } + } +} diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11InteropBootstrap.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11InteropBootstrap.cs new file mode 100644 index 00000000..51f88997 --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11InteropBootstrap.cs @@ -0,0 +1,87 @@ +using EnvelopeGenerator.Application.Common.Configurations; + +namespace EnvelopeGenerator.Infrastructure.Crypt.Services; + +internal static class Pkcs11InteropBootstrap +{ + private static readonly object Sync = new(); + private static bool Initialized; + + public static void EnsureLoaded(Pkcs11Options options) + { + if (Initialized) + return; + + lock (Sync) + { + if (Initialized) + return; + + EnsureNativeDependencySearchPath(options.LibraryPath); + + if (!string.IsNullOrWhiteSpace(options.ProxySocket)) + { + Environment.SetEnvironmentVariable("PKCS11_PROXY_SOCKET", options.ProxySocket); + Environment.SetEnvironmentVariable("PKCS11_DAEMON_SOCKET", options.ProxySocket); + } + + if (!string.IsNullOrWhiteSpace(options.ToolPath)) + { + var toolDir = Path.GetDirectoryName(options.ToolPath); + if (!string.IsNullOrWhiteSpace(toolDir)) + EnsurePathContains(toolDir); + } + + WarmupWithPkcs11Tool(options); + Initialized = true; + } + } + + private static void WarmupWithPkcs11Tool(Pkcs11Options options) + { + if (string.IsNullOrWhiteSpace(options.ToolPath)) + return; + + if (!File.Exists(options.ToolPath)) + return; + + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = options.ToolPath, + Arguments = $"--module \"{options.LibraryPath}\" -L", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + process.Start(); + process.WaitForExit(15000); + } + + private static void EnsureNativeDependencySearchPath(string libraryPath) + { + var directory = Path.GetDirectoryName(libraryPath); + if (string.IsNullOrWhiteSpace(directory)) + return; + + EnsurePathContains(directory); + } + + private static void EnsurePathContains(string directory) + { + var currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var entries = currentPath.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (entries.Any(p => string.Equals(p, directory, StringComparison.OrdinalIgnoreCase))) + return; + + var updatedPath = string.IsNullOrWhiteSpace(currentPath) + ? directory + : directory + ";" + currentPath; + + Environment.SetEnvironmentVariable("PATH", updatedPath); + } +} diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11PdfSigningRsa.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11PdfSigningRsa.cs new file mode 100644 index 00000000..3b9b0d68 --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11PdfSigningRsa.cs @@ -0,0 +1,64 @@ +using EnvelopeGenerator.Application.Common.Configurations; +using Net.Pkcs11Interop.Common; +using Net.Pkcs11Interop.HighLevelAPI; +using System.Security.Cryptography; + +namespace EnvelopeGenerator.Infrastructure.Crypt.Services; + +internal sealed class Pkcs11PdfSigningRsa(Pkcs11Options options) : RSA +{ + public override int KeySize + { + get => KeySizeValue; + set => KeySizeValue = value; + } + + public override KeySizes[] LegalKeySizes => [new(1024, 8192, 8)]; + public override string? KeyExchangeAlgorithm => "RSA"; + public override string SignatureAlgorithm => "RSA"; + + public override byte[] SignHash(byte[] hash, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding) + { + if (padding != RSASignaturePadding.Pkcs1) + throw new NotSupportedException("Only RSA PKCS#1 v1.5 padding is supported."); + + byte[] digestInfo = Pkcs11DigestInfoBuilder.Build(hash, hashAlgorithm); + try + { + return Pkcs11SessionRunner.Run(options, session => + { + var attrs = new List + { + session.Factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, CKO.CKO_PRIVATE_KEY), + session.Factories.ObjectAttributeFactory.Create(CKA.CKA_LABEL, options.PrivateKeyLabel) + }; + + var handles = session.FindAllObjects(attrs); + if (handles.Count == 0) + throw new InvalidOperationException($"Private key '{options.PrivateKeyLabel}' not found in token."); + + var mechanism = session.Factories.MechanismFactory.Create(CKM.CKM_RSA_PKCS); + return session.Sign(mechanism, handles[0], digestInfo); + }); + } + catch when (Pkcs11ToolBridge.IsAvailable(options)) + { + return Pkcs11ToolBridge.SignDigestInfo(options, digestInfo); + } + } + + public override bool VerifyHash(byte[] hash, byte[] signature, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding) + => throw new NotSupportedException(); + + public override byte[] Decrypt(byte[] data, RSAEncryptionPadding padding) + => throw new NotSupportedException(); + + public override byte[] Encrypt(byte[] data, RSAEncryptionPadding padding) + => throw new NotSupportedException(); + + public override RSAParameters ExportParameters(bool includePrivateParameters) + => throw new NotSupportedException(); + + public override void ImportParameters(RSAParameters parameters) + => throw new NotSupportedException(); +} diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11SessionRunner.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11SessionRunner.cs new file mode 100644 index 00000000..c03b3ffe --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11SessionRunner.cs @@ -0,0 +1,124 @@ +using EnvelopeGenerator.Application.Common.Configurations; +using Net.Pkcs11Interop.Common; +using Net.Pkcs11Interop.HighLevelAPI; + +namespace EnvelopeGenerator.Infrastructure.Crypt.Services; + +internal static class Pkcs11SessionRunner +{ + public static T Run(Pkcs11Options options, Func action) + { + Pkcs11InteropBootstrap.EnsureLoaded(options); + + var factories = new Pkcs11InteropFactories(); + using var pkcs11 = LoadLibraryWithFallback(factories, options.LibraryPath, options.ProxySocket); + var slot = ResolveSlot(pkcs11.GetSlotList(SlotsType.WithTokenPresent), options); + + using var session = slot.OpenSession(SessionType.ReadOnly); + session.Login(CKU.CKU_USER, options.UserPin); + + try + { + return action(session); + } + finally + { + session.Logout(); + } + } + + private static IPkcs11Library LoadLibraryWithFallback(Pkcs11InteropFactories factories, string libraryPath, string? proxySocket) + { + List errors = []; + foreach (string candidate in BuildLibraryCandidates(libraryPath)) + { + try + { + return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.MultiThreaded); + } + catch (Exception ex) + { + errors.Add(new InvalidOperationException($"{candidate} | MultiThreaded+WithFunctionList", ex)); + } + + try + { + return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.SingleThreaded); + } + catch (Exception ex) + { + errors.Add(new InvalidOperationException($"{candidate} | SingleThreaded+WithFunctionList", ex)); + } + + try + { + return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.MultiThreaded, InitType.WithoutFunctionList); + } + catch (Exception ex) + { + errors.Add(new InvalidOperationException($"{candidate} | MultiThreaded+WithoutFunctionList", ex)); + } + + try + { + return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.SingleThreaded, InitType.WithoutFunctionList); + } + catch (Exception ex) + { + errors.Add(new InvalidOperationException($"{candidate} | SingleThreaded+WithoutFunctionList", ex)); + } + } + + var socketInfo = string.IsNullOrWhiteSpace(proxySocket) ? "" : proxySocket; + throw new AggregateException($"Unable to initialize PKCS#11 library '{libraryPath}' with any supported initialization mode. PKCS11_PROXY_SOCKET={socketInfo}", errors); + } + + private static IEnumerable BuildLibraryCandidates(string configuredPath) + { + var unique = new HashSet(StringComparer.OrdinalIgnoreCase) + { + configuredPath + }; + + var directory = Path.GetDirectoryName(configuredPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + string[] alternates = + [ + "client.dll", + "pkcs11-proxy.dll", + "libpkcs11-proxy.dll" + ]; + + foreach (var fileName in alternates) + { + var candidate = Path.Combine(directory, fileName); + if (File.Exists(candidate)) + unique.Add(candidate); + } + } + + return unique; + } + + private static ISlot ResolveSlot(IEnumerable slots, Pkcs11Options options) + { + if (options.SlotId.HasValue) + { + var bySlotId = slots.FirstOrDefault(s => s.SlotId == options.SlotId.Value); + if (bySlotId is not null) + return bySlotId; + } + + if (!string.IsNullOrWhiteSpace(options.TokenLabel)) + { + var byTokenLabel = slots.FirstOrDefault(s => + string.Equals(s.GetTokenInfo().Label?.Trim(), options.TokenLabel.Trim(), StringComparison.Ordinal)); + + if (byTokenLabel is not null) + return byTokenLabel; + } + + throw new InvalidOperationException("Unable to resolve PKCS#11 slot. Check Crypt:Pkcs11:SlotId / TokenLabel."); + } +} diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11ToolBridge.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11ToolBridge.cs new file mode 100644 index 00000000..8fb26978 --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/Pkcs11ToolBridge.cs @@ -0,0 +1,147 @@ +using EnvelopeGenerator.Application.Common.Configurations; +using System.Diagnostics; +using System.Security.Cryptography; + +namespace EnvelopeGenerator.Infrastructure.Crypt.Services; + +internal static class Pkcs11ToolBridge +{ + public static bool IsAvailable(Pkcs11Options options) + { + return !string.IsNullOrWhiteSpace(options.ToolPath) && File.Exists(options.ToolPath); + } + + public static byte[] ReadCertificateBytes(Pkcs11Options options) + { + EnsureAvailable(options); + + var outputFile = Path.Combine(Path.GetTempPath(), $"pkcs11-cert-{Guid.NewGuid():N}.der"); + try + { + var args = CreateBaseArgs(options); + args.Add("--read-object"); + args.Add("--type"); + args.Add("cert"); + args.Add("--label"); + args.Add(options.CertificateLabel); + args.Add("--output-file"); + args.Add(outputFile); + + Execute(options, args); + return File.ReadAllBytes(outputFile); + } + finally + { + DeleteIfExists(outputFile); + } + } + + public static byte[] SignDigestInfo(Pkcs11Options options, byte[] digestInfo) + { + EnsureAvailable(options); + + var inputFile = Path.Combine(Path.GetTempPath(), $"pkcs11-sign-in-{Guid.NewGuid():N}.bin"); + var outputFile = Path.Combine(Path.GetTempPath(), $"pkcs11-sign-out-{Guid.NewGuid():N}.bin"); + + try + { + File.WriteAllBytes(inputFile, digestInfo); + + var args = CreateBaseArgs(options); + args.Add("--sign"); + args.Add("--mechanism"); + args.Add("RSA-PKCS"); + args.Add("--type"); + args.Add("privkey"); + args.Add("--label"); + args.Add(options.PrivateKeyLabel); + args.Add("--input-file"); + args.Add(inputFile); + args.Add("--output-file"); + args.Add(outputFile); + + Execute(options, args); + return File.ReadAllBytes(outputFile); + } + finally + { + DeleteIfExists(inputFile); + DeleteIfExists(outputFile); + } + } + + private static List CreateBaseArgs(Pkcs11Options options) + { + var args = new List + { + "--module", + options.LibraryPath, + "--login", + "--pin", + options.UserPin + }; + + if (options.SlotId.HasValue) + { + args.Add("--slot"); + args.Add(options.SlotId.Value.ToString()); + } + else if (!string.IsNullOrWhiteSpace(options.TokenLabel)) + { + args.Add("--token-label"); + args.Add(options.TokenLabel); + } + + return args; + } + + private static void Execute(Pkcs11Options options, IReadOnlyList arguments) + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = options.ToolPath!, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + foreach (var argument in arguments) + process.StartInfo.ArgumentList.Add(argument); + + if (!string.IsNullOrWhiteSpace(options.ProxySocket)) + { + process.StartInfo.Environment["PKCS11_PROXY_SOCKET"] = options.ProxySocket; + process.StartInfo.Environment["PKCS11_DAEMON_SOCKET"] = options.ProxySocket; + } + + process.Start(); + + string stdOut = process.StandardOutput.ReadToEnd(); + string stdErr = process.StandardError.ReadToEnd(); + + if (!process.WaitForExit(30000)) + { + try { process.Kill(entireProcessTree: true); } catch { } + throw new TimeoutException($"pkcs11-tool timed out. Args: {string.Join(' ', process.StartInfo.ArgumentList)}"); + } + + if (process.ExitCode != 0) + throw new InvalidOperationException($"pkcs11-tool failed with exit code {process.ExitCode}. StdOut: {stdOut} StdErr: {stdErr}"); + } + + private static void EnsureAvailable(Pkcs11Options options) + { + if (!IsAvailable(options)) + throw new InvalidOperationException("pkcs11-tool is not available. Configure Crypt:Pkcs11:ToolPath."); + } + + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + File.Delete(path); + } +}