Compare commits

...
13 changed files with 643 additions and 13 deletions
@@ -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;
@@ -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) {}
}
@@ -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;
}
@@ -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,
};
};
+5
View File
@@ -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';