diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/ConnectionConfig.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/ConnectionConfig.java index 176ad85fe4..f6b1640ab0 100644 --- a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/ConnectionConfig.java +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/ConnectionConfig.java @@ -153,6 +153,13 @@ public interface ConnectionConfig extends WithSupervisorConfig, WithActivityChec */ Duration getShutdownTimeout(); + /** + * Returns the configuration for connection fields encryption. + * + * @return the config. + */ + FieldsEncryptionConfig getFieldsEncryptionConfig(); + /** * An enumeration of the known config path expressions and their associated default values for * {@code ConnectionConfig}. diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DefaultConnectionConfig.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DefaultConnectionConfig.java index 3ab9c04583..06b5bb738e 100644 --- a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DefaultConnectionConfig.java +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DefaultConnectionConfig.java @@ -55,6 +55,7 @@ public final class DefaultConnectionConfig implements ConnectionConfig { private final KafkaConfig kafkaConfig; private final HttpPushConfig httpPushConfig; private final ActivityCheckConfig activityCheckConfig; + private final FieldsEncryptionConfig fieldsEncryptionConfig; private final Integer maxNumberOfTargets; private final Integer maxNumberOfSources; private final Duration ackLabelDeclareInterval; @@ -80,6 +81,7 @@ private DefaultConnectionConfig(final ConfigWithFallback config) { kafkaConfig = DefaultKafkaConfig.of(config); httpPushConfig = DefaultHttpPushConfig.of(config); activityCheckConfig = DefaultActivityCheckConfig.of(config); + fieldsEncryptionConfig = DefaultFieldsEncryptionConfig.of(config); maxNumberOfTargets = config.getNonNegativeIntOrThrow(ConnectionConfigValue.MAX_TARGET_NUMBER); maxNumberOfSources = config.getNonNegativeIntOrThrow(ConnectionConfigValue.MAX_SOURCE_NUMBER); ackLabelDeclareInterval = @@ -212,6 +214,11 @@ public ActivityCheckConfig getActivityCheckConfig() { public CleanupConfig getCleanupConfig() { return cleanupConfig; } + @Override + public FieldsEncryptionConfig getFieldsEncryptionConfig(){ + return fieldsEncryptionConfig; + } + @Override public boolean equals(final Object o) { @@ -238,6 +245,7 @@ public boolean equals(final Object o) { Objects.equals(kafkaConfig, that.kafkaConfig) && Objects.equals(httpPushConfig, that.httpPushConfig) && Objects.equals(activityCheckConfig, that.activityCheckConfig) && + Objects.equals(fieldsEncryptionConfig, that.fieldsEncryptionConfig) && Objects.equals(maxNumberOfTargets, that.maxNumberOfTargets) && Objects.equals(maxNumberOfSources, that.maxNumberOfSources) && Objects.equals(ackLabelDeclareInterval, that.ackLabelDeclareInterval) && @@ -250,8 +258,8 @@ public int hashCode() { return Objects.hash(clientActorAskTimeout, clientActorRestartsBeforeEscalation, allowedHostnames, blockedHostnames, blockedSubnets, blockedHostRegex, supervisorConfig, snapshotConfig, acknowledgementConfig, cleanupConfig, maxNumberOfTargets, maxNumberOfSources, activityCheckConfig, - amqp10Config, amqp091Config, mqttConfig, kafkaConfig, httpPushConfig, ackLabelDeclareInterval, - priorityUpdateInterval, shutdownTimeout); + fieldsEncryptionConfig, amqp10Config, amqp091Config, mqttConfig, kafkaConfig, httpPushConfig, + ackLabelDeclareInterval, priorityUpdateInterval, shutdownTimeout); } @Override @@ -273,6 +281,7 @@ public String toString() { ", kafkaConfig=" + kafkaConfig + ", httpPushConfig=" + httpPushConfig + ", activityCheckConfig=" + activityCheckConfig + + ", fieldsEncryptionConfig=" + fieldsEncryptionConfig + ", maxNumberOfTargets=" + maxNumberOfTargets + ", maxNumberOfSources=" + maxNumberOfSources + ", ackLabelDeclareInterval=" + ackLabelDeclareInterval + diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DefaultFieldsEncryptionConfig.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DefaultFieldsEncryptionConfig.java new file mode 100644 index 0000000000..8dfa7039d4 --- /dev/null +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/DefaultFieldsEncryptionConfig.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.ditto.connectivity.service.config; + +import java.util.*; + +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.internal.utils.config.ConfigWithFallback; + +import com.typesafe.config.Config; + +/** + * Default implementation of {@link FieldsEncryptionConfig}. + */ +@Immutable +public class DefaultFieldsEncryptionConfig implements FieldsEncryptionConfig { + + private static final String CONFIG_PATH = "encryption"; + private final boolean enabled; + private final String symmetricalKey; + private final List jsonPointers; + + + private DefaultFieldsEncryptionConfig(final ConfigWithFallback config) { + this.enabled = config.getBoolean(ConfigValue.ENABLED.getConfigPath()); + this.symmetricalKey = config.getString(ConfigValue.SYMMETRICAL_KEY.getConfigPath()); + this.jsonPointers = Collections.unmodifiableList( + new ArrayList<>(config.getStringList(ConfigValue.JSON_POINTERS.getConfigPath()))); + } + + public static DefaultFieldsEncryptionConfig of(final Config config) { + final var fieldEncryptionConfig = + ConfigWithFallback.newInstance(config, CONFIG_PATH, FieldsEncryptionConfig.ConfigValue.values()); + + return new DefaultFieldsEncryptionConfig(fieldEncryptionConfig); + } + + @Override + public boolean isEnabled() { + return this.enabled; + } + + @Override + public String getSymmetricalKey() { + return this.symmetricalKey; + } + + @Override + public Collection getJsonPointers() { + return Collections.unmodifiableList(new ArrayList<>(this.jsonPointers)); + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final DefaultFieldsEncryptionConfig that = (DefaultFieldsEncryptionConfig) o; + return enabled == that.enabled && + Objects.equals(symmetricalKey, that.symmetricalKey) && + Objects.equals(jsonPointers, that.jsonPointers); + } + + @Override + public int hashCode() { + return Objects.hash(enabled, symmetricalKey, jsonPointers); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "[" + + "enabled=" + enabled + + ", symmetricalKey='" + symmetricalKey + '\'' + + ", jsonPointers=" + jsonPointers + + ']'; + } +} diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/FieldsEncryptionConfig.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/FieldsEncryptionConfig.java new file mode 100644 index 0000000000..bc68f93a0e --- /dev/null +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/config/FieldsEncryptionConfig.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.ditto.connectivity.service.config; + +import org.eclipse.ditto.internal.utils.config.KnownConfigValue; + +import java.util.Collection; +import java.util.List; + +/** + * Provides configuration settings for encrypting json field values in Connections. + */ +public interface FieldsEncryptionConfig { + + + /** + * Indicates whether encryption of connection fields should be enabled. + * + * @return {@code true} if connection fields encryption should be enabled. + */ + boolean isEnabled(); + + + /** + * Returns the symmetricalKey used for encryption. + * @return the symmetricalKey + */ + String getSymmetricalKey(); + + + /** + * Returns string json pointers to the values of json fields to be encrypted. + * "uri" has a special handling in which only the password of the uri is encrypted. + * + * @return pointers list + */ + Collection getJsonPointers(); + + + + /** + * An enumeration of the known config path expressions and their associated default values for {@code FieldsEncryptionConfig}. + */ + enum ConfigValue implements KnownConfigValue { + + /** + * Determines whether json value encryption is enabled. + */ + ENABLED("enabled", false), + /** + * The symmetrical key used for encryption. + */ + SYMMETRICAL_KEY("symmetrical-key", ""), + + /** + * The pointer to the json values to be encrypted. + */ + JSON_POINTERS("json-pointers", List.of( + "/uri", + "/credentials/key", + "/sshTunnel/credentials/password", + "/sshTunnel/credentials/privateKey", + "/credentials/parameters/accessKey", + "/credentials/parameters/secretKey", + "/credentials/parameters/sharedKey", + "/credentials/clientSecret")); + + private final String configPath; + private final Object defaultValue; + + ConfigValue(final String theConfigPath, final Object theDefaultValue) { + configPath = theConfigPath; + defaultValue = theDefaultValue; + } + + @Override + public Object getDefaultValue() { + return defaultValue; + } + + @Override + public String getConfigPath() { + return configPath; + } + } +} diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectivityMongoEventAdapter.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectivityMongoEventAdapter.java index d0a5478689..a21129ed7e 100644 --- a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectivityMongoEventAdapter.java +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/ConnectivityMongoEventAdapter.java @@ -12,22 +12,25 @@ */ package org.eclipse.ditto.connectivity.service.messaging.persistence; -import java.util.HashMap; -import java.util.Map; - -import javax.annotation.Nullable; - -import org.eclipse.ditto.connectivity.model.Connection; -import org.eclipse.ditto.internal.utils.persistence.mongo.AbstractMongoEventAdapter; +import akka.actor.ActorSystem; +import akka.actor.ExtendedActorSystem; import org.eclipse.ditto.base.model.signals.JsonParsable; import org.eclipse.ditto.base.model.signals.events.EventJsonDeserializer; import org.eclipse.ditto.base.model.signals.events.EventRegistry; import org.eclipse.ditto.base.model.signals.events.GlobalEventRegistry; +import org.eclipse.ditto.connectivity.model.Connection; import org.eclipse.ditto.connectivity.model.signals.events.ConnectionCreated; import org.eclipse.ditto.connectivity.model.signals.events.ConnectionModified; import org.eclipse.ditto.connectivity.model.signals.events.ConnectivityEvent; +import org.eclipse.ditto.connectivity.service.config.DittoConnectivityConfig; +import org.eclipse.ditto.connectivity.service.config.FieldsEncryptionConfig; +import org.eclipse.ditto.internal.utils.config.DefaultScopedConfig; +import org.eclipse.ditto.internal.utils.persistence.mongo.AbstractMongoEventAdapter; +import org.eclipse.ditto.json.JsonObject; -import akka.actor.ExtendedActorSystem; +import javax.annotation.Nullable; +import java.util.HashMap; +import java.util.Map; /** * EventAdapter for {@link ConnectivityEvent}s persisted into @@ -35,8 +38,28 @@ */ public final class ConnectivityMongoEventAdapter extends AbstractMongoEventAdapter> { + private final FieldsEncryptionConfig encryptionConfig; + public ConnectivityMongoEventAdapter(@Nullable final ExtendedActorSystem system) { super(system, createEventRegistry()); + final DittoConnectivityConfig connectivityConfig = DittoConnectivityConfig.of( + DefaultScopedConfig.dittoScoped(system.settings().config())); + encryptionConfig = connectivityConfig.getConnectionConfig().getFieldsEncryptionConfig(); + } + + @Override + protected JsonObject performToJournalMigration(final JsonObject jsonObject) { + if (encryptionConfig.isEnabled()) { + return JsonFieldsEncryptor.encrypt(jsonObject, encryptionConfig.getJsonPointers(), + encryptionConfig.getSymmetricalKey()); + } + return jsonObject; + } + + @Override + protected JsonObject performFromJournalMigration(final JsonObject jsonObject) { + return JsonFieldsEncryptor.decrypt(jsonObject, encryptionConfig.getJsonPointers(), + encryptionConfig.getSymmetricalKey()); } private static EventRegistry> createEventRegistry() { diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/JsonFieldsEncryptor.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/JsonFieldsEncryptor.java new file mode 100644 index 0000000000..a7d3a54add --- /dev/null +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/messaging/persistence/JsonFieldsEncryptor.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.ditto.connectivity.service.messaging.persistence; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collection; +import java.util.List; +import java.util.function.BiFunction; +import java.util.stream.Collectors; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.connectivity.model.ConnectionUriInvalidException; +import org.eclipse.ditto.connectivity.service.util.EncryptorAesGcm; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handles encryption of fields of a given json object. + * Json values which are considered a connection URI are treated differently. + * Only the password of the user info part if present will be encrypted. + *

+ * For example: + *

+ * "amqps://user:password@amqpbroker.eclipseprojects.io:5671"
+ * "amqps://user:encrypted_NOHOcSMyBvwTQWR4kCTd642m8@hono.eclipseprojects.io:5671" + */ +public class JsonFieldsEncryptor { + + private static final String ENCRYPTED_PREFIX = "encrypted_"; + public static final Logger LOGGER = LoggerFactory.getLogger(JsonFieldsEncryptor.class); + + /** + * Encrypts a json object fields based on a list of pointers with a provided symmetrical key. + * After encryption values are prefixed with {@value ENCRYPTED_PREFIX} prefix. + * + * @param jsonObject the jsonObject whose fields should be encrypted + * @param jsonPointers the pointer to the values to be encrypted + * @param symmetricKey the symmetrical key to be used for the encryption + * @return a new encrypted {@link org.eclipse.ditto.json.JsonObject } + */ + public static JsonObject encrypt(final JsonObject jsonObject, final Collection jsonPointers, + final String symmetricKey) { + return handle(jsonObject, jsonPointers.stream().map(JsonPointer::of).collect(Collectors.toList()), symmetricKey, + JsonFieldsEncryptor::encryptValue); + } + + /** + * Decrypts a json object fields based on a list of pointers with a provided symmetrical key. + * Only fields prefixed with {@value ENCRYPTED_PREFIX} prefix will be decrypted even if configured with a pointer. + * + * @param jsonObject the jsonObject whose fields should be decrypted + * @param jsonPointers the pointer to the values to be decrypted + * @param symmetricKey the symmetrical key to be used for the description + * @return a new decrypted {@link org.eclipse.ditto.json.JsonObject } + */ + public static JsonObject decrypt(final JsonObject jsonObject, final Collection jsonPointers, + final String symmetricKey) { + return handle(jsonObject, jsonPointers.stream().map(JsonPointer::of).collect(Collectors.toList()), symmetricKey, + JsonFieldsEncryptor::decryptValue); + } + + private static JsonObject handle(final JsonObject jsonObject, final List jsonPointers, + final String symmetricKey, final BiFunction encryptionHandler) { + return jsonPointers.stream() + .filter(pointer -> jsonObject.getValue(pointer).isPresent()) + .map(pointer -> createPatch(pointer, jsonObject, encryptionHandler, symmetricKey)) + .filter(patch -> !patch.isEmpty()) + .reduce(jsonObject, (updatedJsonObject, patch) -> JsonFactory.mergeJsonValues(patch, updatedJsonObject) + .asObject()); + } + + + private static JsonObject createPatch(final JsonPointer pointer, final JsonObject jsonObject, + final BiFunction encryptionHandler, final String symmetricKey) { + final JsonValue oldValue = jsonObject.getValue(pointer).get(); // pointers to non-existing values are filtered out + try { + + final String password = getUriPassword(oldValue.asString()); + if (password == null) { + return JsonObject.empty(); + } + final String encryptedPwd = encryptionHandler.apply(password, symmetricKey); + final String encryptedValue = oldValue.asString().replace(password, encryptedPwd); + return JsonFactory.newObject(pointer, JsonValue.of(encryptedValue)); + + } catch (ConnectionUriInvalidException | URISyntaxException e) { + final String encryptedValue = encryptionHandler.apply(oldValue.asString(), symmetricKey); + return JsonFactory.newObject(pointer, JsonValue.of(encryptedValue)); + } catch (RuntimeException re) { + LOGGER.warn("{} of connection value at <{}> failed", re.getMessage(), pointer, re); + return JsonObject.empty(); + } + } + + private static String decryptValue(final String value, final String symmetricKey) { + if (value.startsWith(ENCRYPTED_PREFIX)) { + final String striped = value.replace(ENCRYPTED_PREFIX, ""); + try { + return EncryptorAesGcm.decryptWithPrefixIV(striped, symmetricKey); + } catch (Exception e) { + throw new RuntimeException("Decryption", e); + } + } + return value; + } + + private static String encryptValue(final String value, final String symmetricKey) { + try { + return ENCRYPTED_PREFIX + EncryptorAesGcm.encryptWithPrefixIV(value, symmetricKey); + } catch (Exception e) { + throw new RuntimeException("Encryption", e); + } + } + + @Nullable + private static String getUriPassword(final String value) throws URISyntaxException { + final URI uri = new URI(value); + final String protocol = uri.getScheme(); + if (protocol == null) { + throw ConnectionUriInvalidException.newBuilder(value) + .message("Not a valid connection URI") + .build(); + } + final String userInfo = uri.getUserInfo(); + if (userInfo == null) { + return null; + } + final String[] userPass = userInfo.split(":", 2); + return userPass.length == 2 ? userPass[1] : null; + } +} diff --git a/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/util/EncryptorAesGcm.java b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/util/EncryptorAesGcm.java new file mode 100644 index 0000000000..979c2d0e9c --- /dev/null +++ b/connectivity/service/src/main/java/org/eclipse/ditto/connectivity/service/util/EncryptorAesGcm.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.ditto.connectivity.service.util; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.nio.ByteBuffer; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.concurrent.ThreadLocalRandom; + +import javax.crypto.*; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.eclipse.ditto.internal.utils.config.DittoConfigError; + + +/** + * Encrypts strings with AES GCM Encryption Algorithm + */ +public class EncryptorAesGcm { + + public static final int AES_KEY_SIZE = 256; + public static final int GCM_IV_LENGTH = 12; + public static final int GCM_TAG_LENGTH = 16; + + /** + * Encrypts given string using the AES/GCM/NoPadding transformation using 256bit symmetrical key + * The encrypted result has the Initialisation Vector (IV) used for encryption prefixed. + * + * @param forEncrypt the string to encrypt + * @param key the base64 urlEncoded 256 bits AES secret key used for encryption + * @return the base64 urlEncoded encrypted string + */ + public static String encryptWithPrefixIV(final String forEncrypt, final String key) + throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, + InvalidKeyException, IllegalBlockSizeException, BadPaddingException { + SecretKey secretKey = toSecretKey(key); + final byte[] iv = getRandomNonceIV(); + final byte[] cipherText = encrypt(forEncrypt.getBytes(UTF_8), secretKey, iv); + final byte[] cipherTextWithIv = ByteBuffer.allocate(iv.length + cipherText.length) + .put(iv) + .put(cipherText) + .array(); + return toBase64String(cipherTextWithIv); + } + + /** + * Decrypts given string using the AES/GCM/NoPadding transformation using 256bit symmetrical key + * + * @param forDecrypt the base64 erlEncoded encrypted string + * @param key the base64 urlEncoded 256 bits AES secret key used for decryption + * @return the decrypted string + */ + public static String decryptWithPrefixIV(String forDecrypt, String key) + throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, + InvalidKeyException, IllegalBlockSizeException, BadPaddingException { + SecretKey secretKey = toSecretKey(key); + ByteBuffer bb = ByteBuffer.wrap(fromBase64String(forDecrypt)); + byte[] iv = new byte[GCM_IV_LENGTH]; + bb.get(iv, 0, iv.length); + byte[] cipherText = new byte[bb.remaining()]; + bb.get(cipherText); + return decrypt(cipherText, secretKey, iv); + + } + + + /** + * Generates 256bits AES symmetrical key + * + * @return the key + * @throws NoSuchAlgorithmException if no Provider supports a KeyGeneratorSpi implementation for the AES algorithm + */ + public static SecretKey getAESKey() throws NoSuchAlgorithmException { + return getAESKey(AES_KEY_SIZE); + } + + /** + * Returns a 96-bit (12 bytes) Initialisation Vector (IV) needed by AES-GCM. + * {@link java.util.concurrent.ThreadLocalRandom} is used to fill the IV instead of + * {@link java.security.SecureRandom } as it can be very slow on linux systems and sometimes even block and lock + * in multithreaded cases. + * + * @return returns the 96-bit (12 bytes) IV byte array + */ + static byte[] getRandomNonceIV() { + byte[] nonce = new byte[GCM_IV_LENGTH]; + ThreadLocalRandom.current().nextBytes(nonce); + return nonce; + } + + static SecretKey getAESKey(int size) throws NoSuchAlgorithmException { + KeyGenerator keyGen = KeyGenerator.getInstance("AES"); + keyGen.init(size); + return keyGen.generateKey(); + } + + static byte[] fromBase64String(final String value) { + return Base64.getUrlDecoder().decode(value); + } + + static String toBase64String(final byte[] value) { + return Base64.getUrlEncoder().encodeToString(value); + } + + static SecretKey toSecretKey(String base64Key) { + SecretKey secretKey = new SecretKeySpec(fromBase64String(base64Key), "AES"); + int length = secretKey.getEncoded().length; + if (length != 32) { + throw new DittoConfigError(String.format("%d bits symmetrical key required. Provided is %d bits key.", AES_KEY_SIZE, length * 8)); + } + return secretKey; + } + + private static byte[] encrypt(byte[] plaintext, SecretKey key, byte[] IV) + throws DittoConfigError, NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, + InvalidKeyException, IllegalBlockSizeException, BadPaddingException { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES"); + GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, IV); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmParameterSpec); + + return cipher.doFinal(plaintext); + } + + private static String decrypt(byte[] cipherText, SecretKey key, byte[] IV) + throws DittoConfigError, NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, + InvalidKeyException, IllegalBlockSizeException, BadPaddingException { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES"); + GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, IV); + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmParameterSpec); + byte[] decryptedText = cipher.doFinal(cipherText); + + return new String(decryptedText); + } +} \ No newline at end of file diff --git a/connectivity/service/src/main/resources/connectivity.conf b/connectivity/service/src/main/resources/connectivity.conf index 8402e80011..53e6fcfbd5 100644 --- a/connectivity/service/src/main/resources/connectivity.conf +++ b/connectivity/service/src/main/resources/connectivity.conf @@ -153,6 +153,28 @@ ditto { max-target-number = 4 max-target-number = ${?CONNECTION_TARGET_NUMBER} + encryption { + # Enables encryption on connection fields recorded in the db + enabled = false + enabled = ${?CONNECTION_ENCRYPTION_ENABLED} + + # The 256bit AES symmetrical key used for encription + symmetrical-key = "" + symmetrical-key = ${?CONNECTION_ENCRYPTION_KEY} + + # A comma separated string of Json pointers to the fields to be encrypted + json-pointers = ["/uri", + "/credentials/key", + "/sshTunnel/credentials/password", + "/sshTunnel/credentials/privateKey", + "/credentials/parameters/accessKey", + "/credentials/parameters/secretKey", + "/credentials/parameters/sharedKey", + "/credentials/clientSecret" + ] + json-pointers = ${?CONNECTION_ENCRYPTION_POINTERS} + } + supervisor { exponential-backoff { min = 1s diff --git a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/config/DefaultFieldsEncryptionConfigTest.java b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/config/DefaultFieldsEncryptionConfigTest.java new file mode 100644 index 0000000000..2db45e69dc --- /dev/null +++ b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/config/DefaultFieldsEncryptionConfigTest.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.ditto.connectivity.service.config; + + +import static org.mutabilitydetector.unittesting.MutabilityAssert.assertInstancesOf; +import static org.mutabilitydetector.unittesting.MutabilityMatchers.areImmutable; + +import java.util.List; + +import org.assertj.core.api.JUnitSoftAssertions; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +import nl.jqno.equalsverifier.EqualsVerifier; + +public class DefaultFieldsEncryptionConfigTest { + + private static Config config; + + @Rule + public final JUnitSoftAssertions softly = new JUnitSoftAssertions(); + + @BeforeClass + public static void initTestFixture() { + config = ConfigFactory.load("connection-fields-encryption-test"); + } + + @Test + public void assertImmutability() { + assertInstancesOf(DefaultFieldsEncryptionConfig.class, + areImmutable()); + } + + @Test + public void testHashCodeAndEquals() { + EqualsVerifier.forClass(DefaultFieldsEncryptionConfig.class) + .usingGetClass() + .verify(); + } + @Test + public void underTestReturnsValuesOfConfigFile() { + final FieldsEncryptionConfig underTest = DefaultFieldsEncryptionConfig.of(config.getConfig("connection")); + + softly.assertThat(underTest.isEnabled()).isTrue(); + softly.assertThat(underTest.getJsonPointers()).containsAll(List.of( + "/uri", + "/credentials/key", + "/sshTunnel/credentials/password", + "/sshTunnel/credentials/privateKey", + "/credentials/parameters/accessKey", + "/credentials/parameters/secretKey", + "/credentials/parameters/sharedKey", + "/credentials/clientSecret")); + + softly.assertThat(underTest.getSymmetricalKey()).isEqualTo("dUHYs0YRphpLRnrge5W4ldskE7rI7ntU+x7csfyFWAM="); + + } +} \ No newline at end of file diff --git a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/persistence/JsonFieldsEncryptorTest.java b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/persistence/JsonFieldsEncryptorTest.java new file mode 100644 index 0000000000..afa355fb7a --- /dev/null +++ b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/persistence/JsonFieldsEncryptorTest.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.ditto.connectivity.service.messaging.persistence; + +import static org.junit.Assert.*; + +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Collection; +import java.util.Optional; + +import org.assertj.core.api.JUnitSoftAssertions; +import org.eclipse.ditto.connectivity.service.config.DefaultFieldsEncryptionConfig; +import org.eclipse.ditto.connectivity.service.config.FieldsEncryptionConfig; +import org.eclipse.ditto.connectivity.service.util.EncryptorAesGcm; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonValue; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +public class JsonFieldsEncryptorTest { + + public static String SYMMETRICAL_KEY; + private static FieldsEncryptionConfig testConfig; + + @Rule + public final JUnitSoftAssertions softly = new JUnitSoftAssertions(); + + @BeforeClass + public static void initTestFixture() throws NoSuchAlgorithmException { + Config config = ConfigFactory.load("connection-fields-encryption-test"); + testConfig = DefaultFieldsEncryptionConfig.of(config.getConfig("connection")); + SYMMETRICAL_KEY = Base64.getUrlEncoder().encodeToString(EncryptorAesGcm.getAESKey().getEncoded()); + } + + @Test + public void encryptConnectionFieldsPreservesData() { + Collection jsonPointers = testConfig.getJsonPointers(); + + JsonObject plainConnection = JsonObject.of(connJson); + JsonObject encrypted = JsonFieldsEncryptor.encrypt(plainConnection, jsonPointers, SYMMETRICAL_KEY); + jsonPointers.forEach(pointer -> { + Optional value = encrypted.getValue(pointer); + assertTrue("missing " + pointer + " after encryption", value.isPresent()); + softly.assertThat(value.get()).isNotEqualTo(plainConnection.getValue(pointer).get()); + }); + + JsonObject decryptedConnection = JsonFieldsEncryptor.decrypt(encrypted, jsonPointers, SYMMETRICAL_KEY); + jsonPointers.forEach(pointer -> { + Optional value = decryptedConnection.getValue(pointer); + assertTrue("missing " + pointer + " after encryption", value.isPresent()); + softly.assertThat(value.get()).isEqualTo(plainConnection.getValue(pointer).get()); + }); + + } + + /** + * Connection is not valid as it includes conflicting entries. Only for test purposes + */ + private static final String connJson = """ + { + "__lifecycle": "ACTIVE", + "id": "73d34426-1e67-4a37-82aa-b9064b3b9efa", + "name": "hono-example-sshTunnel-connection-123", + "connectionType": "amqp-10", + "connectionStatus": "closed", + "uri": "amqps://user:passwordValue@hono.eclipseprojects.io:5671", + "sources": [ + { + "addresses": [ + "telemetry/FOO" + ], + "consumerCount": 1, + "authorizationContext": [ + "ditto:inbound-auth-subject" + ], + "enforcement": { + "input": "{{ header:device_id }}", + "filters": [ + "{{ thing:id }}" + ] + }, + "headerMapping": { + "content-type": "{{header:content-type}}", + "reply-to": "{{header:reply-to}}", + "correlation-id": "{{header:correlation-id}}" + }, + "payloadMapping": [ + "Ditto" + ], + "replyTarget": { + "address": "{{header:reply-to}}", + "headerMapping": { + "content-type": "{{header:content-type}}", + "correlation-id": "{{header:correlation-id}}" + }, + "expectedResponseTypes": [ + "response", + "error" + ], + "enabled": true + } + } + ], + "targets": [ + { + "address": "events/twin", + "topics": [ + "_/_/things/twin/events" + ], + "authorizationContext": [ + "ditto:outbound-auth-subject" + ], + "headerMapping": { + "message-id": "{{ header:correlation-id }}", + "content-type": "application/vnd.eclipse.ditto+json" + } + } + ], + "clientCount": 1, + "failoverEnabled": true, + "validateCertificates": true, + "processorPoolSize": 1, + "credentials": { + "key": "someKey", + "clientSecret": "someClientSecret", + "parameters": { + "accessKey": "accessKeyValue", + "secretKey": "secretKeyValue", + "sharedKey": "sharedKeyValue" + } + }, + "sshTunnel": { + "enabled": true, + "credentials": { + "type": "plain", + "username": "usernameValue", + "password": "passwordValue", + "privateKey": "privateKeyValue" + }, + "validateHost": true, + "knownHosts": [ + "MD5:e0:3a:34:1c:68:ed:c6:bc:7c:ca:a8:67:c7:45:2b:19" + ], + "uri": "ssh://ssh-host:2222" + }, + "tags": [] + } + """; +} \ No newline at end of file diff --git a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/util/EncryptorAesGcmTest.java b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/util/EncryptorAesGcmTest.java new file mode 100644 index 0000000000..f0ea5268c1 --- /dev/null +++ b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/util/EncryptorAesGcmTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.ditto.connectivity.service.util; + + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.security.NoSuchAlgorithmException; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.assertj.core.api.JUnitSoftAssertions; +import org.eclipse.ditto.internal.utils.config.DittoConfigError; +import org.junit.Rule; +import org.junit.Test; + + +public class EncryptorAesGcmTest { + + @Rule + public final JUnitSoftAssertions softly = new JUnitSoftAssertions(); + + @Test + public void toBase64PreservesSecretKeySpec() throws Exception { + SecretKey key = EncryptorAesGcm.getAESKey(); + String toBase64Key = EncryptorAesGcm.toBase64String(key.getEncoded()); + SecretKeySpec key2 = new SecretKeySpec(EncryptorAesGcm.fromBase64String(toBase64Key), "AES"); + assertEquals(key, key2); + } + + @Test + public void dataIsPreservedOnEncryptWithPrefixIV() throws Exception { + String input = "This is a plain text which need to be encrypted by Java AES 256 GCM Encryption Algorithm"; + String key = EncryptorAesGcm.toBase64String(EncryptorAesGcm.getAESKey(EncryptorAesGcm.AES_KEY_SIZE).getEncoded()); + String encryptedWithPrefixIV = EncryptorAesGcm.encryptWithPrefixIV(input, key); + String plainText = EncryptorAesGcm.decryptWithPrefixIV(encryptedWithPrefixIV, key); + assertEquals(input, plainText); + } + + @Test + public void keyLengthVerify() throws NoSuchAlgorithmException { + String aesKey = EncryptorAesGcm.toBase64String(EncryptorAesGcm.getAESKey().getEncoded()); + + SecretKey secretKey = EncryptorAesGcm.toSecretKey(aesKey); + assertTrue(secretKey.getEncoded().length == 32); + } + @Test(expected = DittoConfigError.class) + public void keyLengthVerifyThrowsOnIllegalLength() throws NoSuchAlgorithmException { + String aesKey = EncryptorAesGcm.toBase64String(EncryptorAesGcm.getAESKey(128).getEncoded()); + EncryptorAesGcm.toSecretKey(aesKey); + } + +} \ No newline at end of file diff --git a/connectivity/service/src/test/resources/connection-fields-encryption-test.conf b/connectivity/service/src/test/resources/connection-fields-encryption-test.conf new file mode 100644 index 0000000000..8b771ab5cc --- /dev/null +++ b/connectivity/service/src/test/resources/connection-fields-encryption-test.conf @@ -0,0 +1,27 @@ +connection { + encryption { + enabled = true + symmetrical-key = "dUHYs0YRphpLRnrge5W4ldskE7rI7ntU+x7csfyFWAM=" + json-pointers = ["/uri", + "/credentials/key", + "/sshTunnel/credentials/password", + "/sshTunnel/credentials/privateKey", + "/credentials/parameters/accessKey", + "/credentials/parameters/secretKey", + "/credentials/parameters/sharedKey", + "/credentials/clientSecret" + ] + + ## Client-certificate + # "credentials.key" + ## SSH tunneling + # "sshTunnel.credentials.password" + # "sshTunnel.credentials.privateKey" + ## HMAC signing + # "credentials.parameters.accessKey" + # "credentials.parameters.secretKey" + # "credentials.parameters.sharedKey" + ## OAuth2 client credentials flow + # "credentials.clientSecret" + } +}