mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -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
@@ -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
|
||||
|
||||
+96
-15
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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());
|
||||
|
||||
+63
-4
@@ -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();
|
||||
|
||||
+85
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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());
|
||||
|
||||
+21
@@ -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) {}
|
||||
+19
@@ -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) {}
|
||||
}
|
||||
+7
@@ -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) {}
|
||||
+27
-3
@@ -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",
|
||||
|
||||
+5
@@ -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"
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-8
@@ -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);
|
||||
|
||||
+146
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
+65
-36
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user