diff --git a/.gitignore b/.gitignore index 03c6422e..6bad3f90 100644 --- a/.gitignore +++ b/.gitignore @@ -370,3 +370,4 @@ FodyWeavers.xsd /EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output /EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md /ignore/EnvelopeGenerator.Form +/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.Secrets.json diff --git a/AGENTS.md b/AGENTS.md index 8b7e4248..43b1c08c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,16 @@ This section explains every markdown file in the repo: what it contains, why it - Implementing sender-side PDF.js overlay (signature field placement) — the receiver side is the reference implementation **Maintenance:** Update when `pdf-viewer.js` API changes, new JS interop functions are added, or the signing flow is modified. +#### `DATABASE_STRUCTURE_CONTEXT.md` +**What:** Deep DB documentation for the envelope domain. Includes table/column snapshot, entity-to-table mapping, EF relationship/constraint map, stored procedure behavior + call sites, and trigger behavior + wiring path. +**Why it exists:** DB is centralized and can evolve independently; this file preserves migration-critical data semantics when historical DB context is hard to recover. +**When you need it:** +- Planning or reviewing DB/schema changes +- Debugging status transitions/history side effects +- Tracing where `PRSIG_*` procedures or DB triggers affect app behavior +- Reconciling Domain entities with physical SQL schema +**Maintenance:** Refresh on each DB migration, trigger/procedure change, or significant Domain/EGDbContext model change. + --- ### Deployment Docs @@ -268,6 +278,7 @@ Manual testing workflow: ## Technical Debt TODOs +- **CRITICAL (Licensing / pre-commercial release):** Remove all `SixLabors.ImageSharp` and `QRCoder-ImageSharp` package dependencies from production/runtime projects before commercial release. Replace remaining QR/image paths with permissive-license alternatives (no copyleft/share-source obligations), run repo-wide package audit (`*.csproj`), and add CI guardrails to block reintroduction. - Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity. - Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation). - Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component. @@ -293,11 +304,51 @@ Manual testing workflow: **Key Tables:** - `TBSIG_ENVELOPE` - Envelope metadata - `TBSIG_ENVELOPE_RECEIVER` - Receiver assignments -- `TBSIG_DOC_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES) +- `TBSIG_DOCUMENT_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES) - `TBSIG_RECEIVER` - Receiver registry -- `TBSIG_DOCUMENT` - PDF binary data +- `TBSIG_ENVELOPE_DOCUMENT` - PDF binary data - `TBSIG_ENVELOPE_HISTORY` - Audit trail +### Data Structure (Domain + EGDbContext) + +Use this as the canonical quick map before touching sender/receiver workflow code. + +#### Core Aggregate Shape +- `Envelope` (`TBSIG_ENVELOPE`) is the root aggregate for sending workflow. +- `Envelope -> Documents` is `1:N` via `Document.EnvelopeId` (`TBSIG_ENVELOPE_DOCUMENT`). +- `Envelope -> Histories` is `1:N` via `History.EnvelopeId` (`TBSIG_ENVELOPE_HISTORY`). +- `Envelope <-> Receiver` is `N:N` through `EnvelopeReceiver` (`TBSIG_ENVELOPE_RECEIVER`, composite PK `{EnvelopeId, ReceiverId}`). +- `Document -> DocReceiverElement` is `1:N` via `DocReceiverElement.DocumentId` (`TBSIG_DOCUMENT_RECEIVER_ELEMENT`). +- `DocReceiverElement -> ElementAnnotation` is `1:N` via `ElementAnnotation.ElementId` (`TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION`). + +#### Status and Tracking Entities +- `DocumentStatus` (`TBSIG_DOCUMENT_STATUS`) links `EnvelopeId + ReceiverId` and stores receiver-level signing state. +- `History` stores timeline events (`EnvelopeStatus`) with polymorphic `UserReference` lookup semantics. +- `EnvelopeReceiverReadOnly` (`TBSIG_ENVELOPE_RECEIVER_READ_ONLY`) stores temporary read-only delegation with validity window (`DateValid`). + +#### Important Constraints From `EGDbContext` +- `Envelope.Uuid` has a unique index. +- `Receiver.Signature` has a unique index. +- `Receiver.EmailAddress` has a unique index. +- `EnvelopeReceiver` uses composite primary key: `{ EnvelopeId, ReceiverId }`. +- `History.UserReference` is mapped conditionally (`IsMigration == false`) to: + - `Receiver.EmailAddress` (`History.Receiver`) + - `User.Email` from UserManager (`History.Sender`) +- `EnvelopeReceiverReadOnly.AddedWho` maps to `Receiver.EmailAddress` (principal key mapping, not receiver GUID). + +#### Coordinate-Critical Fields +- Signature placement is persisted in `DocReceiverElement` as `X`, `Y`, `Width`, `Height`, `Page`. +- These coordinates are in INCHES (not PDF points, not pixels). + +#### Cross-Module Tables in Same Context +- `EGDbContext` also includes UserManager entities: `User`, `Group`, `Module`, `GroupOfUser`, `ModuleOfUser`, `UserRep`, and `ClientUser`. +- Mail dispatcher entity `EmailOut` is also registered under `#if NET` and trigger-configurable. + +#### Trigger-Aware Model Notes +- Trigger names are injected from `DbTriggerParams` in appsettings and applied via `ToTable(tb => tb.HasTrigger(...))`. +- Current trigger registration is explicit (`AddTrigger()` calls), not attribute/reflection driven. +- Do not remove trigger mappings casually; this project depends on DB-side automation/audit behavior. + ## DevExpress **License:** Commercial (v25.2.3) diff --git a/DATABASE_STRUCTURE_CONTEXT.md b/DATABASE_STRUCTURE_CONTEXT.md new file mode 100644 index 00000000..37a03886 --- /dev/null +++ b/DATABASE_STRUCTURE_CONTEXT.md @@ -0,0 +1,384 @@ +# Database Structure Context + +## Why this file exists + +This is the deep-dive DB reference for migration work when schema can change and old DB knowledge may be lost. + +- Scope: `EnvelopeGenerator.Domain` entities + `EnvelopeGenerator.Infrastructure/EGDbContext.cs` + live DB metadata snapshot from `DD_ECM`. +- Goal: make schema transitions predictable (what exists, how it is linked, where logic executes). +- Safety: all DB checks used read-only `SELECT` metadata queries; no data mutation. + +## Snapshot metadata + +- Source DB: `DD_ECM` on `SDD-VHP04-SQL19\DD_TESTING01` +- Snapshot date: `2026-10-20` +- App context: `EnvelopeGenerator.Infrastructure/EGDbContext.cs` + +## Fast navigation + +- High-level aggregate map: `AGENTS.md` -> `Database` section +- EF model config source: `EnvelopeGenerator.Infrastructure/EGDbContext.cs` +- Stored procedure wrappers: + - `EnvelopeGenerator.Application/Common/SQL/EnvelopeCreateReadSQL.cs` + - `EnvelopeGenerator.Application/Common/SQL/EnvelopeReceiverAddReadSQL.cs` + - `EnvelopeGenerator.Application/Common/SQL/DocumentCreateReadSQL.cs` +- Direct SQL execution in controller: + - `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs` + +## Core data model (ER-level) + +- `Envelope` (`TBSIG_ENVELOPE`) is root aggregate. +- `Envelope (1) -> (N) Document` via `TBSIG_ENVELOPE_DOCUMENT.ENVELOPE_ID`. +- `Envelope (1) -> (N) History` via `TBSIG_ENVELOPE_HISTORY.ENVELOPE_ID`. +- `Envelope (N) <-> (N) Receiver` through `EnvelopeReceiver` (`TBSIG_ENVELOPE_RECEIVER`, composite PK `{ENVELOPE_ID, RECEIVER_ID}`). +- `Document (1) -> (N) DocReceiverElement` via `TBSIG_DOCUMENT_RECEIVER_ELEMENT.DOCUMENT_ID`. +- `DocReceiverElement (1) -> (N) ElementAnnotation` via `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION.ELEMENT_ID`. +- `DocumentStatus` links `Envelope + Receiver` state in `TBSIG_DOCUMENT_STATUS`. + +## Entity <-> table mapping (code-level) + +| Entity | Table | PK in code | PK in DB | Important relation notes | +|---|---|---|---|---| +| `Envelope` | `TBSIG_ENVELOPE` | `Id` | `GUID` | Has `Documents`, `Histories`, `EnvelopeReceivers`; unique index on `Uuid` configured in EF | +| `EnvelopeReceiver` | `TBSIG_ENVELOPE_RECEIVER` | `{EnvelopeId, ReceiverId}` | `{ENVELOPE_ID, RECEIVER_ID}` | DB also has `GUID` identity (not modeled as entity PK) | +| `Document` | `TBSIG_ENVELOPE_DOCUMENT` | `Id` | `GUID` | FK `ENVELOPE_ID` | +| `DocReceiverElement` | `TBSIG_DOCUMENT_RECEIVER_ELEMENT` | `Id` | `GUID` | Coordinates persisted here (`POSITION_X/Y`, `WIDTH`, `HEIGHT`, `PAGE`) | +| `ElementAnnotation` | `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION` | `Id` | `GUID` | FK `ELEMENT_ID` | +| `DocumentStatus` | `TBSIG_DOCUMENT_STATUS` | `Id` | `GUID` | FK `ENVELOPE_ID`, `RECEIVER_ID` | +| `History` | `TBSIG_ENVELOPE_HISTORY` | `Id` | `GUID` | `USER_REFERENCE` maps to `Receiver.EmailAddress` and `User.Email` when `IsMigration == false` | +| `Receiver` | `TBSIG_RECEIVER` | `Id` | `GUID` | Unique indexes in code: `Signature`, `EmailAddress` | +| `EnvelopeReceiverReadOnly` | `TBSIG_ENVELOPE_RECEIVER_READ_ONLY` | `Id` | `GUID` | `AddedWho` is FK-like mapping to `Receiver.EmailAddress` | +| `EnvelopeType` | `TBSIG_ENVELOPE_TYPE` | `Id` | `GUID` | Used as optional envelope defaults/template | +| `EmailTemplate` | `TBSIG_EMAIL_TEMPLATE` | `Id` | `GUID` | Trigger-managed `CHANGED_WHEN` | +| `EmailOut` (legacy/bridge) | `TBEMLP_EMAIL_OUT` | `Id` | `GUID` | Internal/obsolete domain class, still registered for DB access | + +## Physical schema snapshot (table/column level) + +Notes: +- Format per column: `COLUMN_NAME TYPE NULLABLE`. +- This section reflects live DB metadata at snapshot time. + +### `TBSIG_ENVELOPE` + +- `GUID int NOT NULL` +- `USER_ID int NOT NULL` +- `STATUS int NOT NULL` +- `ENVELOPE_UUID nvarchar(36) NOT NULL` +- `MESSAGE nvarchar(max) NOT NULL` +- `EXPIRES_WHEN datetime NULL` +- `EXPIRES_WARNING_WHEN datetime NULL` +- `VERIFICATION_TYPE tinyint NOT NULL` +- `TITLE nvarchar(128) NULL` +- `CONTRACT_TYPE int NULL` +- `LANGUAGE nvarchar(5) NULL` +- `SEND_REMINDER_EMAILS bit NULL` +- `FIRST_REMINDER_DAYS int NULL` +- `REMINDER_INTERVAL_DAYS int NULL` +- `ENVELOPE_TYPE int NULL` +- `CERTIFICATION_TYPE int NULL` +- `USE_ACCESS_CODE bit NULL` +- `FINAL_EMAIL_TO_CREATOR int NULL` +- `FINAL_EMAIL_TO_RECEIVERS int NULL` +- `EXPIRES_WHEN_DAYS int NULL` +- `EXPIRES_WARNING_WHEN_DAYS int NULL` +- `TFA_ENABLED bit NOT NULL` +- `DOC_RESULT varbinary(max) NULL` +- `LANG_CODE varchar(5) NOT NULL` +- `SOURCE varchar(10) NOT NULL` +- `COMMENT varchar(128) NULL` +- `ADDED_WHEN datetime NOT NULL` +- `CHANGED_WHEN datetime NULL` + +### `TBSIG_ENVELOPE_RECEIVER` + +- `ENVELOPE_ID int NOT NULL` +- `RECEIVER_ID int NOT NULL` +- `SEQUENCE int NOT NULL` +- `NAME nvarchar(128) NULL` +- `JOB_TITLE nvarchar(128) NULL` +- `COMPANY_NAME nvarchar(128) NULL` +- `PRIVATE_MESSAGE nvarchar(max) NULL` +- `ACCESS_CODE nvarchar(64) NULL` +- `ADDED_WHEN datetime NOT NULL` +- `CHANGED_WHEN datetime NULL` +- `GUID bigint NOT NULL` +- `PHONE_NUMBER varchar(20) NULL` +- `PARENT_ENVELOPE_RECEIVER_ID int NOT NULL` + +### `TBSIG_ENVELOPE_DOCUMENT` + +- `GUID int NOT NULL` +- `ENVELOPE_ID int NOT NULL` +- `FILENAME nvarchar(256) NOT NULL` +- `FILEPATH nvarchar(256) NOT NULL` +- `ADDED_WHEN datetime NOT NULL` +- `FILENAME_ORIGINAL nvarchar(256) NULL` +- `BYTE_DATA varbinary(max) NULL` + +### `TBSIG_DOCUMENT_RECEIVER_ELEMENT` + +- `GUID int NOT NULL` +- `DOCUMENT_ID int NOT NULL` +- `RECEIVER_ID int NOT NULL` +- `ELEMENT_TYPE int NOT NULL` +- `POSITION_X float NOT NULL` +- `POSITION_Y float NOT NULL` +- `WIDTH float NOT NULL` +- `HEIGHT float NOT NULL` +- `PAGE int NOT NULL` +- `REQUIRED bit NOT NULL` +- `TOOLTIP nvarchar(512) NULL` +- `READ_ONLY bit NOT NULL` +- `ANNOTATION_INDEX int NOT NULL` +- `ADDED_WHEN datetime NOT NULL` +- `CHANGED_WHEN datetime NULL` +- `CHANGED_WHO nvarchar(100) NULL` +- `FULL_NAME nvarchar(100) NULL` +- `POSITION nvarchar(100) NULL` +- `PLACE nvarchar(100) NULL` +- `INK varbinary(max) NULL` + +### `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION` + +- `GUID bigint NOT NULL` +- `ELEMENT_ID int NOT NULL` +- `NAME nvarchar(100) NOT NULL` +- `VALUE nvarchar(max) NOT NULL` +- `ADDED_WHEN datetime NOT NULL` +- `CHANGED_WHEN datetime NULL` +- `CHANGED_WHO nvarchar(100) NULL` +- `TYPE nvarchar(50) NOT NULL` +- `POSITION_X float NULL` +- `POSITION_Y float NULL` +- `WIDTH float NULL` +- `HEIGHT float NULL` + +### `TBSIG_DOCUMENT_STATUS` + +- `GUID int NOT NULL` +- `ENVELOPE_ID int NOT NULL` +- `RECEIVER_ID int NOT NULL` +- `STATUS int NOT NULL` +- `STATUS_CHANGED_WHEN datetime NULL` +- `VALUE nvarchar(max) NULL` +- `ADDED_WHEN datetime NOT NULL` +- `CHANGED_WHEN datetime NULL` + +### `TBSIG_ENVELOPE_HISTORY` + +- `GUID bigint NOT NULL` +- `ENVELOPE_ID int NOT NULL` +- `USER_REFERENCE nvarchar(128) NOT NULL` +- `STATUS int NOT NULL` +- `ADDED_WHEN datetime NOT NULL` +- `ACTION_DATE datetime NULL` +- `COMMENT nvarchar(max) NULL` + +### `TBSIG_RECEIVER` + +- `GUID int NOT NULL` +- `EMAIL_ADDRESS nvarchar(128) NOT NULL` +- `SIGNATURE nvarchar(64) NOT NULL` +- `ADDED_WHEN datetime NOT NULL` +- `TOTP_SECRET_KEY nvarchar(max) NULL` +- `TFA_REG_DEADLINE datetime NULL` +- `CULTURE nvarchar(10) NULL` + +### `TBSIG_ENVELOPE_RECEIVER_READ_ONLY` + +- `GUID bigint NOT NULL` +- `ENVELOPE_ID bigint NOT NULL` +- `RECEIVER_MAIL nvarchar(250) NOT NULL` +- `DATE_VALID date NOT NULL` +- `ADDED_WHO varchar(100) NOT NULL` +- `ADDED_WHEN datetime NULL` +- `CHANGED_WHO varchar(100) NULL` +- `CHANGED_WHEN datetime NULL` + +### `TBSIG_ENVELOPE_TYPE` + +- `GUID int NOT NULL` +- `TITLE nvarchar(128) NOT NULL` +- `LANGUAGE nvarchar(5) NOT NULL` +- `EXPIRES_DAYS int NULL` +- `CERTIFICATION_TYPE int NULL` +- `USE_ACCESS_CODE bit NULL` +- `FINAL_EMAIL_TO_CREATOR int NULL` +- `FINAL_EMAIL_TO_RECEIVERS int NULL` +- `ADDED_WHEN datetime NOT NULL` +- `CHANGED_WHEN datetime NULL` +- `EXPIRES_WARNING_DAYS int NULL` +- `SEND_REMINDER_EMAILS bit NULL` +- `FIRST_REMINDER_DAYS int NULL` +- `REMINDER_INTERVAL_DAYS int NULL` +- `CONTRACT_TYPE int NULL` + +### `TBSIG_EMAIL_TEMPLATE` + +- `GUID int NOT NULL` +- `NAME nvarchar(64) NULL` +- `BODY nvarchar(max) NULL` +- `SUBJECT nvarchar(512) NULL` +- `ADDED_WHEN datetime NOT NULL` +- `CHANGED_WHEN datetime NULL` +- `LANG_CODE varchar(5) NOT NULL` + +### `TBEMLP_EMAIL_OUT` + +- `GUID int NOT NULL` +- `REMINDER_TYPE_ID int NOT NULL` +- `SENDING_PROFILE int NOT NULL` +- `REFERENCE_ID int NOT NULL` +- `REFERENCE_STRING varchar(200) NULL` +- `ENTITY_ID int NULL` +- `WF_ID int NOT NULL` +- `WF_REFERENCE varchar(200) NULL` +- `EMAIL_ADRESS varchar(1000) NOT NULL` +- `EMAIL_SUBJ varchar(500) NOT NULL` +- `EMAIL_BODY varchar(max) NOT NULL` +- `EMAIL_ATTMT1 varchar(512) NULL` +- `EMAIL_SENT datetime NULL` +- `COMMENT varchar(500) NULL` +- `ADDED_WHO varchar(50) NOT NULL` +- `ADDED_WHEN datetime NULL` +- `CHANGED_WHO varchar(50) NULL` +- `CHANGED_WHEN datetime NULL` +- `ERROR_TIMESTAMP datetime NULL` +- `ERROR_MSG varchar(900) NULL` +- `ATTACHMENT1_BYTES varbinary(max) NULL` +- `ATT1_RELATED_ID bigint NULL` +- `ATT1_REL_TYPE varchar(20) NULL` + +## Constraints and indexes (relevant) + +From EF + DB metadata snapshot: + +- `TBSIG_ENVELOPE`: PK `PK_TBSIG_ENVELOPE (GUID)`. +- `TBSIG_ENVELOPE_RECEIVER`: composite PK `PK_TBSIG_ENVELOPE_RECEIVER (ENVELOPE_ID, RECEIVER_ID)`. +- `TBSIG_DOCUMENT_RECEIVER_ELEMENT`: PK `GUID`, plus unique key `UQTBSIG_DOCUMENT_RECEIVER_ELEMENT_DocID_PosX_PosY (DOCUMENT_ID, POSITION_X, POSITION_Y)`. +- `TBSIG_RECEIVER`: PK `GUID`, unique key `UQTBSIG_RECEIVER (EMAIL_ADDRESS)`. +- `TBEMLP_EMAIL_OUT`: PK `GUID`, FK `FK_TBEMLP_EMAIL_OUT_SENDING_PROFILE`. + +## Stored procedures: behavior and code call sites + +### Active in .NET code path + +1) `PRSIG_API_CREATE_ENVELOPE` +- Purpose: creates envelope row (`STATUS=1002`, `SOURCE='API'`, defaults) and returns `ENVELOPE_UUID` via output. +- DB writes: `TBSIG_ENVELOPE`. +- Called by: + - SQL template `EnvelopeGenerator.Application/Common/SQL/EnvelopeCreateReadSQL.cs` + - executor `EnvelopeGenerator.Infrastructure/Executor/EnvelopeExecutor.cs` + - used in `EnvelopeReceiverController.CreateAsync` and `CreateEnvelopeReceiverCommandHandler`. + +2) `PRSIG_API_CREATE_RECEIVER` +- Purpose: upserts `TBSIG_RECEIVER` by email, adds envelope assignment row with generated access code. +- DB writes: `TBSIG_RECEIVER`, `TBSIG_ENVELOPE_RECEIVER`. +- Called by: + - SQL template `EnvelopeGenerator.Application/Common/SQL/EnvelopeReceiverAddReadSQL.cs` + - executor `EnvelopeGenerator.Infrastructure/Executor/EnvelopeReceiverExecutor.cs` + - used in `EnvelopeReceiverController.CreateAsync` and `CreateEnvelopeReceiverCommandHandler`. + +3) `PRSIG_API_ADD_DOC` +- Purpose: stores one document for envelope if absent; returns doc id. +- DB writes: `TBSIG_ENVELOPE_DOCUMENT`. +- Called by: + - SQL template `EnvelopeGenerator.Application/Common/SQL/DocumentCreateReadSQL.cs` + - executor `EnvelopeGenerator.Infrastructure/Executor/DocumentExecutor.cs` + - used in `EnvelopeReceiverController.CreateAsync`. + +4) `PRSIG_API_ADD_DOC_RECEIVER_ELEM` +- Purpose: inserts signature element record if `(DOC_ID, RECEIVER_ID)` pair does not exist. +- DB writes: `TBSIG_DOCUMENT_RECEIVER_ELEMENT`. +- Called directly by: + - `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs` + +5) `PRSIG_API_ADD_HISTORY_STATE` +- Purpose: appends history row with status and user reference. +- DB writes: `TBSIG_ENVELOPE_HISTORY`. +- Called directly by: + - `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs` + +### Legacy/other path references in repo + +- `PRSIG_GET_ENVELOPES_FOR_USER` and `PRSIG_GET_CLOSED_ENVELOPES_FOR_USER` are called in legacy VB path: + - `EnvelopeGenerator.CommonServices/Models/EnvelopeModel.vb` + +- `PRSIG_API_WITHDRAW_ENVELOPE` exists in DB snapshot but no active `.cs` call site found in current server flow. + +## Trigger behavior and where they are wired + +## How trigger names are wired in app + +- Config source: `EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json` -> `DbTriggerParams`. +- DI binding: `EnvelopeGenerator.Infrastructure/DependencyInjection.cs` (`AddDbTriggerParams`). +- Model binding: `EnvelopeGenerator.Infrastructure/EGDbContext.cs` -> `AddTrigger()` -> `ToTable(tb => tb.HasTrigger(triggerName))`. + +### Trigger definitions observed in DB snapshot + +1) `TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS` +- Fires on insert of `TBSIG_DOCUMENT_RECEIVER_ELEMENT`. +- Normalizes/rewrites `POSITION_X`, `POSITION_Y` and sets `CHANGED_WHO='TBSIG_DOC_REC_EL_AFT_INS'`. + +2) `TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD` +- Fires on update of `TBSIG_DOCUMENT_RECEIVER_ELEMENT`. +- Rewrites coordinates and `CHANGED_WHO='TBSIG_DOC_REC_EL_AFT_UPD'` except when `CHANGED_WHO='OFFSET_Y_POS'`. + +3) `TBSIG_ENVELOPE_HISTORY_AFT_INS` +- Fires on history insert. +- Central state machine side-effects: + - syncs `TBSIG_ENVELOPE.STATUS` for selected `100x` states, + - on `1003` applies Y-offset update to receiver elements, + - on `2005` calculates partial/fully signed and may insert follow-up `1006` history, + - on `2007` (type-dependent reject path) writes rejection emails into `TBEMLP_EMAIL_OUT` and updates envelope rejected state, + - on `3001` invitation-message counting can move envelope to `1004`. + +4) `TBSIG_EMAIL_TEMPLATE_AFT_UPD` +- Fires on update of `TBSIG_EMAIL_TEMPLATE`. +- Sets `CHANGED_WHEN = GETDATE()`. + +5) `TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD` +- Fires on update of `TBSIG_ENVELOPE_RECEIVER_READ_ONLY`. +- Updates config timestamp row (`TBSIG_CONFIG.GUID = 1`). + +6) `TBEMLP_EMAIL_OUT_AFT_UPD` +- Fires on update of `TBEMLP_EMAIL_OUT`. +- When `EMAIL_SENT` changes and `WF_ID in (3001..3005)`, writes corresponding history entries to `TBSIG_ENVELOPE_HISTORY`. + +### Trigger mismatch alert (important) + +- `DbTriggerParams` contains `TBEMLP_EMAIL_OUT_AFT_INS`, but this trigger was not found in the snapshot DB (`ExistsInDb = 0`). +- `TBEMLP_EMAIL_OUT_AFT_UPD` exists and is active. + +This should be treated as environment drift and validated before migrations. + +## Known model-vs-DB drift risks + +These are not necessarily bugs, but they are migration risk hotspots: + +- `Receiver.EmailAddress` is `[StringLength(250)]` in entity, DB column is `nvarchar(128)`. +- `EnvelopeReceiverReadOnly.AddedWho` entity expects `nvarchar(250)`, DB column is `varchar(100)`. +- `EnvelopeReceiver` entity does not model DB columns `GUID` and `PARENT_ENVELOPE_RECEIVER_ID`. +- `Envelope` entity does not explicitly model some DB columns (`VERIFICATION_TYPE`, `LANG_CODE`, `SOURCE`). +- `EmailOut` entity has `[NotMapped] AddedWhen`, while DB has `ADDED_WHEN` column. + +Before schema changes, reconcile these deltas intentionally (either mapping update or documented accepted divergence). + +## Migration-safe operating rules + +- Do not rely on implicit trigger behavior; document expected side effects in feature code touching history/status flows. +- Any change to `TBSIG_ENVELOPE_HISTORY` semantics requires trigger impact review first. +- Keep `DbTriggerParams` synchronized with actual DB trigger inventory per environment. +- For stored procedure changes, update both: + - SQL wrapper templates (`Application/Common/SQL/*`) + - direct SQL callers in controllers/executors. + +## Refresh checklist (read-only) + +Run these metadata checks before/after DB migration: + +1. Column inventory per table (`INFORMATION_SCHEMA.COLUMNS`). +2. Constraint inventory (`INFORMATION_SCHEMA.TABLE_CONSTRAINTS` + `KEY_COLUMN_USAGE`). +3. Trigger inventory (`sys.triggers`) and definitions (`OBJECT_DEFINITION`). +4. Stored procedure inventory (`sys.procedures`) and definitions (`OBJECT_DEFINITION`). +5. Repo call-site grep for `PRSIG_` names to detect dead or missing integration points. diff --git a/EnvelopeGenerator.Application/Common/Configurations/ESignParams.cs b/EnvelopeGenerator.Application/Common/Configurations/ESignParams.cs new file mode 100644 index 00000000..3f454cdf --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Configurations/ESignParams.cs @@ -0,0 +1,17 @@ +namespace EnvelopeGenerator.Application.Common.Configurations; + +/// +/// +/// +public class ESignParams +{ + /// + /// + /// + public required string ApplicationName { get; set; } + + /// + /// + /// + public string DefaultReason { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/EnvelopeGenerator.Application/Common/Dto/DocReceiverElementDto.cs b/EnvelopeGenerator.Application/Common/Dto/DocReceiverElementDto.cs index 808561f6..df8ef0e7 100644 --- a/EnvelopeGenerator.Application/Common/Dto/DocReceiverElementDto.cs +++ b/EnvelopeGenerator.Application/Common/Dto/DocReceiverElementDto.cs @@ -1,12 +1,14 @@ -using EnvelopeGenerator.Domain.Constants; +using EnvelopeGenerator.Application.Common.Dto.Receiver; +using EnvelopeGenerator.Domain.Constants; using EnvelopeGenerator.Domain.Interfaces; namespace EnvelopeGenerator.Application.Common.Dto; +// TODO: Add the “Reason” property and integrate with UI and the digital signature process. /// /// Data Transfer Object representing a positioned element assigned to a document receiver. /// -public class DocReceiverElementDto : IDocReceiverElement +public class DocReceiverElementDto : IDocReceiverElement, ISignatureInkPayload { /// /// Gets or sets the unique identifier of the element. @@ -18,11 +20,21 @@ public class DocReceiverElementDto : IDocReceiverElement /// public int DocumentId { get; set; } + /// + /// + /// + public virtual DocumentDto? Document { get; set; } + /// /// Gets or sets the identifier of the receiver. /// public int ReceiverId { get; set; } + /// + /// + /// + public virtual ReceiverDto? Receiver { get; set; } + /// /// Gets or sets the type of the element. /// @@ -122,4 +134,41 @@ public class DocReceiverElementDto : IDocReceiverElement /// /// public byte[]? Ink { get; set; } -} \ No newline at end of file + + /// + /// Returns the stored signature bytes as an image payload with detected MIME type. + /// + /// Thrown when signature image bytes do not exist. + public (byte[] Bytes, string ContentType, string FileExtension) ToImage() + { + if (Ink is not { Length: > 0 }) + throw new InvalidOperationException("Signature image (Ink) does not exist."); + + if (HasPrefix(Ink, 0x89, 0x50, 0x4E, 0x47)) + return (Ink, "image/png", ".png"); + + if (HasPrefix(Ink, 0xFF, 0xD8, 0xFF)) + return (Ink, "image/jpeg", ".jpg"); + + if (HasPrefix(Ink, 0x52, 0x49, 0x46, 0x46) && + Ink.Length >= 12 && + Ink[8] == 0x57 && Ink[9] == 0x45 && Ink[10] == 0x42 && Ink[11] == 0x50) + return (Ink, "image/webp", ".webp"); + + return (Ink, "application/octet-stream", ".bin"); + } + + private static bool HasPrefix(byte[] data, params byte[] prefix) + { + if (data.Length < prefix.Length) + return false; + + for (var i = 0; i < prefix.Length; i++) + { + if (data[i] != prefix[i]) + return false; + } + + return true; + } +} diff --git a/EnvelopeGenerator.Application/Common/Dto/PdfSignOptionsDto.cs b/EnvelopeGenerator.Application/Common/Dto/PdfSignOptionsDto.cs new file mode 100644 index 00000000..8c08b171 --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Dto/PdfSignOptionsDto.cs @@ -0,0 +1,54 @@ +using EnvelopeGenerator.Application.Common.Enums; + +namespace EnvelopeGenerator.Application.Common.Dto; + +/// +/// +/// +public sealed record PdfSignOptionsDto +{ + /// + /// + /// + public long DocumentElementId { get; set; } + + /// + /// + /// + public string Name { get; set; } = null!; + + /// + /// + /// + public string Reason { get; set; } = null!; + + /// + /// + /// + public string Location { get; set; } = null!; + + /// + /// + /// + public string? Email { get; set; } + + /// + /// + /// + public string? Phone { get; set; } + + /// + /// + /// + public string ApplicationName { get; set; } = null!; + + /// + /// + /// + public PdfSignatureCertificationLevel CertificationLevel { get; set; } + + /// + /// + /// + public PdfSignatureAppearanceDto? Appearance { get; set; } +} diff --git a/EnvelopeGenerator.Application/Common/Dto/PdfSignatureAppearanceDto.cs b/EnvelopeGenerator.Application/Common/Dto/PdfSignatureAppearanceDto.cs new file mode 100644 index 00000000..f3afa1fc --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Dto/PdfSignatureAppearanceDto.cs @@ -0,0 +1,96 @@ +namespace EnvelopeGenerator.Application.Common.Dto; + +/// +/// +/// +public enum PdfSignatureCoordinateUnit +{ + /// + /// + /// + Point = 0, + + /// + /// + /// + Inch = 1 +} + +/// +/// +/// +public enum PdfSignatureCoordinateOrigin +{ + /// + /// + /// + BottomLeft = 0, + + /// + /// + /// + TopLeft = 1 +} + +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +public sealed record PdfSignatureAppearanceDto( + int PageNumber, + double Left, + double Bottom, + double Width, + double Height, + bool ShowLabels = false, + bool ShowName = false, + bool ShowDate = false, + bool ShowReason = false, + bool ShowLocation = false, + bool ShowDistinguishedName = false, + string DateTimeFormat = "dd.MM.yyyy HH:mm:ss zzz", + PdfSignatureCoordinateUnit CoordinateUnit = PdfSignatureCoordinateUnit.Point, + PdfSignatureCoordinateOrigin CoordinateOrigin = PdfSignatureCoordinateOrigin.BottomLeft) +{ + /// + /// + /// + /// + /// + /// + /// + /// + public sealed record FontDto(string FamilyName = "Helvetica", float Size = 10, bool IsBold = false, bool IsItalic = false, bool IsUnderline = false) + { + /// + /// + /// + public RgbaColorDto Color { get; init; } = new(); + } + + /// + /// + /// + /// + /// + /// + public sealed record RgbaColorDto(double R = 0, double G = 0, double B = 0); + + /// + /// + /// + public FontDto Font { get; init; } = new(); +} diff --git a/EnvelopeGenerator.Application/Common/Dto/PdfSignatureCheckResultDto.cs b/EnvelopeGenerator.Application/Common/Dto/PdfSignatureCheckResultDto.cs new file mode 100644 index 00000000..7652ca9e --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Dto/PdfSignatureCheckResultDto.cs @@ -0,0 +1,57 @@ +namespace EnvelopeGenerator.Application.Common.Dto; + +public sealed record PdfSignaturePrincipalDto( + string? CommonName, + string? Organization, + string? Country, + string? Full); + +public sealed record PdfSignatureCertificateInfoDto( + PdfSignaturePrincipalDto IssuedTo, + PdfSignaturePrincipalDto IssuedBy, + string? SerialNumber, + DateTimeOffset? ValidFrom, + DateTimeOffset? ValidTo, + bool SelfSigned, + bool Expired, + bool NotYetValid, + bool IsSigner, + int? Version, + string? KeyAlgorithm, + string? SignatureAlgorithm, + string? Pem, + int? KeySizeBits); + +public sealed record PdfSignatureCheckItemDto( + bool CoversWholeDoc, + string? Reason, + string? BusinessReason, + string? Location, + string? DeclaredName, + string? ContactInfo, + string? Email, + string? Phone, + string? DigestAlgorithm, + string? SignatureAlgorithm, + string? SignerName, + string? Issuer, + string? SerialNumber, + DateTimeOffset? ValidFrom, + DateTimeOffset? ValidTo, + string ExpiryCheckedAgainst, + IReadOnlyList Certificates, + bool Unchanged, + string? FieldName, + bool IsCryptographicallyValid, + string? SigningTime, + string? Filter, + string? SubFilter, + string? ApplicationName, + long? DocumentElementId, + string? OriginalDocumentSha256, + string? MessageDigestHex, + string? Error); + +public sealed record PdfSignatureCheckResultDto( + bool Signed, + IReadOnlyList Signatures); diff --git a/EnvelopeGenerator.Application/Common/Enums/PdfSignatureCertificationLevel.cs b/EnvelopeGenerator.Application/Common/Enums/PdfSignatureCertificationLevel.cs new file mode 100644 index 00000000..dc46493f --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Enums/PdfSignatureCertificationLevel.cs @@ -0,0 +1,27 @@ +namespace EnvelopeGenerator.Application.Common.Enums; + +/// +/// +/// +public enum PdfSignatureCertificationLevel +{ + /// + /// All changes are allowed + /// + NoCertification = 0, + + /// + /// No changes are allowed + /// + NoChangesAllowed, + + /// + /// Allows users to fill in forms and sign the document. + /// + FillForms, + + /// + /// Allows users to fill in forms, create, modify and delete annotations, and sign the document. + /// + FillFormsAndAnnotate +} diff --git a/EnvelopeGenerator.Application/Common/Interfaces/Doc/IPdfPageImageRenderer.cs b/EnvelopeGenerator.Application/Common/Interfaces/Doc/IPdfPageImageRenderer.cs index 361211f0..08473724 100644 --- a/EnvelopeGenerator.Application/Common/Interfaces/Doc/IPdfPageImageRenderer.cs +++ b/EnvelopeGenerator.Application/Common/Interfaces/Doc/IPdfPageImageRenderer.cs @@ -1,8 +1,66 @@ +using DigitalData.Core.Exceptions; using EnvelopeGenerator.Application.Common.Dto; +using EnvelopeGenerator.Domain.Interfaces; namespace EnvelopeGenerator.Application.Common.Interfaces.Doc; +/// +/// +/// public interface IPdfPageImageRenderer { + /// + /// + /// + /// + /// + /// Task> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); + + /// + /// + /// + /// + /// + Stream ComposeSignaturePreviewPng(ISignatureInkPayload element); + + /// + /// + /// + /// + /// + public bool IsSupportedImageFormat(byte[] bytes); +} + +/// +/// +/// +public static class PdfPageImageRendererExtensions +{ + /// + /// + /// + /// + /// + /// + /// + public static byte[] EnsureSupportedImageBytes(this IPdfPageImageRenderer renderer, byte[] inkBytes) + { + if (!renderer.IsSupportedImageFormat(inkBytes)) + throw new BadRequestException("Only PNG and JPEG signature formats are supported."); + + return inkBytes; + } + + public static byte[] ComposeSignaturePreviewPngBytes(this IPdfPageImageRenderer renderer, ISignatureInkPayload element) + { + using var stream = renderer.ComposeSignaturePreviewPng(element); + + if (stream is MemoryStream memoryStream) + return memoryStream.ToArray(); + + using var copy = new MemoryStream(); + stream.CopyTo(copy); + return copy.ToArray(); + } } diff --git a/EnvelopeGenerator.Application/Common/Interfaces/Services/IPdfDigitalSignatureService.cs b/EnvelopeGenerator.Application/Common/Interfaces/Services/IPdfDigitalSignatureService.cs new file mode 100644 index 00000000..db8ab3c5 --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Interfaces/Services/IPdfDigitalSignatureService.cs @@ -0,0 +1,29 @@ +using EnvelopeGenerator.Application.Common.Dto; + +namespace EnvelopeGenerator.Application.Common.Interfaces.Services; + +/// +/// Defines PDF digital-signing operations for the application boundary. +/// +public interface IPdfDigitalSignatureService +{ + /// + /// Creates a digital signature for the provided PDF bytes and returns + /// a new signed PDF document. + /// + /// Source PDF content stream. + /// Destination stream for signed PDF output. + /// Optional signing options that map to PDF signature metadata fields. + /// Optional image stream for signature appearance. + /// Cancellation token for cooperative cancellation. + Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, Stream? image = null, CancellationToken cancellationToken = default); + + /// + /// Checks whether the provided PDF contains signature field(s) and whether + /// those signatures are cryptographically valid. + /// + /// PDF content stream to inspect. + /// Cancellation token for cooperative cancellation. + /// A structured result containing signature presence and per-signature details. + Task CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default); +} diff --git a/EnvelopeGenerator.Application/Common/Interfaces/Services/Notification/DocumentSignedNotification.cs b/EnvelopeGenerator.Application/Common/Interfaces/Services/Notification/DocumentSignedNotification.cs new file mode 100644 index 00000000..01fb3573 --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Interfaces/Services/Notification/DocumentSignedNotification.cs @@ -0,0 +1,37 @@ +namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification; + +/// +/// +/// +public record DocumentSignedNotification +{ + /// + /// + /// + public required string DocumentTitle { get; init; } + + /// + /// + /// + public required string NameReceiver { get; init; } + + /// + /// + /// + public string NamePortal { get; init; } = "signFLOW Portal"; + + /// + /// + /// + public required byte[] Attachment { get; init; } +} + +/* + +Umschlag abgeschlossen: '[DOCUMENT_TITLE]' + +--- + +Guten Tag [NAME_RECEIVER],

