detectedLibraries) {
+
+ /** A PKCS#11 driver library detected on disk (or supplied via configuration). */
+ public record Pkcs11LibraryInfo(String name, String path) {}
+}
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/Pkcs11CertificatesRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/Pkcs11CertificatesRequest.java
new file mode 100644
index 0000000000..d80fd55557
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/Pkcs11CertificatesRequest.java
@@ -0,0 +1,7 @@
+package stirling.software.SPDF.model.api.security;
+
+/**
+ * Request body for enumerating the certificates on a PKCS#11 token. The PIN is required to log into
+ * the token; it is used only for the duration of the call and never stored.
+ */
+public record Pkcs11CertificatesRequest(String libraryPath, Integer slot, String pin) {}
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java
index 9b063d19fd..144f516d3a 100644
--- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java
@@ -14,8 +14,10 @@ import stirling.software.common.model.api.PDFFile;
public class SignPDFWithCertRequest extends PDFFile {
@Schema(
- description = "The type of the digital certificate",
- allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER"},
+ description =
+ "The type of the digital certificate. WINDOWS_STORE and PKCS11 are"
+ + " hardware-backed and only available in the desktop app.",
+ allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER", "WINDOWS_STORE", "PKCS11"},
requiredMode = Schema.RequiredMode.REQUIRED)
private String certType;
@@ -39,9 +41,31 @@ public class SignPDFWithCertRequest extends PDFFile {
@Schema(description = "The JKS keystore file (Java Key Store)")
private MultipartFile jksFile;
- @Schema(description = "The password for the keystore or the private key", format = "password")
+ @Schema(
+ description =
+ "The password for the keystore / private key, or the token PIN for PKCS11",
+ format = "password")
private String password;
+ @Schema(
+ description =
+ "The alias of the certificate to sign with. Required for WINDOWS_STORE and"
+ + " recommended for PKCS11 tokens holding multiple certificates.")
+ private String alias;
+
+ @Schema(
+ description =
+ "Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must"
+ + " be an allowed driver - a detected one or configured via"
+ + " STIRLING_PKCS11_LIBRARIES.")
+ private String pkcs11LibraryPath;
+
+ @Schema(
+ description =
+ "Optional PKCS#11 slot index. When omitted the first slot with a token is"
+ + " used.")
+ private Integer pkcs11Slot;
+
@Schema(
description = "Whether to visually show the signature in the PDF file",
defaultValue = "false",
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java
index b45aeefc38..5be131754e 100644
--- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java
@@ -18,6 +18,11 @@ public class SignatureValidationResult {
// Time validation
private boolean notExpired;
+ // Whether the document's signatures cover all of its bytes. False when content was appended
+ // outside every signature's ByteRange (i.e. added after signing), which the signature can't
+ // attest to even though the signed bytes themselves remain cryptographically intact.
+ private boolean coversEntireDocument = true;
+
// Revocation validation
private boolean revocationChecked; // true if PKIX revocation was enabled
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
diff --git a/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java b/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java
index 1167a5bd5e..44ed413584 100644
--- a/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java
+++ b/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java
@@ -115,7 +115,8 @@ public class CertificateValidationService {
log.info("Enabled AIA certificate fetching and revocation checking");
}
- // Trust only what we explicitly opt into:
+ // Trust only what we explicitly opt into. Desktop follows the same flags as the server -
+ // our own signing cert is trusted via serverAsAnchor, not by force-loading every system CA.
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
diff --git a/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java b/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java
new file mode 100644
index 0000000000..988b935f27
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java
@@ -0,0 +1,483 @@
+package stirling.software.SPDF.service;
+
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.security.Provider;
+import java.security.Security;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import javax.security.auth.x500.X500Principal;
+
+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.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
+import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
+import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities.Pkcs11LibraryInfo;
+import stirling.software.common.util.ExceptionUtils;
+
+/**
+ * Bridges PDF signing to hardware-held keys: the Windows certificate store (via the JDK SunMSCAPI
+ * provider) and USB / smart-card PKCS#11 tokens (via SunPKCS11). The private key never leaves the
+ * token - the JCA routes the actual signing operation onto the hardware.
+ *
+ * These code paths are gated to the desktop bundle. On a hosted server the backend cannot reach
+ * a remote user's USB token anyway, and loading an arbitrary PKCS#11 driver library is effectively
+ * native code execution, so PKCS#11 libraries are additionally restricted to an allowlist of
+ * detected / configured driver paths.
+ */
+@Service
+@Slf4j
+public class HardwareKeyStoreService {
+
+ public static final String SOURCE_WINDOWS_STORE = "WINDOWS_STORE";
+ public static final String SOURCE_PKCS11 = "PKCS11";
+
+ private static final String WINDOWS_KEYSTORE_TYPE = "Windows-MY";
+ private static final String MSCAPI_PROVIDER = "SunMSCAPI";
+ private static final String PKCS11_BASE_PROVIDER = "SunPKCS11";
+
+ /** Extra PKCS#11 driver libraries, absolute paths, comma/`File.pathSeparator` separated. */
+ private static final String PKCS11_LIBRARIES_ENV = "STIRLING_PKCS11_LIBRARIES";
+
+ /** Same as {@link #PKCS11_LIBRARIES_ENV} but as a JVM system property. */
+ private static final String PKCS11_LIBRARIES_PROP = "stirling.pkcs11.libraries";
+
+ private final String machineType;
+
+ public HardwareKeyStoreService(
+ @Autowired(required = false) @Qualifier("machineType") String machineType) {
+ this.machineType = machineType;
+ }
+
+ // ---------------------------------------------------------------------
+ // Gating
+ // ---------------------------------------------------------------------
+
+ /**
+ * True when running as the desktop bundle (local sidecar in the user's session). The Tauri
+ * bundle sets {@code STIRLING_PDF_TAURI_MODE=true} (with {@code BROWSER_OPEN=false}, so
+ * machineType is {@code Server-jar} there); the bare-jar desktop launcher instead yields a
+ * {@code Client-*} machineType. Accept either.
+ */
+ public boolean isDesktop() {
+ if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
+ return true;
+ }
+ return machineType != null && machineType.startsWith("Client-");
+ }
+
+ public boolean isWindows() {
+ return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win");
+ }
+
+ private boolean windowsStoreSupported() {
+ return isWindows() && Security.getProvider(MSCAPI_PROVIDER) != null;
+ }
+
+ private boolean pkcs11Supported() {
+ return Security.getProvider(PKCS11_BASE_PROVIDER) != null;
+ }
+
+ /** Reject anything that is not the desktop bundle reached over loopback. */
+ public void assertLocalDesktop(HttpServletRequest request) {
+ if (!isDesktop()) {
+ throw ExceptionUtils.createIllegalArgumentException(
+ "error.hardwareSigningDesktopOnly",
+ "Hardware-backed signing is only available in the Stirling PDF desktop app");
+ }
+ if (request != null && !isLocalRequest(request.getRemoteAddr())) {
+ throw ExceptionUtils.createIllegalArgumentException(
+ "error.hardwareSigningLocalOnly",
+ "Hardware-backed signing can only be used from this device");
+ }
+ }
+
+ /**
+ * True when the request originates from this machine. Loopback (incl. IPv4-mapped IPv6 like
+ * {@code ::ffff:127.0.0.1}) counts, as does any address bound to a local interface - so it
+ * works whether the desktop app reaches the sidecar over {@code localhost} or a LAN IP, while
+ * still rejecting other machines on the network.
+ */
+ static boolean isLocalRequest(String remoteAddr) {
+ if (remoteAddr == null || remoteAddr.isBlank()) {
+ return false;
+ }
+ try {
+ InetAddress addr = InetAddress.getByName(remoteAddr);
+ if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()) {
+ return true;
+ }
+ return NetworkInterface.networkInterfaces()
+ .anyMatch(nif -> nif.inetAddresses().anyMatch(local -> local.equals(addr)));
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Capabilities
+ // ---------------------------------------------------------------------
+
+ public HardwareSigningCapabilities capabilities() {
+ boolean desktop = isDesktop();
+ if (!desktop) {
+ return new HardwareSigningCapabilities(false, "", false, false, List.of());
+ }
+ return new HardwareSigningCapabilities(
+ true,
+ System.getProperty("os.name", ""),
+ windowsStoreSupported(),
+ pkcs11Supported(),
+ detectPkcs11Libraries());
+ }
+
+ /**
+ * Known driver install locations plus any paths configured via {@code
+ * STIRLING_PKCS11_LIBRARIES}.
+ */
+ public List detectPkcs11Libraries() {
+ Map> candidates = new LinkedHashMap<>();
+ String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
+
+ if (os.contains("win")) {
+ candidates.put(
+ "OpenSC",
+ List.of(
+ "C:\\Program Files\\OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll"));
+ candidates.put(
+ "YubiKey (ykcs11)",
+ List.of("C:\\Program Files\\Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll"));
+ candidates.put("SafeNet eToken", List.of("C:\\Windows\\System32\\eTPKCS11.dll"));
+ candidates.put(
+ "Thales/Gemalto IDPrime", List.of("C:\\Windows\\System32\\IDPrimePKCS11.dll"));
+ candidates.put(
+ "SoftHSM2",
+ List.of(
+ "C:\\Program Files\\SoftHSM2\\lib\\softhsm2-x64.dll",
+ "C:\\SoftHSM2\\lib\\softhsm2-x64.dll"));
+ } else if (os.contains("mac")) {
+ candidates.put(
+ "OpenSC",
+ List.of(
+ "/Library/OpenSC/lib/opensc-pkcs11.so",
+ "/usr/local/lib/opensc-pkcs11.so"));
+ candidates.put(
+ "YubiKey (ykcs11)",
+ List.of("/usr/local/lib/libykcs11.dylib", "/opt/homebrew/lib/libykcs11.dylib"));
+ candidates.put(
+ "SoftHSM2",
+ List.of(
+ "/usr/local/lib/softhsm/libsofthsm2.so",
+ "/opt/homebrew/lib/softhsm/libsofthsm2.so"));
+ } else {
+ candidates.put(
+ "OpenSC",
+ List.of(
+ "/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so",
+ "/usr/lib/opensc-pkcs11.so",
+ "/usr/lib64/opensc-pkcs11.so"));
+ candidates.put(
+ "YubiKey (ykcs11)",
+ List.of(
+ "/usr/lib/x86_64-linux-gnu/libykcs11.so",
+ "/usr/local/lib/libykcs11.so"));
+ candidates.put(
+ "SoftHSM2",
+ List.of(
+ "/usr/lib/softhsm/libsofthsm2.so",
+ "/usr/lib64/softhsm/libsofthsm2.so",
+ "/usr/local/lib/softhsm/libsofthsm2.so"));
+ }
+
+ List result = new ArrayList<>();
+ candidates.forEach(
+ (name, paths) ->
+ paths.stream()
+ .filter(p -> Files.exists(Path.of(p)))
+ .findFirst()
+ .ifPresent(p -> result.add(new Pkcs11LibraryInfo(name, p))));
+
+ for (String configured : configuredLibraries()) {
+ if (Files.exists(Path.of(configured))
+ && result.stream().noneMatch(l -> sameFile(l.path(), configured))) {
+ result.add(new Pkcs11LibraryInfo(fileName(configured), configured));
+ }
+ }
+ return result;
+ }
+
+ private static List configuredLibraries() {
+ String env = System.getenv(PKCS11_LIBRARIES_ENV);
+ String prop = System.getProperty(PKCS11_LIBRARIES_PROP);
+ StringBuilder combined = new StringBuilder();
+ if (env != null && !env.isBlank()) {
+ combined.append(env);
+ }
+ if (prop != null && !prop.isBlank()) {
+ if (combined.length() > 0) {
+ combined.append(java.io.File.pathSeparator);
+ }
+ combined.append(prop);
+ }
+ if (combined.length() == 0) {
+ return List.of();
+ }
+ return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]"))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .toList();
+ }
+
+ // ---------------------------------------------------------------------
+ // Windows certificate store
+ // ---------------------------------------------------------------------
+
+ public KeyStore loadWindowsKeyStore() throws Exception {
+ if (!windowsStoreSupported()) {
+ throw ExceptionUtils.createIllegalArgumentException(
+ "error.windowsStoreUnavailable",
+ "The Windows certificate store is not available on this platform");
+ }
+ KeyStore ks = KeyStore.getInstance(WINDOWS_KEYSTORE_TYPE, MSCAPI_PROVIDER);
+ ks.load(null, null);
+ return ks;
+ }
+
+ public Provider windowsProvider() {
+ return Security.getProvider(MSCAPI_PROVIDER);
+ }
+
+ public List listWindowsCertificates() throws Exception {
+ return listSigningCertificates(loadWindowsKeyStore(), SOURCE_WINDOWS_STORE);
+ }
+
+ // ---------------------------------------------------------------------
+ // PKCS#11 tokens
+ // ---------------------------------------------------------------------
+
+ /**
+ * A configured, logged-in PKCS#11 keystore plus the provider that must service signing. Closing
+ * logs the session out so the PIN-authenticated session does not outlive the request. The
+ * provider stays cached (logout is C_Logout, not C_Finalize) so the next call reuses the same
+ * C_Initialize. Single-user desktop model - logout is best-effort.
+ */
+ public record Pkcs11Session(KeyStore keyStore, Provider provider) implements AutoCloseable {
+ @Override
+ public void close() {
+ if (provider instanceof java.security.AuthProvider authProvider) {
+ try {
+ authProvider.logout();
+ } catch (Exception e) {
+ // Not logged in / already logged out - nothing to clear.
+ }
+ }
+ }
+ }
+
+ // One SunPKCS11 provider per driver+slot, reused across enumerate + sign. A PKCS#11 module
+ // typically allows C_Initialize only once per process, so configuring a fresh provider on every
+ // call races with the previous (not-yet-GC'd) one - the cause of "first sign fails, second
+ // works". Reusing the provider keeps a single C_Initialize alive for the session.
+ private final java.util.concurrent.ConcurrentHashMap pkcs11Providers =
+ new java.util.concurrent.ConcurrentHashMap<>();
+
+ public Pkcs11Session openPkcs11(String libraryPath, Integer slot, char[] pin) throws Exception {
+ validateLibraryAllowed(libraryPath);
+ if (!pkcs11Supported()) {
+ throw ExceptionUtils.createIllegalArgumentException(
+ "error.pkcs11Unavailable", "PKCS#11 support is not available in this runtime");
+ }
+
+ String cacheKey = libraryPath + "|" + slot;
+ Provider provider =
+ pkcs11Providers.computeIfAbsent(
+ cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
+ try {
+ KeyStore ks = KeyStore.getInstance("PKCS11", provider);
+ ks.load(null, pin);
+ return new Pkcs11Session(ks, provider);
+ } catch (Exception e) {
+ // A wrong PIN must not be retried: a second C_Login would burn the token's retry
+ // counter twice per attempt and can lock the token. Only rebuild on provider/init
+ // failures (e.g. token removed/re-inserted leaving a stale provider).
+ if (isAuthFailure(e)) {
+ throw e;
+ }
+ pkcs11Providers.remove(cacheKey, provider);
+ Provider fresh =
+ pkcs11Providers.computeIfAbsent(
+ cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
+ KeyStore ks = KeyStore.getInstance("PKCS11", fresh);
+ ks.load(null, pin);
+ return new Pkcs11Session(ks, fresh);
+ }
+ }
+
+ /** True when the failure is a bad/locked PIN rather than a provider/init/device problem. */
+ private static boolean isAuthFailure(Throwable t) {
+ while (t != null) {
+ if (t instanceof javax.security.auth.login.FailedLoginException) {
+ return true;
+ }
+ String msg = t.getMessage();
+ if (msg != null && msg.toUpperCase(Locale.ROOT).contains("CKR_PIN")) {
+ return true; // CKR_PIN_INCORRECT / CKR_PIN_LOCKED / CKR_PIN_INVALID / ...
+ }
+ t = t.getCause();
+ }
+ return false;
+ }
+
+ private Provider buildPkcs11Provider(String libraryPath, Integer slot) {
+ StringBuilder config = new StringBuilder();
+ config.append("--name=").append(providerName(libraryPath)).append('\n');
+ config.append("library=").append(libraryPath).append('\n');
+ if (slot != null) {
+ config.append("slot=").append(slot).append('\n');
+ }
+ try {
+ return Security.getProvider(PKCS11_BASE_PROVIDER).configure(config.toString());
+ } catch (Exception e) {
+ throw ExceptionUtils.createIllegalArgumentException(
+ "error.pkcs11ConfigFailed",
+ "Failed to initialise the PKCS#11 driver: {0}",
+ e.getMessage());
+ }
+ }
+
+ public List listPkcs11Certificates(
+ String libraryPath, Integer slot, char[] pin) throws Exception {
+ try (Pkcs11Session session = openPkcs11(libraryPath, slot, pin)) {
+ return listSigningCertificates(session.keyStore(), SOURCE_PKCS11);
+ }
+ }
+
+ /**
+ * Reject driver paths that are not detected on disk / configured - blocks arbitrary DLL loads.
+ */
+ public void validateLibraryAllowed(String libraryPath) {
+ if (libraryPath == null || libraryPath.isBlank()) {
+ throw ExceptionUtils.createIllegalArgumentException(
+ "error.pkcs11LibraryRequired", "A PKCS#11 driver library path is required");
+ }
+ Set allowed =
+ detectPkcs11Libraries().stream()
+ .map(Pkcs11LibraryInfo::path)
+ .collect(Collectors.toSet());
+ boolean ok = allowed.stream().anyMatch(p -> sameFile(p, libraryPath));
+ if (!ok) {
+ throw ExceptionUtils.createIllegalArgumentException(
+ "error.pkcs11LibraryNotAllowed",
+ "PKCS#11 driver is not in the allowed list. Add it via the"
+ + " STIRLING_PKCS11_LIBRARIES setting: {0}",
+ libraryPath);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Shared helpers
+ // ---------------------------------------------------------------------
+
+ private List listSigningCertificates(KeyStore ks, String source)
+ throws Exception {
+ List certs = new ArrayList<>();
+ Enumeration aliases = ks.aliases();
+ while (aliases.hasMoreElements()) {
+ String alias = aliases.nextElement();
+ if (!ks.isKeyEntry(alias)) {
+ continue; // only entries we can sign with
+ }
+ Certificate cert = ks.getCertificate(alias);
+ if (cert instanceof X509Certificate x509) {
+ certs.add(toInfo(alias, x509, source));
+ }
+ }
+ return certs;
+ }
+
+ private static HardwareCertificateInfo toInfo(
+ String alias, X509Certificate cert, String source) {
+ java.util.Date now = new java.util.Date();
+ return new HardwareCertificateInfo(
+ alias,
+ source,
+ cert.getSubjectX500Principal().getName(),
+ cert.getIssuerX500Principal().getName(),
+ commonName(cert.getSubjectX500Principal()),
+ commonName(cert.getIssuerX500Principal()),
+ cert.getSerialNumber().toString(16),
+ cert.getPublicKey().getAlgorithm(),
+ cert.getNotBefore().toInstant().toString(),
+ cert.getNotAfter().toInstant().toString(),
+ now.after(cert.getNotAfter()),
+ now.before(cert.getNotBefore()));
+ }
+
+ private static String commonName(X500Principal principal) {
+ try {
+ X500Name x500Name = new X500Name(principal.getName());
+ RDN[] rdns = x500Name.getRDNs(BCStyle.CN);
+ if (rdns.length > 0) {
+ return IETFUtils.valueToString(rdns[0].getFirst().getValue());
+ }
+ } catch (Exception e) {
+ log.debug("Could not parse common name from {}", principal.getName());
+ }
+ return principal.getName();
+ }
+
+ private static String providerName(String libraryPath) {
+ String base = fileName(libraryPath).replaceAll("[^a-zA-Z0-9]", "");
+ if (base.isEmpty()) {
+ base = "token";
+ }
+ return "StirlingHW" + base;
+ }
+
+ private static String fileName(String path) {
+ try {
+ return Path.of(path).getFileName().toString();
+ } catch (Exception e) {
+ return path;
+ }
+ }
+
+ private static boolean sameFile(String a, String b) {
+ if (a == null || b == null) {
+ return false;
+ }
+ try {
+ Path pa = Path.of(a);
+ Path pb = Path.of(b);
+ if (Files.exists(pa) && Files.exists(pb)) {
+ return Files.isSameFile(pa, pb);
+ }
+ return pa.toAbsolutePath().normalize().equals(pb.toAbsolutePath().normalize());
+ } catch (Exception e) {
+ return a.equalsIgnoreCase(b);
+ }
+ }
+}
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java
index 2050e3c53c..5194ae55ec 100644
--- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java
+++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java
@@ -30,7 +30,10 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
+import jakarta.servlet.http.HttpServletRequest;
+
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
+import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -51,6 +54,8 @@ class CertSignControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
+ @Mock private HardwareKeyStoreService hardwareKeyStoreService;
+ @Mock private HttpServletRequest httpRequest;
@InjectMocks private CertSignController certSignController;
@@ -169,7 +174,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
- ResponseEntity response = certSignController.signPDFWithCert(request);
+ ResponseEntity response =
+ certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -195,7 +201,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
- ResponseEntity response = certSignController.signPDFWithCert(request);
+ ResponseEntity response =
+ certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -221,7 +228,7 @@ class CertSignControllerTest {
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
- () -> certSignController.signPDFWithCert(request));
+ () -> certSignController.signPDFWithCert(request, httpRequest));
assertTrue(exception.getMessage().contains("PKCS12 keystore"));
}
@@ -247,7 +254,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
- ResponseEntity response = certSignController.signPDFWithCert(request);
+ ResponseEntity response =
+ certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -278,7 +286,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
- ResponseEntity response = certSignController.signPDFWithCert(request);
+ ResponseEntity response =
+ certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -309,7 +318,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
- ResponseEntity response = certSignController.signPDFWithCert(request);
+ ResponseEntity response =
+ certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -340,7 +350,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
- ResponseEntity response = certSignController.signPDFWithCert(request);
+ ResponseEntity response =
+ certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -371,7 +382,8 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
- ResponseEntity response = certSignController.signPDFWithCert(request);
+ ResponseEntity response =
+ certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
diff --git a/app/core/src/test/java/stirling/software/SPDF/service/HardwareKeyStoreServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/HardwareKeyStoreServiceTest.java
new file mode 100644
index 0000000000..50024a4274
--- /dev/null
+++ b/app/core/src/test/java/stirling/software/SPDF/service/HardwareKeyStoreServiceTest.java
@@ -0,0 +1,146 @@
+package stirling.software.SPDF.service;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
+
+/** Unit tests for the gating / allowlist logic that protects the hardware signing paths. */
+class HardwareKeyStoreServiceTest {
+
+ private static final String PKCS11_PROP = "stirling.pkcs11.libraries";
+
+ private HardwareKeyStoreService service(String machineType) {
+ return new HardwareKeyStoreService(machineType);
+ }
+
+ @Test
+ void isDesktop_trueOnlyForClientMachineTypes() {
+ assertTrue(service("Client-windows").isDesktop());
+ assertTrue(service("Client-mac").isDesktop());
+ assertTrue(service("Client-unix").isDesktop());
+ assertFalse(service("Server-jar").isDesktop());
+ assertFalse(service("Docker").isDesktop());
+ assertFalse(service(null).isDesktop());
+ }
+
+ @Test
+ void isDesktop_trueInTauriModeEvenWithoutClientMachineType() {
+ // The Tauri bundle sets STIRLING_PDF_TAURI_MODE=true while machineType stays Server-jar.
+ String previous = System.getProperty("STIRLING_PDF_TAURI_MODE");
+ try {
+ System.setProperty("STIRLING_PDF_TAURI_MODE", "true");
+ assertTrue(service("Server-jar").isDesktop());
+ assertTrue(service(null).isDesktop());
+ } finally {
+ if (previous == null) {
+ System.clearProperty("STIRLING_PDF_TAURI_MODE");
+ } else {
+ System.setProperty("STIRLING_PDF_TAURI_MODE", previous);
+ }
+ }
+ }
+
+ @Test
+ void capabilities_notDesktop_reportsUnavailable() {
+ HardwareSigningCapabilities caps = service("Server-jar").capabilities();
+ assertFalse(caps.desktop());
+ assertFalse(caps.windowsStoreSupported());
+ assertFalse(caps.pkcs11Supported());
+ assertTrue(caps.detectedLibraries().isEmpty());
+ }
+
+ @Test
+ void capabilities_desktop_reportsOsName() {
+ HardwareSigningCapabilities caps = service("Client-windows").capabilities();
+ assertTrue(caps.desktop());
+ assertFalse(caps.osName().isBlank());
+ }
+
+ @Test
+ void assertLocalDesktop_rejectsNonDesktop() {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getRemoteAddr()).thenReturn("127.0.0.1");
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> service("Server-jar").assertLocalDesktop(request));
+ }
+
+ @Test
+ void assertLocalDesktop_rejectsRemoteCallerEvenOnDesktop() {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ // 203.0.113.0/24 is TEST-NET-3 (RFC 5737) - never a real local interface address.
+ when(request.getRemoteAddr()).thenReturn("203.0.113.5");
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> service("Client-windows").assertLocalDesktop(request));
+ }
+
+ @Test
+ void assertLocalDesktop_allowsLoopbackOnDesktop() {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getRemoteAddr()).thenReturn("127.0.0.1");
+ assertDoesNotThrow(() -> service("Client-windows").assertLocalDesktop(request));
+ // No servlet context (e.g. internal call) is also allowed.
+ assertDoesNotThrow(() -> service("Client-windows").assertLocalDesktop(null));
+ }
+
+ @Test
+ void isLocalRequest_acceptsLoopbackForms_rejectsRemote() {
+ assertTrue(HardwareKeyStoreService.isLocalRequest("127.0.0.1"));
+ assertTrue(HardwareKeyStoreService.isLocalRequest("::1"));
+ assertTrue(HardwareKeyStoreService.isLocalRequest("0:0:0:0:0:0:0:1"));
+ // IPv4-mapped IPv6 loopback - what Tomcat reports for the desktop webview.
+ assertTrue(HardwareKeyStoreService.isLocalRequest("::ffff:127.0.0.1"));
+ assertFalse(HardwareKeyStoreService.isLocalRequest("203.0.113.5"));
+ assertFalse(HardwareKeyStoreService.isLocalRequest(null));
+ }
+
+ @Test
+ void validateLibraryAllowed_blankPath_throws() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> service("Client-windows").validateLibraryAllowed(" "));
+ }
+
+ @Test
+ void validateLibraryAllowed_unknownPath_throws() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ service("Client-windows")
+ .validateLibraryAllowed("/definitely/not/a/real/driver.so"));
+ }
+
+ @Test
+ void validateLibraryAllowed_configuredPath_isAllowed(@TempDir Path tempDir) throws Exception {
+ Path fakeDriver = Files.createFile(tempDir.resolve("fake-pkcs11.so"));
+ String previous = System.getProperty(PKCS11_PROP);
+ try {
+ System.setProperty(PKCS11_PROP, fakeDriver.toString());
+ HardwareKeyStoreService service = service("Client-windows");
+ assertDoesNotThrow(() -> service.validateLibraryAllowed(fakeDriver.toString()));
+ assertTrue(
+ service.detectPkcs11Libraries().stream()
+ .anyMatch(l -> l.path().equals(fakeDriver.toString())));
+ } finally {
+ if (previous == null) {
+ System.clearProperty(PKCS11_PROP);
+ } else {
+ System.setProperty(PKCS11_PROP, previous);
+ }
+ }
+ }
+}
diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py
index e470eb1908..3e179eaf0c 100644
--- a/engine/src/stirling/models/tool_models.py
+++ b/engine/src/stirling/models/tool_models.py
@@ -314,7 +314,7 @@ class CbzToPdfParams(ApiModel):
class CertType(StrEnum):
"""
- The type of the digital certificate
+ The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.
"""
pem = "PEM"
@@ -322,17 +322,35 @@ class CertType(StrEnum):
pfx = "PFX"
jks = "JKS"
server = "SERVER"
+ windows_store = "WINDOWS_STORE"
+ pkcs11 = "PKCS11"
class CertSignParams(ApiModel):
- cert_type: CertType = Field(..., description="The type of the digital certificate")
+ alias: str | None = Field(
+ None,
+ description="The alias of the certificate to sign with. Required for WINDOWS_STORE and recommended for PKCS11 tokens holding multiple certificates.",
+ )
+ cert_type: CertType = Field(
+ ...,
+ description="The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.",
+ )
location: str = Field("SPDF", description="The location where the PDF is signed")
name: str = Field("SPDF", description="The name of the signer")
page_number: int = Field(
1,
description="The page number where the signature should be visible. This is required if showSignature is set to true",
)
- password: SecretStr | None = Field(None, description="The password for the keystore or the private key")
+ password: SecretStr | None = Field(
+ None, description="The password for the keystore / private key, or the token PIN for PKCS11"
+ )
+ pkcs11_library_path: str | None = Field(
+ None,
+ description="Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must be an allowed driver - a detected one or configured via STIRLING_PKCS11_LIBRARIES.",
+ )
+ pkcs11_slot: int | None = Field(
+ None, description="Optional PKCS#11 slot index. When omitted the first slot with a token is used."
+ )
reason: str = Field("Signed by SPDF", description="The reason for signing the PDF")
show_logo: bool = Field(True, description="Whether to visually show a signature logo along with the signature")
show_signature: bool = Field(False, description="Whether to visually show the signature in the PDF file")
@@ -961,6 +979,12 @@ class PdfToXlsxParams(ApiModel):
)
+class Pkcs11CertificatesParams(ApiModel):
+ library_path: str | None = None
+ pin: str | None = None
+ slot: int | None = None
+
+
class CustomMode(StrEnum):
"""
The custom mode for page rearrangement. Valid values are:
@@ -1493,6 +1517,7 @@ class Model(
| AddWatermarkParams
| AutoRedactParams
| CertSignParams
+ | Pkcs11CertificatesParams
| SessionsParams
| ValidateCertificateParams
| RedactParams
@@ -1562,6 +1587,7 @@ class Model(
| AddWatermarkParams
| AutoRedactParams
| CertSignParams
+ | Pkcs11CertificatesParams
| SessionsParams
| ValidateCertificateParams
| RedactParams
@@ -1632,6 +1658,7 @@ type ParamToolModel = (
| AddWatermarkParams
| AutoRedactParams
| CertSignParams
+ | Pkcs11CertificatesParams
| SessionsParams
| ValidateCertificateParams
| RedactParams
@@ -1703,6 +1730,7 @@ class ToolEndpoint(StrEnum):
ADD_WATERMARK = "/api/v1/security/add-watermark"
AUTO_REDACT = "/api/v1/security/auto-redact"
CERT_SIGN = "/api/v1/security/cert-sign"
+ PKCS11_CERTIFICATES = "/api/v1/security/cert-sign/hardware/pkcs11-certificates"
SESSIONS = "/api/v1/security/cert-sign/sessions"
VALIDATE_CERTIFICATE = "/api/v1/security/cert-sign/validate-certificate"
REDACT = "/api/v1/security/redact"
@@ -1772,6 +1800,7 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.ADD_WATERMARK: AddWatermarkParams,
ToolEndpoint.AUTO_REDACT: AutoRedactParams,
ToolEndpoint.CERT_SIGN: CertSignParams,
+ ToolEndpoint.PKCS11_CERTIFICATES: Pkcs11CertificatesParams,
ToolEndpoint.SESSIONS: SessionsParams,
ToolEndpoint.VALIDATE_CERTIFICATE: ValidateCertificateParams,
ToolEndpoint.REDACT: RedactParams,
diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml
index e206427288..628ba99a36 100644
--- a/frontend/editor/public/locales/en-GB/translation.toml
+++ b/frontend/editor/public/locales/en-GB/translation.toml
@@ -2463,9 +2463,39 @@ noTeam = "No Team"
noUsers = "No other users found."
placeholder = "Select users..."
+[certSign.device]
+stepTitle = "This device"
+
[certSign.error]
failed = "An error occurred whilst processing signatures."
+[certSign.format]
+pkcs11 = "USB Token"
+windowsStore = "Windows certificate store"
+
+[certSign.hardware]
+certificate = "Certificate"
+customLibrary = "Custom driver path…"
+driver = "PKCS#11 driver"
+driverPath = "Driver library path"
+driverPathPlaceholder = "Full path to your PKCS#11 driver (.dll, .so or .dylib)"
+expired = "expired"
+expires = "expires"
+listCerts = "List certificates"
+loading = "Reading certificates…"
+noCerts = "No signing certificates found"
+noDriver = "No PKCS#11 driver was detected. Install your token's driver (e.g. OpenSC), then reopen this - or enter the driver path manually below."
+notYetValid = "not yet valid"
+pin = "Token PIN"
+pkcs11Hint = "Select your token's PKCS#11 driver, enter the PIN, then list the certificates on the token."
+pkcs11LoadError = "Could not read certificates from the token. Check the PIN and driver."
+refresh = "Refresh"
+selectCert = "Select certificate"
+selectDriver = "Select driver"
+slot = "Slot (optional)"
+windowsHint = "Pick a certificate from your Windows certificate store. Signing uses the key on your card/token - Windows will prompt for the PIN."
+windowsLoadError = "Could not read the Windows certificate store"
+
[certSign.sessions]
deleted = "Session deleted"
fetchFailed = "Failed to load session details"
@@ -2478,9 +2508,6 @@ pdfNotReadyDesc = "The signed PDF is being generated. Please try again in a mome
results = "Signed PDF"
submit = "Sign PDF"
-[certSign.signMode]
-stepTitle = "Sign Mode"
-
[certSign.signMode.tooltip.auto]
text = "Signs with a server self-signed certificate. Same tamper-evident seal and audit trail; typically shows Unverified in viewers."
title = "Auto - Zero-setup, instant system seal"
@@ -2502,6 +2529,12 @@ title = "How signatures work"
text = "Need recipient Trusted status? Manual. Need a fast, tamper-evident seal and audit trail with no setup? Auto."
title = "Rule of thumb"
+[certSign.source]
+device = "This device"
+server = "Server"
+stepTitle = "Certificate source"
+upload = "Upload"
+
[certSign.tooltip.header]
title = "About Managing Signatures"
@@ -7951,6 +7984,9 @@ certExpired = "Certificate expired"
certRevocationUnknown = "Certificate revocation status unknown"
certRevoked = "Certificate revoked"
chainInvalid = "Certificate chain invalid"
+documentModified = "Document modified after signing - content was added outside the signed area"
+revocationNotChecked = "Revocation was not checked"
+selfSigned = "Self-signed - signer identity not verified"
signatureInvalid = "Signature cryptographic check failed"
trustInvalid = "Certificate not trusted"
@@ -7958,7 +7994,8 @@ trustInvalid = "Certificate not trusted"
continued = "Continued"
downloads = "Downloads"
entryLabel = "Signature Summary"
-filesEvaluated = "{{count}} files evaluated"
+filesEvaluated_one = "{{count}} file evaluated"
+filesEvaluated_other = "{{count}} files evaluated"
footer = "Validated via Stirling PDF"
generatedAt = "Generated"
noPdf = "PDF report will be available after a successful validation."
@@ -7968,6 +8005,9 @@ signatureCountLabel_one = "{{count}} signature"
signatureCountLabel_other = "{{count}} signatures"
signaturesFound_one = "{{count}} signature detected"
signaturesFound_other = "{{count}} signatures detected"
+signaturesInvalid = "{{count}} invalid"
+signaturesUnverified_one = "{{count}} needs review"
+signaturesUnverified_other = "{{count}} need review"
signaturesValid = "{{count}} fully valid"
title = "Signature Validation Report"
@@ -7987,7 +8027,9 @@ _value = "Signature"
[validateSignature.status]
complete = "Validation complete"
invalid = "Invalid"
+untrustedShort = "Unverified"
valid = "Valid"
+validUntrusted = "Valid, signer not verified"
[viewer]
cannotPreviewFile = "Cannot Preview File"
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 406524fede..7f81e56f3b 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -2463,9 +2463,39 @@ noTeam = "No Team"
noUsers = "No other users found."
placeholder = "Select users..."
+[certSign.device]
+stepTitle = "This device"
+
[certSign.error]
failed = "An error occurred while processing signatures."
+[certSign.format]
+pkcs11 = "USB Token"
+windowsStore = "Windows certificate store"
+
+[certSign.hardware]
+certificate = "Certificate"
+customLibrary = "Custom driver path…"
+driver = "PKCS#11 driver"
+driverPath = "Driver library path"
+driverPathPlaceholder = "Full path to your PKCS#11 driver (.dll, .so or .dylib)"
+expired = "expired"
+expires = "expires"
+listCerts = "List certificates"
+loading = "Reading certificates…"
+noCerts = "No signing certificates found"
+noDriver = "No PKCS#11 driver was detected. Install your token's driver (e.g. OpenSC), then reopen this - or enter the driver path manually below."
+notYetValid = "not yet valid"
+pin = "Token PIN"
+pkcs11Hint = "Select your token's PKCS#11 driver, enter the PIN, then list the certificates on the token."
+pkcs11LoadError = "Could not read certificates from the token. Check the PIN and driver."
+refresh = "Refresh"
+selectCert = "Select certificate"
+selectDriver = "Select driver"
+slot = "Slot (optional)"
+windowsHint = "Pick a certificate from your Windows certificate store. Signing uses the key on your card/token - Windows will prompt for the PIN."
+windowsLoadError = "Could not read the Windows certificate store"
+
[certSign.sessions]
deleted = "Session deleted"
fetchFailed = "Failed to load session details"
@@ -2478,9 +2508,6 @@ pdfNotReadyDesc = "The signed PDF is being generated. Please try again in a mome
results = "Signed PDF"
submit = "Sign PDF"
-[certSign.signMode]
-stepTitle = "Sign Mode"
-
[certSign.signMode.tooltip.auto]
text = "Signs with a server self-signed certificate. Same tamper-evident seal and audit trail; typically shows Unverified in viewers."
title = "Auto - Zero-setup, instant system seal"
@@ -2502,6 +2529,12 @@ title = "How signatures work"
text = "Need recipient Trusted status? Manual. Need a fast, tamper-evident seal and audit trail with no setup? Auto."
title = "Rule of thumb"
+[certSign.source]
+device = "This device"
+server = "Server"
+stepTitle = "Certificate source"
+upload = "Upload"
+
[certSign.tooltip.header]
title = "About Managing Signatures"
@@ -7951,6 +7984,9 @@ certExpired = "Certificate expired"
certRevocationUnknown = "Certificate revocation status unknown"
certRevoked = "Certificate revoked"
chainInvalid = "Certificate chain invalid"
+documentModified = "Document modified after signing - content was added outside the signed area"
+revocationNotChecked = "Revocation was not checked"
+selfSigned = "Self-signed - signer identity not verified"
signatureInvalid = "Signature cryptographic check failed"
trustInvalid = "Certificate not trusted"
@@ -7958,7 +7994,8 @@ trustInvalid = "Certificate not trusted"
continued = "Continued"
downloads = "Downloads"
entryLabel = "Signature Summary"
-filesEvaluated = "{{count}} files evaluated"
+filesEvaluated_one = "{{count}} file evaluated"
+filesEvaluated_other = "{{count}} files evaluated"
footer = "Validated via Stirling PDF"
generatedAt = "Generated"
noPdf = "PDF report will be available after a successful validation."
@@ -7968,6 +8005,9 @@ signatureCountLabel_one = "{{count}} signature"
signatureCountLabel_other = "{{count}} signatures"
signaturesFound_one = "{{count}} signature detected"
signaturesFound_other = "{{count}} signatures detected"
+signaturesInvalid = "{{count}} invalid"
+signaturesUnverified_one = "{{count}} needs review"
+signaturesUnverified_other = "{{count}} need review"
signaturesValid = "{{count}} fully valid"
title = "Signature Validation Report"
@@ -7987,7 +8027,9 @@ _value = "Signature"
[validateSignature.status]
complete = "Validation complete"
invalid = "Invalid"
+untrustedShort = "Unverified"
valid = "Valid"
+validUntrusted = "Valid, signer not verified"
[viewer]
cannotPreviewFile = "Cannot Preview File"
diff --git a/frontend/editor/src-tauri/src/commands/backend.rs b/frontend/editor/src-tauri/src/commands/backend.rs
index 50d9a24a26..6db0a4c1ac 100644
--- a/frontend/editor/src-tauri/src/commands/backend.rs
+++ b/frontend/editor/src-tauri/src/commands/backend.rs
@@ -210,6 +210,9 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
&log_path_option,
"-Dlogging.file.name=stirling-pdf.log",
"-Dserver.port=0", // Let OS assign an available port
+ // No reverse proxy in front of the local sidecar, so don't trust forwarded headers.
+ // Stops a LAN caller spoofing X-Forwarded-For to defeat the desktop-only signing gate.
+ "-Dserver.forward-headers-strategy=none",
"-Dsecurity.enableLogin=false", // Disable login for desktop mode
"-Dsecurity.csrfDisabled=true", // Disable CSRF for desktop mode
];
diff --git a/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.tsx b/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.tsx
index 7797441027..f093912c29 100644
--- a/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.tsx
@@ -11,6 +11,7 @@ import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParamet
import CertificateTypeSettings from "@app/components/tools/certSign/CertificateTypeSettings";
import CertificateFormatSettings from "@app/components/tools/certSign/CertificateFormatSettings";
import CertificateFilesSettings from "@app/components/tools/certSign/CertificateFilesSettings";
+import HardwareCertificateSettings from "@app/components/tools/certSign/HardwareCertificateSettings";
import SignatureAppearanceSettings from "@app/components/tools/certSign/SignatureAppearanceSettings";
interface CertSignAutomationSettingsProps {
@@ -54,6 +55,15 @@ const CertSignAutomationSettings = ({
/>
)}
+ {/* Hardware certificate (Windows store / USB token) - desktop only */}
+ {parameters.signMode === "DEVICE" && (
+
+ )}
+
{/* Signature Appearance Settings */}
{
+ const { t } = useTranslation();
const { config } = useAppConfig();
const isServerCertificateEnabled = config?.serverCertificateEnabled ?? false;
+ // Hardware-backed signing only works when the backend runs locally (desktop app).
+ const isHardwareAvailable = config?.hardwareSigningAvailable ?? false;
- // Reset to MANUAL if AUTO is selected but feature is disabled
- if (parameters.signMode === "AUTO" && !isServerCertificateEnabled) {
+ // Fall back to upload if a previously chosen source is no longer available
+ // (e.g. an automation saved with DEVICE running on a server). Runs as an effect so we don't
+ // call the parent's setter while rendering.
+ useEffect(() => {
+ if (parameters.signMode === "AUTO" && !isServerCertificateEnabled) {
+ onParameterChange("signMode", "MANUAL");
+ } else if (parameters.signMode === "DEVICE" && !isHardwareAvailable) {
+ onParameterChange("signMode", "MANUAL");
+ }
+ }, [
+ parameters.signMode,
+ isServerCertificateEnabled,
+ isHardwareAvailable,
+ onParameterChange,
+ ]);
+
+ const selectUpload = () => {
onParameterChange("signMode", "MANUAL");
- }
+ if (parameters.signMode !== "MANUAL") {
+ onParameterChange("certType", "");
+ }
+ };
+
+ const selectServer = () => {
+ onParameterChange("signMode", "AUTO");
+ onParameterChange("certType", "");
+ };
+
+ const selectDevice = () => {
+ onParameterChange("signMode", "DEVICE");
+ // Default to the Windows store; the device step lets the user switch to a token.
+ if (
+ parameters.certType !== "WINDOWS_STORE" &&
+ parameters.certType !== "PKCS11"
+ ) {
+ onParameterChange("certType", "WINDOWS_STORE");
+ }
+ onParameterChange("alias", undefined);
+ };
return (
@@ -29,26 +81,12 @@ const CertificateTypeSettings = ({
color={
parameters.signMode === "MANUAL" ? "blue" : "var(--text-muted)"
}
- onClick={() => {
- onParameterChange("signMode", "MANUAL");
- // Reset cert type when switching to manual
- if (parameters.signMode === "AUTO") {
- onParameterChange("certType", "");
- }
- }}
+ onClick={selectUpload}
disabled={disabled}
- style={{
- flex: 1,
- height: "auto",
- minHeight: "40px",
- fontSize: "11px",
- }}
+ style={sourceButtonStyle}
+ styles={sourceButtonStyles}
>
-
- Manual
-
+ {t("certSign.source.upload", "Upload")}
{isServerCertificateEnabled && (
+ )}
+ {isHardwareAvailable && (
+
)}
diff --git a/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx
new file mode 100644
index 0000000000..820636af35
--- /dev/null
+++ b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx
@@ -0,0 +1,456 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ Alert,
+ Button,
+ Group,
+ Loader,
+ NumberInput,
+ Select,
+ Stack,
+ Text,
+ TextInput,
+} from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters";
+import {
+ getHardwareSigningCapabilities,
+ HardwareCertificateInfo,
+ listPkcs11Certificates,
+ listWindowsCertificates,
+ Pkcs11LibraryInfo,
+} from "@app/services/hardwareSigningService";
+
+interface HardwareCertificateSettingsProps {
+ parameters: CertSignParameters;
+ onParameterChange: (key: keyof CertSignParameters, value: any) => void;
+ disabled?: boolean;
+}
+
+const CUSTOM_LIBRARY_VALUE = "__custom__";
+
+const HardwareCertificateSettings = ({
+ parameters,
+ onParameterChange,
+ disabled = false,
+}: HardwareCertificateSettingsProps) => {
+ const { t } = useTranslation();
+ const isWindowsStore = parameters.certType === "WINDOWS_STORE";
+
+ const [certs, setCerts] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const [libraries, setLibraries] = useState([]);
+ const [librarySelection, setLibrarySelection] = useState("");
+ const [customLibrary, setCustomLibrary] = useState("");
+ const [supported, setSupported] = useState({ windows: true, pkcs11: true });
+ const [capsReady, setCapsReady] = useState(false);
+
+ const selectKind = (kind: "WINDOWS_STORE" | "PKCS11") => {
+ if (parameters.certType === kind) {
+ return;
+ }
+ onParameterChange("certType", kind);
+ onParameterChange("alias", undefined);
+ setCerts([]);
+ setError(null);
+ };
+
+ // A GUID-only name (e.g. Microsoft device certs) is unreadable; prefer a real name.
+ const isGuidish = (s?: string | null) =>
+ !s ||
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
+ s.trim(),
+ );
+
+ // Best human-readable name: the Windows friendly name (alias) beats a GUID subject CN.
+ const displayName = (cert: HardwareCertificateInfo): string => {
+ if (cert.subjectCommonName && !isGuidish(cert.subjectCommonName)) {
+ return cert.subjectCommonName;
+ }
+ if (cert.alias && !isGuidish(cert.alias)) {
+ return cert.alias;
+ }
+ return cert.subjectCommonName || cert.alias;
+ };
+
+ const isUsable = (cert: HardwareCertificateInfo) =>
+ !cert.expired && !cert.notYetValid;
+
+ // Build a readable label for a certificate option.
+ const certLabel = useCallback(
+ (cert: HardwareCertificateInfo): string => {
+ const name = displayName(cert);
+ // Omit the issuer when it's the same as the name (self-signed) - avoids "X · X".
+ const showIssuer =
+ cert.issuerCommonName &&
+ cert.issuerCommonName !== cert.subjectCommonName &&
+ cert.issuerCommonName !== name;
+ const issuer = showIssuer ? ` · ${cert.issuerCommonName}` : "";
+ let suffix = "";
+ if (cert.expired) {
+ suffix = ` (${t("certSign.hardware.expired", "expired")})`;
+ } else if (cert.notYetValid) {
+ suffix = ` (${t("certSign.hardware.notYetValid", "not yet valid")})`;
+ } else if (cert.notAfter) {
+ const date = cert.notAfter.slice(0, 10);
+ suffix = ` (${t("certSign.hardware.expires", "expires")} ${date})`;
+ }
+ return `${name}${issuer}${suffix}`;
+ },
+ [t],
+ );
+
+ // Rank: usable + readable first, system/GUID certs next, expired/not-yet-valid last.
+ const rank = (cert: HardwareCertificateInfo): number => {
+ if (!isUsable(cert)) return 3;
+ if (isGuidish(cert.subjectCommonName) && isGuidish(cert.alias)) return 2;
+ return 0;
+ };
+
+ const applyCerts = useCallback(
+ (loaded: HardwareCertificateInfo[]) => {
+ setCerts(loaded);
+ // Auto-select when there is exactly one usable certificate.
+ const usable = loaded.filter((c) => !c.expired && !c.notYetValid);
+ if (usable.length === 1 && !parameters.alias) {
+ onParameterChange("alias", usable[0].alias);
+ }
+ },
+ [onParameterChange, parameters.alias],
+ );
+
+ // Load capabilities once: which hardware kinds are supported and the detected
+ // PKCS#11 driver libraries.
+ useEffect(() => {
+ let cancelled = false;
+ getHardwareSigningCapabilities()
+ .then((caps) => {
+ if (cancelled) {
+ return;
+ }
+ setSupported({
+ windows: caps.windowsStoreSupported,
+ pkcs11: caps.pkcs11Supported,
+ });
+ // Non-Windows (mac/Linux) has no Windows store; default the device to the USB-token path.
+ if (
+ !caps.windowsStoreSupported &&
+ parameters.certType === "WINDOWS_STORE"
+ ) {
+ onParameterChange("certType", "PKCS11");
+ }
+ setCapsReady(true);
+ setLibraries(caps.detectedLibraries);
+ // Pre-select a detected library, or the one already chosen.
+ if (parameters.pkcs11LibraryPath) {
+ const match = caps.detectedLibraries.find(
+ (l) => l.path === parameters.pkcs11LibraryPath,
+ );
+ setLibrarySelection(match ? match.path : CUSTOM_LIBRARY_VALUE);
+ if (!match) {
+ setCustomLibrary(parameters.pkcs11LibraryPath);
+ }
+ } else if (caps.detectedLibraries.length > 0) {
+ setLibrarySelection(caps.detectedLibraries[0].path);
+ onParameterChange(
+ "pkcs11LibraryPath",
+ caps.detectedLibraries[0].path,
+ );
+ }
+ })
+ .catch(() => {
+ if (cancelled) {
+ return;
+ }
+ /* capabilities are best-effort; the user can still type a path */
+ setCapsReady(true);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const loadWindowsCerts = useCallback(() => {
+ setLoading(true);
+ setError(null);
+ listWindowsCertificates()
+ .then(applyCerts)
+ .catch((e: any) =>
+ setError(
+ e?.response?.data?.message ||
+ e?.message ||
+ t(
+ "certSign.hardware.windowsLoadError",
+ "Could not read the Windows certificate store",
+ ),
+ ),
+ )
+ .finally(() => setLoading(false));
+ }, [applyCerts, t]);
+
+ // Windows store certificates can be enumerated without a PIN, so load eagerly -
+ // but only once capabilities confirm the store exists (avoids a spurious call on mac/Linux).
+ useEffect(() => {
+ if (capsReady && isWindowsStore && supported.windows) {
+ loadWindowsCerts();
+ }
+ }, [isWindowsStore, supported.windows, capsReady]);
+
+ const onLibraryChange = (value: string | null) => {
+ const selection = value ?? "";
+ setLibrarySelection(selection);
+ setCerts([]);
+ onParameterChange("alias", undefined);
+ if (selection === CUSTOM_LIBRARY_VALUE) {
+ onParameterChange("pkcs11LibraryPath", customLibrary || "");
+ } else {
+ onParameterChange("pkcs11LibraryPath", selection);
+ }
+ };
+
+ const loadPkcs11Certs = useCallback(() => {
+ if (!parameters.pkcs11LibraryPath || !parameters.password) {
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ listPkcs11Certificates({
+ libraryPath: parameters.pkcs11LibraryPath,
+ slot: parameters.pkcs11Slot,
+ pin: parameters.password,
+ })
+ .then(applyCerts)
+ .catch((e: any) =>
+ setError(
+ e?.response?.data?.message ||
+ e?.message ||
+ t(
+ "certSign.hardware.pkcs11LoadError",
+ "Could not read certificates from the token. Check the PIN and driver.",
+ ),
+ ),
+ )
+ .finally(() => setLoading(false));
+ }, [
+ applyCerts,
+ parameters.password,
+ parameters.pkcs11LibraryPath,
+ parameters.pkcs11Slot,
+ t,
+ ]);
+
+ const certOptions = [...certs]
+ .sort(
+ (a, b) =>
+ rank(a) - rank(b) || displayName(a).localeCompare(displayName(b)),
+ )
+ .map((cert) => ({
+ value: cert.alias,
+ label: certLabel(cert),
+ // Expired / not-yet-valid certs can't produce a valid signature - show but block.
+ disabled: !isUsable(cert),
+ }));
+
+ const libraryOptions = [
+ // Label = driver name only; the long path goes under the dropdown so the
+ // input doesn't overflow / scroll horizontally.
+ ...libraries.map((l) => ({
+ value: l.path,
+ label: l.name,
+ })),
+ {
+ value: CUSTOM_LIBRARY_VALUE,
+ label: t("certSign.hardware.customLibrary", "Custom driver path…"),
+ },
+ ];
+ const selectedLibraryPath =
+ librarySelection && librarySelection !== CUSTOM_LIBRARY_VALUE
+ ? librarySelection
+ : null;
+
+ // Hold the UI until capabilities are known, so the kind toggle / Windows-store
+ // section don't render and then vanish on mac/Linux (no flicker).
+ if (!capsReady) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {supported.windows && supported.pkcs11 && (
+
+
+
+
+ )}
+ {isWindowsStore ? (
+ <>
+
+ {t(
+ "certSign.hardware.windowsHint",
+ "Pick a certificate from your Windows store. Signing uses the key on your card/token - Windows will prompt for the PIN.",
+ )}
+
+
+
+ >
+ ) : (
+ <>
+
+ {t(
+ "certSign.hardware.pkcs11Hint",
+ "Select your token's PKCS#11 driver, enter the PIN, then list the certificates on the token.",
+ )}
+
+ {libraries.length === 0 && (
+
+ {t(
+ "certSign.hardware.noDriver",
+ "No PKCS#11 driver was detected. Install your token's driver (e.g. OpenSC), then reopen this - or enter the driver path manually below.",
+ )}
+
+ )}
+
+ {selectedLibraryPath && (
+
+ {selectedLibraryPath}
+
+ )}
+ {librarySelection === CUSTOM_LIBRARY_VALUE && (
+ {
+ setCustomLibrary(e.currentTarget.value);
+ onParameterChange("pkcs11LibraryPath", e.currentTarget.value);
+ }}
+ disabled={disabled || loading}
+ />
+ )}
+
+
+ onParameterChange("password", e.currentTarget.value)
+ }
+ disabled={disabled || loading}
+ />
+
+ onParameterChange(
+ "pkcs11Slot",
+ v === "" || v == null ? undefined : Number(v),
+ )
+ }
+ min={0}
+ disabled={disabled || loading}
+ />
+
+
+ {certs.length > 0 && (
+
+ );
+};
+
+export default HardwareCertificateSettings;
diff --git a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx
index cd20363954..e805a47636 100644
--- a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx
+++ b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx
@@ -14,9 +14,28 @@ import { useTranslation } from "react-i18next";
import type { SignatureValidationReportEntry } from "@app/types/validateSignature";
import type { ValidateSignatureOperationHook } from "@app/hooks/tools/validateSignature/useValidateSignatureOperation";
import "@app/components/tools/validateSignature/reportView/styles.css";
+import type { TFunction } from "i18next";
import FitText from "@app/components/shared/FitText";
import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection";
import { downloadFile } from "@app/services/downloadService";
+import {
+ computeSignatureStatus,
+ type SignatureStatusKind,
+} from "@app/hooks/tools/validateSignature/utils/signatureStatus";
+
+// Worst trust-aware status across a file's signatures - keeps the summary badge
+// consistent with the per-signature badges in the report (valid vs unverified vs invalid).
+const fileStatusKind = (
+ result: SignatureValidationReportEntry,
+ t: TFunction<"translation">,
+): SignatureStatusKind => {
+ if (result.error) return "invalid";
+ if (result.signatures.length === 0) return "neutral";
+ const kinds = result.signatures.map((s) => computeSignatureStatus(s, t).kind);
+ if (kinds.includes("invalid")) return "invalid";
+ if (kinds.includes("warning")) return "warning";
+ return "valid";
+};
interface ValidateSignatureResultsProps {
operation: ValidateSignatureOperationHook;
@@ -26,31 +45,34 @@ interface ValidateSignatureResultsProps {
reportAvailable?: boolean;
}
-const useFileSummary = (results: SignatureValidationReportEntry[]) => {
+const useFileSummary = (
+ results: SignatureValidationReportEntry[],
+ t: TFunction<"translation">,
+) => {
return useMemo(() => {
- if (results.length === 0) {
- return { fileCount: 0, signatureCount: 0, fullyValidCount: 0 };
- }
-
let signatureCount = 0;
- let fullyValidCount = 0;
+ let validCount = 0;
+ let warningCount = 0;
+ let invalidCount = 0;
results.forEach((result) => {
signatureCount += result.signatures.length;
result.signatures.forEach((signature) => {
- const isValid = signature.valid;
- if (isValid) {
- fullyValidCount += 1;
- }
+ const kind = computeSignatureStatus(signature, t).kind;
+ if (kind === "valid") validCount += 1;
+ else if (kind === "warning") warningCount += 1;
+ else if (kind === "invalid") invalidCount += 1;
});
});
return {
fileCount: results.length,
signatureCount,
- fullyValidCount,
+ validCount,
+ warningCount,
+ invalidCount,
};
- }, [results]);
+ }, [results, t]);
};
const findFileByExtension = (files: File[], extension: string) => {
@@ -64,7 +86,7 @@ const ValidateSignatureResults = ({
errorMessage,
}: ValidateSignatureResultsProps) => {
const { t } = useTranslation();
- const summary = useFileSummary(results);
+ const summary = useFileSummary(results, t);
const pdfFile = useMemo(
() => findFileByExtension(operation.files, ".pdf"),
@@ -173,14 +195,30 @@ const ValidateSignatureResults = ({
},
)}
- {summary.signatureCount > 0 && (
+ {summary.validCount > 0 && (
{t(
"validateSignature.report.signaturesValid",
"{{count}} fully valid",
- {
- count: summary.fullyValidCount,
- },
+ { count: summary.validCount },
+ )}
+
+ )}
+ {summary.warningCount > 0 && (
+
+ {t(
+ "validateSignature.report.signaturesUnverified",
+ "{{count}} need review",
+ { count: summary.warningCount },
+ )}
+
+ )}
+ {summary.invalidCount > 0 && (
+
+ {t(
+ "validateSignature.report.signaturesInvalid",
+ "{{count}} invalid",
+ { count: summary.invalidCount },
)}
)}
@@ -188,25 +226,16 @@ const ValidateSignatureResults = ({
{results.map((result) => {
- const hasError = Boolean(result.error);
- const hasSignatures = result.signatures.length > 0;
- const allValid =
- hasSignatures &&
- result.signatures.every((signature) => signature.valid);
- const badgeLabel = hasError
- ? t("validateSignature.status.invalid", "Invalid")
- : hasSignatures
- ? allValid
- ? t("validateSignature.status.valid", "Valid")
- : t("validateSignature.status.invalid", "Invalid")
- : t("validateSignature.noSignaturesShort", "No signatures");
- const badgeClass = hasError
- ? "status-badge status-badge--invalid"
- : hasSignatures
- ? allValid
- ? "status-badge status-badge--valid"
- : "status-badge status-badge--warning"
- : "status-badge status-badge--neutral";
+ const kind = fileStatusKind(result, t);
+ const badgeLabel =
+ kind === "invalid"
+ ? t("validateSignature.status.invalid", "Invalid")
+ : kind === "warning"
+ ? t("validateSignature.status.untrustedShort", "Unverified")
+ : kind === "valid"
+ ? t("validateSignature.status.valid", "Valid")
+ : t("validateSignature.noSignaturesShort", "No signatures");
+ const badgeClass = `status-badge status-badge--${kind}`;
return (
+ new File(["%PDF-1.4"], "doc.pdf", { type: "application/pdf" });
+
+const params = (
+ overrides: Partial,
+): CertSignParameters => ({
+ ...defaultParameters,
+ ...overrides,
+});
+
+describe("buildCertSignFormData - hardware cert types", () => {
+ test("WINDOWS_STORE sends certType and alias, no files", () => {
+ const formData = buildCertSignFormData(
+ params({
+ signMode: "MANUAL",
+ certType: "WINDOWS_STORE",
+ alias: "My Signing Cert",
+ }),
+ pdf(),
+ );
+
+ expect(formData.get("certType")).toBe("WINDOWS_STORE");
+ expect(formData.get("alias")).toBe("My Signing Cert");
+ expect(formData.get("p12File")).toBeNull();
+ expect(formData.get("jksFile")).toBeNull();
+ });
+
+ test("PKCS11 sends driver path, slot, alias and PIN (as password)", () => {
+ const formData = buildCertSignFormData(
+ params({
+ signMode: "MANUAL",
+ certType: "PKCS11",
+ pkcs11LibraryPath: "/usr/lib/opensc-pkcs11.so",
+ pkcs11Slot: 0,
+ alias: "token-cert",
+ password: "1234",
+ }),
+ pdf(),
+ );
+
+ expect(formData.get("certType")).toBe("PKCS11");
+ expect(formData.get("pkcs11LibraryPath")).toBe("/usr/lib/opensc-pkcs11.so");
+ expect(formData.get("pkcs11Slot")).toBe("0");
+ expect(formData.get("alias")).toBe("token-cert");
+ expect(formData.get("password")).toBe("1234");
+ });
+
+ test("PKCS11 omits slot when not provided", () => {
+ const formData = buildCertSignFormData(
+ params({
+ signMode: "MANUAL",
+ certType: "PKCS11",
+ pkcs11LibraryPath: "/usr/lib/opensc-pkcs11.so",
+ alias: "token-cert",
+ password: "1234",
+ }),
+ pdf(),
+ );
+
+ expect(formData.get("pkcs11Slot")).toBeNull();
+ });
+
+ test("AUTO mode still maps to SERVER without hardware fields", () => {
+ const formData = buildCertSignFormData(params({ signMode: "AUTO" }), pdf());
+
+ expect(formData.get("certType")).toBe("SERVER");
+ expect(formData.get("alias")).toBeNull();
+ expect(formData.get("pkcs11LibraryPath")).toBeNull();
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts b/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts
index ccd2f8c417..8488dec77a 100644
--- a/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts
@@ -45,6 +45,22 @@ export const buildCertSignFormData = (
formData.append("jksFile", parameters.jksFile);
}
break;
+ case "WINDOWS_STORE":
+ if (parameters.alias) {
+ formData.append("alias", parameters.alias);
+ }
+ break;
+ case "PKCS11":
+ if (parameters.pkcs11LibraryPath) {
+ formData.append("pkcs11LibraryPath", parameters.pkcs11LibraryPath);
+ }
+ if (parameters.pkcs11Slot != null) {
+ formData.append("pkcs11Slot", parameters.pkcs11Slot.toString());
+ }
+ if (parameters.alias) {
+ formData.append("alias", parameters.alias);
+ }
+ break;
}
}
diff --git a/frontend/editor/src/core/hooks/tools/certSign/useCertSignParameters.ts b/frontend/editor/src/core/hooks/tools/certSign/useCertSignParameters.ts
index ef65a6ecc5..f69c98ffd2 100644
--- a/frontend/editor/src/core/hooks/tools/certSign/useCertSignParameters.ts
+++ b/frontend/editor/src/core/hooks/tools/certSign/useCertSignParameters.ts
@@ -5,16 +5,24 @@ import {
} from "@app/hooks/tools/shared/useBaseParameters";
export interface CertSignParameters extends BaseParameters {
- // Sign mode selection
- signMode: "MANUAL" | "AUTO";
- // Certificate signing options (only for manual mode)
- certType: "" | "PEM" | "PKCS12" | "PFX" | "JKS";
+ // Where the signing certificate comes from:
+ // MANUAL = upload a keystore file, AUTO = server certificate,
+ // DEVICE = a certificate held on this machine (Windows store or USB PKCS#11 token, desktop only).
+ signMode: "MANUAL" | "AUTO" | "DEVICE";
+ // For MANUAL this is the uploaded file format; for DEVICE it is the hardware kind
+ // (WINDOWS_STORE or PKCS11). Hardware kinds are only offered in the desktop app.
+ certType: "" | "PEM" | "PKCS12" | "PFX" | "JKS" | "WINDOWS_STORE" | "PKCS11";
privateKeyFile?: File;
certFile?: File;
p12File?: File;
jksFile?: File;
password: string;
+ // Hardware signing (desktop only)
+ alias?: string;
+ pkcs11LibraryPath?: string;
+ pkcs11Slot?: number;
+
// Signature appearance options
showSignature: boolean;
reason: string;
@@ -62,6 +70,12 @@ export const useCertSignParameters = (): CertSignParametersHook => {
return !!params.p12File;
case "JKS":
return !!params.jksFile;
+ case "WINDOWS_STORE":
+ // Need a chosen certificate from the Windows store.
+ return !!params.alias;
+ case "PKCS11":
+ // Need a driver library and a chosen certificate on the token.
+ return !!(params.pkcs11LibraryPath && params.alias);
default:
return false;
}
diff --git a/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureStatus.test.ts b/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureStatus.test.ts
new file mode 100644
index 0000000000..e7fca7622a
--- /dev/null
+++ b/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureStatus.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, test } from "vitest";
+import type { TFunction } from "i18next";
+import { computeSignatureStatus } from "@app/hooks/tools/validateSignature/utils/signatureStatus";
+import type { SignatureValidationSignature } from "@app/types/validateSignature";
+
+// t() stub: return the provided default string (2nd arg) so labels are stable.
+const t = ((_key: string, def?: string) =>
+ def ?? _key) as unknown as TFunction<"translation">;
+
+const sig = (
+ overrides: Partial,
+): SignatureValidationSignature =>
+ ({
+ valid: true,
+ chainValid: true,
+ trustValid: true,
+ notExpired: true,
+ selfSigned: false,
+ revocationStatus: "good",
+ ...overrides,
+ }) as SignatureValidationSignature;
+
+describe("computeSignatureStatus - trust surfacing", () => {
+ test("cryptographically valid AND trusted -> green Valid", () => {
+ const status = computeSignatureStatus(sig({}), t);
+ expect(status.kind).toBe("valid");
+ expect(status.label).toBe("Valid");
+ });
+
+ test("valid crypto but self-signed -> yellow warning, not green", () => {
+ const status = computeSignatureStatus(
+ sig({ selfSigned: true, chainValid: false, trustValid: false }),
+ t,
+ );
+ expect(status.kind).toBe("warning");
+ expect(status.label).toBe("Valid, signer not verified");
+ expect(status.details.join(" ")).toMatch(/self-signed/i);
+ });
+
+ test("self-signed BUT explicitly trusted (Stirling auto cert) -> green Valid", () => {
+ const status = computeSignatureStatus(
+ sig({ selfSigned: true, chainValid: true, trustValid: true }),
+ t,
+ );
+ expect(status.kind).toBe("valid");
+ expect(status.label).toBe("Valid");
+ });
+
+ test("valid crypto but untrusted chain (not self-signed) -> warning", () => {
+ const status = computeSignatureStatus(
+ sig({ chainValid: false, trustValid: false }),
+ t,
+ );
+ expect(status.kind).toBe("warning");
+ expect(status.details.join(" ")).toMatch(/not trusted/i);
+ });
+
+ test("expired cert downgrades a valid signature to warning", () => {
+ const status = computeSignatureStatus(sig({ notExpired: false }), t);
+ expect(status.kind).toBe("warning");
+ expect(status.details.join(" ")).toMatch(/expired/i);
+ });
+
+ test("revoked cert downgrades to warning", () => {
+ const status = computeSignatureStatus(
+ sig({ revocationStatus: "revoked" }),
+ t,
+ );
+ expect(status.kind).toBe("warning");
+ expect(status.details.join(" ")).toMatch(/revoked/i);
+ });
+
+ test("content appended after signing -> warning", () => {
+ const status = computeSignatureStatus(
+ sig({ coversEntireDocument: false }),
+ t,
+ );
+ expect(status.kind).toBe("warning");
+ expect(status.details.join(" ")).toMatch(/modified after signing/i);
+ });
+
+ test("revocation not checked -> still valid but surfaced as a caveat", () => {
+ const status = computeSignatureStatus(
+ sig({ revocationStatus: "not-checked" }),
+ t,
+ );
+ expect(status.kind).toBe("valid");
+ expect(status.details.join(" ")).toMatch(/revocation was not checked/i);
+ });
+
+ test("cryptographic failure -> red Invalid regardless of trust", () => {
+ const status = computeSignatureStatus(sig({ valid: false }), t);
+ expect(status.kind).toBe("invalid");
+ expect(status.label).toBe("Invalid");
+ });
+
+ test("backend error message -> Invalid", () => {
+ const status = computeSignatureStatus(
+ sig({ errorMessage: "boom" } as Partial),
+ t,
+ );
+ expect(status.kind).toBe("invalid");
+ expect(status.details).toContain("boom");
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureStatus.ts b/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureStatus.ts
index ec7caff0dc..4f46641d4c 100644
--- a/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureStatus.ts
+++ b/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureStatus.ts
@@ -34,15 +34,28 @@ export const computeSignatureStatus = (
),
);
}
- if (!signature.chainValid) {
- trustIssues.push(
- t("validateSignature.issue.chainInvalid", "Certificate chain invalid"),
- );
- }
- if (!signature.trustValid) {
- trustIssues.push(
- t("validateSignature.issue.trustInvalid", "Certificate not trusted"),
- );
+ if (signature.selfSigned) {
+ // A self-signed cert is only untrusted if it wasn't explicitly trusted.
+ // Stirling's own auto cert is loaded as a trust anchor -> trustValid stays green.
+ if (!signature.trustValid) {
+ trustIssues.push(
+ t(
+ "validateSignature.issue.selfSigned",
+ "Self-signed - signer identity not verified",
+ ),
+ );
+ }
+ } else {
+ if (!signature.chainValid) {
+ trustIssues.push(
+ t("validateSignature.issue.chainInvalid", "Certificate chain invalid"),
+ );
+ }
+ if (!signature.trustValid) {
+ trustIssues.push(
+ t("validateSignature.issue.trustInvalid", "Certificate not trusted"),
+ );
+ }
}
if (!signature.notExpired) {
trustIssues.push(
@@ -65,9 +78,31 @@ export const computeSignatureStatus = (
);
}
+ // Content appended after signing: the signed bytes are intact but the document carries unsigned
+ // additions the signature can't attest to. Treat as a trust caveat (downgrades to warning).
+ if (signature.coversEntireDocument === false) {
+ trustIssues.push(
+ t(
+ "validateSignature.issue.documentModified",
+ "Document modified after signing - content was added outside the signed area",
+ ),
+ );
+ }
+
// Aggregate all issues for details UI (ignore missing metadata fields; they are optional)
issues.push(...trustIssues);
+ // Revocation was not checked at all (disabled by config). Surface as an informational caveat
+ // without downgrading the badge, so an otherwise-clean signature still reads as valid.
+ if (revStatus === "not-checked") {
+ issues.push(
+ t(
+ "validateSignature.issue.revocationNotChecked",
+ "Revocation was not checked",
+ ),
+ );
+ }
+
// If cryptographic validation failed, mark as Invalid
if (!signature.valid) {
return {
@@ -77,7 +112,19 @@ export const computeSignatureStatus = (
};
}
- // Otherwise, mark as Valid regardless of optional field presence and trust warnings
+ // Cryptographically valid. If the signer can't be trusted (untrusted chain,
+ // self-signed, expired, revoked) downgrade to a warning rather than a clean "Valid".
+ if (trustIssues.length > 0) {
+ return {
+ kind: "warning",
+ label: t(
+ "validateSignature.status.validUntrusted",
+ "Valid, signer not verified",
+ ),
+ details: issues,
+ };
+ }
+
return {
kind: "valid",
label: t("validateSignature.status.valid", "Valid"),
diff --git a/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureUtils.ts b/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureUtils.ts
index 8c0dd952a4..380861b7bb 100644
--- a/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureUtils.ts
+++ b/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureUtils.ts
@@ -66,6 +66,8 @@ export const normalizeBackendResult = (
chainValid: Boolean(item.chainValid),
trustValid: Boolean(item.trustValid),
notExpired: Boolean(item.notExpired),
+ // Default to covered when the backend omits it (older payloads) to avoid false alarms.
+ coversEntireDocument: item.coversEntireDocument !== false,
revocationChecked:
item.revocationChecked === null || item.revocationChecked === undefined
? null
diff --git a/frontend/editor/src/core/services/hardwareSigningService.ts b/frontend/editor/src/core/services/hardwareSigningService.ts
new file mode 100644
index 0000000000..c92047c2d7
--- /dev/null
+++ b/frontend/editor/src/core/services/hardwareSigningService.ts
@@ -0,0 +1,64 @@
+import apiClient from "@app/services/apiClient";
+
+/** A signing certificate held on a hardware source (Windows store or PKCS#11 token). */
+export interface HardwareCertificateInfo {
+ alias: string;
+ source: "WINDOWS_STORE" | "PKCS11";
+ subject: string;
+ issuer: string;
+ subjectCommonName: string;
+ issuerCommonName: string;
+ serialNumber: string;
+ keyAlgorithm: string;
+ notBefore: string;
+ notAfter: string;
+ expired: boolean;
+ notYetValid: boolean;
+}
+
+export interface Pkcs11LibraryInfo {
+ name: string;
+ path: string;
+}
+
+export interface HardwareSigningCapabilities {
+ desktop: boolean;
+ osName: string;
+ windowsStoreSupported: boolean;
+ pkcs11Supported: boolean;
+ detectedLibraries: Pkcs11LibraryInfo[];
+}
+
+const BASE = "/api/v1/security/cert-sign/hardware";
+
+export async function getHardwareSigningCapabilities(): Promise {
+ const response = await apiClient.get(
+ `${BASE}/capabilities`,
+ );
+ return response.data;
+}
+
+export async function listWindowsCertificates(): Promise<
+ HardwareCertificateInfo[]
+> {
+ const response = await apiClient.get(
+ `${BASE}/windows-certificates`,
+ );
+ return response.data;
+}
+
+export async function listPkcs11Certificates(params: {
+ libraryPath: string;
+ slot?: number;
+ pin: string;
+}): Promise {
+ const response = await apiClient.post(
+ `${BASE}/pkcs11-certificates`,
+ {
+ libraryPath: params.libraryPath,
+ slot: params.slot ?? null,
+ pin: params.pin,
+ },
+ );
+ return response.data;
+}
diff --git a/frontend/editor/src/core/tests/stubbed/cert-sign-wizard.spec.ts b/frontend/editor/src/core/tests/stubbed/cert-sign-wizard.spec.ts
index e1f4ccdb75..807e4231d4 100644
--- a/frontend/editor/src/core/tests/stubbed/cert-sign-wizard.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/cert-sign-wizard.spec.ts
@@ -1,21 +1,65 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
+import type { Page, Route } from "@playwright/test";
import path from "path";
const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
-/**
- * CertSign is the most complex tool — a 5-step wizard. Stubbed coverage
- * focuses on:
- * - The page renders cleanly with a PDF uploaded.
- * - The cert-file input is reachable.
- * - At least one of the Auto/Manual mode buttons exists.
- * Deeper step-by-step interaction is brittle to render across builds and
- * is best left to vitest unit tests of the underlying step components.
- */
-test.describe("CertSign tool — wizard surface", () => {
- test("renders, accepts PDF upload, exposes cert input and a mode button", async ({
+// app-config the desktop bundle would return: hardware signing is offered only there.
+const DESKTOP_APP_CONFIG = {
+ enableLogin: false,
+ isAdmin: false,
+ languages: ["en-GB"],
+ defaultLocale: "en-GB",
+ hardwareSigningAvailable: true,
+};
+
+async function mockHardwareEndpoints(page: Page) {
+ await page.route(
+ "**/api/v1/security/cert-sign/hardware/capabilities",
+ (route: Route) =>
+ route.fulfill({
+ json: {
+ desktop: true,
+ osName: "Windows 11",
+ windowsStoreSupported: true,
+ pkcs11Supported: true,
+ detectedLibraries: [
+ {
+ name: "OpenSC",
+ path: "C:/Program Files/OpenSC Project/OpenSC/pkcs11/opensc-pkcs11.dll",
+ },
+ ],
+ },
+ }),
+ );
+ await page.route(
+ "**/api/v1/security/cert-sign/hardware/windows-certificates",
+ (route: Route) =>
+ route.fulfill({
+ json: [
+ {
+ alias: "Anthony Stirling",
+ source: "WINDOWS_STORE",
+ subject: "CN=Anthony Stirling",
+ issuer: "CN=Anthony Stirling",
+ subjectCommonName: "Anthony Stirling",
+ issuerCommonName: "Anthony Stirling",
+ serialNumber: "abc123",
+ keyAlgorithm: "RSA",
+ notBefore: "2026-01-01T00:00:00Z",
+ notAfter: "2028-01-01T00:00:00Z",
+ expired: false,
+ notYetValid: false,
+ },
+ ],
+ }),
+ );
+}
+
+test.describe("CertSign tool - certificate source model", () => {
+ test("renders, accepts a PDF, and exposes the Upload source", async ({
page,
}) => {
await page.route("**/api/v1/security/cert-sign", (route) =>
@@ -32,12 +76,140 @@ test.describe("CertSign tool — wizard surface", () => {
await uploadFiles(page, SAMPLE_PDF);
await expect(page).toHaveURL(/\/cert-sign/);
- await expect(page.locator("body").first()).not.toBeEmpty();
+ // Source step always offers "Upload" (the former "Manual" mode).
+ await expect(
+ page.getByRole("button", { name: /^upload$/i }).first(),
+ ).toBeAttached({ timeout: 10_000 });
+ });
- // At least one mode button (Auto or Manual) should be in the DOM
- const modeBtn = page
- .getByRole("button", { name: /^auto$|^manual$/i })
- .first();
- await expect(modeBtn).toBeAttached({ timeout: 10_000 });
+ test("does NOT offer 'This device' when not running as desktop", async ({
+ page,
+ }) => {
+ await page.goto("/cert-sign");
+ await page.waitForLoadState("domcontentloaded");
+ await uploadFiles(page, SAMPLE_PDF);
+
+ await expect(
+ page.getByRole("button", { name: /^upload$/i }).first(),
+ ).toBeAttached({ timeout: 10_000 });
+ await expect(
+ page.getByRole("button", { name: /this device/i }),
+ ).toHaveCount(0);
+ });
+});
+
+test.describe("CertSign tool - hardware on mac/Linux (no Windows store)", () => {
+ test.use({ autoGoto: false });
+
+ test("'This device' goes straight to the USB-token path, no Windows-store toggle", async ({
+ page,
+ }) => {
+ await page.route("**/api/v1/config/app-config", (route: Route) =>
+ route.fulfill({ json: DESKTOP_APP_CONFIG }),
+ );
+ await page.route(
+ "**/api/v1/security/cert-sign/hardware/capabilities",
+ (route: Route) =>
+ route.fulfill({
+ json: {
+ desktop: true,
+ osName: "macOS 14",
+ windowsStoreSupported: false,
+ pkcs11Supported: true,
+ detectedLibraries: [
+ {
+ name: "OpenSC",
+ path: "/Library/OpenSC/lib/opensc-pkcs11.so",
+ },
+ ],
+ },
+ }),
+ );
+
+ await page.goto("/cert-sign");
+ await page.waitForLoadState("domcontentloaded");
+ await uploadFiles(page, SAMPLE_PDF);
+
+ const deviceBtn = page.getByRole("button", { name: /this device/i });
+ await expect(deviceBtn).toBeVisible({ timeout: 10_000 });
+ await deviceBtn.click();
+
+ // Only one hardware kind applies -> no Windows-store toggle.
+ await expect(
+ page.getByRole("button", { name: /windows certificate store/i }),
+ ).toHaveCount(0);
+ // The USB-token (PKCS#11) driver picker is shown instead.
+ await expect(page.getByText(/PKCS#11 driver/i).first()).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+});
+
+test.describe("CertSign tool - server deployment (no hardware)", () => {
+ test.use({ autoGoto: false });
+
+ test("offers Server but never 'This device' when an org cert is configured", async ({
+ page,
+ }) => {
+ // Non-desktop instance with a configured server certificate: Upload + Server, no hardware.
+ await page.route("**/api/v1/config/app-config", (route: Route) =>
+ route.fulfill({
+ json: {
+ enableLogin: false,
+ isAdmin: false,
+ languages: ["en-GB"],
+ defaultLocale: "en-GB",
+ hardwareSigningAvailable: false,
+ serverCertificateEnabled: true,
+ },
+ }),
+ );
+
+ await page.goto("/cert-sign");
+ await page.waitForLoadState("domcontentloaded");
+ await uploadFiles(page, SAMPLE_PDF);
+
+ await expect(
+ page.getByRole("button", { name: /^upload$/i }).first(),
+ ).toBeAttached({ timeout: 10_000 });
+ await expect(
+ page.getByRole("button", { name: /^server$/i }).first(),
+ ).toBeVisible({ timeout: 10_000 });
+ await expect(
+ page.getByRole("button", { name: /this device/i }),
+ ).toHaveCount(0);
+ });
+});
+
+test.describe("CertSign tool - hardware signing (desktop)", () => {
+ test.use({ autoGoto: false });
+
+ test("offers 'This device' and lists Windows store certificates", async ({
+ page,
+ }) => {
+ // Override app-config BEFORE bootstrap so hardwareSigningAvailable is true.
+ await page.route("**/api/v1/config/app-config", (route: Route) =>
+ route.fulfill({ json: DESKTOP_APP_CONFIG }),
+ );
+ await mockHardwareEndpoints(page);
+
+ await page.goto("/cert-sign");
+ await page.waitForLoadState("domcontentloaded");
+ await uploadFiles(page, SAMPLE_PDF);
+
+ // The desktop-only source appears.
+ const deviceBtn = page.getByRole("button", { name: /this device/i });
+ await expect(deviceBtn).toBeVisible({ timeout: 10_000 });
+ await deviceBtn.click();
+
+ // The Windows store / USB token kind toggle renders.
+ await expect(
+ page.getByRole("button", { name: /windows certificate store/i }),
+ ).toBeVisible({ timeout: 10_000 });
+
+ // The single enumerated cert is auto-selected into the picker input.
+ await expect(
+ page.getByRole("textbox", { name: /^certificate$/i }),
+ ).toHaveValue(/Anthony Stirling/, { timeout: 10_000 });
});
});
diff --git a/frontend/editor/src/core/tests/stubbed/validate-signature-trust.spec.ts b/frontend/editor/src/core/tests/stubbed/validate-signature-trust.spec.ts
new file mode 100644
index 0000000000..e699451d9e
--- /dev/null
+++ b/frontend/editor/src/core/tests/stubbed/validate-signature-trust.spec.ts
@@ -0,0 +1,100 @@
+import { test, expect } from "@app/tests/helpers/stub-test-base";
+import { uploadFiles } from "@app/tests/helpers/ui-helpers";
+import type { Page, Route } from "@playwright/test";
+import path from "path";
+
+const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
+const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
+
+// Base backend SignatureValidationResult; tests override the trust-related fields.
+const baseResult = {
+ valid: true,
+ chainValid: true,
+ trustValid: true,
+ notExpired: true,
+ selfSigned: false,
+ revocationStatus: "good",
+ revocationChecked: true,
+ validationTimeSource: "signing-time",
+ signerName: "Test Signer",
+ signatureDate: "Sat Jun 21 00:00:00 BST 2026",
+ reason: "Approval",
+ location: "London",
+ issuerDN: "CN=Some CA",
+ subjectDN: "CN=Test Signer",
+ serialNumber: "abc",
+ validFrom: "Wed Jan 01 00:00:00 BST 2025",
+ validUntil: "Fri Jan 01 00:00:00 BST 2027",
+ signatureAlgorithm: "SHA256withRSA",
+ keySize: 2048,
+ version: "3",
+ keyUsages: ["Digital Signature"],
+ errorMessage: null,
+};
+
+async function mockValidate(page: Page, override: Record) {
+ await page.route("**/api/v1/security/validate-signature", (route: Route) =>
+ route.fulfill({ json: [{ ...baseResult, ...override }] }),
+ );
+}
+
+async function runValidation(page: Page) {
+ await page.goto("/validate-signature");
+ await page.waitForLoadState("domcontentloaded");
+ await uploadFiles(page, SAMPLE_PDF);
+ await page
+ .getByRole("button", { name: /validate signatures/i })
+ .first()
+ .click();
+}
+
+test.describe("Validate Signature - trust surfacing", () => {
+ test("self-signed signature is shown as valid-but-unverified, not a clean Valid", async ({
+ page,
+ }) => {
+ await mockValidate(page, {
+ valid: true,
+ selfSigned: true,
+ chainValid: false,
+ trustValid: false,
+ });
+
+ await runValidation(page);
+
+ await expect(page.getByText(/signer not verified/i).first()).toBeVisible({
+ timeout: 15_000,
+ });
+ });
+
+ test("fully trusted signature does not show the unverified warning", async ({
+ page,
+ }) => {
+ await mockValidate(page, {
+ valid: true,
+ selfSigned: false,
+ chainValid: true,
+ trustValid: true,
+ });
+
+ await runValidation(page);
+
+ // Wait for the report to render (signer surfaces in the details), then
+ // assert the untrusted warning is absent.
+ await expect(page.getByText("Test Signer").first()).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect(page.getByText(/signer not verified/i)).toHaveCount(0);
+ });
+
+ test("cryptographically broken signature is shown as Invalid", async ({
+ page,
+ }) => {
+ await mockValidate(page, { valid: false });
+
+ await runValidation(page);
+
+ await expect(page.getByText(/^invalid$/i).first()).toBeVisible({
+ timeout: 15_000,
+ });
+ });
+});
diff --git a/frontend/editor/src/core/tools/CertSign.tsx b/frontend/editor/src/core/tools/CertSign.tsx
index 1367953100..dd0668a491 100644
--- a/frontend/editor/src/core/tools/CertSign.tsx
+++ b/frontend/editor/src/core/tools/CertSign.tsx
@@ -3,6 +3,7 @@ import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import CertificateTypeSettings from "@app/components/tools/certSign/CertificateTypeSettings";
import CertificateFormatSettings from "@app/components/tools/certSign/CertificateFormatSettings";
import CertificateFilesSettings from "@app/components/tools/certSign/CertificateFilesSettings";
+import HardwareCertificateSettings from "@app/components/tools/certSign/HardwareCertificateSettings";
import SignatureAppearanceSettings from "@app/components/tools/certSign/SignatureAppearanceSettings";
import { useCertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters";
import { useCertSignOperation } from "@app/hooks/tools/certSign/useCertSignOperation";
@@ -44,6 +45,10 @@ const CertSign = (props: BaseToolProps) => {
return !!params.p12File;
case "JKS":
return !!params.jksFile;
+ case "WINDOWS_STORE":
+ return !!params.alias;
+ case "PKCS11":
+ return !!(params.pkcs11LibraryPath && params.alias);
default:
return false;
}
@@ -57,7 +62,7 @@ const CertSign = (props: BaseToolProps) => {
},
steps: [
{
- title: t("certSign.signMode.stepTitle", "Sign Mode"),
+ title: t("certSign.source.stepTitle", "Certificate source"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed
? base.handleSettingsReset
@@ -108,6 +113,24 @@ const CertSign = (props: BaseToolProps) => {
},
]
: []),
+ ...(base.params.parameters.signMode === "DEVICE"
+ ? [
+ {
+ title: t("certSign.device.stepTitle", "This device"),
+ isCollapsed: base.settingsCollapsed,
+ onCollapsedClick: base.settingsCollapsed
+ ? base.handleSettingsReset
+ : undefined,
+ content: (
+
+ ),
+ },
+ ]
+ : []),
{
title: t("certSign.appearance.stepTitle", "Signature Appearance"),
isCollapsed: base.settingsCollapsed || !areCertFilesConfigured(),
diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts
index c73faebd8c..72c7d7c5b6 100644
--- a/frontend/editor/src/core/types/appConfig.ts
+++ b/frontend/editor/src/core/types/appConfig.ts
@@ -32,6 +32,7 @@ export interface AppConfig {
license?: string;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
+ hardwareSigningAvailable?: boolean;
enableMobileScanner?: boolean;
mobileScannerConvertToPdf?: boolean;
mobileScannerImageResolution?: string;
diff --git a/frontend/editor/src/core/types/validateSignature.ts b/frontend/editor/src/core/types/validateSignature.ts
index feeb27b32b..7bd48347c1 100644
--- a/frontend/editor/src/core/types/validateSignature.ts
+++ b/frontend/editor/src/core/types/validateSignature.ts
@@ -5,6 +5,7 @@ export interface SignatureValidationBackendResult {
chainValidationError?: string | null;
certPathLength?: number | null;
notExpired: boolean;
+ coversEntireDocument?: boolean | null; // false = content appended after signing
revocationChecked?: boolean | null;
revocationStatus?: string | null; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp"
@@ -33,6 +34,7 @@ export interface SignatureValidationSignature {
chainValidationError?: string | null;
certPathLength?: number | null;
notExpired: boolean;
+ coversEntireDocument?: boolean | null; // false = content appended after signing
revocationChecked?: boolean | null;
revocationStatus?: string | null; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp"