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;
+ }
+ }
+}