From 5ffdfd6d0bc77eda829054c9c3de6fba950507de Mon Sep 17 00:00:00 2001 From: Tor Brede Vekterli Date: Mon, 30 Jan 2023 14:41:01 +0100 Subject: Add an "interactive" token resealing protocol and basic tooling support Implements a protocol for delegated access to a shared secret key of a token whose private key we do not possess. This builds directly on top of the existing token resealing mechanisms. The primary benefit of the resealing protocol is that none of the data exchanged can reveal anything about the underlying secret. Security note: neither resealing requests nor responses are explicitly authenticated (this is a property inherited from the sealed shared key tokens themselves). It is assumed that an attacker can observe all requests and responses in transit, but cannot modify them. --- .../java/com/yahoo/security/SealedSharedKey.java | 19 ++- .../yahoo/security/SharedKeyResealingSession.java | 155 +++++++++++++++++++++ .../main/java/com/yahoo/security/hpke/Hpke.java | 8 +- .../java/com/yahoo/security/SharedKeyTest.java | 25 +++- .../vespa/security/tool/crypto/DecryptTool.java | 49 ++++++- .../vespa/security/tool/crypto/ResealTool.java | 55 ++++++-- .../resources/expected-decrypt-help-output.txt | 2 + .../test/resources/expected-reseal-help-output.txt | 2 + 8 files changed, 286 insertions(+), 29 deletions(-) create mode 100644 security-utils/src/main/java/com/yahoo/security/SharedKeyResealingSession.java diff --git a/security-utils/src/main/java/com/yahoo/security/SealedSharedKey.java b/security-utils/src/main/java/com/yahoo/security/SealedSharedKey.java index d570cd799cc..99d07465812 100644 --- a/security-utils/src/main/java/com/yahoo/security/SealedSharedKey.java +++ b/security-utils/src/main/java/com/yahoo/security/SealedSharedKey.java @@ -20,11 +20,14 @@ public record SealedSharedKey(int version, KeyId keyId, byte[] enc, byte[] ciphe public static final int CURRENT_TOKEN_VERSION = 2; /** Encryption context for v{1,2} tokens is always a 32-byte X25519 public key */ public static final int MAX_ENC_CONTEXT_LENGTH = 255; + // Expected max decoded size for v1 is 3 + 255 + 32 + 32 = 322. For simplicity, round this + // up to 512 to effectively not have to care about the overhead of any reasonably chosen encoding. + public static final int MAX_TOKEN_STRING_LENGTH = 512; public SealedSharedKey { if (enc.length > MAX_ENC_CONTEXT_LENGTH) { throw new IllegalArgumentException("Encryption context is too large to be encoded (max is %d, got %d)" - .formatted(MAX_ENC_CONTEXT_LENGTH, enc.length)); + .formatted(MAX_ENC_CONTEXT_LENGTH, enc.length)); } } @@ -33,6 +36,10 @@ public record SealedSharedKey(int version, KeyId keyId, byte[] enc, byte[] ciphe * reconstruct the SealedSharedKey instance when passed verbatim to fromTokenString(). */ public String toTokenString() { + return Base62.codec().encode(toSerializedBytes()); + } + + byte[] toSerializedBytes() { byte[] keyIdBytes = keyId.asBytes(); // u8 token version || u8 length(key id) || key id || u8 length(enc) || enc || ciphertext ByteBuffer encoded = ByteBuffer.allocate(1 + 1 + keyIdBytes.length + 1 + enc.length + ciphertext.length); @@ -46,7 +53,7 @@ public record SealedSharedKey(int version, KeyId keyId, byte[] enc, byte[] ciphe byte[] encBytes = new byte[encoded.remaining()]; encoded.get(encBytes); - return Base62.codec().encode(encBytes); + return encBytes; } /** @@ -56,6 +63,10 @@ public record SealedSharedKey(int version, KeyId keyId, byte[] enc, byte[] ciphe public static SealedSharedKey fromTokenString(String tokenString) { verifyInputTokenStringNotTooLarge(tokenString); byte[] rawTokenBytes = Base62.codec().decode(tokenString); + return fromSerializedBytes(rawTokenBytes); + } + + static SealedSharedKey fromSerializedBytes(byte[] rawTokenBytes) { if (rawTokenBytes.length < 1) { throw new IllegalArgumentException("Decoded token too small to contain a version"); } @@ -81,9 +92,7 @@ public record SealedSharedKey(int version, KeyId keyId, byte[] enc, byte[] ciphe public int tokenVersion() { return version; } private static void verifyInputTokenStringNotTooLarge(String tokenString) { - // Expected max decoded size for v1 is 3 + 255 + 32 + 32 = 322. For simplicity, round this - // up to 512 to effectively not have to care about the overhead of any reasonably chosen encoding. - if (tokenString.length() > 512) { + if (tokenString.length() > MAX_TOKEN_STRING_LENGTH) { throw new IllegalArgumentException("Token string is too long to possibly be a valid token"); } } diff --git a/security-utils/src/main/java/com/yahoo/security/SharedKeyResealingSession.java b/security-utils/src/main/java/com/yahoo/security/SharedKeyResealingSession.java new file mode 100644 index 00000000000..6e79b86d832 --- /dev/null +++ b/security-utils/src/main/java/com/yahoo/security/SharedKeyResealingSession.java @@ -0,0 +1,155 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.security; + +import org.bouncycastle.util.Arrays; + +import java.nio.ByteBuffer; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.interfaces.XECPublicKey; +import java.util.Optional; + +/** + *

Delegated resealing protocol for getting access to a shared secret key of a token + * whose private key we do not possess.

+ * + *

The primary benefit of the interactive resealing protocol is that none of the data + * exchanged can reveal anything about the underlying sealed secret itself.

+ * + *

Note that neither resealing requests nor responses are authenticated (this is a property + * inherited from the sealed shared key tokens themselves). It is assumed that an attacker + * can observe all requests and responses in transit, but cannot modify them.

+ * + *

Protocol details

+ * + *

Decryptor (requester):

+ *
    + *
  1. Create a resealing session instance that maintains an ephemeral X25519 key pair that + * is valid only for the lifetime of the session.
  2. + *
  3. Create a resealing request for a token T. The session emits a Base62-encoded + * binary representation of the tuple <ephemeral public key, T>.
  4. + *
  5. Send the request to the private key holder. The session must be kept alive until the + * response is received, or the ephemeral private key associated with the public key will + * be irrevocably lost.
  6. + *
+ *

Private key holder (re-sealer):

+ *
    + *
  1. Decode Base62-encoded request into tuple <ephemeral public key, T>.
  2. + *
  3. Look up the correct private key from the key ID contained in token T.
  4. + *
  5. Reseal token T for the requested ephemeral public key using the correct private key.
  6. + *
  7. Return resealed token TR to requester.
  8. + *
+ *

Decryptor (requester):

+ *
    + *
  1. Decrypt token TR using ephemeral private key.
  2. + *
  3. Use secret key in token to decrypt the payload protected by original token T.
  4. + *
+ * + * @author vekterli + */ +public class SharedKeyResealingSession { + + private final KeyPair ephemeralKeyPair; + + SharedKeyResealingSession(KeyPair ephemeralKeyPair) { + this.ephemeralKeyPair = ephemeralKeyPair; + } + + public static SharedKeyResealingSession newEphemeralSession() { + return new SharedKeyResealingSession(KeyUtils.generateX25519KeyPair()); + } + + @FunctionalInterface + public interface PrivateKeyProvider { + Optional privateKeyForId(KeyId id); + } + + public record ResealingRequest(XECPublicKey ephemeralPubKey, SealedSharedKey sealedKey) { + + private static final byte[] HEADER_BYTES = new byte[] {'R','S'}; + private static final byte CURRENT_VERSION = 1; + + public String toSerializedString() { + byte[] pubKeyBytes = KeyUtils.toRawX25519PublicKeyBytes(ephemeralPubKey); + byte[] tokenBytes = sealedKey.toSerializedBytes(); + + ByteBuffer encoded = ByteBuffer.allocate(HEADER_BYTES.length + 1 + 1 + pubKeyBytes.length + tokenBytes.length); + encoded.put(HEADER_BYTES); + encoded.put(CURRENT_VERSION); + encoded.put((byte)pubKeyBytes.length); + encoded.put(pubKeyBytes); + encoded.put(tokenBytes); + encoded.flip(); + + byte[] encBytes = new byte[encoded.remaining()]; + encoded.get(encBytes); + return Base62.codec().encode(encBytes); + } + + public static ResealingRequest fromSerializedString(String request) { + verifyInputStringNotTooLarge(request); + byte[] rawBytes = Base62.codec().decode(request); + if (rawBytes.length < HEADER_BYTES.length + 2) { + throw new IllegalArgumentException("Resealing request too short to contain a header and key length"); + } + ByteBuffer decoded = ByteBuffer.wrap(rawBytes); + byte[] header = new byte[2]; + decoded.get(header); + if (!Arrays.areEqual(header, HEADER_BYTES)) { + throw new IllegalArgumentException("No resealing request header found"); + } + byte version = decoded.get(); + if (version != CURRENT_VERSION) { + throw new IllegalArgumentException("Unsupported version in resealing request header"); + } + int pubKeyLen = Byte.toUnsignedInt(decoded.get()); + byte[] pubKeyBytes = new byte[pubKeyLen]; + decoded.get(pubKeyBytes); + + byte[] rawTokenBytes = new byte[decoded.remaining()]; + decoded.get(rawTokenBytes); + + return new ResealingRequest(KeyUtils.fromRawX25519PublicKey(pubKeyBytes), + SealedSharedKey.fromSerializedBytes(rawTokenBytes)); + } + + private static void verifyInputStringNotTooLarge(String tokenString) { + if (tokenString.length() > SealedSharedKey.MAX_TOKEN_STRING_LENGTH + 64) { + throw new IllegalArgumentException("String is too long to possibly be a valid resealing request"); + } + } + + } + + public record ResealingResponse(SealedSharedKey resealedKey) { + + public String toSerializedString() { + return resealedKey.toTokenString(); + } + + public static ResealingResponse fromSerializedString(String response) { + return new ResealingResponse(SealedSharedKey.fromTokenString(response)); + } + + } + + public ResealingRequest resealingRequestFor(SealedSharedKey sealedSharedKey) { + return new ResealingRequest((XECPublicKey) ephemeralKeyPair.getPublic(), sealedSharedKey); + } + + public static ResealingResponse reseal(ResealingRequest request, PrivateKeyProvider privateKeyProvider) { + var privKey = privateKeyProvider.privateKeyForId(request.sealedKey.keyId()).orElseThrow( + () -> new IllegalArgumentException("Could not find a private key for key ID '%s'".formatted(request.sealedKey.keyId()))); + + var secretShared = SharedKeyGenerator.fromSealedKey(request.sealedKey, privKey); + var resealed = SharedKeyGenerator.reseal(secretShared, request.ephemeralPubKey, KeyId.ofString("resealed-token")); // TODO key id + + return new ResealingResponse(resealed.sealedSharedKey()); + } + + + public SecretSharedKey openResealingResponse(ResealingResponse response) { + return SharedKeyGenerator.fromSealedKey(response.resealedKey, ephemeralKeyPair.getPrivate()); + } + +} diff --git a/security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java b/security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java index 133798faa99..51f41ab7da7 100644 --- a/security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java +++ b/security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java @@ -175,7 +175,7 @@ public final class Hpke { } } - private static record ContextBase(byte[] key, byte[] nonce, long seqNum, byte[] exporterSecret) { } + private record ContextBase(byte[] key, byte[] nonce, long seqNum, byte[] exporterSecret) { } /** * Section 5.1 Creating the Encryption Context: @@ -219,8 +219,8 @@ public final class Hpke { return new ContextBase(key, baseNonce, 0, exporterSecret); } - private static record ContextS(byte[] enc, ContextBase base) {} - private static record ContextR(ContextBase base) {} + private record ContextS(byte[] enc, ContextBase base) {} + private record ContextR(ContextBase base) {} /** * Section 5.1.1 Encryption to a Public Key: @@ -253,7 +253,7 @@ public final class Hpke { return new ContextR(keySchedule(MODE_BASE, sharedSecret, info, DEFAULT_PSK, DEFAULT_PSK_ID)); } - public static record Sealed(byte[] enc, byte[] ciphertext) {} + public record Sealed(byte[] enc, byte[] ciphertext) {} /** * Section 6.1 Encryption and Decryption: diff --git a/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java b/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java index 35b52d13b1d..875877aed6a 100644 --- a/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java +++ b/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java @@ -9,7 +9,7 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.Arrays; -import java.util.Base64; +import java.util.Optional; import static com.yahoo.security.ArrayUtils.hex; import static org.junit.jupiter.api.Assertions.assertArrayEquals; @@ -232,6 +232,29 @@ public class SharedKeyTest { assertEquals(terrifyingSecret, decrypted); } + @Test + void shared_key_can_be_resealed_via_interactive_resealing_session() { + var originalReceiverKp = KeyUtils.generateX25519KeyPair(); + var shared = SharedKeyGenerator.generateForReceiverPublicKey(originalReceiverKp.getPublic(), KEY_ID_1); + var secret = hex(shared.secretKey().getEncoded()); + + // Resealing requester side; ask for token to be resealed for ephemeral session public key + var session = SharedKeyResealingSession.newEphemeralSession(); + var wrappedResealRequest = session.resealingRequestFor(shared.sealedSharedKey()); + + // Resealing request handler side; reseal using private key for original token + var unwrappedResealRequest = SharedKeyResealingSession.ResealingRequest.fromSerializedString(wrappedResealRequest.toSerializedString()); + var wrappedResponse = SharedKeyResealingSession.reseal(unwrappedResealRequest, + (keyId) -> Optional.ofNullable(keyId.equals(KEY_ID_1) ? originalReceiverKp.getPrivate() : null)); + + // Back to resealing requester side + var unwrappedResponse = SharedKeyResealingSession.ResealingResponse.fromSerializedString(wrappedResponse.toSerializedString()); + var resealed = session.openResealingResponse(unwrappedResponse); + + var resealedSecret = hex(resealed.secretKey().getEncoded()); + assertEquals(secret, resealedSecret); + } + // javax.crypto.CipherOutputStream swallows exceptions caused by MAC failures in cipher // decryption mode (!) and must therefore _not_ be used for this purpose. This is documented, // but still very surprising behavior. diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/DecryptTool.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/DecryptTool.java index 4fbe89d4b03..4b3608fc3f7 100644 --- a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/DecryptTool.java +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/DecryptTool.java @@ -2,14 +2,18 @@ package com.yahoo.vespa.security.tool.crypto; import com.yahoo.security.SealedSharedKey; +import com.yahoo.security.SecretSharedKey; import com.yahoo.security.SharedKeyGenerator; +import com.yahoo.security.SharedKeyResealingSession; import com.yahoo.vespa.security.tool.CliUtils; import com.yahoo.vespa.security.tool.Tool; import com.yahoo.vespa.security.tool.ToolDescription; import com.yahoo.vespa.security.tool.ToolInvocation; import org.apache.commons.cli.Option; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStreamReader; import java.util.List; import java.util.Optional; @@ -31,6 +35,7 @@ public class DecryptTool implements Tool { static final String EXPECTED_KEY_ID_OPTION = "expected-key-id"; static final String ZSTD_DECOMPRESS_OPTION = "zstd-decompress"; static final String TOKEN_OPTION = "token"; + static final String RESEAL_REQUEST = "reseal-request"; private static final List