diff --git a/.gitleaksignore b/.gitleaksignore index 826fa899c4..783a098f68 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -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 diff --git a/.taskfiles/desktop.yml b/.taskfiles/desktop.yml index 239602beb5..c80196305b 100644 --- a/.taskfiles/desktop.yml +++ b/.taskfiles/desktop.yml @@ -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//runtime/jre/...`, so the diff --git a/app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java b/app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java index 1877f1c0a1..d6fd18f060 100644 --- a/app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java +++ b/app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java @@ -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 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"; + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 3ea525a64e..27477f9c2a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -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()); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index ff8b7eb811..7ad69ebc5b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -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 signPDFWithCert(@ModelAttribute SignPDFWithCertRequest request) + public ResponseEntity 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(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/HardwareSigningController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/HardwareSigningController.java new file mode 100644 index 0000000000..b2df936939 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/HardwareSigningController.java @@ -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 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> 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> 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'); + } + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java index 899ac6eddc..ecfc0ad2db 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java @@ -102,8 +102,27 @@ public class ValidateSignatureController { try (PDDocument document = pdfDocumentFactory.load(file.getInputStream())) { List 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()); diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/HardwareCertificateInfo.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/HardwareCertificateInfo.java new file mode 100644 index 0000000000..368d5e27e4 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/HardwareCertificateInfo.java @@ -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) {} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/HardwareSigningCapabilities.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/HardwareSigningCapabilities.java new file mode 100644 index 0000000000..348ca4a50d --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/HardwareSigningCapabilities.java @@ -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 detectedLibraries) { + + /** A PKCS#11 driver library detected on disk (or supplied via configuration). */ + public record Pkcs11LibraryInfo(String name, String path) {} +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/Pkcs11CertificatesRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/Pkcs11CertificatesRequest.java new file mode 100644 index 0000000000..d80fd55557 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/Pkcs11CertificatesRequest.java @@ -0,0 +1,7 @@ +package stirling.software.SPDF.model.api.security; + +/** + * Request body for enumerating the certificates on a PKCS#11 token. The PIN is required to log into + * the token; it is used only for the duration of the call and never stored. + */ +public record Pkcs11CertificatesRequest(String libraryPath, Integer slot, String pin) {} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java index 9b063d19fd..144f516d3a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java @@ -14,8 +14,10 @@ import stirling.software.common.model.api.PDFFile; public class SignPDFWithCertRequest extends PDFFile { @Schema( - description = "The type of the digital certificate", - allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER"}, + description = + "The type of the digital certificate. WINDOWS_STORE and PKCS11 are" + + " hardware-backed and only available in the desktop app.", + allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER", "WINDOWS_STORE", "PKCS11"}, requiredMode = Schema.RequiredMode.REQUIRED) private String certType; @@ -39,9 +41,31 @@ public class SignPDFWithCertRequest extends PDFFile { @Schema(description = "The JKS keystore file (Java Key Store)") private MultipartFile jksFile; - @Schema(description = "The password for the keystore or the private key", format = "password") + @Schema( + description = + "The password for the keystore / private key, or the token PIN for PKCS11", + format = "password") private String password; + @Schema( + description = + "The alias of the certificate to sign with. Required for WINDOWS_STORE and" + + " recommended for PKCS11 tokens holding multiple certificates.") + private String alias; + + @Schema( + description = + "Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must" + + " be an allowed driver - a detected one or configured via" + + " STIRLING_PKCS11_LIBRARIES.") + private String pkcs11LibraryPath; + + @Schema( + description = + "Optional PKCS#11 slot index. When omitted the first slot with a token is" + + " used.") + private Integer pkcs11Slot; + @Schema( description = "Whether to visually show the signature in the PDF file", defaultValue = "false", diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java index b45aeefc38..5be131754e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java @@ -18,6 +18,11 @@ public class SignatureValidationResult { // Time validation private boolean notExpired; + // Whether the document's signatures cover all of its bytes. False when content was appended + // outside every signature's ByteRange (i.e. added after signing), which the signature can't + // attest to even though the signed bytes themselves remain cryptographically intact. + private boolean coversEntireDocument = true; + // Revocation validation private boolean revocationChecked; // true if PKIX revocation was enabled private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown" diff --git a/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java b/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java index 1167a5bd5e..44ed413584 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java @@ -115,7 +115,8 @@ public class CertificateValidationService { log.info("Enabled AIA certificate fetching and revocation checking"); } - // Trust only what we explicitly opt into: + // Trust only what we explicitly opt into. Desktop follows the same flags as the server - + // our own signing cert is trusted via serverAsAnchor, not by force-loading every system CA. if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor(); if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore(); if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts(); diff --git a/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java b/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java new file mode 100644 index 0000000000..988b935f27 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java @@ -0,0 +1,483 @@ +package stirling.software.SPDF.service; + +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.Provider; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.security.auth.x500.X500Principal; + +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x500.style.IETFUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.api.security.HardwareCertificateInfo; +import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities; +import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities.Pkcs11LibraryInfo; +import stirling.software.common.util.ExceptionUtils; + +/** + * Bridges PDF signing to hardware-held keys: the Windows certificate store (via the JDK SunMSCAPI + * provider) and USB / smart-card PKCS#11 tokens (via SunPKCS11). The private key never leaves the + * token - the JCA routes the actual signing operation onto the hardware. + * + *