Der Vorgang '[DOCUMENT_TITLE]' wurde erfolgreich abgeschlossen.

Sie erhalten das Dokument mit einem detaillierten Ergebnisbericht als Anhang zu dieser Email.

Mit freundlichen Grüßen

[NAME_PORTAL] + +*/ \ No newline at end of file diff --git a/EnvelopeGenerator.Application/Common/Interfaces/Services/Notification/IEnvelopeNotificationService.cs b/EnvelopeGenerator.Application/Common/Interfaces/Services/Notification/IEnvelopeNotificationService.cs new file mode 100644 index 00000000..2fb618f7 --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Interfaces/Services/Notification/IEnvelopeNotificationService.cs @@ -0,0 +1,16 @@ +namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification; + +/// +/// +/// +public interface IEnvelopeNotificationService +{ + /// + /// + /// + /// + /// + /// + /// + public Task Notify(string email, DocumentSignedNotification documentSigned, CancellationToken cancel = default); +} diff --git a/EnvelopeGenerator.Application/DependencyInjection.cs b/EnvelopeGenerator.Application/DependencyInjection.cs index 97c9757c..08188fc9 100644 --- a/EnvelopeGenerator.Application/DependencyInjection.cs +++ b/EnvelopeGenerator.Application/DependencyInjection.cs @@ -51,6 +51,7 @@ public static class DependencyInjection services.Configure(config.GetSection(nameof(MailParams))); services.Configure(config.GetSection(nameof(AuthenticatorParams))); services.Configure(config.GetSection(nameof(TotpSmsParams))); + services.Configure(config.GetSection(nameof(ESignParams))); services.AddHttpClientService(config.GetSection(nameof(GtxMessagingParams))); services.TryAddScoped(); // Changed: Singleton → Scoped diff --git a/EnvelopeGenerator.Application/DocReceiverElements/Queries/ReadDocReceiverElementQuery.cs b/EnvelopeGenerator.Application/DocReceiverElements/Queries/ReadDocReceiverElementQuery.cs index 3139051b..95dcac71 100644 --- a/EnvelopeGenerator.Application/DocReceiverElements/Queries/ReadDocReceiverElementQuery.cs +++ b/EnvelopeGenerator.Application/DocReceiverElements/Queries/ReadDocReceiverElementQuery.cs @@ -14,6 +14,15 @@ namespace EnvelopeGenerator.Application.DocReceiverElements.Queries; /// public record ReadDocReceiverElementQuery : EnvelopeReceiverQueryBase, IRequest> { + /// + /// + /// + public int? Id { get; set; } = null; + + /// + /// + /// + public bool IncludeDocument { get; set; } = false; } /// @@ -47,7 +56,10 @@ public class ReadDocReceiverElementQueryHandler : IRequestHandler e.Id == request.Id); + + if (request.Envelope.Id is int envelopeId) q = q.Where(e => e.Document.EnvelopeId == envelopeId); if (request.Envelope.Uuid is string envelopeUuid) @@ -59,6 +71,9 @@ public class ReadDocReceiverElementQueryHandler : IRequestHandler e.Receiver.Signature == signature); + if( request.IncludeDocument) + q = q.Include(e => e.Document); + var elements = await q.ToListAsync(cancellationToken); return _mapper.Map>(elements); diff --git a/EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiverCommand.cs b/EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiver/CreateEnvelopeReceiverCommand.cs similarity index 98% rename from EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiverCommand.cs rename to EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiver/CreateEnvelopeReceiverCommand.cs index dd0cb9c0..7f4ee9f8 100644 --- a/EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiverCommand.cs +++ b/EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiver/CreateEnvelopeReceiverCommand.cs @@ -2,7 +2,7 @@ using MediatR; using System.ComponentModel.DataAnnotations; -namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands; +namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver; /// /// Befehl zur Erstellung eines Umschlags. diff --git a/EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiverCommandHandler.cs b/EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiver/CreateEnvelopeReceiverCommandHandler.cs similarity index 99% rename from EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiverCommandHandler.cs rename to EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiver/CreateEnvelopeReceiverCommandHandler.cs index 519fe4d1..3bb60709 100644 --- a/EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiverCommandHandler.cs +++ b/EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiver/CreateEnvelopeReceiverCommandHandler.cs @@ -4,7 +4,7 @@ using MediatR; using EnvelopeGenerator.Application.Common.Dto.Receiver; using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor; -namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands; +namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver; /// /// Handles the creation of an envelope along with its associated document and recipients. diff --git a/EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiverResponse.cs b/EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiver/CreateEnvelopeReceiverResponse.cs similarity index 95% rename from EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiverResponse.cs rename to EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiver/CreateEnvelopeReceiverResponse.cs index 404656e2..ca1eef50 100644 --- a/EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiverResponse.cs +++ b/EnvelopeGenerator.Application/EnvelopeReceivers/Commands/CreateEnvelopeReceiver/CreateEnvelopeReceiverResponse.cs @@ -1,7 +1,7 @@ using EnvelopeGenerator.Application.Common.Dto; using EnvelopeGenerator.Application.Common.Dto.Receiver; -namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands; +namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver; /// /// diff --git a/EnvelopeGenerator.Application/EnvelopeReceivers/Queries/ReadEnvelopeReceiverSecretQuery.cs b/EnvelopeGenerator.Application/EnvelopeReceivers/Queries/ReadEnvelopeReceiverSecretQuery.cs index e9fd1c20..b3bc2ad0 100644 --- a/EnvelopeGenerator.Application/EnvelopeReceivers/Queries/ReadEnvelopeReceiverSecretQuery.cs +++ b/EnvelopeGenerator.Application/EnvelopeReceivers/Queries/ReadEnvelopeReceiverSecretQuery.cs @@ -21,7 +21,24 @@ namespace EnvelopeGenerator.Application.EnvelopeReceivers.Queries; /// public record ReadEnvelopeReceiverSecretQuery : EnvelopeReceiverQueryBase, - IRequest; + IRequest +{ + /// + /// + /// + public ReadEnvelopeReceiverSecretQuery() { } + + /// + /// + /// + /// + /// + public ReadEnvelopeReceiverSecretQuery(int envelopeId, int receiverId) + { + Envelope.Id = envelopeId; + Receiver.Id = receiverId; + } +} /// /// Extension methods for dispatching via . diff --git a/EnvelopeGenerator.Application/Envelopes/MappingProfile.cs b/EnvelopeGenerator.Application/Envelopes/MappingProfile.cs index cdf07c15..a6cf39f0 100644 --- a/EnvelopeGenerator.Application/Envelopes/MappingProfile.cs +++ b/EnvelopeGenerator.Application/Envelopes/MappingProfile.cs @@ -1,5 +1,5 @@ using AutoMapper; -using EnvelopeGenerator.Application.EnvelopeReceivers.Commands; +using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver; using EnvelopeGenerator.Application.Envelopes.Commands; using EnvelopeGenerator.Domain.Entities; diff --git a/EnvelopeGenerator.Application/Envelopes/Queries/ReadSignedEnvelopeQuery.cs b/EnvelopeGenerator.Application/Envelopes/Queries/ReadSignedEnvelopeQuery.cs new file mode 100644 index 00000000..80f196ea --- /dev/null +++ b/EnvelopeGenerator.Application/Envelopes/Queries/ReadSignedEnvelopeQuery.cs @@ -0,0 +1,62 @@ +using AutoMapper; +using DigitalData.Core.Abstraction.Application.Repository; +using EnvelopeGenerator.Application.Common.Dto; +using EnvelopeGenerator.Domain.Entities; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace EnvelopeGenerator.Application.Envelopes.Queries; + +/// +/// +/// +public record ReadSignedEnvelopeQuery : IRequest> +{ + /// + /// + /// + public bool? Burned { get; init; } = null; + + internal int? UserId { get; private set; } = null; + + /// + /// + /// + /// + /// + public ReadSignedEnvelopeQuery Authorized(int userId) + { + UserId = userId; + return this; + } +} + +/// +/// +/// +/// +/// +public class ReadSignedEnvelopeQueryHandler(IRepository Repo, IMapper Mapper) : IRequestHandler> +{ + /// + /// + /// + /// + /// + /// + /// + public async Task> Handle(ReadSignedEnvelopeQuery request, CancellationToken cancel) + { + var eQuery = Repo.Query.Where(e => e.Status == Domain.Constants.EnvelopeStatus.EnvelopeCompletelySigned); + + if (request.Burned is bool burned) + eQuery = burned ? eQuery.Where(e => e.DocResult != null) : eQuery.Where(e => e.DocResult == null); + + if(request.UserId is int userId) + eQuery = eQuery.Where(e => e.UserId == userId); + + var envelopes = await eQuery.ToListAsync(cancel); + + return Mapper.Map>(envelopes); + } +} \ No newline at end of file diff --git a/EnvelopeGenerator.Application/PdfSigning/FinalizeEnvelopeCommand.cs b/EnvelopeGenerator.Application/PdfSigning/FinalizeEnvelopeCommand.cs new file mode 100644 index 00000000..de3f469b --- /dev/null +++ b/EnvelopeGenerator.Application/PdfSigning/FinalizeEnvelopeCommand.cs @@ -0,0 +1,108 @@ +using DigitalData.Core.Abstraction.Application.Repository; +using DigitalData.Core.Exceptions; +using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification; +using EnvelopeGenerator.Application.Common.Query; +using EnvelopeGenerator.Domain.Constants; +using EnvelopeGenerator.Domain.Entities; +using EnvelopeGenerator.Domain.Interfaces; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace EnvelopeGenerator.Application.PdfSigning; + +/// +/// +/// +public record FinalizeEnvelopeCommand : EnvelopeQueryBase, IRequest +{ + internal int? UserId { get; private set; } = null; + + /// + /// + /// + /// + /// + public FinalizeEnvelopeCommand Authorized(int userId) + { + UserId = userId; + return this; + } + + /// + /// + /// + public bool BurnedAllowed { get; init; } = false; + + /// + /// + /// + public bool SaveDocResult { get; init; } = true; +} + +/// +/// +/// +/// +/// +/// +public class FinalizeEnvelopeCommandHandler(IRepository Repo, ISender Sender, IEnvelopeNotificationService NtfServer) : IRequestHandler +{ + /// + /// + /// + /// + /// + /// + /// + public async Task Handle(FinalizeEnvelopeCommand request, CancellationToken cancel) + { + var envelopeQuery = Repo.Query; + + if (request.Id is int id) + envelopeQuery = envelopeQuery.Where(e => e.Id == id); + else if (request.Uuid is string uuid) + envelopeQuery = envelopeQuery.Where(e => e.Uuid == uuid); + + var envelope = await envelopeQuery + .Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver) + .Include(e => e.Documents) + .SingleOrDefaultAsync(cancel) + ?? throw new NotFoundException($"Envelope is not found ID: {request.Id} UUID: {request.Uuid}"); + + if(request.UserId is int userId && userId != envelope.UserId) + throw new InvalidOperationException($"Envelope is not owned by user ID: {request.Id} UUID: {request.Uuid}"); + + // already burned + if (!request.BurnedAllowed && envelope.IsBurned()) + throw new InvalidOperationException($"Envelope is already burned ID: {request.Id} UUID: {request.Uuid}"); + + // envelope is not completely signed + if (envelope.Status!= EnvelopeStatus.EnvelopeCompletelySigned) + throw new InvalidOperationException($"Envelope status is not completely signed ID: {request.Id} UUID: {request.Uuid}"); + + using var docStream = await Sender.Send(new SignPdfByEnvelopeCommand() + { + Envelope = envelope + }, cancel); + + if (docStream.CanSeek) + docStream.Position = 0; + + using var ms = new MemoryStream(); + docStream.CopyTo(ms); + byte[] doc = ms.ToArray(); + + foreach (var er in envelope.EnvelopeReceivers) + { + await NtfServer.Notify(er.Receiver.EmailAddress, new DocumentSignedNotification() + { + DocumentTitle = envelope.Title, + NameReceiver = er.Name, + Attachment = doc + }, cancel); + } + + if (request.SaveDocResult) + await Repo.UpdateAsync(e => e.DocResult = doc, e => e.Id == envelope.Id, cancel); + } +} \ No newline at end of file diff --git a/EnvelopeGenerator.Application/PdfSigning/SignPdfByElementCommand.cs b/EnvelopeGenerator.Application/PdfSigning/SignPdfByElementCommand.cs new file mode 100644 index 00000000..8b296bc2 --- /dev/null +++ b/EnvelopeGenerator.Application/PdfSigning/SignPdfByElementCommand.cs @@ -0,0 +1,133 @@ +using DigitalData.Core.Exceptions; +using EnvelopeGenerator.Application.Common.Configurations; +using EnvelopeGenerator.Application.Common.Dto; +using EnvelopeGenerator.Application.Common.Enums; +using EnvelopeGenerator.Application.Common.Interfaces.Doc; +using EnvelopeGenerator.Application.Common.Interfaces.Services; +using EnvelopeGenerator.Application.DocReceiverElements.Queries; +using MediatR; +using Microsoft.Extensions.Options; + +namespace EnvelopeGenerator.Application.PdfSigning; + +/// +/// +/// +public record SignPdfByElementCommand : IRequest +{ + /// + /// + /// + public DocReceiverElementDto? Element { get; set; } + + /// + /// + /// + public int? ElementId { get; init; } + + /// + /// Custom document + /// + public Stream? Document { get; set; } + + /// + /// + /// + public string? Phone { get; set; } + + /// + /// + /// + public string? Reason { get; set; } + + /// + /// + /// + public PdfSignatureCertificationLevel CertificationLevel { get; init; } = PdfSignatureCertificationLevel.NoChangesAllowed; +} + +/// +/// +/// +/// +/// +/// +/// +public class SignPdfCommandHandler(ISender sender, IPdfDigitalSignatureService signService, IPdfPageImageRenderer imgRenderer, IOptionsMonitor options) : IRequestHandler +{ + /// + /// + /// + /// + /// + /// + public async Task Handle(SignPdfByElementCommand request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // ensure element + if (request.Element is null) + { + var elements = await sender.Send(new ReadDocReceiverElementQuery() + { + Id = request.ElementId!, + IncludeDocument = request.Document is null + }, cancellationToken); + + request.Element = elements.FirstOrDefault() ?? throw new NotFoundException($"Element with ID {request.ElementId} not found."); ; + } + + // ensure document + if(request.Document is null) + { + if (request.Element?.Document?.ByteData is not { Length: > 0 }) + throw new NotFoundException("Document data is missing or empty."); + + request.Document = new MemoryStream(request.Element.Document.ByteData); + } + + ValidateAfterDataIsComplete(request); + + // create signature image from element + var img = imgRenderer.ComposeSignaturePreviewPng(request.Element); + + // sign pdf + var outputPdf = new MemoryStream(); + + await signService.SignPdfAsync( + inputPdf: request.Document, + outputPdf: outputPdf, + options: new PdfSignOptionsDto + { + Name = request.Element.FullName!, + Location = request.Element.Position!, + Phone = request.Phone, + Reason = request.Reason ?? options.CurrentValue.DefaultReason, + Email = request.Element.Receiver!.EmailAddress, + ApplicationName = options.CurrentValue.ApplicationName, + DocumentElementId = request.Element.Id, + + CertificationLevel = request.CertificationLevel, + Appearance = new PdfSignatureAppearanceDto( + PageNumber: request.Element.Page, + Left: request.Element.Left, + Bottom: request.Element.Y, + Width: request.Element.Width, + Height: request.Element.Height, + CoordinateUnit: PdfSignatureCoordinateUnit.Inch, + CoordinateOrigin: PdfSignatureCoordinateOrigin.TopLeft) + }, + image: img, + cancellationToken: cancellationToken); + + return outputPdf; + } + + private static void ValidateAfterDataIsComplete(SignPdfByElementCommand request) + { + if(request.Element!.FullName is null) + throw new BadRequestException($"Document is not signed. FullName is required. Element ID: {request.Element.Id}"); + else if(request.Element.Position is null) + throw new BadRequestException($"Document is not signed. Position is required. Element ID: {request.Element.Id}"); + } +} \ No newline at end of file diff --git a/EnvelopeGenerator.Application/PdfSigning/SignPdfByElementCommandValidator.cs b/EnvelopeGenerator.Application/PdfSigning/SignPdfByElementCommandValidator.cs new file mode 100644 index 00000000..35a9019e --- /dev/null +++ b/EnvelopeGenerator.Application/PdfSigning/SignPdfByElementCommandValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace EnvelopeGenerator.Application.PdfSigning; + +public class SignPdfByElementCommandValidator : AbstractValidator +{ + public SignPdfByElementCommandValidator() + { + RuleFor(x => x) + .Must(x => (x.Element is not null) ^ x.ElementId.HasValue) + .WithMessage("Exactly one of Element or ElementId must be provided."); + + RuleFor(x => x.ElementId) + .GreaterThan(0) + .When(x => x.ElementId.HasValue) + .WithMessage("ElementId must be greater than 0."); + } +} diff --git a/EnvelopeGenerator.Application/PdfSigning/SignPdfByEnvelopeCommand.cs b/EnvelopeGenerator.Application/PdfSigning/SignPdfByEnvelopeCommand.cs new file mode 100644 index 00000000..019e977b --- /dev/null +++ b/EnvelopeGenerator.Application/PdfSigning/SignPdfByEnvelopeCommand.cs @@ -0,0 +1,94 @@ +using DigitalData.Core.Abstraction.Application.Repository; +using DigitalData.Core.Exceptions; +using EnvelopeGenerator.Application.Common.Query; +using EnvelopeGenerator.Domain.Entities; +using EnvelopeGenerator.Domain.Exceptions; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace EnvelopeGenerator.Application.PdfSigning; +/// +/// +/// +public record SignPdfByEnvelopeCommand : EnvelopeQueryBase, IRequest +{ + /// + /// + /// + public int? ReceiverId { get; init; } = null; + + internal int? UserId { get; private set; } = null; + + /// + /// + /// + /// + /// + public SignPdfByEnvelopeCommand Authorized(int userId) + { + UserId = userId; + return this; + } + + internal Envelope? Envelope { get; set; } +} + +/// +/// +/// +public class SignPdfByEnvelopeCommandHandler(IRepository Repo, ISender sender) : IRequestHandler +{ + /// + /// + /// + /// + /// + /// + /// + public async Task Handle(SignPdfByEnvelopeCommand request, CancellationToken cancel) + { + if (request.Envelope is null) + { + var envelopeQuery = Repo.Query; + + if (request.Id is int id) + envelopeQuery = envelopeQuery.Where(e => e.Id == id); + else if (request.Uuid is string uuid) + envelopeQuery = envelopeQuery.Where(e => e.Uuid == uuid); + + request.Envelope = await envelopeQuery.Include(e => e.EnvelopeReceivers).Include(e => e.Documents).SingleOrDefaultAsync(cancel) + ?? throw new NotFoundException($"Envelope is not found ID: {request.Id} UUID: {request.Uuid}"); + } + + var envelope = request.Envelope; + + if (request.UserId is int userId && userId != envelope.UserId) + throw new AuthorizationException($"Envelope is not authorized for user with Id: {request.UserId} Envelope ID: {request.Id} UUID: {request.Uuid}"); + + if (envelope.EnvelopeReceivers.Count == 0) + throw new NotFoundException($"Envelope has no receivers to sign ID: {request.Id} UUID: {request.Uuid}"); + + if(envelope.Documents.FirstOrDefault() is not Document document || document.ByteData is not byte[] byteData || byteData.Length == 0) + throw new NotFoundException($"Envelope has no documents to sign ID: {request.Id} UUID: {request.Uuid}"); + + Stream doc = new MemoryStream(byteData); + + var eReceivers = request.ReceiverId is int receiverId + ? envelope.EnvelopeReceivers.Where(e => e.ReceiverId == receiverId) + : envelope.EnvelopeReceivers; + + foreach (var eReceiver in eReceivers) + { + var signedDoc = await sender.Send(new SignPdfByEnvelopeReceiverCommand() + { + EnvelopeId = eReceiver.EnvelopeId, + ReceiverId = eReceiver.ReceiverId + }, cancel); + + await doc.DisposeAsync(); + doc = signedDoc; + } + + return doc; + } +} \ No newline at end of file diff --git a/EnvelopeGenerator.Application/PdfSigning/SignPdfByEnvelopeReceiverCommand.cs b/EnvelopeGenerator.Application/PdfSigning/SignPdfByEnvelopeReceiverCommand.cs new file mode 100644 index 00000000..a1714e48 --- /dev/null +++ b/EnvelopeGenerator.Application/PdfSigning/SignPdfByEnvelopeReceiverCommand.cs @@ -0,0 +1,99 @@ +using AutoMapper; +using DigitalData.Core.Abstraction.Application.Repository; +using DigitalData.Core.Exceptions; +using EnvelopeGenerator.Application.Common.Dto; +using EnvelopeGenerator.Application.Common.Enums; +using EnvelopeGenerator.Application.Documents.Queries; +using EnvelopeGenerator.Application.EnvelopeReceivers.Queries; +using EnvelopeGenerator.Application.Envelopes.Queries; +using EnvelopeGenerator.Domain.Entities; +using EnvelopeGenerator.Domain.Exceptions; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace EnvelopeGenerator.Application.PdfSigning; + +/// +/// +/// +public record SignPdfByEnvelopeReceiverCommand : IRequest +{ + /// + /// + /// + public int EnvelopeId { get; init; } + + /// + /// + /// + public int ReceiverId { get; init; } + + /// + /// + /// + public Stream? Document { get; set; } +} + +/// +/// +/// +public class SignPdfByEnvelopeReceiverCommandHandler(IRepository Repo, IMapper Mapper, ISender Sender) : IRequestHandler +{ + + /// + /// + /// + /// + /// + /// + /// + public async Task Handle(SignPdfByEnvelopeReceiverCommand request, CancellationToken cancel) + { + cancel.ThrowIfCancellationRequested(); + + // read elements + var elements = await Repo.Where(el => el.ReceiverId == request.ReceiverId && el.Document.EnvelopeId == request.EnvelopeId) + .Include(e => e.Receiver) + .ToListAsync(cancel) ?? throw new NotFoundException($"No elements found for Envelope ID {request.EnvelopeId} and Receiver ID {request.ReceiverId}."); + var elementDtos = Mapper.Map>(elements) + .OrderBy(e => e.Page) + .ThenBy(e => e.Id) + .ToList(); + + // read secrets for phone number + var erSecrets = await Sender.Send(new ReadEnvelopeReceiverSecretQuery() + { + Envelope = new() { Id = request.EnvelopeId }, + Receiver = new() { Id = request.ReceiverId } + }, cancel) ?? throw new DataIntegrityException($"No secrets found for Envelope ID {request.EnvelopeId} and Receiver ID {request.ReceiverId}."); + + // read document + if(request.Document is null) + { + var unsignedDoc = await Sender.Send(new ReadDocumentQuery(EnvelopeId: request.EnvelopeId), cancel); + var unsignedDocBytes = unsignedDoc.ByteData ?? throw new NotFoundException($"Document not found for Envelope ID {request.EnvelopeId} and Receiver ID {request.ReceiverId}."); + request.Document = new MemoryStream(unsignedDocBytes); + } + + Stream doc = request.Document; + + // sign document for each element + foreach (var element in elementDtos) + { + var lastElement = element == elementDtos.Last(); + + var signedDoc = await Sender.Send(new SignPdfByElementCommand + { + Document = doc, + Element = element, + CertificationLevel = lastElement ? PdfSignatureCertificationLevel.NoChangesAllowed : PdfSignatureCertificationLevel.FillFormsAndAnnotate, + Phone = erSecrets.PhoneNumber + }, cancel); + + await doc.DisposeAsync(); + doc = signedDoc; + } + + return doc; + } +} diff --git a/EnvelopeGenerator.Domain/Constants/EnvelopeStatus.cs b/EnvelopeGenerator.Domain/Constants/EnvelopeStatus.cs index f096fd09..f918b3be 100644 --- a/EnvelopeGenerator.Domain/Constants/EnvelopeStatus.cs +++ b/EnvelopeGenerator.Domain/Constants/EnvelopeStatus.cs @@ -67,7 +67,7 @@ namespace EnvelopeGenerator.Domain.Constants { public static bool IsActive(this EnvelopeStatus status) { - return status >= EnvelopeStatus.EnvelopeCreated && status < EnvelopeStatus.EnvelopePartlySigned; + return status >= EnvelopeStatus.EnvelopeCreated && status <= EnvelopeStatus.EnvelopePartlySigned; } public static bool IsCompleted(this EnvelopeStatus status) diff --git a/EnvelopeGenerator.Domain/Entities/DocReceiverElement.cs b/EnvelopeGenerator.Domain/Entities/DocReceiverElement.cs index ed17921d..9dd59387 100644 --- a/EnvelopeGenerator.Domain/Entities/DocReceiverElement.cs +++ b/EnvelopeGenerator.Domain/Entities/DocReceiverElement.cs @@ -11,7 +11,7 @@ using System.Collections.Generic; namespace EnvelopeGenerator.Domain.Entities { [Table("TBSIG_DOCUMENT_RECEIVER_ELEMENT", Schema = "dbo")] - public class DocReceiverElement : IDocReceiverElement, IHasReceiver, IHasAddedWhen, IUpdateAuditable + public class DocReceiverElement : IDocReceiverElement, ISignatureInkPayload, IHasReceiver, IHasAddedWhen, IUpdateAuditable { public DocReceiverElement() { diff --git a/EnvelopeGenerator.Domain/Entities/Envelope.cs b/EnvelopeGenerator.Domain/Entities/Envelope.cs index 9b1f18a3..ba2f0719 100644 --- a/EnvelopeGenerator.Domain/Entities/Envelope.cs +++ b/EnvelopeGenerator.Domain/Entities/Envelope.cs @@ -137,7 +137,6 @@ namespace EnvelopeGenerator.Domain.Entities = false; #endif - [NotMapped] [Column("DOC_RESULT")] public byte[] #if nullable diff --git a/EnvelopeGenerator.Domain/EnvelopeGenerator.Domain.csproj b/EnvelopeGenerator.Domain/EnvelopeGenerator.Domain.csproj index fc81b9f0..04fd248f 100644 --- a/EnvelopeGenerator.Domain/EnvelopeGenerator.Domain.csproj +++ b/EnvelopeGenerator.Domain/EnvelopeGenerator.Domain.csproj @@ -2,6 +2,7 @@ net462;net7.0;net8.0;net9.0 + latest 1.4.0 1.4.0 diff --git a/EnvelopeGenerator.Domain/Exceptions/AuthorizationException.cs b/EnvelopeGenerator.Domain/Exceptions/AuthorizationException.cs new file mode 100644 index 00000000..e610d452 --- /dev/null +++ b/EnvelopeGenerator.Domain/Exceptions/AuthorizationException.cs @@ -0,0 +1,17 @@ +#if NETFRAMEWORK +using System; +#endif + +namespace EnvelopeGenerator.Domain.Exceptions +{ + public class AuthorizationException : Exception + { + public AuthorizationException(string message) : base(message) + { + } + + public AuthorizationException() + { + } + } +} diff --git a/EnvelopeGenerator.Domain/Exceptions/DataIntegrityException.cs b/EnvelopeGenerator.Domain/Exceptions/DataIntegrityException.cs new file mode 100644 index 00000000..4b633d0c --- /dev/null +++ b/EnvelopeGenerator.Domain/Exceptions/DataIntegrityException.cs @@ -0,0 +1,17 @@ +#if NETFRAMEWORK +using System; +#endif + +namespace EnvelopeGenerator.Domain.Exceptions +{ + public class DataIntegrityException : Exception + { + public DataIntegrityException(string message) : base(message) + { + } + + public DataIntegrityException() : base() + { + } + } +} diff --git a/EnvelopeGenerator.Domain/Interfaces/IEnvelope.cs b/EnvelopeGenerator.Domain/Interfaces/IEnvelope.cs index f4647701..22254dea 100644 --- a/EnvelopeGenerator.Domain/Interfaces/IEnvelope.cs +++ b/EnvelopeGenerator.Domain/Interfaces/IEnvelope.cs @@ -3,6 +3,12 @@ public interface IEnvelope { int? EnvelopeTypeId { get; set; } + + byte[] +#if nullable + ? +#endif + DocResult { get; } } public static class EnvelopeExtensions @@ -16,5 +22,10 @@ { return envelope.EnvelopeTypeId != 2; } + + public static bool IsBurned(this IEnvelope envelope) + { + return envelope.DocResult != null; + } } } \ No newline at end of file diff --git a/EnvelopeGenerator.Domain/Interfaces/ISignatureInkPayload.cs b/EnvelopeGenerator.Domain/Interfaces/ISignatureInkPayload.cs new file mode 100644 index 00000000..c9a722f9 --- /dev/null +++ b/EnvelopeGenerator.Domain/Interfaces/ISignatureInkPayload.cs @@ -0,0 +1,28 @@ +#if NETFRAMEWORK +using System; +#endif + +namespace EnvelopeGenerator.Domain.Interfaces +{ + public interface ISignatureInkPayload + { +#if nullable + byte[]? Ink { get; } + + string? FullName { get; } + + string? Position { get; } + + string? Place { get; } +#else + byte[] Ink { get; } + + string FullName { get; } + + string Position { get; } + + string Place { get; } +#endif + DateTime? ChangedWhen { get; } + } +} \ No newline at end of file diff --git a/EnvelopeGenerator.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs b/EnvelopeGenerator.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..6b51a23b --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using DocumentService.Client.Configuration; +using DocumentService.Client.Extensions; +using EnvelopeGenerator.Application.Common.Interfaces.Services; +using EnvelopeGenerator.Infrastructure.Crypt.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EnvelopeGenerator.Infrastructure.Crypt.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCryptInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + var baseUrl = configuration["DocumentService:BaseUrl"] + ?? throw new InvalidOperationException("DocumentService:BaseUrl is not configured."); + + services.AddDocumentServiceClients(options => options.BaseUrl = baseUrl); + + services.AddSingleton(); + + return services; + } +} diff --git a/EnvelopeGenerator.Infrastructure.Crypt/EnvelopeGenerator.Infrastructure.Crypt.csproj b/EnvelopeGenerator.Infrastructure.Crypt/EnvelopeGenerator.Infrastructure.Crypt.csproj new file mode 100644 index 00000000..1ef00efb --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/EnvelopeGenerator.Infrastructure.Crypt.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/EnvelopeGenerator.Infrastructure.Crypt/README.md b/EnvelopeGenerator.Infrastructure.Crypt/README.md index 4dab4888..a2a1d1d5 100644 --- a/EnvelopeGenerator.Infrastructure.Crypt/README.md +++ b/EnvelopeGenerator.Infrastructure.Crypt/README.md @@ -1,399 +1,384 @@ # EnvelopeGenerator.Infrastructure.Crypt -This project provides PKCS#11-based cryptographic infrastructure for FES (Advanced Electronic Signature) workflows. +Umfassende technische Dokumentation der PKCS#11/SoftHSM-Integration fuer `EnvelopeGenerator`. -If you are new to HSM/SoftHSM: this README is written to let you start from zero and still understand the full path from setup to testing. +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) What this project is (and what it is not) +## 1. Architekturueberblick -This project is the cryptography layer only. +### 1.1 Ziel -- It signs hashes using private keys stored in HSM/SoftHSM. -- It reads certificates from HSM/SoftHSM. -- It verifies signatures. -- It exposes service abstractions for Application/Server integration. +`EnvelopeGenerator.Infrastructure.Crypt` stellt die kryptographische Infrastruktur fuer PDF-Signaturen bereit: -It is **not**: +- Signaturerzeugung mit Schluesseln in SoftHSM/PKCS#11 +- Zertifikat-Auslese aus Token +- Signaturpruefung auf PDF-Ebene +- Health/Startup-Selbsttest fuer Betriebsfaehigkeit -- a REST API by itself, -- a UI component, -- a full document workflow orchestrator. +### 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) SoftHSM and PKCS#11 in one minute +## 2. Linux-Seite: SoftHSM + Proxy Setup -- `SoftHSMv2` is a software HSM implementation. -- It does **not** provide a REST endpoint. -- You access it through a PKCS#11 library/module (`.so`/`.dll`). -- Your app loads that library and calls PKCS#11 functions. +### 2.1 Basispakete -Two deployment patterns are possible: +Beispiel (Debian/Ubuntu): -1. **Direct local module** - - Example library path: `libsofthsm2.so` -2. **PKCS#11 proxy module (remote HSM/SoftHSM)** - - Example library path: `libpkcs11-proxy.so` or proxy DLL - -Both are valid for this project. - ---- - -## 3) How this maps to the FES diagram (Ablauf SignFlow Unternehmenszertifikat) - -From the shared draw.io/PDF process: - -- Step 6: Backend computes document hash and sends it with PIN context to HSM. - - Implemented by: `ISignatureProvider` / `Pkcs11SignatureProvider` -- Step 7: HSM returns digital signature (and cert context can be resolved). - - Implemented by: `Pkcs11SignatureProvider` + `ICertificateProvider` -- Step 8: Backend embeds signature + certificate into PDF and creates audit trail. - - This project provides crypto primitives for this step. - - PDF embedding and audit document composition are integrated in later layers. - -So this project is the cryptographic core for diagram steps 6-8. - -Actor mapping from the diagram: - -- `Ersteller` (sender): creates envelope and defines recipients. -- `Signierer` (recipient/signer): completes 2FA and signs. -- `Backend` (our stack): computes hash, requests HSM signature, embeds proof in PDF. -- `HSM/SoftHSM`: stores private keys and performs cryptographic signing. -- `CA`: issues/renews certificates. - ---- - -## 4) Service surface - -Main interfaces: - -- `ISignatureProvider` -- `ICertificateProvider` -- `ISignatureVerifier` -- `ICryptographicHealthCheck` - -DI entry point: - -- `CryptDependencyInjection.AddCryptInfrastructure(...)` - ---- - -## 5) Required configuration - -Configuration section: `Crypt:Pkcs11` - -Typical keys: - -```json -{ - "Crypt": { - "Pkcs11": { - "LibraryPath": "/usr/lib/softhsm/libsofthsm2.so", - "SlotId": 0, - "TokenLabel": null, - "UserPin": "***", - "PrivateKeyLabel": "sign-key", - "PrivateKeyIdHex": null, - "CertificateLabel": "sign-cert", - "CertificateIdHex": null - } - } -} +```bash +sudo apt-get update && sudo apt-get install -y softhsm2 opensc p11-kit libengine-pkcs11-openssl ``` -Security rule: - -- Do not commit real PINs to source control. -- Use environment variables / secret vault for production. - ---- - -## 6) The 5 test values you must provide - -Integration tests need these environment variables: - -- `FES_TEST_PKCS11_LIBRARY_PATH` -- `FES_TEST_PKCS11_SLOT_ID` -- `FES_TEST_PKCS11_USER_PIN` -- `FES_TEST_PKCS11_PRIVATE_KEY_LABEL` -- `FES_TEST_PKCS11_CERTIFICATE_LABEL` - -Without these values, integration tests are skipped by design. - -## 6.1 Which certificate/key should be created? - -This is the most important business decision for IT and architecture. - -Based on your shared diagram, the **primary model** is: - -- one **company certificate + company key pair** in HSM, -- reused for all signers, -- signer identity proven by audit-trail + 2FA evidence. - -What this means in practical terms: - -- Not one key per sender. -- Not one key per recipient by default. -- One company signing identity, many signing events. - -Alternative model (future/optional): - -- per-signer certificate/key pair (more complex CA lifecycle). - -For current fast validation and your FES flow, use the **company-level key/certificate** model. - ---- - -## 7) How to obtain these values (IT-friendly) - -## 7.1 If you use direct SoftHSM module - -### A) Find module path - -What it is: - -- PKCS#11 shared library file used by the app to talk to SoftHSM. - -Why needed: - -- Without this exact file path, PKCS#11 cannot be loaded. - -Linux examples: +### 2.2 SoftHSM-Modulpfad pruefen ```bash ldconfig -p | grep softhsm find /usr -name "libsofthsm2.so" 2>/dev/null ``` -Use the resolved full path as `FES_TEST_PKCS11_LIBRARY_PATH`. - -### B) List slots/tokens - -What it is: - -- Slot: logical container position in PKCS#11. -- Token: initialized security container in a slot. - -Why needed: - -- The app must know which slot/token contains the company key and certificate. +### 2.3 Token initialisieren ```bash softhsm2-util --show-slots -``` - -Pick your slot as `FES_TEST_PKCS11_SLOT_ID`. - -### C) Identify key and certificate labels - -What it is: - -- `Private key label`: name of the signing private key object. -- `Certificate label`: name of the matching X.509 certificate object. - -Why needed: - -- Tests and runtime use labels to select correct objects in HSM. - -Use `pkcs11-tool` against your module: - -```bash -pkcs11-tool --module /path/to/libsofthsm2.so --slot --login --pin --list-objects -``` - -From output: - -- private key label -> `FES_TEST_PKCS11_PRIVATE_KEY_LABEL` -- certificate label -> `FES_TEST_PKCS11_CERTIFICATE_LABEL` - -### D) User PIN - -What it is: - -- User authorization secret to open authenticated session on token. - -Why needed: - -- HSM will not allow signing with private key without login. - -PIN is assigned during token initialization and must be provided by IT/security team. - -### E) Optional: Initialize token and create objects (Linux quick bootstrap) - -Only if token/keys are not already provisioned by IT PKI team. - -```bash -# 1) Initialize token in slot 0 (example) -softhsm2-util --init-token --slot 0 --label "CompanySignToken" - -# 2) Verify slot/token +softhsm2-util --init-token --slot --label CompanyProdToken --so-pin --pin softhsm2-util --show-slots - -# 3) Generate RSA key pair inside token (example) -pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot 0 --login --pin \ - --keypairgen --key-type rsa:3072 --label sign-key --id 01 - -# 4) Import certificate (already issued by CA) -pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot 0 --login --pin \ - --write-object company-sign-cert.der --type cert --label sign-cert --id 01 - -# 5) List objects and confirm labels -pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot 0 --login --pin --list-objects ``` -Notes: +Hinweis: Nach `--init-token` wird der Slot i. d. R. neu zugewiesen. -- Use this only in non-production unless approved. -- In production, key generation/import should follow company security policy. - -## 7.2 If you use PKCS#11 proxy module - -What it is: - -- A local PKCS#11 bridge library that forwards calls to remote HSM/SoftHSM service. - -Why needed: - -- App still loads a local module path, but crypto operations happen remotely. - -Ask IT for: - -- proxy module path (`libpkcs11-proxy.*`) -- slot id or token label -- user pin -- private key label -- certificate label - -In proxy mode, values come from proxy-backed token, not local SoftHSM token files. - ---- - -## 7.3 Linux handover checklist for IT (copy/paste friendly) - -1. Confirm module path exists (`libsofthsm2.so` or proxy module). -2. Confirm token exists and slot id is known. -3. Confirm company private key label and certificate label. -4. Confirm user PIN for test environment. -5. Share these five values securely with development team. - ---- - -## 8) Test execution checklist (single command flow) - -## 8.1 Set environment variables - -PowerShell example: - -```powershell -$env:FES_TEST_PKCS11_LIBRARY_PATH = "" -$env:FES_TEST_PKCS11_SLOT_ID = "" -$env:FES_TEST_PKCS11_USER_PIN = "" -$env:FES_TEST_PKCS11_PRIVATE_KEY_LABEL = "" -$env:FES_TEST_PKCS11_CERTIFICATE_LABEL = "" -``` - -Linux (bash) example: +### 2.4 Schluesselpaar erzeugen ```bash -export FES_TEST_PKCS11_LIBRARY_PATH="/usr/lib/softhsm/libsofthsm2.so" -export FES_TEST_PKCS11_SLOT_ID="0" -export FES_TEST_PKCS11_USER_PIN="" -export FES_TEST_PKCS11_PRIVATE_KEY_LABEL="sign-key" -export FES_TEST_PKCS11_CERTIFICATE_LABEL="sign-cert" +pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \ + --slot --login --pin \ + --keypairgen --key-type rsa:3072 --label company-sign-key-v1 --id 01 ``` -Verify env values quickly: +### 2.5 Zertifikat importieren ```bash -echo "$FES_TEST_PKCS11_LIBRARY_PATH" -echo "$FES_TEST_PKCS11_SLOT_ID" -echo "$FES_TEST_PKCS11_PRIVATE_KEY_LABEL" -echo "$FES_TEST_PKCS11_CERTIFICATE_LABEL" +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 ``` -## 8.2 Run only crypt tests +### 2.6 Objektzustand verifizieren -```powershell -dotnet test "EnvelopeGenerator.Tests/EnvelopeGenerator.Tests.csproj" -f net8.0 --filter "FullyQualifiedName~EnvelopeGenerator.Tests.Application.Crypt" +```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 ``` -Expected: +### 2.7 Proxy-Daemon endpoint stabilisieren -- Unit tests pass always. -- Integration tests pass when env values are correct. -- Integration tests skip when env values are missing. +Ein wiederkehrendes Betriebsproblem war ein zufaelliger/local-only Listener. Fix: -If all five values are correct: +```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 +``` -- integration tests run automatically, -- no code change is required, -- only environment setup is required. +Pruefung: + +```bash +systemctl show pkcs11-proxy.service -p Environment --no-pager +ss -ltnp | grep 5657 +``` --- -## 9) Common failure meanings +## 3. Web-API/Server-Seite: Installation und Asset-Bereitstellung -- `CRYPT_PROVIDER_UNAVAILABLE` - - invalid library path, missing native dependency, provider cannot load -- `CRYPT_SLOT_NOT_FOUND` - - wrong slot id or token label -- `CRYPT_LOGIN_FAILED` - - wrong PIN -- `CRYPT_KEY_NOT_FOUND` - - private key label/id mismatch -- `CRYPT_CERT_NOT_FOUND` - - certificate label/id mismatch +### 3.1 Bezugsquellen der Windows-Artefakte -This is exactly what IT needs for first-level diagnostics. +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: + +- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/proxy/` +- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/opensc/tools/` +- `EnvelopeGenerator.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. --- -## 10) Integration in the full stack architecture +## 4. Implementierte Services, Modelle, DTOs -Current role by layer: +### 4.1 DI und Startverhalten -- `Infrastructure.Crypt` (this project): PKCS#11 crypto operations -- `Application`: orchestration use-cases and business process steps -- `Server`: API endpoints and presentation concerns +- `AddCryptInfrastructure(...)` in `DependencyInjection/ServiceCollectionExtensions.cs` + - bindet `Pkcs11Options` + - registriert `IPdfDigitalSignatureService` + - registriert Startup-Self-Test Hosted Service -Recommended integration path: +### 4.2 Konfigurationsmodell -1. Stabilize crypt tests and environment setup (this stage). -2. Integrate `ISignatureProvider` + `ICertificateProvider` into Application command flow. -3. Connect Application flow to Server endpoints. -4. Add PDF embedding and audit-trail generation for full step-8 compliance. -5. Add archiving/distribution orchestration for steps 9-10. +- `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` --- -## 11) Important note about signing semantics +## 5. Betriebserfahrungen und Challenges -PKCS#11 mechanisms differ in whether they expect raw data or a precomputed digest. +### 5.1 Interop vs Tool-Verhalten -Current implementation signs with `CKM_SHA256_RSA_PKCS` and receives hash bytes from caller. -This must be validated in your real provider environment. +Beobachtetes Muster in der realen Umgebung: -If provider expects raw data for this mechanism, integration may require adjustment. -This is why integration tests are critical. +- `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) --- -## 12) Quick FAQ +## 6. Health, Monitoring und Verifikation -**Q: Do I need REST calls to SoftHSM?** +### 6.1 API Health -No. SoftHSM is not a REST server. Use PKCS#11 library calls. +- Endpoint: `GET /api/health/crypt` +- Liefert: + - `success`, `mode`, `summary`, `interopError`, `toolError`, `checkedAtUtc` -**Q: Can I use proxy and still call it SoftHSM setup?** +Empfehlung: -Yes. Proxy can forward to SoftHSM-backed token infrastructure. +- In Monitoring aufnehmen und bei `mode=pkcs11-tool` ueber laengere Zeit Warnung triggern. -**Q: Once I provide the 5 values, can tests run directly?** +### 6.2 Smoke-Test (empfohlen vor Release) -Yes. Set env vars and run the single `dotnet test` command above. +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 -**Q: Will one company cert/key be enough for this phase?** +--- -Yes, for the diagram-aligned company-signing model, one company key/certificate is enough for initial implementation and validation. +## 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/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs new file mode 100644 index 00000000..9a39011b --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs @@ -0,0 +1,77 @@ +using DocumentService.Client.Interfaces; +using DocumentService.Client.Models.Requests; +using EnvelopeGenerator.Application.Common.Dto; +using EnvelopeGenerator.Application.Common.Interfaces.Services; +using ClientCertLevel = DocumentService.Client.Models.Requests.PdfSignatureCertificationLevel; +using ClientCoordUnit = DocumentService.Client.Models.Requests.PdfSignatureCoordinateUnit; +using ClientCoordOrigin = DocumentService.Client.Models.Requests.PdfSignatureCoordinateOrigin; + +namespace EnvelopeGenerator.Infrastructure.Crypt.Services; + +/// +/// Proxy that forwards PDF signing to the remote DocumentService API via . +/// +public sealed class PdfDigitalSignatureService(IPdfSignatureClient signatureClient) : IPdfDigitalSignatureService +{ + public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, Stream? image = null, CancellationToken cancellationToken = default) + { + var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken); + var imageBytes = image is null ? null : await ReadAllBytesAsync(image, cancellationToken); + + using var signed = await signatureClient.SignAsync(pdfBytes, Map(options), imageBytes, cancellationToken); + await signed.CopyToAsync(outputPdf, cancellationToken); + } + + public Task CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Signature checking is handled by DocumentService and is not available through this proxy."); + + private static SignPdfOptions Map(PdfSignOptionsDto? o) => new() + { + DocumentElementId = o?.DocumentElementId ?? 0, + Name = o?.Name ?? string.Empty, + Reason = o?.Reason, + Location = o?.Location, + Email = o?.Email, + Phone = o?.Phone, + ApplicationName = o?.ApplicationName, + CertificationLevel = (ClientCertLevel)(o?.CertificationLevel ?? EnvelopeGenerator.Application.Common.Enums.PdfSignatureCertificationLevel.NoCertification), + Appearance = o?.Appearance is null ? null : MapAppearance(o.Appearance) + }; + + private static PdfSignatureAppearance MapAppearance(PdfSignatureAppearanceDto a) => new() + { + PageNumber = a.PageNumber, + Left = a.Left, + Bottom = a.Bottom, + Width = a.Width, + Height = a.Height, + ShowLabels = a.ShowLabels, + ShowName = a.ShowName, + ShowDate = a.ShowDate, + ShowReason = a.ShowReason, + ShowLocation = a.ShowLocation, + ShowDistinguishedName = a.ShowDistinguishedName, + DateTimeFormat = a.DateTimeFormat, + CoordinateUnit = (ClientCoordUnit)a.CoordinateUnit, + CoordinateOrigin = (ClientCoordOrigin)a.CoordinateOrigin, + Font = new PdfSignatureFont + { + FamilyName = a.Font.FamilyName, + Size = a.Font.Size, + IsBold = a.Font.IsBold, + IsItalic = a.Font.IsItalic, + IsUnderline = a.Font.IsUnderline, + Color = new PdfSignatureColor { R = a.Font.Color.R, G = a.Font.Color.G, B = a.Font.Color.B } + } + }; + + private static async Task ReadAllBytesAsync(Stream stream, CancellationToken cancellationToken) + { + if (stream is MemoryStream memory && memory.TryGetBuffer(out var buffer)) + return buffer.AsSpan(0, (int)memory.Length).ToArray(); + + using var copy = new MemoryStream(); + await stream.CopyToAsync(copy, cancellationToken); + return copy.ToArray(); + } +} diff --git a/EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs b/EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs index 234397d8..4f541a09 100644 --- a/EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs +++ b/EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs @@ -1,7 +1,9 @@ using DevExpress.Drawing; using DevExpress.Pdf; +using DigitalData.Core.Exceptions; using EnvelopeGenerator.Application.Common.Dto; using EnvelopeGenerator.Application.Common.Interfaces.Doc; +using EnvelopeGenerator.Domain.Interfaces; namespace EnvelopeGenerator.Infrastructure.Doc; @@ -54,4 +56,125 @@ public class PdfPageImageRenderer : IPdfPageImageRenderer return Task.FromResult>(results); } + + public Stream ComposeSignaturePreviewPng(ISignatureInkPayload element) + { + const double sigW = 1.77 * 72; + const double sigH = 1.96 * 72; + const double imgRatio = 0.52; + const double lineH = 11.5; + const double bgPad = 3.0; + const double padding = 3.0; + + var date = element.ChangedWhen!.Value.ToString("dd.MM.yyyy"); + var position = string.IsNullOrWhiteSpace(element.Position) ? null : element.Position.Trim(); + + var x = bgPad; + var y = bgPad; + var imgH = sigH * imgRatio; + var lineY = y + imgH + 1.0; + var textY = lineY + 1.5; + var row1Y = textY; + var row2Y = row1Y + lineH; + var row3Y = position is null ? row2Y : row2Y + lineH; + var contentBottom = row3Y + lineH; + var pageWidth = sigW + (bgPad * 2); + var pageHeight = contentBottom + bgPad; + + var inkBytes = this.EnsureSupportedImageBytes(element.Ink!); + + using var processor = new PdfDocumentProcessor(); + processor.CreateEmptyDocument(); + var page = processor.AddNewPage(new PdfRectangle(0, 0, pageWidth, pageHeight)); + + using var graphics = processor.CreateGraphics(); + + using var bgBrush = new DXSolidBrush(System.Drawing.Color.FromArgb(255, 255, 253, 240)); + using var linePen = new DXPen(System.Drawing.Color.FromArgb(180, 100, 100, 120), 0.5f); + using var nameBrush = new DXSolidBrush(System.Drawing.Color.FromArgb(255, 20, 20, 20)); + using var infoBrush = new DXSolidBrush(System.Drawing.Color.FromArgb(255, 80, 80, 80)); + + var fontBold = new DXFont("Arial", 7.5f, DXFontStyle.Bold); + var fontNormal = new DXFont("Arial", 6.5f, DXFontStyle.Regular); + + graphics.FillRectangle(bgBrush, new System.Drawing.RectangleF(0, 0, (float)pageWidth, (float)pageHeight)); + + var imageArea = new System.Drawing.RectangleF((float)x, (float)y, (float)sigW, (float)imgH); + var imageRect = FitImageContain(imageArea, inkBytes); + graphics.DrawImage(inkBytes, imageRect); + + graphics.DrawLine(linePen, (float)(x + 2), (float)lineY, (float)(x + sigW - 2), (float)lineY); + + graphics.DrawString( + element.FullName!.Trim(), + fontBold, + nameBrush, + new System.Drawing.RectangleF((float)(x + padding), (float)row1Y, (float)(sigW - (padding * 2)), (float)lineH)); + + if (position is not null) + { + graphics.DrawString( + position, + fontNormal, + infoBrush, + new System.Drawing.RectangleF((float)(x + padding), (float)row2Y, (float)(sigW - (padding * 2)), (float)lineH)); + } + + graphics.DrawString( + $"{element.Place!.Trim()}, {date}", + fontNormal, + infoBrush, + new System.Drawing.RectangleF((float)(x + padding), (float)row3Y, (float)(sigW - (padding * 2)), (float)lineH)); + + graphics.AddToPageForeground(page, 72, 72); + + using var pdfStream = new MemoryStream(); + processor.SaveDocument(pdfStream); + pdfStream.Position = 0; + + using var rasterProcessor = new PdfDocumentProcessor(); + rasterProcessor.LoadDocument(pdfStream); + + var renderOptions = PdfPageRenderingParameters.CreateWithResolution(400f); + using DXBitmap bitmap = rasterProcessor.CreateDXBitmap(1, renderOptions); + var pngStream = new MemoryStream(); + bitmap.Save(pngStream, DXImageFormat.Png); + pngStream.Position = 0; + return pngStream; + } + + private static System.Drawing.RectangleF FitImageContain(System.Drawing.RectangleF area, byte[] imageBytes) + { + using var imageStream = new MemoryStream(imageBytes, writable: false); + using var image = DXImage.FromStream(imageStream); + + if (image.Width <= 0 || image.Height <= 0) + return area; + + var scale = Math.Min(area.Width / image.Width, area.Height / image.Height); + var drawW = image.Width * scale; + var drawH = image.Height * scale; + var drawX = area.X + ((area.Width - drawW) / 2f); + var drawY = area.Y + ((area.Height - drawH) / 2f); + + return new System.Drawing.RectangleF(drawX, drawY, drawW, drawH); + } + + /// + /// + /// + /// + /// + public bool IsSupportedImageFormat(byte[] bytes) + { + var isPng = bytes.Length >= 4 && + bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47; + if (isPng) + return true; + + var isJpeg = bytes.Length >= 3 && + bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; + return isJpeg; + } + } diff --git a/EnvelopeGenerator.Infrastructure.Notification/DependencyInjection.cs b/EnvelopeGenerator.Infrastructure.Notification/DependencyInjection.cs new file mode 100644 index 00000000..c84c778e --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Notification/DependencyInjection.cs @@ -0,0 +1,43 @@ +using DigitalData.MessagingService.Client; +using DigitalData.MessagingService.Domain.Entities; +using DigitalData.MessagingService.RabbitMQ; +using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EnvelopeGenerator.Infrastructure.Notification; + +public static class DependencyInjection +{ + public static IServiceCollection AddNotificationInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + var rMqConfig = configuration.GetSection("RabbitMQ").Get() + ?? throw new ArgumentNullException(nameof(RabbitMqConfiguration), "RabbitMQ configuration is missing"); + + EmailSender.ConnectRabbitMq(config => + { + config.HostName = rMqConfig.HostName; + config.Port = rMqConfig.Port; + config.UserName = rMqConfig.UserName; + config.Password = rMqConfig.Password; + + config.VirtualHost = "/"; + config.QueueName = "emailprofiler.email.outbox"; + config.ExchangeName = "emailprofiler.emails"; + config.RoutingKey = "email.outbox"; + config.DlqQueueName = "emailprofiler.email.outbox.dlq"; + config.DlqExchangeName = "emailprofiler.emails.dlq"; + config.DlqRoutingKey = "email.outbox.dlq"; + }); + + services.Configure(configuration.GetSection("NotificationEmailAccount")); + + services.AddScoped(); + return services; + } +} diff --git a/EnvelopeGenerator.Infrastructure.Notification/EnvelopeGenerator.Infrastructure.Notification.csproj b/EnvelopeGenerator.Infrastructure.Notification/EnvelopeGenerator.Infrastructure.Notification.csproj new file mode 100644 index 00000000..469d3e1b --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Notification/EnvelopeGenerator.Infrastructure.Notification.csproj @@ -0,0 +1,19 @@ + + + + net8.0; + enable + enable + latest + + + + + + + + + + + + diff --git a/EnvelopeGenerator.Infrastructure.Notification/EnvelopeNotificationService.cs b/EnvelopeGenerator.Infrastructure.Notification/EnvelopeNotificationService.cs new file mode 100644 index 00000000..79e87020 --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Notification/EnvelopeNotificationService.cs @@ -0,0 +1,44 @@ +using DigitalData.Core.Abstraction.Application.Repository; +using DigitalData.Core.Exceptions; +using DigitalData.MessagingService.Application.Common.Dto; +using DigitalData.MessagingService.Client; +using DigitalData.MessagingService.Domain.Entities; +using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification; +using EnvelopeGenerator.Domain.Entities; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace EnvelopeGenerator.Infrastructure.Notification; + +public class EnvelopeNotificationService(IOptions EmailAccountOptions, IRepository TempRepo) : IEnvelopeNotificationService +{ + public async Task Notify(string email, DocumentSignedNotification documentSigned, CancellationToken cancel = default) + { + var template = await TempRepo.Where(t => t.Name == "DocumentCompleted").FirstOrDefaultAsync(cancel) + ?? throw new NotFoundException("DocumentCompleted email template not found"); + var subjectTemplate = Replace(template.Subject, documentSigned); + var bodyTemplate = Replace(template.Body, documentSigned); + + await EmailSender.Send(new EmailContext() + { + Body = bodyTemplate, + Subject = subjectTemplate, + Recipients = [email], + Sender = EmailAccountOptions.Value, + Attachments = [new EmailAttachmentDto() + { + FileName = "unterzeichnetes-dokument.pdf", + Content = documentSigned.Attachment + }] + }); + } + + private static string Replace(string template, DocumentSignedNotification documentSigned) + { + return template + .Replace("[DOCUMENT_TITLE]", documentSigned.DocumentTitle) + .Replace("[NAME_PORTAL]", documentSigned.NamePortal) + .Replace("[NAME_RECEIVER]", documentSigned.NameReceiver); + } +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Layout/MainLayout.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Layout/MainLayout.razor index a5d68850..c7935bfb 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Layout/MainLayout.razor +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Layout/MainLayout.razor @@ -22,7 +22,7 @@