Add desktop hardware token signing and trust-aware signature validation (#6765)

# Description of Changes

<img width="432" height="800" alt="image"
src="https://github.com/user-attachments/assets/a01ed9ac-220c-4911-9134-b51e0f321be8"
/>

<img width="408" height="859" alt="image"
src="https://github.com/user-attachments/assets/a9c285b6-5b75-493a-95ec-09e08d0f58f1"
/>

<img width="426" height="874" alt="image"
src="https://github.com/user-attachments/assets/a60db96e-be93-4cc5-ba0a-63512c2857ba"
/>

<img width="356" height="1076" alt="image"
src="https://github.com/user-attachments/assets/24d03674-94d3-40ed-99ee-73395bafae6a"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-06-30 23:23:58 +01:00
committed by GitHub
parent 54042c8e5e
commit ff3e3bd0fc
36 changed files with 2425 additions and 161 deletions
+4
View File
@@ -20,6 +20,10 @@ frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
.github/workflows/tauri-build.yml:generic-api-key:402
+26 -10
View File
@@ -149,16 +149,32 @@ tasks:
# Pin jlink to JAVA_HOME so the bundled JRE matches the JDK the build
# uses. Bare `jlink` on PATH can resolve to an older system Java (the
# ubuntu runner ships Java 11), producing a runtime jlink:verify rejects.
- |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
#
# jdk.crypto.mscapi (the Windows certificate store / SunMSCAPI provider, used by
# hardware-backed cert signing) is a Windows-only module - it only exists in a Windows
# JDK's jmods, so it is added on Windows only or jlink fails to resolve it elsewhere.
- cmd: |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}},jdk.crypto.mscapi \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
platforms: [windows]
- cmd: |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
platforms: [linux, darwin]
# jlink emits its files mode 444 (read-only). Tauri's build-script
# resource copier preserves source permissions when staging
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
@@ -24,12 +24,14 @@ import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.Locale;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.bouncycastle.cert.jcajce.JcaCertStore;
@@ -50,6 +52,13 @@ public abstract class CreateSignatureBase implements SignatureInterface {
@Getter private Certificate[] certificateChain;
@Setter private String tsaUrl;
/**
* Provider that must service the signing operation. Set for hardware-held keys (SunPKCS11 for
* USB tokens, SunMSCAPI for the Windows store) so the {@link java.security.Signature} runs on
* the token. Left {@code null} for software keystores, which use the default provider.
*/
@Setter private Provider signingProvider;
/**
* Specifies whether the external signing scenario should be used. If set to {@code true},
* external signing will be performed and {@link SignatureInterface} will be used for signing.
@@ -80,25 +89,48 @@ public abstract class CreateSignatureBase implements SignatureInterface {
NoSuchAlgorithmException,
IOException,
CertificateException {
// grabs the first alias from the keystore and get the private key. An
// alternative method or constructor could be used for setting a specific
// alias that should be used.
this(keystore, pin, null);
}
/**
* Initialize the signature creator, optionally selecting a specific certificate by alias. A
* hardware token / the Windows store can hold several certificates, so the caller picks one;
* when {@code requestedAlias} is null the first usable entry is used (software keystore
* behaviour).
*
* @param keystore the keystore (software, PKCS#11 or Windows-MY)
* @param pin the keystore / token PIN, may be null for the Windows store
* @param requestedAlias the alias to sign with, or null to pick the first usable entry
*/
public CreateSignatureBase(KeyStore keystore, char[] pin, String requestedAlias)
throws KeyStoreException,
UnrecoverableKeyException,
NoSuchAlgorithmException,
IOException,
CertificateException {
if (requestedAlias != null
&& !requestedAlias.isBlank()
&& keystore.containsAlias(requestedAlias)) {
privateKey = (PrivateKey) keystore.getKey(requestedAlias, pin);
certificateChain = resolveChain(keystore, requestedAlias);
if (certificateChain == null) {
throw new IOException("Could not find certificate for alias " + requestedAlias);
}
checkValidity(certificateChain[0]);
return;
}
// grabs the first alias from the keystore and gets the private key.
Enumeration<String> aliases = keystore.aliases();
String alias;
Certificate cert = null;
while (cert == null && aliases.hasMoreElements()) {
alias = aliases.nextElement();
String alias = aliases.nextElement();
privateKey = (PrivateKey) keystore.getKey(alias, pin);
Certificate[] certChain = keystore.getCertificateChain(alias);
Certificate[] certChain = resolveChain(keystore, alias);
if (certChain != null) {
certificateChain = certChain;
cert = certChain[0];
if (cert instanceof X509Certificate) {
// avoid expired certificate
((X509Certificate) cert).checkValidity();
//// SigUtils.checkCertificateUsage((X509Certificate) cert);
}
checkValidity(cert);
}
}
@@ -107,6 +139,27 @@ public abstract class CreateSignatureBase implements SignatureInterface {
}
}
/**
* Resolve the certificate chain for an alias. PKCS#11 tokens and the Windows store frequently
* expose only the leaf certificate (a null chain), so fall back to the single certificate.
*/
private static Certificate[] resolveChain(KeyStore keystore, String alias)
throws KeyStoreException {
Certificate[] chain = keystore.getCertificateChain(alias);
if (chain != null && chain.length > 0) {
return chain;
}
Certificate single = keystore.getCertificate(alias);
return single != null ? new Certificate[] {single} : null;
}
private static void checkValidity(Certificate cert) throws CertificateException {
if (cert instanceof X509Certificate x509Cert) {
// avoid expired certificate
x509Cert.checkValidity();
}
}
public final void setPrivateKey(PrivateKey privateKey) {
this.privateKey = privateKey;
}
@@ -136,12 +189,18 @@ public abstract class CreateSignatureBase implements SignatureInterface {
try {
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
X509Certificate cert = (X509Certificate) certificateChain[0];
ContentSigner sha1Signer =
new JcaContentSignerBuilder("SHA256WithRSA").build(privateKey);
JcaContentSignerBuilder signerBuilder =
new JcaContentSignerBuilder(resolveSignatureAlgorithm(privateKey, cert));
// Hardware keys (PKCS#11 / Windows store) must sign on their own provider so the
// operation runs on the token; software keys use the default provider.
if (signingProvider != null) {
signerBuilder.setProvider(signingProvider);
}
ContentSigner signer = signerBuilder.build(privateKey);
gen.addSignerInfoGenerator(
new JcaSignerInfoGeneratorBuilder(
new JcaDigestCalculatorProviderBuilder().build())
.build(sha1Signer, cert));
.build(signer, cert));
gen.addCertificates(new JcaCertStore(Arrays.asList(certificateChain)));
CMSProcessableInputStream msg = new CMSProcessableInputStream(content);
CMSSignedData signedData = gen.generate(msg, false);
@@ -157,4 +216,26 @@ public abstract class CreateSignatureBase implements SignatureInterface {
throw new IOException(e);
}
}
/**
* Pick a SHA-256 signature algorithm that matches the key type. RSA keeps the historical
* default; EC / EdDSA tokens are common, so they are handled too.
*/
private static String resolveSignatureAlgorithm(PrivateKey key, X509Certificate cert) {
String alg = key.getAlgorithm();
if (alg == null || alg.isBlank()) {
alg = cert.getPublicKey().getAlgorithm();
}
alg = alg == null ? "" : alg.toUpperCase(Locale.ROOT);
if (alg.contains("ED25519") || alg.contains("EDDSA")) {
return "Ed25519";
}
if (alg.contains("EC")) { // EC, ECDSA
return "SHA256withECDSA";
}
if (alg.contains("DSA")) {
return "SHA256withDSA";
}
return "SHA256withRSA";
}
}
@@ -350,6 +350,18 @@ public class ConfigController {
"serverCertificateEnabled",
serverCertificateService != null && serverCertificateService.isEnabled());
// Hardware-backed signing (Windows store / USB PKCS#11 tokens) is only viable on the
// desktop bundle, where the backend runs locally in the user's session. The Tauri
// bundle signals this via STIRLING_PDF_TAURI_MODE (machineType is Server-jar there);
// the bare-jar desktop launcher signals it via a Client-* machineType.
boolean hardwareSigningAvailable =
Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"));
if (!hardwareSigningAvailable && applicationContext.containsBean("machineType")) {
String mt = applicationContext.getBean("machineType", String.class);
hardwareSigningAvailable = mt != null && mt.startsWith("Client-");
}
configData.put("hardwareSigningAvailable", hardwareSigningAvailable);
// Legal settings
configData.put(
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
@@ -70,10 +70,13 @@ import io.micrometer.common.util.StringUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -109,14 +112,17 @@ public class CertSignController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ServerCertificateServiceInterface serverCertificateService;
private final TempFileManager tempFileManager;
private final HardwareKeyStoreService hardwareKeyStoreService;
public CertSignController(
CustomPDFDocumentFactory pdfDocumentFactory,
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
TempFileManager tempFileManager) {
TempFileManager tempFileManager,
HardwareKeyStoreService hardwareKeyStoreService) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.serverCertificateService = serverCertificateService;
this.tempFileManager = tempFileManager;
this.hardwareKeyStoreService = hardwareKeyStoreService;
}
public static void sign(
@@ -170,7 +176,8 @@ public class CertSignController {
"This endpoint accepts a PDF file, a digital certificate and related"
+ " information to sign the PDF. It then returns the digitally signed PDF"
+ " file. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<Resource> signPDFWithCert(@ModelAttribute SignPDFWithCertRequest request)
public ResponseEntity<Resource> signPDFWithCert(
@ModelAttribute SignPDFWithCertRequest request, HttpServletRequest httpRequest)
throws Exception {
MultipartFile pdf = request.getFileInput();
String certType = request.getCertType();
@@ -196,6 +203,8 @@ public class CertSignController {
KeyStore ks = null;
String keystorePassword = password;
Provider signingProvider = null;
HardwareKeyStoreService.Pkcs11Session pkcs11Session = null;
switch (certType) {
case "PEM":
@@ -245,6 +254,31 @@ public class CertSignController {
ks = serverCertificateService.getServerKeyStore();
keystorePassword = serverCertificateService.getServerCertificatePassword();
break;
case "WINDOWS_STORE":
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
ks = hardwareKeyStoreService.loadWindowsKeyStore();
signingProvider = hardwareKeyStoreService.windowsProvider();
// PIN is prompted by the Windows CSP / token middleware, not passed here.
keystorePassword = password;
break;
case "PKCS11":
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
char[] pkcs11Pin = password != null ? password.toCharArray() : null;
try {
pkcs11Session =
hardwareKeyStoreService.openPkcs11(
request.getPkcs11LibraryPath(),
request.getPkcs11Slot(),
pkcs11Pin);
} finally {
if (pkcs11Pin != null) {
java.util.Arrays.fill(pkcs11Pin, '\0');
}
}
ks = pkcs11Session.keyStore();
signingProvider = pkcs11Session.provider();
keystorePassword = password;
break;
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
@@ -252,7 +286,9 @@ public class CertSignController {
"certificate type: " + certType);
}
CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray());
char[] pin = keystorePassword != null ? keystorePassword.toCharArray() : null;
CreateSignature createSignature =
new CreateSignature(ks, pin, request.getAlias(), signingProvider);
TempFile signedOut = tempFileManager.createManagedTempFile(".pdf");
try (OutputStream os = new FileOutputStream(signedOut.getFile())) {
sign(
@@ -269,6 +305,14 @@ public class CertSignController {
} catch (IOException e) {
signedOut.close();
throw e;
} finally {
// Clear the PIN copy and log out the token session once signing is done.
if (pin != null) {
java.util.Arrays.fill(pin, '\0');
}
if (pkcs11Session != null) {
pkcs11Session.close();
}
}
// Return the signed PDF
return WebResponseUtils.pdfFileToWebResponse(
@@ -324,7 +368,22 @@ public class CertSignController {
NoSuchAlgorithmException,
IOException,
CertificateException {
super(keystore, pin);
this(keystore, pin, null, null);
}
public CreateSignature(
KeyStore keystore, char[] pin, String alias, Provider signingProvider)
throws KeyStoreException,
UnrecoverableKeyException,
NoSuchAlgorithmException,
IOException,
CertificateException {
super(keystore, pin, alias);
setSigningProvider(signingProvider);
loadLogo();
}
private void loadLogo() throws IOException {
ClassPathResource resource = new ClassPathResource("static/images/signature.png");
try (InputStream is = resource.getInputStream()) {
logoFile = Files.createTempFile("signature", ".png").toFile();
@@ -0,0 +1,85 @@
package stirling.software.SPDF.controller.api.security;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
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.Pkcs11CertificatesRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
/**
* Lets the desktop frontend discover which hardware-backed signing options the local backend can
* reach (Windows certificate store, plugged-in USB / PKCS#11 tokens) and enumerate the certificates
* available to sign with. Enumeration endpoints are restricted to the desktop bundle, reached over
* loopback - see {@link HardwareKeyStoreService#assertLocalDesktop}.
*/
@RestController
@RequestMapping("/api/v1/security/cert-sign/hardware")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Security", description = "Security APIs")
public class HardwareSigningController {
private final HardwareKeyStoreService hardwareKeyStoreService;
@GetMapping("/capabilities")
@Operation(
summary = "Hardware signing capabilities",
description =
"Reports whether hardware-backed signing is available on this device and which"
+ " PKCS#11 driver libraries were detected. Returns desktop=false when"
+ " not running as the desktop app.")
public ResponseEntity<HardwareSigningCapabilities> getCapabilities() {
return ResponseEntity.ok(hardwareKeyStoreService.capabilities());
}
@GetMapping("/windows-certificates")
@Operation(
summary = "List Windows certificate store signing certificates",
description =
"Enumerates certificates with a usable private key from the current user's"
+ " Windows certificate store. Desktop-only, loopback-only.")
public ResponseEntity<List<HardwareCertificateInfo>> getWindowsCertificates(
HttpServletRequest request) throws Exception {
hardwareKeyStoreService.assertLocalDesktop(request);
return ResponseEntity.ok(hardwareKeyStoreService.listWindowsCertificates());
}
@PostMapping("/pkcs11-certificates")
@Operation(
summary = "List PKCS#11 token signing certificates",
description =
"Logs into a PKCS#11 token with the supplied PIN and enumerates its signing"
+ " certificates. The PIN is used only for this call. Desktop-only,"
+ " loopback-only.")
public ResponseEntity<List<HardwareCertificateInfo>> getPkcs11Certificates(
HttpServletRequest request, @RequestBody Pkcs11CertificatesRequest body)
throws Exception {
hardwareKeyStoreService.assertLocalDesktop(request);
char[] pin = body.pin() != null ? body.pin().toCharArray() : null;
try {
return ResponseEntity.ok(
hardwareKeyStoreService.listPkcs11Certificates(
body.libraryPath(), body.slot(), pin));
} finally {
if (pin != null) {
java.util.Arrays.fill(pin, '\0');
}
}
}
}
@@ -102,8 +102,27 @@ public class ValidateSignatureController {
try (PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
List<PDSignature> signatures = document.getSignatureDictionaries();
// Detect content appended outside every signature's ByteRange (added after signing). A
// properly signed document has its last signature cover all the way to EOF; if the
// furthest any signature reaches stops short of the file length, the tail is unsigned.
// Taking the max across all signatures avoids false positives on legitimately
// multi-signed PDFs, where an earlier signature intentionally omits later revisions.
long fileLength = file.getSize();
long maxCovered = 0;
for (PDSignature sig : signatures) {
int[] byteRange = sig.getByteRange();
if (byteRange != null && byteRange.length == 4) {
long end = (long) byteRange[2] + byteRange[3];
if (end > maxCovered) {
maxCovered = end;
}
}
}
boolean documentCovered = maxCovered <= 0 || maxCovered >= fileLength;
for (PDSignature sig : signatures) {
SignatureValidationResult result = new SignatureValidationResult();
result.setCoversEntireDocument(documentCovered);
try {
byte[] signedContent = sig.getSignedContent(file.getInputStream());
@@ -0,0 +1,21 @@
package stirling.software.SPDF.model.api.security;
/**
* Metadata for a single signing certificate discovered on a hardware source (Windows certificate
* store or a PKCS#11 token). Returned to the desktop frontend so the user can pick which
* certificate to sign with. Never carries private key material - signing always happens on the
* token / OS.
*/
public record HardwareCertificateInfo(
String alias,
String source,
String subject,
String issuer,
String subjectCommonName,
String issuerCommonName,
String serialNumber,
String keyAlgorithm,
String notBefore,
String notAfter,
boolean expired,
boolean notYetValid) {}
@@ -0,0 +1,19 @@
package stirling.software.SPDF.model.api.security;
import java.util.List;
/**
* Describes what hardware-backed signing the local backend can offer. Only meaningful on the
* desktop bundle, where the backend runs as a local sidecar in the signed-in user's session and can
* reach the Windows certificate store / a plugged-in USB PKCS#11 token.
*/
public record HardwareSigningCapabilities(
boolean desktop,
String osName,
boolean windowsStoreSupported,
boolean pkcs11Supported,
List<Pkcs11LibraryInfo> detectedLibraries) {
/** A PKCS#11 driver library detected on disk (or supplied via configuration). */
public record Pkcs11LibraryInfo(String name, String path) {}
}
@@ -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) {}
@@ -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",
@@ -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"
@@ -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();
@@ -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.
*
* <p>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<Pkcs11LibraryInfo> detectPkcs11Libraries() {
Map<String, List<String>> 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<Pkcs11LibraryInfo> 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<String> 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<HardwareCertificateInfo> 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<String, Provider> 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<HardwareCertificateInfo> 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<String> 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<HardwareCertificateInfo> listSigningCertificates(KeyStore ks, String source)
throws Exception {
List<HardwareCertificateInfo> certs = new ArrayList<>();
Enumeration<String> 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);
}
}
}
@@ -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<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> 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<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> 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<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> 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<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> 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<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> 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<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> 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<Resource> response = certSignController.signPDFWithCert(request);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -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);
}
}
}
}
+32 -3
View File
@@ -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,
@@ -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 <b>self-signed</b> certificate. Same <b>tamper-evident seal</b> and <b>audit trail</b>; typically shows <b>Unverified</b> in viewers."
title = "Auto - Zero-setup, instant system seal"
@@ -2502,6 +2529,12 @@ title = "How signatures work"
text = "Need recipient <b>Trusted</b> status? <b>Manual</b>. Need a fast, tamper-evident seal and audit trail with no setup? <b>Auto</b>."
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"
@@ -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 <b>self-signed</b> certificate. Same <b>tamper-evident seal</b> and <b>audit trail</b>; typically shows <b>Unverified</b> in viewers."
title = "Auto - Zero-setup, instant system seal"
@@ -2502,6 +2529,12 @@ title = "How signatures work"
text = "Need recipient <b>Trusted</b> status? <b>Manual</b>. Need a fast, tamper-evident seal and audit trail with no setup? <b>Auto</b>."
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"
@@ -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
];
@@ -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" && (
<HardwareCertificateSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
)}
{/* Signature Appearance Settings */}
<SignatureAppearanceSettings
parameters={parameters}
@@ -1,4 +1,6 @@
import { useEffect } from "react";
import { Stack, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters";
import { useAppConfig } from "@app/contexts/AppConfigContext";
@@ -8,18 +10,68 @@ interface CertificateTypeSettingsProps {
disabled?: boolean;
}
const sourceButtonStyle = {
flex: 1,
height: "auto",
minHeight: "44px",
fontSize: "11px",
} as const;
// Let labels wrap instead of clipping ("This device" was truncating to "This devi").
const sourceButtonStyles = {
label: { whiteSpace: "normal" as const, lineHeight: 1.15 },
} as const;
const CertificateTypeSettings = ({
parameters,
onParameterChange,
disabled = false,
}: CertificateTypeSettingsProps) => {
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 (
<Stack gap="md">
@@ -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}
>
<div
style={{ textAlign: "center", lineHeight: "1.1", fontSize: "11px" }}
>
Manual
</div>
{t("certSign.source.upload", "Upload")}
</Button>
{isServerCertificateEnabled && (
<Button
@@ -56,28 +94,26 @@ const CertificateTypeSettings = ({
color={
parameters.signMode === "AUTO" ? "green" : "var(--text-muted)"
}
onClick={() => {
onParameterChange("signMode", "AUTO");
// Clear cert type and files when switching to auto
onParameterChange("certType", "");
}}
onClick={selectServer}
disabled={disabled}
style={{
flex: 1,
height: "auto",
minHeight: "40px",
fontSize: "11px",
}}
style={sourceButtonStyle}
styles={sourceButtonStyles}
>
<div
style={{
textAlign: "center",
lineHeight: "1.1",
fontSize: "11px",
}}
>
Auto (server)
</div>
{t("certSign.source.server", "Server")}
</Button>
)}
{isHardwareAvailable && (
<Button
variant={parameters.signMode === "DEVICE" ? "filled" : "outline"}
color={
parameters.signMode === "DEVICE" ? "teal" : "var(--text-muted)"
}
onClick={selectDevice}
disabled={disabled}
style={sourceButtonStyle}
styles={sourceButtonStyles}
>
{t("certSign.source.device", "This device")}
</Button>
)}
</div>
@@ -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<HardwareCertificateInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [libraries, setLibraries] = useState<Pkcs11LibraryInfo[]>([]);
const [librarySelection, setLibrarySelection] = useState<string>("");
const [customLibrary, setCustomLibrary] = useState<string>("");
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 (
<Stack gap="md" align="center" py="sm">
<Loader size="sm" />
</Stack>
);
}
return (
<Stack gap="md">
{supported.windows && supported.pkcs11 && (
<div style={{ display: "flex", gap: "4px" }}>
<Button
variant={isWindowsStore ? "filled" : "outline"}
color={isWindowsStore ? "teal" : "var(--text-muted)"}
onClick={() => selectKind("WINDOWS_STORE")}
disabled={disabled || loading}
style={{ flex: 1, fontSize: "11px", minHeight: 40, height: "auto" }}
styles={{ label: { whiteSpace: "normal", lineHeight: 1.15 } }}
>
{t("certSign.format.windowsStore", "Windows certificate store")}
</Button>
<Button
variant={!isWindowsStore ? "filled" : "outline"}
color={!isWindowsStore ? "teal" : "var(--text-muted)"}
onClick={() => selectKind("PKCS11")}
disabled={disabled || loading}
style={{ flex: 1, fontSize: "11px", minHeight: 40, height: "auto" }}
styles={{ label: { whiteSpace: "normal", lineHeight: 1.15 } }}
>
{t("certSign.format.pkcs11", "USB Token")}
</Button>
</div>
)}
{isWindowsStore ? (
<>
<Text size="sm" c="dimmed">
{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.",
)}
</Text>
<Group gap="xs" align="flex-end">
<Select
style={{ flex: 1 }}
label={t("certSign.hardware.certificate", "Certificate")}
placeholder={t(
"certSign.hardware.selectCert",
"Select certificate",
)}
data={certOptions}
value={parameters.alias ?? null}
onChange={(v) => onParameterChange("alias", v ?? undefined)}
disabled={disabled || loading}
searchable
nothingFoundMessage={t(
"certSign.hardware.noCerts",
"No signing certificates found",
)}
/>
<Button
variant="default"
onClick={loadWindowsCerts}
disabled={disabled || loading}
>
{t("certSign.hardware.refresh", "Refresh")}
</Button>
</Group>
</>
) : (
<>
<Text size="sm" c="dimmed">
{t(
"certSign.hardware.pkcs11Hint",
"Select your token's PKCS#11 driver, enter the PIN, then list the certificates on the token.",
)}
</Text>
{libraries.length === 0 && (
<Alert color="yellow" variant="light">
{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.",
)}
</Alert>
)}
<Select
label={t("certSign.hardware.driver", "PKCS#11 driver")}
placeholder={t("certSign.hardware.selectDriver", "Select driver")}
data={libraryOptions}
value={librarySelection || null}
onChange={onLibraryChange}
disabled={disabled || loading}
/>
{selectedLibraryPath && (
<Text size="xs" c="dimmed" style={{ wordBreak: "break-all" }}>
{selectedLibraryPath}
</Text>
)}
{librarySelection === CUSTOM_LIBRARY_VALUE && (
<TextInput
label={t("certSign.hardware.driverPath", "Driver library path")}
placeholder={t(
"certSign.hardware.driverPathPlaceholder",
"Full path to your PKCS#11 driver (.dll, .so or .dylib)",
)}
value={customLibrary}
onChange={(e) => {
setCustomLibrary(e.currentTarget.value);
onParameterChange("pkcs11LibraryPath", e.currentTarget.value);
}}
disabled={disabled || loading}
/>
)}
<Group gap="xs" grow>
<TextInput
label={t("certSign.hardware.pin", "Token PIN")}
type="password"
value={parameters.password}
onChange={(e) =>
onParameterChange("password", e.currentTarget.value)
}
disabled={disabled || loading}
/>
<NumberInput
label={t("certSign.hardware.slot", "Slot (optional)")}
value={parameters.pkcs11Slot ?? ""}
onChange={(v) =>
onParameterChange(
"pkcs11Slot",
v === "" || v == null ? undefined : Number(v),
)
}
min={0}
disabled={disabled || loading}
/>
</Group>
<Button
variant="default"
onClick={loadPkcs11Certs}
disabled={
disabled ||
loading ||
!parameters.pkcs11LibraryPath ||
!parameters.password
}
>
{t("certSign.hardware.listCerts", "List certificates")}
</Button>
{certs.length > 0 && (
<Select
label={t("certSign.hardware.certificate", "Certificate")}
placeholder={t(
"certSign.hardware.selectCert",
"Select certificate",
)}
data={certOptions}
value={parameters.alias ?? null}
onChange={(v) => onParameterChange("alias", v ?? undefined)}
disabled={disabled || loading}
searchable
/>
)}
</>
)}
{loading && (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
{t("certSign.hardware.loading", "Reading certificates…")}
</Text>
</Group>
)}
{error && (
<Alert color="red" variant="light">
{error}
</Alert>
)}
</Stack>
);
};
export default HardwareCertificateSettings;
@@ -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 = ({
},
)}
</Badge>
{summary.signatureCount > 0 && (
{summary.validCount > 0 && (
<Badge color="green" variant="light">
{t(
"validateSignature.report.signaturesValid",
"{{count}} fully valid",
{
count: summary.fullyValidCount,
},
{ count: summary.validCount },
)}
</Badge>
)}
{summary.warningCount > 0 && (
<Badge color="yellow" variant="light">
{t(
"validateSignature.report.signaturesUnverified",
"{{count}} need review",
{ count: summary.warningCount },
)}
</Badge>
)}
{summary.invalidCount > 0 && (
<Badge color="red" variant="light">
{t(
"validateSignature.report.signaturesInvalid",
"{{count}} invalid",
{ count: summary.invalidCount },
)}
</Badge>
)}
@@ -188,25 +226,16 @@ const ValidateSignatureResults = ({
<Stack gap="sm" style={{ maxHeight: "20rem", overflowY: "auto" }}>
{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 (
<Stack
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import { buildCertSignFormData } from "@app/hooks/tools/certSign/useCertSignOperation";
import {
CertSignParameters,
defaultParameters,
} from "@app/hooks/tools/certSign/useCertSignParameters";
const pdf = () =>
new File(["%PDF-1.4"], "doc.pdf", { type: "application/pdf" });
const params = (
overrides: Partial<CertSignParameters>,
): 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();
});
});
@@ -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;
}
}
@@ -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;
}
@@ -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>,
): 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<SignatureValidationSignature>),
t,
);
expect(status.kind).toBe("invalid");
expect(status.details).toContain("boom");
});
});
@@ -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"),
@@ -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
@@ -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<HardwareSigningCapabilities> {
const response = await apiClient.get<HardwareSigningCapabilities>(
`${BASE}/capabilities`,
);
return response.data;
}
export async function listWindowsCertificates(): Promise<
HardwareCertificateInfo[]
> {
const response = await apiClient.get<HardwareCertificateInfo[]>(
`${BASE}/windows-certificates`,
);
return response.data;
}
export async function listPkcs11Certificates(params: {
libraryPath: string;
slot?: number;
pin: string;
}): Promise<HardwareCertificateInfo[]> {
const response = await apiClient.post<HardwareCertificateInfo[]>(
`${BASE}/pkcs11-certificates`,
{
libraryPath: params.libraryPath,
slot: params.slot ?? null,
pin: params.pin,
},
);
return response.data;
}
@@ -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 });
});
});
@@ -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<string, unknown>) {
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,
});
});
});
+24 -1
View File
@@ -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: (
<HardwareCertificateSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
]
: []),
{
title: t("certSign.appearance.stepTitle", "Signature Appearance"),
isCollapsed: base.settingsCollapsed || !areCertFilesConfigured(),
@@ -32,6 +32,7 @@ export interface AppConfig {
license?: string;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
hardwareSigningAvailable?: boolean;
enableMobileScanner?: boolean;
mobileScannerConvertToPdf?: boolean;
mobileScannerImageResolution?: string;
@@ -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"