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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 Hf06MoXeP@d 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 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 literal 0 HcmV?d00001 diff --git a/app/proprietary/src/test/resources/test-certs/valid-test.p12 b/app/proprietary/src/test/resources/test-certs/valid-test.p12 new file mode 100644 index 0000000000000000000000000000000000000000..bb00bc60a3e4085ab0a2a636c13cd706c7145138 GIT binary patch literal 2660 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 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