summaryrefslogtreecommitdiffstats
path: root/controller-server/src
diff options
context:
space:
mode:
authorTor Brede Vekterli <vekterli@yahooinc.com>2023-02-09 11:16:36 +0100
committerTor Brede Vekterli <vekterli@yahooinc.com>2023-02-10 14:14:47 +0100
commit7424ae2c638e2d7cdb7885cbf84b98d5c5258006 (patch)
treed26241fa56bf3331696f1e3524fb5c43176ac174 /controller-server/src
parentf62bb48baf715609606faa82a6119012b8a727de (diff)
Add a controller handler for resealing decryption tokens
Handles an _already authenticated and authorized request_, using a config-provided secret private key decrypt the original token and reseal it towards the requested public key. Key IDs are expected to be on the format "name.version" where version is an unsigned integer. The name must exactly match the key name used to look up the secret private key, or the request will be failed.
Diffstat (limited to 'controller-server/src')
-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
7 files changed, 264 insertions, 20 deletions
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);
+ }
+
}