View Javadoc
1   /*
2    * DavMail POP/IMAP/SMTP/CalDav/LDAP Exchange Gateway
3    * Copyright (C) 2024  Mickael Guessant
4    *
5    * This program is free software; you can redistribute it and/or
6    * modify it under the terms of the GNU General Public License
7    * as published by the Free Software Foundation; either version 2
8    * of the License, or (at your option) any later version.
9    *
10   * This program is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with this program; if not, write to the Free Software
17   * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18   */
19  package davmail.http;
20  
21  import davmail.Settings;
22  import org.apache.log4j.Logger;
23  
24  import java.io.File;
25  import java.io.IOException;
26  import java.io.OutputStream;
27  import java.lang.reflect.Constructor;
28  import java.lang.reflect.InvocationTargetException;
29  import java.lang.reflect.Method;
30  import java.net.InetAddress;
31  import java.net.UnknownHostException;
32  import java.nio.file.Files;
33  import java.nio.file.Paths;
34  import java.security.GeneralSecurityException;
35  import java.security.KeyPair;
36  import java.security.KeyPairGenerator;
37  import java.security.KeyStore;
38  import java.security.PrivateKey;
39  import java.security.PublicKey;
40  import java.security.SecureRandom;
41  import java.security.cert.X509Certificate;
42  import java.util.Date;
43  
44  /**
45   * Self-signed PKCS12 certificate generator for DavMail SSL.
46   * Uses reflection to access sun.security.x509.* internal APIs
47   * for compatibility across Java 8, 11, and 17+.
48   * <p>
49   * Usage on Java 11+:
50   * <pre>java --add-exports java.base/sun.security.x509=ALL-UNNAMED -cp davmail.jar davmail.DavGateway -generateCert</pre>
51   * On Java 8, the --add-exports flag is not needed.
52   */
53  public class SelfSignedCertificateGenerator {
54      private static final Logger LOGGER = Logger.getLogger(SelfSignedCertificateGenerator.class);
55  
56      private static final String CERTIFICATE_SIGNATURE_ALGORITHM = "SHA384withRSA";
57      private static final int RSA_KEY_LENGTH = 2048;
58      private static final int CERTIFICATE_VALIDITY_DAYS = 3650;
59      private static final String KEY_ALIAS = "davmail";
60      public static final String DEFAULT_KEYSTORE_PASSWORD = "password"; // NOSONAR default keystore password
61      private static final String DEFAULT_KEYSTORE_FILENAME = "davmail.p12";
62  
63      private SelfSignedCertificateGenerator() {
64          // utility class
65      }
66  
67      /**
68       * Resolve keystore path: place davmail.p12 next to the given config file.
69       *
70       * @param configFilePath path to the DavMail properties file
71       * @return resolved keystore file path
72       */
73      public static String resolveKeystorePath(String configFilePath) {
74          if (configFilePath != null && !configFilePath.isEmpty()) {
75              File configFile = new File(configFilePath);
76              File configDir = configFile.getParentFile();
77              if (configDir != null) {
78                  return new File(configDir, DEFAULT_KEYSTORE_FILENAME).getPath();
79              }
80          }
81          return DEFAULT_KEYSTORE_FILENAME;
82      }
83  
84      /**
85       * Generate a self-signed certificate, save it next to the DavMail settings file,
86       * and update settings with the keystore configuration.
87       * Called from {@link davmail.DavGateway} with {@code -generateCert} option.
88       *
89       * @param configFilePath path to the DavMail properties file
90       */
91      public static void generate(String configFilePath) {
92          String keystorePath = resolveKeystorePath(configFilePath);
93  
94          try {
95              generateAndSave(keystorePath, DEFAULT_KEYSTORE_PASSWORD);
96  
97              // Update DavMail settings
98              Settings.setProperty("davmail.ssl.keystoreType", "PKCS12");
99              Settings.setProperty("davmail.ssl.keystoreFile", keystorePath);
100             Settings.setProperty("davmail.ssl.keystorePass", DEFAULT_KEYSTORE_PASSWORD);
101             Settings.setProperty("davmail.ssl.keyPass", DEFAULT_KEYSTORE_PASSWORD);
102             Settings.save();
103 
104             LOGGER.info("Generated self-signed certificate: " + keystorePath);
105             LOGGER.info("DavMail settings updated");
106         } catch (GeneralSecurityException | IOException e) {
107             LOGGER.error("Failed to generate certificate: " + e.getMessage(), e);
108             LOGGER.error("On Java 11+, add: --add-exports java.base/sun.security.x509=ALL-UNNAMED");
109         }
110     }
111 
112     /**
113      * Generate a self-signed PKCS12 keystore file.
114      *
115      * @param keystoreFilename output filename (e.g., "davmail.p12")
116      * @param keystorePassword password for the keystore and key entry
117      * @throws GeneralSecurityException if certificate generation fails
118      * @throws IOException              if keystore file cannot be written
119      */
120     public static void generateAndSave(String keystoreFilename, String keystorePassword) throws GeneralSecurityException, IOException {
121         String hostname;
122         try {
123             hostname = InetAddress.getLocalHost().getCanonicalHostName();
124         } catch (UnknownHostException e) {
125             hostname = "localhost";
126         }
127         String cnString = "cn=" + hostname + ",ou=davmail";
128 
129         LOGGER.info("Generating self-signed certificate for " + hostname);
130 
131         // Generate RSA key pair (standard API, no reflection needed)
132         SecureRandom secureRandom = new SecureRandom();
133         KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
134         keyGen.initialize(RSA_KEY_LENGTH, secureRandom);
135         KeyPair pair = keyGen.generateKeyPair();
136         PublicKey publicKey = pair.getPublic();
137         PrivateKey privateKey = pair.getPrivate();
138 
139         Date startDate = new Date();
140         Date endDate = new Date(startDate.getTime() + (long) CERTIFICATE_VALIDITY_DAYS * 24 * 3600 * 1000);
141 
142         // All sun.security.x509 classes accessed via reflection
143         X509Certificate cert = createSelfSignedCertificate(
144                 cnString, publicKey, privateKey, startDate, endDate, secureRandom);
145 
146         // Create PKCS12 keystore and store to file
147         KeyStore keyStore = KeyStore.getInstance("PKCS12");
148         keyStore.load(null, null);
149         keyStore.setKeyEntry(KEY_ALIAS, privateKey, keystorePassword.toCharArray(),
150                 new X509Certificate[]{cert});
151 
152         try (OutputStream os = Files.newOutputStream(Paths.get(keystoreFilename))) {
153             keyStore.store(os, keystorePassword.toCharArray());
154         }
155 
156         LOGGER.info("Self-signed certificate saved to " + keystoreFilename);
157     }
158 
159     private static X509Certificate createSelfSignedCertificate(
160             String cnString, PublicKey publicKey, PrivateKey privateKey,
161             Date startDate, Date endDate, SecureRandom secureRandom) throws GeneralSecurityException {
162         try {
163             // Load classes via reflection
164             Class<?> x509CertInfoClass = Class.forName("sun.security.x509.X509CertInfo");
165             Class<?> x509CertImplClass = Class.forName("sun.security.x509.X509CertImpl");
166             Class<?> certificateVersionClass = Class.forName("sun.security.x509.CertificateVersion");
167             Class<?> certificateSerialNumberClass = Class.forName("sun.security.x509.CertificateSerialNumber");
168             Class<?> algorithmIdClass = Class.forName("sun.security.x509.AlgorithmId");
169             Class<?> certificateAlgorithmIdClass = Class.forName("sun.security.x509.CertificateAlgorithmId");
170             Class<?> x500NameClass = Class.forName("sun.security.x509.X500Name");
171             Class<?> certificateX509KeyClass = Class.forName("sun.security.x509.CertificateX509Key");
172             Class<?> certificateValidityClass = Class.forName("sun.security.x509.CertificateValidity");
173 
174             // Create X509CertInfo
175             Object info = x509CertInfoClass.getDeclaredConstructor().newInstance();
176 
177             // CertificateVersion.V3 = 2
178             Object certVersion = certificateVersionClass.getDeclaredConstructor(int.class).newInstance(2);
179 
180             // CertificateSerialNumber
181             Object certSerial = certificateSerialNumberClass.getDeclaredConstructor(int.class)
182                     .newInstance(secureRandom.nextInt() & 0x7fffffff);
183 
184             // AlgorithmId.get(algorithm)
185             Method algorithmIdGet = algorithmIdClass.getMethod("get", String.class);
186             Object algId = algorithmIdGet.invoke(null, CERTIFICATE_SIGNATURE_ALGORITHM);
187             Object certAlgId = certificateAlgorithmIdClass.getDeclaredConstructor(algorithmIdClass).newInstance(algId);
188 
189             // X500Name
190             Object x500Name = x500NameClass.getDeclaredConstructor(String.class).newInstance(cnString);
191 
192             // CertificateX509Key
193             Object certKey = certificateX509KeyClass.getDeclaredConstructor(PublicKey.class).newInstance(publicKey);
194 
195             // CertificateValidity
196             Object certValidity = certificateValidityClass.getDeclaredConstructor(Date.class, Date.class)
197                     .newInstance(startDate, endDate);
198 
199             // Set fields on X509CertInfo - try setXXX methods first (newer JDK), fall back to set(String, Object)
200             setInfoField(info, x509CertInfoClass, "version", "setVersion", certVersion, certificateVersionClass);
201             setInfoField(info, x509CertInfoClass, "serialNumber", "setSerialNumber", certSerial, certificateSerialNumberClass);
202             setInfoField(info, x509CertInfoClass, "algorithmId", "setAlgorithmId", certAlgId, certificateAlgorithmIdClass);
203             setInfoField(info, x509CertInfoClass, "key", "setKey", certKey, certificateX509KeyClass);
204             setInfoField(info, x509CertInfoClass, "validity", "setValidity", certValidity, certificateValidityClass);
205 
206             // Subject and Issuer need special handling due to API differences between JDK versions
207             setSubjectOrIssuer(info, x509CertInfoClass, "subject", "setSubject", x500Name, x500NameClass);
208             setSubjectOrIssuer(info, x509CertInfoClass, "issuer", "setIssuer", x500Name, x500NameClass);
209 
210             // Sign the certificate
211             return signCertificate(info, x509CertInfoClass, x509CertImplClass, privateKey);
212         } catch (ClassNotFoundException | NoSuchMethodException | InstantiationException |
213                  IllegalAccessException | InvocationTargetException e) {
214             throw new GeneralSecurityException("Failed to generate self-signed certificate: " + e.getMessage(), e);
215         }
216     }
217 
218     /**
219      * Set a field on X509CertInfo. Try the newer setXXX method first,
220      * fall back to the set(String, Object) method for older JDKs.
221      */
222     private static void setInfoField(Object info, Class<?> infoClass, String fieldName,
223                                      String setterName, Object value, Class<?> valueClass)
224             throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
225         try {
226             // Try newer JDK setter method (e.g., setVersion, setSerialNumber)
227             Method setter = infoClass.getMethod(setterName, valueClass);
228             setter.invoke(info, value);
229         } catch (NoSuchMethodException e) {
230             // Fall back to set(String, Object) for Java 8
231             Method setMethod = infoClass.getMethod("set", String.class, Object.class);
232             setMethod.invoke(info, fieldName, value);
233         }
234     }
235 
236     /**
237      * Set subject or issuer on X509CertInfo. Handles API differences:
238      * - Newer JDK: setSubject(X500Name) / setIssuer(X500Name)
239      * - Java 8: set("subject", CertificateSubjectName) / set("issuer", CertificateIssuerName)
240      */
241     private static void setSubjectOrIssuer(Object info, Class<?> infoClass, String fieldName,
242                                            String setterName, Object x500Name, Class<?> x500NameClass)
243             throws NoSuchMethodException, IllegalAccessException, InvocationTargetException,
244             ClassNotFoundException, InstantiationException {
245         try {
246             // Try newer JDK setter (takes X500Name directly)
247             Method setter = infoClass.getMethod(setterName, x500NameClass);
248             setter.invoke(info, x500Name);
249         } catch (NoSuchMethodException e) {
250             // Fall back: in Java 8, subject/issuer need wrapper classes
251             try {
252                 String wrapperClassName;
253                 if ("subject".equals(fieldName)) {
254                     wrapperClassName = "sun.security.x509.CertificateSubjectName";
255                 } else {
256                     wrapperClassName = "sun.security.x509.CertificateIssuerName";
257                 }
258                 Class<?> wrapperClass = Class.forName(wrapperClassName);
259                 Object wrapper = wrapperClass.getDeclaredConstructor(x500NameClass).newInstance(x500Name);
260                 Method setMethod = infoClass.getMethod("set", String.class, Object.class);
261                 setMethod.invoke(info, fieldName, wrapper);
262             } catch (ClassNotFoundException cnfe) {
263                 // Wrapper classes don't exist, try set with X500Name directly
264                 Method setMethod = infoClass.getMethod("set", String.class, Object.class);
265                 setMethod.invoke(info, fieldName, x500Name);
266             }
267         }
268     }
269 
270     /**
271      * Sign the certificate. Try newer static factory method first,
272      * fall back to instance sign method for Java 8.
273      */
274     private static X509Certificate signCertificate(Object info, Class<?> infoClass,
275                                                    Class<?> certImplClass, PrivateKey privateKey)
276             throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
277         try {
278             // Try newer JDK: X509CertImpl.newSigned(X509CertInfo, PrivateKey, String)
279             Method newSigned = certImplClass.getMethod("newSigned", infoClass, PrivateKey.class, String.class);
280             return (X509Certificate) newSigned.invoke(null, info, privateKey, CERTIFICATE_SIGNATURE_ALGORITHM);
281         } catch (NoSuchMethodException e) {
282             // Fall back to Java 8: new X509CertImpl(X509CertInfo), then cert.sign(PrivateKey, String)
283             Constructor<?> constructor = certImplClass.getDeclaredConstructor(infoClass);
284             Object cert = constructor.newInstance(info);
285             Method signMethod = certImplClass.getMethod("sign", PrivateKey.class, String.class);
286             signMethod.invoke(cert, privateKey, CERTIFICATE_SIGNATURE_ALGORITHM);
287             return (X509Certificate) cert;
288         }
289     }
290 }