Switch to DocumentService API, remove PKCS#11 support

Replaced PKCS#11-based cryptographic operations with a new DocumentService API integration. Removed all PKCS#11-related code, configurations, and dependencies, including options, self-test status, and signing logic. Updated `FinalizeEnvelopeCommand` to include a notification service and added base64 conversion for documents. Updated `ServiceCollectionExtensions` to register the new DocumentService client. Removed outdated documentation and diagrams related to PKCS#11. Updated project files and configurations to reflect these changes and incremented the version number. Added a comment in `EnvelopeFinalizationWorker` for future RabbitMQ integration.
This commit is contained in:
2026-10-25 18:52:12 +01:00
parent 8ba9e8ec20
commit f7af3776c2
33 changed files with 74 additions and 2277 deletions

View File

@@ -1,9 +1,9 @@
using EnvelopeGenerator.Application.Common.Configurations;
using DocumentService.Client.Configuration;
using DocumentService.Client.Extensions;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Infrastructure.Crypt.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection;
namespace EnvelopeGenerator.Infrastructure.Crypt.DependencyInjection;
@@ -11,16 +11,13 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCryptInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services
.AddOptions<Pkcs11Options>()
.Bind(configuration.GetSection(Pkcs11Options.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
var baseUrl = configuration["DocumentService:BaseUrl"]
?? throw new InvalidOperationException("DocumentService:BaseUrl is not configured.");
services.AddDocumentServiceClients(options => options.BaseUrl = baseUrl);
services.AddSingleton<IPdfDigitalSignatureService, PdfDigitalSignatureService>();
services.AddSingleton<Pkcs11SelfTestStatusStore>();
services.AddSingleton<IPkcs11SelfTestStatusProvider>(sp => sp.GetRequiredService<Pkcs11SelfTestStatusStore>());
services.AddHostedService<Pkcs11StartupSelfTestHostedService>();
return services;
}
}

View File

@@ -7,30 +7,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DocumentService.Client" Version="1.3.0" />
<PackageReference Include="DevExpress.Document.Processor" Version="25.2.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
<PackageReference Include="DocumentService.Client" Version="1.3.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.6" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="9.0.6" />
<PackageReference Include="Pkcs11Interop" Version="5.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="resources\pkcs11\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<TargetPath>pkcs11\%(RecursiveDir)%(Filename)%(Extension)</TargetPath>
</None>
</ItemGroup>
</Project>

View File

@@ -1,16 +0,0 @@
using DevExpress.Pdf;
using EnvelopeGenerator.Application.Common.Enums;
namespace EnvelopeGenerator.Infrastructure.Crypt.Helper;
public static class PdfCertificationLevelExtensions
{
public static PdfCertificationLevel ToDevExpress(this PdfSignatureCertificationLevel value) => value switch
{
PdfSignatureCertificationLevel.NoChangesAllowed => PdfCertificationLevel.NoChangesAllowed,
PdfSignatureCertificationLevel.FillForms => PdfCertificationLevel.FillForms,
PdfSignatureCertificationLevel.NoCertification => PdfCertificationLevel.NoCertification,
PdfSignatureCertificationLevel.FillFormsAndAnnotate => PdfCertificationLevel.FillFormsAndAnnotate,
_ => throw new ArgumentOutOfRangeException(nameof(value), value, null),
};
}

View File

