diff options
8 files changed, 266 insertions, 21 deletions
diff --git a/config-model/src/main/java/com/yahoo/vespa/model/admin/monitoring/DefaultVespaMetrics.java b/config-model/src/main/java/com/yahoo/vespa/model/admin/monitoring/DefaultVespaMetrics.java index ef22b462042..d4c3f908eeb 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/admin/monitoring/DefaultVespaMetrics.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/admin/monitoring/DefaultVespaMetrics.java @@ -2,6 +2,7 @@ package com.yahoo.vespa.model.admin.monitoring; import com.yahoo.metrics.ContainerMetrics; +import com.yahoo.metrics.SearchNodeMetrics; import com.google.common.collect.ImmutableSet; @@ -25,7 +26,7 @@ public class DefaultVespaMetrics { ); Set<Metric> defaultContentMetrics = - ImmutableSet.of(new Metric("content.proton.resource_usage.feeding_blocked.last") + ImmutableSet.of(new Metric(SearchNodeMetrics.CONTENT_PROTON_RESOURCE_USAGE_FEEDING_BLOCKED.last()) ); Set<Metric> defaultMetrics = ImmutableSet.<Metric>builder() diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java index c1e3ec851f6..6da4e788de1 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java @@ -7,7 +7,7 @@ import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.container.jdisc.HttpRequest; import com.yahoo.container.jdisc.HttpResponse; import com.yahoo.container.jdisc.ThreadedHttpRequestHandler; -import com.yahoo.io.IOUtils; +import com.yahoo.container.jdisc.secretstore.SecretStore; import com.yahoo.restapi.ErrorResponse; import com.yahoo.restapi.MessageResponse; import com.yahoo.restapi.Path; @@ -22,6 +22,7 @@ import com.yahoo.vespa.hosted.controller.Controller; import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId; import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobId; import com.yahoo.vespa.hosted.controller.auditlog.AuditLoggingRequestHandler; +import com.yahoo.vespa.hosted.controller.config.CoreDumpTokenResealingConfig; import com.yahoo.vespa.hosted.controller.maintenance.ControllerMaintenance; import com.yahoo.vespa.hosted.controller.maintenance.Upgrader; import com.yahoo.vespa.hosted.controller.restapi.ErrorResponses; @@ -29,15 +30,15 @@ import com.yahoo.vespa.hosted.controller.support.access.SupportAccess; import com.yahoo.vespa.hosted.controller.versions.VespaVersion.Confidence; import com.yahoo.yolean.Exceptions; -import java.io.IOException; import java.io.InputStream; -import java.io.UncheckedIOException; import java.security.Principal; import java.security.cert.X509Certificate; import java.time.Instant; import java.util.Optional; import java.util.Scanner; -import java.util.function.Function; + +import static com.yahoo.vespa.hosted.controller.restapi.controller.RequestUtils.requireField; +import static com.yahoo.vespa.hosted.controller.restapi.controller.RequestUtils.toJsonBytes; /** * This implements the controller/v1 API which provides operators with information about, @@ -50,11 +51,19 @@ public class ControllerApiHandler extends AuditLoggingRequestHandler { private final ControllerMaintenance maintenance; private final Controller controller; - - public ControllerApiHandler(ThreadedHttpRequestHandler.Context parentCtx, Controller controller, ControllerMaintenance maintenance) { + private final SecretStore secretStore; + private final CoreDumpTokenResealingConfig tokenResealingConfig; + + public ControllerApiHandler(ThreadedHttpRequestHandler.Context parentCtx, + Controller controller, + ControllerMaintenance maintenance, + SecretStore secretStore, + CoreDumpTokenResealingConfig tokenResealingConfig) { super(parentCtx, controller.auditLogger()); this.controller = controller; this.maintenance = maintenance; + this.secretStore = secretStore; + this.tokenResealingConfig = tokenResealingConfig; } @Override @@ -92,6 +101,7 @@ public class ControllerApiHandler extends AuditLoggingRequestHandler { if (path.matches("/controller/v1/jobs/upgrader/confidence/{version}")) return overrideConfidence(request, path.get("version")); if (path.matches("/controller/v1/access/requests/{user}")) return approveMembership(request, path.get("user")); if (path.matches("/controller/v1/access/grants/{user}")) return grantAccess(request, path.get("user")); + if (path.matches("/controller/v1/access/cores/reseal")) return DecryptionTokenResealer.handleResealRequest(request, tokenResealingConfig.resealingPrivateKeyName(), secretStore); return notFound(path); } @@ -129,12 +139,6 @@ public class ControllerApiHandler extends AuditLoggingRequestHandler { .orElseGet(() -> Text.format("Operator %s granted access and job trigger queued", principal.getName()))); } - private <T> T requireField(Inspector inspector, String field, Function<String, T> mapper) { - return SlimeUtils.optionalString(inspector.field(field)) - .map(mapper::apply) - .orElseThrow(() -> new IllegalArgumentException("Expected field \"" + field + "\" in request")); - } - private HttpResponse delete(HttpRequest request) { Path path = new Path(request.getUri()); if (path.matches("/controller/v1/jobs/upgrader/confidence/{version}")) return removeConfidenceOverride(path.get("version")); @@ -188,14 +192,6 @@ public class ControllerApiHandler extends AuditLoggingRequestHandler { return ""; } - private static byte[] toJsonBytes(InputStream jsonStream) { - try { - return IOUtils.readBytes(jsonStream, 1000 * 1000); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - private static Principal requireUserPrincipal(HttpRequest request) { Principal principal = request.getJDiscRequest().getUserPrincipal(); if (principal == null) throw new RestApiException.InternalServerError("Expected a user principal"); diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/DecryptionTokenResealer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/DecryptionTokenResealer.java new file mode 100644 index 00000000000..758f68d6030 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/DecryptionTokenResealer.java @@ -0,0 +1,68 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.restapi.controller; + +import com.yahoo.container.jdisc.HttpRequest; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.container.jdisc.secretstore.SecretStore; +import com.yahoo.security.KeyId; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.SharedKeyResealingSession; +import com.yahoo.slime.Inspector; +import com.yahoo.slime.SlimeUtils; + +import java.util.Optional; + +import static com.yahoo.vespa.hosted.controller.restapi.controller.RequestUtils.requireField; +import static com.yahoo.vespa.hosted.controller.restapi.controller.RequestUtils.toJsonBytes; + +/** + * @author vekterli + */ +class DecryptionTokenResealer { + + private static int checkKeyNameAndExtractVersion(KeyId tokenKeyId, String expectedKeyName) { + String[] components = tokenKeyId.asString().split("\\."); + if (components.length != 2) { + throw new IllegalArgumentException("Key ID is not of the form 'name.version'"); + } + String keyName = components[0]; + if (!expectedKeyName.equals(keyName)) { + throw new IllegalArgumentException("Token is not generated for the expected key"); + } + try { + return Integer.parseUnsignedInt(components[1]); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Key version is not a valid unsigned integer"); + } + } + + /** + * Extracts a resealing requests from an <strong>already authenticated</strong> HTTP request + * and re-seals it towards the requested public key, using the provided private key name to + * decrypt the token contained in the request. + * + * @param request a request with a JSON payload that contains a resealing request. + * @param privateKeyName The key name used to look up the decryption secret. + * The token must have a matching key name, or the request will be rejected. + * @param secretStore SecretStore instance that holds the private key. The request will fail otherwise. + * @return a response with a JSON payload containing a resealing response (any failure will throw). + */ + static HttpResponse handleResealRequest(HttpRequest request, String privateKeyName, SecretStore secretStore) { + if (privateKeyName.isEmpty()) { + throw new IllegalArgumentException("Private key ID is not set"); + } + byte[] jsonBytes = toJsonBytes(request.getData()); + var inspector = SlimeUtils.jsonToSlime(jsonBytes).get(); + var resealRequest = requireField(inspector, "resealRequest", SharedKeyResealingSession.ResealingRequest::fromSerializedString); + int keyVersion = checkKeyNameAndExtractVersion(resealRequest.sealedKey().keyId(), privateKeyName); + + var b58EncodedPrivateKey = secretStore.getSecret(privateKeyName, keyVersion); + if (b58EncodedPrivateKey == null) { + throw new IllegalArgumentException("Unknown key ID or version"); + } + var privateKey = KeyUtils.fromBase58EncodedX25519PrivateKey(b58EncodedPrivateKey); + var resealResponse = SharedKeyResealingSession.reseal(resealRequest, (keyId) -> Optional.of(privateKey)); + return new ResealedTokenResponse(resealResponse); + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/RequestUtils.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/RequestUtils.java new file mode 100644 index 00000000000..884399f25d9 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/RequestUtils.java @@ -0,0 +1,29 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.restapi.controller; + +import com.yahoo.io.IOUtils; +import com.yahoo.slime.Inspector; +import com.yahoo.slime.SlimeUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.function.Function; + +class RequestUtils { + + static <T> T requireField(Inspector inspector, String field, Function<String, T> mapper) { + return SlimeUtils.optionalString(inspector.field(field)) + .map(mapper::apply) + .orElseThrow(() -> new IllegalArgumentException("Expected field \"" + field + "\" in request")); + } + + static byte[] toJsonBytes(InputStream jsonStream) { + try { + return IOUtils.readBytes(jsonStream, 1000 * 1000); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ResealedTokenResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ResealedTokenResponse.java new file mode 100644 index 00000000000..4714d0e5af1 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ResealedTokenResponse.java @@ -0,0 +1,27 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.restapi.controller; + +import com.yahoo.restapi.SlimeJsonResponse; +import com.yahoo.security.SharedKeyResealingSession; +import com.yahoo.slime.Cursor; +import com.yahoo.slime.Slime; + +/** + * A response that contains a decryption token "resealing response". + * + * @author vekterli + */ +public class ResealedTokenResponse extends SlimeJsonResponse { + + public ResealedTokenResponse(SharedKeyResealingSession.ResealingResponse response) { + super(toSlime(response)); + } + + private static Slime toSlime(SharedKeyResealingSession.ResealingResponse response) { + Slime slime = new Slime(); + Cursor root = slime.setObject(); + root.setString("resealResponse", response.toSerializedString()); + return slime; + } + +} diff --git a/controller-server/src/main/resources/configdefinitions/vespa.hosted.controller.config.core-dump-token-resealing.def b/controller-server/src/main/resources/configdefinitions/vespa.hosted.controller.config.core-dump-token-resealing.def new file mode 100644 index 00000000000..eec6e482cf9 --- /dev/null +++ b/controller-server/src/main/resources/configdefinitions/vespa.hosted.controller.config.core-dump-token-resealing.def @@ -0,0 +1,6 @@ +# Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +namespace=vespa.hosted.controller.config + +# Key name for private key used for re-sealing decryption tokens. +# Using the default of "" means the resealing feature is disabled and no key will be looked up. +resealingPrivateKeyName string default="" diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java index a410221f026..48f9d46fefb 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java @@ -62,6 +62,9 @@ public class ControllerContainerTest { <item key="rotation-id-5">rotation-fqdn-5</item> </rotations> </config> + <config name="vespa.hosted.controller.config.core-dump-token-resealing"> + <resealingPrivateKeyName>a-really-cool-key</resealingPrivateKeyName> + </config> <accesslog type='disabled'/> @@ -76,6 +79,7 @@ public class ControllerContainerTest { <component id='com.yahoo.vespa.hosted.controller.maintenance.ControllerMaintenance'/> <component id='com.yahoo.vespa.hosted.controller.api.integration.stubs.MockMavenRepository'/> <component id='com.yahoo.vespa.hosted.controller.api.integration.stubs.MockUserManagement'/> + <component id='com.yahoo.vespa.hosted.controller.integration.SecretStoreMock'/> <handler id='com.yahoo.vespa.hosted.controller.restapi.deployment.DeploymentApiHandler'> <binding>http://localhost/deployment/v1/*</binding> diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiTest.java index 799189410ea..cb35c85b960 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiTest.java @@ -6,6 +6,12 @@ import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.NodeResources; import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.container.jdisc.HttpRequest; +import com.yahoo.security.KeyId; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.SecretSharedKey; +import com.yahoo.security.SharedKeyGenerator; +import com.yahoo.security.SharedKeyResealingSession; +import com.yahoo.slime.SlimeUtils; import com.yahoo.test.ManualClock; import com.yahoo.vespa.flags.InMemoryFlagSource; import com.yahoo.vespa.flags.PermanentFlags; @@ -15,6 +21,7 @@ import com.yahoo.vespa.hosted.controller.api.integration.configserver.Applicatio import com.yahoo.vespa.hosted.controller.api.integration.resource.ResourceSnapshot; import com.yahoo.vespa.hosted.controller.auditlog.AuditLogger; import com.yahoo.vespa.hosted.controller.integration.NodeRepositoryMock; +import com.yahoo.vespa.hosted.controller.integration.SecretStoreMock; import com.yahoo.vespa.hosted.controller.restapi.ContainerTester; import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerTest; import org.junit.jupiter.api.BeforeEach; @@ -23,11 +30,16 @@ import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; import java.io.File; import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.interfaces.XECPrivateKey; import java.time.Duration; import java.time.Instant; import java.util.List; +import static com.yahoo.security.ArrayUtils.hex; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.fail; /** * @author bratseth @@ -177,4 +189,106 @@ public class ControllerApiTest extends ControllerContainerTest { tester.assertResponse(operatorRequest("http://localhost:8080/controller/v1/access/requests/" + hostedOperator.getName(), requestBody, Request.Method.POST), "{\"members\":[\"user.alice\"]}"); } + + private SharedKeyResealingSession.ResealingResponse extractResealingResponseFromJsonResponse(String json) { + var cursor = SlimeUtils.jsonToSlime(json).get(); + var responseField = cursor.field("resealResponse"); + if (!responseField.valid()) { + fail("No 'resealResponse' field in JSON response"); + } + return SharedKeyResealingSession.ResealingResponse.fromSerializedString(responseField.asString()); + } + + private record ResealingTestData(SharedKeyResealingSession.ResealingRequest resealingRequest, + SharedKeyResealingSession session, + SecretSharedKey originalSecretSharedKey, + KeyPair originalReceiverKeyPair) {} + + private static ResealingTestData createResealingRequestData(String keyIdStr) { + var receiverKeyPair = KeyUtils.generateX25519KeyPair(); + var keyId = KeyId.ofString(keyIdStr); + var sharedKey = SharedKeyGenerator.generateForReceiverPublicKey(receiverKeyPair.getPublic(), keyId); + + var session = SharedKeyResealingSession.newEphemeralSession(); + var resealRequest = session.resealingRequestFor(sharedKey.sealedSharedKey()); + return new ResealingTestData(resealRequest, session, sharedKey, receiverKeyPair); + } + + private static String requestJsonOf(ResealingTestData reqData) { + return "{\"resealRequest\":\"%s\"}".formatted(reqData.resealingRequest.toSerializedString()); + } + + @Test + void decryption_token_reseal_request_succeeds_when_matching_versioned_key_found() { + var reqData = createResealingRequestData("a-really-cool-key.123"); // Must match key name in config + var secret = hex(reqData.originalSecretSharedKey.secretKey().getEncoded()); + + var secretStore = (SecretStoreMock)tester.controller().secretStore(); + secretStore.setSecret("a-really-cool-key", KeyUtils.toBase58EncodedX25519PrivateKey((XECPrivateKey)reqData.originalReceiverKeyPair.getPrivate()), 123); + + tester.assertResponse( + () -> operatorRequest("http://localhost:8080/controller/v1/access/cores/reseal", requestJsonOf(reqData), Request.Method.POST), + (responseJson) -> { + var resealResponse = extractResealingResponseFromJsonResponse(responseJson.getBodyAsString()); + var myShared = reqData.session.openResealingResponse(resealResponse); + assertEquals(secret, hex(reqData.originalSecretSharedKey.secretKey().getEncoded())); + }, + 200); + } + + @Test + void decryption_token_reseal_request_fails_when_unexpected_key_name_is_supplied() { + var reqData = createResealingRequestData("a-really-cool-but-non-existing-key.123"); + tester.assertResponse( + () -> operatorRequest("http://localhost:8080/controller/v1/access/cores/reseal", requestJsonOf(reqData), Request.Method.POST), + "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Token is not generated for the expected key\"}", + 400); + } + + @Test + void secret_key_lookup_does_not_use_key_id_provided_in_user_supplied_token() { + var reqData = createResealingRequestData("a-sneaky-key.123"); + var secretStore = (SecretStoreMock)tester.controller().secretStore(); + // Token key ID is technically valid, but should not be used. Only config should be obeyed. + secretStore.setSecret("a-sneaky-key", KeyUtils.toBase58EncodedX25519PrivateKey((XECPrivateKey)reqData.originalReceiverKeyPair.getPrivate()), 123); + + tester.assertResponse( + () -> operatorRequest("http://localhost:8080/controller/v1/access/cores/reseal", requestJsonOf(reqData), Request.Method.POST), + "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Token is not generated for the expected key\"}", + 400); + } + + @Test + void decryption_token_reseal_request_fails_when_request_payload_is_missing_or_bogus() { + tester.assertResponse( + () -> operatorRequest("http://localhost:8080/controller/v1/access/cores/reseal", "{}", Request.Method.POST), + "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Expected field \\\"resealRequest\\\" in request\"}", + 400); + // TODO this error message is technically an implementation detail... + tester.assertResponse( + () -> operatorRequest("http://localhost:8080/controller/v1/access/cores/reseal", + "{\"resealRequest\":\"five badgers destroying a flowerbed\"}", Request.Method.POST), + "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Input character not part of codec alphabet\"}", + 400); + } + + @Test + void decryption_token_reseal_request_fails_when_key_id_does_not_conform_to_expected_form() { + tester.assertResponse( + () -> operatorRequest("http://localhost:8080/controller/v1/access/cores/reseal", + requestJsonOf(createResealingRequestData("a-really-cool-key")), Request.Method.POST), + "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Key ID is not of the form 'name.version'\"}", + 400); + tester.assertResponse( + () -> operatorRequest("http://localhost:8080/controller/v1/access/cores/reseal", + requestJsonOf(createResealingRequestData("a-really-cool-key.123asdf")), Request.Method.POST), + "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Key version is not a valid unsigned integer\"}", + 400); + tester.assertResponse( + () -> operatorRequest("http://localhost:8080/controller/v1/access/cores/reseal", + requestJsonOf(createResealingRequestData("a-really-cool-key.-123")), Request.Method.POST), + "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Key version is not a valid unsigned integer\"}", + 400); + } + } |