summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--config-model/src/main/java/com/yahoo/vespa/model/admin/monitoring/DefaultVespaMetrics.java3
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java36
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/DecryptionTokenResealer.java68
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/RequestUtils.java29
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ResealedTokenResponse.java27
-rw-r--r--controller-server/src/main/resources/configdefinitions/vespa.hosted.controller.config.core-dump-token-resealing.def6
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java4
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiTest.java114
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);
+ }
+
}