aboutsummaryrefslogtreecommitdiffstats
path: root/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiHandler.java
blob: de9ae889e2d5ff1fdc6a54c0d608f777101b7716 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.ca.restapi;

import com.google.inject.Inject;
import com.yahoo.container.jdisc.HttpRequest;
import com.yahoo.container.jdisc.HttpResponse;
import com.yahoo.container.jdisc.LoggingRequestHandler;
import com.yahoo.container.jdisc.secretstore.SecretStore;
import com.yahoo.jdisc.http.servlet.ServletRequest;
import java.util.logging.Level;
import com.yahoo.restapi.ErrorResponse;
import com.yahoo.restapi.Path;
import com.yahoo.restapi.SlimeJsonResponse;
import com.yahoo.security.KeyUtils;
import com.yahoo.security.SubjectAlternativeName;
import com.yahoo.security.X509CertificateUtils;
import com.yahoo.slime.Slime;
import com.yahoo.vespa.athenz.api.AthenzService;
import com.yahoo.vespa.athenz.identityprovider.api.EntityBindingsMapper;
import com.yahoo.slime.SlimeUtils;
import com.yahoo.vespa.hosted.athenz.instanceproviderservice.config.AthenzProviderServiceConfig;
import com.yahoo.vespa.hosted.athenz.instanceproviderservice.InstanceConfirmation;
import com.yahoo.vespa.hosted.athenz.instanceproviderservice.InstanceValidator;
import com.yahoo.vespa.hosted.ca.Certificates;
import com.yahoo.vespa.hosted.ca.instance.InstanceIdentity;
import com.yahoo.vespa.hosted.ca.instance.InstanceRefresh;
import com.yahoo.yolean.Exceptions;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Stream;

/**
 * REST API for issuing and refreshing node certificates in a hosted Vespa system.
 *
 * The API implements the following subset of methods from the Athenz ZTS REST API:
 *
 * - Instance registration
 * - Instance refresh
 *
 * @author mpolden
 */
public class CertificateAuthorityApiHandler extends LoggingRequestHandler {

    private final SecretStore secretStore;
    private final Certificates certificates;
    private final String caPrivateKeySecretName;
    private final String caCertificateSecretName;
    private final InstanceValidator instanceValidator;

    @Inject
    public CertificateAuthorityApiHandler(Context ctx, SecretStore secretStore, AthenzProviderServiceConfig athenzProviderServiceConfig, InstanceValidator instanceValidator) {
        this(ctx, secretStore, new Certificates(Clock.systemUTC()), athenzProviderServiceConfig, instanceValidator);
    }

    CertificateAuthorityApiHandler(Context ctx, SecretStore secretStore, Certificates certificates, AthenzProviderServiceConfig athenzProviderServiceConfig, InstanceValidator instanceValidator) {
        super(ctx);
        this.secretStore = secretStore;
        this.certificates = certificates;
        this.caPrivateKeySecretName = athenzProviderServiceConfig.secretName();
        this.caCertificateSecretName = athenzProviderServiceConfig.domain() + ".ca.cert";
        this.instanceValidator = instanceValidator;
    }

    @Override
    public HttpResponse handle(HttpRequest request) {
        try {
            switch (request.getMethod()) {
                case POST: return handlePost(request);
                default: return ErrorResponse.methodNotAllowed("Method " + request.getMethod() + " is unsupported");
            }
        } catch (IllegalArgumentException e) {
            return ErrorResponse.badRequest(request.getMethod() + " " + request.getUri() + " failed: " + Exceptions.toMessageString(e));
        } catch (RuntimeException e) {
            log.log(Level.WARNING, "Unexpected error handling " + request.getMethod() + " " + request.getUri(), e);
            return ErrorResponse.internalServerError(Exceptions.toMessageString(e));
        }
    }

    private HttpResponse handlePost(HttpRequest request) {
        Path path = new Path(request.getUri());
        if (path.matches("/ca/v1/instance/")) return registerInstance(request);
        if (path.matches("/ca/v1/instance/{provider}/{domain}/{service}/{instanceId}")) return refreshInstance(request, path.get("provider"), path.get("service"), path.get("instanceId"));
        return ErrorResponse.notFoundError("Nothing at " + path);
    }

    private HttpResponse registerInstance(HttpRequest request) {
        var instanceRegistration = deserializeRequest(request, InstanceSerializer::registrationFromSlime);

        InstanceConfirmation confirmation = new InstanceConfirmation(instanceRegistration.provider(), instanceRegistration.domain(), instanceRegistration.service(), EntityBindingsMapper.toSignedIdentityDocumentEntity(instanceRegistration.attestationData()));
        confirmation.set(InstanceValidator.SAN_IPS_ATTRNAME, Certificates.getSubjectAlternativeNames(instanceRegistration.csr(), SubjectAlternativeName.Type.IP_ADDRESS));
        confirmation.set(InstanceValidator.SAN_DNS_ATTRNAME, Certificates.getSubjectAlternativeNames(instanceRegistration.csr(), SubjectAlternativeName.Type.DNS_NAME));
        if (!instanceValidator.isValidInstance(confirmation)) {
            log.log(Level.INFO, "Invalid instance registration for " + instanceRegistration.toString());
            return ErrorResponse.forbidden("Unable to launch service: " +instanceRegistration.service());
        }
        var certificate = certificates.create(instanceRegistration.csr(), caCertificate(), caPrivateKey());
        var instanceId = Certificates.instanceIdFrom(instanceRegistration.csr());
        var identity = new InstanceIdentity(instanceRegistration.provider(), instanceRegistration.service(), instanceId,
                                            Optional.of(certificate));
        return new SlimeJsonResponse(InstanceSerializer.identityToSlime(identity));
    }

