mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Shared Sign Cert Validation (#5996)
## 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`
This commit is contained in:
+85
@@ -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<CertificateValidationResponse> 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) {
|
||||
|
||||
+114
@@ -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<CertificateValidationResponse> 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<String, Object> 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<String, Object> certSubmission = new HashMap<>();
|
||||
|
||||
+11
@@ -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) {}
|
||||
+18
@@ -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) {}
|
||||
+213
@@ -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.
|
||||
*
|
||||
* <p>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:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Loading the keystore with the provided password
|
||||
* <li>Checking certificate validity (expiry, not-yet-valid)
|
||||
* <li>Test-signing a blank PDF to confirm the key and certificate are fully functional
|
||||
* </ol>
|
||||
*
|
||||
* @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<String> 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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+2
@@ -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");
|
||||
|
||||
+22
-1
@@ -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<String, Object> certSubmission = new HashMap<>();
|
||||
certSubmission.put("certType", request.getCertType());
|
||||
certSubmission.put("password", metadataEncryptionService.encrypt(request.getPassword()));
|
||||
|
||||
Reference in New Issue
Block a user