diff --git a/.claude/skills/test-authenticode-signing/skill.md b/.claude/skills/test-authenticode-signing/skill.md index 8d8784a2..f35d1a4c 100644 --- a/.claude/skills/test-authenticode-signing/skill.md +++ b/.claude/skills/test-authenticode-signing/skill.md @@ -84,11 +84,22 @@ Generate a self-signed certificate and set as GitHub secrets: ```bash cd /tmp + +# Create an extensions file for code signing +cat > code-signing-ext.cnf << 'EOF' +[v3_code_signing] +basicConstraints = critical, CA:FALSE +keyUsage = critical, digitalSignature +extendedKeyUsage = critical, codeSigning +EOF + openssl req -x509 -newkey rsa:2048 \ -keyout test-signing-key.pem \ -out test-signing-cert.pem \ - -days 365 -nodes \ - -subj "/CN=jDeploy Test Signing/O=jDeploy Test/C=US" + -days 730 -nodes \ + -subj "/CN=jDeploy Test Signing/O=jDeploy Test/C=US" \ + -extensions v3_code_signing \ + -config <(cat /etc/ssl/openssl.cnf code-signing-ext.cnf) openssl pkcs12 -export \ -out test-signing.pfx \ @@ -192,12 +203,22 @@ jobs: # Initialize the token softhsm2-util --init-token --slot 0 --label "jdeploy-test" --pin 1234 --so-pin 5678 + # Create extensions file for code signing + cat > $HOME/softhsm/code-signing-ext.cnf << 'EXTEOF' + [v3_code_signing] + basicConstraints = critical, CA:FALSE + keyUsage = critical, digitalSignature + extendedKeyUsage = critical, codeSigning + EXTEOF + # Generate a key pair AND self-signed certificate with OpenSSL openssl req -x509 -newkey rsa:2048 \ -keyout $HOME/softhsm/signing-key.pem \ -out $HOME/softhsm/signing-cert.pem \ - -days 365 -nodes \ - -subj "/CN=jDeploy Test PKCS11/O=jDeploy Test/C=US" + -days 730 -nodes \ + -subj "/CN=jDeploy Test PKCS11/O=jDeploy Test/C=US" \ + -extensions v3_code_signing \ + -config <(cat /etc/ssl/openssl.cnf $HOME/softhsm/code-signing-ext.cnf) # Convert private key to DER openssl rsa -in $HOME/softhsm/signing-key.pem -outform DER -out $HOME/softhsm/signing-key.der @@ -319,5 +340,6 @@ Or right-click the `.exe` → Properties → Digital Signatures tab. | `SignerCertificate is null` | Update jsign dependency - older versions had issues with OpenSSL 3.x certificates | | `No signature found` | Check GitHub Actions log for signing errors | | Signature shows "untrusted" | Expected for self-signed certificates - real CA certificates won't have this issue | +| `0x80096019` basic constraint error | Certificate was generated without proper extensions; regenerate with `basicConstraints=CA:FALSE`, `keyUsage=digitalSignature`, `extendedKeyUsage=codeSigning` | | PKCS#11 key mismatch | Ensure private key and certificate are generated together and imported with same ID | | SoftHSM config error | Use `$HOME` not `$ENV:HOME` in config paths | diff --git a/.github/workflows/mock-network-tests.yml b/.github/workflows/mock-network-tests.yml index d9da4b19..c2597edc 100644 --- a/.github/workflows/mock-network-tests.yml +++ b/.github/workflows/mock-network-tests.yml @@ -141,7 +141,7 @@ jobs: run: cd shared && mvn clean install -DskipTests -q - name: Build installer module - run: cd installer && mvn clean package -DskipTests -q + run: cd installer && mvn clean install -DskipTests -q - name: Run mock network publishing tests (CLI) env: diff --git a/cli/src/test/java/ca/weblite/jdeploy/services/WindowsSigningPfxIntegrationTest.java b/cli/src/test/java/ca/weblite/jdeploy/services/WindowsSigningPfxIntegrationTest.java index 85d75848..d7eb2e41 100644 --- a/cli/src/test/java/ca/weblite/jdeploy/services/WindowsSigningPfxIntegrationTest.java +++ b/cli/src/test/java/ca/weblite/jdeploy/services/WindowsSigningPfxIntegrationTest.java @@ -121,6 +121,8 @@ private static void generateSelfSignedCert() throws Exception { "-keysize", "2048", "-validity", "1", "-dname", "CN=Test Code Signing, O=Test, L=Test, ST=Test, C=US", + "-ext", "BC=ca:false", + "-ext", "KU=digitalSignature", "-ext", "EKU=codeSigning", "-storetype", "PKCS12", "-keystore", keystoreFile.getAbsolutePath(), diff --git a/cli/src/test/java/ca/weblite/jdeploy/services/WindowsSigningPkcs11IntegrationTest.java b/cli/src/test/java/ca/weblite/jdeploy/services/WindowsSigningPkcs11IntegrationTest.java index 8aeac0da..a89c97a6 100644 --- a/cli/src/test/java/ca/weblite/jdeploy/services/WindowsSigningPkcs11IntegrationTest.java +++ b/cli/src/test/java/ca/weblite/jdeploy/services/WindowsSigningPkcs11IntegrationTest.java @@ -183,6 +183,8 @@ private static void generateKeyInToken() throws Exception { "-keysize", "2048", "-validity", "1", "-dname", "CN=PKCS11 Test Signing, O=Test, C=US", + "-ext", "BC=ca:false", + "-ext", "KU=digitalSignature", "-ext", "EKU=codeSigning", "-storetype", "PKCS11", "-providerClass", "sun.security.pkcs11.SunPKCS11", diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java b/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java index 60451b79..b1b2098d 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java @@ -37,6 +37,8 @@ import ca.weblite.jdeploy.installer.win.JnaRegistryOperations; import ca.weblite.jdeploy.installer.win.RegistryOperations; import ca.weblite.jdeploy.installer.win.UninstallWindows; +import ca.weblite.jdeploy.installer.win.AuthenticodeSignatureChecker; +import ca.weblite.jdeploy.installer.win.CertificateTrustService; import ca.weblite.jdeploy.models.DocumentTypeAssociation; import ca.weblite.jdeploy.models.CommandSpec; @@ -1060,7 +1062,11 @@ public void reportWarning(String message) { try { install(); invokeLater(()->evt.getInstallationForm().setInProgress(false, "")); - invokeLater(()-> evt.getInstallationForm().showInstallationCompleteDialog()); + if (Platform.getSystemPlatform().isWindows()) { + promptToTrustCertificateIfNeeded(evt.getInstallationForm()); + } else { + invokeLater(()-> evt.getInstallationForm().showInstallationCompleteDialog()); + } } catch (Exception ex) { invokeLater(()->evt.getInstallationForm().setInProgress(false, "")); ex.printStackTrace(System.err); @@ -1079,6 +1085,49 @@ public void reportWarning(String message) { }).start(); } + /** + * On Windows, checks if the installed exe is signed with an untrusted certificate. + * If so, shows the user certificate details and offers to add it to their trust store. + * Regardless of the outcome, proceeds to show the installation complete dialog. + * + * This method should be called from a background thread. It runs the signature check + * on the background thread and dispatches UI dialogs to the EDT. + */ + private void promptToTrustCertificateIfNeeded(InstallationForm form) { + try { + if (installedApp != null && installedApp.exists() && installedApp.getName().endsWith(".exe")) { + AuthenticodeSignatureChecker checker = new AuthenticodeSignatureChecker(); + AuthenticodeSignatureChecker.SignatureCheckResult result = checker.checkSignature(installedApp); + if (result.isSignedButUntrusted()) { + // Show dialog on EDT and wait for result + final boolean[] userChoice = {false}; + try { + javax.swing.SwingUtilities.invokeAndWait(() -> { + userChoice[0] = form.showCertificateTrustPrompt(result); + }); + } catch (Exception e) { + System.err.println("Failed to show certificate trust dialog: " + e.getMessage()); + } + if (userChoice[0]) { + File certFile = checker.exportCertificate(installedApp); + try { + CertificateTrustService trustService = new CertificateTrustService(); + boolean added = trustService.addToUserTrustStore(certFile); + if (!added) { + System.err.println("Failed to add certificate to user trust store."); + } + } finally { + certFile.delete(); + } + } + } + } + } catch (Exception e) { + System.err.println("Certificate trust check failed: " + e.getMessage()); + } + invokeLater(() -> form.showInstallationCompleteDialog()); + } + private void onVisitSoftwareHomepage(InstallationFormEvent evt) { if (Desktop.isDesktopSupported()) { try { diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/views/DefaultInstallationForm.java b/installer/src/main/java/ca/weblite/jdeploy/installer/views/DefaultInstallationForm.java index d35aedfa..2ffa45e0 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/views/DefaultInstallationForm.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/views/DefaultInstallationForm.java @@ -411,6 +411,107 @@ public void showTrustConfirmationDialog() { } } + @Override + public boolean showCertificateTrustPrompt(ca.weblite.jdeploy.installer.win.AuthenticodeSignatureChecker.SignatureCheckResult result) { + String subject = result.getSubject() != null ? result.getSubject() : ""; + String displayName = extractCN(subject); + String orgName = extractField(subject, "O"); + + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + + // Friendly header with signer name + String signerText = displayName.isEmpty() ? "an unknown publisher" : escapeHtml(displayName); + if (!orgName.isEmpty() && !orgName.equals(displayName)) { + signerText += " (" + escapeHtml(orgName) + ")"; + } + JLabel headerLabel = new JLabel("This app is signed by " + signerText + "."); + headerLabel.setAlignmentX(Component.LEFT_ALIGNMENT); + panel.add(headerLabel); + + panel.add(Box.createVerticalStrut(8)); + + // Simple question with info link + JPanel questionRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + questionRow.setAlignmentX(Component.LEFT_ALIGNMENT); + + JLabel questionLabel = new JLabel("Would you like to trust this publisher?"); + questionRow.add(questionLabel); + + questionRow.add(Box.createHorizontalStrut(6)); + + // Info button for certificate details + JButton infoButton = new JButton("\u24D8"); + infoButton.setFont(infoButton.getFont().deriveFont(Font.PLAIN, 13f)); + infoButton.setBorderPainted(false); + infoButton.setContentAreaFilled(false); + infoButton.setFocusPainted(false); + infoButton.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + infoButton.setToolTipText("View certificate details"); + infoButton.setMargin(new Insets(0, 0, 0, 0)); + infoButton.addActionListener(e -> { + String issuer = result.getIssuer() != null ? result.getIssuer() : "Unknown"; + String thumbprint = result.getThumbprint() != null ? result.getThumbprint() : "Unknown"; + String validFrom = result.getValidFrom() != null ? result.getValidFrom() : "Unknown"; + String validTo = result.getValidTo() != null ? result.getValidTo() : "Unknown"; + + String details = "" + + "Subject: " + escapeHtml(subject) + "
" + + "Issuer: " + escapeHtml(issuer) + "
" + + "Thumbprint: " + escapeHtml(thumbprint) + "
" + + "Valid: " + escapeHtml(validFrom) + " to " + escapeHtml(validTo) + + ""; + + JOptionPane.showMessageDialog( + DefaultInstallationForm.this, + details, + "Certificate Details", + JOptionPane.INFORMATION_MESSAGE + ); + }); + questionRow.add(infoButton); + panel.add(questionRow); + + int choice = JOptionPane.showOptionDialog( + this, + panel, + "Trust Publisher?", + JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE, + null, + new Object[]{"Trust", "Skip"}, + "Skip" + ); + + return choice == 0; + } + + /** + * Extracts the CN (Common Name) value from an X.500 distinguished name string. + */ + private static String extractCN(String dn) { + return extractField(dn, "CN"); + } + + /** + * Extracts a field value from an X.500 distinguished name string. + */ + private static String extractField(String dn, String fieldName) { + if (dn == null || dn.isEmpty()) return ""; + String prefix = fieldName + "="; + int start = dn.indexOf(prefix); + if (start < 0) return ""; + start += prefix.length(); + int end = dn.indexOf(',', start); + if (end < 0) end = dn.length(); + return dn.substring(start, end).trim(); + } + + private static String escapeHtml(String text) { + return text.replace("&", "&").replace("<", "<").replace(">", ">"); + } + @Override public void setEventDispatcher(InstallationFormEventDispatcher dispatcher) { this.dispatcher = dispatcher; diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/views/InstallationForm.java b/installer/src/main/java/ca/weblite/jdeploy/installer/views/InstallationForm.java index c826c096..bdfd8d7a 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/views/InstallationForm.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/views/InstallationForm.java @@ -2,6 +2,7 @@ import ca.weblite.jdeploy.installer.events.InstallationFormEventDispatcher; import ca.weblite.jdeploy.installer.events.InstallationFormEventListener; +import ca.weblite.jdeploy.installer.win.AuthenticodeSignatureChecker; public interface InstallationForm { public void showInstallationCompleteDialog(); @@ -14,4 +15,15 @@ public interface InstallationForm { public void setInProgress(boolean inProgress, String message); public void setAppAlreadyInstalled(boolean installed); + /** + * Shows a dialog asking the user if they want to trust a self-signed certificate. + * Only called on Windows when the installed exe is signed with an untrusted certificate. + * + * @param result the signature check result containing certificate details + * @return true if the user chose to add the certificate to their trust store + */ + default boolean showCertificateTrustPrompt(AuthenticodeSignatureChecker.SignatureCheckResult result) { + return false; + } + } diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/win/AuthenticodeSignatureChecker.java b/installer/src/main/java/ca/weblite/jdeploy/installer/win/AuthenticodeSignatureChecker.java new file mode 100644 index 00000000..77eff3f4 --- /dev/null +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/win/AuthenticodeSignatureChecker.java @@ -0,0 +1,232 @@ +package ca.weblite.jdeploy.installer.win; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; + +/** + * Checks Authenticode signatures on Windows executables using PowerShell. + * Determines if an exe is signed and whether its certificate is trusted. + */ +public class AuthenticodeSignatureChecker { + + /** + * Result of an Authenticode signature check. + */ + public static class SignatureCheckResult { + private final boolean signed; + private final boolean trusted; + private final String subject; + private final String issuer; + private final String thumbprint; + private final String validFrom; + private final String validTo; + private final String statusMessage; + + public SignatureCheckResult( + boolean signed, + boolean trusted, + String subject, + String issuer, + String thumbprint, + String validFrom, + String validTo, + String statusMessage + ) { + this.signed = signed; + this.trusted = trusted; + this.subject = subject; + this.issuer = issuer; + this.thumbprint = thumbprint; + this.validFrom = validFrom; + this.validTo = validTo; + this.statusMessage = statusMessage; + } + + public boolean isSigned() { return signed; } + public boolean isTrusted() { return trusted; } + public String getSubject() { return subject; } + public String getIssuer() { return issuer; } + public String getThumbprint() { return thumbprint; } + public String getValidFrom() { return validFrom; } + public String getValidTo() { return validTo; } + public String getStatusMessage() { return statusMessage; } + + /** + * Returns true if the exe is signed but the certificate is not trusted + * (i.e., self-signed or signed with an untrusted CA). + */ + public boolean isSignedButUntrusted() { + return signed && !trusted; + } + } + + /** + * Checks the Authenticode signature of a Windows executable. + * + * @param exeFile the executable file to check + * @return the signature check result (never null) + * @throws IllegalArgumentException if exeFile is null, does not exist, or is not an .exe file + * @throws IOException if the PowerShell process fails or returns a non-zero exit code + */ + public SignatureCheckResult checkSignature(File exeFile) throws IOException { + if (exeFile == null) { + throw new IllegalArgumentException("exeFile must not be null"); + } + if (!exeFile.exists()) { + throw new IllegalArgumentException("File does not exist: " + exeFile.getAbsolutePath()); + } + if (!exeFile.getName().endsWith(".exe")) { + throw new IllegalArgumentException("File is not an .exe: " + exeFile.getName()); + } + + String script = buildPowerShellScript(exeFile); + ProcessBuilder pb = new ProcessBuilder( + "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script + ); + pb.redirectErrorStream(true); + Process process = pb.start(); + + StringBuilder output = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + } + } + + try { + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IOException("PowerShell Get-AuthenticodeSignature failed with exit code " + exitCode + ": " + output); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for PowerShell process", e); + } + + return parseResult(output.toString()); + } + + private String buildPowerShellScript(File exeFile) { + String escapedPath = exeFile.getAbsolutePath().replace("'", "''"); + return "$sig = Get-AuthenticodeSignature -FilePath '" + escapedPath + "'; " + + "if ($sig.SignerCertificate -ne $null) { " + + " $cert = $sig.SignerCertificate; " + + " Write-Output \"SIGNED=true\"; " + + " Write-Output \"STATUS=$($sig.Status)\"; " + + " Write-Output \"STATUS_MESSAGE=$($sig.StatusMessage)\"; " + + " Write-Output \"SUBJECT=$($cert.Subject)\"; " + + " Write-Output \"ISSUER=$($cert.Issuer)\"; " + + " Write-Output \"THUMBPRINT=$($cert.Thumbprint)\"; " + + " Write-Output \"VALID_FROM=$($cert.NotBefore.ToString('yyyy-MM-dd'))\"; " + + " Write-Output \"VALID_TO=$($cert.NotAfter.ToString('yyyy-MM-dd'))\" " + + "} else { " + + " Write-Output \"SIGNED=false\" " + + "}"; + } + + private SignatureCheckResult parseResult(String output) { + boolean signed = false; + boolean trusted = false; + String subject = ""; + String issuer = ""; + String thumbprint = ""; + String validFrom = ""; + String validTo = ""; + String statusMessage = ""; + String status = ""; + + for (String line : output.split("\n")) { + line = line.trim(); + if (line.startsWith("SIGNED=")) { + signed = "true".equals(line.substring("SIGNED=".length())); + } else if (line.startsWith("STATUS=")) { + status = line.substring("STATUS=".length()); + } else if (line.startsWith("STATUS_MESSAGE=")) { + statusMessage = line.substring("STATUS_MESSAGE=".length()); + } else if (line.startsWith("SUBJECT=")) { + subject = line.substring("SUBJECT=".length()); + } else if (line.startsWith("ISSUER=")) { + issuer = line.substring("ISSUER=".length()); + } else if (line.startsWith("THUMBPRINT=")) { + thumbprint = line.substring("THUMBPRINT=".length()); + } else if (line.startsWith("VALID_FROM=")) { + validFrom = line.substring("VALID_FROM=".length()); + } else if (line.startsWith("VALID_TO=")) { + validTo = line.substring("VALID_TO=".length()); + } + } + + if (!signed) { + return new SignatureCheckResult(false, false, "", "", "", "", "", "Not signed"); + } + + // "Valid" status means the certificate chain is trusted + trusted = "Valid".equals(status); + + return new SignatureCheckResult(signed, trusted, subject, issuer, thumbprint, validFrom, validTo, statusMessage); + } + + /** + * Exports the signing certificate from an exe to a temporary .cer file. + * + * @param exeFile the signed executable + * @return the temporary .cer file (never null) + * @throws IllegalArgumentException if exeFile is null or does not exist + * @throws IOException if the certificate could not be exported + */ + public File exportCertificate(File exeFile) throws IOException { + if (exeFile == null) { + throw new IllegalArgumentException("exeFile must not be null"); + } + if (!exeFile.exists()) { + throw new IllegalArgumentException("File does not exist: " + exeFile.getAbsolutePath()); + } + + File certFile = File.createTempFile("jdeploy_cert_", ".cer"); + certFile.deleteOnExit(); + + String escapedExePath = exeFile.getAbsolutePath().replace("'", "''"); + String escapedCertPath = certFile.getAbsolutePath().replace("'", "''"); + + String script = "$sig = Get-AuthenticodeSignature -FilePath '" + escapedExePath + "'; " + + "if ($sig.SignerCertificate -ne $null) { " + + " [System.IO.File]::WriteAllBytes('" + escapedCertPath + "', $sig.SignerCertificate.RawData); " + + " Write-Output 'OK' " + + "} else { " + + " Write-Output 'NO_CERT' " + + "}"; + + ProcessBuilder pb = new ProcessBuilder( + "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script + ); + pb.redirectErrorStream(true); + Process process = pb.start(); + + StringBuilder output = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line); + } + } + + try { + process.waitFor(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + certFile.delete(); + throw new IOException("Interrupted while waiting for PowerShell process", e); + } + + if (output.toString().trim().equals("OK") && certFile.length() > 0) { + return certFile; + } + + certFile.delete(); + throw new IOException("Failed to export certificate from " + exeFile.getName() + + ": " + output.toString().trim()); + } +} diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/win/CertificateTrustService.java b/installer/src/main/java/ca/weblite/jdeploy/installer/win/CertificateTrustService.java new file mode 100644 index 00000000..6a8b0fa4 --- /dev/null +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/win/CertificateTrustService.java @@ -0,0 +1,50 @@ +package ca.weblite.jdeploy.installer.win; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; + +/** + * Adds certificates to the current user's trust store on Windows. + * Uses certutil with the -user flag so no admin elevation is required. + */ +public class CertificateTrustService { + + /** + * Adds a certificate file to the current user's trusted root certificate store. + * This does not require admin privileges. + * + * @param certFile the .cer certificate file to add + * @return true if the certificate was successfully added + */ + public boolean addToUserTrustStore(File certFile) { + if (certFile == null || !certFile.exists()) { + return false; + } + + try { + ProcessBuilder pb = new ProcessBuilder( + "certutil.exe", "-user", "-addstore", "Root", certFile.getAbsolutePath() + ); + pb.redirectErrorStream(true); + Process process = pb.start(); + + StringBuilder output = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + } + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + System.err.println("certutil failed (exit code " + exitCode + "): " + output); + } + return exitCode == 0; + } catch (Exception e) { + System.err.println("Failed to add certificate to user trust store: " + e.getMessage()); + return false; + } + } +}