From 7d273730170949774aabf78ae40c2af2c3371def Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 02:18:37 +0000 Subject: [PATCH 1/5] feat: prompt user to trust self-signed certificates after Windows install After installation completes on Windows, checks if the installed exe is signed with an untrusted (e.g. self-signed) certificate. If so, shows certificate details and offers to add it to the user's trust store via certutil -user (no admin required). Proceeds to the completion dialog regardless of the user's choice. https://claude.ai/code/session_019uM9CsmXdTMeEtoiNLijWG --- .../ca/weblite/jdeploy/installer/Main.java | 53 ++++- .../views/DefaultInstallationForm.java | 61 +++++ .../installer/views/InstallationForm.java | 12 + .../win/AuthenticodeSignatureChecker.java | 215 ++++++++++++++++++ .../win/CertificateTrustService.java | 50 ++++ 5 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 installer/src/main/java/ca/weblite/jdeploy/installer/win/AuthenticodeSignatureChecker.java create mode 100644 installer/src/main/java/ca/weblite/jdeploy/installer/win/CertificateTrustService.java 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..69aa7061 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,51 @@ 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 != null && 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); + if (certFile != null) { + 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..f052b39f 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,67 @@ public void showTrustConfirmationDialog() { } } + @Override + public boolean showCertificateTrustPrompt(ca.weblite.jdeploy.installer.win.AuthenticodeSignatureChecker.SignatureCheckResult result) { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + + JLabel headerLabel = new JLabel("This application is signed with a certificate that is not in your trust store."); + headerLabel.setAlignmentX(Component.LEFT_ALIGNMENT); + panel.add(headerLabel); + + panel.add(Box.createVerticalStrut(15)); + + JLabel detailsHeader = new JLabel("Certificate Details:"); + detailsHeader.setFont(detailsHeader.getFont().deriveFont(Font.BOLD)); + detailsHeader.setAlignmentX(Component.LEFT_ALIGNMENT); + panel.add(detailsHeader); + + panel.add(Box.createVerticalStrut(5)); + + String subject = result.getSubject() != null ? result.getSubject() : "Unknown"; + 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"; + + JLabel certInfo = new JLabel("" + + "Subject: " + escapeHtml(subject) + "
" + + "Issuer: " + escapeHtml(issuer) + "
" + + "Thumbprint: " + escapeHtml(thumbprint) + "
" + + "Valid from: " + escapeHtml(validFrom) + " to: " + escapeHtml(validTo) + + ""); + certInfo.setFont(new Font("SansSerif", Font.PLAIN, 11)); + certInfo.setAlignmentX(Component.LEFT_ALIGNMENT); + panel.add(certInfo); + + panel.add(Box.createVerticalStrut(15)); + + JLabel questionLabel = new JLabel("Would you like to add this certificate to your trust store?
" + + "(This only affects your user account and does not require admin privileges.)"); + questionLabel.setFont(questionLabel.getFont().deriveFont(Font.PLAIN, 11f)); + questionLabel.setAlignmentX(Component.LEFT_ALIGNMENT); + panel.add(questionLabel); + + int choice = JOptionPane.showOptionDialog( + this, + panel, + "Trust Certificate?", + JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE, + null, + new Object[]{"Yes, Trust This Certificate", "No, Skip"}, + "No, Skip" + ); + + return choice == 0; + } + + 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..2cf77bbc --- /dev/null +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/win/AuthenticodeSignatureChecker.java @@ -0,0 +1,215 @@ +package ca.weblite.jdeploy.installer.win; + +import java.io.BufferedReader; +import java.io.File; +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, or null if the check could not be performed + */ + public SignatureCheckResult checkSignature(File exeFile) { + if (exeFile == null || !exeFile.exists() || !exeFile.getName().endsWith(".exe")) { + return null; + } + + try { + 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"); + } + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + return null; + } + + return parseResult(output.toString()); + } catch (Exception e) { + System.err.println("Failed to check Authenticode signature: " + e.getMessage()); + return null; + } + } + + 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, or null on failure + */ + public File exportCertificate(File exeFile) { + if (exeFile == null || !exeFile.exists()) { + return null; + } + + try { + 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); + } + } + + int exitCode = process.waitFor(); + if (exitCode == 0 && output.toString().trim().equals("OK") && certFile.length() > 0) { + return certFile; + } + + certFile.delete(); + return null; + } catch (Exception e) { + System.err.println("Failed to export certificate: " + e.getMessage()); + return null; + } + } +} 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; + } + } +} From fbeeb83b8472d1f4f8975eecbbd6355c4b6bedbf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 02:40:44 +0000 Subject: [PATCH 2/5] fix: use mvn install instead of package for installer in mock-network-tests The mock-network-tests workflow built the installer module with `mvn package` which does not install the jar to the local Maven repo. When the CLI module subsequently resolves jdeploy-installer as a dependency, it fails because the artifact is not available. https://claude.ai/code/session_019uM9CsmXdTMeEtoiNLijWG --- .github/workflows/mock-network-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From b318a01e3d4e1fe57e39bb51ae4d42a46c39d1c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 02:52:51 +0000 Subject: [PATCH 3/5] refactor: make AuthenticodeSignatureChecker never return null checkSignature() and exportCertificate() now throw IllegalArgumentException for invalid input (null, missing file, non-exe) and IOException for process failures instead of returning null. Callers guard against obvious invalid input before calling, and the existing catch(Exception) block handles any runtime failures. https://claude.ai/code/session_019uM9CsmXdTMeEtoiNLijWG --- .../ca/weblite/jdeploy/installer/Main.java | 18 +-- .../win/AuthenticodeSignatureChecker.java | 143 ++++++++++-------- 2 files changed, 88 insertions(+), 73 deletions(-) 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 69aa7061..b1b2098d 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java @@ -1098,7 +1098,7 @@ private void promptToTrustCertificateIfNeeded(InstallationForm form) { if (installedApp != null && installedApp.exists() && installedApp.getName().endsWith(".exe")) { AuthenticodeSignatureChecker checker = new AuthenticodeSignatureChecker(); AuthenticodeSignatureChecker.SignatureCheckResult result = checker.checkSignature(installedApp); - if (result != null && result.isSignedButUntrusted()) { + if (result.isSignedButUntrusted()) { // Show dialog on EDT and wait for result final boolean[] userChoice = {false}; try { @@ -1110,16 +1110,14 @@ private void promptToTrustCertificateIfNeeded(InstallationForm form) { } if (userChoice[0]) { File certFile = checker.exportCertificate(installedApp); - if (certFile != null) { - 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(); + 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(); } } } 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 index 2cf77bbc..77eff3f4 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/win/AuthenticodeSignatureChecker.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/win/AuthenticodeSignatureChecker.java @@ -2,6 +2,7 @@ import java.io.BufferedReader; import java.io.File; +import java.io.IOException; import java.io.InputStreamReader; /** @@ -65,39 +66,47 @@ public boolean isSignedButUntrusted() { * Checks the Authenticode signature of a Windows executable. * * @param exeFile the executable file to check - * @return the signature check result, or null if the check could not be performed + * @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) { - if (exeFile == null || !exeFile.exists() || !exeFile.getName().endsWith(".exe")) { - return null; + 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()); } - try { - 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"); - } + 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) { - return null; + throw new IOException("PowerShell Get-AuthenticodeSignature failed with exit code " + exitCode + ": " + output); } - - return parseResult(output.toString()); - } catch (Exception e) { - System.err.println("Failed to check Authenticode signature: " + e.getMessage()); - return null; + } 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) { @@ -164,52 +173,60 @@ private SignatureCheckResult parseResult(String output) { * Exports the signing certificate from an exe to a temporary .cer file. * * @param exeFile the signed executable - * @return the temporary .cer file, or null on failure + * @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) { - if (exeFile == null || !exeFile.exists()) { - return null; + 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()); } - try { - 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); - } - } + File certFile = File.createTempFile("jdeploy_cert_", ".cer"); + certFile.deleteOnExit(); - int exitCode = process.waitFor(); - if (exitCode == 0 && output.toString().trim().equals("OK") && certFile.length() > 0) { - return certFile; + 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(); - return null; - } catch (Exception e) { - System.err.println("Failed to export certificate: " + e.getMessage()); - return null; + 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()); } } From 63954608c94f66b7f88d64ed222954e512c1730b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 03:04:51 +0000 Subject: [PATCH 4/5] fix: add BasicConstraints and KeyUsage extensions to code signing certs The signtool verify error 0x80096019 ("basic constraint extension has not been observed") occurs because generated certificates lack proper X.509v3 extensions. Add basicConstraints=CA:FALSE, keyUsage= digitalSignature, and extendedKeyUsage=codeSigning to all certificate generation paths: OpenSSL commands in the test skill and keytool commands in integration tests. Also includes the earlier refactor making AuthenticodeSignatureChecker never return null (throws exceptions instead). https://claude.ai/code/session_019uM9CsmXdTMeEtoiNLijWG --- .../skills/test-authenticode-signing/skill.md | 30 ++++++++++++++++--- .../WindowsSigningPfxIntegrationTest.java | 2 ++ .../WindowsSigningPkcs11IntegrationTest.java | 2 ++ 3 files changed, 30 insertions(+), 4 deletions(-) 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/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", From 2eaccc717c12d8782d126bc96379c3a2c3f18e3d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 03:11:41 +0000 Subject: [PATCH 5/5] fix: simplify certificate trust prompt dialog Replace the verbose certificate details dump with a friendly message showing just the signer name and organization extracted from the subject DN. Full certificate details (subject, issuer, thumbprint, validity) are available via an info icon button. Button labels simplified to "Trust" / "Skip". https://claude.ai/code/session_019uM9CsmXdTMeEtoiNLijWG --- .../views/DefaultInstallationForm.java | 112 ++++++++++++------ 1 file changed, 76 insertions(+), 36 deletions(-) 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 f052b39f..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 @@ -413,61 +413,101 @@ 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)); - JLabel headerLabel = new JLabel("This application is signed with a certificate that is not in your trust store."); + // 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(15)); - - JLabel detailsHeader = new JLabel("Certificate Details:"); - detailsHeader.setFont(detailsHeader.getFont().deriveFont(Font.BOLD)); - detailsHeader.setAlignmentX(Component.LEFT_ALIGNMENT); - panel.add(detailsHeader); - - panel.add(Box.createVerticalStrut(5)); - - String subject = result.getSubject() != null ? result.getSubject() : "Unknown"; - 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"; - - JLabel certInfo = new JLabel("" + - "Subject: " + escapeHtml(subject) + "
" + - "Issuer: " + escapeHtml(issuer) + "
" + - "Thumbprint: " + escapeHtml(thumbprint) + "
" + - "Valid from: " + escapeHtml(validFrom) + " to: " + escapeHtml(validTo) + - ""); - certInfo.setFont(new Font("SansSerif", Font.PLAIN, 11)); - certInfo.setAlignmentX(Component.LEFT_ALIGNMENT); - panel.add(certInfo); - - panel.add(Box.createVerticalStrut(15)); - - JLabel questionLabel = new JLabel("Would you like to add this certificate to your trust store?
" + - "(This only affects your user account and does not require admin privileges.)"); - questionLabel.setFont(questionLabel.getFont().deriveFont(Font.PLAIN, 11f)); - questionLabel.setAlignmentX(Component.LEFT_ALIGNMENT); - panel.add(questionLabel); + 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 Certificate?", + "Trust Publisher?", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, null, - new Object[]{"Yes, Trust This Certificate", "No, Skip"}, - "No, Skip" + 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(">", ">"); }