    private HttpResponse refreshInstance(HttpRequest request, String provider, String service, String instanceId) {
        var instanceRefresh = deserializeRequest(request, InstanceSerializer::refreshFromSlime);
        var instanceIdFromCsr = Certificates.instanceIdFrom(instanceRefresh.csr());

        var athenzService = getRequestAthenzService(request);

        if (!instanceIdFromCsr.equals(instanceId)) {
            throw new IllegalArgumentException("Mismatch between instance ID in URL path and instance ID in CSR " +
                                               "[instanceId=" + instanceId + ",instanceIdFromCsr=" + instanceIdFromCsr +
                                               "]");
        }

        // Verify that the csr instance id matches one of the certificates in the chain
        refreshesSameInstanceId(instanceIdFromCsr, request);


        // Validate that there is no privilege escalation (can only refresh same service)
        refreshesSameService(instanceRefresh, athenzService);

        InstanceConfirmation instanceConfirmation = new InstanceConfirmation(provider, athenzService.getDomain().getName(), athenzService.getName(), null);
        instanceConfirmation.set(InstanceValidator.SAN_IPS_ATTRNAME, Certificates.getSubjectAlternativeNames(instanceRefresh.csr(), SubjectAlternativeName.Type.IP_ADDRESS));
        instanceConfirmation.set(InstanceValidator.SAN_DNS_ATTRNAME, Certificates.getSubjectAlternativeNames(instanceRefresh.csr(), SubjectAlternativeName.Type.DNS_NAME));
        if(!instanceValidator.isValidRefresh(instanceConfirmation)) {
            return ErrorResponse.forbidden("Unable to refresh cert: " + instanceRefresh.csr().getSubject().toString());
        }

        var certificate = certificates.create(instanceRefresh.csr(), caCertificate(), caPrivateKey());
        var identity = new InstanceIdentity(provider, service, instanceIdFromCsr, Optional.of(certificate));
        return new SlimeJsonResponse(InstanceSerializer.identityToSlime(identity));
    }

    public void refreshesSameInstanceId(String csrInstanceId, HttpRequest request) {
        String certificateInstanceId = getRequestCertificateChain(request).stream()
                .map(Certificates::instanceIdFrom)
                .filter(Optional::isPresent)
                .map(Optional::get)
                .findAny().orElseThrow(() -> new IllegalArgumentException("No client certificate with instance id in request."));

        if(! Objects.equals(certificateInstanceId, csrInstanceId)) {
            throw new IllegalArgumentException("Mismatch between instance ID in client certificate and instance ID in CSR " +
                                               "[instanceId=" + certificateInstanceId + ",instanceIdFromCsr=" + csrInstanceId +
                                               "]");
        }
    }

    private void refreshesSameService(InstanceRefresh instanceRefresh, AthenzService athenzService) {
        List<String> commonNames = X509CertificateUtils.getCommonNames(instanceRefresh.csr().getSubject());
        if(commonNames.size() != 1 && !Objects.equals(commonNames.get(0), athenzService.getFullName())) {
            throw new IllegalArgumentException(String.format("Invalid request, trying to refresh service %s using service %s.", instanceRefresh.csr().getSubject().getName(), athenzService.getFullName()));
        }
    }

    /** Returns CA certificate from secret store */
    private X509Certificate caCertificate() {
        return X509CertificateUtils.fromPem(secretStore.getSecret(caCertificateSecretName));
    }

    private List<X509Certificate> getRequestCertificateChain(HttpRequest request) {
        return Optional.ofNullable(request.getJDiscRequest().context().get(ServletRequest.JDISC_REQUEST_X509CERT))
                .map(X509Certificate[].class::cast)
                .map(Arrays::asList)
                .orElse(Collections.emptyList());
    }

    private AthenzService getRequestAthenzService(HttpRequest request) {
        return getRequestCertificateChain(request).stream()
                .findFirst()
                .map(X509CertificateUtils::getSubjectCommonNames)
                .map(List::stream)
                .flatMap(Stream::findFirst)
                .map(AthenzService::new)
                .orElseThrow(() -> new RuntimeException("No certificate found"));
    }

    /** Returns CA private key from secret store */
    private PrivateKey caPrivateKey() {
        return KeyUtils.fromPemEncodedPrivateKey(secretStore.getSecret(caPrivateKeySecretName));
    }

    private static <T> T deserializeRequest(HttpRequest request, Function<Slime, T> serializer) {
        try {
            var slime = SlimeUtils.jsonToSlime(request.getData().readAllBytes());
            return serializer.apply(slime);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

}