Skip to content

Commit

Permalink
feat: Using full cert chain over single certificate (#161)
Browse files Browse the repository at this point in the history
  • Loading branch information
zendern authored Mar 17, 2022
1 parent 5746621 commit 2b4eadd
Show file tree
Hide file tree
Showing 11 changed files with 245 additions and 24 deletions.
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

0 comments on commit 2b4eadd

Please sign in to comment.