summaryrefslogtreecommitdiffstats
path: root/security-utils
diff options
context:
space:
mode:
authorTor Brede Vekterli <vekterli@yahooinc.com>2022-10-03 11:10:09 +0200
committerTor Brede Vekterli <vekterli@yahooinc.com>2022-10-11 13:18:52 +0200
commit06f5f911249177f94893a5985e0f9db1fcf8b098 (patch)
tree72e7fd360b12caacefd40c77f6907590a134f104 /security-utils
parent2b67b2eedee700de6f19d377382361107918cd4d (diff)
Add utilities for secure one-way single-use key exchange tokens using ECIES
Lets a sender generate a random, single-use symmetric key and securely share this with a receiver, with the sender only knowing the public key of the receiver. The shared key is exchanged via an opaque token that can only be decoded by having the private key corresponding to the public key used for encoding it. This is implemented using ECIES, a hybrid encryption scheme using Elliptic Curve Diffie-Hellman (ECDH) for ephemeral key exchange combined with a symmetric cipher using the ephemeral key for actual plaintext encryption/decryption. In addition to the key exchange itself, utilities for creating encryption and decryption ciphers for AES-GCM-256 from the shared keys are included. **Security note**: since the key is intended to be used for producing a single piece of ciphertext, a fixed Initialization Vector (IV) is used. The key MUST NOT be used to produce more than one ciphertext, lest the entire security model breaks down entirely.
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.java118
-rw-r--r--security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java135
4 files changed, 342 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..0d46af648e0
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/SharedKeyGenerator.java
@@ -0,0 +1,118 @@
+// 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.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
+
+ public static SecretSharedKey generateForReceiverPublicKey(PublicKey receiverPublicKey, int keyId) {
+ try {
+ var keyGen = KeyGenerator.getInstance("AES");
+ keyGen.init(256, new SecureRandom());
+ 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, KeyPair receiverKeyPair) {
+ try {
+ var cipher = Cipher.getInstance(ECIES_CIPHER_NAME, BouncyCastleProviderHolder.getInstance());
+ cipher.init(Cipher.DECRYPT_MODE, receiverKeyPair.getPrivate());
+ 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..c5f539dce58
--- /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);
+
+ 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);
+
+ 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));
+ }
+
+ @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);
+ }
+
+}