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
30 changes: 27 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 @@ -22,27 +22,42 @@
* 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 See constructor that takes full certificate chain instead.
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
*
* 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) {
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 +73,13 @@ public KeyPair getDomainKeyPair() {
public boolean isValidationCert() {
return validationCert;
}

/**
* Return the full certificate chain.
*
* @return array of certificates in the chain.
*/
public X509Certificate[] getFullCertificateChain() {
return fullCertificateChain;
}
}
28 changes: 25 additions & 3 deletions acme/src/main/java/io/micronaut/acme/services/AcmeService.java
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,28 @@ public X509Certificate getCurrentCertificate() {
}
}

/**
* Returns the full certificate chain.
*
* @return array of each of the certificates in the chain
*/
protected X509Certificate[] getFullCertificateChain() {
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
try {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
File certificate = new File(certLocation, DOMAIN_CRT);
if (certificate.exists()) {
return cf.generateCertificates(Files.newInputStream(certificate.toPath())).toArray(new X509Certificate[0]);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important change was to call cf.generateCertificates here and then pass this into the CertificateEvent and thus into Netty for initialization.

} else {
return new X509Certificate[]{};
}
} catch (CertificateException | IOException e) {
if (LOG.isWarnEnabled()) {
LOG.warn("Could not create certificate from file", e);
}
return null;
}
}

