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 0000000000..c82b6188e3 Binary files /dev/null and b/app/proprietary/src/test/resources/test-certs/expired-test.p12 differ 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 0000000000..f57b2e5cb2 Binary files /dev/null and b/app/proprietary/src/test/resources/test-certs/not-yet-valid-test.p12 differ 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 0000000000..62407a32ca Binary files /dev/null and b/app/proprietary/src/test/resources/test-certs/valid-test.jks differ 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 0000000000..bb00bc60a3 Binary files /dev/null and b/app/proprietary/src/test/resources/test-certs/valid-test.p12 differ 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