These code paths are gated to the desktop bundle. On a hosted server the backend cannot reach + * a remote user's USB token anyway, and loading an arbitrary PKCS#11 driver library is effectively + * native code execution, so PKCS#11 libraries are additionally restricted to an allowlist of + * detected / configured driver paths. + */ +@Service +@Slf4j +public class HardwareKeyStoreService { + + public static final String SOURCE_WINDOWS_STORE = "WINDOWS_STORE"; + public static final String SOURCE_PKCS11 = "PKCS11"; + + private static final String WINDOWS_KEYSTORE_TYPE = "Windows-MY"; + private static final String MSCAPI_PROVIDER = "SunMSCAPI"; + private static final String PKCS11_BASE_PROVIDER = "SunPKCS11"; + + /** Extra PKCS#11 driver libraries, absolute paths, comma/`File.pathSeparator` separated. */ + private static final String PKCS11_LIBRARIES_ENV = "STIRLING_PKCS11_LIBRARIES"; + + /** Same as {@link #PKCS11_LIBRARIES_ENV} but as a JVM system property. */ + private static final String PKCS11_LIBRARIES_PROP = "stirling.pkcs11.libraries"; + + private final String machineType; + + public HardwareKeyStoreService( + @Autowired(required = false) @Qualifier("machineType") String machineType) { + this.machineType = machineType; + } + + // --------------------------------------------------------------------- + // Gating + // --------------------------------------------------------------------- + + /** + * True when running as the desktop bundle (local sidecar in the user's session). The Tauri + * bundle sets {@code STIRLING_PDF_TAURI_MODE=true} (with {@code BROWSER_OPEN=false}, so + * machineType is {@code Server-jar} there); the bare-jar desktop launcher instead yields a + * {@code Client-*} machineType. Accept either. + */ + public boolean isDesktop() { + if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) { + return true; + } + return machineType != null && machineType.startsWith("Client-"); + } + + public boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + } + + private boolean windowsStoreSupported() { + return isWindows() && Security.getProvider(MSCAPI_PROVIDER) != null; + } + + private boolean pkcs11Supported() { + return Security.getProvider(PKCS11_BASE_PROVIDER) != null; + } + + /** Reject anything that is not the desktop bundle reached over loopback. */ + public void assertLocalDesktop(HttpServletRequest request) { + if (!isDesktop()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.hardwareSigningDesktopOnly", + "Hardware-backed signing is only available in the Stirling PDF desktop app"); + } + if (request != null && !isLocalRequest(request.getRemoteAddr())) { + throw ExceptionUtils.createIllegalArgumentException( + "error.hardwareSigningLocalOnly", + "Hardware-backed signing can only be used from this device"); + } + } + + /** + * True when the request originates from this machine. Loopback (incl. IPv4-mapped IPv6 like + * {@code ::ffff:127.0.0.1}) counts, as does any address bound to a local interface - so it + * works whether the desktop app reaches the sidecar over {@code localhost} or a LAN IP, while + * still rejecting other machines on the network. + */ + static boolean isLocalRequest(String remoteAddr) { + if (remoteAddr == null || remoteAddr.isBlank()) { + return false; + } + try { + InetAddress addr = InetAddress.getByName(remoteAddr); + if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()) { + return true; + } + return NetworkInterface.networkInterfaces() + .anyMatch(nif -> nif.inetAddresses().anyMatch(local -> local.equals(addr))); + } catch (Exception e) { + return false; + } + } + + // --------------------------------------------------------------------- + // Capabilities + // --------------------------------------------------------------------- + + public HardwareSigningCapabilities capabilities() { + boolean desktop = isDesktop(); + if (!desktop) { + return new HardwareSigningCapabilities(false, "", false, false, List.of()); + } + return new HardwareSigningCapabilities( + true, + System.getProperty("os.name", ""), + windowsStoreSupported(), + pkcs11Supported(), + detectPkcs11Libraries()); + } + + /** + * Known driver install locations plus any paths configured via {@code + * STIRLING_PKCS11_LIBRARIES}. + */ + public List detectPkcs11Libraries() { + Map> candidates = new LinkedHashMap<>(); + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + + if (os.contains("win")) { + candidates.put( + "OpenSC", + List.of( + "C:\\Program Files\\OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll")); + candidates.put( + "YubiKey (ykcs11)", + List.of("C:\\Program Files\\Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll")); + candidates.put("SafeNet eToken", List.of("C:\\Windows\\System32\\eTPKCS11.dll")); + candidates.put( + "Thales/Gemalto IDPrime", List.of("C:\\Windows\\System32\\IDPrimePKCS11.dll")); + candidates.put( + "SoftHSM2", + List.of( + "C:\\Program Files\\SoftHSM2\\lib\\softhsm2-x64.dll", + "C:\\SoftHSM2\\lib\\softhsm2-x64.dll")); + } else if (os.contains("mac")) { + candidates.put( + "OpenSC", + List.of( + "/Library/OpenSC/lib/opensc-pkcs11.so", + "/usr/local/lib/opensc-pkcs11.so")); + candidates.put( + "YubiKey (ykcs11)", + List.of("/usr/local/lib/libykcs11.dylib", "/opt/homebrew/lib/libykcs11.dylib")); + candidates.put( + "SoftHSM2", + List.of( + "/usr/local/lib/softhsm/libsofthsm2.so", + "/opt/homebrew/lib/softhsm/libsofthsm2.so")); + } else { + candidates.put( + "OpenSC", + List.of( + "/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so", + "/usr/lib/opensc-pkcs11.so", + "/usr/lib64/opensc-pkcs11.so")); + candidates.put( + "YubiKey (ykcs11)", + List.of( + "/usr/lib/x86_64-linux-gnu/libykcs11.so", + "/usr/local/lib/libykcs11.so")); + candidates.put( + "SoftHSM2", + List.of( + "/usr/lib/softhsm/libsofthsm2.so", + "/usr/lib64/softhsm/libsofthsm2.so", + "/usr/local/lib/softhsm/libsofthsm2.so")); + } + + List result = new ArrayList<>(); + candidates.forEach( + (name, paths) -> + paths.stream() + .filter(p -> Files.exists(Path.of(p))) + .findFirst() + .ifPresent(p -> result.add(new Pkcs11LibraryInfo(name, p)))); + + for (String configured : configuredLibraries()) { + if (Files.exists(Path.of(configured)) + && result.stream().noneMatch(l -> sameFile(l.path(), configured))) { + result.add(new Pkcs11LibraryInfo(fileName(configured), configured)); + } + } + return result; + } + + private static List configuredLibraries() { + String env = System.getenv(PKCS11_LIBRARIES_ENV); + String prop = System.getProperty(PKCS11_LIBRARIES_PROP); + StringBuilder combined = new StringBuilder(); + if (env != null && !env.isBlank()) { + combined.append(env); + } + if (prop != null && !prop.isBlank()) { + if (combined.length() > 0) { + combined.append(java.io.File.pathSeparator); + } + combined.append(prop); + } + if (combined.length() == 0) { + return List.of(); + } + return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + + // --------------------------------------------------------------------- + // Windows certificate store + // --------------------------------------------------------------------- + + public KeyStore loadWindowsKeyStore() throws Exception { + if (!windowsStoreSupported()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.windowsStoreUnavailable", + "The Windows certificate store is not available on this platform"); + } + KeyStore ks = KeyStore.getInstance(WINDOWS_KEYSTORE_TYPE, MSCAPI_PROVIDER); + ks.load(null, null); + return ks; + } + + public Provider windowsProvider() { + return Security.getProvider(MSCAPI_PROVIDER); + } + + public List listWindowsCertificates() throws Exception { + return listSigningCertificates(loadWindowsKeyStore(), SOURCE_WINDOWS_STORE); + } + + // --------------------------------------------------------------------- + // PKCS#11 tokens + // --------------------------------------------------------------------- + + /** + * A configured, logged-in PKCS#11 keystore plus the provider that must service signing. Closing + * logs the session out so the PIN-authenticated session does not outlive the request. The + * provider stays cached (logout is C_Logout, not C_Finalize) so the next call reuses the same + * C_Initialize. Single-user desktop model - logout is best-effort. + */ + public record Pkcs11Session(KeyStore keyStore, Provider provider) implements AutoCloseable { + @Override + public void close() { + if (provider instanceof java.security.AuthProvider authProvider) { + try { + authProvider.logout(); + } catch (Exception e) { + // Not logged in / already logged out - nothing to clear. + } + } + } + } + + // One SunPKCS11 provider per driver+slot, reused across enumerate + sign. A PKCS#11 module + // typically allows C_Initialize only once per process, so configuring a fresh provider on every + // call races with the previous (not-yet-GC'd) one - the cause of "first sign fails, second + // works". Reusing the provider keeps a single C_Initialize alive for the session. + private final java.util.concurrent.ConcurrentHashMap pkcs11Providers = + new java.util.concurrent.ConcurrentHashMap<>(); + + public Pkcs11Session openPkcs11(String libraryPath, Integer slot, char[] pin) throws Exception { + validateLibraryAllowed(libraryPath); + if (!pkcs11Supported()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.pkcs11Unavailable", "PKCS#11 support is not available in this runtime"); + } + + String cacheKey = libraryPath + "|" + slot; + Provider provider = + pkcs11Providers.computeIfAbsent( + cacheKey, k -> buildPkcs11Provider(libraryPath, slot)); + try { + KeyStore ks = KeyStore.getInstance("PKCS11", provider); + ks.load(null, pin); + return new Pkcs11Session(ks, provider); + } catch (Exception e) { + // A wrong PIN must not be retried: a second C_Login would burn the token's retry + // counter twice per attempt and can lock the token. Only rebuild on provider/init + // failures (e.g. token removed/re-inserted leaving a stale provider). + if (isAuthFailure(e)) { + throw e; + } + pkcs11Providers.remove(cacheKey, provider); + Provider fresh = + pkcs11Providers.computeIfAbsent( + cacheKey, k -> buildPkcs11Provider(libraryPath, slot)); + KeyStore ks = KeyStore.getInstance("PKCS11", fresh); + ks.load(null, pin); + return new Pkcs11Session(ks, fresh); + } + } + + /** True when the failure is a bad/locked PIN rather than a provider/init/device problem. */ + private static boolean isAuthFailure(Throwable t) { + while (t != null) { + if (t instanceof javax.security.auth.login.FailedLoginException) { + return true; + } + String msg = t.getMessage(); + if (msg != null && msg.toUpperCase(Locale.ROOT).contains("CKR_PIN")) { + return true; // CKR_PIN_INCORRECT / CKR_PIN_LOCKED / CKR_PIN_INVALID / ... + } + t = t.getCause(); + } + return false; + } + + private Provider buildPkcs11Provider(String libraryPath, Integer slot) { + StringBuilder config = new StringBuilder(); + config.append("--name=").append(providerName(libraryPath)).append('\n'); + config.append("library=").append(libraryPath).append('\n'); + if (slot != null) { + config.append("slot=").append(slot).append('\n'); + } + try { + return Security.getProvider(PKCS11_BASE_PROVIDER).configure(config.toString()); + } catch (Exception e) { + throw ExceptionUtils.createIllegalArgumentException( + "error.pkcs11ConfigFailed", + "Failed to initialise the PKCS#11 driver: {0}", + e.getMessage()); + } + } + + public List listPkcs11Certificates( + String libraryPath, Integer slot, char[] pin) throws Exception { + try (Pkcs11Session session = openPkcs11(libraryPath, slot, pin)) { + return listSigningCertificates(session.keyStore(), SOURCE_PKCS11); + } + } + + /** + * Reject driver paths that are not detected on disk / configured - blocks arbitrary DLL loads. + */ + public void validateLibraryAllowed(String libraryPath) { + if (libraryPath == null || libraryPath.isBlank()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.pkcs11LibraryRequired", "A PKCS#11 driver library path is required"); + } + Set allowed = + detectPkcs11Libraries().stream() + .map(Pkcs11LibraryInfo::path) + .collect(Collectors.toSet()); + boolean ok = allowed.stream().anyMatch(p -> sameFile(p, libraryPath)); + if (!ok) { + throw ExceptionUtils.createIllegalArgumentException( + "error.pkcs11LibraryNotAllowed", + "PKCS#11 driver is not in the allowed list. Add it via the" + + " STIRLING_PKCS11_LIBRARIES setting: {0}", + libraryPath); + } + } + + // --------------------------------------------------------------------- + // Shared helpers + // --------------------------------------------------------------------- + + private List listSigningCertificates(KeyStore ks, String source) + throws Exception { + List certs = new ArrayList<>(); + Enumeration aliases = ks.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (!ks.isKeyEntry(alias)) { + continue; // only entries we can sign with + } + Certificate cert = ks.getCertificate(alias); + if (cert instanceof X509Certificate x509) { + certs.add(toInfo(alias, x509, source)); + } + } + return certs; + } + + private static HardwareCertificateInfo toInfo( + String alias, X509Certificate cert, String source) { + java.util.Date now = new java.util.Date(); + return new HardwareCertificateInfo( + alias, + source, + cert.getSubjectX500Principal().getName(), + cert.getIssuerX500Principal().getName(), + commonName(cert.getSubjectX500Principal()), + commonName(cert.getIssuerX500Principal()), + cert.getSerialNumber().toString(16), + cert.getPublicKey().getAlgorithm(), + cert.getNotBefore().toInstant().toString(), + cert.getNotAfter().toInstant().toString(), + now.after(cert.getNotAfter()), + now.before(cert.getNotBefore())); + } + + private static String commonName(X500Principal principal) { + try { + X500Name x500Name = new X500Name(principal.getName()); + RDN[] rdns = x500Name.getRDNs(BCStyle.CN); + if (rdns.length > 0) { + return IETFUtils.valueToString(rdns[0].getFirst().getValue()); + } + } catch (Exception e) { + log.debug("Could not parse common name from {}", principal.getName()); + } + return principal.getName(); + } + + private static String providerName(String libraryPath) { + String base = fileName(libraryPath).replaceAll("[^a-zA-Z0-9]", ""); + if (base.isEmpty()) { + base = "token"; + } + return "StirlingHW" + base; + } + + private static String fileName(String path) { + try { + return Path.of(path).getFileName().toString(); + } catch (Exception e) { + return path; + } + } + + private static boolean sameFile(String a, String b) { + if (a == null || b == null) { + return false; + } + try { + Path pa = Path.of(a); + Path pb = Path.of(b); + if (Files.exists(pa) && Files.exists(pb)) { + return Files.isSameFile(pa, pb); + } + return pa.toAbsolutePath().normalize().equals(pb.toAbsolutePath().normalize()); + } catch (Exception e) { + return a.equalsIgnoreCase(b); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java index 2050e3c53c..5194ae55ec 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java @@ -30,7 +30,10 @@ import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; +import jakarta.servlet.http.HttpServletRequest; + import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest; +import stirling.software.SPDF.service.HardwareKeyStoreService; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; @@ -51,6 +54,8 @@ class CertSignControllerTest { @Mock private CustomPDFDocumentFactory pdfDocumentFactory; @Mock private TempFileManager tempFileManager; + @Mock private HardwareKeyStoreService hardwareKeyStoreService; + @Mock private HttpServletRequest httpRequest; @InjectMocks private CertSignController certSignController; @@ -169,7 +174,8 @@ class CertSignControllerTest { request.setPageNumber(1); request.setShowLogo(false); - ResponseEntity response = certSignController.signPDFWithCert(request); + ResponseEntity response = + certSignController.signPDFWithCert(request, httpRequest); assertNotNull(response.getBody()); assertTrue(drainBody(response).length > 0); @@ -195,7 +201,8 @@ class CertSignControllerTest { request.setPageNumber(1); request.setShowLogo(false); - ResponseEntity response = certSignController.signPDFWithCert(request); + ResponseEntity response = + certSignController.signPDFWithCert(request, httpRequest); assertNotNull(response.getBody()); assertTrue(drainBody(response).length > 0); @@ -221,7 +228,7 @@ class CertSignControllerTest { IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, - () -> certSignController.signPDFWithCert(request)); + () -> certSignController.signPDFWithCert(request, httpRequest)); assertTrue(exception.getMessage().contains("PKCS12 keystore")); } @@ -247,7 +254,8 @@ class CertSignControllerTest { request.setPageNumber(1); request.setShowLogo(false); - ResponseEntity response = certSignController.signPDFWithCert(request); + ResponseEntity response = + certSignController.signPDFWithCert(request, httpRequest); assertNotNull(response.getBody()); assertTrue(drainBody(response).length > 0); @@ -278,7 +286,8 @@ class CertSignControllerTest { request.setPageNumber(1); request.setShowLogo(false); - ResponseEntity response = certSignController.signPDFWithCert(request); + ResponseEntity response = + certSignController.signPDFWithCert(request, httpRequest); assertNotNull(response.getBody()); assertTrue(drainBody(response).length > 0); @@ -309,7 +318,8 @@ class CertSignControllerTest { request.setPageNumber(1); request.setShowLogo(false); - ResponseEntity response = certSignController.signPDFWithCert(request); + ResponseEntity response = + certSignController.signPDFWithCert(request, httpRequest); assertNotNull(response.getBody()); assertTrue(drainBody(response).length > 0); @@ -340,7 +350,8 @@ class CertSignControllerTest { request.setPageNumber(1); request.setShowLogo(false); - ResponseEntity response = certSignController.signPDFWithCert(request); + ResponseEntity response = + certSignController.signPDFWithCert(request, httpRequest); assertNotNull(response.getBody()); assertTrue(drainBody(response).length > 0); @@ -371,7 +382,8 @@ class CertSignControllerTest { request.setPageNumber(1); request.setShowLogo(false); - ResponseEntity response = certSignController.signPDFWithCert(request); + ResponseEntity response = + certSignController.signPDFWithCert(request, httpRequest); assertNotNull(response.getBody()); assertTrue(drainBody(response).length > 0); diff --git a/app/core/src/test/java/stirling/software/SPDF/service/HardwareKeyStoreServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/HardwareKeyStoreServiceTest.java new file mode 100644 index 0000000000..50024a4274 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/HardwareKeyStoreServiceTest.java @@ -0,0 +1,146 @@ +package stirling.software.SPDF.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import jakarta.servlet.http.HttpServletRequest; + +import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities; + +/** Unit tests for the gating / allowlist logic that protects the hardware signing paths. */ +class HardwareKeyStoreServiceTest { + + private static final String PKCS11_PROP = "stirling.pkcs11.libraries"; + + private HardwareKeyStoreService service(String machineType) { + return new HardwareKeyStoreService(machineType); + } + + @Test + void isDesktop_trueOnlyForClientMachineTypes() { + assertTrue(service("Client-windows").isDesktop()); + assertTrue(service("Client-mac").isDesktop()); + assertTrue(service("Client-unix").isDesktop()); + assertFalse(service("Server-jar").isDesktop()); + assertFalse(service("Docker").isDesktop()); + assertFalse(service(null).isDesktop()); + } + + @Test + void isDesktop_trueInTauriModeEvenWithoutClientMachineType() { + // The Tauri bundle sets STIRLING_PDF_TAURI_MODE=true while machineType stays Server-jar. + String previous = System.getProperty("STIRLING_PDF_TAURI_MODE"); + try { + System.setProperty("STIRLING_PDF_TAURI_MODE", "true"); + assertTrue(service("Server-jar").isDesktop()); + assertTrue(service(null).isDesktop()); + } finally { + if (previous == null) { + System.clearProperty("STIRLING_PDF_TAURI_MODE"); + } else { + System.setProperty("STIRLING_PDF_TAURI_MODE", previous); + } + } + } + + @Test + void capabilities_notDesktop_reportsUnavailable() { + HardwareSigningCapabilities caps = service("Server-jar").capabilities(); + assertFalse(caps.desktop()); + assertFalse(caps.windowsStoreSupported()); + assertFalse(caps.pkcs11Supported()); + assertTrue(caps.detectedLibraries().isEmpty()); + } + + @Test + void capabilities_desktop_reportsOsName() { + HardwareSigningCapabilities caps = service("Client-windows").capabilities(); + assertTrue(caps.desktop()); + assertFalse(caps.osName().isBlank()); + } + + @Test + void assertLocalDesktop_rejectsNonDesktop() { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + assertThrows( + IllegalArgumentException.class, + () -> service("Server-jar").assertLocalDesktop(request)); + } + + @Test + void assertLocalDesktop_rejectsRemoteCallerEvenOnDesktop() { + HttpServletRequest request = mock(HttpServletRequest.class); + // 203.0.113.0/24 is TEST-NET-3 (RFC 5737) - never a real local interface address. + when(request.getRemoteAddr()).thenReturn("203.0.113.5"); + assertThrows( + IllegalArgumentException.class, + () -> service("Client-windows").assertLocalDesktop(request)); + } + + @Test + void assertLocalDesktop_allowsLoopbackOnDesktop() { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + assertDoesNotThrow(() -> service("Client-windows").assertLocalDesktop(request)); + // No servlet context (e.g. internal call) is also allowed. + assertDoesNotThrow(() -> service("Client-windows").assertLocalDesktop(null)); + } + + @Test + void isLocalRequest_acceptsLoopbackForms_rejectsRemote() { + assertTrue(HardwareKeyStoreService.isLocalRequest("127.0.0.1")); + assertTrue(HardwareKeyStoreService.isLocalRequest("::1")); + assertTrue(HardwareKeyStoreService.isLocalRequest("0:0:0:0:0:0:0:1")); + // IPv4-mapped IPv6 loopback - what Tomcat reports for the desktop webview. + assertTrue(HardwareKeyStoreService.isLocalRequest("::ffff:127.0.0.1")); + assertFalse(HardwareKeyStoreService.isLocalRequest("203.0.113.5")); + assertFalse(HardwareKeyStoreService.isLocalRequest(null)); + } + + @Test + void validateLibraryAllowed_blankPath_throws() { + assertThrows( + IllegalArgumentException.class, + () -> service("Client-windows").validateLibraryAllowed(" ")); + } + + @Test + void validateLibraryAllowed_unknownPath_throws() { + assertThrows( + IllegalArgumentException.class, + () -> + service("Client-windows") + .validateLibraryAllowed("/definitely/not/a/real/driver.so")); + } + + @Test + void validateLibraryAllowed_configuredPath_isAllowed(@TempDir Path tempDir) throws Exception { + Path fakeDriver = Files.createFile(tempDir.resolve("fake-pkcs11.so")); + String previous = System.getProperty(PKCS11_PROP); + try { + System.setProperty(PKCS11_PROP, fakeDriver.toString()); + HardwareKeyStoreService service = service("Client-windows"); + assertDoesNotThrow(() -> service.validateLibraryAllowed(fakeDriver.toString())); + assertTrue( + service.detectPkcs11Libraries().stream() + .anyMatch(l -> l.path().equals(fakeDriver.toString()))); + } finally { + if (previous == null) { + System.clearProperty(PKCS11_PROP); + } else { + System.setProperty(PKCS11_PROP, previous); + } + } + } +} diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py index e470eb1908..3e179eaf0c 100644 --- a/engine/src/stirling/models/tool_models.py +++ b/engine/src/stirling/models/tool_models.py @@ -314,7 +314,7 @@ class CbzToPdfParams(ApiModel): class CertType(StrEnum): """ - The type of the digital certificate + The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app. """ pem = "PEM" @@ -322,17 +322,35 @@ class CertType(StrEnum): pfx = "PFX" jks = "JKS" server = "SERVER" + windows_store = "WINDOWS_STORE" + pkcs11 = "PKCS11" class CertSignParams(ApiModel): - cert_type: CertType = Field(..., description="The type of the digital certificate") + alias: str | None = Field( + None, + description="The alias of the certificate to sign with. Required for WINDOWS_STORE and recommended for PKCS11 tokens holding multiple certificates.", + ) + cert_type: CertType = Field( + ..., + description="The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.", + ) location: str = Field("SPDF", description="The location where the PDF is signed") name: str = Field("SPDF", description="The name of the signer") page_number: int = Field( 1, description="The page number where the signature should be visible. This is required if showSignature is set to true", ) - password: SecretStr | None = Field(None, description="The password for the keystore or the private key") + password: SecretStr | None = Field( + None, description="The password for the keystore / private key, or the token PIN for PKCS11" + ) + pkcs11_library_path: str | None = Field( + None, + description="Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must be an allowed driver - a detected one or configured via STIRLING_PKCS11_LIBRARIES.", + ) + pkcs11_slot: int | None = Field( + None, description="Optional PKCS#11 slot index. When omitted the first slot with a token is used." + ) reason: str = Field("Signed by SPDF", description="The reason for signing the PDF") show_logo: bool = Field(True, description="Whether to visually show a signature logo along with the signature") show_signature: bool = Field(False, description="Whether to visually show the signature in the PDF file") @@ -961,6 +979,12 @@ class PdfToXlsxParams(ApiModel): ) +class Pkcs11CertificatesParams(ApiModel): + library_path: str | None = None + pin: str | None = None + slot: int | None = None + + class CustomMode(StrEnum): """ The custom mode for page rearrangement. Valid values are: @@ -1493,6 +1517,7 @@ class Model( | AddWatermarkParams | AutoRedactParams | CertSignParams + | Pkcs11CertificatesParams | SessionsParams | ValidateCertificateParams | RedactParams @@ -1562,6 +1587,7 @@ class Model( | AddWatermarkParams | AutoRedactParams | CertSignParams + | Pkcs11CertificatesParams | SessionsParams | ValidateCertificateParams | RedactParams @@ -1632,6 +1658,7 @@ type ParamToolModel = ( | AddWatermarkParams | AutoRedactParams | CertSignParams + | Pkcs11CertificatesParams | SessionsParams | ValidateCertificateParams | RedactParams @@ -1703,6 +1730,7 @@ class ToolEndpoint(StrEnum): ADD_WATERMARK = "/api/v1/security/add-watermark" AUTO_REDACT = "/api/v1/security/auto-redact" CERT_SIGN = "/api/v1/security/cert-sign" + PKCS11_CERTIFICATES = "/api/v1/security/cert-sign/hardware/pkcs11-certificates" SESSIONS = "/api/v1/security/cert-sign/sessions" VALIDATE_CERTIFICATE = "/api/v1/security/cert-sign/validate-certificate" REDACT = "/api/v1/security/redact" @@ -1772,6 +1800,7 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = { ToolEndpoint.ADD_WATERMARK: AddWatermarkParams, ToolEndpoint.AUTO_REDACT: AutoRedactParams, ToolEndpoint.CERT_SIGN: CertSignParams, + ToolEndpoint.PKCS11_CERTIFICATES: Pkcs11CertificatesParams, ToolEndpoint.SESSIONS: SessionsParams, ToolEndpoint.VALIDATE_CERTIFICATE: ValidateCertificateParams, ToolEndpoint.REDACT: RedactParams, diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index e206427288..628ba99a36 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2463,9 +2463,39 @@ noTeam = "No Team" noUsers = "No other users found." placeholder = "Select users..." +[certSign.device] +stepTitle = "This device" + [certSign.error] failed = "An error occurred whilst processing signatures." +[certSign.format] +pkcs11 = "USB Token" +windowsStore = "Windows certificate store" + +[certSign.hardware] +certificate = "Certificate" +customLibrary = "Custom driver path…" +driver = "PKCS#11 driver" +driverPath = "Driver library path" +driverPathPlaceholder = "Full path to your PKCS#11 driver (.dll, .so or .dylib)" +expired = "expired" +expires = "expires" +listCerts = "List certificates" +loading = "Reading certificates…" +noCerts = "No signing certificates found" +noDriver = "No PKCS#11 driver was detected. Install your token's driver (e.g. OpenSC), then reopen this - or enter the driver path manually below." +notYetValid = "not yet valid" +pin = "Token PIN" +pkcs11Hint = "Select your token's PKCS#11 driver, enter the PIN, then list the certificates on the token." +pkcs11LoadError = "Could not read certificates from the token. Check the PIN and driver." +refresh = "Refresh" +selectCert = "Select certificate" +selectDriver = "Select driver" +slot = "Slot (optional)" +windowsHint = "Pick a certificate from your Windows certificate store. Signing uses the key on your card/token - Windows will prompt for the PIN." +windowsLoadError = "Could not read the Windows certificate store" + [certSign.sessions] deleted = "Session deleted" fetchFailed = "Failed to load session details" @@ -2478,9 +2508,6 @@ pdfNotReadyDesc = "The signed PDF is being generated. Please try again in a mome results = "Signed PDF" submit = "Sign PDF" -[certSign.signMode] -stepTitle = "Sign Mode" - [certSign.signMode.tooltip.auto] text = "Signs with a server self-signed certificate. Same tamper-evident seal and audit trail; typically shows Unverified in viewers." title = "Auto - Zero-setup, instant system seal" @@ -2502,6 +2529,12 @@ title = "How signatures work" text = "Need recipient Trusted status? Manual. Need a fast, tamper-evident seal and audit trail with no setup? Auto." title = "Rule of thumb" +[certSign.source] +device = "This device" +server = "Server" +stepTitle = "Certificate source" +upload = "Upload" + [certSign.tooltip.header] title = "About Managing Signatures" @@ -7951,6 +7984,9 @@ certExpired = "Certificate expired" certRevocationUnknown = "Certificate revocation status unknown" certRevoked = "Certificate revoked" chainInvalid = "Certificate chain invalid" +documentModified = "Document modified after signing - content was added outside the signed area" +revocationNotChecked = "Revocation was not checked" +selfSigned = "Self-signed - signer identity not verified" signatureInvalid = "Signature cryptographic check failed" trustInvalid = "Certificate not trusted" @@ -7958,7 +7994,8 @@ trustInvalid = "Certificate not trusted" continued = "Continued" downloads = "Downloads" entryLabel = "Signature Summary" -filesEvaluated = "{{count}} files evaluated" +filesEvaluated_one = "{{count}} file evaluated" +filesEvaluated_other = "{{count}} files evaluated" footer = "Validated via Stirling PDF" generatedAt = "Generated" noPdf = "PDF report will be available after a successful validation." @@ -7968,6 +8005,9 @@ signatureCountLabel_one = "{{count}} signature" signatureCountLabel_other = "{{count}} signatures" signaturesFound_one = "{{count}} signature detected" signaturesFound_other = "{{count}} signatures detected" +signaturesInvalid = "{{count}} invalid" +signaturesUnverified_one = "{{count}} needs review" +signaturesUnverified_other = "{{count}} need review" signaturesValid = "{{count}} fully valid" title = "Signature Validation Report" @@ -7987,7 +8027,9 @@ _value = "Signature" [validateSignature.status] complete = "Validation complete" invalid = "Invalid" +untrustedShort = "Unverified" valid = "Valid" +validUntrusted = "Valid, signer not verified" [viewer] cannotPreviewFile = "Cannot Preview File" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 406524fede..7f81e56f3b 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -2463,9 +2463,39 @@ noTeam = "No Team" noUsers = "No other users found." placeholder = "Select users..." +[certSign.device] +stepTitle = "This device" + [certSign.error] failed = "An error occurred while processing signatures." +[certSign.format] +pkcs11 = "USB Token" +windowsStore = "Windows certificate store" + +[certSign.hardware] +certificate = "Certificate" +customLibrary = "Custom driver path…" +driver = "PKCS#11 driver" +driverPath = "Driver library path" +driverPathPlaceholder = "Full path to your PKCS#11 driver (.dll, .so or .dylib)" +expired = "expired" +expires = "expires" +listCerts = "List certificates" +loading = "Reading certificates…" +noCerts = "No signing certificates found" +noDriver = "No PKCS#11 driver was detected. Install your token's driver (e.g. OpenSC), then reopen this - or enter the driver path manually below." +notYetValid = "not yet valid" +pin = "Token PIN" +pkcs11Hint = "Select your token's PKCS#11 driver, enter the PIN, then list the certificates on the token." +pkcs11LoadError = "Could not read certificates from the token. Check the PIN and driver." +refresh = "Refresh" +selectCert = "Select certificate" +selectDriver = "Select driver" +slot = "Slot (optional)" +windowsHint = "Pick a certificate from your Windows certificate store. Signing uses the key on your card/token - Windows will prompt for the PIN." +windowsLoadError = "Could not read the Windows certificate store" + [certSign.sessions] deleted = "Session deleted" fetchFailed = "Failed to load session details" @@ -2478,9 +2508,6 @@ pdfNotReadyDesc = "The signed PDF is being generated. Please try again in a mome results = "Signed PDF" submit = "Sign PDF" -[certSign.signMode] -stepTitle = "Sign Mode" - [certSign.signMode.tooltip.auto] text = "Signs with a server self-signed certificate. Same tamper-evident seal and audit trail; typically shows Unverified in viewers." title = "Auto - Zero-setup, instant system seal" @@ -2502,6 +2529,12 @@ title = "How signatures work" text = "Need recipient Trusted status? Manual. Need a fast, tamper-evident seal and audit trail with no setup? Auto." title = "Rule of thumb" +[certSign.source] +device = "This device" +server = "Server" +stepTitle = "Certificate source" +upload = "Upload" + [certSign.tooltip.header] title = "About Managing Signatures" @@ -7951,6 +7984,9 @@ certExpired = "Certificate expired" certRevocationUnknown = "Certificate revocation status unknown" certRevoked = "Certificate revoked" chainInvalid = "Certificate chain invalid" +documentModified = "Document modified after signing - content was added outside the signed area" +revocationNotChecked = "Revocation was not checked" +selfSigned = "Self-signed - signer identity not verified" signatureInvalid = "Signature cryptographic check failed" trustInvalid = "Certificate not trusted" @@ -7958,7 +7994,8 @@ trustInvalid = "Certificate not trusted" continued = "Continued" downloads = "Downloads" entryLabel = "Signature Summary" -filesEvaluated = "{{count}} files evaluated" +filesEvaluated_one = "{{count}} file evaluated" +filesEvaluated_other = "{{count}} files evaluated" footer = "Validated via Stirling PDF" generatedAt = "Generated" noPdf = "PDF report will be available after a successful validation." @@ -7968,6 +8005,9 @@ signatureCountLabel_one = "{{count}} signature" signatureCountLabel_other = "{{count}} signatures" signaturesFound_one = "{{count}} signature detected" signaturesFound_other = "{{count}} signatures detected" +signaturesInvalid = "{{count}} invalid" +signaturesUnverified_one = "{{count}} needs review" +signaturesUnverified_other = "{{count}} need review" signaturesValid = "{{count}} fully valid" title = "Signature Validation Report" @@ -7987,7 +8027,9 @@ _value = "Signature" [validateSignature.status] complete = "Validation complete" invalid = "Invalid" +untrustedShort = "Unverified" valid = "Valid" +validUntrusted = "Valid, signer not verified" [viewer] cannotPreviewFile = "Cannot Preview File" diff --git a/frontend/editor/src-tauri/src/commands/backend.rs b/frontend/editor/src-tauri/src/commands/backend.rs index 50d9a24a26..6db0a4c1ac 100644 --- a/frontend/editor/src-tauri/src/commands/backend.rs +++ b/frontend/editor/src-tauri/src/commands/backend.rs @@ -210,6 +210,9 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: & &log_path_option, "-Dlogging.file.name=stirling-pdf.log", "-Dserver.port=0", // Let OS assign an available port + // No reverse proxy in front of the local sidecar, so don't trust forwarded headers. + // Stops a LAN caller spoofing X-Forwarded-For to defeat the desktop-only signing gate. + "-Dserver.forward-headers-strategy=none", "-Dsecurity.enableLogin=false", // Disable login for desktop mode "-Dsecurity.csrfDisabled=true", // Disable CSRF for desktop mode ]; diff --git a/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.tsx b/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.tsx index 7797441027..f093912c29 100644 --- a/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.tsx +++ b/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.tsx @@ -11,6 +11,7 @@ import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParamet import CertificateTypeSettings from "@app/components/tools/certSign/CertificateTypeSettings"; import CertificateFormatSettings from "@app/components/tools/certSign/CertificateFormatSettings"; import CertificateFilesSettings from "@app/components/tools/certSign/CertificateFilesSettings"; +import HardwareCertificateSettings from "@app/components/tools/certSign/HardwareCertificateSettings"; import SignatureAppearanceSettings from "@app/components/tools/certSign/SignatureAppearanceSettings"; interface CertSignAutomationSettingsProps { @@ -54,6 +55,15 @@ const CertSignAutomationSettings = ({ /> )} + {/* Hardware certificate (Windows store / USB token) - desktop only */} + {parameters.signMode === "DEVICE" && ( + + )} + {/* Signature Appearance Settings */} { + const { t } = useTranslation(); const { config } = useAppConfig(); const isServerCertificateEnabled = config?.serverCertificateEnabled ?? false; + // Hardware-backed signing only works when the backend runs locally (desktop app). + const isHardwareAvailable = config?.hardwareSigningAvailable ?? false; - // Reset to MANUAL if AUTO is selected but feature is disabled - if (parameters.signMode === "AUTO" && !isServerCertificateEnabled) { + // Fall back to upload if a previously chosen source is no longer available + // (e.g. an automation saved with DEVICE running on a server). Runs as an effect so we don't + // call the parent's setter while rendering. + useEffect(() => { + if (parameters.signMode === "AUTO" && !isServerCertificateEnabled) { + onParameterChange("signMode", "MANUAL"); + } else if (parameters.signMode === "DEVICE" && !isHardwareAvailable) { + onParameterChange("signMode", "MANUAL"); + } + }, [ + parameters.signMode, + isServerCertificateEnabled, + isHardwareAvailable, + onParameterChange, + ]); + + const selectUpload = () => { onParameterChange("signMode", "MANUAL"); - } + if (parameters.signMode !== "MANUAL") { + onParameterChange("certType", ""); + } + }; + + const selectServer = () => { + onParameterChange("signMode", "AUTO"); + onParameterChange("certType", ""); + }; + + const selectDevice = () => { + onParameterChange("signMode", "DEVICE"); + // Default to the Windows store; the device step lets the user switch to a token. + if ( + parameters.certType !== "WINDOWS_STORE" && + parameters.certType !== "PKCS11" + ) { + onParameterChange("certType", "WINDOWS_STORE"); + } + onParameterChange("alias", undefined); + }; return ( @@ -29,26 +81,12 @@ const CertificateTypeSettings = ({ color={ parameters.signMode === "MANUAL" ? "blue" : "var(--text-muted)" } - onClick={() => { - onParameterChange("signMode", "MANUAL"); - // Reset cert type when switching to manual - if (parameters.signMode === "AUTO") { - onParameterChange("certType", ""); - } - }} + onClick={selectUpload} disabled={disabled} - style={{ - flex: 1, - height: "auto", - minHeight: "40px", - fontSize: "11px", - }} + style={sourceButtonStyle} + styles={sourceButtonStyles} > -