/**
* Orders a new certificate using ACME protocol.
*
Expand Down Expand Up @@ -278,7 +300,7 @@ 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));
eventPublisher.publishEvent(new CertificateEvent(domainKeyPair, false, getFullCertificateChain()));
Copy link
Contributor

@sdelamo sdelamo Mar 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if getFullCertificateChain returns an empty array, should we publish the event?. Since CertificateEvent assumes a non empty array due to CertificateEvent::getCert where it gets the first certificate in the array.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved via 6a5dd44

if (LOG.isInfoEnabled()) {
LOG.info("ACME certificate order success! Certificate URL: {}", certificate.getLocation());
}
Expand Down Expand Up @@ -462,7 +484,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 +504,7 @@ 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));
eventPublisher.publishEvent(new CertificateEvent(getDomainKeyPair(), false, getFullCertificateChain()));
Copy link
Contributor

@sdelamo sdelamo Mar 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if getFullCertificateChain returns an empty array, should we publish the event?. Since CertificateEvent assumes a non empty array due to CertificateEvent::getCert where it gets the first certificate in the array.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved via 6a5dd44

}

/**
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
certs.length == 2
def cert = (X509Certificate) 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)

def cert2 = (X509Certificate) certs[1]
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert2.getIssuerDN().getName().contains("Pebble Root CA")
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert2.getSubjectDN().getName().contains("Pebble Intermediate CA")
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
}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
certs.length == 2
def cert = (X509Certificate) certs[0]
cert.getIssuerDN().getName().contains("Pebble Intermediate CA")
cert.getSubjectDN().getName().contains(EXPECTED_DOMAIN)
cert.getSubjectAlternativeNames().size() == 1

def cert2 = (X509Certificate) certs[1]
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert2.getIssuerDN().getName().contains("Pebble Root CA")
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert2.getSubjectDN().getName().contains("Pebble Intermediate CA")
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
}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
certs.length == 2
def cert = (X509Certificate) certs[0]
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert.getIssuerDN().getName().contains("Pebble Intermediate CA")
cert.getSubjectDN().getName().contains(EXPECTED_DOMAIN)
cert.getSubjectAlternativeNames().size() == 1

def cert2 = (X509Certificate) certs[1]
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert2.getIssuerDN().getName().contains("Pebble Root CA")
cert2.getSubjectDN().getName().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
certs.length == 2
def cert = (X509Certificate) certs[0]
cert.getIssuerDN().getName().contains("Pebble Intermediate CA")
cert.getSubjectDN().getName().contains(EXPECTED_DOMAIN)
cert.getSubjectAlternativeNames().size() == 1

def cert2 = (X509Certificate) certs[1]
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert2.getIssuerDN().getName().contains("Pebble Root CA")
cert2.getSubjectDN().getName().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
certs.length == 2
def cert = (X509Certificate) 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)

def cert2 = (X509Certificate) certs[1]
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert2.getIssuerDN().getName().contains("Pebble Root CA")
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert2.getSubjectDN().getName().contains("Pebble Intermediate CA")
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
}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
certs.length == 2
def cert = (X509Certificate) certs[0]
cert.getIssuerDN().getName().contains("Pebble Intermediate CA")
cert.getSubjectDN().getName().contains(EXPECTED_ACME_DOMAIN)
cert.getSubjectAlternativeNames().size() == 1

def cert2 = (X509Certificate) certs[1]
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert2.getIssuerDN().getName().contains("Pebble Root CA")
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert2.getSubjectDN().getName().contains("Pebble Intermediate CA")
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
}

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
certs.length == 2
def cert = (X509Certificate) certs[0]
cert.getIssuerDN().getName().contains("Pebble Intermediate CA")
cert.getSubjectDN().getName().contains(EXPECTED_ACME_DOMAIN)
cert.getSubjectAlternativeNames().size() == 1

def cert2 = (X509Certificate) certs[1]
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert2.getIssuerDN().getName().contains("Pebble Root CA")
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
cert2.getSubjectDN().getName().contains("Pebble Intermediate CA")
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
}

void "test send https request when the cert is in place"() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package io.micronaut.acme.events


import org.shredzone.acme4j.util.KeyPairUtils
import spock.lang.Specification

import java.security.KeyPair
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate

class CertificateEventSpec extends Specification {
def DOMAIN_CERT = """
-----BEGIN CERTIFICATE-----
MIIDUDCCAjigAwIBAgIIHuspA0mthF8wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVUGViYmxlIFJvb3QgQ0EgMTU5ZjdmMCAXDTIxMDcxODIxMDczNloYDzIwNTEw
NzE4MjEwNzM2WjAoMSYwJAYDVQQDEx1QZWJibGUgSW50ZXJtZWRpYXRlIENBIDY2
NjViYjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALnw4xa4KQCxbhiW
1o1VYVUbof2mWwkhsWRuws4Uc1kvAVM7k1RZvwNxGQLgJkdXjbUrctddHdFMtksN
imNy/nmB6LKoulzwDL1omCdaiYOxJr93cGYQC3FTm/RaTpaHuec+BaB2Y1iOzbBj
sLL9121eRWUZ0vjaqKwNO8NUlK/geELNgoteIJ1MjOzWp1bryjnaszBfg0eiidD8
4gV36fvrM1UVJZJ4LBV4QHrKVXl7JA5hn9uk7zucH/XEG87DO2DCWJIwZK9Fm8wD
qMmvx/QH+dwXOXe6kXTDuyu7jJMoHDBLNQ9o4gjkqqHxA1f0ewgo64ObJ09hx+96
fuC49x8CAwEAAaOBgzCBgDAOBgNVHQ8BAf8EBAMCAoQwHQYDVR0lBBYwFAYIKwYB
BQUHAwEGCCsGAQUFBwMCMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFA56gdVb
+6pOjZzcKcjPhe7fWr5WMB8GA1UdIwQYMBaAFD8kVZzZs7SeJSiQFrf5AsH/EFmv
MA0GCSqGSIb3DQEBCwUAA4IBAQApcSZ5s0VGT1KgsXh3GrqxwlSyFfVuE4qvMabf
rXAhUbG3C6hgdA2AWA5IUvI9fRqul6m88hLZc8hrgOJ0vGDAD2u/PMdrqtAz8fV4
gch5z+Jn4J+9Af7hOm3DSFtVRqvbtyWTT2ht7wJbtxAOsuD7+Wa6lr+lZxhHXbRv
RpY6uVNZNlnC5k8BFnx8S9SdsK+upYtkgyKLoFpDhyXgmFMJPGA7UY6NQQ1sA/2x
dwYXMfCY829k0hcxcXYC4SYDjwHxF6YIM4lYS8pT0Z8d98H5cK7WNwFmW+izu6cx
87DDk/ZlkyArnozVQ6GFJClfhbKZfPKty1r1Y1psSOAUcUD1
-----END CERTIFICATE-----
"""

def ROOT_CERTIFICATE = """
-----BEGIN CERTIFICATE-----
MIIDezCCAmOgAwIBAgIIBzCDqTIFEj8wDQYJKoZIhvcNAQELBQAwKDEmMCQGA1UE
AxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSA2NjY1YmIwHhcNMjEwNzE4MjEwNzQx
WhcNMjYwNzE4MjEwNzQxWjAnMSUwIwYDVQQDExxob3N0LnRlc3Rjb250YWluZXJz
LmludGVybmFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiwwiBiCF
q1oMiXSOvEjCKkSR5lGu9CDW9UFQgN/UhVG2RyuDojImUOQjOjHe/DWn7g1XKovT
3it/M1onAnmksvqFd6YwSUKT8epL1K0dyVzgwaPAgjpJZgt/IZvA9ATWILuMJDGB
jdRRUQ+xex3AVbwa5UJYPlK2t1yqL5YPP9WpZ8H3c1F6M2by5VbwIi78LSxPc47m
H35efxWX2DalsDYirgP3bL0/X/yeVw058Iga+9MsF5MELDMuh9fe5N81TcrtKHvW
W4DfBPUFSnA/52G/nltZdgXxyMgErgwHx86dQphZMAGAD+wCXnzewAI9ZWN4iU27
IiP1kQqVP33AoQIDAQABo4GpMIGmMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAU
BggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUFXuU
GdoN4vOZ3IiyvXkQw2Dd92cwHwYDVR0jBBgwFoAUDnqB1Vv7qk6NnNwpyM+F7t9a
vlYwJwYDVR0RBCAwHoIcaG9zdC50ZXN0Y29udGFpbmVycy5pbnRlcm5hbDANBgkq
hkiG9w0BAQsFAAOCAQEAEJNY7olfzudkko1FcGq5bCauwB9240uu67YUIJG7y54G
tq2XWYWQ19FAqgb/7iWKq5X2hjp3Ut3x76SCwOKy5Q0dArcxwQYVgMMj9znxH6LL
QBJOPgQDvnxysEXEu4zvR/GV6ZS5ndFKJAPJxklZkdGhhqp15gUnP/1qTGPLEC5j
7gR/TfCwWsiMvkBmkYyacDvIHPd8QHtISNhL5Y+dww8DeL+F4ALC1dFLdAaT/bdx
RVv802SMY7YAh8FAsnTsKLYNbSk6ZHVbJuBcVbHqGuWueZ43hwmOTF6pIDaIoBg1
zSti1w9hjz913WF0dTg7RWFLU8e3Jo1O9MCnORtcgg==
-----END CERTIFICATE-----"""

def FULL_CHAIN_CERT = """
${ROOT_CERTIFICATE}
${DOMAIN_CERT}
"""

def "can get domain keypair"(){
given:
CertificateFactory cf = CertificateFactory.getInstance("X.509")
X509Certificate cert = cf.generateCertificate(new ByteArrayInputStream(FULL_CHAIN_CERT.bytes))
KeyPair keyPair = KeyPairUtils.createKeyPair(2048)

when :
CertificateEvent event = new CertificateEvent(cert, keyPair, new Random().nextBoolean())

then:
event.getDomainKeyPair() == keyPair
}

def "can determine if the event is a validation certificate or not"(){
given:
CertificateFactory cf = CertificateFactory.getInstance("X.509")
X509Certificate cert = cf.generateCertificate(new ByteArrayInputStream(FULL_CHAIN_CERT.bytes))
KeyPair keyPair = KeyPairUtils.createKeyPair(2048)
def validationCert = new Random().nextBoolean()

when :
CertificateEvent event = new CertificateEvent(cert, keyPair, validationCert)

then:
event.isValidationCert() == validationCert
}

def "when pass single cert the full chain only contains that cert"(){
given:
CertificateFactory cf = CertificateFactory.getInstance("X.509")
X509Certificate domainCert = cf.generateCertificate(new ByteArrayInputStream(FULL_CHAIN_CERT.bytes))
KeyPair keyPair = KeyPairUtils.createKeyPair(2048)

when :
CertificateEvent event = new CertificateEvent(domainCert, keyPair, new Random().nextBoolean())

then:
event.getCert() == domainCert
event.getFullCertificateChain().length == 1
event.getFullCertificateChain()[0] == domainCert
}

def "when full certificate chain passed we can still get the domain specific cert"(){
given:
CertificateFactory cf = CertificateFactory.getInstance("X.509")
X509Certificate domainCert = cf.generateCertificate(new ByteArrayInputStream(FULL_CHAIN_CERT.bytes))
Collection<X509Certificate> certs = cf.generateCertificates(new ByteArrayInputStream(FULL_CHAIN_CERT.bytes))
KeyPair keyPair = KeyPairUtils.createKeyPair(2048)
def expectedValidationCert = new Random().nextBoolean()
sdelamo marked this conversation as resolved.
Show resolved Hide resolved

when :
CertificateEvent event = new CertificateEvent(keyPair, expectedValidationCert, certs as X509Certificate[])

then:
event.getCert() == domainCert
event.isValidationCert() == expectedValidationCert
event.getFullCertificateChain().length == 2
event.getFullCertificateChain() == certs.toArray()
}
}