Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Using full cert chain over single certificate #161

Merged
merged 14 commits into from
Mar 17, 2022
35 changes: 32 additions & 3 deletions acme/src/main/java/io/micronaut/acme/events/CertificateEvent.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,53 @@
*/
package io.micronaut.acme.events;

import io.micronaut.core.annotation.NonNull;
import java.security.KeyPair;
import java.security.cert.X509Certificate;

/**
* Event used to alert when a new ACME certificate is ready for use.
*/
public class CertificateEvent {
private final X509Certificate certificate;
private final KeyPair domainKeyPair;
private final X509Certificate[] fullCertificateChain;
private boolean validationCert;

/**
* @deprecated {@link #CertificateEvent(KeyPair, boolean, X509Certificate...)} instead.
*
* Creates a new CertificateEvent.
* @param certificate X509 certificate file
* @param domainKeyPair key pair used to encrypt the certificate
* @param validationCert if this certificate is to be used for tls-apln-01 account validation
*/
@Deprecated
public CertificateEvent(X509Certificate certificate, KeyPair domainKeyPair, boolean validationCert) {
this.certificate = certificate;
this.domainKeyPair = domainKeyPair;
this.validationCert = validationCert;
this.fullCertificateChain = new X509Certificate[]{certificate};
}

/**
* Creates a new CertificateEvent containing the full certificate chain.
* @param domainKeyPair key pair used to encrypt the certificate
* @param validationCert if this certificate is to be used for tls-apln-01 account validation
* @param fullCertificateChain X509 certificate file
*/
public CertificateEvent(KeyPair domainKeyPair, boolean validationCert, X509Certificate... fullCertificateChain) {
if (fullCertificateChain == null || fullCertificateChain.length == 0) {
throw new IllegalArgumentException("Certificate chain must not be empty");
}
this.validationCert = validationCert;
this.domainKeyPair = domainKeyPair;
this.fullCertificateChain = fullCertificateChain;
}

/**
* @return Certificate created by ACME server
*/
public X509Certificate getCert() {
return certificate;
return fullCertificateChain[0];
}

/**
Expand All @@ -58,4 +77,14 @@ public KeyPair getDomainKeyPair() {
public boolean isValidationCert() {
return validationCert;
}

/**
* Return the full certificate chain.
*
* @return array of certificates in the chain.
*/
@NonNull
public X509Certificate[] getFullCertificateChain() {
return fullCertificateChain;
}
}
52 changes: 46 additions & 6 deletions acme/src/main/java/io/micronaut/acme/services/AcmeService.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import io.micronaut.acme.challenge.http.endpoint.HttpChallengeDetails;
import io.micronaut.acme.events.CertificateEvent;
import io.micronaut.context.event.ApplicationEventPublisher;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.io.IOUtils;
import io.micronaut.core.io.ResourceResolver;
import io.micronaut.scheduling.TaskScheduler;
Expand Down Expand Up @@ -67,6 +68,7 @@ public class AcmeService {
private static final Logger LOG = LoggerFactory.getLogger(AcmeService.class);
private static final String DOMAIN_CRT = "domain.crt";
private static final String DOMAIN_CSR = "domain.csr";
private static final String X509_CERT = "X.509";

/**
* Let's Encrypt has different production vs test servers.
Expand Down Expand Up @@ -123,7 +125,7 @@ public AcmeService(ApplicationEventPublisher eventPublisher,
*/
public X509Certificate getCurrentCertificate() {
try {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
CertificateFactory cf = CertificateFactory.getInstance(X509_CERT);
File certificate = new File(certLocation, DOMAIN_CRT);
if (certificate.exists()) {
return (X509Certificate) cf.generateCertificate(Files.newInputStream(certificate.toPath()));
Expand All @@ -138,6 +140,29 @@ public X509Certificate getCurrentCertificate() {
}
}

/**
* Returns the full certificate chain.
*
* @return array of each of the certificates in the chain
*/
@NonNull
protected Optional<X509Certificate[]> getFullCertificateChain() {
try {
CertificateFactory cf = CertificateFactory.getInstance(X509_CERT);
File certificate = new File(certLocation, DOMAIN_CRT);
if (certificate.exists()) {
return Optional.of(cf.generateCertificates(Files.newInputStream(certificate.toPath())).stream()
.map(X509Certificate.class::cast)
.toArray(X509Certificate[]::new));
}
} catch (CertificateException | IOException e) {
if (LOG.isWarnEnabled()) {
LOG.warn("Could not create certificate from file", e);
}
}
return Optional.empty();
}

/**
* Orders a new certificate using ACME protocol.
*
Expand Down Expand Up @@ -278,9 +303,17 @@ private boolean writeCombinedFile(Certificate certificate) {
try (BufferedWriter writer = Files.newBufferedWriter(domainCsr.toPath(), WRITE, CREATE, TRUNCATE_EXISTING)) {
certificate.writeCertificate(writer);
}
eventPublisher.publishEvent(new CertificateEvent(getCurrentCertificate(), domainKeyPair, false));
if (LOG.isInfoEnabled()) {
LOG.info("ACME certificate order success! Certificate URL: {}", certificate.getLocation());
Optional<X509Certificate[]> chainOptional = getFullCertificateChain();
if (chainOptional.isPresent()) {
eventPublisher.publishEvent(new CertificateEvent(domainKeyPair, false, chainOptional.get()));
if (LOG.isInfoEnabled()) {
LOG.info("ACME certificate order success! Certificate URL: {}", certificate.getLocation());
}
} else {
if (LOG.isErrorEnabled()) {
LOG.error("ACME certificate chain could not be loaded from file.");
}
result = true;
}
} catch (IOException e) {
if (LOG.isErrorEnabled()) {
Expand Down Expand Up @@ -462,7 +495,7 @@ private void doChallengeSpecificSetup(Authorization auth, Challenge challenge) t
}
KeyPair domainKeyPair = getDomainKeyPair();
X509Certificate tlsAlpn01Certificate = CertificateUtils.createTlsAlpn01Certificate(domainKeyPair, auth.getIdentifier(), ((TlsAlpn01Challenge) challenge).getAcmeValidation());
eventPublisher.publishEvent(new CertificateEvent(tlsAlpn01Certificate, domainKeyPair, true));
eventPublisher.publishEvent(new CertificateEvent(domainKeyPair, true, tlsAlpn01Certificate));
} else if (challenge instanceof Http01Challenge) {
Http01Challenge http01Challenge = (Http01Challenge) challenge;
eventPublisher.publishEvent(new HttpChallengeDetails(http01Challenge.getToken(), http01Challenge.getAuthorization()));
Expand All @@ -482,7 +515,14 @@ private void doChallengeSpecificSetup(Authorization auth, Challenge challenge) t
* Setup the certificate that has been saved to disk and configures it for use.
*/
public void setupCurrentCertificate() {
eventPublisher.publishEvent(new CertificateEvent(getCurrentCertificate(), getDomainKeyPair(), false));
Optional<X509Certificate[]> fullCertificateChainOptional = getFullCertificateChain();
if (fullCertificateChainOptional.isPresent()) {
eventPublisher.publishEvent(new CertificateEvent(getDomainKeyPair(), false, fullCertificateChainOptional.get()));
} else {
if (LOG.isErrorEnabled()) {
LOG.error("ACME certificate chain could not be loaded from file.");
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ void onNewCertificate(CertificateEvent certificateEvent) {
delegatedSslContext.setNewSslContext(sslContext);
} else {
SslContext sslContext = SslContextBuilder
.forServer(certificateEvent.getDomainKeyPair().getPrivate(), certificateEvent.getCert())
.forServer(certificateEvent.getDomainKeyPair().getPrivate(), certificateEvent.getFullCertificateChain())
.build();
delegatedSslContext.setNewSslContext(sslContext);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,17 @@ class AcmeCertRefresherMultiDomainsTaskSpec extends AcmeBaseSpec {
try {
conn.connect()
Certificate[] certs = conn.getServerCertificates()
certs.length == 1
def cert = (X509Certificate) certs[0]
certs.length == 2
X509Certificate cert = certs[0]
cert.getIssuerDN().getName().contains("Pebble Intermediate CA")
cert.getSubjectDN().getName().contains(EXPECTED_DOMAIN)
cert.getSubjectAlternativeNames().size() == 2
cert.getSubjectAlternativeNames().collect({d-> d.get(1)}).contains(EXPECTED_DOMAIN)
cert.getSubjectAlternativeNames().collect({d-> d.get(1)}).contains(EXPECTED_ACME_DOMAIN)

X509Certificate cert2 = certs[1]
cert2.issuerDN.name.contains("Pebble Root CA")
cert2.subjectDN.name.contains("Pebble Intermediate CA")
}finally{
if(conn != null){
conn.disconnect()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,15 @@ class AcmeCertRefresherTaskSpec extends AcmeBaseSpec {
try {
conn.connect()
Certificate[] certs = conn.getServerCertificates()
certs.length == 1
def cert = (X509Certificate) certs[0]
certs.length == 2
X509Certificate cert = certs[0]
cert.getIssuerDN().getName().contains("Pebble Intermediate CA")
cert.getSubjectDN().getName().contains(EXPECTED_DOMAIN)
cert.getSubjectAlternativeNames().size() == 1

X509Certificate cert2 = certs[1]
cert2.issuerDN.name.contains("Pebble Root CA")
cert2.subjectDN.name.contains("Pebble Intermediate CA")
}finally{
if(conn != null){
conn.disconnect()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,15 @@ class AcmeCertRefresherTaskWithClasspathKeysSpec extends AcmeBaseSpec {
try {
conn.connect()
Certificate[] certs = conn.getServerCertificates()
certs.length == 1
def cert = (X509Certificate) certs[0]
certs.length == 2
X509Certificate cert = certs[0]
cert.getIssuerDN().getName().contains("Pebble Intermediate CA")
cert.getSubjectDN().getName().contains(EXPECTED_DOMAIN)
cert.getSubjectAlternativeNames().size() == 1

X509Certificate cert2 = certs[1]
cert2.issuerDN.name.contains("Pebble Root CA")
cert2.subjectDN.name.contains("Pebble Intermediate CA")
}finally{
if(conn != null){
conn.disconnect()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,15 @@ class AcmeCertRefresherTaskWithFileKeysSpec extends AcmeBaseSpec {
try {
conn.connect()
Certificate[] certs = conn.getServerCertificates()
certs.length == 1
def cert = (X509Certificate) certs[0]
certs.length == 2
X509Certificate cert = certs[0]
cert.getIssuerDN().getName().contains("Pebble Intermediate CA")
cert.getSubjectDN().getName().contains(EXPECTED_DOMAIN)
cert.getSubjectAlternativeNames().size() == 1

X509Certificate cert2 = certs[1]
cert2.issuerDN.name.contains("Pebble Root CA")
cert2.subjectDN.name.contains("Pebble Intermediate CA")
}finally{
if(conn != null){
conn.disconnect()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,17 @@ class AcmeCertWildcardRefresherTaskSpec extends AcmeBaseSpec {
try {
conn.connect()
Certificate[] certs = conn.getServerCertificates()
certs.length == 1
def cert = (X509Certificate) certs[0]
certs.length == 2
X509Certificate cert = certs[0]
cert.getIssuerDN().getName().contains("Pebble Intermediate CA")
cert.getSubjectDN().getName().contains(WILDCARD_DOMAIN)
cert.getSubjectAlternativeNames().size() == 2
cert.getSubjectAlternativeNames().collect({d-> d.get(1)}).contains(WILDCARD_DOMAIN)
cert.getSubjectAlternativeNames().collect({d-> d.get(1)}).contains(EXPECTED_BASE_DOMAIN)

X509Certificate cert2 = certs[1]
cert2.issuerDN.name.contains("Pebble Root CA")
cert2.subjectDN.name.contains("Pebble Intermediate CA")
}finally{
if(conn != null){
conn.disconnect()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,15 @@ class AcmeCertRefresherTaskHttp01ChallengeSpec extends AcmeBaseSpec {
Certificate[] certs = conn.getServerCertificates()

then: "we make sure they are from the pebble test server and the domain is as expected"
certs.length == 1
def cert = (X509Certificate) certs[0]
certs.length == 2
X509Certificate cert = certs[0]
cert.getIssuerDN().getName().contains("Pebble Intermediate CA")
cert.getSubjectDN().getName().contains(EXPECTED_ACME_DOMAIN)
cert.getSubjectAlternativeNames().size() == 1

X509Certificate cert2 = certs[1]
cert2.issuerDN.name.contains("Pebble Root CA")
cert2.subjectDN.name.contains("Pebble Intermediate CA")
}

void "test send https request when the cert is in place"() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,15 @@ class AcmeCertRefresherTaskTlsApln01ChallengeSpec extends AcmeBaseSpec {
Certificate[] certs = conn.getServerCertificates()

then: "we make sure they are from the pebble test server and the domain is as expected"
certs.length == 1
def cert = (X509Certificate) certs[0]
certs.length == 2
X509Certificate cert = certs[0]
cert.getIssuerDN().getName().contains("Pebble Intermediate CA")
cert.getSubjectDN().getName().contains(EXPECTED_ACME_DOMAIN)
cert.getSubjectAlternativeNames().size() == 1

X509Certificate cert2 = certs[1]
cert2.issuerDN.name.contains("Pebble Root CA")
cert2.subjectDN.name.contains("Pebble Intermediate CA")
}

void "test send https request when the cert is in place"() {
Expand Down
Loading