Booklet and server sign (#4371)

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: a <a>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2025-09-23 11:24:48 +01:00
committed by GitHub
parent c76edebf0f
commit 46a4a978fc
36 changed files with 2447 additions and 61 deletions

View File

@@ -0,0 +1,27 @@
package stirling.software.proprietary.configuration;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.ServerCertificateServiceInterface;
@Component
@RequiredArgsConstructor
@Slf4j
public class ServerCertificateInitializer {
private final ServerCertificateServiceInterface serverCertificateService;
@EventListener(ApplicationReadyEvent.class)
public void initializeServerCertificate() {
try {
serverCertificateService.initializeServerCertificate();
} catch (Exception e) {
log.error("Failed to initialize server certificate", e);
}
}
}

View File

@@ -0,0 +1,144 @@
package stirling.software.proprietary.security.controller.api;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.ServerCertificateServiceInterface;
@RestController
@RequestMapping("/api/v1/admin/server-certificate")
@Slf4j
@Tag(
name = "Admin - Server Certificate",
description = "Admin APIs for server certificate management")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class ServerCertificateController {
private final ServerCertificateServiceInterface serverCertificateService;
@GetMapping("/info")
@Operation(
summary = "Get server certificate information",
description = "Returns information about the current server certificate")
public ResponseEntity<ServerCertificateServiceInterface.ServerCertificateInfo>
getServerCertificateInfo() {
try {
ServerCertificateServiceInterface.ServerCertificateInfo info =
serverCertificateService.getServerCertificateInfo();
return ResponseEntity.ok(info);
} catch (Exception e) {
log.error("Failed to get server certificate info", e);
return ResponseEntity.internalServerError().build();
}
}
@PostMapping("/upload")
@Operation(
summary = "Upload server certificate",
description =
"Upload a new PKCS12 certificate file to be used as the server certificate")
public ResponseEntity<String> uploadServerCertificate(
@Parameter(description = "PKCS12 certificate file", required = true)
@RequestParam("file")
MultipartFile file,
@Parameter(description = "Certificate password", required = true)
@RequestParam("password")
String password) {
if (file.isEmpty()) {
return ResponseEntity.badRequest().body("Certificate file cannot be empty");
}
if (!file.getOriginalFilename().toLowerCase().endsWith(".p12")
&& !file.getOriginalFilename().toLowerCase().endsWith(".pfx")) {
return ResponseEntity.badRequest()
.body("Only PKCS12 (.p12 or .pfx) files are supported");
}
try {
serverCertificateService.uploadServerCertificate(file.getInputStream(), password);
return ResponseEntity.ok("Server certificate uploaded successfully");
} catch (IllegalArgumentException e) {
log.warn("Invalid certificate upload: {}", e.getMessage());
return ResponseEntity.badRequest().body("Invalid certificate or password.");
} catch (Exception e) {
log.error("Failed to upload server certificate", e);
return ResponseEntity.internalServerError().body("Failed to upload server certificate");
}
}
@DeleteMapping
@Operation(
summary = "Delete server certificate",
description = "Delete the current server certificate")
public ResponseEntity<String> deleteServerCertificate() {
try {
serverCertificateService.deleteServerCertificate();
return ResponseEntity.ok("Server certificate deleted successfully");
} catch (Exception e) {
log.error("Failed to delete server certificate", e);
return ResponseEntity.internalServerError().body("Failed to delete server certificate");
}
}
@PostMapping("/generate")
@Operation(
summary = "Generate new server certificate",
description = "Generate a new self-signed server certificate")
public ResponseEntity<String> generateServerCertificate() {
try {
serverCertificateService.deleteServerCertificate(); // Remove existing if any
serverCertificateService.initializeServerCertificate(); // Generate new
return ResponseEntity.ok("New server certificate generated successfully");
} catch (Exception e) {
log.error("Failed to generate server certificate", e);
return ResponseEntity.internalServerError()
.body("Failed to generate server certificate");
}
}
@GetMapping("/certificate")
@Operation(
summary = "Download server certificate",
description = "Download the server certificate in DER format for validation purposes")
public ResponseEntity<byte[]> getServerCertificate() {
try {
if (!serverCertificateService.hasServerCertificate()) {
return ResponseEntity.notFound().build();
}
byte[] certificate = serverCertificateService.getServerCertificatePublicKey();
return ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"server-cert.cer\"")
.contentType(MediaType.valueOf("application/pkix-cert"))
.body(certificate);
} catch (Exception e) {
log.error("Failed to get server certificate", e);
return ResponseEntity.internalServerError().build();
}
}
@GetMapping("/enabled")
@Operation(
summary = "Check if server certificate feature is enabled",
description =
"Returns whether the server certificate feature is enabled in configuration")
public ResponseEntity<Boolean> isServerCertificateEnabled() {
return ResponseEntity.ok(serverCertificateService.isEnabled());
}
}

View File