@@ -1,631 +1,77 @@
using DevExpress.Office.DigitalSignatures;
using DevExpress.Pdf;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Configurations;
using DocumentService.Client.Interfaces;
using DocumentService.Client.Models.Requests;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Infrastructure.Crypt.Helper;
using Microsoft.Extensions.Options;
using System.Formats.Asn1;
using System.IO;
using System.Security.Cryptography;
using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.RegularExpressions;
using ClientCertLevel = DocumentService.Client.Models.Requests.PdfSignatureCertificationLevel;
using ClientCoordUnit = DocumentService.Client.Models.Requests.PdfSignatureCoordinateUnit;
using ClientCoordOrigin = DocumentService.Client.Models.Requests.PdfSignatureCoordinateOrigin;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options) : IPdfDigitalSignatureService
/// <summary>
/// Proxy that forwards PDF signing to the remote DocumentService API via <see cref="IPdfSignatureClient"/>.
/// </summary>
public sealed class PdfDigitalSignatureService(IPdfSignatureClient signatureClient) : IPdfDigitalSignatureService
{
private static readonly Regex SignatureReasonMetaRegex = new(@"\|\s*SFMETA:v1;deid=(?<deid>\d+);sha256=(?<sha>[A-Fa-f0-9]{64})$", RegexOptions.CultureInvariant | RegexOptions.Compiled);
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto request, Stream? image = null, CancellationToken cancellationToken = default)
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, Stream? image = null, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var (workingInput, ownsWorkingInput) = await EnsureSeekableInputAsync(inputPdf, cancellationToken);
try
{
workingInput.Position = 0;
var sourceHashHex = Convert.ToHexString(await SHA256.HashDataAsync(workingInput, cancellationToken));
workingInput.Position = 0;
var pkcs11Options = options.Value;
var reasonWithMeta = BuildReasonWithMeta(request.Reason ?? pkcs11Options.DefaultReason, request.DocumentElementId, sourceHashHex);
using var signer = new PdfDocumentSigner(workingInput);
var digestCalculator = new DigestCalculator(HashAlgorithmType.SHA256);
var signerInfo = new ExternalSignerInfo(PdfSignatureType.Pkcs7, 32768, digestCalculator);
var builder = request.Appearance is null ? new PdfDeferredSignatureBuilder(signerInfo) : new (signerInfo, new PdfSignatureFieldInfo(request.Appearance.PageNumber)
{
Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}",
SignatureBounds = BuildSignatureBounds(request.Appearance, workingInput)
});
builder.Name = request.Name;
builder.Reason = reasonWithMeta;
builder.Location = request.Location;
builder.ContactInfo = BuildContactInfo(request.Email, request.Phone);
builder.SigningTime = pkcs11Options.UseUtcSigningTime ? DateTimeOffset.UtcNow : DateTimeOffset.Now;
builder.ApplicationName = pkcs11Options.ApplicationName;
builder.CertificationLevel = request.CertificationLevel.ToDevExpress();
if (request.Appearance is not null)
{
var fColor = request.Appearance.Font.Color;
var font = new PdfSignatureFontSettings(request.Appearance.Font.FamilyName)
{
Size = request.Appearance.Font.Size,
Color = new PdfRGBColor(fColor.R / 255.0, fColor.G / 255.0, fColor.B / 255.0),
Bold = false,
Italic = true
};
var appearance = new PdfSignatureAppearance
{
ShowLabels = request.Appearance.ShowLabels,
ShowName = request.Appearance.ShowName,
ShowDate = request.Appearance.ShowDate,
ShowReason = request.Appearance.ShowReason,
ShowLocation = request.Appearance.ShowLocation,
ShowDistinguishedName = request.Appearance.ShowDistinguishedName,
DateTimeFormat = request.Appearance.DateTimeFormat,
SignatureDetailsFont = font,
SignatureNameFont = font,
AppearanceType = PdfSignatureAppearanceType.Image,
};
if (image is not null)
using (var memoryStream = new MemoryStream())
{
image.CopyTo(memoryStream);
var bytes = memoryStream.ToArray();
appearance.SetBackgroundImageData(bytes);
}
builder.SetSignatureAppearance(appearance);
}
else if (image is not null)
throw new BadRequestException("Signature appearance is not specified, but signature image is provided.");
var deferredSigner = signer.SignDeferred(builder);
var externalSigner = new Pkcs11ExternalSigner(pkcs11Options);
deferredSigner.Sign(outputPdf, externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid));
}
finally
{
if (ownsWorkingInput)
await workingInput.DisposeAsync();
}
}
private static PdfRectangle BuildSignatureBounds(PdfSignatureAppearanceDto appearance, Stream inputPdf)
{
var left = appearance.Left;
var bottom = appearance.Bottom;
var width = appearance.Width;
var height = appearance.Height;
if (appearance.CoordinateUnit == PdfSignatureCoordinateUnit.Inch)
{
left *= 72.0;
bottom *= 72.0;
width *= 72.0;
height *= 72.0;
}
if (width <= 0 || height <= 0)
throw new BadRequestException("Signature appearance width and height must be greater than zero.");
if (appearance.CoordinateOrigin == PdfSignatureCoordinateOrigin.TopLeft)
{
var pageHeight = GetPageHeightInPoints(inputPdf, appearance.PageNumber);
bottom = pageHeight - bottom - height;
}
return new PdfRectangle(left, bottom, left + width, bottom + height);
}
private static double GetPageHeightInPoints(Stream inputPdf, int pageNumber)
{
if (pageNumber <= 0)
throw new BadRequestException("Signature page number must be greater than zero.");
var previousPosition = inputPdf.CanSeek ? inputPdf.Position : 0;
try
{
if (inputPdf.CanSeek)
inputPdf.Position = 0;
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(inputPdf);
var pageCount = processor.Document.Pages.Count;
if (pageNumber > pageCount)
throw new BadRequestException($"Signature page number {pageNumber} is out of range. Document has {pageCount} page(s).");
return processor.Document.Pages[pageNumber - 1].CropBox.Height;
}
finally
{
if (inputPdf.CanSeek)
inputPdf.Position = previousPosition;
}
}
//TODO: verify and optimize (remove memory stream etc.) the check algortihm
public async Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
var imageBytes = image is null ? null : await ReadAllBytesAsync(image, cancellationToken);
using var pdfStream = new MemoryStream(pdfBytes);
using var signer = new PdfDocumentSigner(inputPdf);
var signatures = signer.GetSignatureInfo();
var byteRanges = ParseByteRanges(pdfBytes);
if (signatures is null || signatures.Count == 0)
{
return new PdfSignatureCheckResultDto(
Signed: false,
Signatures: []);
}
var items = new List<PdfSignatureCheckItemDto>(signatures.Count);
for (var i = 0; i < signatures.Count; i++)
{
var signature = signatures[i];
try
{
var pkcs7 = signer.GetPdfPkcs7Signature(signature.FieldName);
bool valid = pkcs7.VerifySignature();
var byteRange = i < byteRanges.Count ? byteRanges[i] : null;
var coversWholeDocument = CoversWholeDocument(byteRange, pdfBytes.LongLength);
var certificate = pkcs7.GetSignatureCertificate();
var issuedTo = ParsePrincipal(certificate?.Subject);
var issuedBy = ParsePrincipal(certificate?.Issuer);
var reasonMeta = ParseReasonMeta(signature.Reason);
var contact = ParseContactInfo(signature.ContactInfo);
items.Add(new PdfSignatureCheckItemDto(
CoversWholeDoc: coversWholeDocument,
Reason: signature.Reason,
BusinessReason: reasonMeta.BusinessReason,
Location: signature.Location,
DeclaredName: signature.SignerName,
ContactInfo: signature.ContactInfo,
Email: contact.Email,
Phone: contact.Phone,
DigestAlgorithm: InferDigestAlgorithm(certificate),
SignatureAlgorithm: InferSignatureAlgorithm(certificate),
SignerName: issuedTo.CommonName ?? signature.SignerName,
Issuer: issuedBy.Full,
SerialNumber: ToColonSerial(certificate?.SerialNumber),
ValidFrom: certificate is null ? null : new DateTimeOffset(certificate.NotBefore).ToUniversalTime(),
ValidTo: certificate is null ? null : new DateTimeOffset(certificate.NotAfter).ToUniversalTime(),
ExpiryCheckedAgainst: "now",
Certificates: [BuildCertificateInfo(certificate, DateTimeOffset.UtcNow)],
Unchanged: valid && coversWholeDocument,
FieldName: signature.FieldName,
IsCryptographicallyValid: valid,
SigningTime: signature.SigningTime?.ToUniversalTime().ToString("O"),
Filter: signature.Filter,
SubFilter: signature.SubFilter,
ApplicationName: null,
DocumentElementId: reasonMeta.DocumentElementId,
OriginalDocumentSha256: reasonMeta.OriginalDocumentSha256,
MessageDigestHex: TryExtractMessageDigestHex(signature.Contents),
Error: null));
}
catch (Exception ex)
{
var reasonMeta = ParseReasonMeta(signature.Reason);
var contact = ParseContactInfo(signature.ContactInfo);
items.Add(new PdfSignatureCheckItemDto(
CoversWholeDoc: false,
Reason: signature.Reason,
BusinessReason: reasonMeta.BusinessReason,
Location: signature.Location,
DeclaredName: signature.SignerName,
ContactInfo: signature.ContactInfo,
Email: contact.Email,
Phone: contact.Phone,
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,
DocumentElementId: reasonMeta.DocumentElementId,
OriginalDocumentSha256: reasonMeta.OriginalDocumentSha256,
MessageDigestHex: null,
Error: ex.Message));
}
}
return new PdfSignatureCheckResultDto(
Signed: true,
Signatures: items);
using var signed = await signatureClient.SignAsync(pdfBytes, Map(options), imageBytes, cancellationToken);
await signed.CopyToAsync(outputPdf, cancellationToken);
}
private static async Task<(Stream Stream, bool OwnsStream)> EnsureSeekableInputAsync(Stream input, CancellationToken cancellationToken)
public Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Signature checking is handled by DocumentService and is not available through this proxy.");
private static SignPdfOptions Map(PdfSignOptionsDto? o) => new()
{
if (input.CanSeek)
DocumentElementId = o?.DocumentElementId ?? 0,
Name = o?.Name ?? string.Empty,
Reason = o?.Reason,
Location = o?.Location,
Email = o?.Email,
Phone = o?.Phone,
ApplicationName = o?.ApplicationName,
CertificationLevel = (ClientCertLevel)(o?.CertificationLevel ?? EnvelopeGenerator.Application.Common.Enums.PdfSignatureCertificationLevel.NoCertification),
Appearance = o?.Appearance is null ? null : MapAppearance(o.Appearance)
};
private static PdfSignatureAppearance MapAppearance(PdfSignatureAppearanceDto a) => new()
{
PageNumber = a.PageNumber,
Left = a.Left,
Bottom = a.Bottom,
Width = a.Width,
Height = a.Height,
ShowLabels = a.ShowLabels,
ShowName = a.ShowName,
ShowDate = a.ShowDate,
ShowReason = a.ShowReason,
ShowLocation = a.ShowLocation,
ShowDistinguishedName = a.ShowDistinguishedName,
DateTimeFormat = a.DateTimeFormat,
CoordinateUnit = (ClientCoordUnit)a.CoordinateUnit,
CoordinateOrigin = (ClientCoordOrigin)a.CoordinateOrigin,
Font = new PdfSignatureFont
{
input.Position = 0;
return (input, false);
FamilyName = a.Font.FamilyName,
Size = a.Font.Size,
IsBold = a.Font.IsBold,
IsItalic = a.Font.IsItalic,
IsUnderline = a.Font.IsUnderline,
Color = new PdfSignatureColor { R = a.Font.Color.R, G = a.Font.Color.G, B = a.Font.Color.B }
}
};
var buffered = new MemoryStream();
await input.CopyToAsync(buffered, cancellationToken);
buffered.Position = 0;
return (buffered, true);
}
private static async Task<byte[]> ReadAllBytesAsync(Stream input, CancellationToken cancellationToken)
private static async Task<byte[]> ReadAllBytesAsync(Stream stream, CancellationToken cancellationToken)
{
if (input is MemoryStream memory && memory.TryGetBuffer(out var buffer))
if (stream is MemoryStream memory && memory.TryGetBuffer(out var buffer))
return buffer.AsSpan(0, (int)memory.Length).ToArray();
if (input.CanSeek)
input.Position = 0;
using var ms = new MemoryStream();
await input.CopyToAsync(ms, cancellationToken);
return ms.ToArray();
}
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, out var keySize) ? keySize : null);
}
private static bool TryGetKeySize(X509Certificate2 certificate, out int keySize)
{
keySize = 0;
try
{
using var rsa = certificate.GetRSAPublicKey();
if (rsa is null)
return false;
keySize = rsa.KeySize;
return true;
}
catch (CryptographicException)
{
return false;
}
}
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 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;
}
}
private static List<long[]> ParseByteRanges(byte[] pdfBytes)
{
var text = Encoding.Latin1.GetString(pdfBytes);
var matches = Regex.Matches(text, @"/ByteRange\s*\[(?<values>[\d\s]+)\]", RegexOptions.CultureInvariant);
var ranges = new List<long[]>(matches.Count);
foreach (Match match in matches)
{
var raw = match.Groups["values"].Value;
var parts = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
var values = new long[parts.Length];
var ok = true;
for (var i = 0; i < parts.Length; i++)
{
if (!long.TryParse(parts[i], out values[i]))
{
ok = false;
break;
}
}
if (ok && values.Length >= 4 && values.Length % 2 == 0)
ranges.Add(values);
}
return ranges;
}
private static bool CoversWholeDocument(long[]? byteRange, long fileLength)
{
if (byteRange is null || byteRange.Length < 4 || byteRange.Length % 2 != 0)
return false;
if (byteRange[0] != 0)
return false;
long previousEnd = -1;
for (var i = 0; i < byteRange.Length; i += 2)
{
var start = byteRange[i];
var length = byteRange[i + 1];
if (start < 0 || length < 0)
return false;
var end = start + length;
if (previousEnd > start)
return false;
previousEnd = end;
}
return previousEnd == fileLength;
}
private static long GetSignedRevisionLength(long[] byteRange)
{
if (byteRange.Length < 2)
return -1;
var start = byteRange[^2];
var length = byteRange[^1];
if (start < 0 || length < 0)
return -1;
return start + length;
}
private static string BuildReasonWithMeta(string? businessReason, long documentElementId, string sourceHashHex)
{
var trimmedReason = string.IsNullOrWhiteSpace(businessReason) ? null : businessReason.Trim();
var prefix = string.IsNullOrWhiteSpace(trimmedReason) ? string.Empty : trimmedReason + " ";
return $"{prefix}| SFMETA:v1;deid={documentElementId};sha256={sourceHashHex}";
}
private static string? BuildContactInfo(string? email, string? phone)
{
var normalizedEmail = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
var normalizedPhone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim();
if (normalizedEmail is null && normalizedPhone is null)
return null;
if (normalizedEmail is null)
return $"tel:{normalizedPhone}";
if (normalizedPhone is null)
return $"mailto:{normalizedEmail}";
return $"mailto:{normalizedEmail};tel:{normalizedPhone}";
}
private static (string? Email, string? Phone) ParseContactInfo(string? contactInfo)
{
if (string.IsNullOrWhiteSpace(contactInfo))
return (null, null);
string? email = null;
string? phone = null;
foreach (var part in contactInfo.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (part.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
{
email = part[7..].Trim();
continue;
}
if (part.StartsWith("tel:", StringComparison.OrdinalIgnoreCase))
{
phone = part[4..].Trim();
continue;
}
if (email is null && part.Contains('@', StringComparison.Ordinal))
{
email = part.Trim();
continue;
}
if (phone is null)
phone = part.Trim();
}
return (
string.IsNullOrWhiteSpace(email) ? null : email,
string.IsNullOrWhiteSpace(phone) ? null : phone);
}
private static (string? BusinessReason, long? DocumentElementId, string? OriginalDocumentSha256) ParseReasonMeta(string? reason)
{
if (string.IsNullOrWhiteSpace(reason))
return (reason, null, null);
var match = SignatureReasonMetaRegex.Match(reason);
if (!match.Success)
return (reason, null, null);
var businessReason = reason[..match.Index].Trim();
if (string.IsNullOrWhiteSpace(businessReason))
businessReason = null;
long? parsedDocumentElementId = long.TryParse(match.Groups["deid"].Value, out var deid) ? deid : null;
return (
businessReason,
parsedDocumentElementId,
match.Groups["sha"].Value.ToUpperInvariant());
using var copy = new MemoryStream();
await stream.CopyToAsync(copy, cancellationToken);
return copy.ToArray();
}
}

