Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a7445ad85 | ||
|
|
dfaa43a24a |
@@ -6,15 +6,33 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.Provider;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.bouncycastle.asn1.x500.RDN;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x500.style.BCStyle;
|
||||
import org.bouncycastle.asn1.x500.style.IETFUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -26,7 +44,10 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.Dependency;
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.SPDF.model.api.security.CertStoreEntriesRequest;
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
import stirling.software.SPDF.util.DesktopModeUtils;
|
||||
import stirling.software.SPDF.util.Pkcs11ProviderLoader;
|
||||
import stirling.software.common.annotations.api.UiDataApi;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
@@ -190,6 +211,50 @@ public class UIDataController {
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
value = "/cert-store-entries",
|
||||
consumes = {
|
||||
MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
MediaType.APPLICATION_FORM_URLENCODED_VALUE
|
||||
})
|
||||
@Operation(summary = "Get available certificates from OS-backed stores")
|
||||
public ResponseEntity<CertificateStoreEntriesData> getCertificateStoreEntries(
|
||||
@ModelAttribute CertStoreEntriesRequest request) throws Exception {
|
||||
String certType = request.getCertType();
|
||||
MultipartFile pkcs11ConfigFile = request.getPkcs11ConfigFile();
|
||||
String password = request.getPassword();
|
||||
|
||||
if (certType == null || certType.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.optionsNotSpecified",
|
||||
"{0} options are not specified",
|
||||
"certificate store type");
|
||||
}
|
||||
|
||||
ensureDesktopMode(certType);
|
||||
|
||||
KeyStore keyStore = loadCertificateStore(certType, pkcs11ConfigFile, password);
|
||||
List<CertificateStoreEntry> entries = new ArrayList<>();
|
||||
Enumeration<String> aliases = keyStore.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
String alias = aliases.nextElement();
|
||||
if (!keyStore.isKeyEntry(alias)) {
|
||||
continue;
|
||||
}
|
||||
Certificate certificate = keyStore.getCertificate(alias);
|
||||
if (certificate instanceof X509Certificate x509Certificate) {
|
||||
entries.add(buildCertificateEntry(alias, x509Certificate));
|
||||
}
|
||||
}
|
||||
entries.sort(
|
||||
Comparator.comparing(
|
||||
CertificateStoreEntry::getDisplayName, String::compareToIgnoreCase));
|
||||
|
||||
CertificateStoreEntriesData data = new CertificateStoreEntriesData();
|
||||
data.setEntries(entries);
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
private List<String> getAvailableTesseractLanguages() {
|
||||
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
|
||||
java.io.File[] files = new java.io.File(tessdataDir).listFiles();
|
||||
@@ -204,6 +269,74 @@ public class UIDataController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void ensureDesktopMode(String certType) {
|
||||
if (!DesktopModeUtils.isDesktopMode()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
"certificate type " + certType + " requires desktop mode");
|
||||
}
|
||||
}
|
||||
|
||||
private KeyStore loadCertificateStore(
|
||||
String certType, MultipartFile pkcs11ConfigFile, String password)
|
||||
throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException {
|
||||
switch (certType) {
|
||||
case "WINDOWS_STORE":
|
||||
KeyStore windowsStore = KeyStore.getInstance("Windows-MY");
|
||||
windowsStore.load(null, null);
|
||||
return windowsStore;
|
||||
case "MAC_KEYCHAIN":
|
||||
KeyStore keychainStore = KeyStore.getInstance("KeychainStore");
|
||||
keychainStore.load(null, null);
|
||||
return keychainStore;
|
||||
case "PKCS11":
|
||||
if (pkcs11ConfigFile == null || pkcs11ConfigFile.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
"PKCS11 configuration file is required");
|
||||
}
|
||||
Provider pkcs11Provider = Pkcs11ProviderLoader.loadProvider(pkcs11ConfigFile);
|
||||
KeyStore pkcs11Store = KeyStore.getInstance("PKCS11", pkcs11Provider);
|
||||
pkcs11Store.load(null, password != null ? password.toCharArray() : null);
|
||||
return pkcs11Store;
|
||||
default:
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
"certificate store type: " + certType);
|
||||
}
|
||||
}
|
||||
|
||||
private CertificateStoreEntry buildCertificateEntry(String alias, X509Certificate certificate) {
|
||||
String displayName = getCertificateDisplayName(certificate);
|
||||
return new CertificateStoreEntry(
|
||||
alias,
|
||||
displayName,
|
||||
certificate.getSubjectX500Principal().getName(),
|
||||
certificate.getIssuerX500Principal().getName(),
|
||||
certificate.getSerialNumber().toString(),
|
||||
formatDate(certificate.getNotBefore()),
|
||||
formatDate(certificate.getNotAfter()),
|
||||
certificate.getNotBefore().getTime(),
|
||||
certificate.getNotAfter().getTime());
|
||||
}
|
||||
|
||||
private String getCertificateDisplayName(X509Certificate certificate) {
|
||||
X500Name x500Name = new X500Name(certificate.getSubjectX500Principal().getName());
|
||||
RDN[] cnRdns = x500Name.getRDNs(BCStyle.CN);
|
||||
if (cnRdns != null && cnRdns.length > 0 && cnRdns[0].getFirst() != null) {
|
||||
return IETFUtils.valueToString(cnRdns[0].getFirst().getValue());
|
||||
}
|
||||
return certificate.getSubjectX500Principal().getName();
|
||||
}
|
||||
|
||||
private String formatDate(Date date) {
|
||||
return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(
|
||||
Instant.ofEpochMilli(date.getTime()).atOffset(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private List<FontResource> getFontNames() {
|
||||
List<FontResource> fontNames = new ArrayList<>();
|
||||
fontNames.addAll(getFontNamesFromLocation("classpath:static/fonts/*.woff2"));
|
||||
@@ -288,6 +421,24 @@ public class UIDataController {
|
||||
private List<String> languages;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CertificateStoreEntriesData {
|
||||
private List<CertificateStoreEntry> entries;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CertificateStoreEntry {
|
||||
private final String alias;
|
||||
private final String displayName;
|
||||
private final String subject;
|
||||
private final String issuer;
|
||||
private final String serialNumber;
|
||||
private final String notBefore;
|
||||
private final String notAfter;
|
||||
private final long notBeforeEpochMs;
|
||||
private final long notAfterEpochMs;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class FontResource {
|
||||
private String name;
|
||||
|
||||
+97
-2
@@ -73,6 +73,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
|
||||
import stirling.software.SPDF.util.DesktopModeUtils;
|
||||
import stirling.software.SPDF.util.Pkcs11ProviderLoader;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
@@ -170,7 +172,9 @@ public class CertSignController {
|
||||
MultipartFile certFile = request.getCertFile();
|
||||
MultipartFile p12File = request.getP12File();
|
||||
MultipartFile jksfile = request.getJksFile();
|
||||
MultipartFile pkcs11ConfigFile = request.getPkcs11ConfigFile();
|
||||
String password = request.getPassword();
|
||||
String certAlias = request.getCertAlias();
|
||||
Boolean showSignature = request.getShowSignature();
|
||||
String reason = request.getReason();
|
||||
String location = request.getLocation();
|
||||
@@ -188,6 +192,7 @@ public class CertSignController {
|
||||
|
||||
KeyStore ks = null;
|
||||
String keystorePassword = password;
|
||||
boolean aliasRequired = false;
|
||||
|
||||
switch (certType) {
|
||||
case "PEM":
|
||||
@@ -201,8 +206,9 @@ public class CertSignController {
|
||||
ks.load(null);
|
||||
PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password);
|
||||
Certificate cert = (Certificate) getCertificateFromPEM(certFile.getBytes());
|
||||
String pemAlias = StringUtils.isBlank(certAlias) ? "alias" : certAlias;
|
||||
ks.setKeyEntry(
|
||||
"alias", privateKey, password.toCharArray(), new Certificate[] {cert});
|
||||
pemAlias, privateKey, password.toCharArray(), new Certificate[] {cert});
|
||||
break;
|
||||
case "PKCS12":
|
||||
case "PFX":
|
||||
@@ -237,6 +243,30 @@ public class CertSignController {
|
||||
ks = serverCertificateService.getServerKeyStore();
|
||||
keystorePassword = serverCertificateService.getServerCertificatePassword();
|
||||
break;
|
||||
case "WINDOWS_STORE":
|
||||
ensureDesktopMode(certType);
|
||||
ks = KeyStore.getInstance("Windows-MY");
|
||||
ks.load(null, null);
|
||||
aliasRequired = true;
|
||||
break;
|
||||
case "MAC_KEYCHAIN":
|
||||
ensureDesktopMode(certType);
|
||||
ks = KeyStore.getInstance("KeychainStore");
|
||||
ks.load(null, null);
|
||||
aliasRequired = true;
|
||||
break;
|
||||
case "PKCS11":
|
||||
ensureDesktopMode(certType);
|
||||
pkcs11ConfigFile =
|
||||
validateFilePresent(
|
||||
pkcs11ConfigFile,
|
||||
"PKCS11 configuration",
|
||||
"PKCS11 configuration file is required");
|
||||
Provider pkcs11Provider = Pkcs11ProviderLoader.loadProvider(pkcs11ConfigFile);
|
||||
ks = KeyStore.getInstance("PKCS11", pkcs11Provider);
|
||||
ks.load(null, password != null ? password.toCharArray() : null);
|
||||
aliasRequired = true;
|
||||
break;
|
||||
default:
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
@@ -244,7 +274,16 @@ public class CertSignController {
|
||||
"certificate type: " + certType);
|
||||
}
|
||||
|
||||
CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray());
|
||||
if (aliasRequired && StringUtils.isBlank(certAlias)) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
"certificate alias is required for the selected certificate store");
|
||||
}
|
||||
|
||||
KeyStoreSelection selection = selectKeyStoreEntry(ks, certAlias, keystorePassword);
|
||||
CreateSignature createSignature =
|
||||
new CreateSignature(selection.keystore(), selection.password());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
sign(
|
||||
pdfDocumentFactory,
|
||||
@@ -274,6 +313,60 @@ public class CertSignController {
|
||||
return file;
|
||||
}
|
||||
|
||||
private void ensureDesktopMode(String certType) {
|
||||
if (!DesktopModeUtils.isDesktopMode()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
"certificate type " + certType + " requires desktop mode");
|
||||
}
|
||||
}
|
||||
|
||||
private KeyStoreSelection selectKeyStoreEntry(
|
||||
KeyStore keystore, String certAlias, String password)
|
||||
throws KeyStoreException,
|
||||
NoSuchAlgorithmException,
|
||||
UnrecoverableKeyException,
|
||||
CertificateException,
|
||||
IOException {
|
||||
if (StringUtils.isBlank(certAlias)) {
|
||||
char[] passwordChars = password != null ? password.toCharArray() : new char[0];
|
||||
return new KeyStoreSelection(keystore, passwordChars);
|
||||
}
|
||||
if (!keystore.containsAlias(certAlias)) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
"certificate alias not found: " + certAlias);
|
||||
}
|
||||
if (!keystore.isKeyEntry(certAlias)) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
"certificate alias does not reference a private key: " + certAlias);
|
||||
}
|
||||
char[] keyPassword = password != null ? password.toCharArray() : null;
|
||||
Key key = keystore.getKey(certAlias, keyPassword);
|
||||
if (!(key instanceof PrivateKey privateKey)) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
"certificate alias does not contain a private key: " + certAlias);
|
||||
}
|
||||
Certificate[] chain = keystore.getCertificateChain(certAlias);
|
||||
if (chain == null || chain.length == 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
"certificate chain not found for alias: " + certAlias);
|
||||
}
|
||||
KeyStore filtered = KeyStore.getInstance("PKCS12");
|
||||
filtered.load(null);
|
||||
char[] entryPassword = password != null ? password.toCharArray() : new char[0];
|
||||
filtered.setKeyEntry(certAlias, privateKey, entryPassword, chain);
|
||||
return new KeyStoreSelection(filtered, entryPassword);
|
||||
}
|
||||
|
||||
private PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password)
|
||||
throws IOException, OperatorCreationException, PKCSException {
|
||||
try (PEMParser pemParser =
|
||||
@@ -410,4 +503,6 @@ public class CertSignController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record KeyStoreSelection(KeyStore keystore, char[] password) {}
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package stirling.software.SPDF.model.api.security;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CertStoreEntriesRequest {
|
||||
|
||||
@Schema(
|
||||
description = "The type of certificate store to query",
|
||||
allowableValues = {"WINDOWS_STORE", "MAC_KEYCHAIN", "PKCS11"},
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String certType;
|
||||
|
||||
@Schema(description = "PKCS11 configuration file for hardware-backed certificates")
|
||||
private MultipartFile pkcs11ConfigFile;
|
||||
|
||||
@Schema(description = "The password or PIN for the certificate store", format = "password")
|
||||
private String password;
|
||||
}
|
||||
+19
-1
@@ -15,7 +15,16 @@ public class SignPDFWithCertRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The type of the digital certificate",
|
||||
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER"},
|
||||
allowableValues = {
|
||||
"PEM",
|
||||
"PKCS12",
|
||||
"PFX",
|
||||
"JKS",
|
||||
"SERVER",
|
||||
"WINDOWS_STORE",
|
||||
"MAC_KEYCHAIN",
|
||||
"PKCS11"
|
||||
},
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String certType;
|
||||
|
||||
@@ -42,6 +51,15 @@ public class SignPDFWithCertRequest extends PDFFile {
|
||||
@Schema(description = "The password for the keystore or the private key", format = "password")
|
||||
private String password;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"The alias of the certificate to use when loading from OS keystores or"
|
||||
+ " PKCS11 tokens")
|
||||
private String certAlias;
|
||||
|
||||
@Schema(description = "PKCS11 configuration file for hardware-backed certificates")
|
||||
private MultipartFile pkcs11ConfigFile;
|
||||
|
||||
@Schema(
|
||||
description = "Whether to visually show the signature in the PDF file",
|
||||
defaultValue = "false",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package stirling.software.SPDF.util;
|
||||
|
||||
public final class DesktopModeUtils {
|
||||
|
||||
private DesktopModeUtils() {}
|
||||
|
||||
public static boolean isDesktopMode() {
|
||||
return Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))
|
||||
|| Boolean.parseBoolean(
|
||||
System.getenv().getOrDefault("STIRLING_PDF_TAURI_MODE", "false"))
|
||||
|| Boolean.parseBoolean(System.getenv().getOrDefault("STIRLING_DESKTOP", "false"))
|
||||
|| Boolean.parseBoolean(System.getenv().getOrDefault("VITE_DESKTOP", "false"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package stirling.software.SPDF.util;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.security.Provider;
|
||||
import java.security.Security;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
public final class Pkcs11ProviderLoader {
|
||||
|
||||
private Pkcs11ProviderLoader() {}
|
||||
|
||||
public static Provider loadProvider(MultipartFile configFile) throws IOException {
|
||||
Provider baseProvider = Security.getProvider("SunPKCS11");
|
||||
if (baseProvider == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
"SunPKCS11 provider is not available in this JVM");
|
||||
}
|
||||
|
||||
File tempFile = File.createTempFile("spdf-pkcs11", ".cfg");
|
||||
tempFile.deleteOnExit();
|
||||
try {
|
||||
FileUtils.copyInputStreamToFile(configFile.getInputStream(), tempFile);
|
||||
Provider provider = baseProvider.configure(tempFile.getAbsolutePath());
|
||||
Provider existingProvider = Security.getProvider(provider.getName());
|
||||
if (existingProvider != null) {
|
||||
return existingProvider;
|
||||
}
|
||||
Security.addProvider(provider);
|
||||
return provider;
|
||||
} catch (IOException e) {
|
||||
if (tempFile.exists()) {
|
||||
tempFile.delete();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2655,6 +2655,7 @@ chooseJksFile = "Choose JKS File"
|
||||
chooseP12File = "Choose PKCS12 File"
|
||||
choosePfxFile = "Choose PFX File"
|
||||
choosePrivateKey = "Choose Private Key File"
|
||||
choosePkcs11Config = "Choose PKCS#11 Config File"
|
||||
location = "Location"
|
||||
logoTitle = "Logo"
|
||||
name = "Name"
|
||||
@@ -2663,8 +2664,18 @@ pageNumber = "Page Number"
|
||||
password = "Certificate Password"
|
||||
passwordOptional = "Leave empty if no password"
|
||||
reason = "Reason"
|
||||
refreshStoreCertificates = "Refresh"
|
||||
selectStoreCertificate = "Select a certificate"
|
||||
serverCertMessage = "Using server certificate - no files or password required"
|
||||
showLogo = "Show Logo"
|
||||
storeCertificateExpires = "Expires"
|
||||
storeCertificateIssuer = "Issuer"
|
||||
storeCertificateSubject = "Subject"
|
||||
storeCertificates = "Available Certificates"
|
||||
storeCertificatesError = "Failed to load certificates"
|
||||
loadingStoreCertificates = "Loading certificates..."
|
||||
pin = "Token PIN / Password"
|
||||
pinOptional = "Leave empty if not required"
|
||||
|
||||
[certSign.signMode]
|
||||
stepTitle = "Sign Mode"
|
||||
@@ -2693,6 +2704,11 @@ text = "Need recipient <b>Trusted</b> status? <b>Manual</b>. Need a fast, tamper
|
||||
[certSign.certTypeStep]
|
||||
stepTitle = "Certificate Format"
|
||||
|
||||
[certSign.certType]
|
||||
windowsStore = "Windows Store"
|
||||
macKeychain = "macOS Keychain"
|
||||
pkcs11 = "PKCS#11"
|
||||
|
||||
[certSign.certFiles]
|
||||
stepTitle = "Certificate Files"
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Button, Group, Loader, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters";
|
||||
import FileUploadButton from "@app/components/shared/FileUploadButton";
|
||||
import DropdownListWithFooter from "@app/components/shared/DropdownListWithFooter";
|
||||
import { isDesktopMode as checkDesktopMode } from "@app/utils/isDesktopMode";
|
||||
import { useCertStoreEntries } from "@app/hooks/tools/certSign/useCertStoreEntries";
|
||||
|
||||
interface CertificateFilesSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
@@ -11,6 +14,24 @@ interface CertificateFilesSettingsProps {
|
||||
|
||||
const CertificateFilesSettings = ({ parameters, onParameterChange, disabled = false }: CertificateFilesSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isDesktopMode = checkDesktopMode();
|
||||
const isStoreCertType = ['WINDOWS_STORE', 'MAC_KEYCHAIN', 'PKCS11'].includes(parameters.certType);
|
||||
const autoFetchStoreEntries = parameters.certType === 'WINDOWS_STORE' || parameters.certType === 'MAC_KEYCHAIN';
|
||||
|
||||
const {
|
||||
entries: storeEntries,
|
||||
loading: storeLoading,
|
||||
error: storeError,
|
||||
fetchEntries: refreshStoreEntries,
|
||||
} = useCertStoreEntries({
|
||||
certType: parameters.certType,
|
||||
password: parameters.password,
|
||||
pkcs11ConfigFile: parameters.pkcs11ConfigFile,
|
||||
enabled: isDesktopMode && isStoreCertType,
|
||||
autoFetch: autoFetchStoreEntries,
|
||||
});
|
||||
|
||||
const selectedStoreEntry = storeEntries.find((entry) => entry.alias === parameters.certAlias);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -66,6 +87,89 @@ const CertificateFilesSettings = ({ parameters, onParameterChange, disabled = fa
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDesktopMode && parameters.certType === 'PKCS11' && (
|
||||
<FileUploadButton
|
||||
file={parameters.pkcs11ConfigFile}
|
||||
onChange={(file) => onParameterChange('pkcs11ConfigFile', file || undefined)}
|
||||
accept=".cfg,.conf,.txt"
|
||||
disabled={disabled}
|
||||
placeholder={t('certSign.choosePkcs11Config', 'Choose PKCS#11 Config File')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDesktopMode && isStoreCertType && (
|
||||
<Stack gap="xs">
|
||||
{(parameters.certType === 'PKCS11') && (
|
||||
<TextInput
|
||||
label={t('certSign.pin', 'Token PIN / Password')}
|
||||
placeholder={t('certSign.pinOptional', 'Leave empty if not required')}
|
||||
type="password"
|
||||
value={parameters.password}
|
||||
onChange={(event) => onParameterChange('password', event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('certSign.storeCertificates', 'Available Certificates')}
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => refreshStoreEntries()}
|
||||
disabled={disabled || (parameters.certType === 'PKCS11' && !parameters.pkcs11ConfigFile)}
|
||||
>
|
||||
{t('certSign.refreshStoreCertificates', 'Refresh')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{storeLoading && (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm">
|
||||
{t('certSign.loadingStoreCertificates', 'Loading certificates...')}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!storeLoading && (
|
||||
<DropdownListWithFooter
|
||||
value={parameters.certAlias}
|
||||
onChange={(value) => onParameterChange('certAlias', value as string)}
|
||||
items={storeEntries.map((entry) => ({
|
||||
value: entry.alias,
|
||||
name: `${entry.displayName} (${entry.alias})`,
|
||||
}))}
|
||||
placeholder={t('certSign.selectStoreCertificate', 'Select a certificate')}
|
||||
disabled={disabled || storeEntries.length === 0}
|
||||
searchable={true}
|
||||
maxHeight={260}
|
||||
/>
|
||||
)}
|
||||
|
||||
{storeError && (
|
||||
<Text size="sm" c="red">
|
||||
{t('certSign.storeCertificatesError', 'Failed to load certificates')} ({storeError})
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{selectedStoreEntry && (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.storeCertificateSubject', 'Subject')}: {selectedStoreEntry.subject}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.storeCertificateIssuer', 'Issuer')}: {selectedStoreEntry.issuer}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('certSign.storeCertificateExpires', 'Expires')}: {new Date(selectedStoreEntry.notAfterEpochMs).toLocaleString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{parameters.signMode === 'AUTO' && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t('certSign.serverCertMessage', 'Using server certificate - no files or password required')}
|
||||
@@ -92,4 +196,4 @@ const CertificateFilesSettings = ({ parameters, onParameterChange, disabled = fa
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificateFilesSettings;
|
||||
export default CertificateFilesSettings;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Stack, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters";
|
||||
import { isDesktopMode as checkDesktopMode } from "@app/utils/isDesktopMode";
|
||||
|
||||
interface CertificateFormatSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
@@ -8,6 +10,16 @@ interface CertificateFormatSettingsProps {
|
||||
}
|
||||
|
||||
const CertificateFormatSettings = ({ parameters, onParameterChange, disabled = false }: CertificateFormatSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isDesktopMode = checkDesktopMode();
|
||||
|
||||
const setCertType = (certType: CertSignParameters['certType']) => {
|
||||
onParameterChange('certType', certType);
|
||||
onParameterChange('certAlias', '');
|
||||
if (certType !== 'PKCS11') {
|
||||
onParameterChange('pkcs11ConfigFile', undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -17,7 +29,7 @@ const CertificateFormatSettings = ({ parameters, onParameterChange, disabled = f
|
||||
<Button
|
||||
variant={parameters.certType === 'PKCS12' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'PKCS12' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('certType', 'PKCS12')}
|
||||
onClick={() => setCertType('PKCS12')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
@@ -28,7 +40,7 @@ const CertificateFormatSettings = ({ parameters, onParameterChange, disabled = f
|
||||
<Button
|
||||
variant={parameters.certType === 'PFX' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'PFX' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('certType', 'PFX')}
|
||||
onClick={() => setCertType('PFX')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
@@ -42,7 +54,7 @@ const CertificateFormatSettings = ({ parameters, onParameterChange, disabled = f
|
||||
<Button
|
||||
variant={parameters.certType === 'PEM' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'PEM' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('certType', 'PEM')}
|
||||
onClick={() => setCertType('PEM')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
@@ -53,7 +65,7 @@ const CertificateFormatSettings = ({ parameters, onParameterChange, disabled = f
|
||||
<Button
|
||||
variant={parameters.certType === 'JKS' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'JKS' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('certType', 'JKS')}
|
||||
onClick={() => setCertType('JKS')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
@@ -62,9 +74,46 @@ const CertificateFormatSettings = ({ parameters, onParameterChange, disabled = f
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
{isDesktopMode && (
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={parameters.certType === 'WINDOWS_STORE' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'WINDOWS_STORE' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => setCertType('WINDOWS_STORE')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('certSign.certType.windowsStore', 'Windows Store')}
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.certType === 'MAC_KEYCHAIN' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'MAC_KEYCHAIN' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => setCertType('MAC_KEYCHAIN')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('certSign.certType.macKeychain', 'macOS Keychain')}
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.certType === 'PKCS11' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'PKCS11' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => setCertType('PKCS11')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('certSign.certType.pkcs11', 'PKCS#11')}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificateFormatSettings;
|
||||
export default CertificateFormatSettings;
|
||||
|
||||
@@ -14,6 +14,9 @@ export const buildCertSignFormData = (parameters: CertSignParameters, file: File
|
||||
} else {
|
||||
formData.append('certType', parameters.certType);
|
||||
formData.append('password', parameters.password);
|
||||
if (['WINDOWS_STORE', 'MAC_KEYCHAIN', 'PKCS11'].includes(parameters.certType) && parameters.certAlias) {
|
||||
formData.append('certAlias', parameters.certAlias);
|
||||
}
|
||||
|
||||
// Add certificate files based on type (only for manual mode)
|
||||
switch (parameters.certType) {
|
||||
@@ -30,11 +33,21 @@ export const buildCertSignFormData = (parameters: CertSignParameters, file: File
|
||||
formData.append('p12File', parameters.p12File);
|
||||
}
|
||||
break;
|
||||
case 'PFX':
|
||||
if (parameters.p12File) {
|
||||
formData.append('p12File', parameters.p12File);
|
||||
}
|
||||
break;
|
||||
case 'JKS':
|
||||
if (parameters.jksFile) {
|
||||
formData.append('jksFile', parameters.jksFile);
|
||||
}
|
||||
break;
|
||||
case 'PKCS11':
|
||||
if (parameters.pkcs11ConfigFile) {
|
||||
formData.append('pkcs11ConfigFile', parameters.pkcs11ConfigFile);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +81,4 @@ export const useCertSignOperation = () => {
|
||||
...certSignOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('certSign.error.failed', 'An error occurred while processing signatures.'))
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,12 +5,14 @@ export interface CertSignParameters extends BaseParameters {
|
||||
// Sign mode selection
|
||||
signMode: 'MANUAL' | 'AUTO';
|
||||
// Certificate signing options (only for manual mode)
|
||||
certType: '' | 'PEM' | 'PKCS12' | 'PFX' | 'JKS';
|
||||
certType: '' | 'PEM' | 'PKCS12' | 'PFX' | 'JKS' | 'WINDOWS_STORE' | 'MAC_KEYCHAIN' | 'PKCS11';
|
||||
privateKeyFile?: File;
|
||||
certFile?: File;
|
||||
p12File?: File;
|
||||
jksFile?: File;
|
||||
password: string;
|
||||
certAlias: string;
|
||||
pkcs11ConfigFile?: File;
|
||||
|
||||
// Signature appearance options
|
||||
showSignature: boolean;
|
||||
@@ -25,6 +27,7 @@ export const defaultParameters: CertSignParameters = {
|
||||
signMode: 'MANUAL',
|
||||
certType: '',
|
||||
password: '',
|
||||
certAlias: '',
|
||||
showSignature: false,
|
||||
reason: '',
|
||||
location: '',
|
||||
@@ -59,9 +62,14 @@ export const useCertSignParameters = (): CertSignParametersHook => {
|
||||
return !!params.p12File;
|
||||
case 'JKS':
|
||||
return !!params.jksFile;
|
||||
case 'WINDOWS_STORE':
|
||||
case 'MAC_KEYCHAIN':
|
||||
return !!params.certAlias;
|
||||
case 'PKCS11':
|
||||
return !!(params.certAlias && params.pkcs11ConfigFile);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export interface CertificateStoreEntry {
|
||||
alias: string;
|
||||
displayName: string;
|
||||
subject: string;
|
||||
issuer: string;
|
||||
serialNumber: string;
|
||||
notBefore: string;
|
||||
notAfter: string;
|
||||
notBeforeEpochMs: number;
|
||||
notAfterEpochMs: number;
|
||||
}
|
||||
|
||||
interface CertificateStoreEntriesResponse {
|
||||
entries: CertificateStoreEntry[];
|
||||
}
|
||||
|
||||
interface UseCertStoreEntriesOptions {
|
||||
certType: string;
|
||||
password: string;
|
||||
pkcs11ConfigFile?: File;
|
||||
enabled: boolean;
|
||||
autoFetch?: boolean;
|
||||
}
|
||||
|
||||
export const useCertStoreEntries = ({
|
||||
certType,
|
||||
password,
|
||||
pkcs11ConfigFile,
|
||||
enabled,
|
||||
autoFetch = true,
|
||||
}: UseCertStoreEntriesOptions) => {
|
||||
const [entries, setEntries] = useState<CertificateStoreEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchEntries = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
if (!certType) {
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
if (certType === 'PKCS11' && !pkcs11ConfigFile) {
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const formData = new FormData();
|
||||
formData.append('certType', certType);
|
||||
if (password) {
|
||||
formData.append('password', password);
|
||||
}
|
||||
if (pkcs11ConfigFile) {
|
||||
formData.append('pkcs11ConfigFile', pkcs11ConfigFile);
|
||||
}
|
||||
|
||||
const response = await apiClient.post<CertificateStoreEntriesResponse>(
|
||||
'/api/v1/ui-data/cert-store-entries',
|
||||
formData
|
||||
);
|
||||
setEntries(response.data.entries ?? []);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [certType, enabled, password, pkcs11ConfigFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFetch) {
|
||||
return;
|
||||
}
|
||||
void fetchEntries();
|
||||
}, [autoFetch, fetchEntries]);
|
||||
|
||||
return {
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
fetchEntries,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export const isDesktopMode = (): boolean =>
|
||||
import.meta.env.MODE === 'desktop'
|
||||
|| import.meta.env.VITE_DESKTOP === 'true'
|
||||
|| import.meta.env.STIRLING_DESKTOP === 'true'
|
||||
|| import.meta.env.STIRLING_PDF_TAURI_MODE === 'true';
|
||||
Reference in New Issue
Block a user