summaryrefslogtreecommitdiffstats
path: root/security-utils
diff options
context:
space:
mode:
Diffstat (limited to 'security-utils')
-rw-r--r--security-utils/src/main/java/com/yahoo/security/SealedSharedKey.java65
-rw-r--r--security-utils/src/main/java/com/yahoo/security/SecretSharedKey.java24
-rw-r--r--security-utils/src/main/java/com/yahoo/security/SharedKeyGenerator.java120
-rw-r--r--security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java135
4 files changed, 344 insertions, 0 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
new file mode 100644
index 00000000000..81cf86a535e
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/SealedSharedKey.java
@@ -0,0 +1,65 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security;
+
+import java.nio.ByteBuffer;
+import java.util.Base64;
+
+/**
+ * A SealedSharedKey represents the public part of a secure one-way ephemeral key exchange.
+ *
+ * It is "sealed" in the sense that it is expected to be computationally infeasible
+ * for anyone to derive the correct shared key from the sealed key without holding
+ * the correct private key.
+ *
+ * A SealedSharedKey can be converted to--and from--an opaque string token representation.
+ * This token representation is expected to be used as a convenient serialization
+ * form when communicating shared keys.
+ */
+public record SealedSharedKey(int keyId, byte[] eciesPayload) {
+
+ /** Current encoding version of opaque sealed key tokens. Must be less than 256. */
+ public static final int CURRENT_TOKEN_VERSION = 1;
+
+ /**
+ * Creates an opaque URL-safe string token that contains enough information to losslessly
+ * reconstruct the SealedSharedKey instance when passed verbatim to fromTokenString().
+ */
+ public String toTokenString() {
+ if (keyId >= (1 << 24)) {
+ throw new IllegalArgumentException("Key id is too large to be encoded");
+ }
+
+ ByteBuffer encoded = ByteBuffer.allocate(4 + eciesPayload.length);
+ encoded.putInt((CURRENT_TOKEN_VERSION << 24) | keyId);
+ encoded.put(eciesPayload);
+ encoded.flip();
+
+ byte[] encBytes = new byte[encoded.remaining()];
+ encoded.get(encBytes);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(encBytes);
+ }
+
+ /**
+ * Attempts to unwrap a SealedSharedKey opaque token representation that was previously
+ * created by a call to toTokenString().
+ */
+ public static SealedSharedKey fromTokenString(String tokenString) {
+ byte[] rawTokenBytes = Base64.getUrlDecoder().decode(tokenString);
+ if (rawTokenBytes.length < 4) {
+ throw new IllegalArgumentException("Decoded token too small to contain a header");
+ }
+ ByteBuffer decoded = ByteBuffer.wrap(rawTokenBytes);
+ int versionAndKeyId = decoded.getInt();
+ int version = versionAndKeyId >>> 24;
+ if (version != CURRENT_TOKEN_VERSION) {
+ throw new IllegalArgumentException("Token had unexpected version. Expected %d, was %d"
+ .formatted(CURRENT_TOKEN_VERSION, version));
+ }
+ byte[] eciesPayload = new byte[decoded.remaining()];
+ decoded.get(eciesPayload);
+
+ int keyId = versionAndKeyId & 0xffffff;
+ return new SealedSharedKey(keyId, eciesPayload);
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/SecretSharedKey.java b/security-utils/src/main/java/com/yahoo/security/SecretSharedKey.java
new file mode 100644
index 00000000000..3e90711d57f
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/SecretSharedKey.java
@@ -0,0 +1,24 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security;
+
+import javax.crypto.SecretKey;
+
+/**
+ * A SecretSharedKey represents a pairing of both the secret and public parts of
+ * a secure one-way ephemeral key exchange.
+ *
+ * The underlying SealedSharedKey may be made public, generally as a token.
+ *
+ * It should not come as a surprise that the underlying SecretKey must NOT be
+ * made public.
+ */
+public record SecretSharedKey(SecretKey secretKey, SealedSharedKey sealedSharedKey) {
+
+ // Explicitly override toString to ensure we can't leak any SecretKey contents
+ // via an implicitly generated method. Only print the sealed key (which is entirely public).
+ @Override
+ public String toString() {
+ return "SharedSecretKey(sealed: %s)".formatted(sealedSharedKey.toTokenString());
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/SharedKeyGenerator.java b/security-utils/src/main/java/com/yahoo/security/SharedKeyGenerator.java
new file mode 100644
index 00000000000..440b17eecfa
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/SharedKeyGenerator.java
@@ -0,0 +1,120 @@
+// 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.jcajce.provider.util.BadBlockException;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.KeyGenerator;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.KeyPair;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+
+/**
+ * Implements both the sender and receiver sides of a secure, anonymous one-way
+ * key generation and exchange protocol implemented using ECIES; a hybrid crypto
+ * scheme built around elliptic curves.
+ *
+ * A shared key, once generated, may have its sealed component sent over a public
+ * channel without revealing anything about the underlying secret key. Only a
+ * recipient holding the private key corresponding to the public used for shared
+ * key creation may derive the same secret key as the sender.
+ *
+ * Every generated key is globally unique (with extremely high probability).
+ *
+ * The secret key is intended to be used <em>only once</em>. It MUST NOT be used to
+ * produce more than a single ciphertext. Using the secret key to produce multiple
+ * ciphertexts completely breaks the security model due to using a fixed Initialization
+ * Vector (IV).
+ */
+public class SharedKeyGenerator {
+
+ private static final int AES_GCM_AUTH_TAG_BITS = 128;
+ private static final String AES_GCM_ALGO_SPEC = "AES/GCM/NoPadding";
+ private static final String ECIES_CIPHER_NAME = "ECIES"; // TODO ensure SHA-256+AES. Needs BC version bump
+ private static final SecureRandom SHARED_CSPRNG = new SecureRandom();
+
+ public static SecretSharedKey generateForReceiverPublicKey(PublicKey receiverPublicKey, int keyId) {
+ try {
+ var keyGen = KeyGenerator.getInstance("AES");
+ keyGen.init(256, SHARED_CSPRNG);
+ var secretKey = keyGen.generateKey();
+
+ var cipher = Cipher.getInstance(ECIES_CIPHER_NAME, BouncyCastleProviderHolder.getInstance());
+ cipher.init(Cipher.ENCRYPT_MODE, receiverPublicKey);
+ byte[] eciesPayload = cipher.doFinal(secretKey.getEncoded());
+
+ var sealedSharedKey = new SealedSharedKey(keyId, eciesPayload);
+ return new SecretSharedKey(secretKey, sealedSharedKey);
+ } catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException
+ | IllegalBlockSizeException | BadPaddingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static SecretSharedKey fromSealedKey(SealedSharedKey sealedKey, PrivateKey receiverPrivateKey) {
+ try {
+ var cipher = Cipher.getInstance(ECIES_CIPHER_NAME, BouncyCastleProviderHolder.getInstance());
+ cipher.init(Cipher.DECRYPT_MODE, receiverPrivateKey);
+ byte[] secretKey = cipher.doFinal(sealedKey.eciesPayload());
+
+ return new SecretSharedKey(new SecretKeySpec(secretKey, "AES"), sealedKey);
+ } catch (BadBlockException e) {
+ throw new IllegalArgumentException("Token integrity check failed; token is either corrupt or was " +
+ "generated for a different public key");
+ } catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException
+ | IllegalBlockSizeException | BadPaddingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ // A given key+IV pair can only be used for one single encryption session, ever.
+ // Since our keys are intended to be inherently single-use, we can satisfy that
+ // requirement even with a fixed IV. This avoids the need for explicitly including
+ // the IV with the token, and also avoids tying the encryption to a particular
+ // token recipient (which would be the case if the IV were deterministically derived
+ // from the recipient key and ephemeral ECDH public key), as that would preclude
+ // support for delegated key forwarding.
+ private static byte[] fixed96BitIvForSingleUseKey() {
+ // Nothing up my sleeve!
+ return new byte[] { 'h', 'e', 'r', 'e', 'B', 'd', 'r', 'a', 'g', 'o', 'n', 's' };
+ }
+
+ private static Cipher makeAes256GcmCipher(SecretSharedKey secretSharedKey, int cipherMode) {
+ try {
+ var cipher = Cipher.getInstance(AES_GCM_ALGO_SPEC);
+ var gcmSpec = new GCMParameterSpec(AES_GCM_AUTH_TAG_BITS, fixed96BitIvForSingleUseKey());
+ cipher.init(cipherMode, secretSharedKey.secretKey(), gcmSpec);
+ return cipher;
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException
+ | InvalidKeyException | InvalidAlgorithmParameterException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Creates an AES-GCM-256 Cipher that can be used to encrypt arbitrary plaintext.
+ *
+ * The given secret key MUST NOT be used to encrypt more than one plaintext.
+ */
+ public static Cipher makeAes256GcmEncryptionCipher(SecretSharedKey secretSharedKey) {
+ return makeAes256GcmCipher(secretSharedKey, Cipher.ENCRYPT_MODE);
+ }
+
+ /**
+ * Creates an AES-GCM-256 Cipher that can be used to decrypt ciphertext that was previously
+ * encrypted with the given secret key.
+ */
+ public static Cipher makeAes256GcmDecryptionCipher(SecretSharedKey secretSharedKey) {
+ return makeAes256GcmCipher(secretSharedKey, Cipher.DECRYPT_MODE);
+ }
+
+}
diff --git a/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java b/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java
new file mode 100644
index 00000000000..348f214aa85
--- /dev/null
+++ b/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java
@@ -0,0 +1,135 @@
+// 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.encoders.Hex;
+import org.junit.jupiter.api.Test;
+
+import javax.crypto.CipherInputStream;
+import javax.crypto.CipherOutputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class SharedKeyTest {
+
+ @Test
+ void generated_secret_key_is_256_bit_aes() {
+ var receiverKeyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC);
+ var shared = SharedKeyGenerator.generateForReceiverPublicKey(receiverKeyPair.getPublic(), 1);
+ var secret = shared.secretKey();
+ assertEquals(secret.getAlgorithm(), "AES");
+ assertEquals(secret.getEncoded().length, 32);
+ }
+
+ @Test
+ void sealed_shared_key_can_be_exchanged_via_token_and_computes_identical_secret_key_at_receiver() {
+ var receiverKeyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC);
+
+ var myShared = SharedKeyGenerator.generateForReceiverPublicKey(receiverKeyPair.getPublic(), 1);
+ var publicToken = myShared.sealedSharedKey().toTokenString();
+
+ var theirSealed = SealedSharedKey.fromTokenString(publicToken);
+ var theirShared = SharedKeyGenerator.fromSealedKey(theirSealed, receiverKeyPair.getPrivate());
+
+ assertArrayEquals(myShared.secretKey().getEncoded(), theirShared.secretKey().getEncoded());
+ }
+
+ @Test
+ void token_v1_representation_is_stable() {
+ var receiverPrivate = KeyUtils.fromPemEncodedPrivateKey(
+ """
+ -----BEGIN EC PRIVATE KEY-----
+ MHcCAQEEIO+CkAccoU9jPjX64mwU54Ar9DNZSLBBTYRSINerSW8EoAoGCCqGSM49
+ AwEHoUQDQgAE3FA2VSuOn0vVhtQgNe13H2UE0Vx5A41demyX8nkHTCO4BDXSEPca
+ vejY7YaVcNSvFUbzDvia51X4pxbr1pe56g==
+ -----END EC PRIVATE KEY-----
+ """);
+ var receiverPublic = KeyUtils.fromPemEncodedPublicKey(
+ """
+ -----BEGIN PUBLIC KEY-----
+ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3FA2VSuOn0vVhtQgNe13H2UE0Vx5
+ A41demyX8nkHTCO4BDXSEPcavejY7YaVcNSvFUbzDvia51X4pxbr1pe56g==
+ -----END PUBLIC KEY-----
+ """
+ );
+ var receiverKeyPair = new KeyPair(receiverPublic, receiverPrivate);
+
+ // Token generated for the above receiver public key, with the below expected shared secret (in hex)
+ var publicToken = "AQAAAQTAVVthmrVAbMgKt8hBc4xColmDmEeEAyPD-ZcPlRmeId9wBaZTTctwV3pwT3FyV0UtvX_7zrRId" +
+ "3mNxvaru0tvFucd7mYY73Hi9d3j8qS6pN0bTTb1sw_dKYrR_0BXhEFE_py8uZnNxvV8-wHtBIAVXBk_4Q";
+ var expectedSharedSecret = "b1aded9dc19593baa08fe64f916dcbaf3328ec666d2e0c81b1f6f8af9794187b";
+
+ var theirSealed = SealedSharedKey.fromTokenString(publicToken);
+ var theirShared = SharedKeyGenerator.fromSealedKey(theirSealed, receiverKeyPair.getPrivate());
+
+ assertEquals(expectedSharedSecret, Hex.toHexString(theirShared.secretKey().getEncoded()));
+ }
+
+ @Test
+ void unrelated_private_key_cannot_decrypt_shared_secret_key() {
+ var aliceKeyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC);
+ var eveKeyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC);
+ var bobShared = SharedKeyGenerator.generateForReceiverPublicKey(aliceKeyPair.getPublic(), 1);
+ assertThrows(IllegalArgumentException.class, // TODO consider distinct exception class
+ () -> SharedKeyGenerator.fromSealedKey(bobShared.sealedSharedKey(), eveKeyPair.getPrivate()));
+ }
+
+ @Test
+ void token_carries_key_id_as_metadata() {
+ int keyId = 12345;
+ var keyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC);
+ var myShared = SharedKeyGenerator.generateForReceiverPublicKey(keyPair.getPublic(), keyId);
+ var publicToken = myShared.sealedSharedKey().toTokenString();
+ var theirShared = SealedSharedKey.fromTokenString(publicToken);
+ assertEquals(theirShared.keyId(), keyId);
+ }
+
+ static byte[] streamEncryptString(String data, SecretSharedKey secretSharedKey) throws IOException {
+ var cipher = SharedKeyGenerator.makeAes256GcmEncryptionCipher(secretSharedKey);
+ var outStream = new ByteArrayOutputStream();
+ try (var cipherStream = new CipherOutputStream(outStream, cipher)) {
+ cipherStream.write(data.getBytes(StandardCharsets.UTF_8));
+ cipherStream.flush();
+ }
+ return outStream.toByteArray();
+ }
+
+ static String streamDecryptString(byte[] encrypted, SecretSharedKey secretSharedKey) throws IOException {
+ var cipher = SharedKeyGenerator.makeAes256GcmDecryptionCipher(secretSharedKey);
+ var inStream = new ByteArrayInputStream(encrypted);
+ var total = ByteBuffer.allocate(encrypted.length); // Assume decrypted form can't be _longer_
+ byte[] tmp = new byte[8]; // short buf to test chunking
+ try (var cipherStream = new CipherInputStream(inStream, cipher)) {
+ while (true) {
+ int read = cipherStream.read(tmp);
+ if (read == -1) {
+ break;
+ }
+ total.put(tmp, 0, read);
+ }
+ }
+ total.flip();
+ byte[] strBytes = new byte[total.remaining()];
+ total.get(strBytes);
+ return new String(strBytes, StandardCharsets.UTF_8);
+ }
+
+ @Test
+ void can_create_symmetric_ciphers_from_shared_secret_key_and_public_keys() throws Exception {
+ var receiverKeyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC);
+ var myShared = SharedKeyGenerator.generateForReceiverPublicKey(receiverKeyPair.getPublic(), 1);
+
+ String terrifyingSecret = "birds are not real D:";
+ byte[] encrypted = streamEncryptString(terrifyingSecret, myShared);
+ String decrypted = streamDecryptString(encrypted, myShared);
+ assertEquals(terrifyingSecret, decrypted);
+ }
+
+}