diff options
author | Tor Brede Vekterli <vekterli@yahooinc.com> | 2022-10-12 13:39:16 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-10-12 13:39:16 +0200 |
commit | 421bad7ed044ca26b74fa5d48fdac683bbe34996 (patch) | |
tree | d780101747c412f18e82eef7a7038e1911b49c4c | |
parent | 022191c0b98478fc700f095d9cba3d119fb9e9e5 (diff) | |
parent | d795dc29ab9416d533010e172f38bb810ce747ed (diff) |
Merge pull request #24387 from vespa-engine/vekterli/shared-secret-key-exchange-via-ecies
Add utilities for secure one-way single-use key exchange tokens using ECIES
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); + } + +} |