View File

@@ -1,43 +0,0 @@
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;
}
}

View File

@@ -1,49 +0,0 @@
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<byte[]> certificates = [ReadCertificateBytesWithFallback(options)];
protected override IDigestCalculator DigestCalculator => digestCalculator;
protected override string SigningAlgorithmOID => "1.2.840.113549.1.1.1";
protected override IEnumerable<byte[]> 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<Net.Pkcs11Interop.HighLevelAPI.IObjectAttribute>
{
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> { Net.Pkcs11Interop.Common.CKA.CKA_VALUE });
return values[0].GetValueAsByteArray();
});
}
catch when (Pkcs11ToolBridge.IsAvailable(options))
{
return Pkcs11ToolBridge.ReadCertificateBytes(options);
}
}
}

View File

@@ -1,119 +0,0 @@
using EnvelopeGenerator.Application.Common.Configurations;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
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)
{
if (Initialized)
return;
lock (Sync)
{
if (Initialized)
return;
var libraryPath = ResolveLibraryPath(options);
var toolPath = ResolveToolPath(options);
options.SetResolvedPaths(libraryPath, toolPath);
EnsureNativeDependencySearchPath(options.ResolvedLibraryPath);
if (!string.IsNullOrWhiteSpace(options.ProxySocket))
{
Environment.SetEnvironmentVariable("PKCS11_PROXY_SOCKET", options.ProxySocket);
Environment.SetEnvironmentVariable("PKCS11_DAEMON_SOCKET", options.ProxySocket);
}
if (!string.IsNullOrWhiteSpace(options.ResolvedToolPath))
{
var toolDir = Path.GetDirectoryName(options.ResolvedToolPath);
if (!string.IsNullOrWhiteSpace(toolDir))
EnsurePathContains(toolDir);
}
WarmupWithPkcs11Tool(options);
Initialized = true;
}
}
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.ResolvedToolPath))
return;
if (!File.Exists(options.ResolvedToolPath))
return;
var process = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = options.ResolvedToolPath,
Arguments = $"--module \"{options.ResolvedLibraryPath}\" -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);
}
}

