feat(infrastructure): add PKCS#11 PDF signing infrastructure
This commit is contained in:
@@ -0,0 +1,631 @@
|
||||
using DevExpress.Office.DigitalSignatures;
|
||||
using DevExpress.Pdf;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using DocumentOperator.Application.Common.Configurations;
|
||||
using DocumentOperator.Application.Common.Dto;
|
||||
using DocumentOperator.Application.Common.Interfaces.Services;
|
||||
using DocumentService.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;
|
||||
|
||||
namespace DocumentService.Infrastructure.Crypt.Services;
|
||||
|
||||
public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options) : 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)
|
||||
{
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static async Task<(Stream Stream, bool OwnsStream)> EnsureSeekableInputAsync(Stream input, CancellationToken cancellationToken)
|
||||
{
|
||||
if (input.CanSeek)
|
||||
{
|
||||
input.Position = 0;
|
||||
return (input, false);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (input 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace DocumentService.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using DocumentOperator.Application.Common.Configurations;
|
||||
using DevExpress.Office.DigitalSignatures;
|
||||
using DevExpress.Pdf;
|
||||
|
||||
namespace DocumentService.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using DocumentOperator.Application.Common.Configurations;
|
||||
|
||||
namespace DocumentService.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using DocumentOperator.Application.Common.Configurations;
|
||||
using Net.Pkcs11Interop.Common;
|
||||
using Net.Pkcs11Interop.HighLevelAPI;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace DocumentService.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();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using DocumentOperator.Application.Common.Dto;
|
||||
using DocumentOperator.Application.Common.Interfaces.Services;
|
||||
using System.Threading;
|
||||
|
||||
namespace DocumentService.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using DocumentOperator.Application.Common.Configurations;
|
||||
using Net.Pkcs11Interop.Common;
|
||||
using Net.Pkcs11Interop.HighLevelAPI;
|
||||
|
||||
namespace DocumentService.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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using DocumentOperator.Application.Common.Configurations;
|
||||
using DocumentOperator.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 DocumentService.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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using DocumentOperator.Application.Common.Configurations;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace DocumentService.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user