diff --git a/DocumentService.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs b/DocumentService.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..80e9c03 --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using DocumentOperator.Application.Common.Configurations; +using DocumentOperator.Application.Common.Interfaces.Services; +using DocumentService.Infrastructure.Crypt.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace DocumentService.Infrastructure.Crypt.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCryptInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services + .AddOptions() + .Bind(configuration.GetSection(Pkcs11Options.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHostedService(); + return services; + } +} diff --git a/DocumentService.Infrastructure.Crypt/DocumentService.Infrastructure.Crypt.csproj b/DocumentService.Infrastructure.Crypt/DocumentService.Infrastructure.Crypt.csproj new file mode 100644 index 0000000..69f0dcc --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/DocumentService.Infrastructure.Crypt.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + PreserveNewest + pkcs11\%(RecursiveDir)%(Filename)%(Extension) + + + + + + + + + diff --git a/DocumentService.Infrastructure.Crypt/Helper/PdfCertificationLevelExtensions.cs b/DocumentService.Infrastructure.Crypt/Helper/PdfCertificationLevelExtensions.cs new file mode 100644 index 0000000..7bafb4a --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/Helper/PdfCertificationLevelExtensions.cs @@ -0,0 +1,16 @@ +using DevExpress.Pdf; +using DocumentOperator.Application.Common.Enums; + +namespace DocumentService.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), + }; +} \ No newline at end of file diff --git a/DocumentService.Infrastructure.Crypt/README.md b/DocumentService.Infrastructure.Crypt/README.md new file mode 100644 index 0000000..be47406 --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/README.md @@ -0,0 +1,384 @@ +# DocumentService.Infrastructure.Crypt + +Umfassende technische Dokumentation der PKCS#11/SoftHSM-Integration fuer `EnvelopeGenerator`. + +Dieses Dokument konsolidiert und ersetzt die verteilten Runbooks aus: + +- `pkcs11-proxy-production/README.md` +- `pkcs11-proxy-production/SSH_TEMP_ACCESS_MUST_DO.md` +- aeltere Abschnitte in diesem README + +Zielgruppe: Senior Developer, Platform Engineers, DevOps, Security Owner. + +--- + +## 1. Architekturueberblick + +### 1.1 Ziel + +`DocumentService.Infrastructure.Crypt` stellt die kryptographische Infrastruktur fuer PDF-Signaturen bereit: + +- Signaturerzeugung mit Schluesseln in SoftHSM/PKCS#11 +- Zertifikat-Auslese aus Token +- Signaturpruefung auf PDF-Ebene +- Health/Startup-Selbsttest fuer Betriebsfaehigkeit + +### 1.2 Topologie (aktueller Betriebsmodus) + +Der aktuell implementierte Produktions-nahe Pfad ist: + +1. Linux hostet SoftHSM + `pkcs11-daemon` (Proxy-Server). +2. Windows hostet `EnvelopeGenerator.Server`. +3. Windows laedt ein Windows-kompatibles Proxy-PKCS#11-Modul (`libpkcs11-proxy.dll`). +4. PKCS#11-Operationen werden ueber Socket (`PKCS11_PROXY_SOCKET`) an Linux weitergeleitet. + +Wichtig: + +- Ein Windows-Prozess kann **kein Linux `.so`** direkt laden. +- Private Keys verbleiben im Token (nicht exportierbar). + +### 1.3 Laufzeitpfade in der Anwendung + +`Pkcs11InteropBootstrap` loest Assets relativ zu `Crypt:Pkcs11:AssetsRootPath` auf: + +- Modul: `pkcs11\windows\proxy\libpkcs11-proxy.dll` +- Tool-Fallback: `pkcs11\windows\opensc\tools\pkcs11-tool.exe` + +Wenn `AssetsRootPath` leer ist, wird `AppContext.BaseDirectory` verwendet. + +--- + +## 2. Linux-Seite: SoftHSM + Proxy Setup + +### 2.1 Basispakete + +Beispiel (Debian/Ubuntu): + +```bash +sudo apt-get update && sudo apt-get install -y softhsm2 opensc p11-kit libengine-pkcs11-openssl +``` + +### 2.2 SoftHSM-Modulpfad pruefen + +```bash +ldconfig -p | grep softhsm +find /usr -name "libsofthsm2.so" 2>/dev/null +``` + +### 2.3 Token initialisieren + +```bash +softhsm2-util --show-slots +softhsm2-util --init-token --slot --label CompanyProdToken --so-pin --pin +softhsm2-util --show-slots +``` + +Hinweis: Nach `--init-token` wird der Slot i. d. R. neu zugewiesen. + +### 2.4 Schluesselpaar erzeugen + +```bash +pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \ + --slot --login --pin \ + --keypairgen --key-type rsa:3072 --label company-sign-key-v1 --id 01 +``` + +### 2.5 Zertifikat importieren + +```bash +pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \ + --slot --login --pin \ + --write-object ./company-sign-cert-v1.der --type cert --id 01 --label company-sign-cert-v1 +``` + +### 2.6 Objektzustand verifizieren + +```bash +pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot --login --pin --list-objects +pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot --login --pin --list-objects --type cert +``` + +### 2.7 Proxy-Daemon endpoint stabilisieren + +Ein wiederkehrendes Betriebsproblem war ein zufaelliger/local-only Listener. Fix: + +```bash +sudo mkdir -p /etc/systemd/system/pkcs11-proxy.service.d +printf "[Service]\nEnvironment=PKCS11_DAEMON_SOCKET=tcp://0.0.0.0:5657\n" | sudo tee /etc/systemd/system/pkcs11-proxy.service.d/override.conf +sudo systemctl daemon-reload +sudo systemctl restart pkcs11-proxy.service +``` + +Pruefung: + +```bash +systemctl show pkcs11-proxy.service -p Environment --no-pager +ss -ltnp | grep 5657 +``` + +--- + +## 3. Web-API/Server-Seite: Installation und Asset-Bereitstellung + +### 3.1 Bezugsquellen der Windows-Artefakte + +Fuer den Proxy-Betrieb werden auf Windows benoetigt: + +1. Proxy-PKCS#11-DLL (`libpkcs11-proxy.dll`) +2. Abhaengige Runtime-DLLs (z. B. MinGW runtime, je nach Build) +3. `pkcs11-tool.exe` + zugehoerige OpenSC-DLLs fuer Fallback + +Beschaffung: + +- Build aus derselben `pkcs11-proxy`-Codebasis/Commit wie Linux-Daemon (Kompatibilitaet sicherstellen) +- oder kontrolliert bereitgestellte, intern freigegebene Binaries + +### 3.2 Empfohlene Ablage im Repo + +Bundled Assets liegen unter: + +- `DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/` +- `DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/opensc/tools/` +- `DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/opensc/pkcs11/` + +Diese werden beim Build in den Output kopiert (siehe `.csproj`-Copy-Regeln). + +### 3.3 Deployment-Muster + +Es gibt zwei gueltige Wege: + +1. **Bundled (Dev/QA, schnelle Reproduzierbarkeit)** + - Assets werden mit dem Build ausgeliefert. + - `AssetsRootPath = null` -> Aufloesung via `AppContext.BaseDirectory`. + +2. **Zentraler Asset-Root (Prod, betriebliche Kontrolle)** + - Assets in zentralem Pfad (z. B. `E:\DataFiles\Digital Data\EnvelopeGenerator`). + - `AssetsRootPath` in `appsettings.json` auf diesen Root setzen. + +### 3.4 Anwendungskonfiguration (`Crypt:Pkcs11`) + +Beispiel: + +```json +{ + "Crypt": { + "Pkcs11": { + "AssetsRootPath": "E:\\DataFiles\\Digital Data\\EnvelopeGenerator", + "SlotId": 2021549410, + "TokenLabel": "CompanyProdToken", + "ProxySocket": "tcp://172.24.12.64:5657", + "UserPin": "", + "PrivateKeyLabel": "company-sign-key-v1", + "CertificateLabel": "company-sign-cert-v1", + "ApplicationName": "signFLOW", + "DefaultReason": null, + "UseUtcSigningTime": true, + "DefaultCertificationLevel": "NoCertification" + } + } +} +``` + +Sicherheitsregel: + +- `UserPin` niemals in versionierte Dateien einchecken. +- Secrets nur in Secret Store / nicht versionierte `appsettings.Secrets.json` / Vault. + +--- + +## 4. Implementierte Services, Modelle, DTOs + +### 4.1 DI und Startverhalten + +- `AddCryptInfrastructure(...)` in `DependencyInjection/ServiceCollectionExtensions.cs` + - bindet `Pkcs11Options` + - registriert `IPdfDigitalSignatureService` + - registriert Startup-Self-Test Hosted Service + +### 4.2 Konfigurationsmodell + +- `Pkcs11Options` (`EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs`) + - `AssetsRootPath`, `SlotId`, `TokenLabel`, `ProxySocket` + - `UserPin`, `PrivateKeyLabel`, `CertificateLabel` + - `ApplicationName`, `DefaultReason`, `UseUtcSigningTime`, `DefaultCertificationLevel` + - `ResolvedLibraryPath`, `ResolvedToolPath` (Runtime-resolved) + +### 4.3 Kernservices + +1. `PdfDigitalSignatureService` + - Signiert PDF via DevExpress `PdfDeferredSignatureBuilder` + - Nutzt `Pkcs11ExternalSigner` fuer externe Signatur + - Erzwingt `ApplicationName` aus Config (nicht client-overridable) + - Erweitert `Reason` um strukturiertes Metaformat: + - `SFMETA:v1;doc=;sha256=` + - Parse beim Verify und Mapping in DTO + +2. `Pkcs11InteropBootstrap` + - Loest Modul-/Toolpfade auf + - Setzt `PKCS11_PROXY_SOCKET`/`PKCS11_DAEMON_SOCKET` + - PATH-Ergaenzung fuer native Abhaengigkeiten + - Warmup via `pkcs11-tool -L` + +3. `Pkcs11SessionRunner` + - Interop Library-Load mit mehreren Init-Modi (Fallback-Reihenfolge) + - Slot-Resolution (`SlotId`/`TokenLabel`) + - Session Login/Logout + +4. `Pkcs11ToolBridge` + - Fallback via Prozessaufruf von `pkcs11-tool` + - Cert read und DigestInfo-Signatur + +5. `Pkcs11StartupSelfTestHostedService` + - Fuehrt beim Start Interop-Probe aus + - Fallback-Probe bei Interop-Fehler + - Schreibt strukturierten Status + +### 4.4 Wichtige Interfaces/DTOs + +- `IPdfDigitalSignatureService` + - `SignPdfAsync(...)`, `CheckPdfSignatureAsync(...)`, `ExtractOriginalPdfAsync(...)` +- `IPkcs11SelfTestStatusProvider` +- `PdfSignOptionsDto` + - `UUID`, `Reason`, `Name`, `Location`, `Email`, `Phone`, `CertificationLevel` +- `PdfSignatureCheckResultDto` / `PdfSignatureCheckItemDto` + - inklusive `BusinessReason`, `UUID`, `OriginalDocumentSha256` +- `Pkcs11SelfTestStatusDto` + +--- + +## 5. Betriebserfahrungen und Challenges + +### 5.1 Interop vs Tool-Verhalten + +Beobachtetes Muster in der realen Umgebung: + +- `pkcs11-tool` konnte erfolgreich auf Token zugreifen und signieren. +- `Pkcs11Interop` scheiterte mit `CKR_FUNCTION_NOT_SUPPORTED` bzw. fehlendem `C_Initialize` Pointer in bestimmten Modi. + +Konsequenz: + +- Architektur wurde auf **interop-first + controlled tool-fallback** erweitert. +- Health-Endpunkt differenziert Modus (`interop`, `pkcs11-tool`, `failed`). + +### 5.2 Typische Fehlbilder + +- `CKR_DEVICE_ERROR` bei Socket-/Proxy-Problemen +- `CRYPT_PROVIDER_UNAVAILABLE` bei Pfad/ABI/Dependency-Mismatch +- `CRYPT_SLOT_NOT_FOUND` bei Slot-/Label-Mismatch +- `CKR_PIN_INCORRECT` bei PIN-Fehler +- 307 Redirects bei HTTP/HTTPS-Mix in manuellen Tests + +### 5.3 Plattformfallen + +- MSVC vs MinGW Build-Unterschiede fuer Proxy-Client +- fehlende Laufzeit-DLLs (`PATH`/Side-by-side) +- Architektur-Mismatch x86 vs x64 +- Daemon-Bindings nicht stabil (random localhost port statt fixed endpoint) + +--- + +## 6. Health, Monitoring und Verifikation + +### 6.1 API Health + +- Endpoint: `GET /api/health/crypt` +- Liefert: + - `success`, `mode`, `summary`, `interopError`, `toolError`, `checkedAtUtc` + +Empfehlung: + +- In Monitoring aufnehmen und bei `mode=pkcs11-tool` ueber laengere Zeit Warnung triggern. + +### 6.2 Smoke-Test (empfohlen vor Release) + +1. `GET /api/health/crypt` -> `success=true` +2. `POST /api/PdfSignature/sign` mit Test-PDF +3. `POST /api/PdfSignature/is-signed` +4. Pruefen: + - `signed=true` + - `allSignaturesCryptographicallyValid=true` + - `UUID`/`OriginalDocumentSha256` korrekt gemappt + +--- + +## 7. Sicherheits- und Betriebsrichtlinien + +### 7.1 Geheimnisse + +- PIN, SO-PIN, Root-Creds niemals in Doku/Screenshots/Repo. +- Nach Troubleshooting: Credentials rotieren. + +### 7.2 Temporare SSH-Freigaben zwingend rueckbauen + +Wenn SSH temporaer geoeffnet wurde, MUSS danach Rollback erfolgen: + +```bash +sudo systemctl disable --now ssh && sudo ufw delete allow from 172.24.10.11 to any port 22 proto tcp && sudo ufw status verbose && sudo systemctl status ssh --no-pager +``` + +Optional harte Bereinigung: + +```bash +sudo apt-get purge -y openssh-server && sudo apt-get autoremove -y +``` + +### 7.3 Schluesselrotation + +- Neue Labels (`...-v2`) anlegen +- App umkonfigurieren +- Sign/Verify Regression testen +- Altmaterial erst nach Freigabe entfernen + +--- + +## 8. API-Verhalten bei Signaturmetadaten + +Aktueller Standard: + +- `UUID` kommt aus Sign-Input (nullable string). Wenn ungeltig/leer -> neue Guid wird erzeugt. +- `Reason` wird intern angereichert mit strukturiertem Meta (`SFMETA:v1`). +- `ApplicationName` ist rein serverseitig aus `appsettings` gesetzt. +- `SigningTime` ist rein serverseitig und wird automatisch gesetzt (`UseUtcSigningTime=true` => `UtcNow`, sonst lokale Zeit). +- `CertificationLevel` wird im Controller serverseitig gesetzt (`FormFillingAndAnnotations`), nicht vom Client. + +Damit ist das Verhalten deterministisch und manipulationsaermer fuer Integrationsclients. + +--- + +## 9. Troubleshooting-Checkliste (praxisnah) + +1. Erreichbarkeit Linux Proxy: + - `Test-NetConnection -Port 5657` +2. Token sichtbar? + - `pkcs11-tool --module -L -v` +3. Zertifikat lesbar? + - `--read-object --type cert --label ` +4. Signatur technisch moeglich? + - `--sign -m SHA256-RSA-PKCS ...` +5. App-Health check: + - `/api/health/crypt` +6. App-sign/verify end-to-end: + - `/api/PdfSignature/sign` + `/api/PdfSignature/is-signed` + +Wenn Interop scheitert, aber Tool funktioniert: + +- Betrieb bleibt verfuegbar (Fallback), +- aber Incident/Bug fuer Interop-Kompatibilitaet erfassen. + +--- + +## 10. Ausblick / empfohlene Verbesserungen + +1. Binary Transport statt Base64 bei grossen PDFs in Render/Sign-Pipelines. +2. Persistente Metriken fuer Fallback-Quote (`interop` vs `tool`). +3. Zertifikatskette/OCSP/CRL Validierung als optionales Policy-Modul. +4. CI-Pruefung auf unsichere SQL-Muster (parallel zum Security-Workstream). +5. Standardisierte Toast/UX-Rueckmeldungen fuer Signierfehler in UI. + +--- + +## 11. Referenzen + +- SoftHSMv2: `https://github.com/softhsm/SoftHSMv2` +- OpenSC / pkcs11-tool: `https://github.com/OpenSC/OpenSC/wiki/Using-pkcs11-tool-and-OpenSSL` +- PKCS#11 Standard: `https://www.oasis-open.org/committees/pkcs11/` diff --git a/DocumentService.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs b/DocumentService.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs new file mode 100644 index 0000000..c01ddb0 --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs @@ -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 options) : IPdfDigitalSignatureService +{ + private static readonly Regex SignatureReasonMetaRegex = new(@"\|\s*SFMETA:v1;deid=(?\d+);sha256=(?[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 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(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 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 ParseByteRanges(byte[] pdfBytes) + { + var text = Encoding.Latin1.GetString(pdfBytes); + var matches = Regex.Matches(text, @"/ByteRange\s*\[(?[\d\s]+)\]", RegexOptions.CultureInvariant); + var ranges = new List(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()); + } +} diff --git a/DocumentService.Infrastructure.Crypt/Services/Pkcs11DigestInfoBuilder.cs b/DocumentService.Infrastructure.Crypt/Services/Pkcs11DigestInfoBuilder.cs new file mode 100644 index 0000000..4121ca3 --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/Services/Pkcs11DigestInfoBuilder.cs @@ -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; + } +} diff --git a/DocumentService.Infrastructure.Crypt/Services/Pkcs11ExternalSigner.cs b/DocumentService.Infrastructure.Crypt/Services/Pkcs11ExternalSigner.cs new file mode 100644 index 0000000..8562810 --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/Services/Pkcs11ExternalSigner.cs @@ -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 certificates = [ReadCertificateBytesWithFallback(options)]; + + protected override IDigestCalculator DigestCalculator => digestCalculator; + + protected override string SigningAlgorithmOID => "1.2.840.113549.1.1.1"; + + protected override IEnumerable GetCertificates() => certificates; + + protected override byte[] SignDigest(byte[] digest) + { + return rsa.SignHash(digest, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1); + } + + private static byte[] ReadCertificateBytesWithFallback(Pkcs11Options options) + { + try + { + return Pkcs11SessionRunner.Run(options, session => + { + var attrs = new List + { + session.Factories.ObjectAttributeFactory.Create(Net.Pkcs11Interop.Common.CKA.CKA_CLASS, Net.Pkcs11Interop.Common.CKO.CKO_CERTIFICATE), + session.Factories.ObjectAttributeFactory.Create(Net.Pkcs11Interop.Common.CKA.CKA_LABEL, options.CertificateLabel) + }; + + var handles = session.FindAllObjects(attrs); + if (handles.Count == 0) + throw new InvalidOperationException($"Certificate '{options.CertificateLabel}' not found in token."); + + var values = session.GetAttributeValue(handles[0], new List { Net.Pkcs11Interop.Common.CKA.CKA_VALUE }); + return values[0].GetValueAsByteArray(); + }); + } + catch when (Pkcs11ToolBridge.IsAvailable(options)) + { + return Pkcs11ToolBridge.ReadCertificateBytes(options); + } + } +} diff --git a/DocumentService.Infrastructure.Crypt/Services/Pkcs11InteropBootstrap.cs b/DocumentService.Infrastructure.Crypt/Services/Pkcs11InteropBootstrap.cs new file mode 100644 index 0000000..8686b2b --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/Services/Pkcs11InteropBootstrap.cs @@ -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); + } +} diff --git a/DocumentService.Infrastructure.Crypt/Services/Pkcs11PdfSigningRsa.cs b/DocumentService.Infrastructure.Crypt/Services/Pkcs11PdfSigningRsa.cs new file mode 100644 index 0000000..9ed7cfb --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/Services/Pkcs11PdfSigningRsa.cs @@ -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 + { + session.Factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, CKO.CKO_PRIVATE_KEY), + session.Factories.ObjectAttributeFactory.Create(CKA.CKA_LABEL, options.PrivateKeyLabel) + }; + + var handles = session.FindAllObjects(attrs); + if (handles.Count == 0) + throw new InvalidOperationException($"Private key '{options.PrivateKeyLabel}' not found in token."); + + var mechanism = session.Factories.MechanismFactory.Create(CKM.CKM_RSA_PKCS); + return session.Sign(mechanism, handles[0], digestInfo); + }); + } + catch when (Pkcs11ToolBridge.IsAvailable(options)) + { + return Pkcs11ToolBridge.SignDigestInfo(options, digestInfo); + } + } + + public override bool VerifyHash(byte[] hash, byte[] signature, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding) + => throw new NotSupportedException(); + + public override byte[] Decrypt(byte[] data, RSAEncryptionPadding padding) + => throw new NotSupportedException(); + + public override byte[] Encrypt(byte[] data, RSAEncryptionPadding padding) + => throw new NotSupportedException(); + + public override RSAParameters ExportParameters(bool includePrivateParameters) + => throw new NotSupportedException(); + + public override void ImportParameters(RSAParameters parameters) + => throw new NotSupportedException(); +} diff --git a/DocumentService.Infrastructure.Crypt/Services/Pkcs11SelfTestStatusStore.cs b/DocumentService.Infrastructure.Crypt/Services/Pkcs11SelfTestStatusStore.cs new file mode 100644 index 0000000..fe4f2f8 --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/Services/Pkcs11SelfTestStatusStore.cs @@ -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); + } +} diff --git a/DocumentService.Infrastructure.Crypt/Services/Pkcs11SessionRunner.cs b/DocumentService.Infrastructure.Crypt/Services/Pkcs11SessionRunner.cs new file mode 100644 index 0000000..0cf66eb --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/Services/Pkcs11SessionRunner.cs @@ -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(Pkcs11Options options, Func 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 errors = []; + foreach (string candidate in BuildLibraryCandidates(libraryPath)) + { + try + { + return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.MultiThreaded); + } + catch (Exception ex) + { + errors.Add(new InvalidOperationException($"{candidate} | MultiThreaded+WithFunctionList", ex)); + } + + try + { + return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.SingleThreaded); + } + catch (Exception ex) + { + errors.Add(new InvalidOperationException($"{candidate} | SingleThreaded+WithFunctionList", ex)); + } + + try + { + return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.MultiThreaded, InitType.WithoutFunctionList); + } + catch (Exception ex) + { + errors.Add(new InvalidOperationException($"{candidate} | MultiThreaded+WithoutFunctionList", ex)); + } + + try + { + return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.SingleThreaded, InitType.WithoutFunctionList); + } + catch (Exception ex) + { + errors.Add(new InvalidOperationException($"{candidate} | SingleThreaded+WithoutFunctionList", ex)); + } + } + + var socketInfo = string.IsNullOrWhiteSpace(proxySocket) ? "" : proxySocket; + throw new AggregateException($"Unable to initialize PKCS#11 library '{libraryPath}' with any supported initialization mode. PKCS11_PROXY_SOCKET={socketInfo}", errors); + } + + private static IEnumerable BuildLibraryCandidates(string configuredPath) + { + var unique = new HashSet(StringComparer.OrdinalIgnoreCase) + { + configuredPath + }; + + var directory = Path.GetDirectoryName(configuredPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + string[] alternates = + [ + "client.dll", + "pkcs11-proxy.dll", + "libpkcs11-proxy.dll" + ]; + + foreach (var fileName in alternates) + { + var candidate = Path.Combine(directory, fileName); + if (File.Exists(candidate)) + unique.Add(candidate); + } + } + + return unique; + } + + private static ISlot ResolveSlot(IEnumerable slots, Pkcs11Options options) + { + if (options.SlotId.HasValue) + { + var bySlotId = slots.FirstOrDefault(s => s.SlotId == options.SlotId.Value); + if (bySlotId is not null) + return bySlotId; + } + + if (!string.IsNullOrWhiteSpace(options.TokenLabel)) + { + var byTokenLabel = slots.FirstOrDefault(s => + string.Equals(s.GetTokenInfo().Label?.Trim(), options.TokenLabel.Trim(), StringComparison.Ordinal)); + + if (byTokenLabel is not null) + return byTokenLabel; + } + + throw new InvalidOperationException("Unable to resolve PKCS#11 slot. Check Crypt:Pkcs11:SlotId / TokenLabel."); + } +} diff --git a/DocumentService.Infrastructure.Crypt/Services/Pkcs11StartupSelfTestHostedService.cs b/DocumentService.Infrastructure.Crypt/Services/Pkcs11StartupSelfTestHostedService.cs new file mode 100644 index 0000000..ed062fd --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/Services/Pkcs11StartupSelfTestHostedService.cs @@ -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 options, + Pkcs11SelfTestStatusStore statusStore, + ILogger logger) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + var pkcs11 = options.Value; + + try + { + RunInteropProbe(pkcs11); + statusStore.SetStatus(new Pkcs11SelfTestStatusDto( + Completed: true, + Success: true, + Mode: "interop", + Summary: "PKCS#11 startup self-test succeeded via interop.", + CheckedAtUtc: DateTimeOffset.UtcNow, + InteropError: null, + ToolError: null)); + logger.LogInformation("PKCS#11 startup self-test succeeded (mode=interop, library={LibraryPath}, proxy={ProxySocket}).", pkcs11.ResolvedLibraryPath, pkcs11.ProxySocket); + return Task.CompletedTask; + } + catch (Exception interopEx) when (Pkcs11ToolBridge.IsAvailable(pkcs11)) + { + try + { + RunToolProbe(pkcs11); + statusStore.SetStatus(new Pkcs11SelfTestStatusDto( + Completed: true, + Success: true, + Mode: "pkcs11-tool", + Summary: "PKCS#11 interop failed, tool fallback succeeded.", + CheckedAtUtc: DateTimeOffset.UtcNow, + InteropError: interopEx.Message, + ToolError: null)); + logger.LogWarning(interopEx, "PKCS#11 interop self-test failed, but tool fallback is healthy (mode=pkcs11-tool, tool={ToolPath}).", pkcs11.ResolvedToolPath); + return Task.CompletedTask; + } + catch (Exception toolEx) + { + statusStore.SetStatus(new Pkcs11SelfTestStatusDto( + Completed: true, + Success: false, + Mode: "failed", + Summary: "PKCS#11 startup self-test failed for both interop and tool fallback.", + CheckedAtUtc: DateTimeOffset.UtcNow, + InteropError: interopEx.Message, + ToolError: toolEx.Message)); + logger.LogError(toolEx, "PKCS#11 startup self-test failed for both interop and tool fallback. library={LibraryPath}, proxy={ProxySocket}, tool={ToolPath}", pkcs11.ResolvedLibraryPath, pkcs11.ProxySocket, pkcs11.ResolvedToolPath); + return Task.CompletedTask; + } + } + catch (Exception ex) + { + statusStore.SetStatus(new Pkcs11SelfTestStatusDto( + Completed: true, + Success: false, + Mode: "failed", + Summary: "PKCS#11 startup self-test failed via interop and no tool fallback is available.", + CheckedAtUtc: DateTimeOffset.UtcNow, + InteropError: ex.Message, + ToolError: null)); + logger.LogError(ex, "PKCS#11 startup self-test failed (mode=interop, no fallback available). library={LibraryPath}, proxy={ProxySocket}", pkcs11.ResolvedLibraryPath, pkcs11.ProxySocket); + return Task.CompletedTask; + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private static void RunInteropProbe(Pkcs11Options options) + { + Pkcs11SessionRunner.Run(options, session => + { + EnsureObjectExists(session, CKO.CKO_CERTIFICATE, options.CertificateLabel, "certificate"); + EnsureObjectExists(session, CKO.CKO_PRIVATE_KEY, options.PrivateKeyLabel, "private key"); + return true; + }); + } + + private static void RunToolProbe(Pkcs11Options options) + { + _ = Pkcs11ToolBridge.ReadCertificateBytes(options); + var testDigest = SHA256.HashData([1, 2, 3, 4]); + var digestInfo = Pkcs11DigestInfoBuilder.Build(testDigest, HashAlgorithmName.SHA256); + _ = Pkcs11ToolBridge.SignDigestInfo(options, digestInfo); + } + + private static void EnsureObjectExists(ISession session, CKO objectClass, string label, string objectType) + { + var attrs = new List + { + session.Factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, objectClass), + session.Factories.ObjectAttributeFactory.Create(CKA.CKA_LABEL, label) + }; + + var handles = session.FindAllObjects(attrs); + if (handles.Count == 0) + throw new InvalidOperationException($"PKCS#11 {objectType} '{label}' not found in token."); + } +} diff --git a/DocumentService.Infrastructure.Crypt/Services/Pkcs11ToolBridge.cs b/DocumentService.Infrastructure.Crypt/Services/Pkcs11ToolBridge.cs new file mode 100644 index 0000000..d9278bc --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/Services/Pkcs11ToolBridge.cs @@ -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 CreateBaseArgs(Pkcs11Options options) + { + var args = new List + { + "--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 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); + } +} diff --git a/DocumentService.Infrastructure.Crypt/resources/INFRASTRUCTURE_PLAN.md b/DocumentService.Infrastructure.Crypt/resources/INFRASTRUCTURE_PLAN.md new file mode 100644 index 0000000..8d893cf --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/resources/INFRASTRUCTURE_PLAN.md @@ -0,0 +1,301 @@ +# FES Signature Infrastructure Plan + +## 1. Goal + +Build a reusable and modular cryptography infrastructure in `DocumentService.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 +DocumentService.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. diff --git a/DocumentService.Infrastructure.Crypt/resources/INTEGRATION_PLAN.md b/DocumentService.Infrastructure.Crypt/resources/INTEGRATION_PLAN.md new file mode 100644 index 0000000..33e186e --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/resources/INTEGRATION_PLAN.md @@ -0,0 +1,228 @@ +# 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). + +- `DocumentService.Infrastructure.Crypt`: + - 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 diff --git a/DocumentService.Infrastructure.Crypt/resources/flow-diagram.drawio b/DocumentService.Infrastructure.Crypt/resources/flow-diagram.drawio new file mode 100644 index 0000000..c45d36f --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/resources/flow-diagram.drawio @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DocumentService.Infrastructure.Crypt/resources/flow-diagram.pdf b/DocumentService.Infrastructure.Crypt/resources/flow-diagram.pdf new file mode 100644 index 0000000..34548d5 Binary files /dev/null and b/DocumentService.Infrastructure.Crypt/resources/flow-diagram.pdf differ diff --git a/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/opensc/pkcs11/opensc-pkcs11.dll b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/opensc/pkcs11/opensc-pkcs11.dll new file mode 100644 index 0000000..4d43edb Binary files /dev/null and b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/opensc/pkcs11/opensc-pkcs11.dll differ diff --git a/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/opensc/tools/opensc.dll b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/opensc/tools/opensc.dll new file mode 100644 index 0000000..6be2325 Binary files /dev/null and b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/opensc/tools/opensc.dll differ diff --git a/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/opensc/tools/pkcs11-tool.exe b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/opensc/tools/pkcs11-tool.exe new file mode 100644 index 0000000..e6cb845 Binary files /dev/null and b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/opensc/tools/pkcs11-tool.exe differ diff --git a/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/libgcc_s_seh-1.dll b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/libgcc_s_seh-1.dll new file mode 100644 index 0000000..2123922 Binary files /dev/null and b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/libgcc_s_seh-1.dll differ diff --git a/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/libpkcs11-proxy.dll b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/libpkcs11-proxy.dll new file mode 100644 index 0000000..ae72930 Binary files /dev/null and b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/libpkcs11-proxy.dll differ diff --git a/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/libstdc++-6.dll b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/libstdc++-6.dll new file mode 100644 index 0000000..72bf0fc Binary files /dev/null and b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/libstdc++-6.dll differ diff --git a/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/libwinpthread-1.dll b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/libwinpthread-1.dll new file mode 100644 index 0000000..8bc1483 Binary files /dev/null and b/DocumentService.Infrastructure.Crypt/resources/pkcs11/windows/proxy/libwinpthread-1.dll differ diff --git a/DocumentService.Infrastructure.Crypt/resources/ticket.md b/DocumentService.Infrastructure.Crypt/resources/ticket.md new file mode 100644 index 0000000..f1f2cb0 --- /dev/null +++ b/DocumentService.Infrastructure.Crypt/resources/ticket.md @@ -0,0 +1,84 @@ +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 +