- Manual -
+ {t("certSign.source.upload", "Upload")} {isServerCertificateEnabled && ( + )} + {isHardwareAvailable && ( + )} diff --git a/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx new file mode 100644 index 0000000000..820636af35 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx @@ -0,0 +1,456 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Alert, + Button, + Group, + Loader, + NumberInput, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters"; +import { + getHardwareSigningCapabilities, + HardwareCertificateInfo, + listPkcs11Certificates, + listWindowsCertificates, + Pkcs11LibraryInfo, +} from "@app/services/hardwareSigningService"; + +interface HardwareCertificateSettingsProps { + parameters: CertSignParameters; + onParameterChange: (key: keyof CertSignParameters, value: any) => void; + disabled?: boolean; +} + +const CUSTOM_LIBRARY_VALUE = "__custom__"; + +const HardwareCertificateSettings = ({ + parameters, + onParameterChange, + disabled = false, +}: HardwareCertificateSettingsProps) => { + const { t } = useTranslation(); + const isWindowsStore = parameters.certType === "WINDOWS_STORE"; + + const [certs, setCerts] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [libraries, setLibraries] = useState([]); + const [librarySelection, setLibrarySelection] = useState(""); + const [customLibrary, setCustomLibrary] = useState(""); + const [supported, setSupported] = useState({ windows: true, pkcs11: true }); + const [capsReady, setCapsReady] = useState(false); + + const selectKind = (kind: "WINDOWS_STORE" | "PKCS11") => { + if (parameters.certType === kind) { + return; + } + onParameterChange("certType", kind); + onParameterChange("alias", undefined); + setCerts([]); + setError(null); + }; + + // A GUID-only name (e.g. Microsoft device certs) is unreadable; prefer a real name. + const isGuidish = (s?: string | null) => + !s || + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + s.trim(), + ); + + // Best human-readable name: the Windows friendly name (alias) beats a GUID subject CN. + const displayName = (cert: HardwareCertificateInfo): string => { + if (cert.subjectCommonName && !isGuidish(cert.subjectCommonName)) { + return cert.subjectCommonName; + } + if (cert.alias && !isGuidish(cert.alias)) { + return cert.alias; + } + return cert.subjectCommonName || cert.alias; + }; + + const isUsable = (cert: HardwareCertificateInfo) => + !cert.expired && !cert.notYetValid; + + // Build a readable label for a certificate option. + const certLabel = useCallback( + (cert: HardwareCertificateInfo): string => { + const name = displayName(cert); + // Omit the issuer when it's the same as the name (self-signed) - avoids "X · X". + const showIssuer = + cert.issuerCommonName && + cert.issuerCommonName !== cert.subjectCommonName && + cert.issuerCommonName !== name; + const issuer = showIssuer ? ` · ${cert.issuerCommonName}` : ""; + let suffix = ""; + if (cert.expired) { + suffix = ` (${t("certSign.hardware.expired", "expired")})`; + } else if (cert.notYetValid) { + suffix = ` (${t("certSign.hardware.notYetValid", "not yet valid")})`; + } else if (cert.notAfter) { + const date = cert.notAfter.slice(0, 10); + suffix = ` (${t("certSign.hardware.expires", "expires")} ${date})`; + } + return `${name}${issuer}${suffix}`; + }, + [t], + ); + + // Rank: usable + readable first, system/GUID certs next, expired/not-yet-valid last. + const rank = (cert: HardwareCertificateInfo): number => { + if (!isUsable(cert)) return 3; + if (isGuidish(cert.subjectCommonName) && isGuidish(cert.alias)) return 2; + return 0; + }; + + const applyCerts = useCallback( + (loaded: HardwareCertificateInfo[]) => { + setCerts(loaded); + // Auto-select when there is exactly one usable certificate. + const usable = loaded.filter((c) => !c.expired && !c.notYetValid); + if (usable.length === 1 && !parameters.alias) { + onParameterChange("alias", usable[0].alias); + } + }, + [onParameterChange, parameters.alias], + ); + + // Load capabilities once: which hardware kinds are supported and the detected + // PKCS#11 driver libraries. + useEffect(() => { + let cancelled = false; + getHardwareSigningCapabilities() + .then((caps) => { + if (cancelled) { + return; + } + setSupported({ + windows: caps.windowsStoreSupported, + pkcs11: caps.pkcs11Supported, + }); + // Non-Windows (mac/Linux) has no Windows store; default the device to the USB-token path. + if ( + !caps.windowsStoreSupported && + parameters.certType === "WINDOWS_STORE" + ) { + onParameterChange("certType", "PKCS11"); + } + setCapsReady(true); + setLibraries(caps.detectedLibraries); + // Pre-select a detected library, or the one already chosen. + if (parameters.pkcs11LibraryPath) { + const match = caps.detectedLibraries.find( + (l) => l.path === parameters.pkcs11LibraryPath, + ); + setLibrarySelection(match ? match.path : CUSTOM_LIBRARY_VALUE); + if (!match) { + setCustomLibrary(parameters.pkcs11LibraryPath); + } + } else if (caps.detectedLibraries.length > 0) { + setLibrarySelection(caps.detectedLibraries[0].path); + onParameterChange( + "pkcs11LibraryPath", + caps.detectedLibraries[0].path, + ); + } + }) + .catch(() => { + if (cancelled) { + return; + } + /* capabilities are best-effort; the user can still type a path */ + setCapsReady(true); + }); + return () => { + cancelled = true; + }; + }, []); + + const loadWindowsCerts = useCallback(() => { + setLoading(true); + setError(null); + listWindowsCertificates() + .then(applyCerts) + .catch((e: any) => + setError( + e?.response?.data?.message || + e?.message || + t( + "certSign.hardware.windowsLoadError", + "Could not read the Windows certificate store", + ), + ), + ) + .finally(() => setLoading(false)); + }, [applyCerts, t]); + + // Windows store certificates can be enumerated without a PIN, so load eagerly - + // but only once capabilities confirm the store exists (avoids a spurious call on mac/Linux). + useEffect(() => { + if (capsReady && isWindowsStore && supported.windows) { + loadWindowsCerts(); + } + }, [isWindowsStore, supported.windows, capsReady]); + + const onLibraryChange = (value: string | null) => { + const selection = value ?? ""; + setLibrarySelection(selection); + setCerts([]); + onParameterChange("alias", undefined); + if (selection === CUSTOM_LIBRARY_VALUE) { + onParameterChange("pkcs11LibraryPath", customLibrary || ""); + } else { + onParameterChange("pkcs11LibraryPath", selection); + } + }; + + const loadPkcs11Certs = useCallback(() => { + if (!parameters.pkcs11LibraryPath || !parameters.password) { + return; + } + setLoading(true); + setError(null); + listPkcs11Certificates({ + libraryPath: parameters.pkcs11LibraryPath, + slot: parameters.pkcs11Slot, + pin: parameters.password, + }) + .then(applyCerts) + .catch((e: any) => + setError( + e?.response?.data?.message || + e?.message || + t( + "certSign.hardware.pkcs11LoadError", + "Could not read certificates from the token. Check the PIN and driver.", + ), + ), + ) + .finally(() => setLoading(false)); + }, [ + applyCerts, + parameters.password, + parameters.pkcs11LibraryPath, + parameters.pkcs11Slot, + t, + ]); + + const certOptions = [...certs] + .sort( + (a, b) => + rank(a) - rank(b) || displayName(a).localeCompare(displayName(b)), + ) + .map((cert) => ({ + value: cert.alias, + label: certLabel(cert), + // Expired / not-yet-valid certs can't produce a valid signature - show but block. + disabled: !isUsable(cert), + })); + + const libraryOptions = [ + // Label = driver name only; the long path goes under the dropdown so the + // input doesn't overflow / scroll horizontally. + ...libraries.map((l) => ({ + value: l.path, + label: l.name, + })), + { + value: CUSTOM_LIBRARY_VALUE, + label: t("certSign.hardware.customLibrary", "Custom driver path…"), + }, + ]; + const selectedLibraryPath = + librarySelection && librarySelection !== CUSTOM_LIBRARY_VALUE + ? librarySelection + : null; + + // Hold the UI until capabilities are known, so the kind toggle / Windows-store + // section don't render and then vanish on mac/Linux (no flicker). + if (!capsReady) { + return ( + + + + ); + } + + return ( + + {supported.windows && supported.pkcs11 && ( +
+ + +
+ )} + {isWindowsStore ? ( + <> + + {t( + "certSign.hardware.windowsHint", + "Pick a certificate from your Windows store. Signing uses the key on your card/token - Windows will prompt for the PIN.", + )} + + + + {selectedLibraryPath && ( + + {selectedLibraryPath} + + )} + {librarySelection === CUSTOM_LIBRARY_VALUE && ( + { + setCustomLibrary(e.currentTarget.value); + onParameterChange("pkcs11LibraryPath", e.currentTarget.value); + }} + disabled={disabled || loading} + /> + )} + + + onParameterChange("password", e.currentTarget.value) + } + disabled={disabled || loading} + /> + + onParameterChange( + "pkcs11Slot", + v === "" || v == null ? undefined : Number(v), + ) + } + min={0} + disabled={disabled || loading} + /> + + + {certs.length > 0 && ( +