diff options
author | Valerij Fredriksen <freva@users.noreply.github.com> | 2017-11-13 15:10:41 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-11-13 15:10:41 +0100 |
commit | 55c3bc5d24ee6d2ba7c6d886c10629ae7600199a (patch) | |
tree | 35062d5882d0757f433c1535581e7ba1609f6d6f | |
parent | 01a534d7df2a025678d7c27487f6a5a4f6aa5e46 (diff) | |
parent | d20d62b5622b373f29e00ce1e582ba5c15cccc55 (diff) |
Merge pull request #4070 from vespa-engine/freva/certificate-signer
Freva/certificate signer
8 files changed, 478 insertions, 2 deletions
diff --git a/athenz-identity-provider-service/pom.xml b/athenz-identity-provider-service/pom.xml index 26e24be526c..260836af892 100644 --- a/athenz-identity-provider-service/pom.xml +++ b/athenz-identity-provider-service/pom.xml @@ -102,6 +102,12 @@ <scope>test</scope> </dependency> <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>testutil</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.4.1</version> diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderService.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderService.java index 26a88896fb9..8ac26938633 100644 --- a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderService.java +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderService.java @@ -8,6 +8,9 @@ import com.yahoo.config.provision.SystemName; import com.yahoo.config.provision.Zone; import com.yahoo.jdisc.http.SecretStore; import com.yahoo.log.LogLevel; +import com.yahoo.net.HostName; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.ca.CertificateSigner; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.ca.CertificateSignerServlet; import com.yahoo.vespa.hosted.athenz.instanceproviderservice.config.AthenzProviderServiceConfig; import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.AthenzCertificateClient; import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.CertificateClient; @@ -64,6 +67,7 @@ public class AthenzInstanceProviderService extends AbstractComponent { CertificateClient certificateClient, SslContextFactory sslContextFactory) { this(config, scheduler, zone, sslContextFactory, + new CertificateSigner(keyProvider, getZoneConfig(config, zone), HostName.getLocalhost()), new InstanceValidator(keyProvider, superModelProvider), new IdentityDocumentGenerator(config, getZoneConfig(config, zone), nodeRepository, zone, keyProvider), new AthenzCertificateUpdater( @@ -74,13 +78,15 @@ public class AthenzInstanceProviderService extends AbstractComponent { ScheduledExecutorService scheduler, Zone zone, SslContextFactory sslContextFactory, + CertificateSigner certificateSigner, InstanceValidator instanceValidator, IdentityDocumentGenerator identityDocumentGenerator, AthenzCertificateUpdater reloader) { // TODO: Enable for all systems. Currently enabled for CD system only if (SystemName.cd.equals(zone.system())) { this.scheduler = scheduler; - this.jetty = createJettyServer(config, sslContextFactory, instanceValidator, identityDocumentGenerator); + this.jetty = createJettyServer(config, sslContextFactory, + certificateSigner, instanceValidator, identityDocumentGenerator); // TODO Configurable update frequency scheduler.scheduleAtFixedRate(reloader, 0, 1, TimeUnit.DAYS); @@ -97,6 +103,7 @@ public class AthenzInstanceProviderService extends AbstractComponent { private static Server createJettyServer(AthenzProviderServiceConfig config, SslContextFactory sslContextFactory, + CertificateSigner certificateSigner, InstanceValidator instanceValidator, IdentityDocumentGenerator identityDocumentGenerator) { Server server = new Server(); @@ -105,6 +112,10 @@ public class AthenzInstanceProviderService extends AbstractComponent { server.addConnector(connector); ServletHandler handler = new ServletHandler(); + + CertificateSignerServlet certificateSignerServlet = new CertificateSignerServlet(certificateSigner); + handler.addServletWithMapping(new ServletHolder(certificateSignerServlet), config.apiPath() + "/sign"); + InstanceConfirmationServlet instanceConfirmationServlet = new InstanceConfirmationServlet(instanceValidator); handler.addServletWithMapping(new ServletHolder(instanceConfirmationServlet), config.apiPath() + "/instance"); diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/CertificateSigner.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/CertificateSigner.java new file mode 100644 index 00000000000..2e00695f2f0 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/CertificateSigner.java @@ -0,0 +1,141 @@ +package com.yahoo.vespa.hosted.athenz.instanceproviderservice.ca; + +import com.google.common.collect.ImmutableList; +import com.yahoo.log.LogLevel; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.config.AthenzProviderServiceConfig; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.KeyProvider; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.DERUTF8String; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.AttributeTypeAndValue; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequest; + +import java.math.BigInteger; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.PublicKey; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.Enumeration; +import java.util.List; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + + +/** + * Signs Certificate Signing Reqest from tenant nodes. This certificate will be used + * by nodes to authenticate themselves when performing operations against the config + * server, such as updating node-repository or orchestrator. + * + * @author freva + */ +public class CertificateSigner { + + private static final Logger log = Logger.getLogger(CertificateSigner.class.getName()); + + static final String SIGNER_ALGORITHM = "SHA256withRSA"; + static final Duration CERTIFICATE_EXPIRATION = Duration.ofDays(30); + private static final List<ASN1ObjectIdentifier> ILLEGAL_EXTENSIONS = ImmutableList.of( + Extension.basicConstraints, Extension.subjectAlternativeName); + + private final JcaX509CertificateConverter certificateConverter = new JcaX509CertificateConverter(); + private final Provider provider = new BouncyCastleProvider(); + + private final PrivateKey caPrivateKey; + private final X500Name issuer; + private final Clock clock; + + public CertificateSigner(KeyProvider keyProvider, + AthenzProviderServiceConfig.Zones zoneConfig, + String configServerHostname) { + this(keyProvider.getPrivateKey(zoneConfig.secretVersion()), configServerHostname, Clock.systemUTC()); + } + + CertificateSigner(PrivateKey caPrivateKey, String configServerHostname, Clock clock) { + this.caPrivateKey = caPrivateKey; + this.issuer = new X500Name("CN=" + configServerHostname); + this.clock = clock; + } + + /** + * Signs the CSR if: + * <ul> + * <li>Common Name matches {@code remoteHostname}</li> + * <li>CSR does not contain any any of the extensions in {@code ILLEGAL_EXTENSIONS}</li> + * </ul> + */ + X509Certificate generateX509Certificate(PKCS10CertificationRequest certReq, String remoteHostname) { + verifyCertificateCommonName(certReq.getSubject(), remoteHostname); + verifyCertificateExtensions(certReq); + + Date notBefore = Date.from(clock.instant()); + Date notAfter = Date.from(clock.instant().plus(CERTIFICATE_EXPIRATION)); + + try { + PublicKey publicKey = new JcaPKCS10CertificationRequest(certReq).getPublicKey(); + X509v3CertificateBuilder caBuilder = new JcaX509v3CertificateBuilder( + issuer, BigInteger.valueOf(clock.millis()), notBefore, notAfter, certReq.getSubject(), publicKey) + + // Set Basic Constraints to false + .addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + + ContentSigner caSigner = new JcaContentSignerBuilder(SIGNER_ALGORITHM).build(caPrivateKey); + + return certificateConverter + .setProvider(provider) + .getCertificate(caBuilder.build(caSigner)); + } catch (Exception ex) { + log.log(LogLevel.ERROR, "Failed to generate X509 Certificate", ex); + throw new RuntimeException("Failed to generate X509 Certificate"); + } + } + + static void verifyCertificateCommonName(X500Name subject, String commonName) { + List<AttributeTypeAndValue> attributesAndValues = Arrays.stream(subject.getRDNs()) + .flatMap(rdn -> rdn.isMultiValued() ? + Stream.of(rdn.getTypesAndValues()) : Stream.of(rdn.getFirst())) + .filter(attr -> attr.getType() == BCStyle.CN) + .collect(Collectors.toList()); + + if (attributesAndValues.size() != 1) { + throw new IllegalArgumentException("Only 1 common name should be set"); + } + + String actualCommonName = DERUTF8String.getInstance(attributesAndValues.get(0).getValue()).getString(); + if (! actualCommonName.equals(commonName)) { + throw new IllegalArgumentException("Expected common name to be " + commonName + ", but was " + actualCommonName); + } + } + + @SuppressWarnings("unchecked") + static void verifyCertificateExtensions(PKCS10CertificationRequest request) { + List<String> illegalExt = Arrays + .stream(request.getAttributes(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest)) + .map(attribute -> Extensions.getInstance(attribute.getAttrValues().getObjectAt(0))) + .flatMap(ext -> Collections.list((Enumeration<ASN1ObjectIdentifier>) ext.oids()).stream()) + .filter(ILLEGAL_EXTENSIONS::contains) + .map(ASN1ObjectIdentifier::getId) + .collect(Collectors.toList()); + + if (! illegalExt.isEmpty()) { + throw new IllegalArgumentException("CSR contains illegal extensions: " + String.join(", ", illegalExt)); + } + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/CertificateSignerServlet.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/CertificateSignerServlet.java new file mode 100644 index 00000000000..d2ebae394a2 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/CertificateSignerServlet.java @@ -0,0 +1,50 @@ +// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.athenz.instanceproviderservice.ca; + +import com.yahoo.log.LogLevel; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.ca.model.CertificateSerializedPayload; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.ca.model.CsrSerializedPayload; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.Utils; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.security.cert.X509Certificate; +import java.util.logging.Logger; + +/** + * @author freva + */ +public class CertificateSignerServlet extends HttpServlet { + + private static final Logger log = Logger.getLogger(CertificateSignerServlet.class.getName()); + + private final CertificateSigner certificateSigner; + + public CertificateSignerServlet(CertificateSigner certificateSigner) { + this.certificateSigner = certificateSigner; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + try { + String remoteHostname = req.getRemoteHost(); + PKCS10CertificationRequest csr = Utils.getMapper().readValue(req.getReader(), CsrSerializedPayload.class).csr; + + log.log(LogLevel.DEBUG, "Certification request from " + remoteHostname + ": " + csr); + + X509Certificate certificate = certificateSigner.generateX509Certificate(csr, remoteHostname); + CertificateSerializedPayload certificateSerializedPayload = new CertificateSerializedPayload(certificate); + + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("application/json"); + resp.getWriter().write(Utils.getMapper().writeValueAsString(certificateSerializedPayload)); + } catch (RuntimeException e) { + log.log(LogLevel.ERROR, e.getMessage(), e); + resp.sendError(HttpServletResponse.SC_BAD_REQUEST, e.getMessage()); + } + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/model/CertificateSerializedPayload.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/model/CertificateSerializedPayload.java new file mode 100644 index 00000000000..2fd34741da7 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/model/CertificateSerializedPayload.java @@ -0,0 +1,68 @@ +// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.athenz.instanceproviderservice.ca.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.util.io.pem.PemObject; + +import java.io.IOException; +import java.io.StringWriter; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; + +/** + * Contains PEM formatted signed certificate + * + * @author freva + */ +public class CertificateSerializedPayload { + + @JsonProperty("certificate") @JsonSerialize(using = CertificateSerializer.class) + public final X509Certificate certificate; + + @JsonCreator + public CertificateSerializedPayload(@JsonProperty("certificate") X509Certificate certificate) { + this.certificate = certificate; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + CertificateSerializedPayload that = (CertificateSerializedPayload) o; + + return certificate.equals(that.certificate); + } + + @Override + public int hashCode() { + return certificate.hashCode(); + } + + @Override + public String toString() { + return "CertificateSerializedPayload{" + + "certificate='" + certificate + '\'' + + '}'; + } + + public static class CertificateSerializer extends JsonSerializer<X509Certificate> { + @Override + public void serialize( + X509Certificate certificate, JsonGenerator gen, SerializerProvider serializers) throws IOException { + try (StringWriter stringWriter = new StringWriter(); JcaPEMWriter pemWriter = new JcaPEMWriter(stringWriter)) { + pemWriter.writeObject(new PemObject("CERTIFICATE", certificate.getEncoded())); + pemWriter.flush(); + gen.writeString(stringWriter.toString()); + } catch (CertificateEncodingException e) { + throw new RuntimeException("Failed to encode X509Certificate", e); + } + } + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/model/CsrSerializedPayload.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/model/CsrSerializedPayload.java new file mode 100644 index 00000000000..d755fbd02a3 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/model/CsrSerializedPayload.java @@ -0,0 +1,62 @@ +// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.athenz.instanceproviderservice.ca.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; + +import java.io.IOException; +import java.io.StringReader; + +/** + * Contains PEM formatted Certificate Signing Request (CSR) + * + * @author freva + */ +public class CsrSerializedPayload { + + @JsonProperty("csr") public final PKCS10CertificationRequest csr; + + @JsonCreator + public CsrSerializedPayload(@JsonProperty("csr") @JsonDeserialize(using = CertificateRequestDeserializer.class) + PKCS10CertificationRequest csr) { + this.csr = csr; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + CsrSerializedPayload that = (CsrSerializedPayload) o; + + return csr.equals(that.csr); + } + + @Override + public int hashCode() { + return csr.hashCode(); + } + + @Override + public String toString() { + return "CsrSerializedPayload{" + + "csr='" + csr + '\'' + + '}'; + } + + public static class CertificateRequestDeserializer extends JsonDeserializer<PKCS10CertificationRequest> { + @Override + public PKCS10CertificationRequest deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + try (PEMParser pemParser = new PEMParser(new StringReader(jsonParser.getValueAsString()))) { + return (PKCS10CertificationRequest) pemParser.readObject(); + } + } + } +} diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderServiceTest.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderServiceTest.java index bf0746aee7e..c8c3826fc39 100644 --- a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderServiceTest.java +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderServiceTest.java @@ -9,6 +9,7 @@ import com.yahoo.config.provision.SystemName; import com.yahoo.config.provision.Zone; import com.yahoo.log.LogLevel; import com.yahoo.vespa.hosted.athenz.instanceproviderservice.AthenzInstanceProviderService.AthenzCertificateUpdater; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.ca.CertificateSigner; import com.yahoo.vespa.hosted.athenz.instanceproviderservice.config.AthenzProviderServiceConfig; import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.CertificateClient; import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.IdentityDocumentGenerator; @@ -101,13 +102,16 @@ public class AthenzInstanceProviderServiceTest { ScheduledExecutorService executor = mock(ScheduledExecutorService.class); when(executor.awaitTermination(anyLong(), any())).thenReturn(true); + CertificateSigner certificateSigner = mock(CertificateSigner.class); + InstanceValidator instanceValidator = mock(InstanceValidator.class); when(instanceValidator.isValidInstance(any())).thenReturn(true); IdentityDocumentGenerator identityDocumentGenerator = mock(IdentityDocumentGenerator.class); AthenzInstanceProviderService athenzInstanceProviderService = new AthenzInstanceProviderService( - config, executor, ZONE, sslContextFactory, instanceValidator, identityDocumentGenerator, certificateUpdater); + config, executor, ZONE, sslContextFactory, certificateSigner, instanceValidator, + identityDocumentGenerator, certificateUpdater); try (CloseableHttpClient client = createHttpClient(domain, service)) { assertFalse(getStatus(client)); diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/CertificateSignerTest.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/CertificateSignerTest.java new file mode 100644 index 00000000000..e691da0b2c3 --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ca/CertificateSignerTest.java @@ -0,0 +1,134 @@ +package com.yahoo.vespa.hosted.athenz.instanceproviderservice.ca; + +import com.yahoo.test.ManualClock; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.asn1.x509.ExtensionsGenerator; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.junit.Test; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +/** + * @author freva + */ +public class CertificateSignerTest { + + private final KeyPair clientKeyPair = getKeyPair(); + + private final long startTime = 1234567890000L; + private final KeyPair caKeyPair = getKeyPair(); + private final String cfgServerHostname = "cfg1.us-north-1.vespa.domain.tld"; + private final ManualClock clock = new ManualClock(Instant.ofEpochMilli(startTime)); + private final CertificateSigner signer = new CertificateSigner(caKeyPair.getPrivate(), cfgServerHostname, clock); + + private final String requestersHostname = "tenant-123.us-north-1.vespa.domain.tld"; + + @Test + public void test_signing() throws Exception { + ExtensionsGenerator extGen = new ExtensionsGenerator(); + String subject = "C=NO,OU=Vespa,CN=" + requestersHostname; + PKCS10CertificationRequest request = makeRequest(subject, extGen.generate()); + + X509Certificate certificate = signer.generateX509Certificate(request, requestersHostname); + assertCertificate(certificate, subject, Collections.singleton(Extension.basicConstraints.getId())); + } + + @Test + public void common_name_test() throws Exception { + CertificateSigner.verifyCertificateCommonName( + new X500Name("CN=" + requestersHostname), requestersHostname); + CertificateSigner.verifyCertificateCommonName( + new X500Name("C=NO,OU=Vespa,CN=" + requestersHostname), requestersHostname); + CertificateSigner.verifyCertificateCommonName( + new X500Name("C=NO+OU=org,CN=" + requestersHostname), requestersHostname); + + assertCertificateCommonNameException("C=NO", "Only 1 common name should be set"); + assertCertificateCommonNameException("C=US+CN=abc123.domain.tld,C=NO+CN=" + requestersHostname, "Only 1 common name should be set"); + assertCertificateCommonNameException("CN=evil.hostname.domain.tld", + "Expected common name to be tenant-123.us-north-1.vespa.domain.tld, but was evil.hostname.domain.tld"); + } + + @Test(expected = IllegalArgumentException.class) + public void extensions_test_subject_alternative_names() throws Exception { + ExtensionsGenerator extGen = new ExtensionsGenerator(); + extGen.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(new GeneralName[] { + new GeneralName(GeneralName.dNSName, "some.other.domain.tld")})); + PKCS10CertificationRequest request = makeRequest("OU=Vespa", extGen.generate()); + + CertificateSigner.verifyCertificateExtensions(request); + } + + @Test + public void extensions_allowed() throws Exception { + ExtensionsGenerator extGen = new ExtensionsGenerator(); + extGen.addExtension(Extension.certificateIssuer, true, new byte[0]); + PKCS10CertificationRequest request = makeRequest("OU=Vespa", extGen.generate()); + + CertificateSigner.verifyCertificateExtensions(request); + } + + private void assertCertificateCommonNameException(String subject, String expectedMessage) { + try { + CertificateSigner.verifyCertificateCommonName(new X500Name(subject), requestersHostname); + fail("Expected to fail"); + } catch (IllegalArgumentException e) { + assertEquals(expectedMessage, e.getMessage()); + } + } + + private void assertCertificate(X509Certificate certificate, String expectedSubjectName, Set<String> expectedExtensions) throws Exception { + assertEquals(3, certificate.getVersion()); + assertEquals(BigInteger.valueOf(startTime), certificate.getSerialNumber()); + assertEquals(startTime, certificate.getNotBefore().getTime()); + assertEquals(startTime + CertificateSigner.CERTIFICATE_EXPIRATION.toMillis(), certificate.getNotAfter().getTime()); + assertEquals(CertificateSigner.SIGNER_ALGORITHM, certificate.getSigAlgName()); + assertEquals(expectedSubjectName, certificate.getSubjectDN().getName()); + assertEquals("CN=" + cfgServerHostname, certificate.getIssuerX500Principal().getName()); + + Set<String> extensions = Stream.of(certificate.getNonCriticalExtensionOIDs(), + certificate.getCriticalExtensionOIDs()) + .flatMap(Collection::stream) + .collect(Collectors.toSet()); + assertEquals(expectedExtensions, extensions); + + certificate.verify(caKeyPair.getPublic()); + } + + private PKCS10CertificationRequest makeRequest(String subject, Extensions extensions) throws Exception { + PKCS10CertificationRequestBuilder builder = new JcaPKCS10CertificationRequestBuilder( + new X500Name(subject), clientKeyPair.getPublic()); + builder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, extensions); + + ContentSigner signGen = new JcaContentSignerBuilder(CertificateSigner.SIGNER_ALGORITHM).build(caKeyPair.getPrivate()); + return builder.build(signGen); + } + + private static KeyPair getKeyPair() { + try { + return KeyPairGenerator.getInstance("RSA").genKeyPair(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} |