diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsAlpn01Challenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsAlpn01Challenge.java new file mode 100644 index 00000000..08d52e91 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsAlpn01Challenge.java @@ -0,0 +1,72 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2018 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.challenge; + +import static org.shredzone.acme4j.toolbox.AcmeUtils.sha256hash; + +import javax.annotation.ParametersAreNonnullByDefault; + +import org.shredzone.acme4j.Login; +import org.shredzone.acme4j.toolbox.JSON; + +/** + * Implements the {@value TYPE} challenge. + * + * @since 2.1 + */ +@ParametersAreNonnullByDefault +public class TlsAlpn01Challenge extends TokenChallenge { + private static final long serialVersionUID = -5590351078176091228L; + + /** + * Challenge type name: {@value} + */ + public static final String TYPE = "tls-alpn-01"; + + /** + * OID of the {@code acmeValidation-v1} extension. + */ + public static final String ACME_VALIDATION_V1_OID = "1.3.6.1.5.5.7.1.30.1"; + + /** + * {@code acme-tls/1} protocol. + */ + public static final String ACME_TLS_1_PROTOCOL = "acme-tls/1"; + + /** + * Creates a new generic {@link TlsAlpn01Challenge} object. + * + * @param login + * {@link Login} the resource is bound with + * @param data + * {@link JSON} challenge data + */ + public TlsAlpn01Challenge(Login login, JSON data) { + super(login, data); + } + + /** + * Returns the value that is to be used as {@code acmeValidation-v1} extension in + * the test certificate. + */ + public byte[] getAcmeValidationV1() { + return sha256hash(getAuthorization()); + } + + @Override + protected boolean acceptable(String type) { + return TYPE.equals(type); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java index d6a40b1a..0919ce5a 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java @@ -27,6 +27,7 @@ import org.shredzone.acme4j.Session; import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; +import org.shredzone.acme4j.challenge.TlsAlpn01Challenge; import org.shredzone.acme4j.challenge.TokenChallenge; import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.connector.DefaultConnection; @@ -71,6 +72,7 @@ public abstract class AbstractAcmeProvider implements AcmeProvider { map.put(Dns01Challenge.TYPE, Dns01Challenge::new); map.put(Http01Challenge.TYPE, Http01Challenge::new); + map.put(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new); return Collections.unmodifiableMap(map); } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java index e8d90c5d..44adf02b 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java @@ -29,6 +29,7 @@ import org.junit.Test; import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; +import org.shredzone.acme4j.challenge.TlsAlpn01Challenge; import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.exception.AcmeRetryAfterException; @@ -66,6 +67,11 @@ public class AuthorizationTest { Challenge c3 = authorization.findChallenge(Dns01Challenge.TYPE); assertThat(c3, is(notNullValue())); assertThat(c3, is(instanceOf(Dns01Challenge.class))); + + // TlsAlpn01Challenge is available + Challenge c4 = authorization.findChallenge(TlsAlpn01Challenge.TYPE); + assertThat(c4, is(notNullValue())); + assertThat(c4, is(instanceOf(TlsAlpn01Challenge.class))); } /** @@ -104,6 +110,7 @@ public class AuthorizationTest { provider.putTestChallenge("http-01", Http01Challenge::new); provider.putTestChallenge("dns-01", Dns01Challenge::new); + provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new); Authorization auth = new Authorization(login, locationUrl); auth.update(); @@ -116,7 +123,8 @@ public class AuthorizationTest { assertThat(auth.getChallenges(), containsInAnyOrder( provider.getChallenge(Http01Challenge.TYPE), - provider.getChallenge(Dns01Challenge.TYPE))); + provider.getChallenge(Dns01Challenge.TYPE), + provider.getChallenge(TlsAlpn01Challenge.TYPE))); provider.close(); } @@ -191,6 +199,7 @@ public class AuthorizationTest { provider.putTestChallenge("http-01", Http01Challenge::new); provider.putTestChallenge("dns-01", Dns01Challenge::new); + provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new); Authorization auth = new Authorization(login, locationUrl); @@ -238,6 +247,7 @@ public class AuthorizationTest { provider.putTestChallenge("http-01", Http01Challenge::new); provider.putTestChallenge("dns-01", Dns01Challenge::new); + provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new); Authorization auth = new Authorization(login, locationUrl); @@ -256,7 +266,8 @@ public class AuthorizationTest { assertThat(auth.getChallenges(), containsInAnyOrder( provider.getChallenge(Http01Challenge.TYPE), - provider.getChallenge(Dns01Challenge.TYPE))); + provider.getChallenge(Dns01Challenge.TYPE), + provider.getChallenge(TlsAlpn01Challenge.TYPE))); provider.close(); } @@ -286,6 +297,7 @@ public class AuthorizationTest { provider.putTestChallenge("http-01", Http01Challenge::new); provider.putTestChallenge("dns-01", Dns01Challenge::new); + provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new); Authorization auth = new Authorization(login, locationUrl); auth.deactivate(); @@ -302,6 +314,7 @@ public class AuthorizationTest { provider.putTestChallenge(Http01Challenge.TYPE, Http01Challenge::new); provider.putTestChallenge(Dns01Challenge.TYPE, Dns01Challenge::new); + provider.putTestChallenge(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new); provider.putTestChallenge(DUPLICATE_TYPE, Challenge::new); Authorization authorization = new Authorization(login, locationUrl); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsAlpn01ChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsAlpn01ChallengeTest.java new file mode 100644 index 00000000..071ed6c4 --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsAlpn01ChallengeTest.java @@ -0,0 +1,60 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2018 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.challenge; + +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; +import static org.shredzone.acme4j.toolbox.TestUtils.getJSON; +import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; + +import java.io.IOException; + +import org.junit.Test; +import org.shredzone.acme4j.Login; +import org.shredzone.acme4j.Status; +import org.shredzone.acme4j.toolbox.AcmeUtils; +import org.shredzone.acme4j.toolbox.JSONBuilder; +import org.shredzone.acme4j.toolbox.TestUtils; + +/** + * Unit tests for {@link TlsAlpn01ChallengeTest}. + */ +public class TlsAlpn01ChallengeTest { + private static final String TOKEN = + "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ"; + private static final String KEY_AUTHORIZATION = + "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0"; + + private Login login = TestUtils.login(); + + /** + * Test that {@link TlsAlpn01Challenge} generates a correct authorization key. + */ + @Test + public void testTlsAlpn01Challenge() throws IOException { + TlsAlpn01Challenge challenge = new TlsAlpn01Challenge(login, getJSON("tlsAlpnChallenge")); + + assertThat(challenge.getType(), is(TlsAlpn01Challenge.TYPE)); + assertThat(challenge.getStatus(), is(Status.PENDING)); + assertThat(challenge.getToken(), is(TOKEN)); + assertThat(challenge.getAuthorization(), is(KEY_AUTHORIZATION)); + assertThat(challenge.getAcmeValidationV1(), is(AcmeUtils.sha256hash(KEY_AUTHORIZATION))); + + JSONBuilder response = new JSONBuilder(); + challenge.prepareResponse(response); + + assertThat(response.toString(), sameJSONAs("{}").allowingExtraUnexpectedFields()); + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java index 723ebc78..ca516995 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java @@ -29,6 +29,7 @@ import org.shredzone.acme4j.Session; import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; +import org.shredzone.acme4j.challenge.TlsAlpn01Challenge; import org.shredzone.acme4j.challenge.TokenChallenge; import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.connector.DefaultConnection; @@ -146,6 +147,10 @@ public class AbstractAcmeProviderTest { assertThat(c3, not(nullValue())); assertThat(c3, instanceOf(Dns01Challenge.class)); + Challenge c4 = provider.createChallenge(login, getJSON("tlsAlpnChallenge")); + assertThat(c4, not(nullValue())); + assertThat(c4, instanceOf(TlsAlpn01Challenge.class)); + JSON json6 = new JSONBuilder() .put("type", "foobar-01") .put("url", "https://example.com/some/challenge") diff --git a/acme4j-client/src/test/resources/json/authorizationChallenges.json b/acme4j-client/src/test/resources/json/authorizationChallenges.json index 2e044a38..a91cd21f 100644 --- a/acme4j-client/src/test/resources/json/authorizationChallenges.json +++ b/acme4j-client/src/test/resources/json/authorizationChallenges.json @@ -10,6 +10,11 @@ "url": "https://example.com/authz/asdf/1", "token": "DGyRejmCefe7v4NfDGDKfA" }, + { + "type": "tls-alpn-01", + "url": "https://example.com/authz/asdf/1", + "token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA" + }, { "type": "duplicate-01", "url": "https://example.com/authz/asdf/3" diff --git a/acme4j-client/src/test/resources/json/tlsAlpnChallenge.json b/acme4j-client/src/test/resources/json/tlsAlpnChallenge.json new file mode 100644 index 00000000..9f9f2d68 --- /dev/null +++ b/acme4j-client/src/test/resources/json/tlsAlpnChallenge.json @@ -0,0 +1,6 @@ +{ + "type": "tls-alpn-01", + "url": "https://example.com/acme/authz/0", + "status": "pending", + "token": "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ" +} diff --git a/acme4j-client/src/test/resources/json/updateAuthorizationResponse.json b/acme4j-client/src/test/resources/json/updateAuthorizationResponse.json index 36f18ed8..27ca50f6 100644 --- a/acme4j-client/src/test/resources/json/updateAuthorizationResponse.json +++ b/acme4j-client/src/test/resources/json/updateAuthorizationResponse.json @@ -17,6 +17,12 @@ "status": "pending", "url": "https://example.com/authz/asdf/1", "token": "DGyRejmCefe7v4NfDGDKfA" - } + }, + { + "type": "tls-alpn-01", + "status": "pending", + "url": "https://example.com/authz/asdf/2", + "token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA" + } ] } diff --git a/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java b/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java index 82114676..cd65bc56 100644 --- a/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java +++ b/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java @@ -13,16 +13,35 @@ */ package org.shredzone.acme4j.util; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Objects; import javax.annotation.ParametersAreNonnullByDefault; import javax.annotation.WillClose; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.shredzone.acme4j.challenge.TlsAlpn01Challenge; /** * Utility class offering convenience methods for certificates. @@ -32,6 +51,14 @@ import org.bouncycastle.pkcs.PKCS10CertificationRequest; @ParametersAreNonnullByDefault public final class CertificateUtils { + /** + * The {@code acmeValidation-v1} object identifier. + * + * @since 2.1 + */ + public static final ASN1ObjectIdentifier ACME_VALIDATION_V1 = + new ASN1ObjectIdentifier(TlsAlpn01Challenge.ACME_VALIDATION_V1_OID).intern(); + private CertificateUtils() { // utility class without constructor } @@ -54,4 +81,56 @@ public final class CertificateUtils { } } + /** + * Creates a self-signed {@link X509Certificate} that can be used for the + * {@link TlsAlpn01Challenge}. The certificate is valid for 7 days. + * + * @param keypair + * A domain {@link KeyPair} to be used for the challenge + * @param subject + * The subject (domain name) that is to be validated + * @param acmeValidationV1 + * The value that is returned by + * {@link TlsAlpn01Challenge#getAcmeValidationV1()} + * @return Created certificate + * @since 2.1 + */ + public static X509Certificate createTlsAlpn01Certificate(KeyPair keypair, String subject, byte[] acmeValidationV1) + throws IOException { + Objects.requireNonNull(keypair, "keypair"); + Objects.requireNonNull(subject, "subject"); + if (acmeValidationV1 == null || acmeValidationV1.length != 32) { + throw new IllegalArgumentException("Bad acmeValidationV1 parameter"); + } + + final long now = System.currentTimeMillis(); + final String signatureAlg = "SHA256withRSA"; + + try { + X500Name issuer = new X500Name("CN=acme.invalid"); + BigInteger serial = BigInteger.valueOf(now); + Instant notBefore = Instant.ofEpochMilli(now); + Instant notAfter = notBefore.plus(Duration.ofDays(7)); + + JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuer, serial, Date.from(notBefore), Date.from(notAfter), + issuer, keypair.getPublic()); + + GeneralName[] gns = new GeneralName[1]; + gns[0] = new GeneralName(GeneralName.dNSName, subject); + certBuilder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(gns)); + + certBuilder.addExtension(ACME_VALIDATION_V1, true, acmeValidationV1); + + JcaContentSignerBuilder signerBuilder = new JcaContentSignerBuilder(signatureAlg); + + byte[] cert = certBuilder.build(signerBuilder.build(keypair.getPrivate())).getEncoded(); + + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) certificateFactory.generateCertificate(new ByteArrayInputStream(cert)); + } catch (CertificateException | OperatorCreationException ex) { + throw new IOException(ex); + } + } + } diff --git a/acme4j-utils/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java b/acme4j-utils/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java index ed769cdd..8fb45cec 100644 --- a/acme4j-utils/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java +++ b/acme4j-utils/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java @@ -23,9 +23,22 @@ import java.io.InputStream; import java.lang.reflect.Constructor; import java.lang.reflect.Modifier; import java.security.KeyPair; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.bouncycastle.asn1.ASN1InputStream; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.x509.GeneralName; import org.bouncycastle.pkcs.PKCS10CertificationRequest; import org.junit.Test; +import org.shredzone.acme4j.challenge.TlsAlpn01Challenge; +import org.shredzone.acme4j.toolbox.AcmeUtils; /** * Unit tests for {@link CertificateUtils}. @@ -67,4 +80,58 @@ public class CertificateUtilsTest { constructor.newInstance(); } + /** + * Test if + * {@link CertificateUtils#createTlsAlpn01Certificate(KeyPair, String, byte[])} + * creates a good certificate. + */ + @Test + public void testCreateTlsSni02Certificate() throws IOException, CertificateParsingException { + KeyPair keypair = KeyPairUtils.createKeyPair(2048); + String subject = "example.com"; + byte[] acmeValidationV1 = AcmeUtils.sha256hash("rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ"); + + X509Certificate cert = CertificateUtils.createTlsAlpn01Certificate(keypair, subject, acmeValidationV1); + + Instant now = Instant.now(); + Instant end = now.plus(Duration.ofDays(8)); + + assertThat(cert, not(nullValue())); + assertThat(cert.getNotAfter(), is(greaterThan(Date.from(now)))); + assertThat(cert.getNotAfter(), is(lessThan(Date.from(end)))); + assertThat(cert.getNotBefore(), is(lessThanOrEqualTo(Date.from(now)))); + + assertThat(cert.getSubjectX500Principal().getName(), is("CN=acme.invalid")); + assertThat(getSANs(cert), contains(subject)); + + assertThat(cert.getCriticalExtensionOIDs(), hasItem(TlsAlpn01Challenge.ACME_VALIDATION_V1_OID)); + + byte[] encodedExtensionValue = cert.getExtensionValue(TlsAlpn01Challenge.ACME_VALIDATION_V1_OID); + assertThat(encodedExtensionValue, is(notNullValue())); + + try (ASN1InputStream asn = new ASN1InputStream(new ByteArrayInputStream(encodedExtensionValue))) { + DEROctetString derOctetString = (DEROctetString) asn.readObject(); + assertThat(derOctetString.getOctets(), is(acmeValidationV1)); + } + } + + /** + * Extracts all DNSName SANs from a certificate. + * + * @param cert + * {@link X509Certificate} + * @return Set of DNSName + */ + private Set getSANs(X509Certificate cert) throws CertificateParsingException { + Set result = new HashSet<>(); + + for (List list : cert.getSubjectAlternativeNames()) { + if (((Number) list.get(0)).intValue() == GeneralName.dNSName) { + result.add((String) list.get(1)); + } + } + + return result; + } + } diff --git a/src/site/markdown/challenge/index.md b/src/site/markdown/challenge/index.md index 1814c6ed..001859a6 100644 --- a/src/site/markdown/challenge/index.md +++ b/src/site/markdown/challenge/index.md @@ -8,3 +8,7 @@ The ACME specifications define these standard challenges: * [http-01](./http-01.html) * [dns-01](./dns-01.html) + +_acme4j_ also supports these non-standard challenges: + +* [tls-alpn-01](./tls-alpn-01.html) diff --git a/src/site/markdown/challenge/tls-alpn-01.md b/src/site/markdown/challenge/tls-alpn-01.md new file mode 100644 index 00000000..0854bd80 --- /dev/null +++ b/src/site/markdown/challenge/tls-alpn-01.md @@ -0,0 +1,36 @@ +# tls-alpn-01 Challenge + +With the `tls-alpn-01` challenge, you prove to the CA that you are able to control the web server of the domain to be authorized, by letting it respond to a request with a specific self-signed cert utilizing the ALPN extension. + + + +`TlsAlpn01Challenge` provides a byte array called `acmeValidationV1`: + +```java +TlsSni02Challenge challenge = auth.findChallenge(TlsSni02Challenge.TYPE); + +byte[] acmeValidationV1 = challenge.getAcmeValidationV1(); +``` + +You need to create a self-signed certificate with the domain to be validated set as the only _Subject Alternative Name_. The `acmeValidationV1` must be set as octet string extension with the object id `1.3.6.1.5.5.7.1.30.1`. It is required to set this extension as critical. + +After that, configure your web server so it will use this certificate on an incoming TLS request having the SNI `subject` and the ALPN protocol `acme-tls/1`. + +The `TlsAlpn01Challenge` class does not generate a self-signed certificate, as it would require _Bouncy Castle_. However, there is a utility method in the _acme4j-utils_ module for this use case: + +```java +String subject = auth.getDomain(); +KeyPair certKeyPair = KeyPairUtils.createKeyPair(2048); + +X509Certificate cert = CertificateUtils. + createTlsAlpn01Certificate(certKeyPair, subject, acmeValidationV1); +``` + +Now use `cert` and `certKeyPair` to let your web server respond to TLS requests containing an ALPN extension with the value `acme-tls/1` and a SNI extension containing `subject`. + + diff --git a/src/site/site.xml b/src/site/site.xml index 9b8d1c4f..e5f808d9 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -45,6 +45,7 @@ +