View File

@@ -1,64 +0,0 @@
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<IObjectAttribute>
{
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();
}

View File

@@ -1,24 +0,0 @@
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);
}
}

View File

@@ -1,124 +0,0 @@
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<T>(Pkcs11Options options, Func<ISession, T> action)
{
Pkcs11InteropBootstrap.EnsureLoaded(options);
var factories = new Pkcs11InteropFactories();
using var pkcs11 = LoadLibraryWithFallback(factories, options.ResolvedLibraryPath, 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<Exception> 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) ? "<empty>" : proxySocket;
throw new AggregateException($"Unable to initialize PKCS#11 library '{libraryPath}' with any supported initialization mode. PKCS11_PROXY_SOCKET={socketInfo}", errors);
}
private static IEnumerable<string> BuildLibraryCandidates(string configuredPath)
{
var unique = new HashSet<string>(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<ISlot> 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.");
}
}

View File

@@ -1,112 +0,0 @@
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<Pkcs11Options> options,
Pkcs11SelfTestStatusStore statusStore,
ILogger<Pkcs11StartupSelfTestHostedService> 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<IObjectAttribute>
{
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.");
}
}

View File

@@ -1,147 +0,0 @@
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.ResolvedToolPath) && File.Exists(options.ResolvedToolPath);
}
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<string> CreateBaseArgs(Pkcs11Options options)
{
var args = new List<string>
{
"--module",
options.ResolvedLibraryPath,
"--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<string> arguments)
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = options.ResolvedToolPath!,
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 at resolved pkcs11 assets path.");
}
private static void DeleteIfExists(string path)
{
if (File.Exists(path))
File.Delete(path);
}
}

