aboutsummaryrefslogtreecommitdiffstats
path: root/security-utils
diff options
context:
space:
mode:
authorTor Brede Vekterli <vekterli@yahooinc.com>2023-01-30 14:41:01 +0100
committerTor Brede Vekterli <vekterli@yahooinc.com>2023-01-31 17:20:36 +0100
commit5ffdfd6d0bc77eda829054c9c3de6fba950507de (patch)
treeddbb173a6300fce2a7c3cf1ee70878d252f4a947 /security-utils
parent3e54969fc961ee51c93404a37d559ab7ea2f9fe6 (diff)
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.
Diffstat (limited to 'security-utils')
-rw-r--r--security-utils/src/main/java/com/yahoo/security/SealedSharedKey.java19
-rw-r--r--security-utils/src/main/java/com/yahoo/security/SharedKeyResealingSession.java155
-rw-r--r--security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java8
-rw-r--r--security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java25
4 files changed, 197 insertions, 10 deletions
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;
+
+/**
+ * <p>Delegated resealing protocol for getting access to a shared secret key of a token
+ * whose private key we do not possess.</p>
+ *
+ * <p>The primary benefit of the interactive resealing protocol is that none of the data
+ * exchanged can reveal anything about the underlying sealed secret itself.</p>
+ *
+ * <p>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 <em>observe</em> all requests and responses in transit, but cannot modify them.</p>
+ *
+ * <h2>Protocol details</h2>
+ *
+ * <p>Decryptor (requester):</p>
+ * <ol>
+ * <li>Create a resealing session instance that maintains an ephemeral X25519 key pair that
+ * is valid only for the lifetime of the session.</li>
+ * <li>Create a resealing request for a token <em>T</em>. The session emits a Base62-encoded
+ * binary representation of the tuple <em>&lt;ephemeral public key, T&gt;</em>.</li>
+ * <li>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.</li>
+ * </ol>
+ * <p>Private key holder (re-sealer):</p>
+ * <ol>
+ * <li>Decode Base62-encoded request into tuple <em>&lt;ephemeral public key, T&gt;</em>.</li>
+ * <li>Look up the correct private key from the key ID contained in token <em>T</em>.</li>
+ * <li>Reseal token <em>T</em> for the requested ephemeral public key using the correct private key.</li>
+ * <li>Return resealed token <em>T<sub>R</sub></em> to requester.</li>
+ * </ol>
+ * <p>Decryptor (requester):</p>
+ * <ol>
+ * <li>Decrypt token <em>T<sub>R</sub></em> using ephemeral private key.</li>
+ * <li>Use secret key in token to decrypt the payload protected by original token <em>T</em>.</li>
+ * </ol>
+ *
+ * @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<PrivateKey> 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.