diff --git a/README.md b/README.md index 81135a5..3d735d2 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,8 @@ mkdir -p ~/dev/certificates mkcert -cert-file ~/dev/certificates/example.org.pem -key-file ~/dev/certificates/example.org.key example.org ``` +In production environments, your certificate will likely be signed by one or more intermediate Certificate Authorities. In addition to the server certificate, ensure that all intermediate CA certificates in the chain are included in your pem file. + Now you can load these into the HTTP server like this: ```java diff --git a/build.savant b/build.savant index 3b10856..b5a4ddf 100644 --- a/build.savant +++ b/build.savant @@ -16,7 +16,7 @@ restifyVersion = "4.1.2" testngVersion = "7.6.1" -project(group: "io.fusionauth", name: "java-http", version: "0.1.10", licenses: ["ApacheV2_0"]) { +project(group: "io.fusionauth", name: "java-http", version: "0.1.11", licenses: ["ApacheV2_0"]) { workflow { fetch { cache() diff --git a/src/main/java/io/fusionauth/http/security/SecurityTools.java b/src/main/java/io/fusionauth/http/security/SecurityTools.java index 6fd9ac7..b67ec3d 100644 --- a/src/main/java/io/fusionauth/http/security/SecurityTools.java +++ b/src/main/java/io/fusionauth/http/security/SecurityTools.java @@ -18,6 +18,7 @@ import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManagerFactory; +import javax.security.auth.x500.X500Principal; import java.io.ByteArrayInputStream; import java.io.IOException; import java.security.GeneralSecurityException; @@ -28,10 +29,13 @@ import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; import java.security.interfaces.RSAPrivateKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; +import java.util.Collection; +import java.util.HashMap; /** * A toolkit for security helper methods. @@ -74,12 +78,87 @@ public static SSLContext clientContext(Certificate certificate) throws GeneralSe return context; } - public static Certificate parseCertificate(String certificate) throws CertificateException { + /** + * Parses a single certificate from a PEM string. + * + * @param certificateString PEM-formatted certificate text. + * @return The first {@link Certificate} encoded in the file. + * @throws CertificateException If unable to parse PEM content. + */ + public static Certificate parseCertificate(String certificateString) throws CertificateException { CertificateFactory factory = CertificateFactory.getInstance("X.509"); - byte[] certBytes = parseDERFromPEM(certificate, CERT_START, CERT_END); - return factory.generateCertificate(new ByteArrayInputStream(certBytes)); + var is = new ByteArrayInputStream(certificateString.getBytes()); + return factory.generateCertificates(is).stream().findFirst().get(); + } + + /** + * Parses and re-orders multiple Certificates from a PEM-formatted string into an ordered certificate chain array. + * + * @param certificateString the PEM-formatted content of one or more certificates in a chain. + * @return An array of {@link Certificate}s ordered from the end-entity through each supplied issuer. + * @throws CertificateException If unable to parse PEM content. + */ + public static Certificate[] parseCertificates(String certificateString) throws CertificateException { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + var is = new ByteArrayInputStream(certificateString.getBytes()); + var certs = (Collection) factory.generateCertificates(is); + return reorderCertificates(certs); + } + + /** + * Returns an array of Certificates ordered starting from the end-entity through each supplied issuer in the List. + * + * @param certs The certificates to re-order. + * @return An array of {@link Certificate}s ordered from the end-entity through each supplied issuer. + * @throws IllegalArgumentException if certs is empty or missing an intermediate issuer cert. + */ + private static Certificate[] reorderCertificates(Collection certs) { + if (certs.isEmpty()) { + throw new IllegalArgumentException("Empty certificate list"); + } + + var orderedCerts = new X509Certificate[certs.size()]; + + // Short circuit if only one cert is in collection + if (certs.size() == 1) { + orderedCerts[0] = certs.stream().findFirst().get(); + return orderedCerts; + } + + // Index certificates by Issuer and Subject + var certsByIssuer = new HashMap(certs.size()); + var certsBySubject = new HashMap(certs.size()); + + // Load all certificates into maps + for (X509Certificate cert : certs) { + certsByIssuer.put(cert.getIssuerX500Principal(), cert); + certsBySubject.put(cert.getSubjectX500Principal(), cert); + } + + // Find the server certificate. It's the one that no other certificate refers to as its issuer. Store it in the first array element. + for (var cert : certs) { + if (!certsByIssuer.containsKey(cert.getSubjectX500Principal())) { + orderedCerts[0] = cert; + break; + } + } + + // Start at the server cert and add each issuer certificate in order. + for (int i = 0; i < orderedCerts.length - 1; i++) { + var issuer = certsBySubject.get(orderedCerts[i].getIssuerX500Principal()); + if (issuer == null) { + throw new IllegalArgumentException("Missing issuer cert for " + orderedCerts[i].getIssuerX500Principal()); + } + orderedCerts[i + 1] = issuer; + } + + return orderedCerts; } + + /** + * Parses a single object in a PEM-formatted string into a byte[]. + */ public static byte[] parseDERFromPEM(String pem, String beginDelimiter, String endDelimiter) { int startIndex = pem.indexOf(beginDelimiter); if (startIndex < 0) { @@ -124,4 +203,26 @@ public static SSLContext serverContext(Certificate certificate, PrivateKey priva context.init(kmf.getKeyManagers(), null, null); return context; } + + /** + * This creates an in-memory keystore containing the certificate chain and private key and initializes the SSLContext with the key + * material it contains. + * + * @param certificateChain The chain of certificates to include in the TLS negotiation. Should be ordered by end-entity first. + * @param privateKey The PrivateKey corresponding to the end-entity certificate in the chain. + * @return A SSLContext configured with the Certificate and Private Key. + */ + public static SSLContext serverContext(Certificate[] certificateChain, PrivateKey privateKey) + throws GeneralSecurityException, IOException { + KeyStore keystore = KeyStore.getInstance("JKS"); + keystore.load(null); + keystore.setKeyEntry("key-alias", privateKey, "changeit".toCharArray(), certificateChain); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509"); + kmf.init(keystore, "changeit".toCharArray()); + + SSLContext context = SSLContext.getInstance("TLS"); + context.init(kmf.getKeyManagers(), null, null); + return context; + } } diff --git a/src/main/java/io/fusionauth/http/server/HTTPListenerConfiguration.java b/src/main/java/io/fusionauth/http/server/HTTPListenerConfiguration.java index ff41a4d..6e62256 100644 --- a/src/main/java/io/fusionauth/http/server/HTTPListenerConfiguration.java +++ b/src/main/java/io/fusionauth/http/server/HTTPListenerConfiguration.java @@ -36,7 +36,7 @@ public class HTTPListenerConfiguration { private final InetAddress bindAddress; - private final Certificate certificate; + private final Certificate[] certificateChain; private final int port; @@ -54,7 +54,7 @@ public HTTPListenerConfiguration(int port) { this.bindAddress = allInterfaces(); this.port = port; this.tls = false; - this.certificate = null; + this.certificateChain = null; this.privateKey = null; } @@ -62,7 +62,7 @@ public HTTPListenerConfiguration(int port) { * Stores the configuration for a single HTTP listener for the server. This constructor sets up a TLS based listener. * * @param port The port of this listener. - * @param certificate The certificate as a PEM encoded X.509 certificate String. + * @param certificate The certificate as a PEM encoded X.509 certificate String. May include intermediate CA certificates. * @param privateKey The private key as a PKCS8 encoded DER private key. * @throws GeneralSecurityException If the private key or certificate Strings were not valid and could not be parsed. */ @@ -73,7 +73,7 @@ public HTTPListenerConfiguration(int port, String certificate, String privateKey this.bindAddress = allInterfaces(); this.port = port; this.tls = true; - this.certificate = SecurityTools.parseCertificate(certificate); + this.certificateChain = SecurityTools.parseCertificates(certificate); this.privateKey = SecurityTools.parsePrivateKey(privateKey); } @@ -91,7 +91,26 @@ public HTTPListenerConfiguration(int port, Certificate certificate, PrivateKey p this.bindAddress = allInterfaces(); this.port = port; this.tls = true; - this.certificate = certificate; + this.certificateChain = new Certificate[]{certificate}; + this.privateKey = privateKey; + } + + /** + * Stores the configuration for a single HTTP listener for the server. This constructor sets up a TLS based listener using the supplied + * certificate chain. + * + * @param port The port of this listener. + * @param certificateChain The certificate Object. + * @param privateKey The private key Object. + */ + public HTTPListenerConfiguration(int port, Certificate[] certificateChain, PrivateKey privateKey) { + Objects.requireNonNull(certificateChain); + Objects.requireNonNull(privateKey); + + this.bindAddress = allInterfaces(); + this.port = port; + this.tls = true; + this.certificateChain = certificateChain; this.privateKey = privateKey; } @@ -107,7 +126,7 @@ public HTTPListenerConfiguration(InetAddress bindAddress, int port) { this.bindAddress = bindAddress; this.port = port; this.tls = false; - this.certificate = null; + this.certificateChain = null; this.privateKey = null; } @@ -129,7 +148,7 @@ public HTTPListenerConfiguration(InetAddress bindAddress, int port, String certi this.bindAddress = bindAddress; this.port = port; this.tls = true; - this.certificate = SecurityTools.parseCertificate(certificate); + this.certificateChain = SecurityTools.parseCertificates(certificate); this.privateKey = SecurityTools.parsePrivateKey(privateKey); } @@ -149,7 +168,7 @@ public HTTPListenerConfiguration(InetAddress bindAddress, int port, Certificate this.bindAddress = bindAddress; this.port = port; this.tls = true; - this.certificate = certificate; + this.certificateChain = new Certificate[]{certificate}; this.privateKey = privateKey; } @@ -158,7 +177,15 @@ public InetAddress getBindAddress() { } public Certificate getCertificate() { - return certificate; + if (certificateChain != null && certificateChain.length > 0) { + return certificateChain[0]; + } else { + return null; + } + } + + public Certificate[] getCertificateChain() { + return certificateChain; } public int getPort() { diff --git a/src/main/java/io/fusionauth/http/server/HTTPS11Processor.java b/src/main/java/io/fusionauth/http/server/HTTPS11Processor.java index eb43c66..f9fec7a 100644 --- a/src/main/java/io/fusionauth/http/server/HTTPS11Processor.java +++ b/src/main/java/io/fusionauth/http/server/HTTPS11Processor.java @@ -58,7 +58,7 @@ public HTTPS11Processor(HTTP11Processor delegate, HTTPServerConfiguration config this.logger = configuration.getLoggerFactory().getLogger(HTTPS11Processor.class); if (listenerConfiguration.isTLS()) { - SSLContext context = SecurityTools.serverContext(listenerConfiguration.getCertificate(), listenerConfiguration.getPrivateKey()); + SSLContext context = SecurityTools.serverContext(listenerConfiguration.getCertificateChain(), listenerConfiguration.getPrivateKey()); this.engine = context.createSSLEngine(); this.engine.setUseClientMode(false); diff --git a/src/test/java/io/fusionauth/http/BaseTest.java b/src/test/java/io/fusionauth/http/BaseTest.java index f6c2f2c..c59a296 100644 --- a/src/test/java/io/fusionauth/http/BaseTest.java +++ b/src/test/java/io/fusionauth/http/BaseTest.java @@ -24,14 +24,24 @@ import java.net.URI; import java.net.http.HttpClient; import java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.KeyStore; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; +import java.security.cert.CertPath; +import java.security.cert.CertPathParameters; +import java.security.cert.CertPathValidator; +import java.security.cert.CertPathValidatorException; import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.PKIXParameters; +import java.security.cert.X509Certificate; import java.time.Duration; import java.time.Instant; +import java.util.Arrays; import java.util.Date; import java.util.UUID; @@ -51,11 +61,17 @@ import sun.security.util.KnownOIDs; import sun.security.util.ObjectIdentifier; import sun.security.x509.AlgorithmId; +import sun.security.x509.BasicConstraintsExtension; import sun.security.x509.CertificateAlgorithmId; +import sun.security.x509.CertificateExtensions; import sun.security.x509.CertificateSerialNumber; import sun.security.x509.CertificateValidity; import sun.security.x509.CertificateVersion; import sun.security.x509.CertificateX509Key; +import sun.security.x509.DNSName; +import sun.security.x509.GeneralName; +import sun.security.x509.GeneralNames; +import sun.security.x509.SubjectAlternativeNameExtension; import sun.security.x509.X500Name; import sun.security.x509.X509CertImpl; import sun.security.x509.X509CertInfo; @@ -82,10 +98,21 @@ public abstract class BaseTest { public static AccumulatingLogger logger = (AccumulatingLogger) AccumulatingLoggerFactory.FACTORY.getLogger(BaseTest.class); + /* + * Keypairs and certificates for a 3-level CA chain (root->intermediate->server). + */ public Certificate certificate; + public Certificate intermediateCertificate; + + public KeyPair intermediateKeyPair; + public KeyPair keyPair; + public Certificate rootCertificate; + + public KeyPair rootKeyPair; + static { logger.setLevel(Level.Trace); } @@ -93,7 +120,7 @@ public abstract class BaseTest { public HttpClient makeClient(String scheme, CookieHandler cookieHandler) throws GeneralSecurityException, IOException { var builder = HttpClient.newBuilder(); if (scheme.equals("https")) { - builder.sslContext(SecurityTools.clientContext(certificate)); + builder.sslContext(SecurityTools.clientContext(rootCertificate)); } if (cookieHandler != null) { @@ -116,9 +143,10 @@ public HTTPServer makeServer(String scheme, HTTPHandler handler, Instrumenter in boolean tls = scheme.equals("https"); HTTPListenerConfiguration listenerConfiguration; if (tls) { - keyPair = generateNewRSAKeyPair(); - certificate = generateSelfSignedCertificate(keyPair.getPublic(), keyPair.getPrivate()); - listenerConfiguration = new HTTPListenerConfiguration(4242, certificate, keyPair.getPrivate()); + setupCertificates(); + + var certChain = new Certificate[]{certificate, intermediateCertificate}; + listenerConfiguration = new HTTPListenerConfiguration(4242, certChain, keyPair.getPrivate()); } else { listenerConfiguration = new HTTPListenerConfiguration(4242); } @@ -170,36 +198,124 @@ public void sendBadRequest(String message) { } } + protected X509CertInfo generateCertInfo(PublicKey publicKey, String commonName) { + try { + X509CertInfo certInfo = new X509CertInfo(); + CertificateX509Key certKey = new CertificateX509Key(publicKey); + certInfo.set(X509CertInfo.KEY, certKey); + // X.509 Certificate version 3 (0 based) + certInfo.set(X509CertInfo.VERSION, new CertificateVersion(2)); + certInfo.set(X509CertInfo.ALGORITHM_ID, new CertificateAlgorithmId(new AlgorithmId(ObjectIdentifier.of(KnownOIDs.SHA256withRSA)))); + certInfo.set(X509CertInfo.SUBJECT, new X500Name("CN=" + commonName)); + certInfo.set(X509CertInfo.VALIDITY, new CertificateValidity(Date.from(Instant.now().minusSeconds(30)), Date.from(Instant.now().plusSeconds(10_000)))); + certInfo.set(X509CertInfo.SERIAL_NUMBER, new CertificateSerialNumber(new BigInteger(UUID.randomUUID().toString().replace("-", ""), 16))); + + return certInfo; + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + protected KeyPair generateNewRSAKeyPair() { try { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); - keyPairGenerator.initialize(2048); + keyPairGenerator.initialize(4096); return keyPairGenerator.generateKeyPair(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } - protected Certificate generateSelfSignedCertificate(PublicKey publicKey, PrivateKey privateKey) + protected Certificate generateRootCA(PublicKey publicKey, PrivateKey privateKey) throws IllegalArgumentException { try { - X509CertInfo certInfo = new X509CertInfo(); - CertificateX509Key certKey = new CertificateX509Key(publicKey); - certInfo.set(X509CertInfo.KEY, certKey); - // X.509 Certificate version 2 (0 based) - certInfo.set(X509CertInfo.VERSION, new CertificateVersion(1)); - certInfo.set(X509CertInfo.ALGORITHM_ID, new CertificateAlgorithmId(new AlgorithmId(ObjectIdentifier.of(KnownOIDs.SHA256withRSA)))); - certInfo.set(X509CertInfo.ISSUER, new X500Name("CN=local.fusionauth.io")); - certInfo.set(X509CertInfo.SUBJECT, new X500Name("CN=local.fusionauth.io")); - certInfo.set(X509CertInfo.VALIDITY, new CertificateValidity(Date.from(Instant.now().minusSeconds(30)), Date.from(Instant.now().plusSeconds(10_000)))); - certInfo.set(X509CertInfo.SERIAL_NUMBER, new CertificateSerialNumber(new BigInteger(UUID.randomUUID().toString().replace("-", ""), 16))); + // Generate the standard CertInfo, but set Issuer and Subject to the same value. + X509CertInfo certInfo = generateCertInfo(publicKey, "root-ca.fusionauth.io"); + certInfo.set(X509CertInfo.ISSUER, new X500Name("CN=root-ca.fusionauth.io")); + + // Self-sign certificate + return signCertificate(new X509CertImpl(certInfo), privateKey, certInfo, true); + + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Generates keypairs and certificates for Root CA -> Intermediate -> Server Certificate. + */ + protected void setupCertificates() { + rootKeyPair = generateNewRSAKeyPair(); + intermediateKeyPair = generateNewRSAKeyPair(); + keyPair = generateNewRSAKeyPair(); + + // Build root and intermediate CAs + rootCertificate = generateRootCA(rootKeyPair.getPublic(), rootKeyPair.getPrivate()); + X509CertInfo intermediateCertInfo = generateCertInfo(intermediateKeyPair.getPublic(), "intermediate.fusionauth.io"); + intermediateCertificate = signCertificate((X509Certificate) rootCertificate, rootKeyPair.getPrivate(), intermediateCertInfo, true); + + // Build server cert + X509CertInfo serverCertInfo = generateCertInfo(keyPair.getPublic(), "local.fusionauth.io"); + certificate = signCertificate((X509Certificate) intermediateCertificate, intermediateKeyPair.getPrivate(), serverCertInfo, false); + } + + protected X509Certificate signCertificate(X509Certificate issuer, PrivateKey issuerPrivateKey, X509CertInfo signingRequest, boolean isCa) + throws IllegalArgumentException { + + try { + X509CertInfo issuerInfo = new X509CertInfo(issuer.getTBSCertificate()); + signingRequest.set(X509CertInfo.ISSUER, issuerInfo.get(X509CertInfo.SUBJECT)); + CertificateExtensions certExtensions = new CertificateExtensions(); + + if (isCa) { + certExtensions.set(BasicConstraintsExtension.NAME, new BasicConstraintsExtension(true, true, 1)); + } + + // Set the Subject Alternate Names field to the DNS hostname. + X500Name subject = (X500Name) signingRequest.get(X509CertInfo.SUBJECT); + String hostname = subject.getCommonName(); + GeneralNames altNames = new GeneralNames(); + altNames.add(new GeneralName(new DNSName(hostname))); + certExtensions.set(SubjectAlternativeNameExtension.NAME, new SubjectAlternativeNameExtension(false, altNames)); + signingRequest.set(X509CertInfo.EXTENSIONS, certExtensions); + + // Sign it + X509CertImpl signed = new X509CertImpl(signingRequest); + signed.sign(issuerPrivateKey, "SHA256withRSA"); + return signed; + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Verifies that the chain certificates can be validated up to the supplied root certificate. See + * {@link CertPathValidator#validate(CertPath, CertPathParameters)} for details. + */ + protected void validateCertPath(Certificate root, Certificate[] chain) + throws CertPathValidatorException, InvalidAlgorithmParameterException { + + CertPathValidator validator; + CertPath certPath; + PKIXParameters pkixParameters; + + try { + var certificateFactory = CertificateFactory.getInstance("X.509"); + certPath = certificateFactory.generateCertPath(Arrays.asList(chain)); + + // Create a trustStore with only the root installed + var trustStore = KeyStore.getInstance("JKS"); + trustStore.load(null); + trustStore.setCertificateEntry("root-ca", root); - X509CertImpl impl = new X509CertImpl(certInfo); - impl.sign(privateKey, "SHA256withRSA"); - return impl; + pkixParameters = new PKIXParameters(trustStore); + pkixParameters.setRevocationEnabled(false); + validator = CertPathValidator.getInstance("PKIX"); } catch (Exception e) { throw new IllegalArgumentException(e); } + // validate() will throw an exception if any check fails. + validator.validate(certPath, pkixParameters); } @SuppressWarnings("unused") diff --git a/src/test/java/io/fusionauth/http/CoreTest.java b/src/test/java/io/fusionauth/http/CoreTest.java index 1ee58e0..b851d04 100644 --- a/src/test/java/io/fusionauth/http/CoreTest.java +++ b/src/test/java/io/fusionauth/http/CoreTest.java @@ -25,6 +25,7 @@ import java.net.http.HttpRequest.BodyPublishers; import java.net.http.HttpResponse.BodySubscribers; import java.nio.charset.StandardCharsets; +import java.security.cert.Certificate; import java.time.Duration; import java.util.List; import java.util.Locale; @@ -470,12 +471,13 @@ public void simpleGetMultiplePorts() throws Exception { } }; - keyPair = generateNewRSAKeyPair(); - certificate = generateSelfSignedCertificate(keyPair.getPublic(), keyPair.getPrivate()); + setupCertificates(); + var certChain = new Certificate[]{certificate, intermediateCertificate}; + try (HTTPServer ignore = new HTTPServer().withHandler(handler) .withListener(new HTTPListenerConfiguration(4242)) .withListener(new HTTPListenerConfiguration(4243)) - .withListener(new HTTPListenerConfiguration(4244, certificate, keyPair.getPrivate())) + .withListener(new HTTPListenerConfiguration(4244, certChain, keyPair.getPrivate())) .withLoggerFactory(AccumulatingLoggerFactory.FACTORY) .withNumberOfWorkerThreads(1) .start()) { @@ -627,4 +629,30 @@ private void sleep(long millis) { // Ignore } } -} \ No newline at end of file + + @Test + public void certificateChain() throws Exception { + HTTPHandler handler = (req, res) -> { + res.setStatus(200); + res.getOutputStream().close(); + }; + + try (HTTPServer ignore = makeServer("https", handler).start()) { + var client = makeClient("https", null); + URI uri = makeURI("https", ""); + HttpRequest request = HttpRequest.newBuilder() + .uri(uri) + .GET() + .build(); + + var response = client.send(request, r -> BodySubscribers.ofInputStream()); + assertEquals(response.statusCode(), 200); + + var sslSession = response.sslSession().get(); + var peerCerts = sslSession.getPeerCertificates(); + + // Verify that we received all intermediates, and can verify the chain all the way up to rootCertificate. + validateCertPath(rootCertificate, peerCerts); + } + } +} diff --git a/src/test/java/io/fusionauth/http/security/SecurityToolsTest.java b/src/test/java/io/fusionauth/http/security/SecurityToolsTest.java new file mode 100644 index 0000000..6f5e55d --- /dev/null +++ b/src/test/java/io/fusionauth/http/security/SecurityToolsTest.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022, FusionAuth, All Rights Reserved + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the License. + */ +package io.fusionauth.http.security; + +import java.nio.file.Files; +import java.nio.file.Path; + +import io.fusionauth.http.BaseTest; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; +import static org.testng.Assert.assertEquals; + +/** + * Tests parsing of certificate chains and keys. + * + * @author Mark Manes + */ +public class SecurityToolsTest extends BaseTest { + + static Path projectDir; + + @BeforeClass + public static void setUp() { + projectDir = Path.of(""); + } + + @Test + public void testParseCertificateChain() throws Exception { + // Test that a combined server and intermediate certificate parse from the same file + var combinedPem = Files.readString(projectDir.resolve("src/test/resources/test-intermediate-server-combined.pem")); + var rootPem = Files.readString(projectDir.resolve("src/test/resources/test-root-ca.pem")); + + var certs = SecurityTools.parseCertificates(combinedPem); + var rootCert = SecurityTools.parseCertificate(rootPem); + assertEquals(certs.length, 3); + + // Ensure that the combined server certificate chain validate up to the root. This will throw an exception on validation failure. + validateCertPath(rootCert, certs); + } +} diff --git a/src/test/resources/test-intermediate-server-combined.pem b/src/test/resources/test-intermediate-server-combined.pem new file mode 100644 index 0000000..98ef1eb --- /dev/null +++ b/src/test/resources/test-intermediate-server-combined.pem @@ -0,0 +1,102 @@ +-----BEGIN CERTIFICATE----- +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 +ICdgh6aJBH1uJ20K2Bg= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +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 +MV1fpQ== +-----END CERTIFICATE----- diff --git a/src/test/resources/test-root-ca.pem b/src/test/resources/test-root-ca.pem new file mode 100644 index 0000000..8bf59e9 --- /dev/null +++ b/src/test/resources/test-root-ca.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFhjCCA26gAwIBAgIIeeYKOslHPfIwDQYJKoZIhvcNAQELBQAwYTELMAkGA1UE +BhMCVVMxETAPBgNVBAgTCENvbG9yYWRvMRMwEQYDVQQHEwpCcm9vbWZpZWxkMRMw +EQYDVQQKEwpGdXNpb25BdXRoMRUwEwYDVQQDEwxUZXN0IFJvb3QgQ0EwHhcNMjIx +MjEyMTUyODAwWhcNMzIxMjEyMTUyODAwWjBhMQswCQYDVQQGEwJVUzERMA8GA1UE +CBMIQ29sb3JhZG8xEzARBgNVBAcTCkJyb29tZmllbGQxEzARBgNVBAoTCkZ1c2lv +bkF1dGgxFTATBgNVBAMTDFRlc3QgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQAD +ggIPADCCAgoCggIBAMHLYYmq169VZHVtn+BmqcHKgsHkvw+pRAS8MaIkFlA52Ng8 +tbQclHbjpWp39b02R3JEShsCaB7NYi/Gmap3BCkx6BEbm4s0qe6wI+4emfn3Th+O +d8Hv0Mhx/eQXK2O+HLNgUGNRzFgpPT3H4Obcu4FvZbpMsBA+BRO9WJS7HnFYqfzz +J+ILUdR4FPXebeBvnI8XZuPJJfCv2UiWOuNczNzlpSOeNxK1EplOr3LfuFlLRwMq +eMXesubGU0Fe4bWlCsh3BwM1Gr4sa/kCpaDFTanVI/v+QPOjiY1iLCrVTeNHUbF7 +Tcw0yxlEAe0FiwxM8FtNIpslhcPrUiz6au+0koOXxLpJkBI7/UxUgBidzqXYSVXb +s0P7uF41d+OsgVcKY41XezmGGG1VHd0+Z1ApKfSRxscuWB+bvNoyCjgv6MndP1Lz +QNMOgY9rEZcAlspWT/roTzW9vn5EpeI8faCcqW/njxdt36DZ3KUtLFdNUYlMQgCo +ULcA+LtA8PPPIfyP/jX0qqPcBCkAkMn2uOVwKLrzK2Pv4btBDuDFmIRClrzsErIb +a3Mjou5onIlxDPuWNqiC7pJSfdghPl0KsCXgwub03vDwvBo5IAzwqT18O5K2eSjC +cQjOv5nrXsO/ZLtGIG/HYajC7kntd5z83LyKEQKT7jKvR4O0dq8mAthGpATLAgMB +AAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFElzQTi8KdL0NYb3DB9z +7t+B2zyVMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAm/uIHJtw +JdZa9VL2pqSwjIfCZMR9MVawSQOIwQlzktV3McFeM016JJ7O+xirBMQt5fNOfzrJ +ooha9/kmqn2ltHoJe5YliPlwBey60Iv88binsSsn1Rucb2a79XsikSrs00rADlJg +tWdNPfz/VGzZ2jB86v3xdBukpaepFJBDGK1TKjv0JqSEupSPamnQ6ayMLsHwwtjr +oQCLdj0gGejIAc3sVvofNUt4OnFv087g8vQy2FtU2K4wFA2B/DIQTXcwcMAFo2L5 ++QRPiIHOrxG2DdoLSFw/O/gXO0MpX93ms6zS+ISGRqNuln0U+BZMw1Ti8FeBm5Zo +kqomVWGLeCWMSuIVa470Bh29z22zOnoJbgtO91WIl7ea5UL7n486QOHa1+sDgPr1 +HW7lU4vcHueOr3GQB9YGGL6Cn6wvMHlhmtAxhBmqiHA7OFqPf+wME4H6OSFmaRgW +cY+YHh15Gsthw6QYYwHbOVSjOmGDc3uUTGhSg6e+oHKWuwKGMkVdlOt5OmzUh92p +jkhqjImI65SSQ04KR4XRqr5l0LTKvCMfwica10LTSFvTCtsGtsCYDpnjiNhYtIFL +5gQCaichj/RpSIDGZEGCoh2gf8kKPufSJI7K+uMFDb8L21zScxNob+VKpo0TQK4Q +LH5Pz+iGI63UJD27At5WBuOYcV9vbiJFs2Q= +-----END CERTIFICATE-----