From dd44de349cabd13af0cc70a9f69821e7c7defbaf Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:01:10 +0000 Subject: [PATCH] Shared Sign Cert Validation (#5996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR: Certificate Pre-Validation for Document Signing ### Problem When a participant uploaded a certificate to sign a document, there was no validation at submission time. If the certificate had the wrong password, was expired, or was incompatible with the signing algorithm, the error only surfaced during **finalization** — potentially days later, after all other participants had signed. At that point the session is stuck with no way to recover. Additionally, `buildKeystore` in the finalization service only recognised `"P12"` as a cert type, causing a `400 Invalid certificate type: PKCS12` error when the **owner** signed using the standard `PKCS12` identifier. --- ### What this PR does #### Backend — Certificate pre-validation service Adds `CertificateSubmissionValidator`, which validates a keystore before it is stored by: 1. Loading the keystore with the provided password (catches wrong password / corrupt file) 2. Checking the certificate's validity dates (catches expired and not-yet-valid certs) 3. Test-signing a blank PDF using the same `PdfSigningService` code path as finalization (catches algorithm incompatibilities) This runs on both the participant submission endpoint (`WorkflowParticipantController`) and the owner signing endpoint (`SigningSessionController`), so both flows are protected. #### Backend — Bug fix `SigningFinalizationService.buildKeystore` now accepts `"PKCS12"` and `"PFX"` as aliases for `"P12"`, consistent with how the validator already handles them. This fixes a `400` error when the owner signed using the `PKCS12` cert type. #### Frontend — Real-time validation feedback `ParticipantView` gains a debounced validation call (600ms) triggered whenever the cert file or password changes. The UI shows: - A spinner while validating - Green "Certificate valid until [date] · [subject name]" on success - Red error message on failure (wrong password, expired, not yet valid) - The submit button is disabled while validation is in flight #### Tests — Three layers | Layer | File | Coverage | |---|---|---| | Service unit | `CertificateSubmissionValidatorTest` | 11 tests — valid P12/JKS, wrong password, corrupt bytes, expired, not-yet-valid, signing failure, cert type aliases | | Controller unit | `WorkflowParticipantValidateCertificateTest` | 4 tests — valid cert, invalid cert, missing file, invalid token | | Controller integration | `CertificateValidationIntegrationTest` | 6 tests — real `.p12`/`.jks` files through the full controller → validator stack | | Frontend E2E | `CertificateValidationE2E.spec.ts` | 7 Playwright tests — all feedback states, button behaviour, SERVER type bypass | #### CI - **PR**: Playwright runs on chromium when frontend files change (~2-3 min) - **Nightly / on-demand**: All three browsers (chromium, firefox, webkit) at 2 AM UTC, also manually triggerable via `workflow_dispatch` --- .github/workflows/build.yml | 31 ++ .github/workflows/nightly.yml | 50 +++ .gitignore | 3 + .../software/common/util/RequestUriUtils.java | 4 +- app/proprietary/.gitignore | 3 + .../controller/SigningSessionController.java | 85 +++++ .../WorkflowParticipantController.java | 114 ++++++ .../workflow/dto/CertificateInfo.java | 11 + .../dto/CertificateValidationResponse.java | 18 + .../CertificateSubmissionValidator.java | 213 +++++++++++ .../service/SigningFinalizationService.java | 2 + .../service/WorkflowSessionService.java | 23 +- .../CertificateValidationIntegrationTest.java | 203 ++++++++++ ...lowParticipantValidateCertificateTest.java | 159 ++++++++ .../CertificateSubmissionValidatorTest.java | 357 ++++++++++++++++++ .../resources/test-certs/expired-test.p12 | Bin 0 -> 2680 bytes .../test-certs/not-yet-valid-test.p12 | Bin 0 -> 2678 bytes .../test/resources/test-certs/valid-test.jks | Bin 0 -> 2150 bytes .../test/resources/test-certs/valid-test.p12 | Bin 0 -> 2660 bytes frontend/playwright.config.ts | 2 +- .../public/locales/en-GB/translation.toml | 13 + .../modals/CertificateConfigModal.tsx | 110 +++++- .../CertificateValidationE2E.spec.ts | 280 ++++++++++++++ .../test-fixtures/certs/expired-test.p12 | Bin 0 -> 2680 bytes .../certs/not-yet-valid-test.p12 | Bin 0 -> 2678 bytes .../tests/test-fixtures/certs/valid-test.jks | Bin 0 -> 2150 bytes .../tests/test-fixtures/certs/valid-test.p12 | Bin 0 -> 2660 bytes frontend/src/proprietary/App.tsx | 21 +- .../components/workflow/ParticipantView.tsx | 84 ++++- 29 files changed, 1777 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/nightly.yml create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/CertificateInfo.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/CertificateValidationResponse.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/CertificateSubmissionValidator.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/CertificateValidationIntegrationTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantValidateCertificateTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/CertificateSubmissionValidatorTest.java create mode 100644 app/proprietary/src/test/resources/test-certs/expired-test.p12 create mode 100644 app/proprietary/src/test/resources/test-certs/not-yet-valid-test.p12 create mode 100644 app/proprietary/src/test/resources/test-certs/valid-test.jks create mode 100644 app/proprietary/src/test/resources/test-certs/valid-test.p12 create mode 100644 frontend/src/core/tests/certValidation/CertificateValidationE2E.spec.ts create mode 100644 frontend/src/core/tests/test-fixtures/certs/expired-test.p12 create mode 100644 frontend/src/core/tests/test-fixtures/certs/not-yet-valid-test.p12 create mode 100644 frontend/src/core/tests/test-fixtures/certs/valid-test.jks create mode 100644 frontend/src/core/tests/test-fixtures/certs/valid-test.p12 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c5ce1d4368..1cef46ae0c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -217,6 +217,37 @@ jobs: path: frontend/dist/ retention-days: 3 + playwright-e2e: + if: needs.files-changed.outputs.frontend == 'true' + needs: files-changed + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 + with: + egress-policy: audit + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + - name: Install frontend dependencies + run: cd frontend && npm ci + - name: Install Playwright (chromium only) + run: cd frontend && npx playwright install chromium --with-deps + - name: Run E2E tests (chromium) + run: cd frontend && npx playwright test src/core/tests/certValidation --project=chromium + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: playwright-report-pr-${{ github.run_id }} + path: frontend/playwright-report/ + retention-days: 7 + check-licence: if: needs.files-changed.outputs.build == 'true' needs: [files-changed, build] diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000000..95da59725d --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,50 @@ +name: Nightly E2E Tests + +on: + schedule: + - cron: "0 2 * * *" # 2 AM UTC every night + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + playwright-all-browsers: + name: Playwright (chromium + firefox + webkit) + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: cd frontend && npm ci + + - name: Install all Playwright browsers + run: cd frontend && npx playwright install --with-deps + + - name: Run E2E tests (all browsers) + run: cd frontend && npx playwright test src/core/tests/certValidation + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: playwright-nightly-${{ github.run_id }} + path: frontend/playwright-report/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 95b82c3e9c..b5025f1b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -203,6 +203,9 @@ out/ *.jks *.asc +# Allow test fixture certificates (synthetic, no real credentials) +!frontend/src/core/tests/test-fixtures/certs/** + # SSH Keys *.pub *.priv diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 690a9213c8..3a62a1eb48 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -180,7 +180,9 @@ public class RequestUriUtils { || trimmedUri.startsWith("/readiness") || trimmedUri.startsWith( "/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth) - || trimmedUri.startsWith("/v1/api-docs"); + || trimmedUri.startsWith("/v1/api-docs") + // Workflow participant endpoints — access controlled by share tokens, not login + || trimmedUri.startsWith("/api/v1/workflow/participant/"); } private static String stripContextPath(String contextPath, String requestURI) { diff --git a/app/proprietary/.gitignore b/app/proprietary/.gitignore index 13c9746463..5156eb74ad 100644 --- a/app/proprietary/.gitignore +++ b/app/proprietary/.gitignore @@ -171,6 +171,9 @@ out/ *.jks *.asc +# Allow test fixture certificates (synthetic, no real credentials) +!src/test/resources/test-certs/** + # SSH Keys *.pub *.priv diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index df45d07816..3269388868 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.controller; +import java.io.IOException; import java.security.Principal; import java.util.List; @@ -14,7 +15,9 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -32,9 +35,12 @@ import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.WebResponseUtils; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.workflow.dto.CertificateInfo; +import stirling.software.proprietary.workflow.dto.CertificateValidationResponse; import stirling.software.proprietary.workflow.dto.ParticipantRequest; import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest; import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator; import stirling.software.proprietary.workflow.service.SigningFinalizationService; import stirling.software.proprietary.workflow.service.WorkflowSessionService; @@ -48,6 +54,7 @@ public class SigningSessionController { private final WorkflowSessionService workflowSessionService; private final UserService userService; private final SigningFinalizationService signingFinalizationService; + private final CertificateSubmissionValidator certificateSubmissionValidator; private final ObjectMapper objectMapper = new ObjectMapper(); @Operation(summary = "List all signing sessions for current user") @@ -397,6 +404,84 @@ public class SigningSessionController { } } + @Operation( + summary = "Pre-validate a certificate before signing", + description = + "Validates that the provided certificate is loadable, not expired, and can " + + "successfully sign a document. Returns validation details so the " + + "user can confirm the correct certificate before committing.") + @PostMapping( + value = "/cert-sign/validate-certificate", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity validateCertificate( + @RequestParam("certType") String certType, + @RequestParam(value = "password", required = false) String password, + @RequestParam(value = "p12File", required = false) MultipartFile p12File, + @RequestParam(value = "jksFile", required = false) MultipartFile jksFile, + Principal principal) { + + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + if (!"SERVER".equalsIgnoreCase(certType) + && !"USER_CERT".equalsIgnoreCase(certType) + && (p12File == null || p12File.isEmpty()) + && (jksFile == null || jksFile.isEmpty())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "No certificate file provided"); + } + + try { + byte[] keystoreBytes = null; + if (p12File != null && !p12File.isEmpty()) { + keystoreBytes = p12File.getBytes(); + } else if (jksFile != null && !jksFile.isEmpty()) { + keystoreBytes = jksFile.getBytes(); + } + + CertificateInfo info = + certificateSubmissionValidator.validateAndExtractInfo( + keystoreBytes, certType, password); + + if (info == null) { + return ResponseEntity.ok( + new CertificateValidationResponse( + true, null, null, null, null, false, null)); + } + + return ResponseEntity.ok( + new CertificateValidationResponse( + true, + info.subjectName(), + info.issuerName(), + info.notAfter() != null ? info.notAfter().toInstant().toString() : null, + info.notBefore() != null + ? info.notBefore().toInstant().toString() + : null, + info.selfSigned(), + null)); + + } catch (ResponseStatusException e) { + return ResponseEntity.ok( + new CertificateValidationResponse( + false, null, null, null, null, false, e.getReason())); + } catch (IOException e) { + log.error("Error reading certificate file during pre-validation", e); + return ResponseEntity.ok( + new CertificateValidationResponse( + false, + null, + null, + null, + null, + false, + "Failed to read certificate file")); + } + } + // ===== HELPER METHODS ===== private User getCurrentUser(Principal principal) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java index 66f3a48afe..f29598af06 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java @@ -2,6 +2,8 @@ package stirling.software.proprietary.workflow.controller; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.Map; @@ -16,6 +18,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import io.swagger.v3.oas.annotations.Operation; @@ -27,6 +30,8 @@ import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.workflow.dto.CertificateInfo; +import stirling.software.proprietary.workflow.dto.CertificateValidationResponse; import stirling.software.proprietary.workflow.dto.ParticipantResponse; import stirling.software.proprietary.workflow.dto.SignatureSubmissionRequest; import stirling.software.proprietary.workflow.dto.WetSignatureMetadata; @@ -35,6 +40,7 @@ import stirling.software.proprietary.workflow.model.ParticipantStatus; import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator; import stirling.software.proprietary.workflow.service.MetadataEncryptionService; import stirling.software.proprietary.workflow.service.WorkflowSessionService; import stirling.software.proprietary.workflow.util.WorkflowMapper; @@ -59,6 +65,10 @@ public class WorkflowParticipantController { private final WorkflowParticipantRepository participantRepository; private final ObjectMapper objectMapper; private final MetadataEncryptionService metadataEncryptionService; + private final CertificateSubmissionValidator certificateSubmissionValidator; + + private static final DateTimeFormatter ISO_UTC = + DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC); @Operation( summary = "Get workflow session details by participant token", @@ -173,6 +183,8 @@ public class WorkflowParticipantController { return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant)); + } catch (ResponseStatusException e) { + throw e; } catch (Exception e) { log.error("Error submitting signature for participant {}", participant.getEmail(), e); throw new ResponseStatusException( @@ -268,6 +280,94 @@ public class WorkflowParticipantController { } } + @Operation( + summary = "Pre-validate a certificate before submission", + description = + "Validates that the provided certificate is loadable, not expired, and can " + + "successfully sign a document. Returns validation details so the " + + "participant can confirm the correct certificate before committing.") + @PostMapping( + value = "/validate-certificate", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity validateCertificate( + @RequestParam("participantToken") @NotBlank String participantToken, + @RequestParam("certType") String certType, + @RequestParam(value = "password", required = false) String password, + @RequestParam(value = "p12File", required = false) MultipartFile p12File, + @RequestParam(value = "jksFile", required = false) MultipartFile jksFile) { + + workflowSessionService.ensureSigningEnabled(); + + participantRepository + .findByShareToken(participantToken) + .filter(p -> !p.isExpired()) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Invalid or expired participant token")); + + // Require a file for non-SERVER/non-USER_CERT types — this is a request error, not a + // validation failure + if (!"SERVER".equalsIgnoreCase(certType) + && !"USER_CERT".equalsIgnoreCase(certType) + && (p12File == null || p12File.isEmpty()) + && (jksFile == null || jksFile.isEmpty())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "No certificate file provided"); + } + + try { + byte[] keystoreBytes = null; + if (p12File != null && !p12File.isEmpty()) { + keystoreBytes = p12File.getBytes(); + } else if (jksFile != null && !jksFile.isEmpty()) { + keystoreBytes = jksFile.getBytes(); + } + + CertificateInfo info = + certificateSubmissionValidator.validateAndExtractInfo( + keystoreBytes, certType, password); + + if (info == null) { + // SERVER type — nothing to validate + return ResponseEntity.ok( + new CertificateValidationResponse( + true, null, null, null, null, false, null)); + } + + return ResponseEntity.ok( + new CertificateValidationResponse( + true, + info.subjectName(), + info.issuerName(), + info.notAfter() != null ? info.notAfter().toInstant().toString() : null, + info.notBefore() != null + ? info.notBefore().toInstant().toString() + : null, + info.selfSigned(), + null)); + + } catch (ResponseStatusException e) { + // Validation failure — return 200 with valid:false so the frontend can display inline + return ResponseEntity.ok( + new CertificateValidationResponse( + false, null, null, null, null, false, e.getReason())); + } catch (IOException e) { + log.error("Error reading certificate file during pre-validation", e); + return ResponseEntity.ok( + new CertificateValidationResponse( + false, + null, + null, + null, + null, + false, + "Failed to read certificate file")); + } + } + /** * Builds metadata map from signature submission request. Includes certificate submission and * wet signature data. @@ -276,6 +376,20 @@ public class WorkflowParticipantController { throws IOException { Map metadata = new HashMap<>(); + // Validate certificate before storing — throws 400 if invalid, expired, or wrong password + if (request.getCertType() != null && !"SERVER".equalsIgnoreCase(request.getCertType())) { + byte[] keystoreBytes = null; + if (request.getP12File() != null && !request.getP12File().isEmpty()) { + keystoreBytes = request.getP12File().getBytes(); + } else if (request.getJksFile() != null && !request.getJksFile().isEmpty()) { + keystoreBytes = request.getJksFile().getBytes(); + } + if (keystoreBytes != null) { + certificateSubmissionValidator.validateAndExtractInfo( + keystoreBytes, request.getCertType(), request.getPassword()); + } + } + // Add certificate submission if provided if (request.getCertType() != null) { Map certSubmission = new HashMap<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/CertificateInfo.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/CertificateInfo.java new file mode 100644 index 0000000000..4e963bb0fc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/CertificateInfo.java @@ -0,0 +1,11 @@ +package stirling.software.proprietary.workflow.dto; + +import java.util.Date; + +/** + * Certificate metadata extracted from a keystore submission. Returned by + * CertificateSubmissionValidator after successful validation so callers can surface details + * (expiry, subject) to the user. + */ +public record CertificateInfo( + String subjectName, String issuerName, Date notBefore, Date notAfter, boolean selfSigned) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/CertificateValidationResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/CertificateValidationResponse.java new file mode 100644 index 0000000000..501cb5e241 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/CertificateValidationResponse.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.workflow.dto; + +/** + * API response returned by the certificate pre-validation endpoints. Always returns HTTP 200; the + * {@code valid} field indicates success. Frontend should use this to display inline feedback before + * the user completes signing. + */ +public record CertificateValidationResponse( + boolean valid, + String subjectName, + String issuerName, + /** ISO-8601 formatted expiry date, or null if validation failed. */ + String notAfter, + /** ISO-8601 formatted start-of-validity date, or null if validation failed. */ + String notBefore, + boolean selfSigned, + /** Human-readable error message, or null if valid. */ + String error) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/CertificateSubmissionValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/CertificateSubmissionValidator.java new file mode 100644 index 0000000000..a833ac0343 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/CertificateSubmissionValidator.java @@ -0,0 +1,213 @@ +package stirling.software.proprietary.workflow.service; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateExpiredException; +import java.security.cert.CertificateNotYetValidException; +import java.security.cert.X509Certificate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Enumeration; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.PdfSigningService; +import stirling.software.proprietary.workflow.dto.CertificateInfo; + +/** + * Validates a certificate submission before it is stored in participant metadata. Catches issues + * (wrong password, expired cert, algorithm incompatibility) at signing time rather than days later + * at finalization. + * + *

The core check is a test-sign of a minimal blank PDF using the exact same {@link + * PdfSigningService} code path used at finalization, so any failure that would block finalization + * is caught here first. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CertificateSubmissionValidator { + + private static final DateTimeFormatter DATE_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneId.systemDefault()); + + private final PdfSigningService pdfSigningService; + + /** + * Validates a certificate submission end-to-end by: + * + *

    + *
  1. Loading the keystore with the provided password + *
  2. Checking certificate validity (expiry, not-yet-valid) + *
  3. Test-signing a blank PDF to confirm the key and certificate are fully functional + *
+ * + * @param keystoreBytes raw bytes of the keystore file + * @param certType "P12", "PKCS12", "PFX", or "JKS" (case-insensitive) + * @param password keystore password (may be null or empty) + * @return {@link CertificateInfo} with subject, issuer, and validity dates on success + * @throws ResponseStatusException HTTP 400 with a user-friendly message on any failure + */ + public CertificateInfo validateAndExtractInfo( + byte[] keystoreBytes, String certType, String password) { + if (certType == null + || "SERVER".equalsIgnoreCase(certType) + || "USER_CERT".equalsIgnoreCase(certType)) { + // Server-managed or pre-configured user certificate — no file uploaded, nothing to + // validate + return null; + } + + char[] passwordChars = password != null ? password.toCharArray() : new char[0]; + + KeyStore keystore = loadKeyStore(keystoreBytes, certType, passwordChars); + X509Certificate cert = extractSigningCert(keystore, passwordChars); + + validateCertValidity(cert); + + String subjectName = extractCN(cert.getSubjectX500Principal().getName()); + String issuerName = extractCN(cert.getIssuerX500Principal().getName()); + boolean selfSigned = cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal()); + + testSign(keystore, passwordChars, subjectName); + + return new CertificateInfo( + subjectName, issuerName, cert.getNotBefore(), cert.getNotAfter(), selfSigned); + } + + // ---- private helpers ---- + + private KeyStore loadKeyStore(byte[] bytes, String certType, char[] password) { + String keystoreType = resolveKeystoreType(certType); + try { + KeyStore ks = KeyStore.getInstance(keystoreType); + ks.load(new java.io.ByteArrayInputStream(bytes), password); + return ks; + } catch (IOException e) { + // PKCS12: wrong password produces an IOException with "keystore password was incorrect" + // JKS: wrong password produces IOException wrapping UnrecoverableKeyException + log.debug("Failed to load {} keystore: {}", keystoreType, e.getMessage()); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Invalid certificate password or corrupt keystore file"); + } catch (Exception e) { + log.debug("Failed to instantiate {} keystore: {}", keystoreType, e.getMessage()); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Invalid certificate password or corrupt keystore file"); + } + } + + private X509Certificate extractSigningCert(KeyStore keystore, char[] password) { + try { + Enumeration aliases = keystore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + PrivateKey key = null; + try { + key = (PrivateKey) keystore.getKey(alias, password); + } catch (UnrecoverableKeyException | java.security.NoSuchAlgorithmException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Invalid certificate password or corrupt keystore file"); + } + if (key == null) continue; + + Certificate[] chain = keystore.getCertificateChain(alias); + if (chain != null && chain.length > 0 && chain[0] instanceof X509Certificate) { + return (X509Certificate) chain[0]; + } + } + } catch (ResponseStatusException e) { + throw e; + } catch (KeyStoreException e) { + log.debug("KeyStore alias enumeration failed: {}", e.getMessage()); + } + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "No private key found in the provided keystore"); + } + + private void validateCertValidity(X509Certificate cert) { + try { + cert.checkValidity(); + } catch (CertificateExpiredException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Certificate has expired (expired: " + + DATE_FORMAT.format(cert.getNotAfter().toInstant()) + + ")"); + } catch (CertificateNotYetValidException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Certificate is not yet valid (valid from: " + + DATE_FORMAT.format(cert.getNotBefore().toInstant()) + + ")"); + } + } + + private void testSign(KeyStore keystore, char[] password, String signerName) { + try { + byte[] blankPdf = createBlankPdf(); + pdfSigningService.signWithKeystore( + blankPdf, keystore, password, false, null, signerName, null, null, false); + } catch (ResponseStatusException e) { + throw e; + } catch (Exception e) { + log.debug("Certificate test-sign failed: {}", e.getMessage()); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Certificate is not compatible with the signing algorithm: " + e.getMessage()); + } + } + + /** Creates a minimal valid 1-page blank PDF for use in test-signing. */ + private byte[] createBlankPdf() throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + doc.addPage(new PDPage()); + doc.save(out); + return out.toByteArray(); + } + } + + /** + * Maps the user-facing certType string to a JCA KeyStore type string. + * + *

All PKCS12 variants ("P12", "PKCS12", "PFX") map to {@code "PKCS12"}. {@code "JKS"} maps + * to {@code "JKS"}. + */ + private String resolveKeystoreType(String certType) { + if (certType == null) return "PKCS12"; + return switch (certType.toUpperCase()) { + case "JKS" -> "JKS"; + default -> "PKCS12"; // P12, PKCS12, PFX + }; + } + + /** + * Extracts the CN value from an X.500 distinguished name string. Falls back to the full DN if + * no CN attribute is present. + */ + private String extractCN(String dn) { + if (dn == null) return ""; + for (String part : dn.split(",")) { + String trimmed = part.trim(); + if (trimmed.toUpperCase().startsWith("CN=")) { + return trimmed.substring(3); + } + } + return dn; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java index 11475053e4..aac9c180fc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java @@ -822,6 +822,8 @@ public class SigningFinalizationService { switch (certType) { case "P12": + case "PKCS12": + case "PFX": if (submission.getP12Keystore() == null) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "P12 keystore data is required"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index a8110b0cfd..e8887e2f55 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -61,6 +61,7 @@ public class WorkflowSessionService { private final ObjectMapper objectMapper; private final ApplicationProperties applicationProperties; private final MetadataEncryptionService metadataEncryptionService; + private final CertificateSubmissionValidator certificateSubmissionValidator; public void ensureSigningEnabled() { if (!applicationProperties.getStorage().isEnabled() @@ -684,7 +685,27 @@ public class WorkflowSessionService { metadata = new HashMap<>(existingMetadata); } - // 1. Store certificate submission data + // 1. Validate certificate before storing — throws 400 if invalid, expired, or wrong + // password + if (request.getCertType() != null + && !"SERVER".equalsIgnoreCase(request.getCertType()) + && request.getP12File() != null + && !request.getP12File().isEmpty()) { + try { + certificateSubmissionValidator.validateAndExtractInfo( + request.getP12File().getBytes(), + request.getCertType(), + request.getPassword()); + } catch (ResponseStatusException e) { + throw e; + } catch (IOException e) { + log.error("Failed to read P12 keystore file for validation", e); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Failed to process certificate file"); + } + } + + // 2. Store certificate submission data Map certSubmission = new HashMap<>(); certSubmission.put("certType", request.getCertType()); certSubmission.put("password", metadataEncryptionService.encrypt(request.getPassword())); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/CertificateValidationIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/CertificateValidationIntegrationTest.java new file mode 100644 index 0000000000..7311354138 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/CertificateValidationIntegrationTest.java @@ -0,0 +1,203 @@ +package stirling.software.proprietary.workflow.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.io.InputStream; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import stirling.software.common.service.PdfSigningService; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator; +import stirling.software.proprietary.workflow.service.MetadataEncryptionService; +import stirling.software.proprietary.workflow.service.WorkflowSessionService; + +import tools.jackson.databind.ObjectMapper; + +/** + * Integration test that wires the real {@link CertificateSubmissionValidator} into {@link + * WorkflowParticipantController} and exercises it with the actual test-certificate files. + * + *

This fills the gap between the two unit-test layers: + * + *

    + *
  • {@link WorkflowParticipantValidateCertificateTest} — controller only, validator mocked + *
  • {@link stirling.software.proprietary.workflow.service.CertificateSubmissionValidatorTest} — + * validator only, certs generated programmatically + *
+ * + * Here both layers run together against real .p12 / .jks files, so any field-name mismatch, routing + * bug, or wiring issue between controller and validator is caught. + */ +@ExtendWith(MockitoExtension.class) +class CertificateValidationIntegrationTest { + + // Infrastructure mocks — not under test + @Mock private WorkflowSessionService workflowSessionService; + @Mock private WorkflowParticipantRepository participantRepository; + @Mock private MetadataEncryptionService metadataEncryptionService; + + // Mock PdfSigningService so the test-sign step succeeds without a real PDF engine + @Mock private PdfSigningService pdfSigningService; + + private MockMvc mockMvc; + + private static final String TOKEN = "integration-test-token"; + + @BeforeEach + void setUp() throws Exception { + // Use the REAL validator wired with the mock signing service + CertificateSubmissionValidator realValidator = + new CertificateSubmissionValidator(pdfSigningService); + + WorkflowParticipantController controller = + new WorkflowParticipantController( + workflowSessionService, + participantRepository, + new ObjectMapper(), + metadataEncryptionService, + realValidator); + + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + // Return a non-expired participant for all tests + WorkflowParticipant participant = new WorkflowParticipant(); + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(participant)); + + // test-sign succeeds — only reached by valid-cert tests, lenient to avoid + // UnnecessaryStubbingException on error-path tests (wrong password, expired, etc.) + org.mockito.Mockito.lenient() + .when( + pdfSigningService.signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + isNull(), + anyString(), + isNull(), + isNull(), + anyBoolean())) + .thenReturn(new byte[0]); + } + + // ---- helpers ---- + + private static byte[] loadCert(String filename) throws Exception { + try (InputStream in = + CertificateValidationIntegrationTest.class.getResourceAsStream( + "/test-certs/" + filename)) { + if (in == null) throw new IllegalStateException("cert not found: " + filename); + return in.readAllBytes(); + } + } + + private static MockMultipartFile p12Part(String filename) throws Exception { + return new MockMultipartFile( + "p12File", filename, "application/octet-stream", loadCert(filename)); + } + + private static MockMultipartFile jksPart(String filename) throws Exception { + return new MockMultipartFile( + "jksFile", filename, "application/octet-stream", loadCert(filename)); + } + + // ---- tests ---- + + @Test + void validP12_returnsValidTrueWithSubjectName() throws Exception { + mockMvc.perform( + multipart("/api/v1/workflow/participant/validate-certificate") + .file(p12Part("valid-test.p12")) + .param("participantToken", TOKEN) + .param("certType", "P12") + .param("password", "testpass")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.valid").value(true)) + .andExpect(jsonPath("$.subjectName").isNotEmpty()) + .andExpect(jsonPath("$.notAfter").isNotEmpty()); + } + + @Test + void wrongPassword_returnsValidFalseWithErrorMessage() throws Exception { + mockMvc.perform( + multipart("/api/v1/workflow/participant/validate-certificate") + .file(p12Part("valid-test.p12")) + .param("participantToken", TOKEN) + .param("certType", "P12") + .param("password", "wrongpassword")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.valid").value(false)) + .andExpect( + jsonPath("$.error") + .value("Invalid certificate password or corrupt keystore file")); + } + + @Test + void expiredP12_returnsValidFalseWithExpiryMessage() throws Exception { + mockMvc.perform( + multipart("/api/v1/workflow/participant/validate-certificate") + .file(p12Part("expired-test.p12")) + .param("participantToken", TOKEN) + .param("certType", "P12") + .param("password", "testpass")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.valid").value(false)) + .andExpect( + jsonPath("$.error").value(org.hamcrest.Matchers.containsString("expired"))); + } + + @Test + void notYetValidP12_returnsValidFalseWithNotYetValidMessage() throws Exception { + mockMvc.perform( + multipart("/api/v1/workflow/participant/validate-certificate") + .file(p12Part("not-yet-valid-test.p12")) + .param("participantToken", TOKEN) + .param("certType", "P12") + .param("password", "testpass")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.valid").value(false)) + .andExpect( + jsonPath("$.error") + .value(org.hamcrest.Matchers.containsString("not yet valid"))); + } + + @Test + void validJks_returnsValidTrueWithSubjectName() throws Exception { + mockMvc.perform( + multipart("/api/v1/workflow/participant/validate-certificate") + .file(jksPart("valid-test.jks")) + .param("participantToken", TOKEN) + .param("certType", "JKS") + .param("password", "jkspass")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.valid").value(true)) + .andExpect(jsonPath("$.subjectName").isNotEmpty()); + } + + @Test + void serverCertType_returnsValidTrueWithoutFileUpload() throws Exception { + mockMvc.perform( + multipart("/api/v1/workflow/participant/validate-certificate") + .param("participantToken", TOKEN) + .param("certType", "SERVER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.valid").value(true)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantValidateCertificateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantValidateCertificateTest.java new file mode 100644 index 0000000000..695612abdc --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantValidateCertificateTest.java @@ -0,0 +1,159 @@ +package stirling.software.proprietary.workflow.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Date; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.proprietary.workflow.dto.CertificateInfo; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator; +import stirling.software.proprietary.workflow.service.MetadataEncryptionService; +import stirling.software.proprietary.workflow.service.WorkflowSessionService; + +import tools.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +class WorkflowParticipantValidateCertificateTest { + + @Mock private WorkflowSessionService workflowSessionService; + @Mock private WorkflowParticipantRepository participantRepository; + @Mock private MetadataEncryptionService metadataEncryptionService; + @Mock private CertificateSubmissionValidator certificateSubmissionValidator; + + private MockMvc mockMvc; + + private static final String VALID_TOKEN = "valid-share-token-abc123"; + private static final byte[] DUMMY_CERT = "dummy-cert-bytes".getBytes(); + + @BeforeEach + void setUp() { + WorkflowParticipantController controller = + new WorkflowParticipantController( + workflowSessionService, + participantRepository, + new ObjectMapper(), + metadataEncryptionService, + certificateSubmissionValidator); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + private WorkflowParticipant activeParticipant() { + WorkflowParticipant p = new WorkflowParticipant(); + // isExpired() returns false by default (no expiry date set) + return p; + } + + // ---- Happy path: valid cert ---- + + @Test + void validCertificate_returns200WithValidTrue() throws Exception { + when(participantRepository.findByShareToken(VALID_TOKEN)) + .thenReturn(Optional.of(activeParticipant())); + + CertificateInfo info = + new CertificateInfo( + "Test Signer", + "Test CA", + new Date(), + new Date(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000), + true); + when(certificateSubmissionValidator.validateAndExtractInfo(any(), eq("P12"), eq("secret"))) + .thenReturn(info); + + MockMultipartFile certFile = + new MockMultipartFile( + "p12File", "cert.p12", "application/octet-stream", DUMMY_CERT); + + mockMvc.perform( + multipart("/api/v1/workflow/participant/validate-certificate") + .file(certFile) + .param("participantToken", VALID_TOKEN) + .param("certType", "P12") + .param("password", "secret")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.valid").value(true)) + .andExpect(jsonPath("$.subjectName").value("Test Signer")); + } + + // ---- Bad password / invalid cert → validator throws 400, we return 200 valid:false ---- + + @Test + void invalidCertificate_returns200WithValidFalseAndErrorMessage() throws Exception { + when(participantRepository.findByShareToken(VALID_TOKEN)) + .thenReturn(Optional.of(activeParticipant())); + + when(certificateSubmissionValidator.validateAndExtractInfo(any(), any(), any())) + .thenThrow( + new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Invalid certificate password or corrupt keystore file")); + + MockMultipartFile certFile = + new MockMultipartFile( + "p12File", "cert.p12", "application/octet-stream", DUMMY_CERT); + + mockMvc.perform( + multipart("/api/v1/workflow/participant/validate-certificate") + .file(certFile) + .param("participantToken", VALID_TOKEN) + .param("certType", "P12") + .param("password", "wrong")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.valid").value(false)) + .andExpect( + jsonPath("$.error") + .value("Invalid certificate password or corrupt keystore file")); + } + + // ---- No file → 400 bad request ---- + + @Test + void missingCertFile_returns400() throws Exception { + when(participantRepository.findByShareToken(VALID_TOKEN)) + .thenReturn(Optional.of(activeParticipant())); + + mockMvc.perform( + multipart("/api/v1/workflow/participant/validate-certificate") + .param("participantToken", VALID_TOKEN) + .param("certType", "P12") + .param("password", "pass")) + .andExpect(status().isBadRequest()); + } + + // ---- Invalid / expired token → 403 ---- + + @Test + void invalidToken_returns403() throws Exception { + when(participantRepository.findByShareToken("bad-token")).thenReturn(Optional.empty()); + + MockMultipartFile certFile = + new MockMultipartFile( + "p12File", "cert.p12", "application/octet-stream", DUMMY_CERT); + + mockMvc.perform( + multipart("/api/v1/workflow/participant/validate-certificate") + .file(certFile) + .param("participantToken", "bad-token") + .param("certType", "P12") + .param("password", "pass")) + .andExpect(status().isForbidden()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/CertificateSubmissionValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/CertificateSubmissionValidatorTest.java new file mode 100644 index 0000000000..6ec4d0055d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/CertificateSubmissionValidatorTest.java @@ -0,0 +1,357 @@ +package stirling.software.proprietary.workflow.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.service.PdfSigningService; +import stirling.software.proprietary.workflow.dto.CertificateInfo; + +@ExtendWith(MockitoExtension.class) +class CertificateSubmissionValidatorTest { + + @Mock private PdfSigningService pdfSigningService; + + private CertificateSubmissionValidator validator; + + @BeforeEach + void setUp() { + validator = new CertificateSubmissionValidator(pdfSigningService); + } + + // ---- helper: build a PKCS12 keystore with a self-signed cert ---- + + private static byte[] buildP12Keystore( + String alias, String password, Date notBefore, Date notAfter) throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + X500Name subject = new X500Name("CN=Test Signer,O=Test Org,C=GB"); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()); + X509Certificate cert = + new JcaX509CertificateConverter() + .getCertificate( + new JcaX509v3CertificateBuilder( + subject, + BigInteger.valueOf(System.currentTimeMillis()), + notBefore, + notAfter, + subject, + kp.getPublic()) + .build(signer)); + + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry(alias, kp.getPrivate(), password.toCharArray(), new Certificate[] {cert}); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ks.store(bos, password.toCharArray()); + return bos.toByteArray(); + } + + private static byte[] validP12(String password) throws Exception { + Date now = new Date(); + Date future = new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000); + return buildP12Keystore("test", password, now, future); + } + + private static byte[] expiredP12(String password) throws Exception { + Date past1 = new Date(System.currentTimeMillis() - 10_000_000L); + Date past2 = new Date(System.currentTimeMillis() - 1_000L); + return buildP12Keystore("test", password, past1, past2); + } + + private static byte[] notYetValidP12(String password) throws Exception { + Date future1 = new Date(System.currentTimeMillis() + 10_000_000L); + Date future2 = new Date(System.currentTimeMillis() + 20_000_000L); + return buildP12Keystore("test", password, future1, future2); + } + + // ---- SERVER type: skip validation ---- + + @Test + void serverCertType_returnsNull_withoutCallingSigningService() throws Exception { + CertificateInfo result = validator.validateAndExtractInfo(new byte[0], "SERVER", "pass"); + + assertThat(result).isNull(); + verify(pdfSigningService, never()) + .signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + any(), + any(), + any(), + any(), + anyBoolean()); + } + + @Test + void nullCertType_returnsNull_withoutCallingSigningService() throws Exception { + CertificateInfo result = validator.validateAndExtractInfo(new byte[0], null, "pass"); + + assertThat(result).isNull(); + verify(pdfSigningService, never()) + .signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + any(), + any(), + any(), + any(), + anyBoolean()); + } + + // ---- Valid P12 certificate ---- + + @Test + void validP12Certificate_returnsInfo() throws Exception { + byte[] p12 = validP12("password"); + when(pdfSigningService.signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + isNull(), + anyString(), + isNull(), + isNull(), + anyBoolean())) + .thenReturn(new byte[0]); + + CertificateInfo info = validator.validateAndExtractInfo(p12, "P12", "password"); + + assertThat(info).isNotNull(); + assertThat(info.subjectName()).isEqualTo("Test Signer"); + assertThat(info.notAfter()).isNotNull(); + assertThat(info.notAfter()).isAfter(new Date()); + } + + @Test + void validPkcs12Alias_acceptedAsCertType() throws Exception { + byte[] p12 = validP12("password"); + when(pdfSigningService.signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + isNull(), + anyString(), + isNull(), + isNull(), + anyBoolean())) + .thenReturn(new byte[0]); + + CertificateInfo info = validator.validateAndExtractInfo(p12, "PKCS12", "password"); + + assertThat(info).isNotNull(); + } + + @Test + void validPfxAlias_acceptedAsCertType() throws Exception { + byte[] p12 = validP12("password"); + when(pdfSigningService.signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + isNull(), + anyString(), + isNull(), + isNull(), + anyBoolean())) + .thenReturn(new byte[0]); + + CertificateInfo info = validator.validateAndExtractInfo(p12, "PFX", "password"); + + assertThat(info).isNotNull(); + } + + // ---- Wrong password ---- + + @Test + void wrongPassword_throws400() { + byte[] p12; + try { + p12 = validP12("correct-password"); + } catch (Exception e) { + throw new RuntimeException(e); + } + + assertThatThrownBy(() -> validator.validateAndExtractInfo(p12, "P12", "wrong-password")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + ex -> + assertThat(((ResponseStatusException) ex).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + } + + // ---- Corrupt keystore bytes ---- + + @Test + void corruptBytes_throws400() { + byte[] garbage = "this is not a keystore".getBytes(); + + assertThatThrownBy(() -> validator.validateAndExtractInfo(garbage, "P12", "password")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + ex -> + assertThat(((ResponseStatusException) ex).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + } + + // ---- Expired certificate ---- + + @Test + void expiredCertificate_throws400WithExpiryDateInMessage() { + byte[] p12; + try { + p12 = expiredP12("password"); + } catch (Exception e) { + throw new RuntimeException(e); + } + + assertThatThrownBy(() -> validator.validateAndExtractInfo(p12, "P12", "password")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + ex -> { + ResponseStatusException rse = (ResponseStatusException) ex; + assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(rse.getReason()).contains("expired"); + }); + } + + // ---- Not-yet-valid certificate ---- + + @Test + void notYetValidCertificate_throws400() { + byte[] p12; + try { + p12 = notYetValidP12("password"); + } catch (Exception e) { + throw new RuntimeException(e); + } + + assertThatThrownBy(() -> validator.validateAndExtractInfo(p12, "P12", "password")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + ex -> { + ResponseStatusException rse = (ResponseStatusException) ex; + assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(rse.getReason()).contains("not yet valid"); + }); + } + + // ---- Test-sign failure ---- + + @Test + void signingServiceThrows_wrapsAs400() throws Exception { + byte[] p12 = validP12("password"); + doThrow(new RuntimeException("algorithm not supported")) + .when(pdfSigningService) + .signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + isNull(), + anyString(), + isNull(), + isNull(), + anyBoolean()); + + assertThatThrownBy(() -> validator.validateAndExtractInfo(p12, "P12", "password")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + ex -> { + ResponseStatusException rse = (ResponseStatusException) ex; + assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(rse.getReason()).contains("compatible"); + }); + } + + // ---- JKS keystore ---- + + @Test + void validJksKeystore_returnsInfo() throws Exception { + // Build a JKS keystore with the same cert + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + X500Name subject = new X500Name("CN=JKS Signer,O=Test,C=GB"); + Date now = new Date(); + Date future = new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()); + X509Certificate cert = + new JcaX509CertificateConverter() + .getCertificate( + new JcaX509v3CertificateBuilder( + subject, + BigInteger.valueOf(System.currentTimeMillis()), + now, + future, + subject, + kp.getPublic()) + .build(signer)); + + KeyStore jks = KeyStore.getInstance("JKS"); + jks.load(null, null); + jks.setKeyEntry( + "jks-test", kp.getPrivate(), "jkspass".toCharArray(), new Certificate[] {cert}); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + jks.store(bos, "jkspass".toCharArray()); + byte[] jksBytes = bos.toByteArray(); + + when(pdfSigningService.signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + isNull(), + anyString(), + isNull(), + isNull(), + anyBoolean())) + .thenReturn(new byte[0]); + + CertificateInfo info = validator.validateAndExtractInfo(jksBytes, "JKS", "jkspass"); + + assertThat(info).isNotNull(); + assertThat(info.subjectName()).isEqualTo("JKS Signer"); + } +} diff --git a/app/proprietary/src/test/resources/test-certs/expired-test.p12 b/app/proprietary/src/test/resources/test-certs/expired-test.p12 new file mode 100644 index 0000000000000000000000000000000000000000..c82b6188e3fd9708376b2430329edadeeb9d4ef9 GIT binary patch literal 2680 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 literal 0 HcmV?d00001 diff --git a/app/proprietary/src/test/resources/test-certs/not-yet-valid-test.p12 b/app/proprietary/src/test/resources/test-certs/not-yet-valid-test.p12 new file mode 100644 index 0000000000000000000000000000000000000000..f57b2e5cb2c50dd692e49e8b2a6efbe342fb8897 GIT binary patch literal 2678 zcmXqL;woZdWHxBxl40Z2YV&CO&dbQoxS)xPm!*k|)1Zlq)u4%W4~i7)T9zi(l?F|$ zOAVS>7qW3fb@6a9GA(FgbuegRwJ}J8>tN+Ih%~T3aCr>0Swuc;U6N^GDEf~htJU7p z_9SyCQ%D>Wlez#Cqk;hs8wbQhCQeoc16elCgf^+`tJC-QLc7*6XhM z`#t_y%3=I`2|vTDF9i~6O}#M_LaI|s4z#Q|6uOi}x;6OTdj125@T5x{5tDn z!0gG5T8+Qj8rfuz8tt_IyS(`Jcg5)@@qMM|WoxX0e1i@j^}4p?$%;TO*Z8HLBF&TX zf)-SteL781tK(xt&G!qTD?;RKTrRLAFHT8%KIucGn_ydZ+Hq%vtFyghb9|;Rjg@$9 za=zpD!uW43lMWf&UMqUyjGIzY>Hg23az1U*dp-B*G=JmTnR7L`-mmumxBlI|pI2{I z`u?5eSNQ3d`>w5b7C66=Dry&AWimXc70y8WRcdZXlLEkhZirOA#`8kTC&;% zZ8PrN@TBFoZtFzn=IMOCH6x_uefG8uw`Iz{RUW_o!u!JJ{3-LRkG-{9AL_tTJ6R}g z9`|YcZP<1wpNDdzkf4pF1J2kOW(9sw(S8)jPv&z zKQm6wZgJGQt8(Iu%%>}2AGEt4-)Bz*R9jO}?yHZ?yMWxaF?e`b*3| zA3vBdtNpZj1MGMhuz`JpiHWN#z;9@BG+|Fwl~U1%u&;7P)Z#c$rt zoKtx^@W`u3cY&hG&8MTYQvPeIESQmWDYq~8*`$x^Y^J|dj&|!AZMSQw+TB{4`n4oP zv!_-2`&S!lQAOC#W_tE40hqs%IX6*aMvg^uS-|(4X6PUsRuW+`i#Gc4% zFI3pHP9-d3j-j7{CcL=h6fu;MlVV6?C}k*NC}k*ONM+DvC}zlHNN31nNM$H8P(&yd zH56eH3dzh(RWLL+H!(3WG_#A0O7#G=Q<$Y9XKqK=SZBvfe2 zX7&l6*uKJ?>!xSKhux+}j&7-1M@*qDq_jTxvhrP*2T}?%_+HIOY-nQMkpE`xL0i-H z&rIze{_)xRrq(3pYE{asTf1LmL_0p1oMgB8?JX}`wcS4)9>17f8}Ish-S4}f=UzS) z&NBDso_#I%O21V9soEg7Kr=fz&}~b=mhyZ)C9YczlUA+yH#aQ!mc+t823maWzOr?h z=Z)l4LmqM!ObpqlSG(}{2SF3YHs-b4Bfk|r3O3%k&*|9{ZDGUZN31q&{AKY}Rr%JN z4Xj5FSY4Jd*_(OZi(Pd3-s@fcUncg{y6!ytIFj8Y#NF8=_0unp6`?++TOO@gllAz) z1Xpbdjv6xerUE%llT?LXU`Y#Bh`O{_u00+u}4##zbrXq7<#|j^h2`cxhrmr zFI64~M!GPV_0(?YN>~wg#Jkn(fm239yZ$2g{Bxg|cAkizA1`d=-s7sP`oGM3tt?x@ zzyI4)`;2xZJ;`f{Gsb(y`kYcV=b3QByfz^8I`2mZ_)RD|URJEhcvEdc2F| zJkRGTSKM{DZ&+NY(iF&(+;Zmp1OBg#i#R_1yJTJz^({h|f0sJ%_W6fzcW!YL>|100bKRf1 z#vMNwnX5~$Qn5PC`b^-u?e=pDAB7Twvpp7mXNv0kn_yYF!?bflV&aQ}2iMk2Ztgi< zzfGc?Ey%Zo@5k{e(N0>v1y?5A|DKZEFA(l#Dl>7 z#}#=wmWexO7fzh&@%qw1xx$?u6{|(|C$*er`}=)WXt&L*vQ9?v!yRfbzRfWUI9R8g1fe~`t+|&I(_@ls{*&qI)_|6RRI^x#W#Lew4OU{CZHx3rJuFBe&vUi zr@y+dms4V~KddJbsD1mq9~5}JdY63{t?=B`v3z>h zdXKeH0aMP`D$YLl{cGomtIv*cy-Z&{XOV1*RP|1y=9#;tAD*?OSPA_y}fPm90{R| z!3Mqth6cRwwipv5D+7x{JmW=S&V8wOPFXY7pYxrq`!8d|nY`7fj~w)TB(tzOPm@Jt bT5?;Zob#=hWodCP5wji!xARm@0TmelX<^Sf literal 0 HcmV?d00001 diff --git a/app/proprietary/src/test/resources/test-certs/valid-test.jks b/app/proprietary/src/test/resources/test-certs/valid-test.jks new file mode 100644 index 0000000000000000000000000000000000000000..62407a32cad4733bcdda22fc24f9bc377390021d GIT binary patch literal 2150 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 literal 0 HcmV?d00001 diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 26a94f00b7..14eb25f85c 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test'; * @see https://playwright.dev/docs/test-configuration */ export default defineConfig({ - testDir: './src/tests', + testDir: './src/core/tests', testMatch: '**/*.spec.ts', /* Run tests in files in parallel */ fullyParallel: true, diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 94d8a6f94e..4550ce99f3 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -2317,8 +2317,21 @@ continue = "Continue" [certSign.collab.signRequest.certModal] description = "You have placed {{count}} signature(s). Choose your certificate to complete signing." sign = "Sign Document" +certValidating = "Validating certificate..." +certValidUntil = "Certificate valid until {{date}}" +certInvalid = "Certificate invalid: {{error}}" +certInvalidFallback = "Invalid certificate" +certNetworkError = "Could not validate certificate" title = "Configure Certificate" +[certSign.collab.participant] +certValidating = "Validating certificate..." +certValid = "✓ Certificate valid" +certValidUntil = " until {{date}}" +certInvalid = "✗ {{error}}" +certInvalidFallback = "Invalid certificate" +certNetworkError = "Could not validate certificate" + [certSign.collab.signRequest.image] hint = "Upload a PNG or JPG image of your signature" diff --git a/frontend/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx b/frontend/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx index 373ad8df72..d70cb7d1c1 100644 --- a/frontend/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx +++ b/frontend/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx @@ -1,7 +1,10 @@ -import { Modal, Stack, Group, Button, Text, Collapse, TextInput } from '@mantine/core'; +import { Modal, Stack, Group, Button, Text, Collapse, TextInput, Loader } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; import { CertificateSelector, CertificateType, UploadFormat } from '@app/components/tools/certSign/CertificateSelector'; +import apiClient from '@app/services/apiClient'; export interface CertificateSubmitData { certType: CertificateType; @@ -13,6 +16,12 @@ export interface CertificateSubmitData { password: string; } +type CertValidationState = + | { status: 'idle' } + | { status: 'validating' } + | { status: 'valid'; subjectName: string | null; notAfter: string | null } + | { status: 'error'; message: string }; + interface CertificateConfigModalProps { opened: boolean; onClose: () => void; @@ -21,6 +30,8 @@ interface CertificateConfigModalProps { disabled?: boolean; defaultReason?: string; defaultLocation?: string; + /** Share token for external participants. When present, the participant validation endpoint is used. */ + participantToken?: string; } export const CertificateConfigModal: React.FC = ({ @@ -31,6 +42,7 @@ export const CertificateConfigModal: React.FC = ({ disabled = false, defaultReason = '', defaultLocation = '', + participantToken, }) => { const { t } = useTranslation(); @@ -42,6 +54,65 @@ export const CertificateConfigModal: React.FC = ({ const [jksFile, setJksFile] = useState(null); const [password, setPassword] = useState(''); const [signing, setSigning] = useState(false); + const [certValidation, setCertValidation] = useState({ status: 'idle' }); + const validationTimerRef = useRef | null>(null); + + // Debounced certificate pre-validation: fires 600ms after cert file or password changes + useEffect(() => { + // Only validate uploaded keystores (not SERVER/USER_CERT, not PEM which uses separate files) + const keystoreFile = uploadFormat === 'JKS' ? jksFile : p12File; + if (certType !== 'UPLOAD' || !keystoreFile || uploadFormat === 'PEM') { + setCertValidation({ status: 'idle' }); + return; + } + + if (validationTimerRef.current) clearTimeout(validationTimerRef.current); + setCertValidation({ status: 'validating' }); + + validationTimerRef.current = setTimeout(async () => { + try { + const formData = new FormData(); + formData.append('certType', uploadFormat === 'JKS' ? 'JKS' : 'P12'); + formData.append('password', password); + if (uploadFormat === 'JKS') { + formData.append('jksFile', keystoreFile); + } else { + formData.append('p12File', keystoreFile); + } + + const endpoint = participantToken + ? '/api/v1/workflow/participant/validate-certificate' + : '/api/v1/security/cert-sign/validate-certificate'; + + if (participantToken) { + formData.append('participantToken', participantToken); + } + + const response = await apiClient.post<{ + valid: boolean; + subjectName: string | null; + notAfter: string | null; + error: string | null; + }>(endpoint, formData, { headers: { 'Content-Type': 'multipart/form-data' } }); + + if (response.data.valid) { + setCertValidation({ + status: 'valid', + subjectName: response.data.subjectName, + notAfter: response.data.notAfter, + }); + } else { + setCertValidation({ status: 'error', message: response.data.error ?? t('certSign.collab.signRequest.certModal.certInvalidFallback', 'Invalid certificate') }); + } + } catch { + setCertValidation({ status: 'error', message: t('certSign.collab.signRequest.certModal.certNetworkError', 'Could not validate certificate') }); + } + }, 600); + + return () => { + if (validationTimerRef.current) clearTimeout(validationTimerRef.current); + }; + }, [certType, uploadFormat, p12File, jksFile, password, participantToken]); // Advanced settings const [showAdvanced, setShowAdvanced] = useState(false); @@ -118,6 +189,39 @@ export const CertificateConfigModal: React.FC = ({ disabled={disabled || signing} /> + {/* Certificate validation status */} + {certValidation.status === 'validating' && ( + + + + {t('certSign.collab.signRequest.certModal.certValidating', 'Validating certificate...')} + + + )} + {certValidation.status === 'valid' && ( + + + + {t('certSign.collab.signRequest.certModal.certValidUntil', 'Certificate valid until {{date}}', { + date: certValidation.notAfter + ? new Date(certValidation.notAfter).toLocaleDateString() + : '—', + })} + {certValidation.subjectName ? ` · ${certValidation.subjectName}` : ''} + + + )} + {certValidation.status === 'error' && ( + + + + {t('certSign.collab.signRequest.certModal.certInvalid', 'Certificate invalid: {{error}}', { + error: certValidation.message, + })} + + + )} + {/* Advanced Settings - Optional */}
@@ -253,6 +334,7 @@ const ParticipantView: React.FC = ({ token }) => { onClick={handleDecline} color="red" variant="light" + data-testid="decline-button" > Decline