View File

@@ -1,301 +0,0 @@
# FES Signature Infrastructure Plan
## 1. Goal
Build a reusable and modular cryptography infrastructure in `EnvelopeGenerator.Infrastructure.Crypt` to support FES (Advanced Electronic Signature) workflows.
This module must:
- use PKCS#11 providers (SoftHSMv2 first, real HSM later),
- stay independent from UI/API concerns,
- expose clean interfaces to `Application` layer use-cases,
- support future extensions (timestamping, certificate chain validation, multiple key algorithms).
---
## 2. Context and Constraints
1. SoftHSMv2 is **not** a REST service. It is a PKCS#11 module loaded as a native library.
2. `EnvelopeGenerator.Server` is presentation only; cryptographic implementation belongs to infrastructure.
3. Clean Architecture boundaries must be respected:
- `Application` depends on abstractions.
- `Infrastructure.Crypt` implements abstractions.
4. Initial target framework is `net8.0`.
5. We need production-ready behavior, not only PoC signing.
---
## 3. High-Level Design
## 3.1 Architecture slice
- **Application layer (contracts/use-cases):**
- request signing hash/document,
- request certificate/public key material,
- request verification,
- consume signature evidence result.
- **Infrastructure.Crypt layer (this project):**
- PKCS#11 session handling,
- key/certificate object discovery,
- hash signing operations,
- local cryptographic verification,
- certificate parsing helpers,
- error normalization.
## 3.2 Provider model
Define provider-agnostic APIs and add one concrete provider first:
- `Pkcs11SignatureProvider` (primary)
- future providers:
- `RemoteSigningProvider` (if a dedicated signing gateway is introduced)
- `MockSignatureProvider` (for deterministic test scenarios)
---
## 4. Deliverables (Phase by Phase)
## Phase 0 - Contracts and Domain Language
Create neutral models and interfaces in `Infrastructure.Crypt` (or shared abstractions if needed later):
- `SigningAlgorithm` (enum/value object)
- `Pkcs11ProviderOptions`
- `KeyLocator` (label/id/slot/token selectors)
- `SignHashRequest`
- `SignHashResult`
- `VerifySignatureRequest`
- `VerifySignatureResult`
- `CertificateDescriptor`
- `SignatureEvidence` (technical evidence payload)
Interfaces:
- `ISignatureProvider`
- `ICertificateProvider`
- `ISignatureVerifier`
- `ICryptographicHealthCheck`
Acceptance criteria:
- no dependency on ASP.NET types,
- no UI/API naming leakage,
- all model names are business-neutral.
## Phase 1 - PKCS#11 Core Adapter (SoftHSM-ready)
Implement:
- `Pkcs11LibraryLoader`
- `Pkcs11SessionFactory`
- `Pkcs11ObjectFinder`
- `Pkcs11SignatureProvider`
- `Pkcs11CertificateProvider`
Capabilities:
1. load module path from options (`libsofthsm2.so` or proxy module when required),
2. enumerate slots/tokens,
3. open read-write session,
4. login with user pin,
5. find private key by label/id,
6. sign digest with selected mechanism,
7. read certificate object by label/id.
Acceptance criteria:
- deterministic disposal of sessions,
- no leaked native handles,
- detailed typed errors (not raw exception strings only).
## Phase 2 - Verification and Certificate Utilities
Implement:
- `DefaultSignatureVerifier`
- `X509CertificateParser`
- optional `CertificateChainValidator` (toggleable)
Capabilities:
1. verify signature using returned certificate/public key,
2. expose certificate metadata (`thumbprint`, `subject`, `issuer`, `notBefore`, `notAfter`),
3. map validation failures into stable error codes.
Acceptance criteria:
- same request yields same verification outcome,
- invalid signature returns structured negative result.
## Phase 3 - Evidence Builder
Implement:
- `SignatureEvidenceBuilder`
Evidence payload should contain:
- transaction/correlation id,
- algorithm,
- hash metadata,
- key locator snapshot (safe subset),
- certificate fingerprint and subject,
- sign timestamp (UTC),
- verification outcome,
- provider type (`PKCS11`).
Acceptance criteria:
- no secret values in evidence (pin, private key id internals),
- evidence serializable for storage/audit.
## Phase 4 - Dependency Injection Module
Implement:
- `CryptInfrastructureDependencyInjection` extension methods
Methods:
- `AddCryptInfrastructure(...)`
- `AddPkcs11Provider(...)`
Acceptance criteria:
- one-line registration from composition root,
- options validation on startup.
## Phase 5 - Test Suite (`EnvelopeGenerator.Tests`)
Add test categories for `net8.0`:
1. `Unit`:
- request validation,
- algorithm mapping,
- evidence building,
- error mapping.
2. `Integration` (conditional/manual profile):
- SoftHSM slot listing,
- login,
- sign hash,
- verify,
- certificate read.
Acceptance criteria:
- unit tests run in CI without native SoftHSM,
- integration tests run when module path + pins are provided via environment variables.
---
## 5. Proposed Project Structure
```text
EnvelopeGenerator.Infrastructure.Crypt/
Abstractions/
ISignatureProvider.cs
ICertificateProvider.cs
ISignatureVerifier.cs
ICryptographicHealthCheck.cs
Models/
SigningAlgorithm.cs
SignHashRequest.cs
SignHashResult.cs
VerifySignatureRequest.cs
VerifySignatureResult.cs
SignatureEvidence.cs
CertificateDescriptor.cs
KeyLocator.cs
Options/
Pkcs11ProviderOptions.cs
Pkcs11/
Pkcs11LibraryLoader.cs
Pkcs11SessionFactory.cs
Pkcs11ObjectFinder.cs
Pkcs11SignatureProvider.cs
Pkcs11CertificateProvider.cs
Verification/
DefaultSignatureVerifier.cs
X509CertificateParser.cs
Evidence/
SignatureEvidenceBuilder.cs
DependencyInjection/
CryptInfrastructureDependencyInjection.cs
Exceptions/
CryptographicOperationException.cs
Pkcs11ProviderException.cs
```
---
## 6. Configuration Strategy
Use options (bound from host config) with environment override support:
- `Crypt:Provider = PKCS11`
- `Crypt:Pkcs11:AssetsRootPath` (base path that contains bundled `pkcs11` assets)
- `Crypt:Pkcs11:SlotId` or token selectors
- `Crypt:Pkcs11:UserPin` (from secret store/env, not plain committed value)
- `Crypt:Pkcs11:PrivateKeyLabel`
- `Crypt:Pkcs11:CertificateLabel`
- `Crypt:Pkcs11:LoginType`
Validation rules:
- resolved module path must exist under configured/default assets root,
- at least one key locator required,
- pin required for signing operations.
---
## 7. Error Model
Define stable error codes to prevent provider-specific leakage:
- `CRYPT_PROVIDER_UNAVAILABLE`
- `CRYPT_SLOT_NOT_FOUND`
- `CRYPT_LOGIN_FAILED`
- `CRYPT_KEY_NOT_FOUND`
- `CRYPT_CERT_NOT_FOUND`
- `CRYPT_SIGN_FAILED`
- `CRYPT_VERIFY_FAILED`
Map low-level exceptions to these codes with safe diagnostics.
---
## 8. Non-Functional Requirements
1. **Security:** never log pins or raw private key handles.
2. **Reliability:** deterministic cleanup (`IDisposable`/`IAsyncDisposable`).
3. **Observability:** structured logs with correlation id.
4. **Performance:** avoid repeated login per operation when a safe session strategy is available.
5. **Extensibility:** algorithm/provider mapping must be open for extension.
---
## 9. Implementation Order (Small Tasks)
1. Add models + interfaces.
2. Add options + validators.
3. Add PKCS#11 low-level loader/session wrappers.
4. Implement `Pkcs11SignatureProvider` for hash signing.
5. Implement certificate retrieval.
6. Implement verifier.
7. Implement evidence builder.
8. Add DI registration extension.
9. Add unit tests.
10. Add optional integration tests (SoftHSM profile).
---
## 10. Definition of Done
Done means:
1. `Infrastructure.Crypt` exposes generic cryptographic services usable by Application use-cases.
2. SoftHSM-backed PKCS#11 sign/verify path works in integration tests.
3. All sensitive configuration is externalized.
4. Structured error codes are returned for expected failure scenarios.
5. Documentation exists for host registration and required settings.

