From 18a6ea29cac743d283c08a7b450e5d0f98946edd Mon Sep 17 00:00:00 2001 From: Clement Escoffier Date: Wed, 19 Jun 2024 14:27:03 +0200 Subject: [PATCH] Generate root certificate --- certificate-generator/pom.xml | 11 + .../me/escoffier/certs/CertificateHolder.java | 14 +- .../escoffier/certs/CertificateRequest.java | 23 +- .../certs/CertificateRequestManager.java | 6 +- .../me/escoffier/certs/CertificateUtils.java | 131 +++++++++-- .../me/escoffier/certs/ca/CaGenerator.java | 218 ++++++++++++++++++ .../escoffier/certs/ca/LinuxCAInstaller.java | 74 ++++++ .../me/escoffier/certs/ca/MacCAInstaller.java | 111 +++++++++ .../certs/ca/WindowsCAInstaller.java | 26 +++ .../chain/CertificateChainGenerator.java | 2 +- .../me/escoffier/certs/ca/GenerateCaTest.java | 99 ++++++++ 11 files changed, 682 insertions(+), 33 deletions(-) create mode 100644 certificate-generator/src/main/java/me/escoffier/certs/ca/CaGenerator.java create mode 100644 certificate-generator/src/main/java/me/escoffier/certs/ca/LinuxCAInstaller.java create mode 100644 certificate-generator/src/main/java/me/escoffier/certs/ca/MacCAInstaller.java create mode 100644 certificate-generator/src/main/java/me/escoffier/certs/ca/WindowsCAInstaller.java create mode 100644 certificate-generator/src/test/java/me/escoffier/certs/ca/GenerateCaTest.java diff --git a/certificate-generator/pom.xml b/certificate-generator/pom.xml index ea51c02..3dac3da 100644 --- a/certificate-generator/pom.xml +++ b/certificate-generator/pom.xml @@ -19,6 +19,17 @@ org.bouncycastle bcpkix-jdk18on + + io.smallrye.common + smallrye-common-os + 2.4.0 + + + + com.googlecode.plist + dd-plist + 1.28 + io.vertx vertx-core diff --git a/certificate-generator/src/main/java/me/escoffier/certs/CertificateHolder.java b/certificate-generator/src/main/java/me/escoffier/certs/CertificateHolder.java index 8d03d2d..c559ff0 100644 --- a/certificate-generator/src/main/java/me/escoffier/certs/CertificateHolder.java +++ b/certificate-generator/src/main/java/me/escoffier/certs/CertificateHolder.java @@ -18,20 +18,22 @@ public class CertificateHolder { private final X509Certificate clientCertificate; private final String password; + private final CertificateRequest.Issuer issuer; /** * Generates a new instance of {@link CertificateHolder}, with a new random key pair and a certificate. */ - public CertificateHolder(String cn, List sans, Duration duration, boolean generateClient, String password) throws Exception { + public CertificateHolder(String cn, List sans, Duration duration, boolean generateClient, String password, CertificateRequest.Issuer issuer) throws Exception { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); - keys = keyPairGenerator.generateKeyPair(); - certificate = CertificateUtils.generateCertificate(keys, cn, sans, duration); + this.issuer = issuer; + this.keys = keyPairGenerator.generateKeyPair(); + this.certificate = CertificateUtils.generateCertificate(this.keys, cn, sans, duration, issuer); if (generateClient) { clientKeys = keyPairGenerator.generateKeyPair(); - clientCertificate = CertificateUtils.generateCertificate(clientKeys, cn, sans, duration); + clientCertificate = CertificateUtils.generateCertificate(clientKeys, cn, sans, duration, issuer); } else { clientKeys = null; clientCertificate = null; @@ -59,6 +61,10 @@ public boolean hasClient() { return clientKeys != null; } + public CertificateRequest.Issuer issuer() { + return issuer; + } + public char[] password() { if (password == null) { return null; diff --git a/certificate-generator/src/main/java/me/escoffier/certs/CertificateRequest.java b/certificate-generator/src/main/java/me/escoffier/certs/CertificateRequest.java index d82e036..47f60d6 100644 --- a/certificate-generator/src/main/java/me/escoffier/certs/CertificateRequest.java +++ b/certificate-generator/src/main/java/me/escoffier/certs/CertificateRequest.java @@ -1,5 +1,7 @@ package me.escoffier.certs; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; @@ -18,6 +20,9 @@ public final class CertificateRequest { private final Map aliases = new HashMap<>(); private final List sans = new ArrayList<>(); + private boolean signed; + private Issuer issuer; + public CertificateRequest withName(String name) { this.name = name; return this; @@ -71,6 +76,15 @@ public CertificateRequest withAlias(String alias, AliasRequest request) { return this; } + public record Issuer(X509Certificate issuer, PrivateKey issuerPrivateKey) { + } + + public CertificateRequest signedWith(X509Certificate issuer, PrivateKey issuerPrivateKey) { + this.signed = true; + this.issuer = new Issuer(issuer, issuerPrivateKey); + return this; + } + void validate() { if (cn == null || cn.isEmpty()) { cn = "localhost"; @@ -106,8 +120,15 @@ public List getSubjectAlternativeNames() { return sans; } - public Map aliases() { return aliases; } + + public boolean isSelfSigned() { + return ! signed; + } + + public Issuer issuer() { + return issuer; + } } diff --git a/certificate-generator/src/main/java/me/escoffier/certs/CertificateRequestManager.java b/certificate-generator/src/main/java/me/escoffier/certs/CertificateRequestManager.java index fa414ea..9ffb627 100644 --- a/certificate-generator/src/main/java/me/escoffier/certs/CertificateRequestManager.java +++ b/certificate-generator/src/main/java/me/escoffier/certs/CertificateRequestManager.java @@ -29,7 +29,8 @@ public class CertificateRequestManager { public CertificateRequestManager(CertificateRequest request) throws Exception { this.request = request; this.name = request.name(); - holders.put(request.name(), new CertificateHolder(request.getCN(), request.getSubjectAlternativeNames(), request.getDuration(), request.hasClient(), request.getPassword())); + holders.put(request.name(), + new CertificateHolder(request.getCN(), request.getSubjectAlternativeNames(), request.getDuration(), request.hasClient(), request.getPassword(), request.issuer())); for (String alias : request.aliases().keySet()) { AliasRequest nested = request.aliases().get(alias); @@ -38,7 +39,8 @@ public CertificateRequestManager(CertificateRequest request) throws Exception { if (cn == null) { cn = request.getCN(); } - holders.put(alias, new CertificateHolder(cn, nested.getSubjectAlternativeNames(), request.getDuration(), nested.hasClient(), nested.getPassword())); + holders.put(alias, + new CertificateHolder(cn, nested.getSubjectAlternativeNames(), request.getDuration(), nested.hasClient(), nested.getPassword(), request.issuer())); } } diff --git a/certificate-generator/src/main/java/me/escoffier/certs/CertificateUtils.java b/certificate-generator/src/main/java/me/escoffier/certs/CertificateUtils.java index 0f0350c..ba14f02 100644 --- a/certificate-generator/src/main/java/me/escoffier/certs/CertificateUtils.java +++ b/certificate-generator/src/main/java/me/escoffier/certs/CertificateUtils.java @@ -1,30 +1,51 @@ package me.escoffier.certs; import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1Sequence; import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.ExtendedKeyUsage; import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.asn1.x509.GeneralName; import org.bouncycastle.asn1.x509.KeyPurposeId; import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.cert.CertIOException; import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; -import org.bouncycastle.jce.X509Principal; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.bouncycastle.x509.X509V3CertificateGenerator; -import java.io.*; +import javax.security.auth.x500.X500Principal; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; import java.math.BigInteger; -import java.security.*; -import java.security.cert.*; +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Security; import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Base64; +import java.util.Date; import java.util.List; import java.util.Map; @@ -34,26 +55,42 @@ public class CertificateUtils { Security.addProvider(new BouncyCastleProvider()); } - public static X509Certificate generateCertificate(KeyPair keyPair, String cn, List sans, Duration duration) throws Exception { - // Generate self-signed X509 Certificate - X509V3CertificateGenerator certGen = new X509V3CertificateGenerator(); - certGen.setSerialNumber(BigInteger.valueOf(System.nanoTime())); - certGen.setSubjectDN(new X509Principal("CN=" + cn)); - certGen.setIssuerDN(new X509Principal("CN=" + cn)); - + public static X509Certificate generateCertificate(KeyPair keyPair, String cn, List sans, Duration duration, CertificateRequest.Issuer issuerHolder) throws Exception { + if (issuerHolder != null) { + return generateSignedCertificate(keyPair, cn, sans, duration, issuerHolder); + } + var issuer = new X500Name("CN=" + cn); + X509v3CertificateBuilder builder = getCertificateBuilder(keyPair, cn, sans, duration, issuer); + JcaContentSignerBuilder contentSignerBuilder = new JcaContentSignerBuilder("SHA256WithRSAEncryption"); + return new JcaX509CertificateConverter().getCertificate(builder.build(contentSignerBuilder.build(keyPair.getPrivate()))); + } - certGen.addExtension(Extension.subjectKeyIdentifier, false, new JcaX509ExtensionUtils().createSubjectKeyIdentifier(keyPair.getPublic())); - certGen.addExtension(Extension.authorityKeyIdentifier, false, new JcaX509ExtensionUtils().createAuthorityKeyIdentifier(keyPair.getPublic())); + private static X509v3CertificateBuilder getCertificateBuilder(KeyPair keyPair, String cn, List sans, Duration duration, X500Name issuer) throws CertIOException, NoSuchAlgorithmException { + var subject = new X500Name("CN=" + cn); + var before = Instant.now().minus(2, ChronoUnit.DAYS); + var after = Instant.now().plus(duration.toDays(), ChronoUnit.DAYS); + var keyInfo = SubjectPublicKeyInfo.getInstance(ASN1Sequence.getInstance(keyPair.getPublic().getEncoded())); + X509v3CertificateBuilder builder = new X509v3CertificateBuilder( + issuer, + BigInteger.valueOf(System.nanoTime()), + new Date(before.toEpochMilli()), + new Date(after.toEpochMilli()), + subject, + keyInfo + ); + + builder.addExtension(Extension.subjectKeyIdentifier, false, new JcaX509ExtensionUtils().createSubjectKeyIdentifier(keyPair.getPublic())); + builder.addExtension(Extension.authorityKeyIdentifier, false, new JcaX509ExtensionUtils().createAuthorityKeyIdentifier(keyPair.getPublic())); // Set certificate extensions // (1) digitalSignature extension - certGen.addExtension(Extension.keyUsage, true, + builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment | KeyUsage.dataEncipherment | KeyUsage.keyAgreement | KeyUsage.nonRepudiation)); - certGen.addExtension(Extension.basicConstraints, false, new BasicConstraints(false)); + builder.addExtension(Extension.basicConstraints, false, new BasicConstraints(false)); // (2) extendedKeyUsage extension - certGen.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_clientAuth, KeyPurposeId.id_kp_serverAuth})); + builder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_clientAuth, KeyPurposeId.id_kp_serverAuth})); // (3) subjectAlternativeName if (sans.isEmpty()) { @@ -62,7 +99,7 @@ public static X509Certificate generateCertificate(KeyPair keyPair, String cn, Li new GeneralName(GeneralName.iPAddress, "127.0.0.1"), new GeneralName(GeneralName.iPAddress, "0.0.0.0") }); - certGen.addExtension(Extension.subjectAlternativeName, false, subjectAlternativeNames); + builder.addExtension(Extension.subjectAlternativeName, false, subjectAlternativeNames); } else { DERSequence subjectAlternativeNames = new DERSequence(sans.stream().map(s -> { @@ -74,19 +111,57 @@ public static X509Certificate generateCertificate(KeyPair keyPair, String cn, Li return new GeneralName(GeneralName.dNSName, s); } }).toArray(ASN1Encodable[]::new)); - certGen.addExtension(Extension.subjectAlternativeName, false, subjectAlternativeNames); + builder.addExtension(Extension.subjectAlternativeName, false, subjectAlternativeNames); } + return builder; + } - + public static X509Certificate generateSignedCertificate(KeyPair keyPair, String cn, List sans, Duration duration, CertificateRequest.Issuer issuerHolder) throws Exception { var before = Instant.now().minus(2, ChronoUnit.DAYS); var after = Instant.now().plus(duration.toDays(), ChronoUnit.DAYS); - - certGen.setNotBefore(new java.util.Date(before.toEpochMilli())); - certGen.setNotAfter(new java.util.Date(after.toEpochMilli())); + X509V3CertificateGenerator certGen = new X509V3CertificateGenerator(); + certGen.setSerialNumber(new java.math.BigInteger("2")); + certGen.setIssuerDN(issuerHolder.issuer().getSubjectX500Principal()); + certGen.setSubjectDN(new X500Principal("CN=" + cn)); certGen.setPublicKey(keyPair.getPublic()); + certGen.setNotBefore(new Date(before.toEpochMilli())); // Yesterday + certGen.setNotAfter(new Date(after.toEpochMilli())); // 1 year certGen.setSignatureAlgorithm("SHA256WithRSAEncryption"); - return certGen.generate(keyPair.getPrivate()); + if (sans.isEmpty()) { + DERSequence subjectAlternativeNames = new DERSequence(new ASN1Encodable[]{ + new GeneralName(GeneralName.dNSName, cn), + new GeneralName(GeneralName.iPAddress, "127.0.0.1"), + new GeneralName(GeneralName.iPAddress, "0.0.0.0") + }); + certGen.addExtension(Extension.subjectAlternativeName, false, subjectAlternativeNames); + } else { + DERSequence subjectAlternativeNames = + new DERSequence(sans.stream().map(s -> { + if (s.startsWith("DNS:")) { + return new GeneralName(GeneralName.dNSName, s.substring(4)); + } else if (s.startsWith("IP:")) { + return new GeneralName(GeneralName.iPAddress, s.substring(3)); + } else { + return new GeneralName(GeneralName.dNSName, s); + } + }).toArray(ASN1Encodable[]::new)); + certGen.addExtension(Extension.subjectAlternativeName, false, subjectAlternativeNames); + } + certGen.addExtension(Extension.subjectKeyIdentifier, false, new JcaX509ExtensionUtils().createSubjectKeyIdentifier(keyPair.getPublic())); + // Do not add authority when using a CA-signed certificate + + // Set certificate extensions +// // (1) digitalSignature extension + certGen.addExtension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment | KeyUsage.dataEncipherment | KeyUsage.keyAgreement | KeyUsage.nonRepudiation)); + + certGen.addExtension(Extension.basicConstraints, false, new BasicConstraints(false)); + + // (2) extendedKeyUsage extension + certGen.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_clientAuth, KeyPurposeId.id_kp_serverAuth})); + + return certGen.generate(issuerHolder.issuerPrivateKey(), "BC"); } public static void writeCertificateToPEM(X509Certificate certificate, File output, X509Certificate... chain) throws IOException, CertificateEncodingException { @@ -202,6 +277,9 @@ public static void writePrivateKeyAndCertificateToPKCS12(Map entry : certificates.entrySet()) { + if (entry.getValue().issuer() != null) { + keyStore.setCertificateEntry("issuer-" + entry.getKey(), entry.getValue().issuer().issuer()); + } keyStore.setKeyEntry( entry.getKey(), entry.getValue().keys().getPrivate(), @@ -209,6 +287,8 @@ public static void writePrivateKeyAndCertificateToPKCS12(Map + * + * @param ca the file where the CA certificate should be stored (PEM file), must not be null + * @param key the file where the private key should be stored (PEM file), must not be null + * @param ks the file where the keystore should be stored (P12 file), must not be null + * @param password the password to protect the keystore, and the private key, must not be null or empty + */ + public CaGenerator(File ca, File key, File ks, String password) { + Security.addProvider(new BouncyCastleProvider()); + this.ca = ca; + this.key = key; + this.ks = ks; + this.password = password; + } + + + /** + * Generate a Root CA certificate and store it in a keystore. + *

+ * This method writes the CA certificate to a PEM file, the private key to a PEM file, and the key and cert to a PKCS12 keystore. + * It also returns the {@code X509Certificate} instance. + * + * @param cn the common name of the certificate, must not be null + * @param org the organization, can be null, must not be empty + * @param unit the organizational unit, can be null, must not be empty + * @param location the location, can be null, must not be empty + * @param state the state, can be null, must not be empty + * @param country, the country, can be null, must not be empty + * @return the generated CA certificate + * @throws Exception if the generation fails + */ + public X509Certificate generate(String cn, String org, String unit, String location, String state, String country) throws Exception { + String issuerText = "CN=" + cn; + if (org != null) { + issuerText += ",O=" + org; + } + String subjectText = issuerText; + if (unit != null) { + subjectText += ",OU=" + unit; + } + if (location != null) { + subjectText += ",L=" + location; + } + if (state != null) { + subjectText += ",ST=" + state; + } + if (country != null) { + subjectText += ",C=" + country; + } + + var issuer = new X500Name(issuerText); + var subject = new X500Name(subjectText); + var yesterday = new Date(System.currentTimeMillis() - 86400000); + var oneYear = new Date(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000); // 1 year + + // Generate RSA key pair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); + keyPairGenerator.initialize(2048, new SecureRandom()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + var keyInfo = SubjectPublicKeyInfo.getInstance(ASN1Sequence.getInstance(keyPair.getPublic().getEncoded())); + + X509v3CertificateBuilder builder = new X509v3CertificateBuilder( + issuer, + BigInteger.valueOf(System.currentTimeMillis()), + yesterday, + oneYear, + subject, + keyInfo + ); + + builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign)); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(0)); // CA + Path Length + builder.addExtension(Extension.subjectKeyIdentifier, false, new JcaX509ExtensionUtils().createSubjectKeyIdentifier(keyPair.getPublic())); + + JcaContentSignerBuilder contentSignerBuilder = new JcaContentSignerBuilder("SHA256WithRSAEncryption"); + var cert = new JcaX509CertificateConverter().getCertificate(builder.build(contentSignerBuilder.build(keyPair.getPrivate()))); + + // Save the Root CA certificate to a pem file + try (FileWriter fileWriter = new FileWriter(ca); + BufferedWriter pemWriter = new BufferedWriter(fileWriter)) { + pemWriter.write("-----BEGIN CERTIFICATE-----\n"); + pemWriter.write(Base64.getEncoder().encodeToString(cert.getEncoded())); + pemWriter.write("\n-----END CERTIFICATE-----\n\n"); + + } + + try (FileWriter fileWriter = new FileWriter(key); + BufferedWriter pemWriter = new BufferedWriter(fileWriter)) { + pemWriter.write("-----BEGIN PRIVATE KEY-----\n"); + pemWriter.write(Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded())); + pemWriter.write("\n-----END PRIVATE KEY-----\n\n"); + } + + // Store the key and cert in a P12 keystore + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setKeyEntry(KEYSTORE_KEY_ENTRY, keyPair.getPrivate(), password.toCharArray(), new java.security.cert.Certificate[]{cert}); + keyStore.setCertificateEntry(KEYSTORE_CERT_ENTRY, cert); + keyStore.store(new FileOutputStream(ks), password.toCharArray()); + + // Adjust permissions + if (OS.MAC.isCurrent() || OS.LINUX.isCurrent()) { + Set ownerWritable = PosixFilePermissions.fromString("rw-r--r--"); + Set ownerRW = PosixFilePermissions.fromString("rw-------"); + Files.setPosixFilePermissions(ca.toPath(), ownerWritable); + Files.setPosixFilePermissions(key.toPath(), ownerRW); + Files.setPosixFilePermissions(ks.toPath(), ownerRW); + } + + LOGGER.log(INFO, "🔥 Root CA certificate generated successfully!"); + + this.generatedCA = cert; + this.cn = cn; // Required for the installation in the system truststore on MacOS + return cert; + } + + /** + * Generate a PKCS#12 truststore containing the CA certificate. + *

+ * The generated truststore is a PKCS12 file containing the CA certificate at the entry {@code ca}. + * The truststore is protected by the password provided when creating the instance of {@link CaGenerator}. + * + * @param trustStore the truststore file, must not be null + * @throws KeyStoreException if the truststore cannot be generated + */ + public void generateTrustStore(File trustStore) throws Exception { + if (!ks.isFile() || generatedCA == null) { + throw new IllegalStateException("The keystore has not been generated yet, call `generate` first"); + } + + LOGGER.log(INFO, "🔥 Generating p12 truststore..."); + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setCertificateEntry(KEYSTORE_CERT_ENTRY, generatedCA); + var fos = new FileOutputStream(trustStore); + keyStore.store(fos, password.toCharArray()); + fos.close(); + LOGGER.log(INFO, "🔥 Truststore generated successfully: {0}.", trustStore.getAbsolutePath()); + } + + /** + * Install the CA certificate in the system truststore. + *

+ * The behavior of this method depends on the operating system. + * It requires elevated privileges. + */ + public void installToSystem() throws Exception { + if (!ks.isFile() || generatedCA == null) { + throw new IllegalStateException("The keystore has not been generated yet, call `generate` first"); + } + + LOGGER.log(INFO, "🔥 Installing the CA certificate in the system truststore..."); + + if (OS.MAC.isCurrent()) { + installCAOnMac(cn, ca); + } else if (OS.WINDOWS.isCurrent()) { + installCAOnWindows(cn, ca); + } else if (OS.LINUX.isCurrent()) { + installCAOnLinux(cn, ca); + } else { + LOGGER.log(WARNING, "❌ Unsupported operating system: {0}", OS.current()); + } + + } + + +} diff --git a/certificate-generator/src/main/java/me/escoffier/certs/ca/LinuxCAInstaller.java b/certificate-generator/src/main/java/me/escoffier/certs/ca/LinuxCAInstaller.java new file mode 100644 index 0000000..b10aed9 --- /dev/null +++ b/certificate-generator/src/main/java/me/escoffier/certs/ca/LinuxCAInstaller.java @@ -0,0 +1,74 @@ +package me.escoffier.certs.ca; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +import static java.lang.System.Logger.Level.*; + +/** + * A utility to install the CA certificate on Linux. + *

+ * Each linux distribution has its own way to install the CA certificate. + *

+ * Fedora commands works for Centos, Fedora, RHEL, etc. + * Ubuntu commands works for Ubuntu, Debian, etc. + */ +public class LinuxCAInstaller { + static System.Logger LOGGER = System.getLogger(CaGenerator.class.getName()); + + + private static final String FEDORA_LOCATION = "/etc/pki/ca-trust/source/anchors/"; + private static final String FEDORA_FILENAME = "/etc/pki/ca-trust/source/anchors/%s.pem"; + private static final List FEDORA_COMMAND = List.of("sudo", "update-ca-trust", "extract"); + private static final String UBUNTU_LOCATION = "/usr/local/share/ca-certificates"; + private static final String UBUNTU_FILENAME = "/usr/local/share/ca-certificates/%s.crt"; + private static final List UBUNTU_COMMAND = List.of("sudo", "update-ca-certificates"); + + private static final String SUSE_LOCATION = "/usr/share/pki/trust/anchors"; + private static final String SUSE_FILENAME = "/usr/share/pki/trust/anchors/%s.pem"; + private static final List SUSE_COMMAND = List.of("sudo", "update-ca-certificates"); + + + public static void installCAOnLinux(String cn, File ca) throws Exception { + LOGGER.log(INFO, "🔥 Installing the CA certificate (issuer: {0}) into your operating system keychain. Your admin password may be asked.", cn); + + String certName = ca.getName().substring(0, ca.getName().lastIndexOf('.')); + if (new File(FEDORA_LOCATION).isDirectory()) { + String filename = String.format(FEDORA_FILENAME, certName); + copy(ca, new File(filename)); + run(FEDORA_COMMAND); + } else if (new File(UBUNTU_LOCATION).isDirectory()) { + String filename = String.format(UBUNTU_FILENAME, certName); + copy(ca, new File(filename)); + run(UBUNTU_COMMAND); + } else if (new File(SUSE_LOCATION).isDirectory()) { + String filename = String.format(SUSE_FILENAME, certName); + copy(ca, new File(filename)); + run(SUSE_COMMAND); + } else { + LOGGER.log(ERROR, "❌ Unsupported Linux distribution, please install the CA certificate ({0}) manually", ca.getAbsolutePath()); + } + + LOGGER.log(WARNING, "❗️ Please restart your browser to take the changes into account. Some browser requires the certificate to be manually imported. " + + "Please refer to the browser documentation, and import the certificate located at {0}", ca.getAbsolutePath()); + + } + + + private static void run(List command) throws IOException, InterruptedException { + LOGGER.log(DEBUG, "\t Executing command {0}", String.join(" ", command)); + ProcessBuilder pb = new ProcessBuilder(command); + pb.inheritIO(); + pb.start().waitFor(); + } + + private static void copy(File ca, File out) throws Exception { + ProcessBuilder pb = new ProcessBuilder("sudo", "cp", ca.getAbsolutePath(), out.getAbsolutePath()); + pb.inheritIO(); + pb.start().waitFor(); + LOGGER.log(DEBUG, "\t Certificate copied to {0}", out.getAbsolutePath()); + } + +} diff --git a/certificate-generator/src/main/java/me/escoffier/certs/ca/MacCAInstaller.java b/certificate-generator/src/main/java/me/escoffier/certs/ca/MacCAInstaller.java new file mode 100644 index 0000000..42b1af9 --- /dev/null +++ b/certificate-generator/src/main/java/me/escoffier/certs/ca/MacCAInstaller.java @@ -0,0 +1,111 @@ +package me.escoffier.certs.ca; + +import com.dd.plist.*; + +import java.io.File; +import java.nio.file.Files; + +import static java.lang.System.Logger.Level.DEBUG; +import static java.lang.System.Logger.Level.INFO; + +/** + * Utility class to install the CA certificate on a Mac. + */ +public class MacCAInstaller { + + static System.Logger LOGGER = System.getLogger(CaGenerator.class.getName()); + + + public static void installCAOnMac(String cn, File ca) throws Exception { + LOGGER.log(INFO, "🔥 Installing CA certificate (issuer: {0}) into your operating system keychain. Your admin password may be asked.", cn); + ProcessBuilder pb = new ProcessBuilder("sudo", "security", "-v", "add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", ca.getAbsolutePath()); + pb.inheritIO(); + pb.start().waitFor(); + LOGGER.log(DEBUG, "\t Certificate added to the keychain"); + + var tmp = new File("trust-settings.plist"); + pb = new ProcessBuilder("sudo", "security", "trust-settings-export", "-d", tmp.getAbsolutePath()); + pb.inheritIO(); + pb.start().waitFor(); + LOGGER.log(DEBUG, "\t Trust settings exported to {0}", tmp.getAbsolutePath()); + + pb = new ProcessBuilder("sudo", "chown", System.getProperty("user.name"), tmp.getAbsolutePath()); + pb.inheritIO(); + pb.start().waitFor(); + + updateCertificateTrustSettings(cn, tmp); + + LOGGER.log(DEBUG, "\t Trust settings updated"); + + pb = new ProcessBuilder("sudo", "security", "trust-settings-import", "-d", tmp.getAbsolutePath()); + pb.inheritIO(); + pb.start().waitFor(); + + LOGGER.log(DEBUG, "\t Trust settings imported"); + } + + private static void updateCertificateTrustSettings(String cn, File plist) throws Exception { + var content = Files.readString(plist.toPath()); + NSDictionary main = (NSDictionary) PropertyListParser.parse(content.getBytes()); + NSDictionary certs = (NSDictionary) main.get("trustList"); + + boolean found = false; + for (int i = 0; i < certs.allKeys().length; i++) { + String k = certs.allKeys()[i]; + NSDictionary value = (NSDictionary) certs.objectForKey(k); + NSData data = (NSData) value.get("issuerName"); + String v = data.getBase64EncodedData(); + byte[] decodedBytes = java.util.Base64.getDecoder().decode(v.getBytes()); + String in = new String(decodedBytes); + if (in.contains(cn)) { + LOGGER.log(DEBUG, "found ca certificate in plist"); + found = true; + /* + * + kSecTrustSettingsAllowedError + -2147408896 + kSecTrustSettingsPolicy + + KoZIhvdjZAED + + kSecTrustSettingsPolicyName + sslServer + kSecTrustSettingsResult + 2 + + + kSecTrustSettingsAllowedError + -2147409654 + kSecTrustSettingsPolicy + + KoZIhvdjZAEC + + kSecTrustSettingsPolicyName + basicX509 + kSecTrustSettingsResult + 2 + + */ + NSArray settings = new NSArray(2); + NSDictionary dict0 = new NSDictionary(); + NSDictionary dict1 = new NSDictionary(); + dict0.put("kSecTrustSettingsPolicy", new NSData("KoZIhvdjZAED")); + dict0.put("kSecTrustSettingsPolicyName", new NSString("sslServer")); + dict0.put("kSecTrustSettingsResult", new NSNumber(2)); + + dict1.put("kSecTrustSettingsPolicy", new NSData("KoZIhvdjZAEC")); + dict1.put("kSecTrustSettingsPolicyName", new NSString("basicX509")); + dict1.put("kSecTrustSettingsResult", new NSNumber(2)); + + settings.setValue(0, dict0); + settings.setValue(1, dict1); + + value.put("trustSettings", settings); + } + } + if (! found) { + LOGGER.log(INFO, "\uD83D\uDEAB CA certificate not found in plist"); + } + Files.writeString(plist.toPath(), main.toXMLPropertyList()); + } +} diff --git a/certificate-generator/src/main/java/me/escoffier/certs/ca/WindowsCAInstaller.java b/certificate-generator/src/main/java/me/escoffier/certs/ca/WindowsCAInstaller.java new file mode 100644 index 0000000..a6581e8 --- /dev/null +++ b/certificate-generator/src/main/java/me/escoffier/certs/ca/WindowsCAInstaller.java @@ -0,0 +1,26 @@ +package me.escoffier.certs.ca; + +import java.io.File; + +/** + * A utility to install the CA certificate on Windows. + */ +public class WindowsCAInstaller { + static System.Logger LOGGER = System.getLogger(CaGenerator.class.getName()); + + + public static void installCAOnWindows(String cn, File ca) throws Exception { + LOGGER.log(System.Logger.Level.INFO, "🔥 Installing CA certificate (issuer: {0}) into your operating system keychain. Make sure your are in a privileged (`run with administrator`) terminal.", cn); + ProcessBuilder pb = new ProcessBuilder("certutil", "-addstore", "-v", "-f", "-user", "Root", ca.getAbsolutePath()); + pb.inheritIO(); + int res = pb.start().waitFor(); + + if (res != 0) { + LOGGER.log(System.Logger.Level.ERROR, "❌ Unable to install the CA certificate into the keychain. Please run: `certutil -addstore -v -f -user Root " + ca.getAbsolutePath() + "` in a privileged terminal."); + return; + } + + LOGGER.log(System.Logger.Level.DEBUG, "\t Certificate added to the keychain"); + } + +} diff --git a/certificate-generator/src/main/java/me/escoffier/certs/chain/CertificateChainGenerator.java b/certificate-generator/src/main/java/me/escoffier/certs/chain/CertificateChainGenerator.java index e0e76c4..e4bd402 100644 --- a/certificate-generator/src/main/java/me/escoffier/certs/chain/CertificateChainGenerator.java +++ b/certificate-generator/src/main/java/me/escoffier/certs/chain/CertificateChainGenerator.java @@ -36,7 +36,7 @@ public class CertificateChainGenerator { private List sans = List.of("DNS:localhost"); - private File baseDir; // Mandatory + private final File baseDir; public CertificateChainGenerator(File baseDir) { this.baseDir = baseDir; diff --git a/certificate-generator/src/test/java/me/escoffier/certs/ca/GenerateCaTest.java b/certificate-generator/src/test/java/me/escoffier/certs/ca/GenerateCaTest.java new file mode 100644 index 0000000..eaa6eb7 --- /dev/null +++ b/certificate-generator/src/test/java/me/escoffier/certs/ca/GenerateCaTest.java @@ -0,0 +1,99 @@ +package me.escoffier.certs.ca; + +import me.escoffier.certs.CertificateGenerator; +import me.escoffier.certs.CertificateRequest; +import me.escoffier.certs.CertificateUtils; +import me.escoffier.certs.Format; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileReader; +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Generate a CA certificate (without the installation) and generate a signed certificate. + */ +public class GenerateCaTest { + + @Test + void test() throws Exception { + File out = new File("target/ca"); + out.mkdirs(); + + var ca = new File(out, "ca.crt"); + var key = new File(out, "ca.key"); + var store = new File(out, "ks.p12"); + CaGenerator generator = new CaGenerator(ca, key, store, "test"); + generator.generate("localhost", "Test", "Test Dev", "home", "world", "Cloud"); + + assertThat(ca).exists(); + assertThat(key).exists(); + assertThat(store).exists(); + + File trustStore = new File(out, "truststore.p12"); + generator.generateTrustStore(trustStore); + assertThat(trustStore).exists(); + + + // Generate a signed certificate + CertificateGenerator gen = new CertificateGenerator(out.toPath(), true); + gen.generate(new CertificateRequest().withName("test").signedWith(loadRootCertificate(ca), loadPrivateKey(key)).withFormat(Format.PKCS12).withPassword("secret")); + + File signedKS = new File(out, "test-keystore.p12"); + File signedTS = new File(out, "test-truststore.p12"); + + assertThat(signedKS).exists(); + assertThat(signedTS).exists(); + + try (FileInputStream fis = new FileInputStream(signedKS)) { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(fis, "secret".toCharArray()); + assertThat(ks.getCertificate("test")).isNotNull(); + assertThat(ks.getKey("test", "secret".toCharArray())).isNotNull(); + + ks.getCertificate("test").verify(loadRootCertificate(ca).getPublicKey()); + } + + try (FileInputStream fis = new FileInputStream(signedTS)) { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(fis, "secret".toCharArray()); + assertThat(ks.getCertificate("test")).isNotNull(); + } + + } + + private X509Certificate loadRootCertificate(File ca) throws Exception { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + try (FileInputStream fis = new FileInputStream(ca)) { + return (X509Certificate) cf.generateCertificate(fis); + } + } + + private PrivateKey loadPrivateKey(File key) throws Exception { + try (BufferedReader reader = new BufferedReader(new FileReader(key)); + PEMParser pemParser = new PEMParser(reader)) { + Object obj = pemParser.readObject(); + if (obj instanceof KeyPair) { + return ((KeyPair) obj).getPrivate(); + } else if (obj instanceof PrivateKeyInfo) { + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + return converter.getPrivateKey(((PrivateKeyInfo) obj)); + } else { + throw new IllegalStateException( + "The file " + key.getAbsolutePath() + " does not contain a private key " + + obj.getClass().getName()); + } + } + } +}