@@ -0,0 +1,252 @@
package stirling.software.proprietary.service;
import java.io.*;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Date;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.KeyPurposeId;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.service.ServerCertificateServiceInterface;
@Service
@Slf4j
public class ServerCertificateService implements ServerCertificateServiceInterface {
private static final String KEYSTORE_FILENAME = "server-certificate.p12";
private static final String KEYSTORE_ALIAS = "stirling-pdf-server";
private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert";
@Value("${system.serverCertificate.enabled:false}")
private boolean enabled;
@Value("${system.serverCertificate.organizationName:Stirling-PDF}")
private String organizationName;
@Value("${system.serverCertificate.validity:365}")
private int validityDays;
@Value("${system.serverCertificate.regenerateOnStartup:false}")
private boolean regenerateOnStartup;
static {
Security.addProvider(new BouncyCastleProvider());
}
private Path getKeystorePath() {
return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME);
}
public boolean isEnabled() {
return enabled;
}
public boolean hasServerCertificate() {
return Files.exists(getKeystorePath());
}
public void initializeServerCertificate() {
if (!enabled) {
log.debug("Server certificate feature is disabled");
return;
}
Path keystorePath = getKeystorePath();
if (!Files.exists(keystorePath) || regenerateOnStartup) {
try {
generateServerCertificate();
log.info("Generated new server certificate at: {}", keystorePath);
} catch (Exception e) {
log.error("Failed to generate server certificate", e);
}
} else {
log.info("Server certificate already exists at: {}", keystorePath);
}
}
public KeyStore getServerKeyStore() throws Exception {
if (!enabled || !hasServerCertificate()) {
throw new IllegalStateException("Server certificate is not available");
}
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(getKeystorePath().toFile())) {
keyStore.load(fis, DEFAULT_PASSWORD.toCharArray());
}
return keyStore;
}
public String getServerCertificatePassword() {
return DEFAULT_PASSWORD;
}
public X509Certificate getServerCertificate() throws Exception {
KeyStore keyStore = getServerKeyStore();
return (X509Certificate) keyStore.getCertificate(KEYSTORE_ALIAS);
}
public byte[] getServerCertificatePublicKey() throws Exception {
X509Certificate cert = getServerCertificate();
return cert.getEncoded();
}
public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception {
// Validate the uploaded certificate
KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12");
uploadedKeyStore.load(p12Stream, password.toCharArray());
// Find the first private key entry
String alias = null;
for (String a : java.util.Collections.list(uploadedKeyStore.aliases())) {
if (uploadedKeyStore.isKeyEntry(a)) {
alias = a;
break;
}
}
if (alias == null) {
throw new IllegalArgumentException("No private key found in uploaded certificate");
}
// Create new keystore with our standard alias and password
KeyStore newKeyStore = KeyStore.getInstance("PKCS12");
newKeyStore.load(null, null);
PrivateKey privateKey = (PrivateKey) uploadedKeyStore.getKey(alias, password.toCharArray());
Certificate[] chain = uploadedKeyStore.getCertificateChain(alias);
newKeyStore.setKeyEntry(KEYSTORE_ALIAS, privateKey, DEFAULT_PASSWORD.toCharArray(), chain);
// Save to server keystore location
Path keystorePath = getKeystorePath();
Files.createDirectories(keystorePath.getParent());
try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) {
newKeyStore.store(fos, DEFAULT_PASSWORD.toCharArray());
}
log.info("Server certificate updated from uploaded file");
}
public void deleteServerCertificate() throws Exception {
Path keystorePath = getKeystorePath();
if (Files.exists(keystorePath)) {
Files.delete(keystorePath);
log.info("Server certificate deleted");
}
}
public ServerCertificateInfo getServerCertificateInfo() throws Exception {
if (!hasServerCertificate()) {
return new ServerCertificateInfo(false, null, null, null, null);
}
X509Certificate cert = getServerCertificate();
return new ServerCertificateInfo(
true,
cert.getSubjectX500Principal().getName(),
cert.getIssuerX500Principal().getName(),
cert.getNotBefore(),
cert.getNotAfter());
}
private void generateServerCertificate() throws Exception {
// Generate key pair
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
keyPairGenerator.initialize(2048, new SecureRandom());
KeyPair keyPair = keyPairGenerator.generateKeyPair();
// Certificate details
X500Name subject =
new X500Name(
"CN=" + organizationName + " Server, O=" + organizationName + ", C=US");
BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis());
Date notBefore = new Date();
Date notAfter = new Date(notBefore.getTime() + ((long) validityDays * 24 * 60 * 60 * 1000));
// Build certificate
JcaX509v3CertificateBuilder certBuilder =
new JcaX509v3CertificateBuilder(
subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic());
// Add PDF-specific certificate extensions for optimal PDF signing compatibility
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
// 1) End-entity certificate, not a CA (critical)
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
// 2) Key usage for PDF digital signatures (critical)
certBuilder.addExtension(
Extension.keyUsage,
true,
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
// 3) Extended key usage for document signing (non-critical, widely accepted)
certBuilder.addExtension(
Extension.extendedKeyUsage,
false,
new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning));
// 4) Subject Key Identifier for chain building (non-critical)
certBuilder.addExtension(
Extension.subjectKeyIdentifier,
false,
extUtils.createSubjectKeyIdentifier(keyPair.getPublic()));
// 5) Authority Key Identifier for self-signed cert (non-critical)
certBuilder.addExtension(
Extension.authorityKeyIdentifier,
false,
extUtils.createAuthorityKeyIdentifier(keyPair.getPublic()));
// Sign certificate
ContentSigner signer =
new JcaContentSignerBuilder("SHA256WithRSA")
.setProvider("BC")
.build(keyPair.getPrivate());
X509CertificateHolder certHolder = certBuilder.build(signer);
X509Certificate cert =
new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder);
// Create keystore
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, null);
keyStore.setKeyEntry(
KEYSTORE_ALIAS,
keyPair.getPrivate(),
DEFAULT_PASSWORD.toCharArray(),
new Certificate[] {cert});
// Save keystore
Path keystorePath = getKeystorePath();
Files.createDirectories(keystorePath.getParent());
try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) {
keyStore.store(fos, DEFAULT_PASSWORD.toCharArray());
}
}
}