View File

@@ -1,228 +0,0 @@
# FES (Advanced Electronic Signature) Summary and Integration Plan - EN
This document summarizes the shared diagram flow (`Ablauf SignFlow Unternehmenszertifikat`) and provides a small-step integration plan for the `EnvelopeGenerator` solution.
---
## 1) Simplified flow summary
Flow logic:
1. The sender uploads a document and defines signers.
2. If a valid company certificate already exists in HSM, it is used with the company key pair.
3. If no valid company certificate exists, CA issuance/renewal flow is required.
4. The signer receives an e-mail with a signing link.
5. The signer completes SMS OTP (2FA) and performs UI signature interaction.
6. Backend computes the hash for the current signing stage and sends it to HSM.
7. HSM signs the hash with the private key and returns signature + certificate context.
8. Backend embeds digital signature and certificate into PDF and writes audit trail.
9. After all signers complete, the final artifact is archived.
10. The final signed document is distributed to signers.
Important: The visible UI signature is not yet the legal electronic signature. The legal signature is produced in backend+HSM steps.
---
## 2) Relation to current codebase (as-is)
- `EnvelopeGenerator.Server` is the presentation layer host (Blazor + API hosting).
- Business flow is handled in `Application` via MediatR pipelines.
- Current receiver flow is centered around UI overlay/signature capture.
- Missing FES core: PKCS#11 hash-sign-verify + certificate-based PDF embedding pipeline.
Critical deployment fact:
- `EnvelopeGenerator.Server` is planned to run on Windows.
- SoftHSM is currently planned to run on Linux.
- A Windows process cannot directly load Linux PKCS#11 module (`libsofthsm2.so`).
- Therefore, architecture must include a compatibility bridge (service or proxy) unless HSM/SoftHSM is local to Windows.
Conclusion: FES is not a replacement of the current flow; it is a cryptographic hardening layer behind it.
---
## 3) Why SoftHSM tests are foundational
The tests defined in `SOFTHSM_TEST_KILAVUZU_TR.md` are the technical prerequisite for FES:
- `slots/login` -> HSM connectivity and authentication
- `certificate` -> certificate retrieval path
- `sign` -> hash signing with private key
- `verify` -> signature and certificate consistency validation
Without these tests passing, diagram steps 6-8 should not go live.
---
## 4) Target architecture (clean architecture aligned)
### Deployment topology options (must choose one)
1. **Linux signing service (recommended)**
- Windows `EnvelopeGenerator.Server` calls Linux signing API over HTTPS.
- Linux signing service performs PKCS#11 operations against SoftHSM.
- Private keys never leave Linux/HSM boundary.
2. **PKCS#11 proxy bridge**
- Windows host loads a Windows PKCS#11 proxy client DLL.
- Proxy forwards PKCS#11 operations to Linux side connected to SoftHSM.
3. **Windows-local SoftHSM (dev/test)**
- SoftHSM also installed on Windows and used locally.
- Useful for local testing, not preferred production topology.
Decision note:
- For production-critical FES, choose option 1 by default unless enterprise HSM policy requires option 2.
- Keep option 3 for development convenience only.
### Layer responsibilities
- `EnvelopeGenerator.Server`:
- API endpoints, auth orchestration, input validation.
- FES branch routing: standard-sign path vs external signing-service/proxy path.
- No private-key material handling.
- `EnvelopeGenerator.Application`:
- Use cases such as `SignWithHsmCommand`.
- MediatR pipeline orchestration for status/history/audit.
- FES evidence model aggregation (ip/email/phone/user-agent/otp proof/timestamps).
- `EnvelopeGenerator.Infrastructure`:
- Implementations for `IPkcs11Service`, `IPdfSignatureService`, `ITimestampService`.
- Pkcs11Interop integration and cryptographic operations.
- Optional client for Linux signing service if topology option 1 is selected.
- `Domain`:
- Signature evidence and audit-trail models.
---
## 5) Small-step integration plan
## Phase 0 - Scope and contracts
1. Finalize signing algorithms (for example `SHA256_RSA_PKCS`).
2. Confirm strategy: company certificate vs signer-specific certificate.
3. Freeze mandatory audit fields:
- identity, OTP proof, IP, user-agent, transaction id, timestamps, hash metadata.
4. Freeze deployment topology decision:
- option 1 (Linux signing service), option 2 (PKCS#11 proxy), or option 3 (Windows-local SoftHSM).
5. Define production host mapping:
- where `EnvelopeGenerator.Server` runs,
- where HSM/SoftHSM runs,
- where certificate lifecycle is managed.
Deliverable: Technical decision record (ADR-style markdown).
## Phase 1 - PKCS#11 connector (infrastructure)
1. Define `IPkcs11Service` interface.
2. Add `Pkcs11Interop` implementation.
3. Add config model:
- library path, slot, token label, key label, certificate label.
4. Ensure secure secret handling for PIN.
5. Ensure host-compatible module loading:
- Windows server must load Windows DLL,
- Linux server must load Linux `.so`.
Deliverable: Testable PKCS#11 service with health/slot/login/sign/verify capabilities.
## Phase 1.5 - Cross-host bridge implementation
1. If option 1 selected:
- implement Linux signing service API contract (`/sign`, `/certificate`, `/health`),
- implement authenticated HTTP client in `EnvelopeGenerator.Server`.
2. If option 2 selected:
- install/configure PKCS#11 proxy client DLL on Windows,
- install/configure proxy server on Linux and bind to SoftHSM.
Deliverable: Windows-to-Linux signing communication path proven end-to-end.
## Phase 2 - Application use case
1. Add `SignDocumentHashCommand` (or equivalent).
2. Pipeline flow:
- compute/receive hash,
- sign with HSM,
- attach certificate context,
- verify,
- produce evidence object.
3. Add resilient error mapping and retry strategy.
Deliverable: HSM signing use case in Application layer.
## Phase 3 - PDF signature embedding
1. Implement PDF signature embedding via `IPdfSignatureService`.
2. Build signer-specific audit-trail pages.
3. Append all audit pages to final PDF artifact.
Deliverable: Signed PDF with certificate context and audit trail pages.
## Phase 4 - Presentation/API integration
1. Integrate FES option into submit flow (feature flag guarded).
2. Add operational endpoints:
- HSM health/check
- signature evidence query (restricted roles)
3. Keep DTOs safe; avoid exposing sensitive cryptographic material.
4. In `SignatureController.Submit`, add FES branch that sends signing payload + audit context to selected signing backend.
Deliverable: API-level orchestration with minimal UI disruption.
## Phase 5 - Audit, archive, distribution
1. Persist signature evidence records.
2. Include hash/cert metadata in archival package.
3. Add signed artifact reference to distribution e-mails.
Deliverable: Alignment with diagram steps 9-10.
## Phase 6 - Testing and operations
1. Postman collection + Newman automation.
2. Positive/negative matrix:
- wrong pin, wrong slot, missing cert, expired cert, HSM timeout.
3. Observability:
- structured logs, correlation id, latency metrics.
Deliverable: Production readiness checklist.
---
## 6) Transition strategy from test API to production flow
1. Keep `/api/pkcs11-test/*` only for non-production.
2. In production, use the same internal services through business endpoints only.
3. Roll out by feature flag:
- `FesEnabled=false` initially
- pilot sender group
- full rollout
4. Keep explicit environment matrix:
- local dev (option 3),
- integration/staging (option 1 or 2),
- production (option 1 or 2 only).
---
## 7) Risks and mitigations
- Certificate expiry risk -> proactive monitoring and renewal alerting.
- HSM connectivity risk -> retry policy, circuit breaker, fallback queue.
- PIN/secret exposure risk -> vault-backed secrets and rotation policy.
- Verification failure risk -> block PDF sealing and move envelope to technical hold state.
- Cross-host connectivity risk -> mTLS/JWT auth + timeout/retry/circuit breaker + health probe.
---
## 8) Short done checklist
- [ ] PKCS#11 service interface + implementation
- [ ] Application command + pipeline behaviors
- [ ] PDF signature embedding
- [ ] Audit-trail generation
- [ ] API integration + feature flag
- [ ] Postman/Newman regression suite
- [ ] Operational runbook and alerting rules

File diff suppressed because one or more lines are too long

View File

@@ -1,84 +0,0 @@
10/6/26, 10:46 PM
Test SoftHSM : SWINFRA-54
Projects / Infrastruktur Software / Issues /
Enter search…
SWINFRA-54
All…
Created by Marvin Kamm 3 months ago
Visible to issue readers 1
Updated by Matthias Dewald about 1 month ago
# **Test SoftHSM**
Project Priorität Status **SWI** Infrastruktur Softwa H Hoch O Offen re… Bearbeiter Fälligkeit Projekteinfluss MD _ ? ? Matthias Dewald Kein fälligkeit Kein projektbezug Boards No visible boards
Bitte eine Test-VM mit der Software SoftHSM bereitstellen.
Laut Info von @Henning Emrich soll die Installation unter Debian Linux deutlich einfacher sein, als unter Windows. Bitte prüfen.
@Hakan Tek & @OlgunR
Sollen bitte die API Ansteuerung testen
FYI
@Marlon Schreiber @Jan-Ulrich Hoss @Henning Emrich @Hakan Tek @OlgunR
**Attachments** 1
Files Attached to Comments
**PDF**
Ablauf SignFlow Un ternehme… 83 kB
MD _ **Matthias Dewald** Commented 3 months ago Ist für Dienstag 30.6 10:00 Uhr Eingeplant
HT **Hakan Tek** Commented 3 months ago
Für die Integration auf der .NET-Seite können wir die Pkcs11Interop-Bibliothek und die entsprechende DevExpress-Infrastruktur verwenden. @OlgunR, falls du noch weitere Vorschläge hast, füge sie bitte hinzu. @Marvin Kamm, @Henning Emrich, @Matthias Dewald, um mit den Tests beginnen zu können, benötigen wir die entsprechenden Verbindungsdaten für SoftHSM (Slot-ID, PIN, Bibliothekspfad usw.).
FYI
@Marlon Schreiber @Jan-Ulrich Hoss
bugtracker.dd:8080/issue/SWINFRA-54/Test-SoftHSM
1/2
10/6/26, 10:46 PM
Test SoftHSM : SWINFRA-54
MD
## **Matthias Dewald** Commented 3 months ago
Hallo zusammen, SoftHSM ist auf der 172.24.12.64 installiert:
Der Proxy läuft auf Port 5657 und erwartet Verbindungen über die Proxy-Bibliothek (libpkcs11-proxy.so)
@Hakan Tek Bitte testen
MD
## **Matthias Dewald** Commented about 1 month ago
Ablauf SignFlow Unternehmenszertifikat.drawio.pdf
**PDF**
Ablauf SignFlow Un ternehme… 83 kB
bugtracker.dd:8080/issue/SWINFRA-54/Test-SoftHSM
2/2