Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions .claude/skills/test-authenticode-signing/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
2 changes: 1 addition & 1 deletion .github/workflows/mock-network-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 50 additions & 1 deletion installer/src/main/java/ca/weblite/jdeploy/installer/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("<html>This app is signed by <b>" + signerText + "</b>.</html>");
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 = "<html>" +
"<b>Subject:</b> " + escapeHtml(subject) + "<br>" +
"<b>Issuer:</b> " + escapeHtml(issuer) + "<br>" +
"<b>Thumbprint:</b> " + escapeHtml(thumbprint) + "<br>" +
"<b>Valid:</b> " + escapeHtml(validFrom) + " to " + escapeHtml(validTo) +
"</html>";

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}

@Override
public void setEventDispatcher(InstallationFormEventDispatcher dispatcher) {
this.dispatcher = dispatcher;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}

}
Loading
Loading